Re-apply mhkim work lost in jskim-node conflict resolution

Backend:
- outboundController.ts: restore sales_order_id/shipment_plan_id/item_info_id columns in outbound_mng INSERT; restore getProductionResults endpoint
- popProductionController.ts: restore transactionPackagingService import (ensureLoadingInstance/insertPackagingRows); restore material auto-input + inventory_stock deduction before WIP trigger; restore autoCompleteProcess/savePackaging/getProcessPackaging endpoints
- workInstructionController.ts: restore getProcessResults function for production-result right panel
- workInstructionRoutes.ts: restore GET /:wiId/process-results route

Frontend:
- COMPANY_7/production/work-instruction/page.tsx: restore Lock icon, WorkRow detailId/locked fields, items mapping detailId, locked column UI with lock icon and disabled cells
- COMPANY_8/10/16/28/29/production/work-instruction/page.tsx: restore SelectedItem baseQty/splitMode fields, calcBatchCount/splitQty helpers, expandedItems batch split logic in finalizeRegistration payload (keeps jskim infos field)
- COMPANY_8/logistics/inbound-outbound/page.tsx: restore autoFilter:true (company scope) for user_info writer lookup — replaces jskim autoFilter:{enabled:false} which violated multitenancy policy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kmh
2026-05-22 09:39:54 +09:00
parent 9da6b22a18
commit 15fa3e37f9
11 changed files with 821 additions and 28 deletions

View File

