Refactor Outbound and Work Instruction Controllers for Source Table Updates

- Updated the outbound and outsourcing outbound controllers to replace `source_type` with `source_table` for improved clarity and consistency in data handling.
- Enhanced the work instruction controller to include automatic migration for the `work_instruction_info` table, allowing for better management of work instruction notes.
- Implemented new logic to handle material input types in the work instruction detail modal, supporting both automatic and manual input methods.
- Added new routes for retrieving work instruction information, facilitating better data retrieval for editing purposes.

(TASK: ERP-node-095, ERP-node-096)
This commit is contained in:
kjs
2026-05-21 15:03:09 +09:00
parent 1ebd9348ae
commit c8994b49fc
37 changed files with 1733 additions and 149 deletions

View File

@@ -144,7 +144,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id, memo,
source_type, sales_order_id, shipment_plan_id, item_info_id,
source_table, source_id,
destination_code, delivery_destination, delivery_address,
created_date, created_by, writer, status
) VALUES (
@@ -154,9 +154,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23, $24, $25,
$26, $27, $28,
NOW(), $29, $29, '출고'
$22, $23,
$24, $25, $26,
NOW(), $27, $27, '출고'
) RETURNING *`,
[
companyCode,
@@ -180,10 +180,8 @@ export async function create(req: AuthenticatedRequest, res: Response) {
item.outbound_status || "대기",
manager_id || item.manager_id || null,
memo || item.memo || null,
item.source_type || item.source_table || null,
item.sales_order_id || null,
item.shipment_plan_id || null,
item.item_info_id || null,
item.source_table || null,
item.source_id || null,
item.destination_code || null,
item.delivery_destination || null,
item.delivery_address || null,
@@ -258,7 +256,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 + master status 자동 전환
const itemSourceTable = item.source_type || item.source_table;
const itemSourceTable = item.source_table;
if (
item.outbound_type === "판매출고" &&
item.source_id &&

View File

@@ -107,7 +107,7 @@ export async function getCandidates(req: AuthenticatedRequest, res: Response) {
AND NOT EXISTS (
SELECT 1 FROM outbound_mng om
WHERE om.outbound_type = '외주출고'
AND om.source_type = 'work_order_process'
AND om.source_table = 'work_order_process'
AND om.source_id = wop_done.id
${companyCode !== "*" ? "AND om.company_code = $1" : ""}
)
@@ -231,7 +231,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
outbound_qty, unit_price, total_amount,
warehouse_code, location_code,
outbound_status, manager_id, memo,
source_type, source_id,
source_table, source_id,
created_date, created_by, writer, status
) VALUES (
gen_random_uuid()::text, $1, $2, '외주출고', $3,

View File

@@ -3,6 +3,34 @@ import type { Pool, PoolClient } from "pg";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../middleware/authMiddleware";
import logger from "../utils/logger";
import {
onAcceptCancelled,
onProcessAccept,
onProcessCompleted,
onProcessMove,
onResultSaved,
} from "../services/wipStockService";
/**
* user_id → user_name(한글명) 조회 헬퍼 — wip_stock_history.manager_name 기록용.
* 조회 실패 시 user_id 를 fallback 으로 반환.
*/
async function resolveUserName(
exec: { query: (text: string, values?: any[]) => Promise<any> },
userId: string,
companyCode: string,
): Promise<string> {
try {
const r = await exec.query(
`SELECT COALESCE(NULLIF(user_name, ''), user_id) AS user_name
FROM user_info WHERE user_id = $1 AND company_code = $2 LIMIT 1`,
[userId, companyCode],
);
return r.rows[0]?.user_name || userId;
} catch {
return userId;
}
}
// 불량 상세 항목 타입
interface DefectDetailItem {
@@ -978,7 +1006,7 @@ export const controlTimer = async (
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND status != 'completed'
RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty`,
RETURNING id, status, completed_at, completed_by, actual_work_time, good_qty, defect_qty, wop_id, equipment_code`,
[
work_order_process_id,
companyCode,
@@ -999,6 +1027,26 @@ export const controlTimer = async (
});
}
// [WIP 적재 — 트리거 4] 타이머 완료(complete) 시 wip_stock status='completed' 전이
if (action === "complete" && result.rows[0]?.wop_id) {
try {
const row = result.rows[0];
const managerName = await resolveUserName(pool, userId, companyCode);
await onProcessCompleted(
pool,
companyCode,
row.wop_id,
work_order_process_id,
userId,
row.equipment_code || null,
managerName,
"타이머 완료(상태 전이)",
);
} catch (wipErr) {
logger.error("[pop/production] WIP 적재 오류 (타이머 완료는 유지):", wipErr);
}
}
return res.json({
success: true,
data: result.rows[0],
@@ -1527,6 +1575,36 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => {
await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId);
}
// [WIP 적재 — 트리거 2] 실적 저장분(양품/불량 증분)을 wip_stock 에 반영
// SAVEPOINT 로 감싼다: 공유 트랜잭션(client)에서 WIP 쿼리가 throw 하면
// PG 가 트랜잭션 전체를 aborted 로 만들어 이후 COMMIT 이 ROLLBACK 처리된다.
// SAVEPOINT 까지만 되돌려 트랜잭션을 건강하게 살려 본작업(실적저장)을 보존한다.
if ((currentSeq.rowCount ?? 0) > 0) {
await client.query("SAVEPOINT wip_save_result");
try {
const cs = currentSeq.rows[0];
const managerName = await resolveUserName(client, userId, companyCode);
await onResultSaved(
client,
companyCode,
cs.wop_id,
work_order_process_id,
addGood,
addDefect,
userId,
cs.equipment_code || null,
managerName,
);
await client.query("RELEASE SAVEPOINT wip_save_result");
} catch (wipErr) {
// WIP 부분쓰기만 되돌리고 본작업 트랜잭션은 살린다 (데이터는 후속 백필로 보정 가능)
await client
.query("ROLLBACK TO SAVEPOINT wip_save_result")
.catch(() => {});
logger.error("[pop/production] WIP 적재 오류 (실적 저장은 유지):", wipErr);
}
}
const latestData = await client.query(
`SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail,
result_note, result_status, status, input_qty,
@@ -1808,7 +1886,7 @@ export const confirmResult = async (
writer = $3,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`,
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id, equipment_code`,
[work_order_process_id, companyCode, userId],
);
@@ -1819,6 +1897,23 @@ export const confirmResult = async (
});
}
// [WIP 적재 — 트리거 3] 실적 확정 시 wip_stock status='completed' 전이
try {
const managerName = await resolveUserName(pool, userId, companyCode);
await onProcessCompleted(
pool,
companyCode,
result.rows[0].wop_id,
work_order_process_id,
userId,
result.rows[0].equipment_code || null,
managerName,
"실적 확정(상태 전이)",
);
} catch (wipErr) {
logger.error("[pop/production] WIP 적재 오류 (실적 확정은 유지):", wipErr);
}
// 작업지시 완료 캐스케이드
const wopLookup = await pool.query(
`SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
@@ -2358,6 +2453,63 @@ export const acceptProcess = async (
{ skipAStrategy: true },
);
// [WIP 적재 — 트리거 1·6] 공정 접수 시 wip_stock UPSERT + 직전 공정 이동 반영.
// 공유 트랜잭션(client) 이므로 WIP 쿼리가 throw 하면 트랜잭션 전체가 aborted 되어
// 이후 COMMIT 이 ROLLBACK 처리된다 → SAVEPOINT 로 감싸 본작업(접수)을 보존한다.
// 트리거1·6 을 각각 별도 SAVEPOINT 로 감싸 트리거6 실패가 트리거1 적재를 되돌리지 않게 한다.
const wipManagerName = await resolveUserName(client, userId, companyCode);
// 트리거 1: 이번 공정 접수분 적재 (input_qty 누적, status='in_progress')
await client.query("SAVEPOINT wip_accept");
try {
await onProcessAccept(
client,
companyCode,
masterId,
inserted.id,
qty,
userId,
req.body.equipment_code || null,
wipManagerName,
);
await client.query("RELEASE SAVEPOINT wip_accept");
} catch (wipErr) {
await client.query("ROLLBACK TO SAVEPOINT wip_accept").catch(() => {});
logger.error("[pop/production] WIP 적재 오류 (공정 접수는 유지):", wipErr);
}
// 트리거 6: 후속 공정(seq>최소) 접수면 직전 공정 wip_stock 행에 이동 반영
if (seqNum > 1) {
await client.query("SAVEPOINT wip_move");
try {
const prevWopRes = await client.query(
`SELECT id FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND NULLIF(regexp_replace(COALESCE(seq_no, ''), '[^0-9-]', '', 'g'), '')::int < $3
ORDER BY NULLIF(regexp_replace(COALESCE(seq_no, ''), '[^0-9-]', '', 'g'), '')::int DESC
LIMIT 1`,
[row.wo_id, companyCode, seqNum],
);
if ((prevWopRes.rowCount ?? 0) > 0) {
await onProcessMove(
client,
companyCode,
prevWopRes.rows[0].id,
masterId,
inserted.id,
qty,
userId,
req.body.equipment_code || null,
wipManagerName,
);
}
await client.query("RELEASE SAVEPOINT wip_move");
} catch (wipErr) {
await client.query("ROLLBACK TO SAVEPOINT wip_move").catch(() => {});
logger.error("[pop/production] WIP 이동 적재 오류 (공정 접수는 유지):", wipErr);
}
}
await client.query("COMMIT");
logger.info("[pop/production] accept-process 접수 완료", {
@@ -2420,7 +2572,7 @@ export const cancelAccept = async (
const current = await pool.query(
`SELECT wr.id, wr.status, wr.input_qty, wr.total_production_qty, wr.result_status,
wr.wop_id, wr.good_qty, wr.concession_qty,
wr.wop_id, wr.good_qty, wr.concession_qty, wr.equipment_code,
wop.wo_id, wop.seq_no, wop.process_name,
wop.target_warehouse_id, wop.target_location_code
FROM work_order_process_result wr
@@ -2538,6 +2690,34 @@ export const cancelAccept = async (
}
}
// [WIP 적재 — 트리거 5] 접수 취소 시 wip_stock 미소진 접수분 롤백.
// 공유 트랜잭션(client) 이므로 WIP 쿼리 throw 시 트랜잭션 전체가 aborted 되어
// 이후 COMMIT 이 ROLLBACK 처리된다 → SAVEPOINT 로 감싸 본작업(접수 취소)을 보존한다.
await client.query("SAVEPOINT wip_cancel");
try {
const managerName = await resolveUserName(client, userId, companyCode);
await onAcceptCancelled(
client,
companyCode,
proc.wop_id,
work_order_process_id,
cancelledQty,
userId,
totalProduced === 0, // 실적 0 → result 행 전체 삭제(fullRemove)
proc.equipment_code || null,
managerName,
);
await client.query("RELEASE SAVEPOINT wip_cancel");
} catch (wipErr) {
await client
.query("ROLLBACK TO SAVEPOINT wip_cancel")
.catch(() => {});
logger.error(
"[pop/production] WIP 적재 오류 (접수 취소는 유지):",
wipErr,
);
}
await client.query("COMMIT");
} catch (txErr) {
await client.query("ROLLBACK");

View File

@@ -38,6 +38,43 @@ async function ensureItemInfoBatchUseColumn() {
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
}
// 자동 마이그레이션: 작업지시 인포(메모) 테이블 (TASK:ERP-node-095)
// 작업지시 1건당 인포 N개 (1:N). POP 상단 표시용 자유 텍스트.
let _wiInfoMigrationDone = false;
async function ensureWorkInstructionInfoTable() {
if (_wiInfoMigrationDone) return;
try {
const pool = getPool();
await pool.query(`
CREATE TABLE IF NOT EXISTS work_instruction_info (
id VARCHAR(64) PRIMARY KEY,
company_code VARCHAR(50) NOT NULL,
work_instruction_id VARCHAR(64) NOT NULL,
work_instruction_no VARCHAR(100),
content TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_date TIMESTAMP DEFAULT NOW(),
writer VARCHAR(100)
)
`);
await pool.query("CREATE INDEX IF NOT EXISTS idx_wi_info_wid ON work_instruction_info (work_instruction_id)");
await pool.query("CREATE INDEX IF NOT EXISTS idx_wi_info_wno ON work_instruction_info (company_code, work_instruction_no)");
_wiInfoMigrationDone = true;
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
}
// 자동 마이그레이션: wi_process_work_item_detail에 material_input_type(자재투입 자동/수동) 컬럼 추가 (TASK:ERP-node-096)
// 'auto'=자동투입(기본, 기존 동작) / 'manual'=수동투입. 공정작업기준 모달 자재투입 항목 전용.
let _matInputTypeMigrationDone = false;
async function ensureMaterialInputTypeColumn() {
if (_matInputTypeMigrationDone) return;
try {
const pool = getPool();
await pool.query("ALTER TABLE wi_process_work_item_detail ADD COLUMN IF NOT EXISTS material_input_type VARCHAR(10) DEFAULT 'auto'");
_matInputTypeMigrationDone = true;
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
}
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
@@ -278,9 +315,10 @@ export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
export async function save(req: AuthenticatedRequest, res: Response) {
try {
await ensureDetailRoutingColumn();
await ensureWorkInstructionInfoTable();
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId, materialOverrides } = req.body;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId, materialOverrides, infos } = req.body;
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
@@ -386,6 +424,26 @@ export async function save(req: AuthenticatedRequest, res: Response) {
[String(totalQty), effectiveRouting, wiId]
);
// ── 작업지시 인포(메모) 저장 (TASK:ERP-node-095) ──
// 정책: 신규/편집 공통으로 기존 인포 전체 DELETE 후 재INSERT (work_instruction_detail과 동일 패턴).
// - infos가 없거나 빈 배열이면 INSERT 0건 (편집 시 인포 전체 삭제 효과).
// - 각 항목 trim 후 빈 문자열은 제외, 입력 순서대로 sort_order 0,1,2… 부여.
await client.query(`DELETE FROM work_instruction_info WHERE work_instruction_id = $1`, [wiId]);
if (Array.isArray(infos)) {
const cleanInfos = infos
.map((x: any) => (x == null ? "" : String(x).trim()))
.filter((x: string) => x.length > 0);
let infoSort = 0;
for (const content of cleanInfos) {
await client.query(
`INSERT INTO work_instruction_info (id,company_code,work_instruction_id,work_instruction_no,content,sort_order,created_date,writer)
VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,NOW(),$6)`,
[companyCode, wiId, wiNo, content, infoSort, userId]
);
infoSort++;
}
}
// ── materialOverrides 적용: wi_process_work_item + wi_process_work_item_detail INSERT ──
// 정책:
// - 수정(editId) 진입 시 이 작업지시의 기존 wi_* 자재(material_input) 데이터를 모두 삭제 후 재구축.
@@ -565,6 +623,8 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
await client.query("BEGIN");
// 디테일 삭제 (id 기반)
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=ANY($1)`, [ids]);
// 인포(메모) 동반 삭제 — 고아 데이터 방지 (TASK:ERP-node-095)
await client.query(`DELETE FROM work_instruction_info WHERE work_instruction_id=ANY($1)`, [ids]);
const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
await client.query("COMMIT");
return res.json({ success: true, deletedCount: result.rowCount });
@@ -576,6 +636,27 @@ export async function remove(req: AuthenticatedRequest, res: Response) {
}
}
// ─── 작업지시 인포(메모) 조회 (편집 모달 복원용, TASK:ERP-node-095) ───
export async function getInfos(req: AuthenticatedRequest, res: Response) {
try {
await ensureWorkInstructionInfoTable();
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const pool = getPool();
const rows = await pool.query(
`SELECT id, content, sort_order
FROM work_instruction_info
WHERE work_instruction_no = $1 AND company_code = $2
ORDER BY sort_order ASC, created_date ASC`,
[wiNo, companyCode]
);
return res.json({ success: true, data: rows.rows });
} catch (error: any) {
logger.error("작업지시 인포 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 품목 소스 (페이징) ───
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
try {
@@ -816,6 +897,7 @@ export async function updateRouting(req: AuthenticatedRequest, res: Response) {
// ─── 작업지시 전용 공정작업기준 조회 ───
export async function getWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
await ensureMaterialInputTypeColumn();
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const { routingVersionId } = req.query;
@@ -873,7 +955,8 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response)
work_qty_auto_collect, work_qty_plc_data,
defect_qty_auto_collect, defect_qty_plc_data,
good_qty_auto_collect, good_qty_plc_data,
loss_qty_auto_collect, loss_qty_plc_data
loss_qty_auto_collect, loss_qty_plc_data,
material_input_type
FROM wi_process_work_item_detail
WHERE wi_work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
@@ -1088,6 +1171,7 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response)
// ─── 작업지시 전용 공정작업기준 저장 (일괄) ───
export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
await ensureMaterialInputTypeColumn();
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { wiNo } = req.params;
@@ -1129,9 +1213,9 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response)
if (wi.details && Array.isArray(wi.details)) {
for (const d of wi.details) {
await client.query(
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, d.bom_item_id || null, d.bom_item_name || null, d.bom_qty != null && d.bom_qty !== "" ? String(d.bom_qty) : null, d.bom_unit || null, d.work_qty_auto_collect || null, d.work_qty_plc_data || null, d.defect_qty_auto_collect || null, d.defect_qty_plc_data || null, d.good_qty_auto_collect || null, d.good_qty_plc_data || null, d.loss_qty_auto_collect || null, d.loss_qty_plc_data || null, userId]
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, material_input_type, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, d.bom_item_id || null, d.bom_item_name || null, d.bom_qty != null && d.bom_qty !== "" ? String(d.bom_qty) : null, d.bom_unit || null, d.work_qty_auto_collect || null, d.work_qty_plc_data || null, d.defect_qty_auto_collect || null, d.defect_qty_plc_data || null, d.good_qty_auto_collect || null, d.good_qty_plc_data || null, d.loss_qty_auto_collect || null, d.loss_qty_plc_data || null, d.material_input_type || 'auto', userId]
);
}
}

View File

@@ -35,4 +35,7 @@ router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
// 작업지시별 자재투입(BOM 대체) 매핑 조회 — 편집 모달 복원용
router.get("/:wiNo/material-overrides", ctrl.getMaterialOverrides);
// 작업지시별 인포(메모) 조회 — 편집 모달 복원용 (TASK:ERP-node-095)
router.get("/:wiNo/infos", ctrl.getInfos);
export default router;

View File

@@ -1,3 +1,4 @@
import { randomUUID } from "crypto";
import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
@@ -515,6 +516,38 @@ export class DynamicFormService {
console.log("✅ 타입 변환 완료된 데이터:", dataToInsert);
// 🆕 id PK 자동 생성 (엑셀 업로드 경로 대응)
// 개발 DB의 item_info.id 등 일부 테이블은 gen_random_uuid() default가 없어
// /dynamic-form/save 경로에서 id 미지정 시 NOT NULL 위반 발생.
// 아래 4개 조건을 모두 충족할 때만 백엔드에서 UUID를 채워 넣는다.
// 1) PK가 단일 컬럼이고 그 이름이 "id"
// 2) 테이블에 실제 "id" 컬럼이 존재
// 3) 클라이언트가 보낸 id 값이 비어 있음 (이중 생성 방지)
// 4) id 컬럼 타입이 문자열/uuid 계열 (integer serial id에 UUID 주입 방지)
const idIsEmpty =
dataToInsert.id === undefined ||
dataToInsert.id === null ||
String(dataToInsert.id).trim() === "";
if (
primaryKeys.length === 1 &&
primaryKeys[0] === "id" &&
tableColumns.includes("id") &&
idIsEmpty
) {
const idColumn = columnInfo.find((col) => col.column_name === "id");
const idDataType = idColumn?.data_type?.toLowerCase() || "";
const isStringTypeId =
idDataType === "character varying" ||
idDataType === "text" ||
idDataType === "uuid";
if (isStringTypeId) {
dataToInsert.id = randomUUID();
console.log(
`🆔 id 자동 생성 (백엔드): ${tableName}.id = ${dataToInsert.id} (data_type=${idDataType})`
);
}
}
// 동적 SQL을 사용하여 실제 테이블에 UPSERT
const columns = Object.keys(dataToInsert);
const values: any[] = Object.values(dataToInsert);

View File

@@ -696,7 +696,7 @@ export async function requestRelease(
const ob = await client.query(
`INSERT INTO outbound_mng (
id, company_code, outbound_number, outbound_type,
outbound_date, reference_number, source_type,
outbound_date, reference_number, source_table,
outbound_status, status, writer, created_date, updated_date,
created_by, updated_by, memo, warehouse_code,
customer_code, customer_name,

View File

@@ -0,0 +1,652 @@
/**
* wipStockService — 재공품(WIP) 재고 적재 서비스 (TASK:ERP-094)
*
* 마이그레이션 1088(`wip_stock` / `wip_stock_history`)이 만든 구조에
* POP 공정 트랜잭션 시점마다 재공품 현재고 + 이력을 적재한다.
*
* 핵심 원칙:
* - 본 서비스의 모든 함수는 **호출한 컨트롤러가 보유한 DB 트랜잭션(client/pool)** 을
* 인자로 받아 같은 트랜잭션 안에서 실행한다. 별도 트랜잭션을 열지 않는다(부분 커밋 방지).
* - `wip_stock` 고유키는 (company_code, work_order_process_id, equipment_code, lot_number).
* equipment_code / lot_number 의 NULL 은 PostgreSQL UNIQUE 에서 서로 다른 값으로 취급되므로
* 조회/비교 시 COALESCE(...,'') 로 정규화하여 중복 행을 방지한다.
* - work_order_process_id 는 work_order_process.id(공정 인스턴스)이며
* work_order_process_result.id 가 아니다 → 재접수/리워크는 같은 wip_stock 행에 누적된다.
* - current_qty = good_qty - moved_qty - scrap 으로 일관 계산한다.
* - id 규칙: wip_stock = 'WIP-' + uuid, wip_stock_history = 'WIPH-' + uuid.
*/
import { randomUUID } from "crypto";
import logger from "../utils/logger";
/** pool / poolClient 양쪽 모두 만족하는 최소 인터페이스 */
export interface DbExec {
query: (text: string, values?: any[]) => Promise<any>;
}
/** wip_stock_history.transaction_type 허용값 */
export type WipTransactionType =
| "start"
| "produce"
| "defect"
| "move_in"
| "move_out"
| "scrap"
| "adjust";
/** wip_stock.status 허용값 */
export type WipStatus = "in_progress" | "completed" | "moved_out" | "scrapped";
/**
* wip_stock 행 식별/생성에 필요한 컨텍스트.
* 각 트리거는 work_order_process(공정 인스턴스) 정보를 조회해 이 객체를 채운다.
*/
export interface WipStockContext {
companyCode: string;
/** work_order_process.id (공정 인스턴스) — 고유키 구성요소 */
workOrderProcessId: string;
/** work_instruction.id */
workInstructionId?: string | null;
processCode?: string | null;
/** work_order_process.seq_no (정수 변환) */
processSeq?: number | null;
/** 설비코드 — NULL 가능 (미배정) */
equipmentCode?: string | null;
/** item_info.item_number */
itemCode: string;
/** 로트번호 — work_order_process.batch_id, 없으면 wo_id */
lotNumber?: string | null;
unit?: string | null;
userId: string;
}
/** history 1행 기록에 필요한 정보 */
export interface WipHistoryInput {
transactionType: WipTransactionType;
/** 변동량 (양수=증가, 음수=감소) */
quantity: number;
/** 변동 직후 wip_stock.current_qty 스냅샷 */
balanceQty: number;
reason?: string | null;
/** 출처 테이블명 — POP 적재는 'work_order_process_result' */
referenceType?: string | null;
/** 출처 row id — work_order_process_result.id */
referenceId?: string | null;
referenceNumber?: string | null;
managerName?: string | null;
}
/** wip_stock 현재 행 스냅샷 */
interface WipStockRow {
id: string;
input_qty: number;
good_qty: number;
defect_qty: number;
moved_qty: number;
scrap_qty: number;
current_qty: number;
status: string;
}
/** numeric 컬럼 → number 안전 변환 */
function toNum(v: unknown): number {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* wip_stock 행을 고유키(COALESCE 정규화)로 조회한다. 없으면 null.
* scrap 누적은 별도 컬럼이 없으므로 wip_stock 에는 두지 않고,
* current_qty = good_qty - moved_qty - scrap 계산상의 scrap 은
* history(transaction_type='scrap') 합으로 도출한다. (스키마 1088 준수)
*/
async function findWipStock(
exec: DbExec,
ctx: WipStockContext,
): Promise<WipStockRow | null> {
const res = await exec.query(
`SELECT id, input_qty, good_qty, defect_qty, moved_qty, current_qty, status
FROM wip_stock
WHERE company_code = $1
AND work_order_process_id = $2
AND COALESCE(equipment_code, '') = COALESCE($3, '')
AND COALESCE(lot_number, '') = COALESCE($4, '')
LIMIT 1`,
[
ctx.companyCode,
ctx.workOrderProcessId,
ctx.equipmentCode || null,
ctx.lotNumber || null,
],
);
if ((res.rowCount ?? 0) === 0) return null;
const r = res.rows[0];
// scrap 합계: 이 wip_stock 의 history 중 transaction_type='scrap' 절대값 합
const scrapRes = await exec.query(
`SELECT COALESCE(SUM(ABS(quantity)), 0) AS scrap_sum
FROM wip_stock_history
WHERE wip_stock_id = $1 AND transaction_type = 'scrap'`,
[r.id],
);
return {
id: r.id,
input_qty: toNum(r.input_qty),
good_qty: toNum(r.good_qty),
defect_qty: toNum(r.defect_qty),
moved_qty: toNum(r.moved_qty),
scrap_qty: toNum(scrapRes.rows[0]?.scrap_sum),
current_qty: toNum(r.current_qty),
status: r.status,
};
}
/**
* wip_stock 이력 1행 기록. 변동 1건당 1행.
*/
async function recordWipHistory(
exec: DbExec,
ctx: WipStockContext,
wipStockId: string,
input: WipHistoryInput,
): Promise<void> {
await exec.query(
`INSERT INTO wip_stock_history (
id, company_code,
wip_stock_id, work_order_process_id, process_code, equipment_code,
item_code, lot_number,
transaction_type, transaction_date, quantity, balance_qty,
reference_type, reference_id, reference_number,
reason, manager_id, manager_name,
created_at, created_by
) VALUES (
$1, $2,
$3, $4, $5, $6,
$7, $8,
$9, NOW(), $10, $11,
$12, $13, $14,
$15, $16, $17,
NOW(), $16
)`,
[
`WIPH-${randomUUID()}`,
ctx.companyCode,
wipStockId,
ctx.workOrderProcessId,
ctx.processCode || null,
ctx.equipmentCode || null,
ctx.itemCode,
ctx.lotNumber || null,
input.transactionType,
String(input.quantity),
String(input.balanceQty),
input.referenceType || null,
input.referenceId || null,
input.referenceNumber || null,
input.reason || null,
ctx.userId,
input.managerName || null,
],
);
}
/**
* wip_stock UPSERT — delta(증분) 방식.
* 각 수량 컬럼에 delta 를 누적 가산하고 current_qty 를 재계산한다.
* 행이 없으면 INSERT, 있으면 UPDATE. (UNIQUE 제약은 COALESCE 미정규화라
* ON CONFLICT 대신 SELECT→INSERT/UPDATE 패턴을 쓴다 — inventory_stock 과 동일)
*
* @returns { stockId, currentQty } — history 의 balance_qty 에 쓸 변동 직후 잔량
*/
async function upsertWipStock(
exec: DbExec,
ctx: WipStockContext,
delta: {
input?: number;
good?: number;
defect?: number;
moved?: number;
scrap?: number;
},
status?: WipStatus,
): Promise<{ stockId: string; currentQty: number }> {
const dInput = delta.input ?? 0;
const dGood = delta.good ?? 0;
const dDefect = delta.defect ?? 0;
const dMoved = delta.moved ?? 0;
const dScrap = delta.scrap ?? 0;
const existing = await findWipStock(exec, ctx);
if (existing) {
const newInput = existing.input_qty + dInput;
const newGood = existing.good_qty + dGood;
const newDefect = existing.defect_qty + dDefect;
const newMoved = existing.moved_qty + dMoved;
const newScrap = existing.scrap_qty + dScrap;
// current_qty = good - moved - scrap (음수 가드)
const newCurrent = Math.max(0, newGood - newMoved - newScrap);
await exec.query(
`UPDATE wip_stock
SET input_qty = $2,
good_qty = $3,
defect_qty = $4,
moved_qty = $5,
current_qty = $6,
status = COALESCE($7, status),
updated_at = NOW(),
updated_by = $8
WHERE id = $1`,
[
existing.id,
String(Math.max(0, newInput)),
String(Math.max(0, newGood)),
String(Math.max(0, newDefect)),
String(Math.max(0, newMoved)),
String(newCurrent),
status || null,
ctx.userId,
],
);
return { stockId: existing.id, currentQty: newCurrent };
}
// 신규 INSERT
const stockId = `WIP-${randomUUID()}`;
const newGood = Math.max(0, dGood);
const newMoved = Math.max(0, dMoved);
const newScrap = Math.max(0, dScrap);
const newCurrent = Math.max(0, newGood - newMoved - newScrap);
await exec.query(
`INSERT INTO wip_stock (
id, company_code,
work_instruction_id, work_order_process_id, process_code, process_seq,
equipment_code, item_code, lot_number,
input_qty, good_qty, defect_qty, moved_qty, current_qty, unit,
status, created_at, updated_at, created_by, updated_by
) VALUES (
$1, $2,
$3, $4, $5, $6,
$7, $8, $9,
$10, $11, $12, $13, $14, $15,
$16, NOW(), NOW(), $17, $17
)`,
[
stockId,
ctx.companyCode,
ctx.workInstructionId || null,
ctx.workOrderProcessId,
ctx.processCode || null,
ctx.processSeq ?? null,
ctx.equipmentCode || null,
ctx.itemCode,
ctx.lotNumber || null,
String(Math.max(0, dInput)),
String(newGood),
String(Math.max(0, dDefect)),
String(newMoved),
String(newCurrent),
ctx.unit || null,
status || "in_progress",
ctx.userId,
],
);
return { stockId, currentQty: newCurrent };
}
/**
* 공정 컨텍스트 조회 헬퍼.
* work_order_process.id 로 work_instruction / item_info 를 LEFT JOIN 해
* WipStockContext 의 item_code / unit / lot_number / process 정보를 채운다.
*
* 품목 식별 정책 (백필 1097 과 동일):
* - item_info 행이 있으면 item_code = item_info.item_number.
* - item_info 행이 삭제된 경우 item_code = wi.item_id(UUID) fallback.
* - work_instruction 자체가 없어 품목 식별자를 전혀 구할 수 없으면 null 반환(스킵).
*
* @param wopId work_order_process.id (공정 인스턴스)
* @param equipmentCode 접수 시 입력된 설비코드 (NULL 가능)
* @returns 품목 식별 완전 불가(work_instruction 없음) 시 null — 호출자는 적재 스킵
*/
export async function buildWipContext(
exec: DbExec,
companyCode: string,
wopId: string,
userId: string,
equipmentCode?: string | null,
): Promise<WipStockContext | null> {
const res = await exec.query(
`SELECT wop.id AS wop_id,
wop.wo_id AS wi_id,
wop.process_code AS process_code,
wop.seq_no AS seq_no,
wop.batch_id AS batch_id,
wi.item_id AS wi_item_id,
ii.item_number AS item_number,
ii.unit AS unit
FROM work_order_process wop
LEFT JOIN work_instruction wi
ON wi.id = wop.wo_id AND wi.company_code = wop.company_code
LEFT JOIN item_info ii
ON ii.id = wi.item_id AND ii.company_code = wi.company_code
WHERE wop.id = $1 AND wop.company_code = $2`,
[wopId, companyCode],
);
if ((res.rowCount ?? 0) === 0) return null;
const r = res.rows[0];
// item_code: item_info.item_number 우선, 삭제품목이면 wi.item_id(UUID) fallback
const itemCode =
(r.item_number && String(r.item_number).trim()) ||
(r.wi_item_id && String(r.wi_item_id).trim()) ||
null;
if (!itemCode) {
// work_instruction 자체가 없음 — 품목 식별 불가, wip_stock.item_code NOT NULL 적재 불가
logger.warn("[wipStockService] 품목 식별 불가로 적재 스킵", {
companyCode,
wopId,
});
return null;
}
const seqNum = parseInt(r.seq_no, 10);
return {
companyCode,
workOrderProcessId: r.wop_id,
workInstructionId: r.wi_id,
processCode: r.process_code || null,
processSeq: Number.isFinite(seqNum) ? seqNum : null,
equipmentCode: equipmentCode || null,
itemCode,
// lot_number ← batch_id, 없으면 wo_id 로 대체 (기획서 필드 매핑)
lotNumber: r.batch_id || r.wi_id,
unit: r.unit || null,
userId,
};
}
// ───────────────────────────────────────────────────────────────────────────
// 트리거별 진입점 — popProductionController 의 6개 지점에서 호출
// ───────────────────────────────────────────────────────────────────────────
/**
* [트리거 1] 공정 접수 (acceptProcess).
* input_qty 누적, current_qty 가산 없음(투입은 잔량 아님), status='in_progress'.
* history: transaction_type='start', quantity=accept_qty.
*
* @param wopId work_order_process.id (마스터 공정 인스턴스 id)
* @param resultId 이번 접수로 생성된 work_order_process_result.id (history reference_id)
* @param acceptQty 접수 수량
*/
export async function onProcessAccept(
exec: DbExec,
companyCode: string,
wopId: string,
resultId: string,
acceptQty: number,
userId: string,
equipmentCode?: string | null,
managerName?: string | null,
): Promise<void> {
const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode);
if (!ctx) return;
const { stockId, currentQty } = await upsertWipStock(
exec,
ctx,
{ input: acceptQty },
"in_progress",
);
await recordWipHistory(exec, ctx, stockId, {
transactionType: "start",
quantity: acceptQty,
balanceQty: currentQty,
reason: "공정 접수",
referenceType: "work_order_process_result",
referenceId: resultId,
managerName: managerName || null,
});
}
/**
* [트리거 2] 실적 저장 (saveResult).
* good_qty / defect_qty 증분 갱신, current_qty 재계산.
* history: 양품 증가분은 'produce', 불량 증가분은 'defect' 로 각각 1행.
*
* @param addGood 이번 저장으로 증가한 양품 수량 (>=0)
* @param addDefect 이번 저장으로 증가한 불량 수량 (>=0)
*/
export async function onResultSaved(
exec: DbExec,
companyCode: string,
wopId: string,
resultId: string,
addGood: number,
addDefect: number,
userId: string,
equipmentCode?: string | null,
managerName?: string | null,
): Promise<void> {
if (addGood === 0 && addDefect === 0) return;
const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode);
if (!ctx) return;
const { stockId, currentQty } = await upsertWipStock(exec, ctx, {
good: addGood,
defect: addDefect,
});
if (addGood !== 0) {
await recordWipHistory(exec, ctx, stockId, {
transactionType: "produce",
quantity: addGood,
balanceQty: currentQty,
reason: "양품 생산 실적",
referenceType: "work_order_process_result",
referenceId: resultId,
managerName: managerName || null,
});
}
if (addDefect !== 0) {
await recordWipHistory(exec, ctx, stockId, {
transactionType: "defect",
quantity: addDefect,
balanceQty: currentQty,
reason: "불량 발생 실적",
referenceType: "work_order_process_result",
referenceId: resultId,
managerName: managerName || null,
});
}
}
/**
* [트리거 3·4] 실적 확정 / 타이머 완료 — 상태 전이 기록.
* wip_stock.status='completed' 로 갱신. 수량 변동 없음.
* history: transaction_type='adjust', quantity=0 (상태전이 기록 행).
*/
export async function onProcessCompleted(
exec: DbExec,
companyCode: string,
wopId: string,
resultId: string,
userId: string,
equipmentCode?: string | null,
managerName?: string | null,
reason: string = "공정 완료(상태 전이)",
): Promise<void> {
const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode);
if (!ctx) return;
// 기존 wip_stock 행이 없으면 상태전이만으로 빈 행을 만들지 않는다 (수량 0 행 방지)
const existing = await findWipStock(exec, ctx);
if (!existing) return;
if (existing.status === "completed") return; // 멱등 — 이미 완료면 중복 기록 안 함
const { stockId, currentQty } = await upsertWipStock(
exec,
ctx,
{},
"completed",
);
await recordWipHistory(exec, ctx, stockId, {
transactionType: "adjust",
quantity: 0,
balanceQty: currentQty,
reason,
referenceType: "work_order_process_result",
referenceId: resultId,
managerName: managerName || null,
});
}
/**
* [트리거 5] 접수 취소 (cancelAccept) — 롤백.
* 미소진 접수분(input_qty)을 차감한다.
* - 실적이 전혀 없으면(취소로 result 행 삭제) input_qty 전량 차감,
* 잔량 0 이면 status='scrapped'.
* - 실적이 일부 있으면 미소진분만 차감, 행은 유지(status 유지).
* 잔량/수량 음수 가드는 upsertWipStock 내부에서 처리.
* history: transaction_type='adjust', quantity=-cancelledQty.
*
* @param cancelledQty 취소된 미소진 접수 수량 (>0)
* @param fullRemove 해당 result 행이 완전 삭제됐는지 여부 (true면 행 status='scrapped' 후보)
*/
export async function onAcceptCancelled(
exec: DbExec,
companyCode: string,
wopId: string,
resultId: string,
cancelledQty: number,
userId: string,
fullRemove: boolean,
equipmentCode?: string | null,
managerName?: string | null,
): Promise<void> {
if (cancelledQty <= 0) return;
const ctx = await buildWipContext(exec, companyCode, wopId, userId, equipmentCode);
if (!ctx) return;
const existing = await findWipStock(exec, ctx);
if (!existing) return; // 적재 안 된 공정이면 롤백 대상 없음
// 접수분(input_qty)만 차감 — 잔량 음수 방지 위해 차감량을 input_qty 로 클램프
const deductInput = Math.min(cancelledQty, existing.input_qty);
const newInput = existing.input_qty - deductInput;
// 실적(good_qty)이 있으면 행 유지, 없고 전량 취소면 scrapped
const nextStatus: WipStatus | undefined =
fullRemove && newInput <= 0 && existing.good_qty <= 0
? "scrapped"
: undefined;
const { stockId, currentQty } = await upsertWipStock(
exec,
ctx,
{ input: -deductInput },
nextStatus,
);
await recordWipHistory(exec, ctx, stockId, {
transactionType: "adjust",
quantity: -deductInput,
balanceQty: currentQty,
reason: "접수 취소(미소진분 롤백)",
referenceType: "work_order_process_result",
referenceId: resultId,
managerName: managerName || null,
});
}
/**
* [트리거 6] 다음 공정 이동.
* 후속 공정 접수 시 직전 공정 wip_stock 행의 moved_qty 를 가산하고 current_qty 차감.
* history: 직전 공정 행에 'move_out'(음수), 후속 공정 행에 'move_in'(양수) 각 1행.
*
* 직전 공정의 wip_stock 행을 lot_number 기준으로 찾을 수 없을 수 있으므로
* (직전 공정 batch_id 가 다를 수 있음) work_order_process_id 단위로 매칭한다.
*
* @param prevWopId 직전 공정 work_order_process.id
* @param nextWopId 후속(이번 접수) 공정 work_order_process.id
* @param nextResultId 후속 공정 접수 result.id
* @param moveQty 이동 수량
*/
export async function onProcessMove(
exec: DbExec,
companyCode: string,
prevWopId: string,
nextWopId: string,
nextResultId: string,
moveQty: number,
userId: string,
nextEquipmentCode?: string | null,
managerName?: string | null,
): Promise<void> {
if (moveQty <= 0) return;
// 직전 공정 wip_stock 행 조회 (work_order_process_id 단위 — 설비/로트 무관 합산 대상 1행)
const prevRes = await exec.query(
`SELECT id, good_qty, moved_qty, equipment_code, lot_number, process_code,
item_code, process_seq, work_instruction_id, unit
FROM wip_stock
WHERE company_code = $1 AND work_order_process_id = $2
ORDER BY created_at ASC
LIMIT 1`,
[companyCode, prevWopId],
);
if ((prevRes.rowCount ?? 0) > 0) {
const p = prevRes.rows[0];
const prevCtx: WipStockContext = {
companyCode,
workOrderProcessId: prevWopId,
workInstructionId: p.work_instruction_id || null,
processCode: p.process_code || null,
processSeq: p.process_seq ?? null,
equipmentCode: p.equipment_code || null,
itemCode: p.item_code,
lotNumber: p.lot_number || null,
unit: p.unit || null,
userId,
};
const prevGood = toNum(p.good_qty);
const prevMoved = toNum(p.moved_qty);
// 이동량은 직전 공정 미이동 잔량(good - moved)을 초과하지 않도록 클램프
const movableQty = Math.max(0, prevGood - prevMoved);
const actualMove = Math.min(moveQty, movableQty);
if (actualMove > 0) {
const { stockId, currentQty } = await upsertWipStock(exec, prevCtx, {
moved: actualMove,
});
await recordWipHistory(exec, prevCtx, stockId, {
transactionType: "move_out",
quantity: -actualMove,
balanceQty: currentQty,
reason: "후속 공정으로 이동",
referenceType: "work_order_process_result",
referenceId: nextResultId,
managerName: managerName || null,
});
}
}
// 후속 공정 wip_stock 행에 move_in 이력 기록 (수량 컬럼 변동 없음 — input 은 start 에서 처리)
const nextCtx = await buildWipContext(
exec,
companyCode,
nextWopId,
userId,
nextEquipmentCode,
);
if (nextCtx) {
const existing = await findWipStock(exec, nextCtx);
if (existing) {
await recordWipHistory(exec, nextCtx, existing.id, {
transactionType: "move_in",
quantity: moveQty,
balanceQty: existing.current_qty,
reason: "직전 공정에서 이동 수령",
referenceType: "work_order_process_result",
referenceId: nextResultId,
managerName: managerName || null,
});
}
}
}