Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into mhkim-node

This commit is contained in:
kmh
2026-04-22 14:55:32 +09:00
111 changed files with 7067 additions and 1075 deletions

View File

@@ -2142,6 +2142,35 @@ export const getDepartmentList = async (
}
};
/**
* GET /api/admin/users/name-map
* 사용자 ID → 이름 매핑만 반환하는 경량 엔드포인트
* 목적: 이력(writer/created_by 등)에 찍힌 user_id를 이름으로 표시하기 위함
* 보안: 민감 정보(전화번호/이메일 등) 미포함, 인증된 사용자면 누구나 조회
* 회사 필터 없음 — 최고 관리자 계정(company_code='*')도 포함
*/
export const getUserNameMap = async (req: AuthenticatedRequest, res: Response) => {
try {
const rows = await query(
`SELECT user_id, user_name FROM user_info WHERE user_id IS NOT NULL`,
[]
);
res.status(200).json({
success: true,
data: rows.map((r: any) => ({
user_id: r.user_id,
user_name: r.user_name,
})),
});
} catch (error) {
logger.error("사용자 이름 맵 조회 실패", { error });
res.status(500).json({
success: false,
message: "사용자 이름 맵 조회 중 오류가 발생했습니다.",
});
}
};
/**
* GET /api/admin/users/:userId
* 사용자 상세 조회 API

View File

@@ -56,47 +56,75 @@ export async function getProductionReportData(req: any, res: Response): Promise<
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "wi", idx);
const cf = buildCompanyFilter(companyCode, "wop", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
const dateExpr = "COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text)";
const df = buildDateFilter(startDate, endDate, dateExpr, idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
// 실제 공정별 생산 데이터는 work_order_process에 있음
// (work_instruction.routing은 routing_version_id UUID일 뿐이라 공정명이 아님)
const dataQuery = `
SELECT
COALESCE(wi.start_date, wi.created_date::date::text) as date,
COALESCE(NULLIF(rv.version_name, ''), '미지정') as process,
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
COALESCE(wi.worker, '미지정') as worker,
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
COALESCE(pr.production_qty, 0) as "prodQty",
COALESCE(pr.defect_qty, 0) as "defectQty",
0 as "runTime",
0 as "downTime",
wi.status,
wi.company_code
FROM work_instruction wi
LEFT JOIN item_routing_version rv
ON wi.routing = rv.id AND wi.company_code = rv.company_code
LEFT JOIN (
SELECT wo_id, company_code,
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
FROM production_record GROUP BY wo_id, company_code
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
LEFT JOIN (
SELECT DISTINCT ON (equipment_code, company_code)
equipment_code, equipment_name, equipment_type, company_code
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
COALESCE(NULLIF(wop.started_at, ''), wop.created_date::date::text) as date,
COALESCE(NULLIF(wop.process_name, ''), NULLIF(wop.process_code, ''), '미지정') as process,
COALESCE(NULLIF(em.equipment_name, ''), NULLIF(em.equipment_code, ''), '미지정') as equipment,
COALESCE(NULLIF(ii.item_name, ''), NULLIF(ii.item_number, ''), '미지정') as item,
COALESCE(NULLIF(wi.worker, ''), '미지정') as worker,
CAST(COALESCE(NULLIF(wop.plan_qty, ''), '0') AS numeric) as "planQty",
CAST(COALESCE(NULLIF(wop.good_qty, ''), '0') AS numeric) as "prodQty",
CAST(COALESCE(NULLIF(wop.defect_qty, ''), '0') AS numeric) as "defectQty",
CASE
WHEN NULLIF(wop.started_at, '') IS NOT NULL
AND NULLIF(wop.completed_at, '') IS NOT NULL
THEN GREATEST(
EXTRACT(EPOCH FROM (wop.completed_at::timestamp - wop.started_at::timestamp)) / 3600.0,
0
)
ELSE 0
END as "runTime",
CAST(COALESCE(NULLIF(wop.total_paused_time, ''), '0') AS numeric) / 3600.0 as "downTime",
wop.status,
wop.company_code
FROM work_order_process wop
LEFT JOIN work_instruction wi
ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
LEFT JOIN LATERAL (
SELECT equipment_code, equipment_name
FROM equipment_mng
WHERE company_code = wi.company_code
AND (id = wi.equipment_id OR equipment_code = wi.equipment_id
OR id = wop.equipment_code OR equipment_code = wop.equipment_code)
ORDER BY (id = wi.equipment_id OR id = wop.equipment_code) DESC, created_date DESC
LIMIT 1
) em ON true
LEFT JOIN LATERAL (
SELECT ii_inner.item_number, ii_inner.item_name
FROM item_info ii_inner
WHERE ii_inner.company_code = wi.company_code
AND (
(NULLIF(wi.item_id, '') IS NOT NULL
AND (ii_inner.id = wi.item_id OR ii_inner.item_number = wi.item_id))
OR ii_inner.item_number = (
SELECT wid.item_number
FROM work_instruction_detail wid
WHERE wid.work_instruction_id = wi.id
AND wid.company_code = wi.company_code
AND NULLIF(wid.item_number, '') IS NOT NULL
ORDER BY wid.created_date ASC
LIMIT 1
)
)
ORDER BY
CASE WHEN ii_inner.id = wi.item_id THEN 1
WHEN ii_inner.item_number = wi.item_id THEN 2
ELSE 3 END,
ii_inner.created_date DESC
LIMIT 1
) ii ON true
${whereClause}
ORDER BY date DESC NULLS LAST
`;

View File

@@ -174,6 +174,7 @@ export async function getMaterialStatus(
ii.item_name AS material_name,
ii.item_number AS material_code,
ii.unit AS material_unit,
ii.inventory_unit AS material_inventory_unit,
COALESCE(ii.width::text, '') AS material_width,
COALESCE(ii.height::text, '') AS material_height,
COALESCE(ii.thickness::text, '') AS material_thickness
@@ -220,7 +221,11 @@ export async function getMaterialStatus(
materialCode:
bomRow.material_code || bomRow.child_item_id,
materialName: bomRow.material_name || "알 수 없음",
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
unit:
bomRow.material_inventory_unit ||
bomRow.bom_unit ||
bomRow.material_unit ||
"EA",
requiredQty,
width: bomRow.material_width || "",
height: bomRow.material_height || "",
@@ -260,12 +265,16 @@ export async function getMaterialStatus(
}
const stockQuery = `
SELECT
SELECT
s.item_code,
s.warehouse_code,
w.warehouse_name,
s.location_code,
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
FROM inventory_stock s
LEFT JOIN warehouse_info w
ON w.warehouse_code = s.warehouse_code
AND w.company_code = s.company_code
WHERE ${stockConditions.join(" AND ")}
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
ORDER BY s.item_code, s.warehouse_code, s.location_code
@@ -277,7 +286,7 @@ export async function getMaterialStatus(
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
const stockByItem: Record<
string,
{ location: string; warehouse: string; qty: number }[]
{ location: string; warehouse: string; warehouse_name: string; qty: number }[]
> = {};
for (const stockRow of stockResult.rows) {
@@ -288,6 +297,7 @@ export async function getMaterialStatus(
stockByItem[code].push({
location: stockRow.location_code || "",
warehouse: stockRow.warehouse_code || "",
warehouse_name: stockRow.warehouse_name || "",
qty: Number(stockRow.current_qty),
});
}

View File

@@ -0,0 +1,473 @@
/**
* 외주출고 컨트롤러
*
* 이전 공정이 완료되고 다음 공정이 외주 공정이면
* 자동으로 외주출고 대상 목록에 표시 → 출고 처리
*
* 출고 데이터는 기존 outbound_mng 테이블 재사용
* (outbound_type='외주출고', source_type='work_order_process')
*/
import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../types/auth";
import { adjustInventory } from "../utils/inventoryUtils";
import { logger } from "../utils/logger";
/**
* 외주출고 대상 자동 조회
* GET /api/outsourcing-outbound/candidates
*
* 이전 공정 완료 + 다음 공정이 외주 공정인 건 자동 표시
*/
export async function getCandidates(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const pool = getPool();
let keywordCondition = "";
const params: any[] = [];
let paramIdx = 1;
if (companyCode !== "*") {
params.push(companyCode);
paramIdx++;
}
if (keyword) {
keywordCondition = `AND (
wi.instruction_no ILIKE $${paramIdx}
OR wi.item_name ILIKE $${paramIdx}
OR wi.item_code ILIKE $${paramIdx}
OR sm.subcontractor_name ILIKE $${paramIdx}
)`;
params.push(`%${keyword}%`);
paramIdx++;
}
const companyFilter = companyCode !== "*"
? `wop_done.company_code = $1`
: `1=1`;
const query = `
SELECT
wop_done.id AS completed_process_id,
wop_done.wo_id,
wop_done.seq_no AS completed_seq_no,
wop_done.process_code AS completed_process_code,
COALESCE(pm_done.process_name, wop_done.process_name, wop_done.process_code) AS completed_process_name,
COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) AS good_qty,
wop_next.id AS next_process_id,
wop_next.seq_no AS next_seq_no,
wop_next.process_code AS next_process_code,
COALESCE(pm_next.process_name, wop_next.process_name, wop_next.process_code) AS next_process_name,
wop_next.status AS next_status,
wi.instruction_no,
wi.item_code,
wi.item_name,
ii.size AS spec,
ii.material,
ii.inventory_unit AS unit,
sm.id AS subcontractor_id,
sm.subcontractor_code,
sm.subcontractor_name
FROM work_order_process wop_done
INNER JOIN work_instruction wi
ON wop_done.wo_id = wi.id
AND wop_done.company_code = wi.company_code
-- 다음 공정 (바로 다음 seq_no)
INNER JOIN LATERAL (
SELECT wop2.*
FROM work_order_process wop2
WHERE wop2.wo_id = wop_done.wo_id
AND wop2.company_code = wop_done.company_code
AND wop2.parent_process_id IS NULL
AND CAST(wop2.seq_no AS int) > CAST(wop_done.seq_no AS int)
ORDER BY CAST(wop2.seq_no AS int)
LIMIT 1
) wop_next ON TRUE
-- 다음 공정이 외주인지 확인
INNER JOIN item_routing_subcontractor irs
ON irs.routing_detail_id = wop_next.routing_detail_id
INNER JOIN subcontractor_mng sm
ON irs.subcontractor_id = sm.id
LEFT JOIN item_info ii
ON wi.item_code = ii.item_number AND wi.company_code = ii.company_code
LEFT JOIN process_mng pm_done
ON wop_done.process_code = pm_done.process_code AND wop_done.company_code = pm_done.company_code
LEFT JOIN process_mng pm_next
ON wop_next.process_code = pm_next.process_code AND wop_next.company_code = pm_next.company_code
WHERE ${companyFilter}
AND wop_done.parent_process_id IS NULL
AND wop_done.status IN ('completed', 'acceptable')
AND COALESCE(CAST(NULLIF(wop_done.good_qty, '') AS numeric), 0) > 0
-- 아직 외주출고 등록 안 된 건만
AND NOT EXISTS (
SELECT 1 FROM outbound_mng om
WHERE om.outbound_type = '외주출고'
AND om.source_type = 'work_order_process'
AND om.source_id = wop_done.id
${companyCode !== "*" ? "AND om.company_code = $1" : ""}
)
${keywordCondition}
ORDER BY wi.instruction_no, CAST(wop_done.seq_no AS int)
`;
const result = await pool.query(query, params);
logger.info("외주출고 대상 조회", { companyCode, count: result.rowCount });
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("외주출고 대상 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 외주출고 목록 조회
* GET /api/outsourcing-outbound/list
*/
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { outbound_status, search_keyword, date_from, date_to } = req.query;
const conditions: string[] = ["om.outbound_type = '외주출고'"];
const params: any[] = [];
let paramIdx = 1;
if (companyCode !== "*") {
conditions.push(`om.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (outbound_status && outbound_status !== "all") {
conditions.push(`om.outbound_status = $${paramIdx}`);
params.push(outbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(`(
om.outbound_number ILIKE $${paramIdx}
OR om.item_name ILIKE $${paramIdx}
OR om.item_code ILIKE $${paramIdx}
OR om.customer_name ILIKE $${paramIdx}
OR om.reference_number ILIKE $${paramIdx}
)`);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`om.outbound_date >= $${paramIdx}`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`om.outbound_date <= $${paramIdx}`);
params.push(date_to);
paramIdx++;
}
const whereClause = `WHERE ${conditions.join(" AND ")}`;
const pool = getPool();
const result = await pool.query(
`SELECT om.*, wh.warehouse_name
FROM outbound_mng om
LEFT JOIN warehouse_info wh ON om.warehouse_code = wh.warehouse_code AND om.company_code = wh.company_code
${whereClause}
ORDER BY om.created_date DESC`,
params,
);
logger.info("외주출고 목록 조회", { companyCode, count: result.rowCount });
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("외주출고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 외주출고 등록
* POST /api/outsourcing-outbound
*/
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
items,
outbound_number,
outbound_date,
warehouse_code,
location_code,
manager_id,
memo,
} = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "출고 품목이 없습니다." });
}
await client.query("BEGIN");
const insertedRows: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO outbound_mng (
id, company_code, outbound_number, outbound_type, outbound_date,
reference_number, customer_code, customer_name,
item_code, item_name, specification, material, unit,
outbound_qty, unit_price, total_amount,
warehouse_code, location_code,
outbound_status, manager_id, memo,
source_type, source_id,
created_date, created_by, writer, status
) VALUES (
gen_random_uuid()::text, $1, $2, '외주출고', $3,
$4, $5, $6,
$7, $8, $9, $10, $11,
$12, 0, 0,
$13, $14,
'출고완료', $15, $16,
'work_order_process', $17,
NOW(), $18, $18, '출고'
) RETURNING *`,
[
companyCode,
outbound_number || item.outbound_number,
outbound_date || item.outbound_date,
item.reference_number || null, // 작업지시번호
item.subcontractor_code || null, // 외주사코드 → customer_code
item.subcontractor_name || null, // 외주사명 → customer_name
item.item_code || null,
item.item_name || null,
item.spec || null,
item.material || null,
item.unit || null,
item.outbound_qty || 0,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
manager_id || item.manager_id || null,
memo || item.memo || null,
item.completed_process_id || null, // source_id = 완료된 공정 ID
userId,
],
);
insertedRows.push(result.rows[0]);
// 재고 차감
const itemCode = item.item_code || null;
const whCode = warehouse_code || item.warehouse_code || null;
const locCode = location_code || item.location_code || null;
const outQty = Number(item.outbound_qty) || 0;
if (itemCode && outQty > 0 && whCode) {
await adjustInventory(client, {
companyCode,
userId,
itemCode,
whCode,
locCode,
delta: -outQty,
transactionType: "외주출고",
remark: `외주출고 (${outbound_number || ""}) → ${item.subcontractor_name || ""}`,
});
}
}
await client.query("COMMIT");
logger.info("외주출고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
outbound_number,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 외주출고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("외주출고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
/**
* 외주출고 수정
* PUT /api/outsourcing-outbound/:id
*/
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const { outbound_date, outbound_qty, warehouse_code, location_code, memo } = req.body;
const pool = getPool();
const companyCondition = companyCode === "*" ? "" : `AND company_code = '${companyCode}'`;
const result = await pool.query(
`UPDATE outbound_mng SET
outbound_date = COALESCE($1::date, outbound_date),
outbound_qty = COALESCE($2::numeric, outbound_qty),
warehouse_code = COALESCE($3, warehouse_code),
location_code = COALESCE($4, location_code),
memo = COALESCE($5, memo),
updated_date = NOW(),
updated_by = $6
WHERE id = $7 ${companyCondition}
RETURNING *`,
[outbound_date, outbound_qty, warehouse_code, location_code, memo, userId, id],
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("외주출고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 외주출고 삭제
* DELETE /api/outsourcing-outbound/:id
*/
export async function deleteOutbound(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
await client.query("BEGIN");
// 삭제 전 데이터 조회 (재고 복구용)
const companyCondition = companyCode === "*" ? "" : `AND company_code = $2`;
const queryParams = companyCode === "*" ? [id] : [id, companyCode];
const oldRes = await client.query(
`SELECT * FROM outbound_mng WHERE id = $1 ${companyCondition}`,
queryParams,
);
if (oldRes.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
const old = oldRes.rows[0];
const itemCode = old.item_code || null;
const whCode = old.warehouse_code || null;
const locCode = old.location_code || null;
const oldQty = Number(old.outbound_qty) || 0;
// 재고 복구
if (itemCode && oldQty > 0 && whCode) {
await adjustInventory(client, {
companyCode: old.company_code,
userId,
itemCode,
whCode,
locCode,
delta: +oldQty,
transactionType: "외주출고취소",
remark: `외주출고 삭제 (${old.outbound_number || ""})`,
});
}
// 삭제
await client.query(
`DELETE FROM outbound_mng WHERE id = $1 ${companyCondition}`,
queryParams,
);
await client.query("COMMIT");
logger.info("외주출고 삭제", { companyCode, id });
return res.json({ success: true, message: "삭제되었습니다." });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("외주출고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
/**
* 외주출고번호 자동생성
* GET /api/outsourcing-outbound/generate-number
*/
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const yyyy = new Date().getFullYear();
const prefix = `OSOUT-${yyyy}-`;
const result = await pool.query(
`SELECT outbound_number FROM outbound_mng
WHERE company_code = $1 AND outbound_number LIKE $2
ORDER BY outbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`],
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].outbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("외주출고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
/**
* 창고 목록 (outbound 컨트롤러와 공유)
*/
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
const params = companyCode === "*" ? [] : [companyCode];
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type FROM warehouse_info ${condition} ORDER BY warehouse_name`,
params,
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -207,7 +207,7 @@ export async function getPkgUnitItems(
const pool = getPool();
const result = await pool.query(
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit
`SELECT pui.*, ii.item_name, ii.size AS spec, ii.unit, ii.inventory_unit, ii.material
FROM pkg_unit_item pui
LEFT JOIN item_info ii ON pui.item_number = ii.item_number AND pui.company_code = ii.company_code
WHERE pui.pkg_code=$1 AND pui.company_code=$2
@@ -596,7 +596,7 @@ export async function getItemsByDivision(
}
const result = await pool.query(
`SELECT id, item_number, item_name, size, material, unit, division
`SELECT id, item_number, item_name, size, material, unit, inventory_unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
@@ -649,7 +649,7 @@ export async function getGeneralItems(
}
const result = await pool.query(
`SELECT id, item_number, item_name, size AS spec, material, unit, division
`SELECT id, item_number, item_name, size AS spec, material, unit, inventory_unit, division
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name

View File

@@ -7,13 +7,19 @@ import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
// 자동 마이그레이션: work_instruction_detail에 routing_version_id 컬럼 추가
// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가
let _migrationDone = false;
async function ensureDetailRoutingColumn() {
if (_migrationDone) return;
try {
const pool = getPool();
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)");
// 품목별 일정/설비/작업조/작업자 컬럼 (옵션 A — 다중선택 지원)
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS start_date VARCHAR(500)");
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS end_date VARCHAR(500)");
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS equipment_ids VARCHAR(1000)");
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS work_teams VARCHAR(200)");
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS workers VARCHAR(1000)");
_migrationDone = true;
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
}
@@ -130,6 +136,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
d.source_table,
d.source_id,
d.routing_version_id AS detail_routing_version_id,
d.start_date AS detail_start_date,
d.end_date AS detail_end_date,
d.equipment_ids AS detail_equipment_ids,
d.work_teams AS detail_work_teams,
d.workers AS detail_workers,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.type, '') AS item_type,
COALESCE(itm.size, '') AS item_spec,
@@ -186,6 +197,11 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
d.source_table,
d.source_id,
d.routing_version_id AS detail_routing_version_id,
d.start_date AS detail_start_date,
d.end_date AS detail_end_date,
d.equipment_ids AS detail_equipment_ids,
d.work_teams AS detail_work_teams,
d.workers AS detail_workers,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.type, '') AS item_type,
COALESCE(itm.size, '') AS item_spec,
@@ -293,8 +309,25 @@ export async function save(req: AuthenticatedRequest, res: Response) {
if (!firstRouting && itemRouting) firstRouting = itemRouting;
totalQty += Number(item.qty || 0);
await client.query(
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11)`,
[companyCode, wiNo, wiId, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", itemRouting, userId]
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16)`,
[
companyCode,
wiNo,
wiId,
item.itemNumber||item.itemCode||"",
item.qty||"0",
item.remark||"",
item.sourceTable||"",
item.sourceId||"",
item.partCode||item.itemNumber||item.itemCode||"",
itemRouting,
item.startDate||"",
item.endDate||"",
item.equipmentIds||"",
item.workTeams||"",
item.workers||"",
userId,
]
);
}
@@ -394,7 +427,30 @@ export async function getProductionPlanSource(req: AuthenticatedRequest, res: Re
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
// work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty
const rows = await pool.query(
`SELECT p.id, p.plan_no, p.item_code,
COALESCE(p.item_name,'') AS item_name,
COALESCE(p.plan_qty,0) AS plan_qty,
p.start_date, p.end_date, p.status,
COALESCE(p.equipment_name,'') AS equipment_name,
COALESCE(wi.applied_qty, 0) AS applied_qty,
(COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0)
- COALESCE(wi.applied_qty, 0)) AS remain_qty
FROM production_plan_mng p
LEFT JOIN (
SELECT source_id,
SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty
FROM work_instruction_detail
WHERE source_table = 'production_plan_mng'
AND company_code = $1
GROUP BY source_id
) wi ON wi.source_id = p.id::text
WHERE ${w}
ORDER BY p.created_date DESC
LIMIT $${idx} OFFSET $${idx+1}`,
params,
);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}