@@ -144,7 +144,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
outbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
outbound_status, manager_id, memo,
source_table, source_id,
source_table, source_id, sales_order_id, shipment_plan_id, item_info_id,
destination_code, delivery_destination, delivery_address,
created_date, created_by, writer, status
) VALUES (
@@ -154,9 +154,9 @@ export async function create(req: AuthenticatedRequest, res: Response) {
$13, $14, $15,
$16, $17, $18,
$19, $20, $21,
$22, $23,
$24, $25, $26,
NOW(), $27, $27, '출고'
$22, $23, $24, $25, $26,
$27, $28, $29,
NOW(), $30, $30, '출고'
) RETURNING *`,
[
companyCode,
@@ -180,8 +180,11 @@ export async function create(req: AuthenticatedRequest, res: Response) {
item.outbound_status || "대기",
manager_id || item.manager_id || null,
memo || item.memo || null,
item.source_table || null,
item.source_type || item.source_table || null,
item.source_id || null,
item.sales_order_id || null,
item.shipment_plan_id || null,
item.item_info_id || null,
item.destination_code || null,
item.delivery_destination || null,
item.delivery_address || null,
@@ -853,3 +856,149 @@ export async function getLocations(req: AuthenticatedRequest, res: Response) {
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getProductionResults(
req: AuthenticatedRequest,
res: Response,
) {
try {
const companyCode = req.user!.companyCode;
const { processCode, keyword, pageSize } = req.query;
const limit = Math.min(500, Math.max(1, Number(pageSize) || 100));
const params: any[] = [companyCode];
let paramIdx = 2;
let processCondition = "";
if (processCode) {
processCondition = `AND wop.process_code = $${paramIdx}`;
params.push(processCode);
paramIdx++;
}
let keywordCondition = "";
if (keyword) {
keywordCondition = `AND (wi.work_instruction_no ILIKE $${paramIdx} OR COALESCE(ii.item_name, '') ILIKE $${paramIdx} OR COALESCE(ii.item_number, '') ILIKE $${paramIdx})`;
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const dataResult = await pool.query(
`SELECT
wr.id,
wop.id AS wop_id,
wop.wo_id,
wi.work_instruction_no,
wi.start_date AS order_date,
COALESCE(CAST(NULLIF(wi.qty, '') AS numeric), 0) AS instruction_qty,
wop.process_code,
wop.process_name,
wop.seq_no,
COALESCE(ii.item_number, wi.item_id) AS item_code,
COALESCE(ii.item_name, ii.item_number, wi.item_id) AS item_name,
COALESCE(ii.size, '') AS spec,
COALESCE(ii.material, '') AS material,
NULLIF(ii.unit, '') AS unit,
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0) AS good_qty,
COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS concession_qty,
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0) AS order_qty,
COALESCE(ship.shipped_qty, 0) AS shipped_qty,
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
- COALESCE(ship.shipped_qty, 0) AS remain_qty,
'work_order_process_result' AS source_table,
wr.result_status,
COALESCE(ii.image, NULL) AS image,
CASE WHEN EXISTS (
SELECT 1 FROM item_inspection_info iii
WHERE iii.company_code = wop.company_code
AND COALESCE(iii.is_active, 'Y') IN ('Y', '사용')
AND iii.item_code = COALESCE(ii.item_number, wi.item_id)
) THEN 'self' ELSE NULL END AS inspection_type,
tp_agg.packages,
tl.loading_code,
tl.loading_name
FROM work_order_process_result wr
JOIN work_order_process wop
ON wop.id = wr.wop_id
AND wop.company_code = wr.company_code
JOIN work_instruction wi
ON wi.id = wop.wo_id
AND wi.company_code = wop.company_code
LEFT JOIN (
SELECT DISTINCT ON (id, company_code)
id, item_number, item_name, size, material, unit, image, company_code
FROM item_info
ORDER BY id, company_code, created_date DESC
) ii ON wi.item_id = ii.id AND wi.company_code = ii.company_code
LEFT JOIN (
SELECT source_id, company_code,
SUM(COALESCE(CAST(NULLIF(outbound_qty::text, '') AS numeric), 0)) AS shipped_qty
FROM outbound_mng
WHERE source_table = 'work_order_process_result'
AND company_code = $1
AND source_id IS NOT NULL
GROUP BY source_id, company_code
) ship ON ship.source_id = wr.id AND ship.company_code = wr.company_code
LEFT JOIN (
SELECT packed.source_id, packed.company_code,
JSON_AGG(JSON_BUILD_OBJECT(
'pkg_code', packed.pkg_code,
'pkg_name', COALESCE(pu.pkg_name, packed.pkg_code),
'count', packed.cnt,
'qty_per_unit', packed.qty_per_unit
)) AS packages
FROM (
SELECT source_id, company_code, pkg_code,
CAST(quantity AS numeric) AS qty_per_unit,
COUNT(*)::int AS cnt
FROM transaction_packaging
WHERE company_code = $1
AND source_type = 'work_order_process_result'
GROUP BY source_id, company_code, pkg_code, quantity
) packed
LEFT JOIN pkg_unit pu
ON pu.pkg_code = packed.pkg_code
AND pu.company_code = packed.company_code
GROUP BY packed.source_id, packed.company_code
) tp_agg ON tp_agg.source_id = wr.id AND tp_agg.company_code = wr.company_code
LEFT JOIN (
SELECT DISTINCT ON (source_doc_id, company_code)
source_doc_id, company_code,
loading_code, loading_name
FROM transaction_loading
WHERE company_code = $1
AND source_type = 'work_order_process_result'
ORDER BY source_doc_id, company_code, COALESCE(loading_seq, 1) ASC
) tl ON tl.source_doc_id = wr.id AND tl.company_code = wr.company_code
WHERE wr.company_code = $1
AND CAST(wop.seq_no AS int) = (
SELECT MAX(CAST(wop2.seq_no AS int))
FROM work_order_process wop2
WHERE wop2.wo_id = wop.wo_id
AND wop2.company_code = wop.company_code
)
AND (
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
) > 0
AND (
COALESCE(CAST(NULLIF(wr.good_qty, '') AS numeric), 0)
+ COALESCE(CAST(NULLIF(wr.concession_qty, '') AS numeric), 0)
- COALESCE(ship.shipped_qty, 0)
) > 0
${processCondition}
${keywordCondition}
ORDER BY wi.work_instruction_no, wr.created_date NULLS LAST
LIMIT ${limit}`,
params,
);
return res.json({ success: true, data: dataResult.rows });
} catch (error: any) {
logger.error("생산출고 소스 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -10,6 +10,10 @@ import {
onProcessMove,
onResultSaved,
} from "../services/wipStockService";
import {
ensureLoadingInstance,
insertPackagingRows,
} from "../services/transactionPackagingService";
/**
* user_id → user_name(한글명) 조회 헬퍼 — wip_stock_history.manager_name 기록용.
@@ -1304,6 +1308,7 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => {
defect_qty,
defect_detail,
result_note,
material_inputs,
} = req.body;
// validation: BEGIN 이전에 처리
@@ -1575,6 +1580,102 @@ export const saveResult = async (req: AuthenticatedRequest, res: Response) => {
await checkAndCompleteWorkInstruction(client, csWoId, companyCode, userId);
}
// 자재 자동 투입 + 재고 차감 (이번 차수 생산수량 × 1개당 소요량)
// material_inputs 가 있으면 process_work_result INSERT + inventory_stock 차감.
// 재고 부족 시 ROLLBACK + 400 응답.
if (Array.isArray(material_inputs) && material_inputs.length > 0) {
for (const mi of material_inputs) {
const childItemId = mi.child_item_id;
const childItemCode = mi.child_item_code;
const childItemName = mi.child_item_name || "";
const inputQty = parseFloat(String(mi.input_qty));
const unit = mi.unit || "";
const bomDetailId = mi.bom_detail_id || null;
const requiredQty = mi.required_qty != null ? String(mi.required_qty) : null;
if (!childItemId || !childItemCode || isNaN(inputQty) || inputQty <= 0) {
continue;
}
// 창고/로케이션 자동 선택 (기존 saveMaterialInput 패턴: 가장 최근 입고 우선)
const autoStock = await client.query(
`SELECT warehouse_code, location_code,
COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) AS current_qty
FROM inventory_stock
WHERE company_code = $1 AND item_code = $2
AND COALESCE(CAST(NULLIF(current_qty, '') AS numeric), 0) > 0
ORDER BY last_in_date DESC NULLS LAST LIMIT 1`,
[companyCode, childItemCode],
);
if (autoStock.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 0)`,
});
}
const stockRow = autoStock.rows[0];
const stockQty = parseFloat(String(stockRow.current_qty)) || 0;
if (stockQty < inputQty) {
await client.query("ROLLBACK");
return res.status(400).json({
success: false,
message: `자재 재고 부족: ${childItemName || childItemCode} (현재고 ${stockQty}, 필요 ${inputQty})`,
});
}
const effectiveWh = stockRow.warehouse_code;
const effectiveLoc = stockRow.location_code || effectiveWh;
await client.query(
`INSERT INTO process_work_result (
id, company_code, work_order_process_id,
detail_type, detail_content, item_title,
result_value, unit, is_passed, status,
bom_detail_id, required_qty, warehouse_code, location_code,
recorded_by, recorded_at, writer
) VALUES (
gen_random_uuid()::text, $1, $2,
'material_input', $3, $4,
$5, $6, 'Y', 'completed',
$7, $8, $9, $10,
$11, NOW()::text, $11
)`,
[
companyCode,
work_order_process_id,
childItemCode,
childItemName,
String(inputQty),
unit,
bomDetailId,
requiredQty,
effectiveWh,
effectiveLoc,
userId,
],
);
await client.query(
`UPDATE inventory_stock
SET current_qty = (COALESCE(current_qty::numeric, 0) - $4::numeric)::text,
updated_date = NOW(), writer = $5
WHERE company_code = $1 AND item_code = $2
AND warehouse_code = $3 AND location_code = $6`,
[
companyCode,
childItemCode,
effectiveWh,
String(inputQty),
userId,
effectiveLoc,
],
);
}
}
// [WIP 적재 — 트리거 2] 실적 저장분(양품/불량 증분)을 wip_stock 에 반영
// SAVEPOINT 로 감싼다: 공유 트랜잭션(client)에서 WIP 쿼리가 throw 하면
// PG 가 트랜잭션 전체를 aborted 로 만들어 이후 COMMIT 이 ROLLBACK 처리된다.
@@ -3939,3 +4040,289 @@ export const getProcessResult = async (
return res.status(500).json({ success: false, message: error.message });
}
};
export const autoCompleteProcess = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { work_order_process_id } = req.body;
if (!work_order_process_id) {
return res.status(400).json({
success: false,
message: "work_order_process_id는 필수입니다.",
});
}
const wopr = await pool.query(
`SELECT wr.id, wr.input_qty, wr.status, wr.result_status, wr.wop_id
FROM work_order_process_result wr
WHERE wr.id = $1 AND wr.company_code = $2`,
[work_order_process_id, companyCode],
);
if (wopr.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "접수 카드를 찾을 수 없습니다." });
}
const prev = wopr.rows[0];
if (prev.result_status === "confirmed" || prev.status === "completed") {
return res
.status(403)
.json({ success: false, message: "이미 확정된 실적입니다." });
}
const prCheck = await pool.query(
`SELECT COUNT(*)::int AS cnt FROM process_work_result
WHERE work_order_process_id = $1 AND company_code = $2
AND detail_type = 'production_result'`,
[work_order_process_id, companyCode],
);
if ((prCheck.rows[0]?.cnt ?? 0) > 0) {
return res.status(400).json({
success: false,
message: "실적등록 항목이 존재하는 공정은 자동 완료할 수 없습니다.",
});
}
const acceptedQty = parseInt(prev.input_qty, 10) || 0;
if (acceptedQty <= 0) {
return res
.status(400)
.json({ success: false, message: "접수 수량이 없습니다." });
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await client.query(
`UPDATE work_order_process_result
SET status = 'completed',
result_status = 'confirmed',
total_production_qty = $3,
good_qty = $3,
defect_qty = '0',
concession_qty = '0',
completed_at = NOW()::text,
completed_by = $4,
writer = $4,
updated_date = NOW()
WHERE id = $1 AND company_code = $2
RETURNING id, status, result_status, total_production_qty, good_qty, defect_qty, wop_id`,
[work_order_process_id, companyCode, String(acceptedQty), userId],
);
const wopLookup = await client.query(
`SELECT wo_id FROM work_order_process WHERE id = $1 AND company_code = $2`,
[result.rows[0].wop_id, companyCode],
);
await client.query("COMMIT");
if ((wopLookup.rowCount ?? 0) > 0) {
await checkAndCompleteWorkInstruction(
pool,
wopLookup.rows[0].wo_id,
companyCode,
userId,
);
}
return res.json({ success: true, data: result.rows[0] });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("[pop/production] auto-complete 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "자동 완료 처리 중 오류가 발생했습니다.",
});
}
};
/**
* 실적 이력 조회 — work_order_process_result 기준 (wop_result.id)
*/
export const savePackaging = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
work_order_process_result_id,
packages,
loading_code,
loading_name,
} = req.body;
if (!work_order_process_result_id) {
return res.status(400).json({
success: false,
message: "work_order_process_result_id는 필수입니다.",
});
}
const procInfo = await pool.query(
`SELECT wr.id, wr.wop_id, wop.wo_id,
wi.work_instruction_no
FROM work_order_process_result wr
JOIN work_order_process wop ON wop.id = wr.wop_id AND wop.company_code = wr.company_code
JOIN work_instruction wi ON wi.id = wop.wo_id AND wi.company_code = wop.company_code
WHERE wr.id = $1 AND wr.company_code = $2`,
[work_order_process_result_id, companyCode],
);
if (procInfo.rowCount === 0) {
return res
.status(404)
.json({ success: false, message: "실적을 찾을 수 없습니다." });
}
const proc = procInfo.rows[0];
const labelPrefix: string =
proc.work_instruction_no || work_order_process_result_id;
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query(
`DELETE FROM transaction_packaging
WHERE company_code = $1
AND source_type = 'work_order_process_result'
AND source_id = $2`,
[companyCode, work_order_process_result_id],
);
const loadingId = await ensureLoadingInstance(client, {
companyCode,
sourceType: "work_order_process_result",
sourceDocId: work_order_process_result_id,
loadingCode: loading_code || null,
loadingName: loading_name || null,
loadingSeq: 1,
writer: userId,
});
const rawPkgs: any[] = Array.isArray(packages) ? packages : [];
const packagesInput = rawPkgs
.map((p) => ({
pkg_code: String(p?.pkg_code ?? p?.unit?.value ?? ""),
count: Number(p?.count ?? 0),
qty_per_unit: Number(p?.qty_per_unit ?? p?.qtyPerUnit ?? 0),
}))
.filter((p) => p.pkg_code && p.count > 0 && p.qty_per_unit > 0);
let labels: string[] = [];
if (packagesInput.length > 0) {
labels = await insertPackagingRows(client, {
companyCode,
sourceType: "work_order_process_result",
sourceId: work_order_process_result_id,
inboundNumber: labelPrefix,
packages: packagesInput,
loadingId,
warehouseCode: null,
locationCode: null,
writer: userId,
});
}
await client.query("COMMIT");
return res.json({
success: true,
message: "포장/적재함이 저장되었습니다.",
data: {
loading_id: loadingId,
loading_code: loading_code || null,
loading_name: loading_name || null,
packages: packagesInput,
labels,
},
});
} catch (txErr: any) {
await client.query("ROLLBACK").catch(() => {});
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("[pop/production] save-packaging 오류:", error);
return res
.status(500)
.json({ success: false, message: error.message || "포장/적재함 저장 오류" });
}
};
/**
* 공정 실적에 저장된 포장단위/적재함 조회
*/
export const getProcessPackaging = async (
req: AuthenticatedRequest,
res: Response,
) => {
const pool = getPool();
try {
const companyCode = req.user!.companyCode;
const { wopResultId } = req.params;
if (!wopResultId) {
return res.status(400).json({
success: false,
message: "wopResultId는 필수입니다.",
});
}
const pkgResult = await pool.query(
`SELECT id, package_label, pkg_code, quantity, loading_id, seq_no, status
FROM transaction_packaging
WHERE company_code = $1
AND source_type = 'work_order_process_result'
AND source_id = $2
ORDER BY seq_no ASC`,
[companyCode, wopResultId],
);
const loadingResult = await pool.query(
`SELECT tl.id, tl.loading_code, tl.loading_name, tl.loading_seq,
lu.loading_type, lu.max_load_kg, lu.max_stack
FROM transaction_loading tl
LEFT JOIN loading_unit lu ON lu.loading_code = tl.loading_code AND lu.company_code = tl.company_code
WHERE tl.company_code = $1
AND tl.source_type = 'work_order_process_result'
AND tl.source_doc_id = $2
ORDER BY tl.loading_seq ASC
LIMIT 1`,
[companyCode, wopResultId],
);
return res.json({
success: true,
data: {
loading: loadingResult.rows[0] || null,
packages: pkgResult.rows,
},
});
} catch (error: any) {
logger.error("[pop/production] get-packaging 오류:", error);
return res
.status(500)
.json({ success: false, message: error.message || "포장 조회 오류" });
}
};

