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:
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
652
backend-node/src/services/wipStockService.ts
Normal file
652
backend-node/src/services/wipStockService.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user