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