feat(backend/work-instruction): re-project master checklist snapshot on wi edit

This commit is contained in:
kmh
2026-04-24 15:00:38 +09:00
parent 450f1fe9f5
commit 6656a525a2
3 changed files with 462 additions and 0 deletions

View File

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

View File

@@ -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<any> },
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 });