Files
vexplor_dev/backend-node/src/controllers/popProductionController.ts
SeongHyun Kim 8c23f48996 feat: POP 재고관리 전면 구현 — 재고조정/재고이동/다중품목 공정
재고조정:
- fullBleed 좌우 분할, 숫자키패드 모달, 위치불일치 QR스캔+모달
- 임시저장 cart_items 상태관리 (saved/cancelled/confirmed)
- 조정이력 별도 페이지, DateRangePicker 통일
- popInventoryController 11개 API (adjust-batch, stock-detail, locations 등)

재고이동:
- 창고 탭: 탭 버튼 패턴 + flat 리스트 (아코디언 제거)
- 공정 탭: 공정명/설비 필터 모달 (작업지시번호 탭 제거)
- move-batch API: 창고→창고 + 공정→창고 (source_type 확장)
- 품목 이력 바텀시트 (transaction_type별 색상)

다중품목 공정실행:
- syncWorkInstructions LIMIT 1 제거 → detail 전체 순회
- batch_id 기반 품목별 공정 분리
- WorkOrderList/ProcessWork 품목 구분 표시

기타:
- PopShell fullBleed 모드 추가
- alert() → 토스트 메시지 교체
- MonitoringSettings import 수정
2026-04-10 17:17:23 +09:00

3562 lines
111 KiB
TypeScript

