Restore POP batch-id separation SQL in popProductionController (B1~B10)

Re-implements the batch-id separation backend SQL that was lost during
the 2026-05-21 jskim-node merge (commit 9da6b22a). The previous re-apply
commit (15fa3e37) covered packaging/material auto-input/autoComplete
but missed the batch separation block.

Changes (B1~B10 in POP.md log):
- syncWorkInstructions: detail SELECT + generateWorkProcessesForInstruction call use detail.id instead of detail.item_number
- generateWorkProcessesForInstruction: existCheck uses batch_id = $3 OR matching item_number via subquery
- syncWorkInstructions unsynced EXISTS: matches both wid.id and wid.item_number
- getProcessList prev_good_raw: 5 inner subqueries match COALESCE(wop2.batch_id,'') = COALESCE(wop.batch_id,'') and CTE exposes wop.batch_id
- prev_good CTE: first-process fallback uses COALESCE(wid.qty, wi.qty, 0) with LEFT JOIN work_instruction_detail wid ON wid.id = pgr.batch_id
- Final SELECT: ROW_NUMBER batch_index, COUNT batch_count, wid_b.item_number batch_item_number; LEFT JOIN wid_b; ORDER BY batch keys
- getPrevProcessGoodQty: batchId param + 3 SELECTs filter COALESCE(batch_id,'') = $batchKey
- evaluatePrevProcesses: batchId param + wop_with_seq CTE adds COALESCE(wop.batch_id,'') = $4 + fetchInstructionQty prefers work_instruction_detail.qty when batchKey present
- getAvailableQty: current SELECT adds wop.batch_id and passes to evaluatePrevProcesses
- acceptProcess: master FOR UPDATE SELECT adds wop.batch_id and passes to evaluatePrevProcesses

Verification (per POP.md):
- backend npm run build PASS
- GET /api/pop/production/processes responds with batch_id/batch_index/batch_count/batch_item_number
- COMPANY_7 GUI: 25 cards, 17 with -NN (n/m) suffix; CODE-00027 shows -01..04 of 4 correctly
- No regression on single-batch (batch_id NULL) rows due to COALESCE matching pattern

Known follow-up: work-instruction edit guard (locked detail rows) — implemented in next commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kmh
2026-05-22 11:18:14 +09:00
parent a86a191a22
commit 1961385ddf
2 changed files with 102 additions and 20 deletions

View File