View File

@@ -1503,3 +1503,56 @@ export async function getMaterialOverrides(req: AuthenticatedRequest, res: Respo
return res.status(500).json({ success: false, message: error.message });
}
}
// 작업지시 공정별 실적 (생산실적 우측 패널용)
// work_order_process LEFT JOIN work_order_process_result, 카드(result) 단위로 flat 반환.
// 공정에 카드가 없으면 wr.* 컬럼들이 모두 NULL 인 한 행을 반환.
export async function getProcessResults(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiId } = req.params;
if (!wiId) return res.status(400).json({ success: false, message: "wiId는 필수입니다." });
const pool = getPool();
const result = await pool.query(
`SELECT
wop.id AS wop_id,
wop.wo_id,
wop.seq_no AS process_seq_no,
wop.process_code,
wop.process_name,
wr.id AS result_id,
wr.seq AS result_seq,
wr.equipment_code,
wr.input_qty,
wr.good_qty,
wr.defect_qty,
wr.concession_qty,
wr.total_production_qty,
wr.started_at,
wr.completed_at,
wr.status,
wr.result_status,
wr.result_note,
wr.defect_detail,
wr.is_rework,
wr.rework_source_id
FROM work_order_process wop
LEFT JOIN work_order_process_result wr
ON wr.wop_id = wop.id
AND wr.company_code = wop.company_code
WHERE wop.wo_id = $1 AND wop.company_code = $2
ORDER BY
CASE WHEN wop.seq_no::text ~ '^[0-9]+$' THEN wop.seq_no::text::int ELSE NULL END NULLS LAST,
wop.seq_no::text,
CASE WHEN wr.seq::text ~ '^[0-9]+$' THEN wr.seq::text::int ELSE NULL END NULLS LAST,
wr.seq::text`,
[wiId, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("작업지시 공정 실적 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -24,6 +24,9 @@ router.post("/bom-base-qty", ctrl.getBomBaseQtyMap);
// BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용 — TASK:ERP-node-090 트리화)
router.get("/bom-tree/:itemCode", ctrl.getBomTree);
// 작업지시별 공정 실적 집계 (생산실적 화면 우측 패널)
router.get("/:wiId/process-results", ctrl.getProcessResults);
// 라우팅 & 공정작업기준
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.put("/:wiNo/routing", ctrl.updateRouting);