import type { Response } from "express";
import { getPool } from "../database/db";
import type { AuthenticatedRequest } from "../middleware/authMiddleware";
import logger from "../utils/logger";
// 불량 상세 항목 타입
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 사용 불가)
*/
async function upsertInventoryStock(
client: { query: (text: string, values?: any[]) => Promise<any> },
companyCode: string,
itemCode: string,
warehouseCode: string,
locationCode: string | null,
qty: number,
userId: string,
): Promise<void> {
const whCode = warehouseCode || null;
const locCode = locationCode || null;
const existing = await client.query(
`SELECT id 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 || ""],
);
if (existing.rows.length > 0) {
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, existing.rows[0].id],
);
} else {
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)`,
[companyCode, itemCode, whCode, locCode, String(qty), userId],
);
}
}
/**
* 체크리스트 복사 공통 함수
* 분할 행/재작업 카드 생성 시 마스터의 체크리스트를 새 행에 복사한다.
*
* 전략: routingDetailId가 있으면 원본 템플릿에서, 없으면 마스터의 기존 결과에서 복사
*/
async function copyChecklistToSplit(
client: { query: (text: string, values?: any[]) => Promise<any> },
masterProcessId: string,
newProcessId: string,
routingDetailId: string | null,
companyCode: string,
userId: string,
): Promise<number> {
// A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사
if (routingDetailId) {
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`,
[newProcessId, userId, routingDetailId, companyCode],
);
const countA = result.rowCount ?? 0;
if (countA > 0) return countA;
// A 전략에서 0건이면 B 전략(마스터 행의 기존 결과 복사)으로 fallthrough
}
// B. routing_detail_id가 없거나 A 전략에서 0건이면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화)
const result = await client.query(
`INSERT INTO process_work_result (
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
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`,
[newProcessId, userId, masterProcessId, companyCode],
);
return result.rowCount ?? 0;
}
/**
* 내부 헬퍼: 단일 작업지시에 대해 work_order_process + process_work_result 일괄 생성
* createWorkProcesses와 syncWorkInstructions 양쪽에서 재사용한다.
*
* @returns 생성된 공정 목록 + 체크리스트 총 수. 이미 존재하면 null 반환.
*/
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> {
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리)
if (batchId) {
// 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip
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`,
[workInstructionId, companyCode, batchId],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
}
} else {
// 기존 동작: batch_id 없으면 wo_id 전체로 체크
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; // 이미 존재
}
}
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
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; // 공정 없음
}
const processes: Array<{
id: string;
seq_no: string;
process_name: string;
checklist_count: number;
}> = [];
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
// 2. work_order_process INSERT (batch_id 포함)
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,
status, routing_detail_id, batch_id, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
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,
parseInt(rd.seq_no, 10) === 1 || rd.is_fixed_order === "Y"
? "acceptable"
: "waiting",
rd.id,
batchId || null,
userId,
],
);
const wopId = wopResult.rows[0].id;
// 3. process_work_result INSERT (공통 함수로 체크리스트 복사)
const checklistCount = await copyChecklistToSplit(
client,
wopId,
wopId,
rd.id,
companyCode,
userId,
);
totalChecklists += checklistCount;
processes.push({
id: wopId,
seq_no: rd.seq_no,
process_name: rd.process_name,
checklist_count: checklistCount,
});
}
return { processes, total_checklists: totalChecklists };
}
/**
* D-BE1: 작업지시 공정 일괄 생성
* PC에서 작업지시 생성 후 호출. 1 트랜잭션으로 work_order_process + process_work_result 일괄 생성.
*/
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는 필수입니다.",
});
}
logger.info("[pop/production] create-work-processes 요청", {
companyCode,
userId,
work_instruction_id,
item_code,
routing_version_id,
plan_qty,
});
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
work_instruction_id,
routing_version_id,
plan_qty,
companyCode,
userId,
);
if (!result) {
await client.query("ROLLBACK");
return res.status(409).json({
success: false,
message: "이미 공정이 생성된 작업지시이거나 라우팅에 공정이 없습니다.",
});
}
await client.query("COMMIT");
logger.info("[pop/production] create-work-processes 완료", {
companyCode,
work_instruction_id,
total_processes: result.processes.length,
total_checklists: result.total_checklists,
});
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
* routing이 있지만 work_order_process가 없는 작업지시를 찾아 공정을 자동 생성한다.
* 각 건별 개별 try-catch로 하나 실패해도 나머지 진행.
*/
export const syncWorkInstructions = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
logger.info("[pop/production] sync-work-instructions 요청", {
companyCode,
userId,
});
// 미동기화 작업지시 조회 — 다중 품목(detail) 지원
// 1단계: header routing이 있고 아직 공정이 없는 작업지시 (기존 호환)
// 2단계: detail별 routing이 있고 해당 detail의 공정(batch_id)이 없는 항목
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 (
-- header routing이 있는데 공정이 아예 없는 경우
(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
-- detail에 routing이 있는 경우 (다중 품목 지원)
EXISTS (
SELECT 1 FROM work_instruction_detail wid
WHERE wid.work_instruction_no = wi.work_instruction_no
AND wid.company_code = $1
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.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) {
// detail 목록 조회: routing_version_id가 있고 qty > 0인 것
const detailResult = await pool.query(
`SELECT wid.item_number, wid.routing_version_id, wid.qty
FROM work_instruction_detail wid
WHERE wid.work_instruction_no = $1 AND wid.company_code = $2
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.work_instruction_no, companyCode],
);
const detailRows = detailResult.rows;
if (detailRows.length === 0 && wi.header_routing) {
// detail이 없지만 header routing이 있는 경우: 기존 방식 (단일 품목)
// header에 routing/qty/item_id 자동 보정
const firstDetail = await pool.query(
`SELECT routing_version_id, qty, item_number
FROM work_instruction_detail
WHERE work_instruction_no = $1 AND company_code = $2
LIMIT 1`,
[wi.work_instruction_no, companyCode],
);
const wid = firstDetail.rows[0];
if (wid) {
await pool.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)`,
[wi.id, wid.routing_version_id, wid.qty, wid.item_number, companyCode],
);
}
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,
});
logger.info("[pop/production] sync: 공정 생성 완료 (header routing)", {
work_instruction_no: wi.work_instruction_no,
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 || "알 수 없는 오류",
});
logger.error("[pop/production] sync: header routing 오류", {
work_instruction_no: wi.work_instruction_no,
error: err.message,
});
} finally {
client.release();
}
continue;
}
// 다중 품목: 각 detail별로 공정 생성 (batch_id = item_number)
// header routing/item_id도 첫 번째 detail 기준 보정
if (detailRows.length > 0) {
const first = detailRows[0];
await pool.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)`,
[wi.id, first.routing_version_id, first.qty, first.item_number, companyCode],
);
}
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.item_number, // batch_id = item_number
);
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,
});
logger.info("[pop/production] sync: 다중품목 공정 생성 완료", {
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
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 || "알 수 없는 오류",
});
logger.error("[pop/production] sync: 다중품목 개별 오류", {
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
error: err.message,
});
} finally {
client.release();
}
}
}
logger.info("[pop/production] sync-work-instructions 완료", {
companyCode,
synced,
skipped,
errors,
});
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 || "작업지시 동기화 중 오류가 발생했습니다.",
});
}
};
/**
* D-BE2: 타이머 API (시작/일시정지/재시작)
*/
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 중 하나여야 합니다.",
});
}
logger.info("[pop/production] timer 요청", {
companyCode,
userId,
work_order_process_id,
action,
});
let result;
switch (action) {
case "start":
// 최초 1회만 설정, 이미 있으면 무시
result = await pool.query(
`UPDATE work_order_process
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
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":
// 일시정지 시간 누적 후 paused_at 초기화
result = await pool.query(
`UPDATE work_order_process
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
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`,
[
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: "대상 공정을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
});
}
logger.info("[pop/production] timer 완료", {
action,
work_order_process_id,
result: result.rows[0],
});
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 || "타이머 처리 중 오류가 발생했습니다.",
});
}
};
/**
* 그룹(작업항목)별 타이머 제어
* 좌측 사이드바의 각 작업 그룹마다 독립적인 시작/정지/재개/완료 타이머
*/
export const controlGroupTimer = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { work_order_process_id, source_work_item_id, action } = req.body;
if (!work_order_process_id || !source_work_item_id || !action) {
return res.status(400).json({
success: false,
message:
"work_order_process_id, source_work_item_id, action은 필수입니다.",
});
}
if (!["start", "pause", "resume", "complete"].includes(action)) {
return res.status(400).json({
success: false,
message: "action은 start, pause, resume, complete 중 하나여야 합니다.",
});
}
logger.info("[pop/production] group-timer 요청", {
companyCode,
work_order_process_id,
source_work_item_id,
action,
});
const whereClause = `work_order_process_id = $1 AND source_work_item_id = $2 AND company_code = $3`;
const baseParams = [
work_order_process_id,
source_work_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,
);
await pool.query(
`UPDATE work_order_process
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,
);
break;
}
}
if (!result || result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "대상 그룹을 찾을 수 없거나 현재 상태에서 수행할 수 없습니다.",
});
}
logger.info("[pop/production] group-timer 완료", {
action,
source_work_item_id,
affectedRows: result.rowCount,
});
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)
*/
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 = 'Y'
ORDER BY defect_code`;
params = [];
} else {
query = `
SELECT id, defect_code, defect_name, defect_type, severity, company_code
FROM defect_standard_mng
WHERE is_active = 'Y' AND company_code = $1
ORDER BY defect_code`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info("[pop/production] defect-types 조회", {
companyCode,
count: result.rowCount,
});
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 || "불량 유형 조회 중 오류가 발생했습니다.",
});
}
};
/**
* 실적 저장 (누적 방식)
* 이번 차수 생산수량을 기존 누적치에 더한다.
* result_status는 'draft' 유지 (확정 전까지 계속 추가 등록 가능)
*/
export const saveResult = async (req: AuthenticatedRequest, res: Response) => {
const pool = getPool();
try {
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,
} = req.body;
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 wop.status, wop.result_status, wop.total_production_qty, wop.good_qty,
wop.defect_qty, wop.concession_qty, wop.defect_detail,
wop.input_qty, wop.parent_process_id, wop.wo_id, wop.seq_no
FROM work_order_process wop
WHERE wop.id = $1 AND wop.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.parent_process_id) {
const splitCheck = await pool.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE parent_process_id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
if (parseInt(splitCheck.rows[0].cnt, 10) > 0) {
return res.status(400).json({
success: false,
message:
"원본 공정에는 직접 실적을 등록할 수 없습니다. 분할된 접수 카드에서 등록해주세요.",
});
}
}
if (prev.result_status === "confirmed") {
return res.status(403).json({
success: false,
message: "이미 확정된 실적입니다. 추가 등록이 불가능합니다.",
});
}
// 초과 생산 경고 (차단하지 않음 - 현장 유연성)
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,
overAmount: prevTotal + requestedQty - acceptedQty,
});
}
// 서버 측 양품/불량/특채 계산 (클라이언트 good_qty는 참고만)
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 {
/* 파싱 실패 시 빈 배열 */
}
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 pool.query(
`UPDATE work_order_process
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) {
return res.status(404).json({
success: false,
message: "공정을 찾을 수 없거나 권한이 없습니다.",
});
}
// === BUG-2 FIX: SPLIT 실적 저장 후 master 행에 합산 ===
if (prev.parent_process_id) {
await pool.query(
`UPDATE work_order_process
SET good_qty = sub.sum_good,
defect_qty = sub.sum_defect,
total_production_qty = sub.sum_total,
concession_qty = sub.sum_concession,
updated_date = NOW()
FROM (
SELECT
COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good,
COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect,
COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total,
COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession
FROM work_order_process
WHERE parent_process_id = $1 AND company_code = $2
) sub
WHERE id = $1 AND company_code = $2`,
[prev.parent_process_id, companyCode],
);
logger.info("[pop/production] master 합산 업데이트", {
masterId: prev.parent_process_id,
splitId: work_order_process_id,
});
}
// 현재 분할 행의 공정 정보 조회
const currentSeq = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.input_qty as current_input_qty,
wop.parent_process_id, wop.process_code, wop.process_name,
wop.is_required, wop.is_fixed_order, wop.standard_time,
wop.equipment_code, 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`,
[work_order_process_id, companyCode],
);
// 재작업 카드 자동 생성 (disposition = 'rework' 항목이 있을 때)
if (
currentSeq.rowCount > 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];
const masterId = proc.parent_process_id || work_order_process_id;
// 재작업 대상 공정 결정
let reworkSeqNo = proc.seq_no;
let reworkProcessCode = proc.process_code;
let reworkProcessName = proc.process_name;
let reworkRoutingDetailId = proc.routing_detail_id;
let reworkMasterId = masterId;
// target_process_code가 지정되면 해당 공정 정보를 조회
if (targetProcessCode) {
const targetProc = await pool.query(
`SELECT id, seq_no, process_code, process_name, routing_detail_id
FROM work_order_process
WHERE wo_id = $1 AND process_code = $2 AND company_code = $3
AND parent_process_id IS NULL
LIMIT 1`,
[proc.wo_id, targetProcessCode, companyCode],
);
if (targetProc.rowCount > 0) {
const tp = targetProc.rows[0];
reworkSeqNo = tp.seq_no;
reworkProcessCode = tp.process_code;
reworkProcessName = tp.process_name;
reworkRoutingDetailId = tp.routing_detail_id;
reworkMasterId = tp.id; // 지정 공정의 마스터 ID
}
}
const reworkInsert = await pool.query(
`INSERT INTO work_order_process (
id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
status, input_qty, good_qty, defect_qty, concession_qty, total_production_qty,
result_status, is_rework, rework_source_id,
parent_process_id, company_code, writer
) VALUES (
gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9,
'acceptable', $10, '0', '0', '0', '0',
'draft', 'Y', $11,
$12, $13, $14
) RETURNING id`,
[
proc.wo_id,
reworkSeqNo,
reworkProcessCode,
reworkProcessName,
proc.is_required,
proc.is_fixed_order,
proc.standard_time,
proc.equipment_code,
reworkRoutingDetailId,
String(totalReworkQty),
work_order_process_id,
reworkMasterId,
companyCode,
userId,
],
);
// 재작업 카드에 체크리스트 복사
const reworkId = reworkInsert.rows[0]?.id;
if (reworkId) {
const reworkChecklistCount = await copyChecklistToSplit(
pool,
reworkMasterId,
reworkId,
reworkRoutingDetailId,
companyCode,
userId,
);
logger.info("[pop/production] 재작업 카드 자동 생성", {
reworkId,
sourceId: work_order_process_id,
reworkQty: totalReworkQty,
targetProcess: targetProcessCode || "(같은 공정)",
reworkSeqNo,
checklistCount: reworkChecklistCount,
});
}
}
}
// 개별 분할 행 자동완료 (다음 공정 활성화보다 먼저 실행)
if (currentSeq.rowCount > 0) {
const {
seq_no: csSeq,
wo_id: csWoId,
current_input_qty: csInputQty,
instruction_qty: csInstrQty,
parent_process_id: csParentId,
} = currentSeq.rows[0];
const csMyInput = parseInt(csInputQty, 10) || 0;
if (newTotal >= csMyInput && csMyInput > 0) {
await pool.query(
`UPDATE work_order_process 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],
);
// 같은 seq의 모든 분할 행 완료 체크 → 마스터도 completed
const csSeqNum = parseInt(csSeq, 10);
let csPrevGood = parseInt(csInstrQty, 10) || 0;
if (csSeqNum > 1) {
const prev = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as tg
FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[csWoId, String(csSeqNum - 1), companyCode],
);
if (prev.rowCount > 0)
csPrevGood = parseInt(prev.rows[0].tg, 10) || 0;
}
const sibCheck = await pool.query(
`SELECT COALESCE(SUM(input_qty::int), 0) as ti, COUNT(*) FILTER (WHERE status != 'completed') as ic
FROM work_order_process WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3 AND parent_process_id IS NOT NULL`,
[csWoId, csSeq, companyCode],
);
const csTotalInput = parseInt(sibCheck.rows[0].ti, 10) || 0;
const csIncomplete = parseInt(sibCheck.rows[0].ic, 10) || 0;
if (
csIncomplete === 0 &&
csPrevGood - csTotalInput <= 0 &&
csParentId
) {
await pool.query(
`UPDATE work_order_process 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'`,
[csParentId, companyCode, userId],
);
}
}
await checkAndCompleteWorkInstruction(pool, csWoId, companyCode, userId);
}
// 다음 공정 활성화 (다중공정 대응)
// is_fixed_order='Y' 그룹이면 그룹 전체 완료 후 다음 활성화
if (addGood > 0 && currentSeq.rowCount > 0) {
const { seq_no, wo_id, is_fixed_order } = currentSeq.rows[0];
const seqNum = parseInt(seq_no, 10);
let shouldActivateNext = true;
if (is_fixed_order === "Y") {
// 같은 seq_no에서 is_fixed_order='Y'인 병렬 공정이 모두 완료되었는지 확인
// (병렬 그룹 = 같은 seq_no를 공유하는 공정들)
const groupCheck = await pool.query(
`SELECT id, seq_no, status,
COALESCE(good_qty::int, 0) + COALESCE(concession_qty::int, 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND parent_process_id IS NULL
AND seq_no = $3
ORDER BY CAST(seq_no AS int)`,
[wo_id, companyCode, seq_no],
);
// 같은 seq의 미완료 공정 확인 (병렬 그룹 내)
const incomplete = groupCheck.rows.filter(
(r: Record<string, unknown>) =>
String(r.status) !== "completed" &&
parseInt(String(r.total_good), 10) <= 0,
);
shouldActivateNext = incomplete.length === 0;
if (!shouldActivateNext) {
logger.info("[pop/production] 병렬 그룹 미완료 — 다음 공정 대기", {
groupSize: groupCheck.rows.length,
incomplete: incomplete.length,
});
}
}
if (shouldActivateNext) {
// 다음 seq 활성화 (seq_no 비순차 대응: seqNum+1이 아니라 "현재보다 큰 가장 작은 seq_no")
const nextSeqQuery = await pool.query(
`SELECT MIN(CAST(seq_no AS int)) as next_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL
AND CAST(seq_no AS int) > $3`,
[wo_id, companyCode, seqNum],
);
const actualNextSeq = nextSeqQuery.rows[0]?.next_seq;
if (actualNextSeq != null) {
const nextUpdate = await pool.query(
`UPDATE work_order_process
SET status = 'acceptable',
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NULL
RETURNING id, process_name, status`,
[wo_id, String(actualNextSeq), companyCode],
);
if (nextUpdate.rowCount > 0) {
logger.info("[pop/production] 다음 공정 상태 전환", {
nextProcess: nextUpdate.rows[0],
});
}
}
}
}
// (분할행 완료 + 마스터 캐스케이드는 위에서 이미 처리됨)
logger.info("[pop/production] save-result 완료 (누적)", {
companyCode,
work_order_process_id,
added: {
production_qty: addProduction,
good_qty: addGood,
defect_qty: addDefect,
},
accumulated: { total: newTotal, good: newGood, defect: newDefect },
});
// 자동 완료 후 최신 데이터 반환 (status가 변경되었을 수 있음)
const latestData = await pool.query(
`SELECT id, total_production_qty, good_qty, defect_qty, concession_qty, defect_detail, result_note, result_status, status, input_qty
FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
// 리워크 정보도 응답에 포함 (프론트에서 다음 공정 접수 시 전달 가능)
const responseData = latestData.rows[0] || result.rows[0];
if (responseData) {
const reworkInfo = await pool.query(
`SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`,
[work_order_process_id],
);
if (reworkInfo.rows[0]?.rework_source_id) {
responseData.rework_source_id = reworkInfo.rows[0].rework_source_id;
responseData.is_rework = reworkInfo.rows[0].is_rework;
}
}
return res.json({
success: true,
data: responseData,
});
} catch (error: any) {
logger.error("[pop/production] save-result 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "실적 저장 중 오류가 발생했습니다.",
});
}
};
/**
* 작업지시(work_instruction) 전체 완료 판정
* 마지막 공정의 모든 행이 completed이면 작업지시도 완료 처리
*/
const checkAndCompleteWorkInstruction = async (
pool: any,
woId: string,
companyCode: string,
userId: string,
) => {
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`,
[woId, companyCode],
);
if (maxSeqResult.rowCount === 0 || !maxSeqResult.rows[0].max_seq) return;
const maxSeq = String(maxSeqResult.rows[0].max_seq);
const incompleteCheck = await pool.query(
`SELECT COUNT(*) as cnt
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND status != 'completed'`,
[woId, maxSeq, companyCode],
);
if (parseInt(incompleteCheck.rows[0].cnt, 10) > 0) return;
const totalGoodResult = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[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],
);
logger.info("[pop/production] 작업지시 전체 완료", {
woId,
completedQty,
companyCode,
});
// 생산완료→재고 입고: 마지막 공정의 target_warehouse_id가 설정된 경우 inventory_stock UPSERT
if (updateResult.rowCount > 0 && completedQty > 0) {
try {
const itemId = updateResult.rows[0].item_id;
// item_info에서 item_number 조회
const itemResult = await pool.query(
`SELECT item_number FROM item_info WHERE id = $1 AND company_code = $2`,
[itemId, companyCode],
);
if (itemResult.rowCount === 0) {
logger.warn("[pop/production] 재고입고 건너뜀: item_info 없음", {
itemId,
companyCode,
});
return;
}
const itemCode = itemResult.rows[0].item_number;
// 마지막 공정의 창고 설정 조회 (마스터 행에서)
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
AND parent_process_id IS NULL
LIMIT 1`,
[woId, maxSeq, companyCode],
);
if (
warehouseResult.rowCount === 0 ||
!warehouseResult.rows[0].target_warehouse_id
) {
logger.info("[pop/production] 재고입고 건너뜀: 목표창고 미설정", {
woId,
});
return;
}
const warehouseCode = warehouseResult.rows[0].target_warehouse_id;
const locationCode =
warehouseResult.rows[0].target_location_code || warehouseCode;
// inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴)
await upsertInventoryStock(
pool,
companyCode,
itemCode,
warehouseCode,
locationCode,
completedQty,
userId,
);
logger.info("[pop/production] 생산완료→재고 입고 완료", {
woId,
itemCode,
warehouseCode,
locationCode,
qty: completedQty,
companyCode,
});
} catch (inventoryError: any) {
// 재고 입고 실패해도 공정 완료는 유지 (재고는 보조 기능)
logger.error(
"[pop/production] 재고입고 오류 (공정 완료는 유지):",
inventoryError,
);
}
}
};
/**
* 실적 확정은 더 이상 단일 확정이 아님.
* 마지막 등록 확인 용도로 유지. 실적은 save-result에서 차수별로 쌓임.
*/
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
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: "등록된 실적이 없습니다. 실적을 먼저 등록해주세요.",
});
}
// 수동 확정: 무조건 completed 처리 (수동 완료 용도)
const result = await pool.query(
`UPDATE work_order_process
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`,
[work_order_process_id, companyCode, userId],
);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "공정을 찾을 수 없습니다.",
});
}
// 공정 정보 조회 (다음 공정 활성화 + 마스터 캐스케이드용)
const seqCheck = await pool.query(
`SELECT wop.seq_no, wop.wo_id, wop.parent_process_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 (seqCheck.rowCount > 0) {
const { seq_no, wo_id, parent_process_id, instruction_qty } =
seqCheck.rows[0];
const seqNum = parseInt(seq_no, 10);
const instrQty = parseInt(instruction_qty, 10) || 0;
// 다음 공정 활성화 (양품이 있으면)
const goodQty = parseInt(result.rows[0].good_qty, 10) || 0;
if (goodQty > 0) {
const nextSeq = String(seqNum + 1);
await pool.query(
`UPDATE work_order_process
SET status = 'acceptable',
updated_date = NOW()
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NULL`,
[wo_id, nextSeq, companyCode],
);
}
// === BUG-2 FIX: confirmResult에서도 master 합산 ===
if (parent_process_id) {
await pool.query(
`UPDATE work_order_process
SET good_qty = sub.sum_good,
defect_qty = sub.sum_defect,
total_production_qty = sub.sum_total,
concession_qty = sub.sum_concession,
updated_date = NOW()
FROM (
SELECT
COALESCE(SUM(CAST(NULLIF(good_qty, '') AS numeric)), 0)::text AS sum_good,
COALESCE(SUM(CAST(NULLIF(defect_qty, '') AS numeric)), 0)::text AS sum_defect,
COALESCE(SUM(CAST(NULLIF(total_production_qty, '') AS numeric)), 0)::text AS sum_total,
COALESCE(SUM(CAST(NULLIF(concession_qty, '') AS numeric)), 0)::text AS sum_concession
FROM work_order_process
WHERE parent_process_id = $1 AND company_code = $2
) sub
WHERE id = $1 AND company_code = $2`,
[parent_process_id, companyCode],
);
}
// 마스터 자동완료 캐스케이드 (분할 행인 경우)
if (parent_process_id) {
let prevGoodQty = instrQty;
if (seqNum > 1) {
const prevSeq = String(seqNum - 1);
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[wo_id, prevSeq, companyCode],
);
if (prevProcess.rowCount > 0) {
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
const siblingCheck = await pool.query(
`SELECT
COALESCE(SUM(input_qty::int), 0) as total_input,
COUNT(*) FILTER (WHERE status != 'completed') as incomplete_count
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[wo_id, seq_no, companyCode],
);
const totalInput = parseInt(siblingCheck.rows[0].total_input, 10) || 0;
const incompleteCount =
parseInt(siblingCheck.rows[0].incomplete_count, 10) || 0;
const remainingAcceptable = prevGoodQty - totalInput;
if (incompleteCount === 0 && remainingAcceptable <= 0) {
await pool.query(
`UPDATE work_order_process
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'`,
[parent_process_id, companyCode, userId],
);
logger.info("[pop/production] confirmResult: 마스터 자동 완료", {
masterId: parent_process_id,
totalInput,
prevGoodQty,
});
}
}
// 작업지시 전체 완료 판정
await checkAndCompleteWorkInstruction(pool, wo_id, companyCode, userId);
}
logger.info("[pop/production] confirm-result 완료", {
companyCode,
work_order_process_id,
userId,
finalStatus: result.rows[0].status,
});
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_log에서 차수별 추출)
* total_production_qty 변경 이력 = 각 차수의 등록 기록
*/
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 WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
if (ownerCheck.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "공정을 찾을 수 없습니다." });
}
// 같은 changed_at 기준으로 그룹핑하여 차수별 이력 추출
// total_production_qty가 증가한(new_value > old_value) 로그만 = 실적 등록 시점
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,
};
});
logger.info("[pop/production] result-history 조회", {
work_order_process_id,
batchCount: batches.length,
});
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 || "이력 조회 중 오류가 발생했습니다.",
});
}
};
/**
* 앞공정 완료량 + 접수가능량 조회
* GET /api/pop/production/available-qty?work_order_process_id=xxx
* 반환: { prevGoodQty, myInputQty, availableQty, instructionQty }
*/
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.seq_no, wop.wo_id, wop.parent_process_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 } = current.rows[0];
const instrQty = parseInt(instruction_qty, 10) || 0;
const seqNum = parseInt(seq_no, 10);
// 재작업 카드 여부 확인
const reworkCheck = await pool.query(
`SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`,
[work_order_process_id],
);
const isRework = reworkCheck.rows[0]?.is_rework === "Y";
let myInputQty: number;
let prevGoodQty: number;
let availableQty: number;
if (isRework) {
// 재작업 카드: 자체 input_qty가 접수 가능 수량
const reworkInput = parseInt(reworkCheck.rows[0]?.input_qty, 10) || 0;
myInputQty = 0;
prevGoodQty = reworkInput;
availableQty = reworkInput;
} else {
// 일반 카드: 앞공정 양품 - 기접수합계 (재작업 카드 제외)
const totalAccepted = await pool.query(
`SELECT COALESCE(SUM(input_qty::int), 0) as total_input
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL
AND (is_rework IS NULL OR is_rework != 'Y')`,
[wo_id, seq_no, companyCode],
);
myInputQty = parseInt(totalAccepted.rows[0].total_input, 10) || 0;
prevGoodQty = instrQty;
// 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단
// (라우팅 seq_no가 1, 2, 3이 아니라 10, 20, 30 같은 비순차여도 정상 동작)
const minSeqCheck = await pool.query(
`SELECT MIN(CAST(seq_no AS int)) as min_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
[wo_id, companyCode],
);
const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum;
const isFirstProcess = seqNum <= minSeq;
if (!isFirstProcess) {
// 이전 공정 찾기 (seq_no가 더 작은 가장 가까운 공정)
const prevProcessSeq = await pool.query(
`SELECT MAX(CAST(seq_no AS int)) as prev_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL
AND CAST(seq_no AS int) < $3`,
[wo_id, companyCode, seqNum],
);
const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq;
if (actualPrevSeq != null) {
const prevProcess = await pool.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[wo_id, String(actualPrevSeq), companyCode],
);
if (prevProcess.rowCount > 0) {
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
}
availableQty = Math.max(0, prevGoodQty - myInputQty);
}
logger.info("[pop/production] available-qty 조회", {
work_order_process_id,
prevGoodQty,
myInputQty,
availableQty,
instructionQty: instrQty,
});
// 앞공정에서 리워크로 완료된 양품 수량 (마크 표시용)
// rework_source_id별로 개별 추적하여 정확한 미소진 리워크 수량 계산
let reworkAvailableQty = 0;
if (!isRework && seqNum > 1) {
const prevSeq = String(seqNum - 1);
// 앞공정의 리워크 완료 SPLIT들 (rework_source_id별)
const reworkSplits = await pool.query(
`SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rg
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL
AND is_rework = 'Y'
AND status = 'completed'
AND good_qty::int > 0
GROUP BY rework_source_id`,
[wo_id, prevSeq, companyCode],
);
// 현재 공정에서 각 rework_source_id별로 소비된 수량
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(input_qty::int), 0) as consumed
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL
AND is_rework = 'Y'
AND 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 || "접수가능량 조회 중 오류가 발생했습니다.",
});
}
};
/**
* 공정 접수 (수량 지정)
* POST /api/pop/production/accept-process
* body: { work_order_process_id, accept_qty }
* - 접수 상한 = 앞공정.good_qty - 내.input_qty (첫 공정은 지시수량 - input_qty)
* - 추가 접수 가능 (in_progress 상태에서도)
* - status: acceptable/waiting -> in_progress (또는 이미 in_progress면 유지)
*/
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) {
client.release();
return res.status(400).json({
success: false,
message: "work_order_process_id와 accept_qty가 필요합니다.",
});
}
const qty = parseInt(accept_qty, 10);
if (qty <= 0) {
client.release();
return res
.status(400)
.json({ success: false, message: "접수 수량은 1 이상이어야 합니다." });
}
await client.query("BEGIN");
// 원본(마스터) 행 조회 + FOR UPDATE (동시 접수 방지)
const current = await client.query(
`SELECT wop.id, wop.seq_no, wop.wo_id, wop.status, wop.parent_process_id,
wop.process_code, wop.process_name, wop.is_required, wop.is_fixed_order,
wop.standard_time, wop.equipment_code, 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");
client.release();
return res
.status(404)
.json({ success: false, message: "공정을 찾을 수 없습니다." });
}
const row = current.rows[0];
const masterId = row.parent_process_id || row.id;
if (row.status === "completed") {
await client.query("ROLLBACK");
client.release();
return res
.status(400)
.json({ success: false, message: "이미 완료된 공정입니다." });
}
if (row.status !== "acceptable") {
await client.query("ROLLBACK");
client.release();
return res.status(400).json({
success: false,
message: `원본 공정 상태(${row.status})에서는 접수할 수 없습니다.`,
});
}
const instrQty = parseInt(row.instruction_qty, 10) || 0;
const seqNum = parseInt(row.seq_no, 10);
// 재작업 카드 여부 확인
const isReworkCard = await client.query(
`SELECT is_rework, input_qty FROM work_order_process WHERE id = $1`,
[work_order_process_id],
);
const isRework = isReworkCard.rows[0]?.is_rework === "Y";
const reworkInputQty = parseInt(isReworkCard.rows[0]?.input_qty, 10) || 0;
let prevGoodQty: number;
let currentTotalInput: number;
let availableQty: number;
if (isRework) {
// 재작업 카드: 자체 input_qty가 접수 가능 수량 (앞공정과 무관)
prevGoodQty = reworkInputQty;
currentTotalInput = 0; // 재작업 카드는 자체가 마스터, 분할 행 없음
availableQty = reworkInputQty;
} else {
// 일반 카드: 앞공정 양품 - 기접수합계
const totalAccepted = await client.query(
`SELECT COALESCE(SUM(input_qty::int), 0) as total_input
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL
AND (is_rework IS NULL OR is_rework != 'Y')`,
[row.wo_id, row.seq_no, companyCode],
);
currentTotalInput = parseInt(totalAccepted.rows[0].total_input, 10) || 0;
prevGoodQty = instrQty;
// 첫 공정 여부를 seq_no==1이 아니라 "이 공정보다 작은 seq_no가 있는지"로 판단
const minSeqCheck = await client.query(
`SELECT MIN(CAST(seq_no AS int)) as min_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
[row.wo_id, companyCode],
);
const minSeq = parseInt(minSeqCheck.rows[0]?.min_seq, 10) || seqNum;
const isFirstProcess = seqNum <= minSeq;
if (!isFirstProcess) {
// 이전 공정 = 이 공정보다 작은 seq_no 중 가장 큰 값
const prevProcessSeq = await client.query(
`SELECT MAX(CAST(seq_no AS int)) as prev_seq
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND parent_process_id IS NULL
AND CAST(seq_no AS int) < $3`,
[row.wo_id, companyCode, seqNum],
);
const actualPrevSeq = prevProcessSeq.rows[0]?.prev_seq;
const prevSeq =
actualPrevSeq != null ? String(actualPrevSeq) : String(seqNum - 1);
const prevProcess = await client.query(
`SELECT COALESCE(SUM(good_qty::int), 0) + COALESCE(SUM(concession_qty::int), 0) as total_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL`,
[row.wo_id, prevSeq, companyCode],
);
if (prevProcess.rowCount > 0) {
prevGoodQty = parseInt(prevProcess.rows[0].total_good, 10) || 0;
}
}
availableQty = prevGoodQty - currentTotalInput;
}
if (qty > availableQty) {
await client.query("ROLLBACK");
client.release();
return res.status(400).json({
success: false,
message: `접수가능량(${availableQty})을 초과합니다. (앞공정 완료: ${prevGoodQty}, 기접수합계: ${currentTotalInput})`,
});
}
// batch_id: 컬럼이 있으면 포함, 없으면 제외
const batchId =
req.body.batch_id ||
`BATCH-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
const hasBatchCol = _batchMigrationDone;
// 리워크 정보 전달: 리워크 카드 접수 / 프론트 전달 / 자동 감지
let splitIsRework: string | null = null;
let splitReworkSourceId: string | null = null;
if (isRework) {
// 케이스 1: 리워크 카드에서 직접 접수
const parentReworkInfo = await client.query(
`SELECT is_rework, rework_source_id FROM work_order_process WHERE id = $1`,
[work_order_process_id],
);
splitIsRework = parentReworkInfo?.rows[0]?.is_rework || null;
splitReworkSourceId = parentReworkInfo?.rows[0]?.rework_source_id || null;
} else if (req.body.rework_source_id) {
// 케이스 2: 프론트에서 리워크 추적 정보 전달
splitIsRework = "Y";
splitReworkSourceId = req.body.rework_source_id;
} else if (seqNum > 1) {
// 케이스 3: 자동 감지 — 앞공정에서 리워크로 완료된 양품이 있는지 확인
const prevSeq = String(seqNum - 1);
// rework_source_id별로 개별 추적
const prevReworkSplits = await client.query(
`SELECT rework_source_id, COALESCE(SUM(good_qty::int), 0) as rework_good
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL
AND is_rework = 'Y'
AND status = 'completed'
AND good_qty::int > 0
GROUP BY rework_source_id`,
[row.wo_id, prevSeq, companyCode],
);
// 각 rework_source별로 미소진 수량 확인
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(input_qty::int), 0) as consumed
FROM work_order_process
WHERE wo_id = $1 AND seq_no = $2 AND company_code = $3
AND parent_process_id IS NOT NULL
AND is_rework = 'Y'
AND 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;
}
// normalAvailable > 0 → 합류 가능 → 마크 없음 (splitIsRework = null)
break;
}
}
}
// 분할 행 INSERT (batch_id는 컬럼 존재 시에만, 리워크 정보 포함)
const reworkCols = splitIsRework ? ", is_rework, rework_source_id" : "";
const reworkVals = splitIsRework
? `, $${hasBatchCol ? 15 : 14}, $${hasBatchCol ? 16 : 15}`
: "";
const reworkParams = splitIsRework
? [splitIsRework, splitReworkSourceId]
: [];
const insertCols = `id, wo_id, seq_no, process_code, process_name, is_required, is_fixed_order,
standard_time, equipment_code, routing_detail_id,
status, input_qty, good_qty, defect_qty, total_production_qty,
result_status, accepted_by, accepted_at, started_at,
parent_process_id, company_code, writer${hasBatchCol ? ", batch_id" : ""}${reworkCols}`;
const insertVals = `gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9,
'in_progress', $10, '0', '0', '0',
'draft', $11, NOW()::text, NOW()::text,
$12, $13, $11${hasBatchCol ? ", $14" : ""}${reworkVals}`;
const insertParams = [
row.wo_id,
row.seq_no,
row.process_code,
row.process_name,
row.is_required,
row.is_fixed_order,
row.standard_time,
row.equipment_code,
row.routing_detail_id,
String(qty),
userId,
masterId,
companyCode,
...(hasBatchCol ? [batchId] : []),
...reworkParams,
];
const result = await client.query(
`INSERT INTO work_order_process (${insertCols}) VALUES (${insertVals})
RETURNING id, input_qty, status, process_name, result_status, accepted_by`,
insertParams,
);
// 분할 행에 체크리스트 복사
const splitId = result.rows[0].id;
const checklistCount = await copyChecklistToSplit(
client,
masterId,
splitId,
row.routing_detail_id,
companyCode,
userId,
);
// 마스터 행의 input_qty를 분할 합계로 갱신 (리워크 접수 시에는 마스터 input_qty 변경 안 함)
let newTotalInput = currentTotalInput + qty;
if (!isRework) {
await client.query(
`UPDATE work_order_process SET input_qty = $3, updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[masterId, companyCode, String(newTotalInput)],
);
} else {
newTotalInput = currentTotalInput; // 리워크는 기존 합계 유지
// 리워크 카드: 전량 접수 시에만 이 카드만 completed로 변경
// (다른 리워크 카드에 영향 없도록 id 정확히 지정)
const reworkAlreadyAccepted = await client.query(
`SELECT COALESCE(SUM(input_qty::int), 0) as total
FROM work_order_process
WHERE parent_process_id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
const totalReworkAccepted =
(parseInt(reworkAlreadyAccepted.rows[0]?.total, 10) || 0) + qty;
if (totalReworkAccepted >= reworkInputQty) {
await client.query(
`UPDATE work_order_process SET status = 'completed', updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
}
}
await client.query("COMMIT");
logger.info("[pop/production] accept-process 분할 접수 완료", {
companyCode,
userId,
masterId,
splitId,
acceptedQty: qty,
totalAccepted: newTotalInput,
prevGoodQty,
checklistCount,
});
const acceptData = result.rows[0] || {};
if (splitReworkSourceId) {
acceptData.rework_source_id = splitReworkSourceId;
acceptData.is_rework = splitIsRework;
}
return res.json({
success: true,
data: acceptData,
message: `${qty}개 접수 완료 (총 접수합계: ${newTotalInput})`,
});
} 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();
}
};
/**
* 접수 취소: input_qty를 0으로 리셋하고 status를 acceptable로 되돌림
* 조건: 아직 실적(total_production_qty)이 없어야 함
*/
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 id, status, input_qty, total_production_qty, result_status,
parent_process_id, wo_id, seq_no, process_name,
target_warehouse_id, target_location_code, good_qty, concession_qty
FROM work_order_process
WHERE id = $1 AND 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.parent_process_id) {
return res.status(400).json({
success: false,
message:
"원본 공정은 접수 취소할 수 없습니다. 분할된 접수 카드에서 취소해주세요.",
});
}
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) {
// 실적이 없으면 체크리스트 먼저 삭제 → 분할 행 삭제
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 WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
} else {
// 실적이 있으면 input_qty를 실적 수량으로 축소 + completed
await client.query(
`UPDATE work_order_process
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],
);
}
// 재고 원복: 분할 행에 target_warehouse_id가 있으면 입고된 수량을 차감
if (proc.target_warehouse_id) {
const inboundQty =
parseInt(proc.good_qty || "0", 10) +
parseInt(proc.concession_qty || "0", 10);
if (inboundQty > 0) {
// work_instruction에서 item_id 조회
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) {
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) {
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,
],
);
}
}
}
}
// 마스터 행의 input_qty를 분할 합계로 재계산
const remainingSplits = await client.query(
`SELECT COALESCE(SUM(input_qty::int), 0) as total_input
FROM work_order_process
WHERE parent_process_id = $1 AND company_code = $2`,
[proc.parent_process_id, companyCode],
);
const newMasterInput =
parseInt(remainingSplits.rows[0].total_input, 10) || 0;
// 원본(마스터) 행: input_qty 복원 + acceptable 상태 유지
await client.query(
`UPDATE work_order_process
SET status = 'acceptable', input_qty = $3, updated_date = NOW()
WHERE id = $1 AND company_code = $2 AND parent_process_id IS NULL`,
[proc.parent_process_id, companyCode, String(newMasterInput)],
);
await client.query("COMMIT");
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
logger.info("[pop/production] cancel-accept 완료 (분할 행)", {
companyCode,
userId,
work_order_process_id,
masterId: proc.parent_process_id,
previousInputQty: currentInputQty,
totalProduced,
cancelledQty,
action: totalProduced === 0 ? "DELETE" : "SHRINK",
});
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 });
}
};
/**
* 특정 창고의 위치(로케이션) 목록 조회
* warehouseId는 warehouse_info.id → warehouse_code를 조회해서 warehouse_location과 매칭
*/
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는 필수입니다." });
}
// warehouse_info.id → warehouse_code 변환
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 });
}
};
/**
* 마지막 공정 여부 확인
* 같은 wo_id에서 현재 seq_no보다 큰 공정(마스터 행)이 없으면 마지막
*/
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 } });
}
// 현재 공정의 wo_id와 seq_no 조회 (분할 행이면 parent의 seq_no 기준)
const process = await pool.query(
`SELECT wo_id, seq_no, parent_process_id
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, parent_process_id } = process.rows[0];
// 분할 행이면 마스터의 seq_no 기준으로 판단
let effectiveSeqNo = seq_no;
if (parent_process_id) {
const master = await pool.query(
`SELECT seq_no FROM work_order_process WHERE id = $1 AND company_code = $2`,
[parent_process_id, companyCode],
);
if (master.rowCount > 0) {
effectiveSeqNo = master.rows[0].seq_no;
}
}
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 parent_process_id IS NULL
LIMIT 1`,
[wo_id, companyCode, effectiveSeqNo],
);
// 현재 공정의 기존 창고 설정도 반환 (기본값 세팅용)
const warehouseInfo = await pool.query(
`SELECT target_warehouse_id, target_location_code
FROM work_order_process
WHERE id = $1 AND company_code = $2`,
[processId, companyCode],
);
return res.json({
success: true,
data: {
isLast: next.rowCount === 0,
woId: wo_id,
seqNo: effectiveSeqNo,
targetWarehouseId: warehouseInfo.rows[0]?.target_warehouse_id || null,
targetLocationCode: warehouseInfo.rows[0]?.target_location_code || null,
},
});
} catch (error: any) {
logger.error("[pop/production] 마지막 공정 확인 오류:", error);
return res.status(500).json({ success: false, message: error.message });
}
};
/**
* 공정의 목표 창고/위치 업데이트
* 마지막 공정 완료 전 또는 완료 후 창고를 지정한다.
* 마스터 행에 저장하여 checkAndCompleteWorkInstruction이 참조할 수 있도록 한다.
*/
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는 필수입니다.",
});
}
// 분할 행이면 마스터 행도 함께 업데이트
const procInfo = await pool.query(
`SELECT parent_process_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
const idsToUpdate = [work_order_process_id];
if (procInfo.rowCount > 0 && procInfo.rows[0].parent_process_id) {
idsToUpdate.push(procInfo.rows[0].parent_process_id);
}
for (const id of idsToUpdate) {
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`,
[
id,
companyCode,
target_warehouse_id,
target_location_code || null,
userId,
],
);
}
logger.info("[pop/production] 목표 창고 업데이트", {
companyCode,
userId,
work_order_process_id,
target_warehouse_id,
target_location_code,
updatedIds: idsToUpdate,
});
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 });
}
};
/**
* 독립 재고 입고 API
* 창고 저장 + inventory_stock UPSERT를 한 번에 수행한다.
* 실적(save-result) 완료 후 나중에 창고를 선택해도 재고가 들어가도록 분리.
* 이중 입고 방지: target_warehouse_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");
// 1. work_order_process에서 wo_id, good_qty, parent_process_id, 기존 target_warehouse_id 조회
const procResult = await client.query(
`SELECT wo_id, good_qty, concession_qty, parent_process_id, target_warehouse_id, seq_no, is_rework
FROM work_order_process
WHERE id = $1 AND 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];
// 이중 입고 방지: 이미 target_warehouse_id가 설정되어 있으면 거부
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이므로 재고 입고할 수 없습니다.",
});
}
// 2. work_instruction에서 item_id 조회
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;
// 3. item_info에서 item_number 조회
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;
// 4. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴)
await upsertInventoryStock(
client,
companyCode,
itemCode,
warehouse_code,
effectiveLocationCode,
goodQty,
userId,
);
// 5. work_order_process에 target_warehouse_id 저장 (현재 행 + 마스터 행)
const idsToUpdate = [work_order_process_id];
if (proc.parent_process_id) {
idsToUpdate.push(proc.parent_process_id);
}
for (const id of idsToUpdate) {
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`,
[id, companyCode, warehouse_code, location_code || null, userId],
);
}
// 6. 리워크 마크 해제 (창고 입고 = 정상 제품 인정, 이력은 rework_source_id에 영구 보존)
if (proc.is_rework === "Y") {
await client.query(
`UPDATE work_order_process SET is_rework = NULL, updated_date = NOW()
WHERE id = $1 AND company_code = $2`,
[work_order_process_id, companyCode],
);
}
await client.query("COMMIT");
logger.info("[pop/production] 독립 재고 입고 완료", {
companyCode,
userId,
work_order_process_id,
itemCode,
warehouse_code,
location_code: effectiveLocationCode,
qty: goodQty,
reworkCleared: proc.is_rework === "Y",
});
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();
}
};
/**
* 간이 재고 입고 (공정 접수 없이 바로 입고)
* 품목 + 수량 + 창고만으로 inventory_stock UPSERT + inbound_mng 이력 기록
*/
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");
// 1. item_info에서 item_number, item_name 조회
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;
// 2. inventory_stock UPSERT (PC receivingController와 동일한 SELECT→INSERT/UPDATE 패턴)
await upsertInventoryStock(
client,
companyCode,
itemCode,
warehouse_code,
effectiveLocationCode,
parsedQty,
userId,
);
// 3. inbound_mng에 간이입고 이력 기록
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");
logger.info("[pop/production] 간이 재고 입고 완료", {
companyCode,
userId,
item_id,
itemCode,
warehouse_code,
location_code: effectiveLocationCode,
qty: parsedQty,
inboundNumber,
});
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();
}
};
/**
* 재작업 이력 조회
* 작업지시(wo_id) 기준으로 모든 재작업 체인을 반환한다.
* 원본 → 재작업1 → 재작업2 → ... 순서로 체인 추적.
*/
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 id, seq_no, process_code, process_name, status,
input_qty, good_qty, defect_qty, concession_qty,
is_rework, rework_source_id, parent_process_id,
accepted_by, accepted_at, started_at, completed_at,
created_date
FROM work_order_process
WHERE wo_id = $1 AND company_code = $2
AND (is_rework = 'Y' OR is_rework = '1' OR defect_qty::int > 0 OR parent_process_id IS NOT NULL)
ORDER BY created_date ASC`,
[woId, companyCode],
);
// 체인 구성: rework_source_id를 따라 트리 구조
const rows = result.rows;
const byId: Record<string, (typeof rows)[0]> = {};
for (const r of rows) byId[r.id] = r;
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 자재 목록 + 소요량 계산
* work_order_process_id → item_code → bom + bom_detail 조회
*/
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 필수" });
}
// 1. work_order_process → work_instruction → item_code, plan_qty
const procResult = await pool.query(
`SELECT wop.wo_id, wop.process_code, wop.input_qty, wop.plan_qty,
wi.item_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`,
[processId, companyCode],
);
if (procResult.rowCount === 0) {
return res.json({
success: true,
data: { materials: [], processQty: 0 },
});
}
const proc = procResult.rows[0];
const processQty = parseInt(
proc.input_qty || proc.plan_qty || proc.instruction_qty || "0",
10,
);
// 2. item_info → item_code (item_number)
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;
// 3. BOM 조회
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
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],
);
// 4. 소요량 계산
const bomBase = await pool.query(
`SELECT base_qty FROM bom WHERE (item_code = $1 OR item_id = $2) AND company_code = $3 LIMIT 1`,
[itemCode, proc.item_id, companyCode],
);
const baseQty = parseFloat(bomBase.rows[0]?.base_qty || "1") || 1;
// 기존 투입량 조회 (item_code별 합산 — detail_content에 item_code 저장됨)
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 work_order_process_id = $1 AND company_code = $2 AND detail_type = 'material_input'
AND result_value IS NOT NULL AND result_value != ''
GROUP BY detail_content`,
[processId, companyCode],
);
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 });
}
};
/**
* 자재 투입 기록 저장
* BOM 기준과 다른 수량도 허용 (유동 투입)
*/
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;
// item_code/qty 등 대안 필드명도 허용
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;
// 투입 기록 INSERT (process_work_result에 material_input 타입으로)
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,
],
);
// 재고 차감: warehouse_code 있으면 그 창고, 없으면 자동으로 재고가 있는 창고 탐색
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();
}
};
/**
* 자재 투입 현황 조회
*/
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 });
}
};
/**
* 체크리스트 조회 (judgment_criteria 조인 포함)
*
* process_work_result를 조회하면서 inspection_standard.judgment_criteria를
* LEFT JOIN으로 같이 반환한다.
*
* UI는 프론트의 resolveInputType()에서
* 1순위: judgment_criteria (CAT_JC_01~04)
* 2순위: detail_type 폴백
* 으로 입력 UI를 결정한다.
*/
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 필수" });
}
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,
pwr.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
FROM process_work_result pwr
LEFT JOIN inspection_standard ist
ON pwr.inspection_code = ist.inspection_code
AND pwr.company_code = ist.company_code
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 });
}
};