@@ -174,41 +174,47 @@ async function getPrevProcessGoodQty(
woId: string,
seqNum: number,
companyCode: string,
batchId?: string | null,
): Promise<number | null> {
// 1. 첫 공정 여부 판정
const batchKey = batchId ?? "";
// 1. 첫 공정 여부 판정 (같은 batch 내에서)
const minSeqCheck = await exec.query(
`SELECT MIN(CAST(seq_no AS int)) as min_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[woId, companyCode],
WHERE wo_id = $1 AND company_code = $2
AND COALESCE(batch_id, '') = $3`,
[woId, companyCode, batchKey],
);
const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum;
if (seqNum <= minSeq) {
return null; // 첫 공정
}
// 2. 앞 seq 조회
// 2. 앞 seq 조회 (같은 batch 내에서)
const prevProcessSeq = await exec.query(
`SELECT MAX(CAST(seq_no AS int)) as prev_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND COALESCE(batch_id, '') = $4
AND CAST(seq_no AS int) < $3`,
[woId, companyCode, seqNum],
[woId, companyCode, seqNum, batchKey],
);
const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq;
if (actualPrevSeq == null) {
return null;
}
// 3. 앞공정 양품 SUM
// 3. 앞공정 양품 SUM (같은 batch 내에서)
const prevAgg = await exec.query(
`SELECT COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0)
+ COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS int)), 0) as total_good
FROM work_order_process_result wr
JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code
WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3
AND COALESCE(wop.batch_id, '') = $4
AND wr.result_status IN ('draft','confirmed')`,
[woId, String(actualPrevSeq), companyCode],
[woId, String(actualPrevSeq), companyCode, batchKey],
);
return parseInt(prevAgg.rows[0].total_good, 10) || 0;
}
@@ -229,6 +235,7 @@ async function evaluatePrevProcesses(
woId: string,
currentSeq: number,
companyCode: string,
batchId?: string | null,
): Promise<{
canAccept: boolean;
blockedReason: string | null;
@@ -239,6 +246,7 @@ async function evaluatePrevProcesses(
processName: string;
}>;
}> {
const batchKey = batchId ?? "";
const sql = `
WITH wop_with_seq AS (
SELECT
@@ -250,6 +258,7 @@ async function evaluatePrevProcesses(
FROM work_order_process wop
WHERE wop.wo_id = $1::varchar AND wop.company_code = $2::varchar
AND CAST(wop.seq_no AS int) < $3::int
AND COALESCE(wop.batch_id, '') = $4::varchar
),
wr_agg AS (
SELECT
@@ -276,7 +285,7 @@ async function evaluatePrevProcesses(
ORDER BY w.seq_int DESC
`;
const result = await exec.query(sql, [woId, companyCode, currentSeq]);
const result = await exec.query(sql, [woId, companyCode, currentSeq, batchKey]);
const rows = result.rows as Array<{
id: string;
seq_int: number;
@@ -289,6 +298,16 @@ async function evaluatePrevProcesses(
}>;
const fetchInstructionQty = async (): Promise<number> => {
// batch 가 있으면 work_instruction_detail.qty 우선
if (batchKey) {
const wid = await exec.query(
`SELECT qty FROM work_instruction_detail
WHERE id = $1 AND company_code = $2`,
[batchKey, companyCode],
);
const widQty = parseInt(wid.rows[0]?.qty, 10);
if (!Number.isNaN(widQty) && widQty > 0) return widQty;
}
const wi = await exec.query(
`SELECT qty FROM work_instruction WHERE id = $1 AND company_code = $2`,
[woId, companyCode],
@@ -531,7 +550,14 @@ async function generateWorkProcessesForInstruction(
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND (batch_id = $3 OR batch_id IS NULL)`,
AND (
batch_id = $3
OR batch_id = (
SELECT item_number FROM work_instruction_detail
WHERE id = $3 AND company_code = $2
LIMIT 1
)
)`,
[workInstructionId, companyCode, batchId],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
@@ -728,7 +754,7 @@ export const syncWorkInstructions = async (
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
AND wop.batch_id = wid.item_number
AND (wop.batch_id = wid.id OR wop.batch_id = wid.item_number)
)
)
)`,
@@ -758,7 +784,7 @@ export const syncWorkInstructions = async (
for (const wi of unsynced) {
const detailResult = await pool.query(
`SELECT wid.item_number, wid.routing_version_id, wid.qty
`SELECT wid.id, wid.item_number, wid.routing_version_id, wid.qty
FROM work_instruction_detail wid
WHERE wid.work_instruction_id = $1
AND wid.routing_version_id IS NOT NULL
@@ -857,7 +883,7 @@ export const syncWorkInstructions = async (
detail.qty || null,
companyCode,
userId,
detail.item_number,
detail.id,
);
if (!result) {
@@ -2156,7 +2182,7 @@ export const getAvailableQty = async (
}
const current = await pool.query(
`SELECT wop.id, wop.seq_no, wop.wo_id,
`SELECT wop.id, wop.seq_no, wop.wo_id, wop.batch_id,
wi.qty as instruction_qty
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
@@ -2170,7 +2196,7 @@ export const getAvailableQty = async (
.json({ success: false, message: "공정을 찾을 수 없습니다." });
}
const { seq_no, wo_id, instruction_qty } = current.rows[0];
const { seq_no, wo_id, instruction_qty, batch_id: currentBatchId } = current.rows[0];
const instrQty = parseInt(instruction_qty, 10) || 0;
const seqNum = parseInt(seq_no, 10);
@@ -2189,7 +2215,13 @@ export const getAvailableQty = async (
// accept-process 와 정책 일치: 비필수 0건 자동 skip 흐름을 모달 max 에도 반영
// (getPrevProcessGoodQty 는 단순 직전 seq 만 봐서 비필수 wop 가 끼면 0 으로 잘못 잡힘)
const prevEval = await evaluatePrevProcesses(pool, wo_id, seqNum, companyCode);
const prevEval = await evaluatePrevProcesses(
pool,
wo_id,
seqNum,
companyCode,
currentBatchId,
);
const prevGoodQty = prevEval.prevGoodQty;
const availableQty = Math.max(0, prevGoodQty - myInputQty);
@@ -2295,7 +2327,7 @@ export const acceptProcess = async (
// 마스터 wop FOR UPDATE (동시 접수 race 방지)
const current = await client.query(
`SELECT wop.id, wop.seq_no, wop.wo_id,
`SELECT wop.id, wop.seq_no, wop.wo_id, wop.batch_id,
wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order,
wop.standard_time, wop.routing_detail_id,
wi.qty as instruction_qty
@@ -2355,7 +2387,13 @@ export const acceptProcess = async (
parseInt(totalAccepted.rows[0].total_input, 10) || 0;
// 앞공정 평가 (스킵/차단/접수가능량 통합)
const evalResult = await evaluatePrevProcesses(client, row.wo_id, seqNum, companyCode);
const evalResult = await evaluatePrevProcesses(
client,
row.wo_id,
seqNum,
companyCode,
row.batch_id,
);
if (!evalResult.canAccept) {
await client.query("ROLLBACK");
return res.status(400).json({
@@ -3847,6 +3885,7 @@ prev_good_raw AS (
wop.id AS wop_id,
wop.wo_id,
wop.company_code,
wop.batch_id,
(
SELECT COALESCE(SUM(CAST(NULLIF(wr2.good_qty, '') AS numeric)), 0)
+ COALESCE(SUM(CAST(NULLIF(wr2.concession_qty, '') AS numeric)), 0)
@@ -3857,6 +3896,7 @@ prev_good_raw AS (
WHERE wop2.wo_id = wop.wo_id
AND wop2.company_code = wop.company_code
AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int)
AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '')
AND EXISTS (
SELECT 1 FROM work_order_process_result wr3
WHERE wr3.wop_id = wop2.id AND wr3.company_code = wop2.company_code
@@ -3870,6 +3910,7 @@ prev_good_raw AS (
WHERE wop2.wo_id = wop.wo_id
AND wop2.company_code = wop.company_code
AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int)
AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '')
AND COALESCE(wop2.is_required, '') = 'Y'
) AS has_prior_required,
EXISTS (
@@ -3879,6 +3920,7 @@ prev_good_raw AS (
WHERE wop2.wo_id = wop.wo_id
AND wop2.company_code = wop.company_code
AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int)
AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '')
) AS has_prior_wr,
EXISTS (
SELECT 1 FROM work_order_process wop2
@@ -3887,6 +3929,7 @@ prev_good_raw AS (
WHERE wop2.wo_id = wop.wo_id
AND wop2.company_code = wop.company_code
AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int)
AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '')
AND COALESCE(wop2.is_required, '') <> 'Y'
AND wr2.result_status NOT IN ('confirmed','skipped')
AND COALESCE(CAST(NULLIF(wr2.input_qty, '') AS numeric), 0) > 0
@@ -3896,6 +3939,7 @@ prev_good_raw AS (
WHERE wop2.wo_id = wop.wo_id
AND wop2.company_code = wop.company_code
AND CAST(wop2.seq_no AS int) < CAST(wop.seq_no AS int)
AND COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '')
AND COALESCE(wop2.is_required, '') = 'Y'
AND NOT EXISTS (
SELECT 1 FROM work_order_process_result wr2
@@ -3913,11 +3957,17 @@ prev_good AS (
WHEN pgr.has_unfinished_required_prior THEN 0
WHEN pgr.has_prior_wr THEN COALESCE(pgr.prev_good_qty_raw, 0)
WHEN pgr.has_prior_required THEN 0
ELSE COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0)
ELSE COALESCE(
CAST(NULLIF(wid.qty, '') AS numeric),
CAST(NULLIF(wi.qty, '') AS numeric),
0
)
END AS prev_good_qty
FROM prev_good_raw pgr
LEFT JOIN work_instruction wi
ON wi.id = pgr.wo_id AND wi.company_code = pgr.company_code
LEFT JOIN work_instruction_detail wid
ON wid.id = pgr.batch_id AND wid.company_code = pgr.company_code
),
accepted_results AS (
SELECT wop_id,
@@ -3977,12 +4027,21 @@ SELECT
COALESCE(pg.prev_good_qty, 0) AS prev_good_qty,
COALESCE(wa.sum_input_norework, 0) AS my_input_qty,
GREATEST(0, COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0)) AS available_qty,
COALESCE(ar.accepted_results, '[]'::json) AS accepted_results
COALESCE(ar.accepted_results, '[]'::json) AS accepted_results,
ROW_NUMBER() OVER (
PARTITION BY wop.wo_id, wop.process_code
ORDER BY wid_b.created_date NULLS LAST, wop.batch_id NULLS LAST, wop.id
) AS batch_index,
COUNT(*) OVER (PARTITION BY wop.wo_id, wop.process_code) AS batch_count,
wid_b.item_number AS batch_item_number
FROM wop
LEFT JOIN wr_agg wa ON wa.wop_id = wop.id
LEFT JOIN prev_good pg ON pg.wop_id = wop.id
LEFT JOIN accepted_results ar ON ar.wop_id = wop.id
ORDER BY wop.wo_id, CAST(wop.seq_no AS int)
LEFT JOIN work_instruction_detail wid_b
ON wid_b.id = wop.batch_id AND wid_b.company_code = wop.company_code
ORDER BY wop.wo_id, CAST(wop.seq_no AS int),
wid_b.created_date NULLS LAST, wop.batch_id NULLS LAST, wop.id
`;
const result = await pool.query(sql, [companyCode]);

View File

@@ -185,6 +185,29 @@ frontend/app/(main)/COMPANY_8/pop/ <- 업체별 커스터마이징 자유
## 작업 로그
### 2026-05-22 — POP 공정실행 batch 카드 분리 백엔드 SQL 복원 (popProductionController 단일 파일)
- **배경**: 2026-05-21 jskim-node 머지(9da6b22a) 시 popProductionController.ts 충돌 → mhkim 워킹트리에만 있던 2026-05-13 batch 분리 백엔드 작업(B1~B10)이 commit 누락 상태로 손실. 5/22 복원 커밋 15fa3e37 은 packaging/material auto-input/autoComplete 만 복원하고 batch 분리는 누락. 결과: API 응답에 `batch_id`/`batch_index`/`batch_count`/`batch_item_number` 0 노출 → 카드 헤더의 `-NN (n/m)` suffix 사라짐(프론트 코드는 그대로 살아있어 batch_count<=1 분기로 fallback). git 전체 ref + reflog + dangling 47개 + stash 2개 풀스캔으로 batch SQL 코드가 git 어디에도 없음 확정.
- **대상**: `backend-node/src/controllers/popProductionController.ts` 1 파일. 프론트(types.ts/WorkOrderList.tsx)는 5/13 커밋이 살아있어 추가 변경 없음.
- **복원 변경 (POP.md 2026-05-13 spec 그대로 재구현)**:
- **B1** `syncWorkInstructions` detail SELECT 에 `wid.id` 추가 + `generateWorkProcessesForInstruction(..., detail.item_number)``detail.id` 로 교체
- **B2** `existCheck` (batchId 분기) — `OR batch_id IS NULL` 제거하고 `batch_id = $3 OR batch_id = (SELECT item_number FROM work_instruction_detail WHERE id = $3 ...)` 호환 매칭
- **B3** `unsyncedResult` EXISTS — `wop.batch_id = wid.item_number``(wop.batch_id = wid.id OR wop.batch_id = wid.item_number)` 호환
- **B4** `getProcessList` `prev_good_raw` — 5개 inner subquery(메인 prev_good_qty_raw + 4개 EXISTS) 전부에 `COALESCE(wop2.batch_id, '') = COALESCE(wop.batch_id, '')` 매칭 추가, CTE 출력에 `wop.batch_id` 컬럼 추가
- **B5** `prev_good` CTE — 첫 공정 fallback `ELSE COALESCE(wid.qty, wi.qty, 0)` 로 교체, `LEFT JOIN work_instruction_detail wid ON wid.id = pgr.batch_id` 추가 (batch 별 detail.qty 우선)
- **B6** 최종 SELECT — `ROW_NUMBER() OVER (PARTITION BY wop.wo_id, wop.process_code ORDER BY wid_b.created_date NULLS LAST, wop.batch_id NULLS LAST, wop.id) AS batch_index`, `COUNT(*) OVER (PARTITION BY wop.wo_id, wop.process_code) AS batch_count`, `wid_b.item_number AS batch_item_number` 추가. FROM 절에 `LEFT JOIN work_instruction_detail wid_b ON wid_b.id = wop.batch_id` 추가, ORDER BY 에 batch 보조키 추가
- **B7** `getPrevProcessGoodQty``batchId` 인자 추가, 3개 SELECT (minSeqCheck/prevProcessSeq/prevAgg) 전부에 `COALESCE(batch_id, '') = $batchKey` 추가
- **B8** `evaluatePrevProcesses``batchId` 인자 추가, `wop_with_seq` CTE 에 `AND COALESCE(wop.batch_id, '') = $4::varchar` 추가, `fetchInstructionQty` 안에서 batchKey 있을 때 `work_instruction_detail.qty` 우선 lookup
- **B9** `getAvailableQty` — current SELECT 에 `wop.batch_id` 추가 → `evaluatePrevProcesses(..., currentBatchId)` 전달
- **B10** `acceptProcess` 마스터 FOR UPDATE SELECT 에 `wop.batch_id` 추가 → `evaluatePrevProcesses(..., row.batch_id)` 전달
- **검증**:
- backend `npm run build` PASS
- GET `/api/pop/production/processes` 응답에 4개 batch 키(`batch_id`/`batch_index`/`batch_count`/`batch_item_number`) 정상 노출, 1093 row 중 multi-batch 그룹(`wo_id + process_code`) 6+ 건 확인 (예: wo `12e3ebc6` x P001 = 4 rows / `ab1af887` x P001 = 7 rows)
- GUI(COMPANY_7 / 제조반_계량): 25개 카드 중 17개에 `-NN (n/m)` suffix 노출. CODE-00027 4장 모두 `-01 (1/4) / -02 (2/4) / -03 (3/4) / -04 (4/4)` 정상. 단일 batch 카드(`CODE-00016` 등) suffix 없음(정상). 콘솔 에러 0 (페이지 동작 무관 prefetch 401만)
- **회귀 영향 없음**: 기존 단일 wop 작업지시(batch_id=null)는 `COALESCE(batch_id,'') = COALESCE(batch_id,'')` 매칭으로 자기 자신만 매칭 → 기존 동작 보존. `wid_b LEFT JOIN` 실패 시 `batch_item_number=null` 이지만 ROW_NUMBER/COUNT 는 wo+process 묶음으로 매겨져 라벨 출력은 정상
- **알려진 미해결 (5/13 spec 과 동일)**:
- 작업지시 수정 케이스 (`workInstructionController.ts:292`): detail DELETE 후 재 INSERT 시 wop.batch_id 가 옛 detail.id 가리키는 고아 발생 가능 — 본 작업 범위 외
- `getAvailableQty` 리워크 분기는 batch_id 미적용
### 2026-05-21 (5차) — POP 공정작업 O/X 유형 버튼 라벨 변경 (COMPANY_7만)
- **배경**: 검사정보관리에 등록된 `judgment_criteria=CAT_JC_03` (O/X) 유형 검사기준이 POP 공정 작업 화면에서 "합격/불합격" 두 버튼으로 렌더되고 있었음. 사용자 요청: O/X 유형은 "O"/"X" 버튼 라벨로 표기.
- **대상**: `_components/production/ProcessWork.tsx` 1 파일 (line 3033, 3043)