From 6656a525a2e062716a0484a73ab789269ef4a323 Mon Sep 17 00:00:00 2001 From: kmh Date: Fri, 24 Apr 2026 15:00:38 +0900 Subject: [PATCH] feat(backend/work-instruction): re-project master checklist snapshot on wi edit --- ...ionController.copyChecklistToSplit.test.ts | 378 ++++++++++++++++++ .../controllers/workInstructionController.ts | 82 ++++ .../src/routes/popProductionRoutes.ts | 2 + 3 files changed, 462 insertions(+) create mode 100644 backend-node/src/controllers/__tests__/popProductionController.copyChecklistToSplit.test.ts diff --git a/backend-node/src/controllers/__tests__/popProductionController.copyChecklistToSplit.test.ts b/backend-node/src/controllers/__tests__/popProductionController.copyChecklistToSplit.test.ts new file mode 100644 index 00000000..236e09fb --- /dev/null +++ b/backend-node/src/controllers/__tests__/popProductionController.copyChecklistToSplit.test.ts @@ -0,0 +1,378 @@ +/** + * copyChecklistToSplit 단위 테스트 + * + * 대상: /src/controllers/popProductionController.ts 의 copyChecklistToSplit + * + * 전략: 실제 DB 연결 없이 client.query 를 Jest mock 으로 주입한다. + * 테스트 대상 함수는 외부에서 주입된 client 만을 사용하여 쿼리를 실행하므로 + * pg/Pool 전체를 모킹할 필요가 없다 (순수한 query router 로직 검증). + * + * 커버 분기: + * - A-1 wi_* 커스텀 템플릿 존재 (wi_process_work_item) -> wi_* 에서 복사 + * - A-2 wi_* 미존재 또는 workInstructionNo 미지정 -> 원본 process_work_item 에서 복사 + * - skipAStrategy=true -> A 전략 전체 skip, B 전략 진입 + * - A 에서 0 건 -> B 전략 fallthrough + * - routingDetailId=null -> 곧장 B 전략 + * - B 전략 = 마스터 wop 의 기존 process_work_result 구조 복사 + */ + +import { copyChecklistToSplit } from "../popProductionController"; + +type QueryCall = { text: string; values?: unknown[] }; + +/** + * client.query mock 헬퍼. + * calls 배열에 호출 인자를 순서대로 저장하고, responses 큐에서 응답을 순서대로 반환한다. + * responses 가 고갈되면 기본값 { rows: [], rowCount: 0 } 을 반환한다. + */ +function makeClient( + responses: Array<{ rows?: unknown[]; rowCount?: number }>, +): { + client: { query: jest.Mock }; + calls: QueryCall[]; +} { + const calls: QueryCall[] = []; + const queue = [...responses]; + const client = { + query: jest.fn((text: string, values?: unknown[]) => { + calls.push({ text, values }); + const next = queue.shift(); + return Promise.resolve(next ?? { rows: [], rowCount: 0 }); + }), + }; + return { client, calls }; +} + +const COMPANY = "TESTCO"; +const USER = "tester01"; +const MASTER_WOP = "master-wop-id"; +const WOP_RESULT = "wop-result-id"; +const ROUTING_DETAIL = "routing-detail-id"; +const WI_NO = "WI-20260424-001"; + +describe("copyChecklistToSplit", () => { + describe("A-1: wi_* 커스텀 템플릿 우선 복사", () => { + it("workInstructionNo 지정 + wi_process_work_item row 존재 시 wi_* 템플릿에서 복사한다", async () => { + const { client, calls } = makeClient([ + { rows: [{ "?column?": 1 }], rowCount: 1 }, + { rows: [], rowCount: 3 }, + ]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + { workInstructionNo: WI_NO }, + ); + + expect(inserted).toBe(3); + expect(client.query).toHaveBeenCalledTimes(2); + + expect(calls[0].text).toContain("FROM wi_process_work_item"); + expect(calls[0].values).toEqual([ + WI_NO, + ROUTING_DETAIL, + COMPANY, + ]); + + expect(calls[1].text).toContain("INSERT INTO process_work_result"); + expect(calls[1].text).toContain("FROM wi_process_work_item wi"); + expect(calls[1].text).toContain("wi_process_work_item_detail wid"); + expect(calls[1].values).toEqual([ + WOP_RESULT, + USER, + ROUTING_DETAIL, + COMPANY, + WI_NO, + ]); + }); + + it("A-1 결과가 0건이면 B 전략으로 fallthrough 하여 마스터 스냅샷에서 복사한다", async () => { + const { client, calls } = makeClient([ + { rows: [{ "?column?": 1 }], rowCount: 1 }, + { rows: [], rowCount: 0 }, + { rows: [], rowCount: 7 }, + ]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + { workInstructionNo: WI_NO }, + ); + + expect(inserted).toBe(7); + expect(client.query).toHaveBeenCalledTimes(3); + expect(calls[2].text).toContain("FROM process_work_result"); + expect(calls[2].text).toContain("WHERE work_order_process_id = $3"); + expect(calls[2].values).toEqual([ + WOP_RESULT, + USER, + MASTER_WOP, + COMPANY, + ]); + }); + }); + + describe("A-2: 원본 템플릿 fallback", () => { + it("workInstructionNo 미지정 시 원본 process_work_item 에서 복사한다", async () => { + const { client, calls } = makeClient([{ rows: [], rowCount: 5 }]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + ); + + expect(inserted).toBe(5); + expect(client.query).toHaveBeenCalledTimes(1); + expect(calls[0].text).toContain("FROM process_work_item pwi"); + expect(calls[0].text).toContain("process_work_item_detail pwd"); + expect(calls[0].text).not.toContain("wi_process_work_item"); + expect(calls[0].values).toEqual([ + WOP_RESULT, + USER, + ROUTING_DETAIL, + COMPANY, + ]); + }); + + it("workInstructionNo 지정됐지만 wi_* row 가 0개면 원본 템플릿에서 복사한다", async () => { + const { client, calls } = makeClient([ + { rows: [], rowCount: 0 }, + { rows: [], rowCount: 4 }, + ]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + { workInstructionNo: WI_NO }, + ); + + expect(inserted).toBe(4); + expect(client.query).toHaveBeenCalledTimes(2); + expect(calls[0].text).toContain("SELECT 1 FROM wi_process_work_item"); + expect(calls[1].text).toContain("FROM process_work_item pwi"); + expect(calls[1].text).not.toContain("wi_process_work_item"); + }); + + it("A-2 결과가 0건이면 B 전략으로 fallthrough 한다", async () => { + const { client, calls } = makeClient([ + { rows: [], rowCount: 0 }, + { rows: [], rowCount: 2 }, + ]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + ); + + expect(inserted).toBe(2); + expect(client.query).toHaveBeenCalledTimes(2); + expect(calls[1].text).toContain("FROM process_work_result"); + expect(calls[1].text).toContain("WHERE work_order_process_id = $3"); + }); + }); + + describe("skipAStrategy: A 전략 전체 건너뛰기", () => { + it("skipAStrategy=true 이면 routingDetailId 와 workInstructionNo 가 있어도 B 전략만 실행한다", async () => { + const { client, calls } = makeClient([{ rows: [], rowCount: 10 }]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + { workInstructionNo: WI_NO, skipAStrategy: true }, + ); + + expect(inserted).toBe(10); + expect(client.query).toHaveBeenCalledTimes(1); + expect(calls[0].text).toContain("FROM process_work_result"); + expect(calls[0].text).toContain("WHERE work_order_process_id = $3"); + expect(calls[0].text).not.toContain("wi_process_work_item"); + expect(calls[0].text).not.toContain("process_work_item pwi"); + expect(calls[0].values).toEqual([ + WOP_RESULT, + USER, + MASTER_WOP, + COMPANY, + ]); + }); + + it("skipAStrategy=false (명시) 는 기본 동작과 동일하다", async () => { + const { client } = makeClient([{ rows: [], rowCount: 2 }]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + { skipAStrategy: false }, + ); + + expect(inserted).toBe(2); + expect(client.query).toHaveBeenCalledTimes(1); + }); + }); + + describe("B: routingDetailId 없음", () => { + it("routingDetailId=null 이면 A 전략 skip, 곧장 B 전략 실행", async () => { + const { client, calls } = makeClient([{ rows: [], rowCount: 6 }]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + null, + COMPANY, + USER, + ); + + expect(inserted).toBe(6); + expect(client.query).toHaveBeenCalledTimes(1); + expect(calls[0].text).toContain("FROM process_work_result"); + expect(calls[0].values).toEqual([ + WOP_RESULT, + USER, + MASTER_WOP, + COMPANY, + ]); + }); + + it("routingDetailId=null + workInstructionNo 지정은 workInstructionNo 무시하고 B 전략 실행", async () => { + const { client, calls } = makeClient([{ rows: [], rowCount: 1 }]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + null, + COMPANY, + USER, + { workInstructionNo: WI_NO }, + ); + + expect(inserted).toBe(1); + expect(client.query).toHaveBeenCalledTimes(1); + expect(calls[0].text).not.toContain("wi_process_work_item"); + expect(calls[0].text).toContain("FROM process_work_result"); + }); + }); + + describe("엣지 케이스", () => { + it("B 전략에서도 0 건이면 0 을 반환한다", async () => { + const { client } = makeClient([{ rows: [], rowCount: 0 }]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + null, + COMPANY, + USER, + ); + + expect(inserted).toBe(0); + }); + + it("rowCount 가 undefined 면 0 을 반환한다 (null safety)", async () => { + const { client } = makeClient([{ rows: [] }]); + + const inserted = await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + null, + COMPANY, + USER, + ); + + expect(inserted).toBe(0); + }); + + it("wi_* 체크 쿼리에 company_code 가 필터로 포함된다 (멀티테넌시)", async () => { + const { client, calls } = makeClient([ + { rows: [], rowCount: 0 }, + { rows: [], rowCount: 1 }, + ]); + + await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + { workInstructionNo: WI_NO }, + ); + + expect(calls[0].text).toContain("company_code = $3"); + expect(calls[0].values?.[2]).toBe(COMPANY); + }); + + it("모든 INSERT 쿼리는 파라미터 바인딩을 사용한다 (문자열 삽입 금지)", async () => { + const { client, calls } = makeClient([ + { rows: [{ "?column?": 1 }], rowCount: 1 }, + { rows: [], rowCount: 1 }, + ]); + + await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + ROUTING_DETAIL, + COMPANY, + USER, + { workInstructionNo: WI_NO }, + ); + + // 모든 호출에서 values 가 존재하고 query 텍스트에 placeholder 가 있어야 한다 + for (const call of calls) { + expect(call.values).toBeDefined(); + expect(Array.isArray(call.values)).toBe(true); + expect(call.text).toMatch(/\$\d+/); + } + }); + + it("B 전략은 항상 masterProcessId 로 소스 스냅샷을 조회한다", async () => { + const { client, calls } = makeClient([{ rows: [], rowCount: 3 }]); + + await copyChecklistToSplit( + client, + MASTER_WOP, + WOP_RESULT, + null, + COMPANY, + USER, + ); + + const insertSql = calls[0].text; + expect(insertSql).toContain("FROM process_work_result"); + expect(insertSql).toContain("WHERE work_order_process_id = $3"); + expect(calls[0].values?.[2]).toBe(MASTER_WOP); + expect(calls[0].values?.[3]).toBe(COMPANY); + }); + }); +}); diff --git a/backend-node/src/controllers/workInstructionController.ts b/backend-node/src/controllers/workInstructionController.ts index c6b9e667..e4972915 100644 --- a/backend-node/src/controllers/workInstructionController.ts +++ b/backend-node/src/controllers/workInstructionController.ts @@ -6,6 +6,7 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; import { numberingRuleService } from "../services/numberingRuleService"; +import { copyChecklistToSplit } from "./popProductionController"; // 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가 let _migrationDone = false; @@ -709,6 +710,80 @@ export async function getWorkStandard(req: AuthenticatedRequest, res: Response) } } +/** + * wi_* 편집 시 마스터 체크리스트 스냅샷을 재투영한다. + * 접수(work_order_process_result) 가 0건일 때만 동기화되며, 1건 이상이면 스냅샷 불변. + * 트랜잭션 내에서 호출되어야 한다 (caller 가 BEGIN/COMMIT 관리). + * + * @param routingDetailId null 이면 해당 작업지시의 모든 routing detail 동기화 + * @returns synced: 실제 동기화 수행 여부, affectedProcesses: 재복사된 마스터 공정 수 + */ +async function syncMasterChecklistFromWi( + client: { query: (text: string, values?: any[]) => Promise }, + workInstructionNo: string, + routingDetailId: string | null, + companyCode: string, + userId: string, +): Promise<{ synced: boolean; affectedProcesses: number; reason?: string }> { + // 1. 작업지시 id 조회 + const wiRow = await client.query( + `SELECT id FROM work_instruction WHERE work_instruction_no = $1 AND company_code = $2`, + [workInstructionNo, companyCode], + ); + if (wiRow.rowCount === 0) { + return { synced: false, affectedProcesses: 0, reason: "work_instruction not found" }; + } + const wiId = wiRow.rows[0].id as string; + + // 2. advisory lock — 편집/접수 동시성 보호 + await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [ + `wi_snapshot:${companyCode}:${wiId}`, + ]); + + // 3. 접수 건수 확인 + const acceptCount = await client.query( + `SELECT COUNT(*)::int AS cnt FROM work_order_process_result wopr + JOIN work_order_process wop ON wop.id = wopr.work_order_process_id + WHERE wop.wo_id = $1 AND wop.company_code = $2 AND wopr.company_code = $2`, + [wiId, companyCode], + ); + if ((acceptCount.rows[0]?.cnt ?? 0) > 0) { + return { synced: false, affectedProcesses: 0, reason: "accepted_count > 0" }; + } + + // 4. 대상 마스터 공정 목록 + const masterQuery = routingDetailId + ? `SELECT id, routing_detail_id FROM work_order_process + WHERE wo_id = $1 AND routing_detail_id = $2 AND company_code = $3` + : `SELECT id, routing_detail_id FROM work_order_process + WHERE wo_id = $1 AND company_code = $2`; + const masterParams = routingDetailId + ? [wiId, routingDetailId, companyCode] + : [wiId, companyCode]; + const masters = await client.query(masterQuery, masterParams); + + let affected = 0; + for (const m of masters.rows) { + // 5. 기존 마스터 스냅샷 삭제 + await client.query( + `DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`, + [m.id, companyCode], + ); + // 6. 재복사 — copyChecklistToSplit 재활용 (wi_* 우선, 없으면 원본 fallback) + await copyChecklistToSplit( + client, + m.id, + m.id, + m.routing_detail_id, + companyCode, + userId, + { workInstructionNo }, + ); + affected++; + } + return { synced: true, affectedProcesses: affected }; +} + // ─── 원본 공정작업기준 -> 작업지시 전용 복사 ─── export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) { try { @@ -775,6 +850,8 @@ export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) } } + const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId); + logger.info("[work-instruction] wi_* copy 후 마스터 스냅샷 동기화", { wiNo, ...sync }); await client.query("COMMIT"); logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId }); return res.json({ success: true }); @@ -839,6 +916,8 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) } } + const sync = await syncMasterChecklistFromWi(client, wiNo, routingDetailId, companyCode, userId); + logger.info("[work-instruction] wi_* save 후 마스터 스냅샷 동기화", { wiNo, routingDetailId, ...sync }); await client.query("COMMIT"); logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId }); return res.json({ success: true }); @@ -858,6 +937,7 @@ export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) { try { const companyCode = req.user!.companyCode; + const userId = req.user!.userId; const { wiNo } = req.params; const pool = getPool(); const client = await pool.connect(); @@ -878,6 +958,8 @@ export async function resetWorkStandard(req: AuthenticatedRequest, res: Response `DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`, [wiNo, companyCode] ); + const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId); + logger.info("[work-instruction] wi_* reset 후 마스터 스냅샷 원본 복원", { wiNo, ...sync }); await client.query("COMMIT"); logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo }); return res.json({ success: true }); diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index be614604..eea9ebad 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -24,6 +24,7 @@ import { getMaterialInputs, getChecklistItems, getProcessList, + getProcessResult, } from "../controllers/popProductionController"; const router = Router(); @@ -53,5 +54,6 @@ router.post("/material-input", saveMaterialInput); router.get("/material-inputs/:processId", getMaterialInputs); router.get("/checklist-items/:processId", getChecklistItems); router.get("/processes", getProcessList); +router.get("/result/:id", getProcessResult); export default router;