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

@@ -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");