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]);