Files
vexplor/backend-node/src/controllers/popProductionController.ts
kjs 17721af30b Merge branch 'mhkim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node
충돌 해결: COMPANY_9 ProcessWork.tsx — checklist 조회를 mhkim-node 방식(process_work_result + process_work_inspection_result 통합)으로 채택, judgment_criteria 중복 선언 정리 (TASK:ERP-103 O/X 분기 렌더링 유지)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:40:43 +09:00

4410 lines
138 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Response } from "express";
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";
import {
ensureLoadingInstance,
insertPackagingRows,
} from "../services/transactionPackagingService";
/**
* 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 {
defect_code: string;
defect_name: string;
qty: string;
disposition: string;
}
// 자동 마이그레이션: batch_id 컬럼 추가 (배치/로트 추적용)
let _batchMigrationDone = false;
async function ensureBatchIdColumn() {
if (_batchMigrationDone) return;
try {
const pool = getPool();
await pool.query(
"ALTER TABLE work_order_process ADD COLUMN IF NOT EXISTS batch_id VARCHAR(100)",
);
_batchMigrationDone = true;
} catch {
/* 이미 존재하거나 권한 문제 시 무시 */
}
}
/**
* inventory_stock UPSERT 공통 함수
* PC의 receivingController와 동일한 SELECT→INSERT/UPDATE 패턴 사용.
* (inventory_stock에 UNIQUE 제약조건이 없으므로 ON CONFLICT 사용 불가)
*/
interface InventoryHistoryOptions {
userName: string;
source: "auto_cascade" | "manual_inbound" | "quick_inbound";
woId?: string;
workOrderProcessResultId?: string;
referenceNumber?: string;
}
async function upsertInventoryStock(
client: { query: (text: string, values?: any[]) => Promise<any> },
companyCode: string,
itemCode: string,
warehouseCode: string,
locationCode: string | null,
qty: number,
userId: string,
historyOptions?: InventoryHistoryOptions,
): Promise<void> {
const whCode = warehouseCode || null;
const locCode = locationCode || null;
const qtyNum = Number(qty) || 0;
const existing = await client.query(
`SELECT id, current_qty FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(warehouse_code, '') = COALESCE($3, '')
AND COALESCE(location_code, '') = COALESCE($4, '')
LIMIT 1`,
[companyCode, itemCode, whCode || "", locCode || ""],
);
let stockId: string;
let balanceAfter: number;
if (existing.rows.length > 0) {
stockId = existing.rows[0].id;
const prev = parseFloat(existing.rows[0].current_qty || "0") || 0;
balanceAfter = prev + qtyNum;
await client.query(
`UPDATE inventory_stock
SET current_qty = CAST(COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) + $1 AS text),
last_in_date = NOW(),
updated_date = NOW(),
writer = $2
WHERE id = $3`,
[qty, userId, stockId],
);
} else {
const inserted = await client.query(
`INSERT INTO inventory_stock (
id, company_code, item_code, warehouse_code, location_code,
current_qty, safety_qty, last_in_date,
created_date, updated_date, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, '0', NOW(), NOW(), NOW(), $6)
RETURNING id`,
[companyCode, itemCode, whCode, locCode, String(qtyNum), userId],
);
stockId = inserted.rows[0].id;
balanceAfter = qtyNum;
}
if (historyOptions) {
const remarkJson = JSON.stringify({
type: "process_inbound",
source: historyOptions.source,
wo_id: historyOptions.woId ?? null,
work_order_process_result_id: historyOptions.workOrderProcessResultId ?? null,
qty: qtyNum,
});
await client.query(
`INSERT INTO inventory_history (
id, company_code, stock_id, item_code, warehouse_code, location_code,
transaction_type, transaction_date, quantity, balance_qty,
reference_type, reference_id, reference_number,
remark, writer, manager_id, manager_name, created_date
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4, $5,
'입고', NOW(), $6, $7,
$8, $9, $10,
$11, $12, $12, $13, NOW()
)`,
[
companyCode,
stockId,
itemCode,
whCode,
locCode,
String(qtyNum),
String(balanceAfter),
historyOptions.workOrderProcessResultId ? "work_order_process_result" : null,
historyOptions.workOrderProcessResultId ?? null,
historyOptions.referenceNumber ?? null,
remarkJson,
userId,
historyOptions.userName,
],
);
}
}
/**
* 앞공정 양품 합계 조회 헬퍼
* - 첫 공정이면 null 반환 (호출자가 instruction_qty 사용)
* - 앞공정이 있으면 SUM(good_qty + concession_qty) 반환
*/
async function getPrevProcessGoodQty(
exec: Pool | PoolClient,
woId: string,
seqNum: number,
companyCode: string,
batchId?: string | null,
): Promise<number | null> {
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
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 조회 (같은 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, batchKey],
);
const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq;
if (actualPrevSeq == null) {
return null;
}
// 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, batchKey],
);
return parseInt(prevAgg.rows[0].total_good, 10) || 0;
}
/**
* 앞공정 평가 (스킵/차단/접수가능량 통합 판정)
*
* 정책:
* - 비필수 공정(is_required != 'Y')이 진행 중(input_qty>0 AND result_status NOT IN ('confirmed','skipped'))이면 접수 차단
* - 비필수 공정 wop_result 0건은 자동 skip 대상
* - 비필수 공정이 모두 confirmed/skipped 이면 정상 흐름
* - prevGoodQty = currentSeq 미만 wop 중 wop_result 가 있는 wop 의 최대 seq 양품(good+concession).
* 없고 미만 wop 에 필수가 있으면 0 (직전 필수 미처리 비정상 상태).
* 미만 wop 이 모두 비필수 0건이면 work_instruction.qty (스킵 흐름).
*/
async function evaluatePrevProcesses(
exec: Pool | PoolClient,
woId: string,
currentSeq: number,
companyCode: string,
batchId?: string | null,
): Promise<{
canAccept: boolean;
blockedReason: string | null;
prevGoodQty: number;
skipTargets: Array<{
wopId: string;
processCode: string;
processName: string;
}>;
}> {
const batchKey = batchId ?? "";
const sql = `
WITH wop_with_seq AS (
SELECT
wop.id,
CAST(wop.seq_no AS int) AS seq_int,
wop.process_code,
wop.process_name,
COALESCE(wop.is_required, '') AS is_req
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
wr.wop_id,
COUNT(*) AS row_count,
BOOL_OR(
wr.result_status NOT IN ('confirmed','skipped')
AND COALESCE(CAST(NULLIF(wr.input_qty, '') AS int), 0) > 0
) AS has_inprogress,
COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0)
+ COALESCE(SUM(CAST(NULLIF(wr.concession_qty, '') AS int)), 0) AS sum_good
FROM work_order_process_result wr
JOIN wop_with_seq w ON w.id = wr.wop_id
WHERE wr.company_code = $2
GROUP BY wr.wop_id
)
SELECT
w.id, w.seq_int, w.process_code, w.process_name, w.is_req,
COALESCE(wa.row_count, 0) AS row_count,
COALESCE(wa.has_inprogress, false) AS has_inprogress,
COALESCE(wa.sum_good, 0) AS sum_good
FROM wop_with_seq w
LEFT JOIN wr_agg wa ON wa.wop_id = w.id
ORDER BY w.seq_int DESC
`;
const result = await exec.query(sql, [woId, companyCode, currentSeq, batchKey]);
const rows = result.rows as Array<{
id: string;
seq_int: number;
process_code: string;
process_name: string;
is_req: string;
row_count: string | number;
has_inprogress: boolean;
sum_good: string | number;
}>;
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],
);
return parseInt(wi.rows[0]?.qty, 10) || 0;
};
if (rows.length === 0) {
return {
canAccept: true,
blockedReason: null,
prevGoodQty: await fetchInstructionQty(),
skipTargets: [],
};
}
for (const r of rows) {
// 필수 공정 미처리 차단 (점프 방지) — 진행 중 차단은 제거(양품이 있으면 다음 공정에서 수령 가능)
if (r.is_req === "Y" && Number(r.row_count) === 0) {
return {
canAccept: false,
blockedReason: `필수 공정 ${r.process_name} 미처리`,
prevGoodQty: 0,
skipTargets: [],
};
}
}
const skipTargets = rows
.filter((r) => r.is_req !== "Y" && Number(r.row_count) === 0)
.map((r) => ({
wopId: r.id,
processCode: r.process_code,
processName: r.process_name,
}));
const withRows = rows.filter((r) => Number(r.row_count) > 0);
let prevGoodQty: number;
if (withRows.length > 0) {
prevGoodQty = parseInt(String(withRows[0].sum_good), 10) || 0;
} else if (rows.some((r) => r.is_req === "Y")) {
prevGoodQty = 0;
} else {
prevGoodQty = await fetchInstructionQty();
}
return {
canAccept: true,
blockedReason: null,
prevGoodQty,
skipTargets,
};
}
/**
* work_instruction 헤더 필드를 COALESCE 방식으로 업데이트하는 공통 헬퍼
* syncWorkInstructions 내 header_routing 단일 경로와 detailRows 다중 경로에서 공용
*/
async function updateWorkInstructionHeader(
exec: Pool | PoolClient,
wiId: string,
companyCode: string,
routing: string | null,
qty: string | number | null,
itemNumber: string | null,
): Promise<void> {
await exec.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wiId, routing, qty != null ? String(qty) : null, itemNumber, companyCode],
);
}
/**
* 체크리스트 복사 공통 함수
* 접수 카드(work_order_process_result) 생성 시 마스터 공정의 체크리스트 템플릿을
* 새 접수 row에 복사한다.
*
* 주의: process_work_result.work_order_process_id 컬럼은
* - 마스터 체크리스트(routing 스냅샷): work_order_process.id 저장 (createWorkProcesses에서 1회)
* - 접수 카드 체크리스트: work_order_process_result.id 저장 (acceptProcess 호출 시)
* 컬럼명은 DB 스키마상 변경 금지.
*
* 전략: routingDetailId가 있으면 원본 템플릿에서, 없으면 마스터의 기존 체크리스트에서 복사
*
* options:
* - workInstructionNo: 주어지고 wi_process_work_item 에 (wiNo, routingDetailId) row 가 있으면
* 원본(process_work_item) 대신 wi_* 템플릿에서 복사 (작업지시별 커스텀 우선)
* - skipAStrategy: true 면 A 전략(템플릿 복사)을 건너뛰고 바로 B 전략(마스터 스냅샷 복사)으로 진입
* (접수 시 마스터 스냅샷 일관성 보장용)
*/
export async function copyChecklistToSplit(
client: { query: (text: string, values?: any[]) => Promise<any> },
masterProcessId: string,
wopResultId: string,
routingDetailId: string | null,
companyCode: string,
userId: string,
options?: { workInstructionNo?: string; skipAStrategy?: boolean },
): Promise<number> {
// A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사
// 단, options.skipAStrategy === true 이면 A 전략 전체를 건너뛰고 B 로 진입
if (routingDetailId && options?.skipAStrategy !== true) {
const wiNo = options?.workInstructionNo;
let wiExists = false;
if (wiNo) {
const wiCheck = await client.query(
`SELECT 1 FROM wi_process_work_item
WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3
LIMIT 1`,
[wiNo, routingDetailId, companyCode],
);
wiExists = (wiCheck.rowCount ?? 0) > 0;
}
let countA = 0;
if (wiNo && wiExists) {
// A-1. wi_* (작업지시 커스텀 템플릿) 에서 복사
const result = await client.query(
`INSERT INTO process_work_result (
id, company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
gen_random_uuid()::text, wi.company_code, $1,
wi.id, wid.id,
wi.work_phase, wi.title, wi.sort_order::text,
wid.content, wid.detail_type, wid.sort_order::text, wid.is_required,
wid.inspection_code, wid.inspection_method, wid.unit, wid.lower_limit, wid.upper_limit,
wid.input_type, wid.lookup_target, wid.display_fields, wid.duration_minutes::text,
'pending', $2
FROM wi_process_work_item wi
JOIN wi_process_work_item_detail wid
ON wid.wi_work_item_id = wi.id AND wid.company_code = wi.company_code
WHERE wi.work_instruction_no = $5
AND wi.routing_detail_id = $3
AND wi.company_code = $4
ORDER BY wi.sort_order::int, wid.sort_order::int`,
[wopResultId, userId, routingDetailId, companyCode, wiNo],
);
countA = result.rowCount ?? 0;
} else {
// A-2. 원본 템플릿(process_work_item) 에서 복사 (기존 동작)
const result = await client.query(
`INSERT INTO process_work_result (
id, company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
gen_random_uuid()::text, pwi.company_code, $1,
pwi.id, pwd.id,
pwi.work_phase, pwi.title, pwi.sort_order::text,
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
'pending', $2
FROM process_work_item pwi
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
AND pwd.company_code = pwi.company_code
WHERE pwi.routing_detail_id = $3
AND pwi.company_code = $4
ORDER BY pwi.sort_order, pwd.sort_order`,
[wopResultId, userId, routingDetailId, companyCode],
);
countA = result.rowCount ?? 0;
}
if (countA > 0) return countA;
// A 전략에서 0건이면 B 전략(마스터 wop의 체크리스트 복사)으로 fallthrough
}
// B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 wop의 process_work_result 구조 복사
const result = await client.query(
`INSERT INTO process_work_result (
id, company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
gen_random_uuid()::text, company_code, $1,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
'pending', $2
FROM process_work_result
WHERE work_order_process_id = $3
AND company_code = $4
ORDER BY item_sort_order, detail_sort_order`,
[wopResultId, userId, masterProcessId, companyCode],
);
return result.rowCount ?? 0;
}
/**
* 내부 헬퍼: 단일 작업지시에 대해 work_order_process(마스터) + process_work_result(체크리스트 스냅샷) 생성
*/
async function generateWorkProcessesForInstruction(
client: { query: (text: string, values?: any[]) => Promise<any> },
workInstructionId: string,
routingVersionId: string,
planQty: string | null,
companyCode: string,
userId: string,
batchId?: string | null,
): Promise<{
processes: Array<{
id: string;
seq_no: string;
process_name: string;
checklist_count: number;
}>;
total_checklists: number;
} | null> {
await client.query(
`SELECT pg_advisory_xact_lock(hashtext($1))`,
[`wop_sync:${companyCode}:${workInstructionId}:${batchId || ""}`],
);
if (batchId) {
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 = (
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) {
return null;
}
} else {
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[workInstructionId, companyCode],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null;
}
}
const routingDetails = await client.query(
`SELECT rd.id, rd.seq_no, rd.process_code,
COALESCE(pm.process_name, rd.process_code) as process_name,
rd.is_required, rd.is_fixed_order, rd.standard_time
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON pm.process_code = rd.process_code
AND pm.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY CAST(rd.seq_no AS int) NULLS LAST`,
[routingVersionId, companyCode],
);
if (routingDetails.rows.length === 0) {
return null;
}
// 작업지시번호 1회 조회 — copyChecklistToSplit 에 전달하여 wi_* 커스텀 템플릿 우선 적용
const wiRow = await client.query(
`SELECT work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`,
[workInstructionId, companyCode],
);
const workInstructionNo = wiRow.rows[0]?.work_instruction_no as string | undefined;
const processes: Array<{
id: string;
seq_no: string;
process_name: string;
checklist_count: number;
}> = [];
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
const wopResult = await client.query(
`INSERT INTO work_order_process (
id, company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
routing_detail_id, batch_id, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id`,
[
companyCode,
workInstructionId,
rd.seq_no,
rd.process_code,
rd.process_name,
rd.is_required,
rd.is_fixed_order,
rd.standard_time,
planQty || null,
rd.id,
batchId || null,
userId,
],
);
const wopId = wopResult.rows[0].id;
const checklistCount = await copyChecklistToSplit(
client,
wopId,
wopId,
rd.id,
companyCode,
userId,
{ workInstructionNo },
);
totalChecklists += checklistCount;
processes.push({
id: wopId,
seq_no: rd.seq_no,
process_name: rd.process_name,
checklist_count: checklistCount,
});
}
return { processes, total_checklists: totalChecklists };
}
/**
* 작업지시 공정 일괄 생성
*/
export const createWorkProcesses = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_instruction_id, item_code, routing_version_id, plan_qty } =
req.body;
if (!work_instruction_id || !routing_version_id) {
return res.status(400).json({
success: false,
message: "work_instruction_id와 routing_version_id는 필수입니다.",
});
}
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
work_instruction_id,
routing_version_id,
plan_qty,
companyCode,
userId,
item_code || null,
);
if (!result) {
await client.query("ROLLBACK");
return res.status(409).json({
success: false,
message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.",
});
}
await client.query("COMMIT");
return res.json({
success: true,
data: {
processes: result.processes,
total_processes: result.processes.length,
total_checklists: result.total_checklists,
},
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("[pop/production] create-work-processes 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "공정 생성 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
};
/**
* POP 온디맨드 Pull: 미동기화 작업지시 일괄 sync
*/
export const syncWorkInstructions = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
await ensureBatchIdColumn();
const unsyncedResult = await pool.query(
`SELECT wi.id, wi.work_instruction_no,
wi.routing AS header_routing,
wi.qty AS header_qty,
wi.item_id AS header_item_id
FROM work_instruction wi
WHERE wi.company_code = $1
AND (
(wi.routing IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
))
OR
EXISTS (
SELECT 1 FROM work_instruction_detail wid
WHERE wid.work_instruction_id = wi.id
AND wid.routing_version_id IS NOT NULL
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
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.id OR wop.batch_id = wid.item_number)
)
)
)`,
[companyCode],
);
const unsynced = unsyncedResult.rows;
if (unsynced.length === 0) {
return res.json({
success: true,
data: { synced: 0, skipped: 0, errors: 0, details: [] },
});
}
let synced = 0;
let skipped = 0;
let errors = 0;
const details: Array<{
work_instruction_id: string;
work_instruction_no: string;
item_number?: string;
status: "synced" | "skipped" | "error";
process_count?: number;
error?: string;
}> = [];
for (const wi of unsynced) {
const detailResult = await pool.query(
`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
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
ORDER BY wid.created_date ASC`,
[wi.id],
);
const detailRows = detailResult.rows;
if (detailRows.length === 0 && wi.header_routing) {
const firstDetail = await pool.query(
`SELECT routing_version_id, qty, item_number
FROM work_instruction_detail
WHERE work_instruction_id = $1
LIMIT 1`,
[wi.id],
);
const wid = firstDetail.rows[0];
if (wid) {
await updateWorkInstructionHeader(
pool,
wi.id,
companyCode,
wid.routing_version_id,
wid.qty,
wid.item_number,
);
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
wi.header_routing,
wi.header_qty || null,
companyCode,
userId,
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "skipped",
});
} else {
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "synced",
process_count: result.processes.length,
});
}
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "error",
error: err.message || "알 수 없는 오류",
});
} finally {
client.release();
}
continue;
}
if (detailRows.length > 0) {
const first = detailRows[0];
await updateWorkInstructionHeader(
pool,
wi.id,
companyCode,
first.routing_version_id,
first.qty,
first.item_number,
);
}
for (const detail of detailRows) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
detail.routing_version_id,
detail.qty || null,
companyCode,
userId,
detail.id,
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "skipped",
});
continue;
}
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "synced",
process_count: result.processes.length,
});
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "error",
error: err.message || "알 수 없는 오류",
});
} finally {
client.release();
}
}
}
return res.json({
success: true,
data: { synced, skipped, errors, details },
});
} catch (error: any) {
logger.error("[pop/production] sync-work-instructions 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "작업지시 동기화 중 오류가 발생했습니다.",
});
}
};
/**
* 타이머 API — work_order_process_result 기준.
* 요청 본문 work_order_process_id 는 work_order_process_result.id
*/
export const controlTimer = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, action } = req.body;
if (!work_order_process_id || !action) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 action은 필수입니다.",
});
}
if (!["start", "pause", "resume", "complete"].includes(action)) {
return res.status(400).json({
success: false,
message: "action은 start, pause, resume, complete 중 하나여야 합니다.",
});
}
let result;
switch (action) {
case "start":
result = await pool.query(
`UPDATE work_order_process_result
SET started_at = CASE WHEN started_at IS NULL THEN NOW()::text ELSE started_at END,
status = CASE WHEN status = 'waiting' THEN 'in_progress' ELSE status END,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, started_at, status`,
[work_order_process_id, companyCode],
);
break;
case "pause":
result = await pool.query(
`UPDATE work_order_process_result
SET paused_at = NOW()::text,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND paused_at IS NULL
RETURNING id, paused_at`,
[work_order_process_id, companyCode],
);
break;
case "resume":
result = await pool.query(
`UPDATE work_order_process_result
SET total_paused_time = (
COALESCE(total_paused_time::int, 0)
+ EXTRACT(EPOCH FROM NOW() - paused_at::timestamp)::int
)::text,
paused_at = NULL,
updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND paused_at IS NOT NULL
RETURNING id, total_paused_time`,
[work_order_process_id, companyCode],
);
break;
case "complete": {
const { good_qty, defect_qty } = req.body;
const groupSumResult = await pool.query(
`SELECT COALESCE(SUM(
CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN
EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int
- COALESCE(group_total_paused_time::int, 0)
ELSE 0 END
), 0)::text AS total_work_seconds
FROM process_work_result
WHERE work_order_process_id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
const calculatedWorkTime =
groupSumResult.rows[0]?.total_work_seconds || "0";
result = await pool.query(
`UPDATE work_order_process_result
SET status = 'completed',
completed_at = NOW()::text,
completed_by = $3,
actual_work_time = $4,
good_qty = COALESCE($5, good_qty),
defect_qty = COALESCE($6, defect_qty),
paused_at = NULL,
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, wop_id, equipment_code`,
[
work_order_process_id,
companyCode,
userId,
calculatedWorkTime,
good_qty || null,
defect_qty || null,
],
);
break;
}
}
if (!result || result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "대상 접수 카드를 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
});
}
// [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],
});
} catch (error: any) {
logger.error("[pop/production] timer 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "타이머 처리 중 오류가 발생했습니다.",
});
}
};
/**
* Phase/그룹 타이머 제어
* process_work_result.group_* 컬럼을 phase 단위로 공유.
* complete 액션에서 wop_result.actual_work_time 합산 갱신.
*
* Body:
* - phase (optional): 있으면 해당 phase 내 모든 row 를 동일하게 업데이트
* - item_id (optional): phase 가 없을 때만 단일 row 대상 (backward-compat)
* - work_order_process_id (required with phase, optional with item_id)
* - action: start / pause / resume / complete
*/
export const controlGroupTimer = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { item_id, work_order_process_id, action, phase } = req.body;
if (!action) {
return res.status(400).json({
success: false,
message: "action은 필수입니다.",
});
}
if (!phase && !item_id) {
return res.status(400).json({
success: false,
message: "phase 또는 item_id 중 하나는 필수입니다.",
});
}
if (phase && !work_order_process_id) {
return res.status(400).json({
success: false,
message: "phase 모드에서는 work_order_process_id 가 필수입니다.",
});
}
if (!["start", "pause", "resume", "complete"].includes(action)) {
return res.status(400).json({
success: false,
message: "action은 start, pause, resume, complete 중 하나여야 합니다.",
});
}
// phase 모드: phase 내 모든 row 에 동일값 반영 / fallback: item_id 단일 row
const whereClause = phase
? `work_order_process_id = $1 AND work_phase = $2 AND company_code = $3`
: `id = $1 AND company_code = $2`;
const baseParams: unknown[] = phase
? [work_order_process_id, phase, companyCode]
: [item_id, companyCode];
let result;
switch (action) {
case "start":
result = await pool.query(
`UPDATE process_work_result
SET group_started_at = CASE WHEN group_started_at IS NULL THEN NOW()::text ELSE group_started_at END,
updated_date = NOW()
WHERE ${whereClause}
RETURNING id, group_started_at`,
baseParams,
);
if (work_order_process_id) {
await pool.query(
`UPDATE work_order_process_result
SET started_at = NOW()::text, updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND started_at IS NULL`,
[work_order_process_id, companyCode],
);
}
break;
case "pause":
result = await pool.query(
`UPDATE process_work_result
SET group_paused_at = NOW()::text,
updated_date = NOW()
WHERE ${whereClause} AND group_paused_at IS NULL
RETURNING id, group_paused_at`,
baseParams,
);
break;
case "resume":
result = await pool.query(
`UPDATE process_work_result
SET group_total_paused_time = (
COALESCE(group_total_paused_time::int, 0)
+ EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int
)::text,
group_paused_at = NULL,
updated_date = NOW()
WHERE ${whereClause} AND group_paused_at IS NOT NULL
RETURNING id, group_total_paused_time`,
baseParams,
);
break;
case "complete": {
result = await pool.query(
`UPDATE process_work_result
SET group_completed_at = NOW()::text,
group_total_paused_time = CASE
WHEN group_paused_at IS NOT NULL THEN (
COALESCE(group_total_paused_time::int, 0)
+ EXTRACT(EPOCH FROM NOW() - group_paused_at::timestamp)::int
)::text
ELSE group_total_paused_time
END,
group_paused_at = NULL,
updated_date = NOW()
WHERE ${whereClause}
RETURNING id, group_started_at, group_completed_at, group_total_paused_time`,
baseParams,
);
// 접수 카드(wop_result) 의 actual_work_time 합산 갱신
// phase 내 모든 row 가 동일값을 가지므로 phase 별 MAX 로 대표값 추출 후 SUM
if (work_order_process_id) {
await pool.query(
`UPDATE work_order_process_result wr
SET actual_work_time = sub.total_work_seconds::text,
updated_date = NOW()
FROM (
SELECT COALESCE(SUM(phase_seconds), 0) AS total_work_seconds
FROM (
SELECT work_phase,
MAX(
CASE WHEN group_started_at IS NOT NULL AND group_completed_at IS NOT NULL THEN
EXTRACT(EPOCH FROM group_completed_at::timestamp - group_started_at::timestamp)::int
- COALESCE(group_total_paused_time::int, 0)
ELSE 0 END
) AS phase_seconds
FROM process_work_result
WHERE work_order_process_id = $1 AND company_code = $2
GROUP BY work_phase
) p
) sub
WHERE wr.id = $1 AND wr.company_code = $2`,
[work_order_process_id, companyCode],
);
}
break;
}
}
if (!result || result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "대상 항목을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
});
}
return res.json({
success: true,
data: result.rows[0],
affectedRows: result.rowCount,
});
} catch (error: any) {
logger.error("[pop/production] group-timer 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "그룹 타이머 처리 중 오류가 발생했습니다.",
});
}
};
/**
* 불량 유형 목록 조회
* - defect_standard_mng 중 inspection_type 에 공정검사(CAT_MMEBA4LJ_UFJ9) 가 포함된 활성 행 반환
* - inspection_type 은 콤마 구분 다중값으로 저장됨
* - is_active 는 카테고리 코드(CAT_DA_01='사용')로 저장됨
*/
const PROCESS_INSPECTION_CODE = "CAT_MMEBA4LJ_UFJ9";
const ACTIVE_CODE = "CAT_DA_01";
export const getDefectTypes = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
let query: string;
let params: unknown[];
if (companyCode === "*") {
query = `
SELECT id, defect_code, defect_name, defect_type, severity, company_code
FROM defect_standard_mng
WHERE is_active = $1
AND (',' || COALESCE(inspection_type, '') || ',') LIKE '%,' || $2 || ',%'
ORDER BY defect_code`;
params = [ACTIVE_CODE, PROCESS_INSPECTION_CODE];
} else {
query = `
SELECT id, defect_code, defect_name, defect_type, severity, company_code
FROM defect_standard_mng
WHERE is_active = $1
AND company_code = $2
AND (',' || COALESCE(inspection_type, '') || ',') LIKE '%,' || $3 || ',%'
ORDER BY defect_code`;
params = [ACTIVE_CODE, companyCode, PROCESS_INSPECTION_CODE];
}
const result = await pool.query(query, params);
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("[pop/production] defect-types 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "불량 유형 조회 중 오류가 발생했습니다.",
});
}
};
/**
* 실적 저장 (누적 방식) — work_order_process_result UPDATE
* work_order_process_id 는 work_order_process_result.id
*/
export const saveResult = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
work_order_process_id,
production_qty,
good_qty,
defect_qty,
defect_detail,
result_note,
material_inputs,
} = req.body;
// validation: BEGIN 이전에 처리
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
if (!production_qty || parseInt(production_qty, 10) <= 0) {
return res.status(400).json({
success: false,
message: "생산수량을 입력해주세요.",
});
}
const statusCheck = await pool.query(
`SELECT wr.status, wr.result_status, wr.total_production_qty, wr.good_qty,
wr.defect_qty, wr.concession_qty, wr.defect_detail,
wr.input_qty, wr.wop_id, wop.wo_id, wop.seq_no
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 wr.id = $1 AND wr.company_code = $2`,
[work_order_process_id, companyCode],
);
if (statusCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "접수 카드를 찾을 수 없습니다.",
});
}
const prev = statusCheck.rows[0];
if (prev.result_status === "confirmed") {
return res.status(403).json({
success: false,
message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.",
});
}
const client = await pool.connect();
try {
await client.query("BEGIN");
// 초과 생산 경고 (차단하지 않음)
const prevTotal = parseInt(prev.total_production_qty, 10) || 0;
const acceptedQty = parseInt(prev.input_qty, 10) || 0;
const requestedQty = parseInt(production_qty, 10) || 0;
if (acceptedQty > 0 && prevTotal + requestedQty > acceptedQty) {
logger.warn("[pop/production] 초과 생산 감지", {
work_order_process_id,
prevTotal,
requestedQty,
acceptedQty,
});
}
const addProduction = parseInt(production_qty, 10) || 0;
let addDefect = 0;
let addConcession = 0;
let defectDetailStr: string | null = null;
if (defect_detail && Array.isArray(defect_detail)) {
const validated = defect_detail.map((item: DefectDetailItem) => ({
defect_code: item.defect_code || "",
defect_name: item.defect_name || "",
qty: item.qty || "0",
disposition: item.disposition || "scrap",
}));
defectDetailStr = JSON.stringify(validated);
for (const item of validated) {
const itemQty = parseInt(item.qty, 10) || 0;
addDefect += itemQty;
if (item.disposition === "accept") {
addConcession += itemQty;
}
}
} else {
addDefect = parseInt(defect_qty, 10) || 0;
}
const addGood = addProduction - addDefect;
const newTotal =
(parseInt(prev.total_production_qty, 10) || 0) + addProduction;
const newGood = (parseInt(prev.good_qty, 10) || 0) + addGood;
const newDefect = (parseInt(prev.defect_qty, 10) || 0) + addDefect;
const newConcession =
(parseInt(prev.concession_qty, 10) || 0) + addConcession;
// defect_detail 병합
let mergedDefectDetail: string | null = null;
if (defectDetailStr) {
let existingEntries: DefectDetailItem[] = [];
try {
existingEntries = prev.defect_detail
? JSON.parse(prev.defect_detail)
: [];
} catch {
/* ignore */
}
const newEntries: DefectDetailItem[] = JSON.parse(defectDetailStr);
const merged = [...existingEntries];
for (const ne of newEntries) {
const existing = merged.find(
(e) =>
e.defect_code === ne.defect_code &&
e.disposition === ne.disposition,
);
if (existing) {
existing.qty = String(
(parseInt(existing.qty, 10) || 0) + (parseInt(ne.qty, 10) || 0),
);
} else {
merged.push(ne);
}
}
mergedDefectDetail = JSON.stringify(merged);
}
const result = await client.query(
`UPDATE work_order_process_result
SET total_production_qty = $3,
good_qty = $4,
defect_qty = $5,
concession_qty = $9,
defect_detail = COALESCE($6, defect_detail),
result_note = COALESCE($7, result_note),
result_status = 'draft',
status = CASE WHEN status IN ('acceptable', 'waiting') THEN 'in_progress' ELSE status END,
writer = $8,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status`,
[
work_order_process_id,
companyCode,
String(newTotal),
String(newGood),
String(newDefect),
mergedDefectDetail,
result_note || null,
userId,
String(newConcession),
],
);
if (result.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "접수 카드를 찾을 수 없거나 권한이 없습니다.",
});
}
// 현재 접수 카드 공정 정보
const currentSeq = await client.query(
`SELECT wr.id as wr_id, wr.wop_id, wr.input_qty as current_input_qty,
wop.seq_no, wop.wo_id,
wop.process_code, wop.process_name,
wop.is_required, wop.is_fixed_order, wop.standard_time,
wr.equipment_code, wop.routing_detail_id,
wi.qty as instruction_qty
FROM work_order_process_result wr
JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
WHERE wr.id = $1 AND wr.company_code = $2`,
[work_order_process_id, companyCode],
);
// 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때)
// 신 구조: work_order_process_result 에 새 row INSERT (is_rework='Y', seq=MAX+1, UNIQUE 충돌 시 1회 재시도)
if (
(currentSeq.rowCount ?? 0) > 0 &&
defect_detail &&
Array.isArray(defect_detail)
) {
let totalReworkQty = 0;
let targetProcessCode: string | null = null;
for (const item of defect_detail) {
if (item.disposition === "rework") {
totalReworkQty += parseInt(item.qty, 10) || 0;
if (item.target_process_code)
targetProcessCode = item.target_process_code;
}
}
if (totalReworkQty > 0) {
const proc = currentSeq.rows[0];
let reworkMasterId: string = proc.wop_id;
if (targetProcessCode) {
const targetProc = await client.query(
`SELECT id FROM work_order_process
WHERE wo_id = $1 AND process_code = $2 AND company_code = $3
LIMIT 1`,
[proc.wo_id, targetProcessCode, companyCode],
);
if ((targetProc.rowCount ?? 0) > 0) {
reworkMasterId = targetProc.rows[0].id;
}
}
let reworkId: string | null = null;
for (let attempt = 0; attempt < 2 && !reworkId; attempt++) {
try {
const reworkInsert = await client.query(
`INSERT INTO work_order_process_result (
id, wop_id, seq, company_code,
status, result_status,
input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
is_rework, rework_source_id,
writer, created_date, updated_date
) VALUES (
gen_random_uuid()::text, $1::varchar,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1::varchar AND company_code = $2::varchar),
$2::varchar,
'acceptable', 'draft',
$3::varchar, '0', '0', '0', '0',
'Y', $4::varchar,
$5::varchar, NOW(), NOW()
) RETURNING id`,
[
reworkMasterId,
companyCode,
String(totalReworkQty),
work_order_process_id,
userId,
],
);
reworkId = reworkInsert.rows[0]?.id;
} catch (err: any) {
if (err?.code !== "23505" || attempt >= 1) throw err;
}
}
if (reworkId) {
await copyChecklistToSplit(
client,
reworkMasterId,
reworkId,
null,
companyCode,
userId,
);
}
}
}
// 접수 카드 자동완료 판정 + 작업지시 완료 캐스케이드
if ((currentSeq.rowCount ?? 0) > 0) {
const {
wo_id: csWoId,
current_input_qty: csInputQty,
} = currentSeq.rows[0];
const csMyInput = parseInt(csInputQty, 10) || 0;
if (newTotal >= csMyInput && csMyInput > 0) {
await client.query(
`UPDATE work_order_process_result
SET status = 'completed', result_status = 'confirmed',
completed_at = NOW()::text, completed_by = $3, updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND status != 'completed'`,
[work_order_process_id, companyCode, userId],
);
}
await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId);
}
// 자재 자동 투입 + 재고 차감 (이번 차수 생산수량 × 1개당 소요량)
// material_inputs 가 있으면 process_work_result INSERT + inventory_stock 차감.
// 재고 부족 시 ROLLBACK + 400 응답.
if (Array.isArray(material_inputs) && material_inputs.length > 0) {
for (const mi of material_inputs) {
const childItemId = mi.child_item_id;
const childItemCode = mi.child_item_code;
const childItemName = mi.child_item_name || "";
const inputQty = parseFloat(String(mi.input_qty));
const unit = mi.unit || "";
const bomDetailId = mi.bom_detail_id || null;
const requiredQty = mi.required_qty != null ? String(mi.required_qty) : null;
if (!childItemId || !childItemCode || isNaN(inputQty) || inputQty <= 0) {
continue;
}
// 창고/로케이션 자동 선택 (기존 saveMaterialInput 패턴: 가장 최근 입고 우선)
const autoStock = await client.query(
`SELECT warehouse_code, location_code,
COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS current_qty
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) > 0
ORDER BY last_in_date DESC NULLS LAST LIMIT 1`,
[companyCode, childItemCode],
);
if (autoStock.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 0)`,
});
}
const stockRow = autoStock.rows[0];
const stockQty = parseFloat(String(stockRow.current_qty)) || 0;
if (stockQty < inputQty) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 ${stockQty}, 필요 ${inputQty})`,
});
}
const effectiveWh = stockRow.warehouse_code;
const effectiveLoc = stockRow.location_code || effectiveWh;
await client.query(
`INSERT INTO process_work_result (
id, company_code, work_order_process_id,
detail_type, detail_content, item_title,
result_value, unit, is_passed, status,
bom_detail_id, required_qty, warehouse_code, location_code,
recorded_by, recorded_at, writer
) VALUES (
gen_random_uuid()::text, $1, $2,
'material_input', $3, $4,
$5, $6, 'Y', 'completed',
$7, $8, $9, $10,
$11, NOW()::text, $11
)`,
[
companyCode,
work_order_process_id,
childItemCode,
childItemName,
String(inputQty),
unit,
bomDetailId,
requiredQty,
effectiveWh,
effectiveLoc,
userId,
],
);
await client.query(
`UPDATE inventory_stock
SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text,
updated_date = NOW(), writer = $5
WHERE company_code = $1 AND item_code = $2
AND warehouse_code = $3 AND location_code = $6`,
[
companyCode,
childItemCode,
effectiveWh,
String(inputQty),
userId,
effectiveLoc,
],
);
}
}
// [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,
is_rework, rework_source_id
FROM work_order_process_result WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
await client.query("COMMIT");
const responseData = latestData.rows[0] || result.rows[0];
return res.json({
success: true,
data: responseData,
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] save-result 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "실적 저장 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
};
/**
* 작업지시(work_instruction) 전체 완료 판정
* 신 구조: 마지막 seq 의 각 마스터 wop 에 대해 그 wop 의 wop_result 들이
* 모두 result_status='confirmed' 이고 SUM(good+concession) >= wi.qty 이면 완료 처리.
*/
const checkAndCompleteWorkInstruction = async (
pool: any,
woId: string,
companyCode: string,
userId: string,
) => {
// "마지막 필수 seq" 기준: 후행에 비필수 공정만 있어도 작업지시 완료 판정 가능
const maxSeqResult = await pool.query(
`SELECT MAX(seq_no::int) as max_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND COALESCE(is_required, '') = 'Y'`,
[woId, companyCode],
);
if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return;
const maxSeq = String(maxSeqResult.rows[0].max_seq);
// 마지막 필수 seq 의 마스터 wop 중 confirmed/skipped 가 하나라도 없는 게 있으면 미완료
const incompleteCheck = await pool.query(
`SELECT wop.id
FROM work_order_process wop
WHERE wop.wo_id = $1 AND wop.seq_no = $2 AND wop.company_code = $3
AND COALESCE(wop.is_required, '') = 'Y'
AND NOT EXISTS (
SELECT 1 FROM work_order_process_result wr
WHERE wr.wop_id = wop.id AND wr.company_code = wop.company_code
AND wr.result_status IN ('confirmed','skipped')
)
LIMIT 1`,
[woId, maxSeq, companyCode],
);
if ((incompleteCheck.rowCount ?? 0) > 0) return;
const totalGoodResult = await pool.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`,
[woId, maxSeq, companyCode],
);
const completedQty = totalGoodResult.rows[0].total_good;
const updateResult = await pool.query(
`UPDATE work_instruction
SET status = 'completed',
progress_status = 'completed',
completed_qty = $3,
writer = $4,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
AND status != 'completed'
RETURNING id, item_id`,
[woId, companyCode, String(completedQty), userId],
);
// 작업지시 완료 시점에 후행 비필수 wop 들에 자동 skipped row INSERT (wop_result 0건만)
if ((updateResult.rowCount ?? 0) > 0) {
const trailingNonReqs = await pool.query(
`SELECT wop.id 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.is_required, '') <> 'Y'
AND NOT EXISTS (
SELECT 1 FROM work_order_process_result wr
WHERE wr.wop_id = wop.id AND wr.company_code = wop.company_code
)`,
[woId, companyCode, parseInt(maxSeq, 10)],
);
for (const row of trailingNonReqs.rows) {
try {
await pool.query(
`INSERT INTO work_order_process_result (
id, wop_id, seq, company_code,
status, result_status,
input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
completed_at, completed_by,
writer, created_date, updated_date
) VALUES (
gen_random_uuid()::text, $1::varchar,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1::varchar AND company_code = $2::varchar),
$2::varchar,
'completed', 'skipped',
'0', '0', '0', '0', '0',
NOW()::text, $3::varchar,
$3::varchar, NOW(), NOW()
)`,
[row.id, companyCode, userId],
);
} catch (err: any) {
if (err?.code !== "23505") throw err;
}
}
}
if ((updateResult.rowCount ?? 0) > 0 && completedQty > 0) {
try {
const itemId = updateResult.rows[0].item_id;
const itemResult = await pool.query(
`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`,
[itemId, companyCode],
);
if (itemResult.rowCount === 0) return;
const itemCode = itemResult.rows[0].item_number;
// 담당자 한글명 조회 — inventory_history manager_name 기록용
// (CLAUDE.md "사용자 식별 표시 필수": DB에는 user_id 저장, 표시는 user_name)
let autoUserName = userId;
try {
const mgrRes = await pool.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],
);
if (mgrRes.rows[0]?.user_name) autoUserName = mgrRes.rows[0].user_name;
} catch {
/* user_info 조회 실패 시 userId fallback 유지 */
}
const warehouseResult = await pool.query(
`SELECT target_warehouse_id, target_location_code
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
LIMIT 1`,
[woId, maxSeq, companyCode],
);
let warehouseCode: string | null = null;
let locationCode: string | null = null;
if (
warehouseResult.rowCount &&
warehouseResult.rows[0].target_warehouse_id
) {
warehouseCode = warehouseResult.rows[0].target_warehouse_id;
locationCode =
warehouseResult.rows[0].target_location_code || warehouseCode;
} else {
// 완제품 창고 fallback: 1개 이상이면 첫 번째(warehouse_code 오름차순) 자동 입고
const finishedWh = await pool.query(
`SELECT warehouse_code FROM warehouse_info
WHERE company_code = $1
AND warehouse_type IN (
SELECT value_code FROM category_values
WHERE company_code = $1
AND table_name = 'warehouse_info'
AND column_name = 'warehouse_type'
AND value_label = '완제품'
)
ORDER BY warehouse_code`,
[companyCode],
);
if ((finishedWh.rowCount ?? 0) >= 1) {
warehouseCode = finishedWh.rows[0].warehouse_code;
locationCode = warehouseCode;
}
}
if (!warehouseCode) {
// 자동 입고 skip (사용자가 inventoryInbound 로 명시 입고)
return;
}
await upsertInventoryStock(
pool,
companyCode,
itemCode,
warehouseCode,
locationCode,
completedQty,
userId,
{
userName: autoUserName,
source: "auto_cascade",
woId,
},
);
} catch (inventoryError: any) {
logger.error(
"[pop/production] 재고입고 오류 (공정 완료는 유지):",
inventoryError,
);
}
}
};
/**
* 실적 확정 — work_order_process_result UPDATE.
* work_order_process_id 는 work_order_process_result.id
*/
export const confirmResult = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id } = req.body;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
const statusCheck = await pool.query(
`SELECT status, result_status, total_production_qty
FROM work_order_process_result
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
if (statusCheck.rowCount === 0) {
return res.status(404).json({
success: false,
message: "접수 카드를 찾을 수 없습니다.",
});
}
const currentProcess = statusCheck.rows[0];
if (
!currentProcess.total_production_qty ||
parseInt(currentProcess.total_production_qty, 10) <= 0
) {
return res.status(400).json({
success: false,
message: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.",
});
}
const result = await pool.query(
`UPDATE work_order_process_result
SET result_status = 'confirmed',
status = 'completed',
completed_at = NOW()::text,
completed_by = $3,
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, equipment_code`,
[work_order_process_id, companyCode, userId],
);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "접수 카드를 찾을 수 없습니다.",
});
}
// [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`,
[result.rows[0].wop_id, companyCode],
);
if ((wopLookup.rowCount ?? 0) > 0) {
await checkAndCompleteWorkInstruction(
pool,
wopLookup.rows[0].wo_id,
companyCode,
userId,
);
}
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("[pop/production] confirm-result 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "실적 확정 중 오류가 발생했습니다.",
});
}
};
/**
* 실적 이력 조회 — work_order_process_result 기준 (wop_result.id)
*/
export const getResultHistory = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const rawWopId = req.query.work_order_process_id;
const work_order_process_id = Array.isArray(rawWopId)
? rawWopId[0]
: rawWopId;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
const ownerCheck = await pool.query(
`SELECT id FROM work_order_process_result WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
if (ownerCheck.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "접수 카드를 찾을 수 없습니다." });
}
const historyResult = await pool.query(
`WITH grouped AS (
SELECT
changed_at,
MAX(changed_by) as changed_by,
MAX(CASE WHEN changed_column = 'total_production_qty' THEN old_value END) as total_old,
MAX(CASE WHEN changed_column = 'total_production_qty' THEN new_value END) as total_new,
MAX(CASE WHEN changed_column = 'good_qty' THEN old_value END) as good_old,
MAX(CASE WHEN changed_column = 'good_qty' THEN new_value END) as good_new,
MAX(CASE WHEN changed_column = 'defect_qty' THEN old_value END) as defect_old,
MAX(CASE WHEN changed_column = 'defect_qty' THEN new_value END) as defect_new
FROM work_order_process_log
WHERE original_id = $1
AND changed_column IN ('total_production_qty', 'good_qty', 'defect_qty')
AND new_value IS NOT NULL
GROUP BY changed_at
)
SELECT * FROM grouped
WHERE total_new IS NOT NULL
AND (COALESCE(total_new::int, 0) - COALESCE(total_old::int, 0)) > 0
ORDER BY changed_at ASC`,
[work_order_process_id],
);
const batches = historyResult.rows.map((row: any, idx: number) => {
const batchQty =
(parseInt(row.total_new, 10) || 0) - (parseInt(row.total_old, 10) || 0);
const batchGood =
(parseInt(row.good_new, 10) || 0) - (parseInt(row.good_old, 10) || 0);
const batchDefect =
(parseInt(row.defect_new, 10) || 0) -
(parseInt(row.defect_old, 10) || 0);
return {
seq: idx + 1,
batch_qty: batchQty,
batch_good: batchGood,
batch_defect: batchDefect,
accumulated_total: parseInt(row.total_new, 10) || 0,
changed_at: row.changed_at,
changed_by: row.changed_by,
};
});
return res.json({
success: true,
data: batches,
});
} catch (error: any) {
logger.error("[pop/production] result-history 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "이력 조회 중 오류가 발생했습니다.",
});
}
};
/**
* 앞공정 완료량 + 접수가능량 조회 (신 구조)
* work_order_process_id 는 마스터 work_order_process.id
*/
export const getAvailableQty = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const rawWopId = req.query.work_order_process_id;
const work_order_process_id = Array.isArray(rawWopId)
? rawWopId[0]
: rawWopId;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id가 필요합니다.",
});
}
const current = await pool.query(
`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
WHERE wop.id = $1 AND wop.company_code = $2`,
[work_order_process_id, companyCode],
);
if (current.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "공정을 찾을 수 없습니다." });
}
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);
// 기접수합계 (같은 wop_id, 리워크 제외)
const totalAccepted = await pool.query(
`SELECT COALESCE(SUM(GREATEST(
CAST(COALESCE(NULLIF(input_qty, ''), '0') AS int),
CAST(COALESCE(NULLIF(total_production_qty, ''), '0') AS int)
)), 0) as total_input
FROM work_order_process_result
WHERE wop_id = $1 AND company_code = $2
AND (is_rework IS NULL OR is_rework NOT IN ('Y','true','1'))`,
[work_order_process_id, companyCode],
);
const myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0;
// accept-process 와 정책 일치: 비필수 0건 자동 skip 흐름을 모달 max 에도 반영
// (getPrevProcessGoodQty 는 단순 직전 seq 만 봐서 비필수 wop 가 끼면 0 으로 잘못 잡힘)
const prevEval = await evaluatePrevProcesses(
pool,
wo_id,
seqNum,
companyCode,
currentBatchId,
);
const prevGoodQty = prevEval.prevGoodQty;
const availableQty = Math.max(0, prevGoodQty - myInputQty);
// 앞공정 리워크 미소진
let reworkAvailableQty = 0;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const reworkSplits = await pool.query(
`SELECT wr.rework_source_id, COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) as rg
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 wr.is_rework IN ('Y','true','1')
AND wr.status = 'completed'
AND CAST(NULLIF(wr.good_qty, '') AS int) > 0
GROUP BY wr.rework_source_id`,
[wo_id, prevSeq, companyCode],
);
for (const rs of reworkSplits.rows) {
const srcId = rs.rework_source_id;
const srcGood = parseInt(rs.rg, 10) || 0;
const consumedResult = await pool.query(
`SELECT COALESCE(SUM(CAST(NULLIF(wr.input_qty, '') AS int)), 0) as consumed
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 wr.is_rework IN ('Y','true','1')
AND wr.rework_source_id = $4`,
[wo_id, seq_no, companyCode, srcId],
);
const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0;
reworkAvailableQty += Math.max(0, srcGood - consumed);
}
}
return res.json({
success: true,
data: {
prevGoodQty,
myInputQty,
availableQty,
instructionQty: instrQty,
reworkAvailableQty,
},
});
} catch (error: any) {
logger.error("[pop/production] available-qty 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "접수가능량 조회 중 오류가 발생했습니다.",
});
}
};
/**
* 공정 접수 — work_order_process_result INSERT (신 구조)
* body: { work_order_process_id, accept_qty, batch_id?, rework_source_id?, equipment_code? }
* work_order_process_id 는 항상 마스터 work_order_process.id
*/
export const acceptProcess = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, accept_qty } = req.body;
if (!work_order_process_id || !accept_qty) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 accept_qty가 필요합니다.",
});
}
const qty = parseInt(accept_qty, 10);
if (qty <= 0) {
return res
.status(400)
.json({ success: false, message: "접수 수량은 1 이상이어야 합니다." });
}
await client.query("BEGIN");
// wi_* 편집(syncMasterChecklistFromWi) 과 직렬화하여 마스터 스냅샷 불변 계약 보장
const woRow = await client.query(
`SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
if (woRow.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "공정을 찾을 수 없습니다." });
}
await client.query(
`SELECT pg_advisory_xact_lock(hashtext($1))`,
[`wi_snapshot:${companyCode}:${woRow.rows[0].wo_id}`],
);
// 마스터 wop FOR UPDATE (동시 접수 race 방지)
const current = await client.query(
`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
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
WHERE wop.id = $1 AND wop.company_code = $2
FOR UPDATE OF wop`,
[work_order_process_id, companyCode],
);
if (current.rowCount === 0) {
await client.query("ROLLBACK");
return res
.status(404)
.json({ success: false, message: "공정을 찾을 수 없습니다." });
}
const row = current.rows[0];
const masterId: string = row.id;
const instrQty = parseInt(row.instruction_qty, 10) || 0;
const seqNum = parseInt(row.seq_no, 10);
// 완료 판정: confirmed 존재 + SUM(good+concession) >= instrQty → 거부
const completedCheck = await client.query(
`SELECT
EXISTS (
SELECT 1 FROM work_order_process_result
WHERE wop_id = $1 AND company_code = $2 AND result_status = 'confirmed'
) AS has_confirmed,
COALESCE(SUM(CAST(NULLIF(good_qty, '') AS int)), 0)
+ COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS int)), 0) AS total_good
FROM work_order_process_result
WHERE wop_id = $1 AND company_code = $2`,
[masterId, companyCode],
);
const hasConfirmed = completedCheck.rows[0].has_confirmed === true;
const totalGood = parseInt(completedCheck.rows[0].total_good, 10) || 0;
if (hasConfirmed && instrQty > 0 && totalGood >= instrQty) {
await client.query("ROLLBACK");
return res
.status(400)
.json({ success: false, message: "이미 완료된 공정입니다." });
}
// 기접수합계 (리워크 제외)
const totalAccepted = await client.query(
`SELECT COALESCE(SUM(GREATEST(
CAST(COALESCE(NULLIF(input_qty, ''), '0') AS int),
CAST(COALESCE(NULLIF(total_production_qty, ''), '0') AS int)
)), 0) as total_input
FROM work_order_process_result
WHERE wop_id = $1 AND company_code = $2
AND (is_rework IS NULL OR is_rework != 'Y')`,
[masterId, companyCode],
);
const currentTotalInput =
parseInt(totalAccepted.rows[0].total_input, 10) || 0;
// 앞공정 평가 (스킵/차단/접수가능량 통합)
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({
success: false,
message: evalResult.blockedReason || "앞공정이 진행 중이라 접수할 수 없습니다.",
});
}
const prevGoodQty = evalResult.prevGoodQty;
const availableQty = prevGoodQty - currentTotalInput;
// 리워크 접수: 자동 생성된 리워크 카드(status='acceptable', is_rework='Y') 의 input_qty 기준으로 검증.
// 일반 가용량(availableQty) 검증은 우회 — 리워크 물량은 앞공정 양품 풀과 별개.
const reqReworkSourceId =
typeof req.body.rework_source_id === "string" &&
req.body.rework_source_id.trim()
? req.body.rework_source_id.trim()
: null;
if (reqReworkSourceId) {
const autoCardRes = await client.query(
`SELECT COALESCE(CAST(NULLIF(input_qty, '') AS int), 0) AS planned
FROM work_order_process_result
WHERE wop_id = $1 AND company_code = $2
AND rework_source_id = $3
AND is_rework IN ('Y','true','1')
AND status = 'acceptable'
LIMIT 1`,
[masterId, companyCode, reqReworkSourceId],
);
if ((autoCardRes.rowCount ?? 0) === 0) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: "리워크 카드를 찾을 수 없습니다.",
});
}
const reworkPlanned =
parseInt(autoCardRes.rows[0].planned, 10) || 0;
const acceptedRes = await client.query(
`SELECT COALESCE(SUM(GREATEST(
CAST(COALESCE(NULLIF(input_qty, ''), '0') AS int),
CAST(COALESCE(NULLIF(total_production_qty, ''), '0') AS int)
)), 0) AS accepted
FROM work_order_process_result
WHERE wop_id = $1 AND company_code = $2
AND rework_source_id = $3
AND is_rework IN ('Y','true','1')
AND status <> 'acceptable'`,
[masterId, companyCode, reqReworkSourceId],
);
const reworkAccepted =
parseInt(acceptedRes.rows[0].accepted, 10) || 0;
const reworkAvail = Math.max(0, reworkPlanned - reworkAccepted);
if (qty > reworkAvail) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: `리워크 접수가능량(${reworkAvail})을 초과합니다. (예정: ${reworkPlanned}, 기접수: ${reworkAccepted})`,
});
}
} else if (qty > availableQty) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`,
});
}
// 건너뛴 비필수 공정 자동 skip 마크 (같은 트랜잭션)
for (const tgt of evalResult.skipTargets) {
try {
await client.query(
`INSERT INTO work_order_process_result (
id, wop_id, seq, company_code,
status, result_status,
input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
completed_at, completed_by,
writer, created_date, updated_date
) VALUES (
gen_random_uuid()::text, $1::varchar,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1::varchar AND company_code = $2::varchar),
$2::varchar,
'completed', 'skipped',
'0', '0', '0', '0', '0',
NOW()::text, $3::varchar,
$3::varchar, NOW(), NOW()
)`,
[tgt.wopId, companyCode, userId],
);
} catch (err: any) {
// 23505: 동시성으로 이미 누가 skipped INSERT 함 — 무시
if (err?.code !== "23505") throw err;
}
}
// 리워크 정보 결정
let splitIsRework: string | null = null;
let splitReworkSourceId: string | null = null;
if (req.body.rework_source_id) {
splitIsRework = "Y";
splitReworkSourceId = req.body.rework_source_id;
} else if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevReworkSplits = await client.query(
`SELECT wr.rework_source_id,
COALESCE(SUM(CAST(NULLIF(wr.good_qty, '') AS int)), 0) as rework_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 wr.is_rework = 'Y'
AND wr.status = 'completed'
AND CAST(NULLIF(wr.good_qty, '') AS int) > 0
GROUP BY wr.rework_source_id`,
[row.wo_id, prevSeq, companyCode],
);
for (const rs of prevReworkSplits.rows) {
const srcId = rs.rework_source_id;
const srcGood = parseInt(rs.rework_good, 10) || 0;
const consumedResult = await client.query(
`SELECT COALESCE(SUM(CAST(NULLIF(wr.input_qty, '') AS int)), 0) as consumed
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 wr.is_rework = 'Y'
AND wr.rework_source_id = $4`,
[row.wo_id, row.seq_no, companyCode, srcId],
);
const consumed = parseInt(consumedResult.rows[0]?.consumed, 10) || 0;
const remaining = srcGood - consumed;
if (remaining > 0 && qty <= remaining) {
const normalAvailable = availableQty - remaining;
if (normalAvailable <= 0) {
splitIsRework = "Y";
splitReworkSourceId = srcId;
}
break;
}
}
}
// work_order_process_result INSERT (seq=MAX+1, UNIQUE 충돌 시 1회 재시도)
let inserted: any = null;
for (let attempt = 0; attempt < 2 && !inserted; attempt++) {
try {
const result = await client.query(
`INSERT INTO work_order_process_result (
id, wop_id, seq, company_code,
status, result_status,
accepted_by, accepted_at, started_at,
input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
equipment_code, batch_id,
is_rework, rework_source_id,
writer, created_date, updated_date
) VALUES (
gen_random_uuid()::text, $1::varchar,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1 AND company_code = $2),
$2,
'in_progress', 'draft',
$3, NOW()::text, NOW()::text,
$4, '0', '0', '0', '0',
$5, $6,
$7, $8,
$3, NOW(), NOW()
) RETURNING id, seq, input_qty, status, result_status, accepted_by`,
[
masterId,
companyCode,
userId,
String(qty),
req.body.equipment_code || null,
req.body.batch_id || null,
splitIsRework,
splitReworkSourceId,
],
);
inserted = result.rows[0];
} catch (err: any) {
if (err?.code !== "23505" || attempt >= 1) {
await client.query("ROLLBACK");
logger.error("[pop/production] accept-process INSERT 오류:", err);
return res.status(500).json({ success: false, message: err.message });
}
}
}
// 체크리스트 복사: masterId → wop_result.id
// 접수는 항상 마스터 스냅샷(B 전략)에서만 복사 — 첫 접수 이후 일관성 보장
const checklistCount = await copyChecklistToSplit(
client,
masterId,
inserted.id,
row.routing_detail_id,
companyCode,
userId,
{ 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 접수 완료", {
companyCode,
userId,
masterId,
wopResultId: inserted.id,
seq: inserted.seq,
acceptedQty: qty,
checklistCount,
});
const acceptData: any = {
...inserted,
process_name: row.process_name,
};
if (splitReworkSourceId) {
acceptData.rework_source_id = splitReworkSourceId;
acceptData.is_rework = splitIsRework;
}
return res.json({
success: true,
data: acceptData,
message: `${qty}개 접수 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] accept-process 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "접수 중 오류가 발생했습니다.",
});
} finally {
client.release();
}
};
/**
* 접수 취소 — work_order_process_result 기준
* work_order_process_id 는 wop_result.id
*/
export const cancelAccept = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id } = req.body;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
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.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
JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code
WHERE wr.id = $1 AND wr.company_code = $2`,
[work_order_process_id, companyCode],
);
if (current.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "접수 카드를 찾을 수 없습니다." });
}
const proc = current.rows[0];
if (proc.status !== "in_progress") {
return res.status(400).json({
success: false,
message: `현재 상태(${proc.status})에서는 접수 취소할 수 없습니다. 진행중 상태만 가능합니다.`,
});
}
const totalProduced = parseInt(proc.total_production_qty ?? "0", 10) || 0;
const currentInputQty = parseInt(proc.input_qty ?? "0", 10) || 0;
const unproducedQty = currentInputQty - totalProduced;
if (unproducedQty <= 0) {
return res.status(400).json({
success: false,
message:
"취소할 미소진 접수분이 없습니다. 모든 접수량에 대해 실적이 등록되었습니다.",
});
}
const cancelledQty = unproducedQty;
const client = await pool.connect();
try {
await client.query("BEGIN");
if (totalProduced === 0) {
// 자기 wop_result 가 acceptProcess 시점에 같이 INSERT 한 자동 skipped row 들도 함께 DELETE
// (같은 트랜잭션이라 created_date 동일)
await client.query(
`DELETE FROM work_order_process_result wr
USING work_order_process wop
WHERE wr.wop_id = wop.id
AND wr.company_code = wop.company_code
AND wop.wo_id = $1::varchar
AND wr.company_code = $2::varchar
AND wr.result_status = 'skipped'
AND wr.id <> $3::varchar
AND wr.created_date = (
SELECT created_date FROM work_order_process_result
WHERE id = $3::varchar AND company_code = $2::varchar
)`,
[proc.wo_id, companyCode, work_order_process_id],
);
await client.query(
`DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
await client.query(
`DELETE FROM work_order_process_result WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
} else {
await client.query(
`UPDATE work_order_process_result
SET input_qty = $3, status = 'completed', result_status = 'confirmed',
completed_at = NOW()::text, completed_by = $4,
updated_date = NOW(), writer = $4
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode, String(totalProduced), userId],
);
}
// 재고 원복
if (proc.target_warehouse_id) {
const inboundQty =
parseInt(proc.good_qty || "0", 10) +
parseInt(proc.concession_qty || "0", 10);
if (inboundQty > 0) {
const wiResult = await client.query(
`SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`,
[proc.wo_id, companyCode],
);
if ((wiResult.rowCount ?? 0) > 0) {
const itemResult = await client.query(
`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`,
[wiResult.rows[0].item_id, companyCode],
);
if ((itemResult.rowCount ?? 0) > 0) {
const itemCode = itemResult.rows[0].item_number;
const locCode =
proc.target_location_code || proc.target_warehouse_id;
await client.query(
`UPDATE inventory_stock
SET current_qty = GREATEST((COALESCE(current_qty::numeric, 0) - $4::numeric), 0)::text,
updated_date = NOW(), writer = $5
WHERE company_code = $1 AND item_code = $2
AND warehouse_code = $3 AND location_code = $6`,
[
companyCode,
itemCode,
proc.target_warehouse_id,
String(inboundQty),
userId,
locCode,
],
);
}
}
}
}
// [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");
throw txErr;
} finally {
client.release();
}
return res.json({
success: true,
data: { id: work_order_process_id, process_name: proc.process_name },
message: `미소진 ${cancelledQty}개 접수가 취소되었습니다.`,
});
} catch (error: any) {
logger.error("[pop/production] cancel-accept 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "접수 취소 중 오류가 발생했습니다.",
});
}
};
/**
* 창고 목록 조회 (POP 생산용)
*/
export const getWarehouses = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const result = await pool.query(
`SELECT id, warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND COALESCE(status, '') != '삭제'
ORDER BY warehouse_name`,
[companyCode],
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("[pop/production] 창고 목록 조회 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 특정 창고의 위치(로케이션) 목록 조회
*/
export const getWarehouseLocations = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { warehouseId } = req.params;
if (!warehouseId) {
return res
.status(400)
.json({ success: false, message: "warehouseId는 필수입니다." });
}
const whInfo = await pool.query(
`SELECT warehouse_code FROM warehouse_info WHERE id = $1 AND company_code = $2`,
[warehouseId, companyCode],
);
if (whInfo.rowCount === 0) {
return res.json({ success: true, data: [] });
}
const warehouseCode = whInfo.rows[0].warehouse_code;
const result = await pool.query(
`SELECT id, location_code, location_name
FROM warehouse_location
WHERE warehouse_code = $1 AND company_code = $2
ORDER BY location_name`,
[warehouseCode, companyCode],
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("[pop/production] 창고 위치 조회 실패:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 마지막 공정 여부 확인 — 마스터 wop 기준 (seq_no MAX)
*/
export const isLastProcess = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { processId } = req.params;
if (!processId) {
return res.json({ success: true, data: { isLast: false } });
}
const process = await pool.query(
`SELECT wo_id, seq_no, target_warehouse_id, target_location_code
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[processId, companyCode],
);
if (process.rowCount === 0) {
return res.json({ success: true, data: { isLast: false } });
}
const { wo_id, seq_no, target_warehouse_id, target_location_code } = process.rows[0];
// "마지막 공정" = 자기 이후에 필수 공정이 하나도 없음 (후행 비필수만 있어도 isLast=true)
const next = await pool.query(
`SELECT id FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND CAST(seq_no AS int) > CAST($3 AS int)
AND COALESCE(is_required, '') = 'Y'
LIMIT 1`,
[wo_id, companyCode, seq_no],
);
return res.json({
success: true,
data: {
isLast: next.rowCount === 0,
woId: wo_id,
seqNo: seq_no,
targetWarehouseId: target_warehouse_id || null,
targetLocationCode: target_location_code || null,
},
});
} catch (error: any) {
logger.error("[pop/production] 마지막 공정 확인 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 공정의 목표 창고/위치 업데이트 (마스터 wop 기준)
*/
export const updateTargetWarehouse = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, target_warehouse_id, target_location_code } =
req.body;
if (!work_order_process_id || !target_warehouse_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 target_warehouse_id는 필수입니다.",
});
}
await pool.query(
`UPDATE work_order_process
SET target_warehouse_id = $3,
target_location_code = $4,
writer = $5,
updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[
work_order_process_id,
companyCode,
target_warehouse_id,
target_location_code || null,
userId,
],
);
return res.json({
success: true,
data: { target_warehouse_id, target_location_code },
});
} catch (error: any) {
logger.error("[pop/production] 목표 창고 업데이트 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 독립 재고 입고 — work_order_process_result 기준.
* body.work_order_process_id 는 wop_result.id
*/
export const inventoryInbound = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, warehouse_code, location_code } = req.body;
if (!work_order_process_id || !warehouse_code) {
return res.status(400).json({
success: false,
message: "work_order_process_id와 warehouse_code는 필수입니다.",
});
}
await client.query("BEGIN");
const procResult = await client.query(
`SELECT wr.wop_id, wr.good_qty, wr.concession_qty, wr.is_rework,
wop.wo_id, wop.seq_no, wop.target_warehouse_id
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 wr.id = $1 AND wr.company_code = $2`,
[work_order_process_id, companyCode],
);
if (procResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "해당 접수 카드를 찾을 수 없습니다.",
});
}
const proc = procResult.rows[0];
if (proc.target_warehouse_id) {
await client.query("ROLLBACK");
return res.status(409).json({
success: false,
message: "이미 재고 입고가 완료된 공정입니다.",
data: { existing_warehouse: proc.target_warehouse_id },
});
}
const goodQty =
parseInt(proc.good_qty || "0", 10) +
parseInt(proc.concession_qty || "0", 10);
if (goodQty <= 0) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: "양품 수량이 0이므로 재고 입고할 수 없습니다.",
});
}
const wiResult = await client.query(
`SELECT item_id FROM work_instruction WHERE id = $1 AND company_code = $2`,
[proc.wo_id, companyCode],
);
if (wiResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "작업지시를 찾을 수 없습니다.",
});
}
const itemId = wiResult.rows[0].item_id;
const itemResult = await client.query(
`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`,
[itemId, companyCode],
);
if (itemResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "품목 정보를 찾을 수 없습니다.",
});
}
const itemCode = itemResult.rows[0].item_number;
const effectiveLocationCode = location_code || null;
await upsertInventoryStock(
client,
companyCode,
itemCode,
warehouse_code,
effectiveLocationCode,
goodQty,
userId,
{
userName: req.user!.userName || userId,
source: "manual_inbound",
woId: proc.wo_id,
workOrderProcessResultId: work_order_process_id,
},
);
// 마스터 wop 에 target_warehouse_id 저장
await client.query(
`UPDATE work_order_process
SET target_warehouse_id = $3,
target_location_code = $4,
writer = $5,
updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[proc.wop_id, companyCode, warehouse_code, location_code || null, userId],
);
// 자기 wop 이 "마지막 필수 공정" 이면 후행 비필수 자동 skipped INSERT
const trailingRequired = await client.query(
`SELECT 1 FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND CAST(seq_no AS int) > CAST($3 AS int)
AND COALESCE(is_required, '') = 'Y'
LIMIT 1`,
[proc.wo_id, companyCode, proc.seq_no],
);
if ((trailingRequired.rowCount ?? 0) === 0) {
const trailingNonReqs = await client.query(
`SELECT wop.id 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.is_required, '') <> 'Y'
AND NOT EXISTS (
SELECT 1 FROM work_order_process_result wr
WHERE wr.wop_id = wop.id AND wr.company_code = wop.company_code
)`,
[proc.wo_id, companyCode, parseInt(proc.seq_no, 10)],
);
for (const row of trailingNonReqs.rows) {
try {
await client.query(
`INSERT INTO work_order_process_result (
id, wop_id, seq, company_code,
status, result_status,
input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
completed_at, completed_by,
writer, created_date, updated_date
) VALUES (
gen_random_uuid()::text, $1::varchar,
(SELECT COALESCE(MAX(seq), 0) + 1 FROM work_order_process_result WHERE wop_id = $1::varchar AND company_code = $2::varchar),
$2::varchar,
'completed', 'skipped',
'0', '0', '0', '0', '0',
NOW()::text, $3::varchar,
$3::varchar, NOW(), NOW()
)`,
[row.id, companyCode, userId],
);
} catch (err: any) {
if (err?.code !== "23505") throw err;
}
}
}
if (proc.is_rework === "Y") {
await client.query(
`UPDATE work_order_process_result SET is_rework = NULL, updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
}
await client.query("COMMIT");
return res.json({
success: true,
message: "재고 입고가 완료되었습니다.",
data: {
item_code: itemCode,
warehouse_code,
location_code: effectiveLocationCode,
qty: goodQty,
},
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] 독립 재고 입고 오류:", error);
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
};
/**
* 간이 재고 입고 (공정 접수 없이 바로 입고)
*/
export const quickInventoryInbound = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { item_id, qty, warehouse_code, location_code, remark } = req.body;
if (!item_id || !qty || !warehouse_code) {
return res.status(400).json({
success: false,
message: "item_id, qty, warehouse_code는 필수입니다.",
});
}
const parsedQty = parseInt(String(qty), 10);
if (isNaN(parsedQty) || parsedQty <= 0) {
return res.status(400).json({
success: false,
message: "수량은 1 이상의 정수여야 합니다.",
});
}
await client.query("BEGIN");
const itemResult = await client.query(
`SELECT item_number, item_name, size, material, unit
FROM item_info WHERE id = $1 AND company_code = $2`,
[item_id, companyCode],
);
if (itemResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "품목 정보를 찾을 수 없습니다.",
});
}
const item = itemResult.rows[0];
const itemCode = item.item_number;
const effectiveLocationCode = location_code || null;
await upsertInventoryStock(
client,
companyCode,
itemCode,
warehouse_code,
effectiveLocationCode,
parsedQty,
userId,
{
userName: req.user!.userName || userId,
source: "quick_inbound",
},
);
const seqResult = await client.query(
`SELECT COALESCE(MAX(
CASE WHEN inbound_number ~ '^QIB-[0-9]{4}-[0-9]+$'
THEN CAST(SUBSTRING(inbound_number FROM '[0-9]+$') AS INTEGER)
ELSE 0 END
), 0) + 1 AS next_seq
FROM inbound_mng WHERE company_code = $1`,
[companyCode],
);
const nextSeq = seqResult.rows[0].next_seq;
const year = new Date().getFullYear();
const inboundNumber = `QIB-${year}-${String(nextSeq).padStart(4, "0")}`;
await client.query(
`INSERT INTO inbound_mng (
id, company_code, inbound_number, inbound_type, inbound_date,
item_number, item_name, spec, material, unit,
inbound_qty, warehouse_code, location_code,
inbound_status, memo, remark,
created_date, updated_date, writer, created_by, updated_by
) VALUES (
gen_random_uuid()::text, $1, $2, '간이입고', CURRENT_DATE,
$3, $4, $5, $6, $7,
$8, $9, $10,
'완료', $11, $12,
NOW(), NOW(), $13, $13, $13
)`,
[
companyCode,
inboundNumber,
item.item_number,
item.item_name,
item.size,
item.material,
item.unit,
parsedQty,
warehouse_code,
effectiveLocationCode,
remark || "POP 간이입고",
remark || null,
userId,
],
);
await client.query("COMMIT");
return res.json({
success: true,
message: "간이 재고 입고가 완료되었습니다.",
data: {
inbound_number: inboundNumber,
item_code: itemCode,
item_name: item.item_name,
warehouse_code,
location_code: effectiveLocationCode,
qty: parsedQty,
},
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] 간이 재고 입고 오류:", error);
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
};
/**
* 재작업 이력 조회 — 신 구조: 접수 카드(wop_result) 기반
*/
export const getReworkHistory = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const woId = (req.query.wo_id as string) || req.params.woId;
if (!woId) {
return res
.status(400)
.json({ success: false, message: "wo_id는 필수입니다." });
}
const result = await pool.query(
`SELECT wr.id, wop.seq_no, wop.process_code, wop.process_name,
wr.status, wr.input_qty, wr.good_qty, wr.defect_qty, wr.concession_qty,
wr.is_rework, wr.rework_source_id, wr.wop_id,
wr.accepted_by, wr.accepted_at, wr.started_at, wr.completed_at,
wr.created_date
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 wr.company_code = $2
AND (wr.is_rework = 'Y' OR wr.is_rework = '1' OR CAST(NULLIF(wr.defect_qty, '') AS int) > 0)
ORDER BY wr.created_date ASC`,
[woId, companyCode],
);
const rows = result.rows;
const chains: Array<{
source: (typeof rows)[0];
reworks: typeof rows;
totalReworkCount: number;
}> = [];
const reworkSourceIds = new Set(
rows.filter((r) => r.rework_source_id).map((r) => r.rework_source_id),
);
const sources = rows.filter(
(r) =>
reworkSourceIds.has(r.id) ||
(parseInt(r.defect_qty || "0", 10) > 0 && r.is_rework !== "Y"),
);
for (const src of sources) {
const chain: typeof rows = [];
const visited = new Set<string>();
const queue = rows.filter((r) => r.rework_source_id === src.id);
while (queue.length > 0) {
const item = queue.shift()!;
if (visited.has(item.id)) continue;
visited.add(item.id);
chain.push(item);
const next = rows.filter((r) => r.rework_source_id === item.id);
queue.push(...next);
}
chains.push({
source: src,
reworks: chain,
totalReworkCount: chain.length,
});
}
return res.json({
success: true,
data: {
wo_id: woId,
total_rework_count: rows.filter(
(r) => r.is_rework === "Y" || r.is_rework === "1",
).length,
chains,
all_records: rows,
},
});
} catch (error: any) {
logger.error("[pop/production] rework-history 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 공정별 BOM 자재 목록 + 소요량 계산
* processId = 마스터 work_order_process.id
*/
export const getBomMaterials = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { processId } = req.params;
if (!processId) {
return res
.status(400)
.json({ success: false, message: "processId 필수" });
}
const procResult = await pool.query(
`SELECT wop.wo_id, wop.process_code, wop.plan_qty,
wi.item_id, wi.qty as instruction_qty,
(SELECT COALESCE(SUM(CAST(NULLIF(input_qty, '') AS int)), 0)
FROM work_order_process_result
WHERE wop_id = wop.id AND company_code = wop.company_code) as total_input
FROM work_order_process wop
JOIN work_instruction wi ON wop.wo_id = wi.id AND wop.company_code = wi.company_code
WHERE wop.id = $1 AND wop.company_code = $2`,
[processId, companyCode],
);
if (procResult.rowCount === 0) {
return res.json({
success: true,
data: { materials: [], processQty: 0 },
});
}
const proc = procResult.rows[0];
const processQty = parseInt(
String(
proc.total_input && parseInt(proc.total_input, 10) > 0
? proc.total_input
: proc.plan_qty || proc.instruction_qty || "0",
),
10,
);
const itemResult = await pool.query(
`SELECT item_number, item_name FROM item_info WHERE id = $1 AND company_code = $2`,
[proc.item_id, companyCode],
);
if (itemResult.rowCount === 0) {
return res.json({ success: true, data: { materials: [], processQty } });
}
const itemCode = itemResult.rows[0].item_number;
const bomResult = await pool.query(
`SELECT bd.id, bd.child_item_id, bd.quantity, bd.unit, bd.process_type, bd.loss_rate,
i.item_name as child_item_name, i.item_number as child_item_code, i.unit as item_unit,
b.base_qty
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info i ON bd.child_item_id = i.id AND i.company_code = b.company_code
WHERE (b.item_code = $1 OR b.item_id = $2) AND b.company_code = $3
ORDER BY bd.seq_no ASC`,
[itemCode, proc.item_id, companyCode],
);
const baseQty = parseFloat(bomResult.rows[0]?.base_qty || "1") || 1;
// 투입량: 마스터 wop.id 직결 + 접수 카드들(wop_result.id) 의 material_input 전부 합산
const inputResult = await pool.query(
`SELECT detail_content as item_code, SUM(CAST(NULLIF(result_value, '') AS numeric)) as total_input
FROM process_work_result
WHERE company_code = $1
AND detail_type = 'material_input'
AND result_value IS NOT NULL AND result_value != ''
AND (
work_order_process_id = $2
OR work_order_process_id IN (
SELECT id FROM work_order_process_result WHERE wop_id = $2 AND company_code = $1
)
)
GROUP BY detail_content`,
[companyCode, processId],
);
const inputMap = new Map<string, number>();
for (const row of inputResult.rows) {
inputMap.set(String(row.item_code), parseFloat(row.total_input) || 0);
}
const materials = bomResult.rows.map((bd: Record<string, unknown>) => {
const bomQty = parseFloat(String(bd.quantity || "0")) || 0;
const lossRate = parseFloat(String(bd.loss_rate || "0")) || 0;
const requiredQty = Math.ceil(
(processQty / baseQty) * bomQty * (1 + lossRate / 100),
);
const childItemCode = String(bd.child_item_code || "");
return {
id: bd.id,
child_item_id: bd.child_item_id,
child_item_code: childItemCode,
child_item_name: bd.child_item_name || "",
bom_qty: bomQty,
unit: bd.unit || bd.item_unit || "",
process_type: bd.process_type || "",
loss_rate: lossRate,
required_qty: requiredQty,
input_qty: inputMap.get(childItemCode) || 0,
};
});
return res.json({
success: true,
data: {
materials,
processQty,
baseQty,
itemCode,
itemName: itemResult.rows[0].item_name,
},
});
} catch (error: any) {
logger.error("[pop/production] bom-materials 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 자재 투입 기록 저장
* work_order_process_id 는 wop_result.id (접수 카드) 기준으로 저장.
*/
export const saveMaterialInput = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id, inputs } = req.body;
if (!work_order_process_id || !inputs || !Array.isArray(inputs)) {
return res.status(400).json({
success: false,
message: "work_order_process_id, inputs[] 필수",
});
}
await client.query("BEGIN");
const results = [];
for (const input of inputs) {
const {
child_item_id,
child_item_code,
child_item_name,
input_qty,
unit,
bom_detail_id,
required_qty,
warehouse_code,
location_code,
} = input;
const effectiveItemId =
child_item_id || input.item_id || input.item_code || child_item_code;
const effectiveItemCode =
child_item_code || input.item_code || child_item_id;
const effectiveItemName = child_item_name || input.item_name || "";
const effectiveQty = input_qty || input.qty || input.quantity;
if (!effectiveItemId || !effectiveQty) continue;
const parsedQty = parseFloat(String(effectiveQty));
if (isNaN(parsedQty) || parsedQty <= 0) continue;
const insertResult = await client.query(
`INSERT INTO process_work_result (
id, company_code, work_order_process_id,
detail_type, detail_content, item_title,
result_value, unit, is_passed, status,
remark, recorded_by, recorded_at, writer
) VALUES (
gen_random_uuid()::text, $1, $2,
'material_input', $3, $4,
$5, $6, 'Y', 'completed',
$7, $8, NOW()::text, $8
) RETURNING id`,
[
companyCode,
work_order_process_id,
effectiveItemCode || effectiveItemId,
effectiveItemName,
String(parsedQty),
unit || "",
JSON.stringify({
bom_detail_id,
required_qty: required_qty || 0,
warehouse_code,
location_code,
}),
userId,
],
);
let effectiveWh = warehouse_code;
let effectiveLoc = location_code;
if (!effectiveWh) {
const autoStock = await client.query(
`SELECT warehouse_code, location_code FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) > 0
ORDER BY last_in_date DESC NULLS LAST LIMIT 1`,
[companyCode, effectiveItemCode],
);
if (autoStock.rows.length > 0) {
effectiveWh = autoStock.rows[0].warehouse_code;
effectiveLoc = autoStock.rows[0].location_code || effectiveWh;
}
}
if (effectiveWh) {
const locCode = effectiveLoc || effectiveWh;
await client.query(
`UPDATE inventory_stock
SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text,
updated_date = NOW(), writer = $5
WHERE company_code = $1 AND item_code = $2
AND warehouse_code = $3 AND location_code = $6`,
[
companyCode,
effectiveItemCode,
effectiveWh,
String(parsedQty),
userId,
locCode,
],
);
}
results.push({
id: insertResult.rows[0].id,
child_item_code: effectiveItemCode,
input_qty: parsedQty,
});
}
await client.query("COMMIT");
return res.json({
success: true,
message: `${results.length}건 자재 투입 완료`,
data: results,
});
} catch (error: any) {
await client.query("ROLLBACK").catch(() => {});
logger.error("[pop/production] material-input 오류:", error);
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
};
/**
* 자재 투입 현황 조회
* processId 는 wop_result.id (접수 카드) 기준
*/
export const getMaterialInputs = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { processId } = req.params;
if (!processId) {
return res
.status(400)
.json({ success: false, message: "processId 필수" });
}
const result = await pool.query(
`SELECT id, detail_content as item_code, item_title as item_name,
result_value as input_qty, unit, remark, recorded_by, recorded_at
FROM process_work_result
WHERE work_order_process_id = $1 AND company_code = $2
AND detail_type = 'material_input'
ORDER BY recorded_at ASC`,
[processId, companyCode],
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("[pop/production] material-inputs 조회 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 체크리스트 조회
* processId 는 wop_result.id (접수 카드) 기준
*/
export const getChecklistItems = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { processId } = req.params;
if (!processId) {
return res
.status(400)
.json({ success: false, message: "processId 필수" });
}
// 검사기준 마스터(inspection_standard) JOIN — 판단기준(judgment_criteria) 전달용.
// - 체크리스트 row 의 inspection_code 는 비어있는 경우가 많아(템플릿 미연결)
// 1차로 검사항목명(detail_content/item_title) ↔ inspection_standard.inspection_item 로 매칭,
// 2차로 inspection_code 매칭을 fallback 으로 둔다.
// - 매칭된 검사기준의 unit/lower_limit/upper_limit 도 보조 노출(체크리스트 row 자체값 우선).
const result = await pool.query(
`SELECT
pwr.id,
pwr.company_code,
pwr.work_order_process_id,
pwr.source_work_item_id,
pwr.source_detail_id,
pwr.work_phase,
pwr.item_title,
pwr.item_sort_order,
pwr.detail_content,
pwr.detail_type,
pwr.detail_sort_order,
pwr.is_required,
pwr.inspection_code,
pwr.inspection_method,
COALESCE(NULLIF(pwr.unit, ''), ist.unit) AS unit,
pwr.lower_limit,
pwr.upper_limit,
pwr.input_type,
pwr.lookup_target,
pwr.display_fields,
pwr.duration_minutes,
pwr.status,
pwr.result_value,
pwr.is_passed,
pwr.remark,
pwr.recorded_by,
pwr.recorded_at,
pwr.started_at,
pwr.group_started_at,
pwr.group_paused_at,
pwr.group_total_paused_time,
pwr.group_completed_at,
ist.judgment_criteria,
ist.selection_options,
ist.inspection_code AS matched_inspection_code
FROM process_work_result pwr
LEFT JOIN LATERAL (
SELECT s.judgment_criteria, s.unit, s.inspection_code, s.selection_options
FROM inspection_standard s
WHERE s.company_code = pwr.company_code
AND (
(NULLIF(pwr.inspection_code, '') IS NOT NULL
AND s.inspection_code = pwr.inspection_code)
-- detail_content 에는 UI 구분자(" |") 가 함께 저장된 경우가 있어 양끝 공백/'|' 제거 후 비교
OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, ''))
= TRIM(BOTH ' |' FROM COALESCE(pwr.detail_content, ''))
OR TRIM(BOTH ' |' FROM COALESCE(s.inspection_item, ''))
= TRIM(BOTH ' |' FROM COALESCE(pwr.item_title, ''))
)
-- inspection_code 정확매칭을 최우선, 그 다음 항목명 매칭
ORDER BY CASE WHEN NULLIF(pwr.inspection_code, '') IS NOT NULL
AND s.inspection_code = pwr.inspection_code THEN 0 ELSE 1 END
LIMIT 1
) ist ON true
WHERE pwr.work_order_process_id = $1
AND pwr.company_code = $2
ORDER BY
COALESCE(NULLIF(pwr.item_sort_order, '')::int, 0),
COALESCE(NULLIF(pwr.detail_sort_order, '')::int, 0)`,
[processId, companyCode],
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("[pop/production] checklist-items 조회 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/* ================================================================== */
/* getProcessList — 신 구조 (work_order_process + work_order_process_result) */
/* ================================================================== */
let _processListIndexesEnsured = false;
async function ensureProcessListIndexes() {
if (_processListIndexesEnsured) return;
try {
const pool = getPool();
await pool.query(
"CREATE INDEX IF NOT EXISTS idx_wop_wo_seq ON work_order_process (wo_id, seq_no)",
);
_processListIndexesEnsured = true;
} catch {
/* ignore */
}
}
export const getProcessList = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
await ensureBatchIdColumn();
await ensureProcessListIndexes();
// 마스터 wop 행 + wop_result 집계 + 앞공정 양품 + 접수 카드 배열
const sql = `
WITH wop AS (
SELECT w.*, COALESCE(wi.qty, '0') AS instruction_qty
FROM work_order_process w
LEFT JOIN work_instruction wi
ON w.wo_id = wi.id AND w.company_code = wi.company_code
WHERE w.company_code = $1
),
wr_agg AS (
SELECT wop_id,
COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0) AS sum_good,
COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0) AS sum_defect,
COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0) AS sum_concession,
COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0) AS sum_total,
COALESCE(SUM(CAST(NULLIF(input_qty, '') AS numeric)), 0) AS sum_input,
COALESCE(SUM(GREATEST(
CAST(COALESCE(NULLIF(input_qty, ''), '0') AS numeric),
CAST(COALESCE(NULLIF(total_production_qty, ''), '0') AS numeric)
)) FILTER (WHERE is_rework IS NULL OR is_rework NOT IN ('Y','true','1')), 0) AS sum_input_norework,
BOOL_OR(result_status = 'confirmed') AS has_confirmed,
BOOL_OR(result_status = 'skipped') AS has_skipped,
BOOL_AND(status = 'completed') AS all_completed,
COUNT(*) AS accept_count
FROM work_order_process_result
WHERE company_code = $1
GROUP BY wop_id
),
prev_good_raw AS (
SELECT
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)
FROM work_order_process_result wr2
WHERE wr2.company_code = wop.company_code
AND wr2.wop_id = (
SELECT wop2.id FROM work_order_process wop2
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
)
ORDER BY CAST(wop2.seq_no AS int) DESC
LIMIT 1
)
) AS prev_good_qty_raw,
EXISTS (
SELECT 1 FROM work_order_process wop2
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 (
SELECT 1 FROM work_order_process wop2
JOIN work_order_process_result wr2
ON wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code
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
JOIN work_order_process_result wr2
ON wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code
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
) AS has_prior_blocking,
EXISTS (
SELECT 1 FROM work_order_process wop2
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
WHERE wr2.wop_id = wop2.id AND wr2.company_code = wop2.company_code
)
) AS has_unfinished_required_prior
FROM work_order_process wop
WHERE wop.company_code = $1
),
prev_good AS (
SELECT
pgr.wop_id,
pgr.has_prior_blocking,
CASE
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(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,
JSON_AGG(
JSON_BUILD_OBJECT(
'id', id,
'seq', seq,
'status', status,
'result_status', result_status,
'input_qty', input_qty,
'good_qty', good_qty,
'defect_qty', defect_qty,
'concession_qty', concession_qty,
'total_production_qty', total_production_qty,
'is_rework', is_rework,
'rework_source_id', rework_source_id,
'accepted_by', accepted_by,
'accepted_at', accepted_at,
'started_at', started_at,
'completed_at', completed_at,
'equipment_code', equipment_code,
'defect_detail', defect_detail,
'result_note', result_note,
'batch_id', batch_id
) ORDER BY seq
) AS accepted_results
FROM work_order_process_result
WHERE company_code = $1
GROUP BY wop_id
)
SELECT
wop.*,
COALESCE(wa.sum_good, 0) AS good_qty,
COALESCE(wa.sum_defect, 0) AS defect_qty,
COALESCE(wa.sum_concession, 0) AS concession_qty,
COALESCE(wa.sum_total, 0) AS total_production_qty,
COALESCE(wa.sum_input, 0) AS input_qty,
CASE
-- 비필수 진행중 차단
WHEN COALESCE(pg.has_prior_blocking, false) THEN 'waiting'
-- 자기 wop 이 자동 skip 처리됨 (confirmed 없음) → completed
WHEN wa.has_skipped AND NOT wa.has_confirmed AND wa.all_completed THEN 'completed'
-- 추가 접수가능량 > 0 → acceptable (master 카드: 분할 접수 권유. 진행중 row 는 split 카드로 별도 표시)
WHEN GREATEST(0, COALESCE(pg.prev_good_qty, 0) - COALESCE(wa.sum_input_norework, 0)) > 0 THEN 'acceptable'
-- 잔량 0 + 자기 row 진행중 → in_progress
WHEN wa.accept_count > 0 AND NOT wa.all_completed THEN 'in_progress'
-- 잔량 0 + 자기 row 전부 confirmed → completed
WHEN wa.has_confirmed AND wa.all_completed THEN 'completed'
-- wop_result 0건 + prev_good_qty 0 → waiting
ELSE 'waiting'
END AS status,
CASE
WHEN wa.has_confirmed THEN 'confirmed'
WHEN wa.has_skipped THEN 'skipped'
ELSE 'draft'
END AS result_status,
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,
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
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]);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("[pop/production] processes 조회 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 단일 접수 카드 조회 (신규 엔드포인트)
* GET /api/pop/production/result/:id
* :id = work_order_process_result.id
*/
export const getProcessResult = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
if (!id) {
return res
.status(400)
.json({ success: false, message: "id는 필수입니다." });
}
const result = await pool.query(
`SELECT wr.*,
wop.process_code,
wop.process_name,
wop.seq_no,
wop.wo_id,
wop.routing_detail_id,
wop.plan_qty,
wop.target_warehouse_id,
wop.target_location_code
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 wr.id = $1 AND wr.company_code = $2`,
[id, companyCode],
);
if (result.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "접수 카드를 찾을 수 없습니다." });
}
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("[pop/production] get-process-result 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
export const autoCompleteProcess = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id } = req.body;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
const wopr = await pool.query(
`SELECT wr.id, wr.input_qty, wr.status, wr.result_status, wr.wop_id
FROM work_order_process_result wr
WHERE wr.id = $1 AND wr.company_code = $2`,
[work_order_process_id, companyCode],
);
if (wopr.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "접수 카드를 찾을 수 없습니다." });
}
const prev = wopr.rows[0];
if (prev.result_status === "confirmed" || prev.status === "completed") {
return res
.status(403)
.json({ success: false, message: "이미 확정된 실적입니다." });
}
const prCheck = await pool.query(
`SELECT COUNT(*)::int AS cnt FROM process_work_result
WHERE work_order_process_id = $1 AND company_code = $2
AND detail_type = 'production_result'`,
[work_order_process_id, companyCode],
);
if ((prCheck.rows[0]?.cnt ?? 0) > 0) {
return res.status(400).json({
success: false,
message: "실적등록 항목이 존재하는 공정은 자동 완료할 수 없습니다.",
});
}
const acceptedQty = parseInt(prev.input_qty, 10) || 0;
if (acceptedQty <= 0) {
return res
.status(400)
.json({ success: false, message: "접수 수량이 없습니다." });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await client.query(
`UPDATE work_order_process_result
SET status = 'completed',
result_status = 'confirmed',
total_production_qty = $3,
good_qty = $3,
defect_qty = '0',
concession_qty = '0',
completed_at = NOW()::text,
completed_by = $4,
writer = $4,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`,
[work_order_process_id, companyCode, String(acceptedQty), userId],
);
const wopLookup = await client.query(
`SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[result.rows[0].wop_id, companyCode],
);
await client.query("COMMIT");
if ((wopLookup.rowCount ?? 0) > 0) {
await checkAndCompleteWorkInstruction(
pool,
wopLookup.rows[0].wo_id,
companyCode,
userId,
);
}
return res.json({ success: true, data: result.rows[0] });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("[pop/production] auto-complete 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "자동 완료 처리 중 오류가 발생했습니다.",
});
}
};
/**
* 실적 이력 조회 — work_order_process_result 기준 (wop_result.id)
*/
export const savePackaging = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
work_order_process_result_id,
packages,
loading_code,
loading_name,
} = req.body;
if (!work_order_process_result_id) {
return res.status(400).json({
success: false,
message: "work_order_process_result_id는 필수입니다.",
});
}
const procInfo = await pool.query(
`SELECT wr.id, wr.wop_id, wop.wo_id,
wi.work_instruction_no
FROM work_order_process_result wr
JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code
JOIN work_instruction wi ON wi.id = wop.wo_id AND wi.company_code = wop.company_code
WHERE wr.id = $1 AND wr.company_code = $2`,
[work_order_process_result_id, companyCode],
);
if (procInfo.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "실적을 찾을 수 없습니다." });
}
const proc = procInfo.rows[0];
const labelPrefix: string =
proc.work_instruction_no || work_order_process_result_id;
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
`DELETE FROM transaction_packaging
WHERE company_code = $1
AND source_type = 'work_order_process_result'
AND source_id = $2`,
[companyCode, work_order_process_result_id],
);
const loadingId = await ensureLoadingInstance(client, {
companyCode,
sourceType: "work_order_process_result",
sourceDocId: work_order_process_result_id,
loadingCode: loading_code || null,
loadingName: loading_name || null,
loadingSeq: 1,
writer: userId,
});
const rawPkgs: any[] = Array.isArray(packages) ? packages : [];
const packagesInput = rawPkgs
.map((p) => ({
pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""),
count: Number(p?.count ?? 0),
qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0),
}))
.filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0);
let labels: string[] = [];
if (packagesInput.length > 0) {
labels = await insertPackagingRows(client, {
companyCode,
sourceType: "work_order_process_result",
sourceId: work_order_process_result_id,
inboundNumber: labelPrefix,
packages: packagesInput,
loadingId,
warehouseCode: null,
locationCode: null,
writer: userId,
});
}
await client.query("COMMIT");
return res.json({
success: true,
message: "포장/적재함이 저장되었습니다.",
data: {
loading_id: loadingId,
loading_code: loading_code || null,
loading_name: loading_name || null,
packages: packagesInput,
labels,
},
});
} catch (txErr: any) {
await client.query("ROLLBACK").catch(() => {});
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("[pop/production] save-packaging 오류:", error);
return res
.status(500)
.json({ success: false, message: error.message || "포장/적재함 저장 오류" });
}
};
/**
* 공정 실적에 저장된 포장단위/적재함 조회
*/
export const getProcessPackaging = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { wopResultId } = req.params;
if (!wopResultId) {
return res.status(400).json({
success: false,
message: "wopResultId는 필수입니다.",
});
}
const pkgResult = await pool.query(
`SELECT id, package_label, pkg_code, quantity, loading_id, seq_no, status
FROM transaction_packaging
WHERE company_code = $1
AND source_type = 'work_order_process_result'
AND source_id = $2
ORDER BY seq_no ASC`,
[companyCode, wopResultId],
);
const loadingResult = await pool.query(
`SELECT tl.id, tl.loading_code, tl.loading_name, tl.loading_seq,
lu.loading_type, lu.max_load_kg, lu.max_stack
FROM transaction_loading tl
LEFT JOIN loading_unit lu ON lu.loading_code = tl.loading_code AND lu.company_code = tl.company_code
WHERE tl.company_code = $1
AND tl.source_type = 'work_order_process_result'
AND tl.source_doc_id = $2
ORDER BY tl.loading_seq ASC
LIMIT 1`,
[companyCode, wopResultId],
);
return res.json({
success: true,
data: {
loading: loadingResult.rows[0] || null,
packages: pkgResult.rows,
},
});
} catch (error: any) {
logger.error("[pop/production] get-packaging 오류:", error);
return res
.status(500)
.json({ success: false, message: error.message || "포장 조회 오류" });
}
};