- Added validation for `materialOverrides` in the `save` function of the work instruction controller, ensuring proper structure and required fields. - Implemented logic to handle the insertion and deletion of material input details based on the provided `materialOverrides`, maintaining data integrity during edits. - Introduced new routes for retrieving BOM tree and material overrides, enhancing the work instruction management process. - Updated the frontend to support new material mapping features, including a structured approach for handling BOM substitutes and material inputs. (TASK: ERP-node-090)
1422 lines
68 KiB
TypeScript
1422 lines
68 KiB
TypeScript
/**
|
|
* 작업지시 컨트롤러 (work_instruction + work_instruction_detail)
|
|
*/
|
|
import { Response } from "express";
|
|
import { AuthenticatedRequest } from "../types/auth";
|
|
import { getPool } from "../database/db";
|
|
import { logger } from "../utils/logger";
|
|
import { numberingRuleService } from "../services/numberingRuleService";
|
|
import { copyChecklistToSplit } from "./popProductionController";
|
|
|
|
// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가
|
|
let _migrationDone = false;
|
|
async function ensureDetailRoutingColumn() {
|
|
if (_migrationDone) return;
|
|
try {
|
|
const pool = getPool();
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)");
|
|
// 품목별 일정/설비/작업조/작업자 컬럼 (옵션 A — 다중선택 지원)
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS start_date VARCHAR(500)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS end_date VARCHAR(500)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS equipment_ids VARCHAR(1000)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS work_teams VARCHAR(200)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS workers VARCHAR(1000)");
|
|
_migrationDone = true;
|
|
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
|
|
}
|
|
|
|
// 자동 마이그레이션: item_info에 batch_use(배치사용여부) 컬럼 추가 (TASK:ERP-node-074)
|
|
// 'Y'=사용(현행 유지, 기본) / 'N'=미사용(작업지시 자동 배치분할 안 함)
|
|
let _batchUseMigrationDone = false;
|
|
async function ensureItemInfoBatchUseColumn() {
|
|
if (_batchUseMigrationDone) return;
|
|
try {
|
|
const pool = getPool();
|
|
await pool.query("ALTER TABLE item_info ADD COLUMN IF NOT EXISTS batch_use VARCHAR(1) DEFAULT 'Y'");
|
|
await pool.query("UPDATE item_info SET batch_use = 'Y' WHERE batch_use IS NULL OR batch_use = ''");
|
|
_batchUseMigrationDone = true;
|
|
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
|
|
}
|
|
|
|
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
|
export async function getList(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
await ensureDetailRoutingColumn();
|
|
const companyCode = req.user!.companyCode;
|
|
const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query;
|
|
|
|
// 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환)
|
|
const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null;
|
|
const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null;
|
|
const paginated = pageNum !== null && sizeNum !== null;
|
|
|
|
const conditions: string[] = [];
|
|
const params: any[] = [];
|
|
let idx = 1;
|
|
|
|
if (companyCode !== "*") {
|
|
conditions.push(`wi.company_code = $${idx}`);
|
|
params.push(companyCode);
|
|
idx++;
|
|
}
|
|
if (dateFrom) {
|
|
conditions.push(`wi.start_date >= $${idx}`);
|
|
params.push(dateFrom);
|
|
idx++;
|
|
}
|
|
if (dateTo) {
|
|
conditions.push(`wi.end_date <= $${idx}`);
|
|
params.push(dateTo);
|
|
idx++;
|
|
}
|
|
if (status && status !== "all") {
|
|
conditions.push(`wi.status = $${idx}`);
|
|
params.push(status);
|
|
idx++;
|
|
}
|
|
if (progressStatus && progressStatus !== "all") {
|
|
conditions.push(`wi.progress_status = $${idx}`);
|
|
params.push(progressStatus);
|
|
idx++;
|
|
}
|
|
// keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS
|
|
if (keyword) {
|
|
conditions.push(`(
|
|
wi.work_instruction_no ILIKE $${idx}
|
|
OR wi.worker ILIKE $${idx}
|
|
OR EXISTS (
|
|
SELECT 1 FROM work_instruction_detail dd
|
|
LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code
|
|
WHERE dd.work_instruction_id = wi.id
|
|
AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx})
|
|
)
|
|
)`);
|
|
params.push(`%${keyword}%`);
|
|
idx++;
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
|
|
const pool = getPool();
|
|
|
|
// 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN
|
|
if (paginated) {
|
|
// 1) 총 WI 개수 카운트
|
|
const countSql = `
|
|
SELECT COUNT(*)::int AS cnt
|
|
FROM work_instruction wi
|
|
${whereClause}
|
|
`;
|
|
const countRes = await pool.query(countSql, params);
|
|
const totalCount = countRes.rows[0]?.cnt ?? 0;
|
|
|
|
// 2) 현재 페이지 WI id 목록 (최신 생성순, 동일 created_date일 때 번호 내림차순)
|
|
const offset = (pageNum! - 1) * sizeNum!;
|
|
const pageSql = `
|
|
SELECT wi.id
|
|
FROM work_instruction wi
|
|
${whereClause}
|
|
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC
|
|
LIMIT ${sizeNum} OFFSET ${offset}
|
|
`;
|
|
const pageRes = await pool.query(pageSql, params);
|
|
const wiIds = pageRes.rows.map((r) => r.id);
|
|
|
|
if (wiIds.length === 0) {
|
|
return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum });
|
|
}
|
|
|
|
// 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN
|
|
const dataSql = `
|
|
SELECT
|
|
wi.id AS wi_id,
|
|
wi.work_instruction_no,
|
|
wi.status,
|
|
wi.progress_status,
|
|
wi.qty AS total_qty,
|
|
wi.completed_qty,
|
|
wi.start_date,
|
|
wi.end_date,
|
|
wi.equipment_id,
|
|
wi.work_team,
|
|
wi.worker,
|
|
wi.remark AS wi_remark,
|
|
wi.created_date,
|
|
wi.batch_no,
|
|
wi.cutting_plan_id,
|
|
d.id AS detail_id,
|
|
d.item_number,
|
|
d.qty AS detail_qty,
|
|
d.remark AS detail_remark,
|
|
d.part_code,
|
|
d.source_table,
|
|
d.source_id,
|
|
d.routing_version_id AS detail_routing_version_id,
|
|
d.start_date AS detail_start_date,
|
|
d.end_date AS detail_end_date,
|
|
d.equipment_ids AS detail_equipment_ids,
|
|
d.work_teams AS detail_work_teams,
|
|
d.workers AS detail_workers,
|
|
COALESCE(itm.item_name, '') AS item_name,
|
|
COALESCE(itm.type, '') AS item_type,
|
|
COALESCE(itm.size, '') AS item_spec,
|
|
COALESCE(e.equipment_name, '') AS equipment_name,
|
|
COALESCE(e.equipment_code, '') AS equipment_code,
|
|
wi.routing AS routing_version_id,
|
|
COALESCE(rv.version_name, '') AS routing_name,
|
|
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq,
|
|
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
|
FROM work_instruction wi
|
|
INNER JOIN work_instruction_detail d
|
|
ON d.work_instruction_id = wi.id
|
|
LEFT JOIN item_info itm
|
|
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
|
LEFT JOIN equipment_mng e
|
|
ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
|
LEFT JOIN item_routing_version rv
|
|
ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
|
WHERE wi.id = ANY($1::varchar[])
|
|
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC
|
|
`;
|
|
const dataRes = await pool.query(dataSql, [wiIds]);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: dataRes.rows,
|
|
totalCount,
|
|
page: pageNum,
|
|
pageSize: sizeNum,
|
|
});
|
|
}
|
|
|
|
// 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체
|
|
const query = `
|
|
SELECT
|
|
wi.id AS wi_id,
|
|
wi.work_instruction_no,
|
|
wi.status,
|
|
wi.progress_status,
|
|
wi.qty AS total_qty,
|
|
wi.completed_qty,
|
|
wi.start_date,
|
|
wi.end_date,
|
|
wi.equipment_id,
|
|
wi.work_team,
|
|
wi.worker,
|
|
wi.remark AS wi_remark,
|
|
wi.created_date,
|
|
wi.batch_no,
|
|
wi.cutting_plan_id,
|
|
d.id AS detail_id,
|
|
d.item_number,
|
|
d.qty AS detail_qty,
|
|
d.remark AS detail_remark,
|
|
d.part_code,
|
|
d.source_table,
|
|
d.source_id,
|
|
d.routing_version_id AS detail_routing_version_id,
|
|
d.start_date AS detail_start_date,
|
|
d.end_date AS detail_end_date,
|
|
d.equipment_ids AS detail_equipment_ids,
|
|
d.work_teams AS detail_work_teams,
|
|
d.workers AS detail_workers,
|
|
COALESCE(itm.item_name, '') AS item_name,
|
|
COALESCE(itm.type, '') AS item_type,
|
|
COALESCE(itm.size, '') AS item_spec,
|
|
COALESCE(e.equipment_name, '') AS equipment_name,
|
|
COALESCE(e.equipment_code, '') AS equipment_code,
|
|
wi.routing AS routing_version_id,
|
|
COALESCE(rv.version_name, '') AS routing_name,
|
|
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date, d.id) AS detail_seq,
|
|
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
|
|
FROM work_instruction wi
|
|
INNER JOIN work_instruction_detail d
|
|
ON d.work_instruction_id = wi.id
|
|
LEFT JOIN item_info itm
|
|
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
|
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
|
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
|
|
${whereClause}
|
|
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC
|
|
`;
|
|
|
|
const result = await pool.query(query, params);
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// ─── 다음 작업지시번호 미리보기 ───
|
|
export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
let wiNo: string;
|
|
try {
|
|
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
|
|
if (rule) {
|
|
wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {});
|
|
} else { throw new Error("채번 규칙 없음"); }
|
|
} catch {
|
|
const pool = getPool();
|
|
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
|
const seqRes = await pool.query(
|
|
`SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`,
|
|
[companyCode, `WI-${today}-%`]
|
|
);
|
|
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
|
}
|
|
return res.json({ success: true, instructionNo: wiNo });
|
|
} catch (error: any) {
|
|
logger.error("작업지시번호 미리보기 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 작업지시 저장 (신규/수정) ───
|
|
export async function save(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
await ensureDetailRoutingColumn();
|
|
const companyCode = req.user!.companyCode;
|
|
const userId = req.user!.userId;
|
|
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId, batchNo, cuttingPlanId, materialOverrides } = req.body;
|
|
|
|
if (!items || items.length === 0) {
|
|
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
|
}
|
|
|
|
// ── materialOverrides 사전 검증 (옵셔널) ──
|
|
// 형식: [{ itemNumber, routingVersionId, materials: [{ bomItemId, bomItemName?, bomQty, bomUnit?, routingDetailId, content?, isOverride?, originalBomItemId? }] }]
|
|
// 비어있거나 materials 빈 배열이면 wi_* INSERT 자체를 건너뛰어 기존 마스터 폴백 동작 유지.
|
|
if (materialOverrides != null && !Array.isArray(materialOverrides)) {
|
|
return res.status(400).json({ success: false, message: "materialOverrides는 배열이어야 합니다" });
|
|
}
|
|
if (Array.isArray(materialOverrides)) {
|
|
for (const ov of materialOverrides) {
|
|
if (!ov || typeof ov !== "object") {
|
|
return res.status(400).json({ success: false, message: "materialOverrides 항목 형식 오류" });
|
|
}
|
|
if (!Array.isArray(ov.materials)) continue; // materials 없으면 스킵 (해당 품목 건너뛴 효과)
|
|
for (const m of ov.materials) {
|
|
if (!m || typeof m !== "object") {
|
|
return res.status(400).json({ success: false, message: "materialOverrides.materials 항목 형식 오류" });
|
|
}
|
|
if (!m.bomItemId || String(m.bomItemId).trim() === "") {
|
|
return res.status(400).json({ success: false, message: "자재 품목(bomItemId)을 선택해주세요" });
|
|
}
|
|
if (!m.routingDetailId || String(m.routingDetailId).trim() === "") {
|
|
return res.status(400).json({ success: false, message: "자재 투입 공정(routingDetailId)을 선택해주세요" });
|
|
}
|
|
const qtyNum = Number(m.bomQty);
|
|
if (!Number.isFinite(qtyNum) || qtyNum <= 0) {
|
|
return res.status(400).json({ success: false, message: "수량은 0보다 커야 합니다" });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
let wiId: string;
|
|
let wiNo: string;
|
|
|
|
if (editId) {
|
|
const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]);
|
|
if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다");
|
|
wiId = editId;
|
|
wiNo = check.rows[0].work_instruction_no;
|
|
await client.query(
|
|
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, batch_no=COALESCE($11, batch_no), cutting_plan_id=COALESCE($12, cutting_plan_id), updated_date=NOW(), writer=$13 WHERE id=$14 AND company_code=$15`,
|
|
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId, editId, companyCode]
|
|
);
|
|
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]);
|
|
} else {
|
|
try {
|
|
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
|
|
if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); }
|
|
else { throw new Error("채번 규칙 없음 - 폴백"); }
|
|
} catch {
|
|
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
|
const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]);
|
|
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
|
|
}
|
|
const insertRes = await client.query(
|
|
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,batch_no,cutting_plan_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW(),$15) RETURNING id`,
|
|
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId]
|
|
);
|
|
wiId = insertRes.rows[0].id;
|
|
}
|
|
|
|
let totalQty = 0;
|
|
let firstRouting: string | null = null;
|
|
for (const item of items) {
|
|
const itemRouting = item.routing || null;
|
|
if (!firstRouting && itemRouting) firstRouting = itemRouting;
|
|
totalQty += Number(item.qty || 0);
|
|
await client.query(
|
|
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,clock_timestamp(),$16)`,
|
|
[
|
|
companyCode,
|
|
wiNo,
|
|
wiId,
|
|
item.itemNumber||item.itemCode||"",
|
|
item.qty||"0",
|
|
item.remark||"",
|
|
item.sourceTable||"",
|
|
item.sourceId||"",
|
|
item.partCode||item.itemNumber||item.itemCode||"",
|
|
itemRouting,
|
|
item.startDate||"",
|
|
item.endDate||"",
|
|
item.equipmentIds||"",
|
|
item.workTeams||"",
|
|
item.workers||"",
|
|
userId,
|
|
]
|
|
);
|
|
}
|
|
|
|
// 마스터 qty/routing 자동 동기화 (디테일 합계 + 첫 번째 라우팅)
|
|
const effectiveRouting = routingVersionId || firstRouting;
|
|
await client.query(
|
|
`UPDATE work_instruction SET qty = $1, routing = COALESCE(routing, $2) WHERE id = $3`,
|
|
[String(totalQty), effectiveRouting, wiId]
|
|
);
|
|
|
|
// ── materialOverrides 적용: wi_process_work_item + wi_process_work_item_detail INSERT ──
|
|
// 정책:
|
|
// - 수정(editId) 진입 시 이 작업지시의 기존 wi_* 자재(material_input) 데이터를 모두 삭제 후 재구축.
|
|
// - materialOverrides가 빈 배열/없음이면 wi_* INSERT 자체를 안 함 → 마스터 process_work_item 폴백 그대로.
|
|
// - 같은 (routingDetailId) 묶음마다 wi_process_work_item 1행을 만들고, 자재들은 detail_type='material_input'으로 묶음.
|
|
if (Array.isArray(materialOverrides) && materialOverrides.length > 0) {
|
|
// 1) 편집 모드: 기존 wi_* (material_input 한정) 삭제 — 마스터 체크리스트(검사/조건 등) 보존 위해 detail_type 한정 삭제 후, 빈 work_item은 따로 cleanup
|
|
if (editId) {
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item_detail
|
|
WHERE company_code = $1
|
|
AND detail_type = 'material_input'
|
|
AND wi_work_item_id IN (
|
|
SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1
|
|
)`,
|
|
[companyCode, wiNo]
|
|
);
|
|
}
|
|
|
|
// 2) routingDetailId 단위 묶음 — 동일 공정 자재는 한 work_item 아래로
|
|
for (const ov of materialOverrides) {
|
|
if (!ov || !Array.isArray(ov.materials) || ov.materials.length === 0) continue;
|
|
const byRouting = new Map<string, any[]>();
|
|
for (const m of ov.materials) {
|
|
const rdId = String(m.routingDetailId);
|
|
if (!byRouting.has(rdId)) byRouting.set(rdId, []);
|
|
byRouting.get(rdId)!.push(m);
|
|
}
|
|
|
|
for (const [routingDetailId, mats] of byRouting.entries()) {
|
|
// 동일 (work_instruction_no, routing_detail_id)에 자재투입 전용 work_item이 이미 있으면 재사용, 없으면 신규 INSERT.
|
|
// title은 식별용 고정 라벨 '자재투입' 사용. 사용자 화면(공정작업기준)과 충돌하지 않도록 별도 work_phase('material').
|
|
const existing = await client.query(
|
|
`SELECT id FROM wi_process_work_item
|
|
WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND work_phase = 'material'
|
|
LIMIT 1`,
|
|
[wiNo, routingDetailId, companyCode]
|
|
);
|
|
let wiWorkItemId: string;
|
|
if (existing.rowCount && existing.rowCount > 0) {
|
|
wiWorkItemId = existing.rows[0].id;
|
|
// 이 work_item의 기존 material_input detail 정리 (편집 모드 위 1차 삭제와 중복돼도 무해)
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2 AND detail_type = 'material_input'`,
|
|
[wiWorkItemId, companyCode]
|
|
);
|
|
} else {
|
|
const ins = await client.query(
|
|
`INSERT INTO wi_process_work_item (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
|
[companyCode, wiNo, routingDetailId, "material", "자재투입", "N", 0, "작업지시 등록 시 매핑된 자재투입", null, userId]
|
|
);
|
|
wiWorkItemId = ins.rows[0].id;
|
|
}
|
|
|
|
// 3) 자재 detail INSERT
|
|
let sortOrder = 0;
|
|
for (const m of mats) {
|
|
sortOrder++;
|
|
const qtyStr = String(Number(m.bomQty));
|
|
const isOverride = m.isOverride === true || (m.originalBomItemId && String(m.originalBomItemId) !== String(m.bomItemId));
|
|
const contentMemo = m.content
|
|
? String(m.content)
|
|
: isOverride
|
|
? `대체${m.originalBomItemId ? `:${m.originalBomItemId}` : ""}`
|
|
: "원본";
|
|
await client.query(
|
|
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`,
|
|
[
|
|
companyCode,
|
|
wiWorkItemId,
|
|
"material_input",
|
|
contentMemo,
|
|
"N",
|
|
sortOrder,
|
|
m.remark || null,
|
|
null, null, null, null, null,
|
|
null, null, null, null,
|
|
null, null,
|
|
null, null, null,
|
|
null, null,
|
|
String(m.bomItemId),
|
|
m.bomItemName || null,
|
|
qtyStr,
|
|
m.bomUnit || null,
|
|
null, null,
|
|
null, null,
|
|
null, null,
|
|
null, null,
|
|
userId,
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4) cleanup: material_input detail이 하나도 없는 work_phase='material' work_item 제거 (이번 페이로드에서 빠진 공정)
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item
|
|
WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM wi_process_work_item_detail d
|
|
WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1
|
|
)`,
|
|
[companyCode, wiNo]
|
|
);
|
|
} else if (editId) {
|
|
// 편집 시 명시적으로 빈 materialOverrides가 들어오면 기존 자재 매핑 비움 (사용자가 다시 폴백으로 돌리려는 의도)
|
|
if (Array.isArray(materialOverrides)) {
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item_detail
|
|
WHERE company_code = $1
|
|
AND detail_type = 'material_input'
|
|
AND wi_work_item_id IN (
|
|
SELECT id FROM wi_process_work_item WHERE work_instruction_no = $2 AND company_code = $1
|
|
)`,
|
|
[companyCode, wiNo]
|
|
);
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item
|
|
WHERE company_code = $1 AND work_instruction_no = $2 AND work_phase = 'material'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM wi_process_work_item_detail d
|
|
WHERE d.wi_work_item_id = wi_process_work_item.id AND d.company_code = $1
|
|
)`,
|
|
[companyCode, wiNo]
|
|
);
|
|
}
|
|
}
|
|
|
|
await client.query("COMMIT");
|
|
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
|
|
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
|
|
finally { client.release(); }
|
|
} catch (error: any) {
|
|
logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 작업지시 삭제 ───
|
|
export async function remove(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { ids } = req.body;
|
|
if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" });
|
|
|
|
const pool = getPool();
|
|
|
|
// 진행중/완료 작업지시는 삭제 불가 (데이터 무결성 가드, TASK:ERP-node-075)
|
|
// progress_status: in_progress/completed → 차단. NULL이라도 실적(completed_qty)이 있으면 진행으로 간주.
|
|
const guard = await pool.query(
|
|
`SELECT work_instruction_no,
|
|
progress_status,
|
|
CASE WHEN completed_qty ~ '^[0-9]+(\\.[0-9]+)?$' THEN completed_qty::numeric ELSE 0 END AS completed_qty
|
|
FROM work_instruction
|
|
WHERE id = ANY($1) AND company_code = $2`,
|
|
[ids, companyCode]
|
|
);
|
|
const blocked = guard.rows.filter((r: any) => {
|
|
const ps = String(r.progress_status || "").toLowerCase();
|
|
if (ps === "in_progress" || ps === "completed") return true;
|
|
if (!ps || ps === "pending") return Number(r.completed_qty) > 0;
|
|
return false;
|
|
});
|
|
if (blocked.length > 0) {
|
|
const nos = blocked.map((r: any) => r.work_instruction_no).filter(Boolean).join(", ");
|
|
return res.status(409).json({
|
|
success: false,
|
|
message: `진행중이거나 완료된 작업지시는 삭제할 수 없습니다.${nos ? ` (${nos})` : ""}`,
|
|
});
|
|
}
|
|
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query("BEGIN");
|
|
// 디테일 삭제 (id 기반)
|
|
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=ANY($1)`, [ids]);
|
|
const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
|
|
await client.query("COMMIT");
|
|
return res.json({ success: true, deletedCount: result.rowCount });
|
|
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
|
|
finally { client.release(); }
|
|
} catch (error: any) {
|
|
logger.error("작업지시 삭제 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 품목 소스 (페이징) ───
|
|
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { keyword, page: ps, pageSize: pss } = req.query;
|
|
const page = Math.max(1, parseInt(ps as string) || 1);
|
|
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
|
const offset = (page - 1) * pageSize;
|
|
|
|
const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
|
if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
|
const w = conds.join(" AND ");
|
|
const pool = getPool();
|
|
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params);
|
|
params.push(pageSize, offset);
|
|
const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params);
|
|
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
|
}
|
|
|
|
// ─── 수주 소스 (페이징) ───
|
|
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { keyword, page: ps, pageSize: pss } = req.query;
|
|
const page = Math.max(1, parseInt(ps as string) || 1);
|
|
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
|
const offset = (page - 1) * pageSize;
|
|
|
|
const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
|
if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
|
const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`;
|
|
const pool = getPool();
|
|
const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
|
params.push(pageSize, offset);
|
|
const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
|
|
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
|
}
|
|
|
|
// ─── 생산계획 소스 (페이징) ───
|
|
export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { keyword, page: ps, pageSize: pss } = req.query;
|
|
const page = Math.max(1, parseInt(ps as string) || 1);
|
|
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
|
|
const offset = (page - 1) * pageSize;
|
|
|
|
const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
|
|
if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
|
|
const w = conds.join(" AND ");
|
|
const pool = getPool();
|
|
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
|
|
params.push(pageSize, offset);
|
|
// work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty
|
|
const rows = await pool.query(
|
|
`SELECT p.id, p.plan_no, p.item_code,
|
|
COALESCE(p.item_name,'') AS item_name,
|
|
COALESCE(p.plan_qty,0) AS plan_qty,
|
|
p.start_date, p.end_date, p.status,
|
|
COALESCE(p.equipment_name,'') AS equipment_name,
|
|
COALESCE(wi.applied_qty, 0) AS applied_qty,
|
|
(COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0)
|
|
- COALESCE(wi.applied_qty, 0)) AS remain_qty
|
|
FROM production_plan_mng p
|
|
LEFT JOIN (
|
|
SELECT source_id,
|
|
SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty
|
|
FROM work_instruction_detail
|
|
WHERE source_table = 'production_plan_mng'
|
|
AND company_code = $1
|
|
GROUP BY source_id
|
|
) wi ON wi.source_id = p.id::text
|
|
WHERE ${w}
|
|
ORDER BY p.created_date DESC
|
|
LIMIT $${idx} OFFSET $${idx+1}`,
|
|
params,
|
|
);
|
|
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
|
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
|
}
|
|
|
|
// ─── 사원 목록 (작업자 Select용) ───
|
|
export async function getEmployeeList(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const pool = getPool();
|
|
let query: string;
|
|
let params: any[];
|
|
if (companyCode !== "*") {
|
|
query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`;
|
|
params = [companyCode];
|
|
} else {
|
|
query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`;
|
|
params = [];
|
|
}
|
|
const result = await pool.query(query, params);
|
|
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 });
|
|
}
|
|
}
|
|
|
|
// ─── 설비 목록 (Select용) ───
|
|
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const pool = getPool();
|
|
const cond = companyCode !== "*" ? "WHERE company_code = $1" : "";
|
|
const params = companyCode !== "*" ? [companyCode] : [];
|
|
const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params);
|
|
return res.json({ success: true, data: result.rows });
|
|
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
|
|
}
|
|
|
|
// ─── 품목의 라우팅 버전 + 공정 조회 ───
|
|
export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { itemCode } = req.params;
|
|
const pool = getPool();
|
|
|
|
const versionsResult = await pool.query(
|
|
`SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
|
|
FROM item_routing_version
|
|
WHERE item_code = $1 AND company_code = $2
|
|
ORDER BY is_default DESC, created_date DESC`,
|
|
[itemCode, companyCode]
|
|
);
|
|
|
|
const routings = [];
|
|
for (const version of versionsResult.rows) {
|
|
const detailsResult = await pool.query(
|
|
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
|
|
rd.is_required, rd.work_type,
|
|
COALESCE(p.process_name, rd.process_code) AS process_name
|
|
FROM item_routing_detail rd
|
|
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
|
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
|
ORDER BY rd.seq_no::integer`,
|
|
[version.id, companyCode]
|
|
);
|
|
routings.push({ ...version, processes: detailsResult.rows });
|
|
}
|
|
|
|
return res.json({ success: true, data: routings });
|
|
} catch (error: any) {
|
|
logger.error("라우팅 버전 조회 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 품목별 라우팅 벌크 조회 (엑셀 업로드용) ───
|
|
export async function getRoutingVersionsBulk(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { itemCodes } = req.body as { itemCodes: string[] };
|
|
|
|
if (!itemCodes || !Array.isArray(itemCodes) || itemCodes.length === 0) {
|
|
return res.json({ success: true, data: {} });
|
|
}
|
|
|
|
const pool = getPool();
|
|
const result: Record<string, { code: string; name: string }[]> = {};
|
|
|
|
// 청크 단위로 분할 (PostgreSQL placeholder 제한 대응)
|
|
const CHUNK_SIZE = 5000;
|
|
for (let ci = 0; ci < itemCodes.length; ci += CHUNK_SIZE) {
|
|
const chunk = itemCodes.slice(ci, ci + CHUNK_SIZE);
|
|
|
|
// 1. 기본 라우팅 버전 조회
|
|
const placeholders = chunk.map((_, i) => `$${i + 2}`).join(",");
|
|
const versionsResult = await pool.query(
|
|
`SELECT DISTINCT ON (item_code) id, item_code, version_name
|
|
FROM item_routing_version
|
|
WHERE company_code = $1 AND item_code IN (${placeholders})
|
|
ORDER BY item_code, is_default DESC, created_date DESC`,
|
|
[companyCode, ...chunk]
|
|
);
|
|
|
|
if (versionsResult.rows.length === 0) continue;
|
|
|
|
// 2. 라우팅 디테일 조회
|
|
const versionIds = versionsResult.rows.map((v: any) => v.id);
|
|
const vPlaceholders = versionIds.map((_: any, i: number) => `$${i + 2}`).join(",");
|
|
const detailsResult = await pool.query(
|
|
`SELECT rd.routing_version_id, rd.process_code,
|
|
COALESCE(p.process_name, rd.process_code) AS process_name
|
|
FROM item_routing_detail rd
|
|
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
|
WHERE rd.company_code = $1 AND rd.routing_version_id IN (${vPlaceholders})
|
|
ORDER BY rd.seq_no::integer`,
|
|
[companyCode, ...versionIds]
|
|
);
|
|
|
|
// 3. 매핑
|
|
const versionToItem: Record<string, string> = {};
|
|
for (const v of versionsResult.rows) {
|
|
versionToItem[v.id] = v.item_code;
|
|
}
|
|
for (const d of detailsResult.rows) {
|
|
const itemCode = versionToItem[d.routing_version_id];
|
|
if (!itemCode) continue;
|
|
if (!result[itemCode]) result[itemCode] = [];
|
|
result[itemCode].push({ code: d.process_code, name: d.process_name });
|
|
}
|
|
}
|
|
|
|
return res.json({ success: true, data: result });
|
|
} catch (error: any) {
|
|
logger.error("벌크 라우팅 조회 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 작업지시 라우팅 변경 ───
|
|
export async function updateRouting(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { wiNo } = req.params;
|
|
const { routingVersionId } = req.body;
|
|
const pool = getPool();
|
|
|
|
await pool.query(
|
|
`UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`,
|
|
[routingVersionId || null, wiNo, companyCode]
|
|
);
|
|
|
|
return res.json({ success: true });
|
|
} catch (error: any) {
|
|
logger.error("라우팅 변경 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 작업지시 전용 공정작업기준 조회 ───
|
|
export async function getWorkStandard(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { wiNo } = req.params;
|
|
const { routingVersionId } = req.query;
|
|
const pool = getPool();
|
|
|
|
if (!routingVersionId) {
|
|
return res.status(400).json({ success: false, message: "routingVersionId 필요" });
|
|
}
|
|
|
|
// 라우팅 디테일(공정) 목록 조회
|
|
const processesResult = await pool.query(
|
|
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
|
|
COALESCE(p.process_name, rd.process_code) AS process_name
|
|
FROM item_routing_detail rd
|
|
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
|
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
|
|
ORDER BY rd.seq_no::integer`,
|
|
[routingVersionId, companyCode]
|
|
);
|
|
|
|
// 커스텀 작업기준이 있는지 확인
|
|
// 주의: work_phase='material' 은 작업지시별 BOM 자재투입 매핑 전용 항목이므로 작업기준 화면 표시 대상에서 제외.
|
|
const customCheck = await pool.query(
|
|
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
|
[wiNo, companyCode]
|
|
);
|
|
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
|
|
|
|
const processes = [];
|
|
for (const proc of processesResult.rows) {
|
|
let workItems;
|
|
|
|
if (hasCustom) {
|
|
// 커스텀 버전에서 조회 (work_phase='material'은 자재투입 매핑 전용이므로 제외)
|
|
const wiResult = await pool.query(
|
|
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
|
|
(SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
|
|
FROM wi_process_work_item wi
|
|
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3 AND COALESCE(wi.work_phase, '') <> 'material'
|
|
ORDER BY wi.work_phase, wi.sort_order`,
|
|
[wiNo, proc.routing_detail_id, companyCode]
|
|
);
|
|
workItems = wiResult.rows;
|
|
|
|
// 각 work_item의 상세도 로드
|
|
for (const wi of workItems) {
|
|
const detailsResult = await pool.query(
|
|
`SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark,
|
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
|
duration_minutes, input_type, lookup_target, display_fields,
|
|
process_inspection_apply, equip_inspection_apply,
|
|
condition_unit, condition_base_value, condition_tolerance,
|
|
condition_auto_collect, condition_plc_data,
|
|
bom_item_id, bom_item_name, bom_qty, bom_unit,
|
|
work_qty_auto_collect, work_qty_plc_data,
|
|
defect_qty_auto_collect, defect_qty_plc_data,
|
|
good_qty_auto_collect, good_qty_plc_data,
|
|
loss_qty_auto_collect, loss_qty_plc_data
|
|
FROM wi_process_work_item_detail
|
|
WHERE wi_work_item_id = $1 AND company_code = $2
|
|
ORDER BY sort_order`,
|
|
[wi.id, companyCode]
|
|
);
|
|
wi.details = detailsResult.rows;
|
|
}
|
|
} else {
|
|
// 원본에서 조회
|
|
const origResult = await pool.query(
|
|
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
|
|
(SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
|
|
FROM process_work_item wi
|
|
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
|
|
ORDER BY wi.work_phase, wi.sort_order`,
|
|
[proc.routing_detail_id, companyCode]
|
|
);
|
|
workItems = origResult.rows;
|
|
|
|
for (const wi of workItems) {
|
|
const detailsResult = await pool.query(
|
|
`SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
|
|
inspection_code, inspection_method, unit, lower_limit, upper_limit,
|
|
duration_minutes, input_type, lookup_target, display_fields,
|
|
process_inspection_apply, equip_inspection_apply,
|
|
condition_unit, condition_base_value, condition_tolerance,
|
|
condition_auto_collect, condition_plc_data,
|
|
bom_item_id, bom_item_name, bom_qty, bom_unit,
|
|
work_qty_auto_collect, work_qty_plc_data,
|
|
defect_qty_auto_collect, defect_qty_plc_data,
|
|
good_qty_auto_collect, good_qty_plc_data,
|
|
loss_qty_auto_collect, loss_qty_plc_data
|
|
FROM process_work_item_detail
|
|
WHERE work_item_id = $1 AND company_code = $2
|
|
ORDER BY sort_order`,
|
|
[wi.id, companyCode]
|
|
);
|
|
wi.details = detailsResult.rows;
|
|
}
|
|
}
|
|
|
|
processes.push({
|
|
...proc,
|
|
workItems,
|
|
});
|
|
}
|
|
|
|
return res.json({ success: true, data: { processes, isCustom: hasCustom } });
|
|
} catch (error: any) {
|
|
logger.error("작업지시 공정작업기준 조회 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* wi_* 편집 시 마스터 체크리스트 스냅샷을 재투영한다.
|
|
* 접수(work_order_process_result) 가 0건일 때만 동기화되며, 1건 이상이면 스냅샷 불변.
|
|
* 트랜잭션 내에서 호출되어야 한다 (caller 가 BEGIN/COMMIT 관리).
|
|
*
|
|
* @param routingDetailId null 이면 해당 작업지시의 모든 routing detail 동기화
|
|
* @returns synced: 실제 동기화 수행 여부, affectedProcesses: 재복사된 마스터 공정 수
|
|
*/
|
|
async function syncMasterChecklistFromWi(
|
|
client: { query: (text: string, values?: any[]) => Promise<any> },
|
|
workInstructionNo: string,
|
|
routingDetailId: string | null,
|
|
companyCode: string,
|
|
userId: string,
|
|
): Promise<{ synced: boolean; affectedProcesses: number; reason?: string }> {
|
|
// 1. 작업지시 id 조회
|
|
const wiRow = await client.query(
|
|
`SELECT id FROM work_instruction WHERE work_instruction_no = $1 AND company_code = $2`,
|
|
[workInstructionNo, companyCode],
|
|
);
|
|
if (wiRow.rowCount === 0) {
|
|
return { synced: false, affectedProcesses: 0, reason: "work_instruction not found" };
|
|
}
|
|
const wiId = wiRow.rows[0].id as string;
|
|
|
|
// 2. advisory lock — 편집/접수 동시성 보호
|
|
await client.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [
|
|
`wi_snapshot:${companyCode}:${wiId}`,
|
|
]);
|
|
|
|
// 3. 접수 건수 확인
|
|
const acceptCount = await client.query(
|
|
`SELECT COUNT(*)::int AS cnt FROM work_order_process_result wopr
|
|
JOIN work_order_process wop ON wop.id = wopr.wop_id
|
|
WHERE wop.wo_id = $1 AND wop.company_code = $2 AND wopr.company_code = $2`,
|
|
[wiId, companyCode],
|
|
);
|
|
if ((acceptCount.rows[0]?.cnt ?? 0) > 0) {
|
|
return { synced: false, affectedProcesses: 0, reason: "accepted_count > 0" };
|
|
}
|
|
|
|
// 4. 대상 마스터 공정 목록
|
|
const masterQuery = routingDetailId
|
|
? `SELECT id, routing_detail_id FROM work_order_process
|
|
WHERE wo_id = $1 AND routing_detail_id = $2 AND company_code = $3`
|
|
: `SELECT id, routing_detail_id FROM work_order_process
|
|
WHERE wo_id = $1 AND company_code = $2`;
|
|
const masterParams = routingDetailId
|
|
? [wiId, routingDetailId, companyCode]
|
|
: [wiId, companyCode];
|
|
const masters = await client.query(masterQuery, masterParams);
|
|
|
|
let affected = 0;
|
|
for (const m of masters.rows) {
|
|
// 5. 기존 마스터 스냅샷 삭제
|
|
await client.query(
|
|
`DELETE FROM process_work_result WHERE work_order_process_id = $1 AND company_code = $2`,
|
|
[m.id, companyCode],
|
|
);
|
|
// 6. 재복사 — copyChecklistToSplit 재활용 (wi_* 우선, 없으면 원본 fallback)
|
|
await copyChecklistToSplit(
|
|
client,
|
|
m.id,
|
|
m.id,
|
|
m.routing_detail_id,
|
|
companyCode,
|
|
userId,
|
|
{ workInstructionNo },
|
|
);
|
|
affected++;
|
|
}
|
|
return { synced: true, affectedProcesses: affected };
|
|
}
|
|
|
|
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
|
|
export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const userId = req.user!.userId;
|
|
const { wiNo } = req.params;
|
|
const { routingVersionId } = req.body;
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
// 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존)
|
|
const existingItems = await client.query(
|
|
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
|
[wiNo, companyCode]
|
|
);
|
|
for (const row of existingItems.rows) {
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
|
[row.id, companyCode]
|
|
);
|
|
}
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
|
[wiNo, companyCode]
|
|
);
|
|
|
|
// 라우팅 디테일 목록 조회
|
|
const routingDetails = await client.query(
|
|
`SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`,
|
|
[routingVersionId, companyCode]
|
|
);
|
|
|
|
// 각 공정(routing_detail)별 원본 작업항목 복사
|
|
for (const rd of routingDetails.rows) {
|
|
const origItems = await client.query(
|
|
`SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`,
|
|
[rd.id, companyCode]
|
|
);
|
|
|
|
for (const origItem of origItems.rows) {
|
|
const newItemResult = await client.query(
|
|
`INSERT INTO wi_process_work_item (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
|
[companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId]
|
|
);
|
|
const newItemId = newItemResult.rows[0].id;
|
|
|
|
// 상세 복사
|
|
const origDetails = await client.query(
|
|
`SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`,
|
|
[origItem.id, companyCode]
|
|
);
|
|
|
|
for (const origDetail of origDetails.rows) {
|
|
await client.query(
|
|
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`,
|
|
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, origDetail.bom_item_id || null, origDetail.bom_item_name || null, origDetail.bom_qty || null, origDetail.bom_unit || null, origDetail.work_qty_auto_collect || null, origDetail.work_qty_plc_data || null, origDetail.defect_qty_auto_collect || null, origDetail.defect_qty_plc_data || null, origDetail.good_qty_auto_collect || null, origDetail.good_qty_plc_data || null, origDetail.loss_qty_auto_collect || null, origDetail.loss_qty_plc_data || null, userId]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
|
|
logger.info("[work-instruction] wi_* copy 후 마스터 스냅샷 동기화", { wiNo, ...sync });
|
|
await client.query("COMMIT");
|
|
logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId });
|
|
return res.json({ success: true });
|
|
} catch (txErr) {
|
|
await client.query("ROLLBACK");
|
|
throw txErr;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (error: any) {
|
|
logger.error("공정작업기준 복사 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 작업지시 전용 공정작업기준 저장 (일괄) ───
|
|
export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const userId = req.user!.userId;
|
|
const { wiNo } = req.params;
|
|
const { routingDetailId, workItems } = req.body;
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
// 해당 공정의 기존 커스텀 데이터 삭제 (work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존)
|
|
const existing = await client.query(
|
|
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`,
|
|
[wiNo, routingDetailId, companyCode]
|
|
);
|
|
for (const row of existing.rows) {
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
|
[row.id, companyCode]
|
|
);
|
|
}
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3 AND COALESCE(work_phase, '') <> 'material'`,
|
|
[wiNo, routingDetailId, companyCode]
|
|
);
|
|
|
|
// 새 데이터 삽입
|
|
// NOTE: wi_process_work_item / wi_process_work_item_detail.id 컬럼에 DEFAULT(gen_random_uuid()) 누락
|
|
// → id를 명시하지 않으면 NULL 저장되어 재조회 시 wi_work_item_id 매칭 실패(0건 반환)로 이어짐.
|
|
// 원본 테이블(process_work_item) DEFAULT와 동기되지 않은 스키마 이슈. 여기서 명시 바인딩으로 회피.
|
|
for (const wi of workItems) {
|
|
const wiResult = await client.query(
|
|
`INSERT INTO wi_process_work_item (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
|
[companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId]
|
|
);
|
|
const newId = wiResult.rows[0].id;
|
|
|
|
if (wi.details && Array.isArray(wi.details)) {
|
|
for (const d of wi.details) {
|
|
await client.query(
|
|
`INSERT INTO wi_process_work_item_detail (id, company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, bom_item_id, bom_item_name, bom_qty, bom_unit, work_qty_auto_collect, work_qty_plc_data, defect_qty_auto_collect, defect_qty_plc_data, good_qty_auto_collect, good_qty_plc_data, loss_qty_auto_collect, loss_qty_plc_data, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36)`,
|
|
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || null, d.bom_item_id || null, d.bom_item_name || null, d.bom_qty != null && d.bom_qty !== "" ? String(d.bom_qty) : null, d.bom_unit || null, d.work_qty_auto_collect || null, d.work_qty_plc_data || null, d.defect_qty_auto_collect || null, d.defect_qty_plc_data || null, d.good_qty_auto_collect || null, d.good_qty_plc_data || null, d.loss_qty_auto_collect || null, d.loss_qty_plc_data || null, userId]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const sync = await syncMasterChecklistFromWi(client, wiNo, routingDetailId, companyCode, userId);
|
|
logger.info("[work-instruction] wi_* save 후 마스터 스냅샷 동기화", { wiNo, routingDetailId, ...sync });
|
|
await client.query("COMMIT");
|
|
logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId });
|
|
return res.json({ success: true });
|
|
} catch (txErr) {
|
|
await client.query("ROLLBACK");
|
|
throw txErr;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (error: any) {
|
|
logger.error("작업지시 공정작업기준 저장 실패", {
|
|
message: error?.message,
|
|
code: error?.code,
|
|
detail: error?.detail,
|
|
where: error?.where,
|
|
stack: error?.stack,
|
|
});
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 작업지시 전용 커스텀 데이터 삭제 (원본으로 초기화) ───
|
|
export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const userId = req.user!.userId;
|
|
const { wiNo } = req.params;
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
// 원본 초기화 — work_phase='material' 자재투입 매핑은 별도 흐름이므로 보존
|
|
const items = await client.query(
|
|
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
|
[wiNo, companyCode]
|
|
);
|
|
for (const row of items.rows) {
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
|
|
[row.id, companyCode]
|
|
);
|
|
}
|
|
await client.query(
|
|
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2 AND COALESCE(work_phase, '') <> 'material'`,
|
|
[wiNo, companyCode]
|
|
);
|
|
const sync = await syncMasterChecklistFromWi(client, wiNo, null, companyCode, userId);
|
|
logger.info("[work-instruction] wi_* reset 후 마스터 스냅샷 원본 복원", { wiNo, ...sync });
|
|
await client.query("COMMIT");
|
|
logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo });
|
|
return res.json({ success: true });
|
|
} catch (txErr) {
|
|
await client.query("ROLLBACK");
|
|
throw txErr;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (error: any) {
|
|
logger.error("작업지시 공정작업기준 초기화 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── BOM 기준수(0레벨 base_qty) 일괄 조회 ───
|
|
// itemCodes(item_info.item_number 기준) 배열을 받아 { [itemCode]: base_qty | null } 맵 반환
|
|
export async function getBomBaseQtyMap(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const itemCodes: string[] = Array.isArray(req.body?.itemCodes) ? req.body.itemCodes.filter(Boolean) : [];
|
|
if (itemCodes.length === 0) return res.json({ success: true, data: {}, batchUse: {} });
|
|
|
|
await ensureItemInfoBatchUseColumn();
|
|
const pool = getPool();
|
|
// bom.item_code 우선 매칭, 없으면 item_info.id 경유 매칭
|
|
const result = await pool.query(
|
|
`SELECT i.item_number AS item_code, b.base_qty
|
|
FROM bom b
|
|
LEFT JOIN item_info i ON i.id = b.item_id AND i.company_code = b.company_code
|
|
WHERE b.company_code = $1
|
|
AND (b.item_code = ANY($2::text[]) OR i.item_number = ANY($2::text[]))`,
|
|
[companyCode, itemCodes]
|
|
);
|
|
|
|
const map: Record<string, number | null> = {};
|
|
for (const code of itemCodes) map[code] = null;
|
|
for (const row of result.rows) {
|
|
const code = row.item_code;
|
|
const base = parseFloat(row.base_qty || "");
|
|
if (!code) continue;
|
|
if (Number.isFinite(base) && base > 0) {
|
|
// 동일 품목 다건 BOM 시 첫 유효값 유지
|
|
if (map[code] == null) map[code] = base;
|
|
}
|
|
}
|
|
|
|
// 품목별 배치사용여부 일괄 조회 (BOM 유무와 무관하게 item_info 기준, TASK:ERP-node-074)
|
|
// 빈 값/NULL/미등록 품목은 'Y'(사용, 현행 유지)로 간주
|
|
const batchUse: Record<string, "Y" | "N"> = {};
|
|
for (const code of itemCodes) batchUse[code] = "Y";
|
|
const buResult = await pool.query(
|
|
`SELECT item_number, COALESCE(NULLIF(batch_use, ''), 'Y') AS batch_use
|
|
FROM item_info
|
|
WHERE company_code = $1 AND item_number = ANY($2::text[])`,
|
|
[companyCode, itemCodes]
|
|
);
|
|
for (const row of buResult.rows) {
|
|
if (!row.item_number) continue;
|
|
batchUse[row.item_number] = String(row.batch_use).toUpperCase() === "N" ? "N" : "Y";
|
|
}
|
|
|
|
return res.json({ success: true, data: map, batchUse });
|
|
} catch (error: any) {
|
|
logger.error("BOM 기준수 일괄 조회 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── BOM 트리 조회 (작업지시 등록/수정 모달의 자재 트리 섹션용, TASK:ERP-node-090 트리화) ───
|
|
// GET /bom-tree/:itemCode
|
|
// itemCode → bom 헤더(현재 활성 버전) → bom_detail 전체를 parent_detail_id로 재귀 트리 구성하여 응답.
|
|
// 응답: { success, hasBom, treeRoots: [{ ...node, children }] } — 평면 호환 필드도 포함(child_item_id, child_item_name, child_item_code, quantity, unit, item_unit).
|
|
// 활성 버전이 없는 경우(미초기화 BOM): version_id IS NULL 인 detail을 폴백 조회 — 기존 process-info/bom-materials 동작과 정합.
|
|
export async function getBomTree(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { itemCode } = req.params;
|
|
if (!itemCode) {
|
|
return res.status(400).json({ success: false, message: "itemCode는 필수입니다" });
|
|
}
|
|
|
|
const pool = getPool();
|
|
// 1) BOM 헤더 찾기 — bom.item_code 우선, 없으면 item_info.id 경유
|
|
// 루트 노드(품목 자체)를 트리 0레벨로 표시하기 위해 item_info도 함께 조회 (BomTreeComponent 패턴 동일)
|
|
const bomRes = await pool.query(
|
|
`SELECT b.id AS bom_id, b.current_version_id, b.base_qty,
|
|
COALESCE(b.item_code, i.item_number) AS root_item_code,
|
|
i.id AS root_item_id,
|
|
i.item_number AS root_item_number,
|
|
i.item_name AS root_item_name,
|
|
i.unit AS root_unit
|
|
FROM bom b
|
|
LEFT JOIN item_info i ON i.id = b.item_id AND i.company_code = b.company_code
|
|
WHERE b.company_code = $1
|
|
AND (b.item_code = $2 OR i.item_number = $2)
|
|
LIMIT 1`,
|
|
[companyCode, itemCode]
|
|
);
|
|
if (bomRes.rowCount === 0) {
|
|
return res.json({ success: true, hasBom: false, treeRoots: [], rootItem: null });
|
|
}
|
|
const bomRow = bomRes.rows[0];
|
|
const bomId = bomRow.bom_id;
|
|
const versionId = bomRow.current_version_id || null;
|
|
const rootItem = {
|
|
itemId: bomRow.root_item_id || null,
|
|
itemCode: bomRow.root_item_number || bomRow.root_item_code || itemCode,
|
|
itemName: bomRow.root_item_name || "",
|
|
baseQty: bomRow.base_qty || "1",
|
|
unit: bomRow.root_unit || "",
|
|
};
|
|
|
|
// 2) bom_detail 전체 조회 — 활성 버전 또는 version_id IS NULL 폴백
|
|
const detailParams: any[] = [bomId, companyCode];
|
|
let whereVersion = "AND bd.version_id IS NULL";
|
|
if (versionId) {
|
|
whereVersion = "AND bd.version_id = $3";
|
|
detailParams.push(versionId);
|
|
}
|
|
const detailRes = await pool.query(
|
|
`SELECT bd.id, bd.bom_id, bd.parent_detail_id, bd.child_item_id,
|
|
bd.quantity, bd.unit AS detail_unit, bd.process_type,
|
|
bd.level, bd.seq_no,
|
|
ii.item_number AS child_item_code, ii.item_name AS child_item_name,
|
|
ii.unit AS item_unit
|
|
FROM bom_detail bd
|
|
LEFT JOIN item_info ii ON ii.id = bd.child_item_id AND ii.company_code = bd.company_code
|
|
WHERE bd.bom_id = $1 AND bd.company_code = $2 ${whereVersion}
|
|
ORDER BY bd.parent_detail_id NULLS FIRST, NULLIF(bd.seq_no, '')::int NULLS LAST, bd.id`,
|
|
detailParams
|
|
);
|
|
|
|
// 3) 클라이언트가 트리 빌드도 가능하지만, 백엔드에서 재귀 빌드까지 해 응답 (회사 표준 패턴)
|
|
type Node = Record<string, any> & { children: Node[] };
|
|
const nodeMap = new Map<string, Node>();
|
|
const roots: Node[] = [];
|
|
for (const r of detailRes.rows) {
|
|
nodeMap.set(r.id, { ...r, children: [] });
|
|
}
|
|
for (const r of detailRes.rows) {
|
|
const node = nodeMap.get(r.id)!;
|
|
if (r.parent_detail_id && nodeMap.has(r.parent_detail_id)) {
|
|
nodeMap.get(r.parent_detail_id)!.children.push(node);
|
|
} else {
|
|
roots.push(node);
|
|
}
|
|
}
|
|
|
|
return res.json({ success: true, hasBom: true, treeRoots: roots, rootItem });
|
|
} catch (error: any) {
|
|
logger.error("BOM 트리 조회 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|
|
|
|
// ─── 작업지시 단위 자재투입 매핑 조회 (편집 모달 복원용) ───
|
|
// GET /:wiNo/material-overrides
|
|
// 응답: { success, data: [{ routingDetailId, processCode, processName, materials: [{ bomItemId, bomItemName, bomQty, bomUnit, content, isOverride, originalBomItemId }] }] }
|
|
// work_phase='material' 인 wi_process_work_item과 그 하위 detail_type='material_input' detail만 묶어 반환.
|
|
export async function getMaterialOverrides(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { wiNo } = req.params;
|
|
if (!wiNo) return res.status(400).json({ success: false, message: "wiNo 필요" });
|
|
|
|
const pool = getPool();
|
|
const rows = await pool.query(
|
|
`SELECT wi.id AS wi_work_item_id,
|
|
wi.routing_detail_id,
|
|
rd.process_code,
|
|
COALESCE(p.process_name, rd.process_code, '') AS process_name,
|
|
d.id AS detail_id,
|
|
d.bom_item_id,
|
|
d.bom_item_name,
|
|
d.bom_qty,
|
|
d.bom_unit,
|
|
d.content,
|
|
d.sort_order
|
|
FROM wi_process_work_item wi
|
|
LEFT JOIN wi_process_work_item_detail d
|
|
ON d.wi_work_item_id = wi.id AND d.company_code = wi.company_code AND d.detail_type = 'material_input'
|
|
LEFT JOIN item_routing_detail rd
|
|
ON rd.id = wi.routing_detail_id AND rd.company_code = wi.company_code
|
|
LEFT JOIN process_mng p
|
|
ON p.process_code = rd.process_code AND p.company_code = rd.company_code
|
|
WHERE wi.work_instruction_no = $1
|
|
AND wi.company_code = $2
|
|
AND wi.work_phase = 'material'
|
|
ORDER BY wi.routing_detail_id, d.sort_order NULLS LAST, d.id`,
|
|
[wiNo, companyCode]
|
|
);
|
|
|
|
// routingDetailId 단위로 그룹핑
|
|
const byRouting = new Map<string, any>();
|
|
for (const r of rows.rows) {
|
|
const rdId = r.routing_detail_id;
|
|
if (!rdId) continue;
|
|
if (!byRouting.has(rdId)) {
|
|
byRouting.set(rdId, {
|
|
routingDetailId: rdId,
|
|
processCode: r.process_code || "",
|
|
processName: r.process_name || "",
|
|
materials: [],
|
|
});
|
|
}
|
|
if (r.detail_id) {
|
|
const contentStr = String(r.content || "");
|
|
const isOverride = contentStr.startsWith("대체");
|
|
const originalBomItemId = isOverride && contentStr.includes(":")
|
|
? contentStr.split(":", 2)[1] || null
|
|
: null;
|
|
byRouting.get(rdId).materials.push({
|
|
detailId: r.detail_id,
|
|
bomItemId: r.bom_item_id || "",
|
|
bomItemName: r.bom_item_name || "",
|
|
bomQty: r.bom_qty || "",
|
|
bomUnit: r.bom_unit || "",
|
|
content: contentStr,
|
|
isOverride,
|
|
originalBomItemId,
|
|
});
|
|
}
|
|
}
|
|
|
|
return res.json({ success: true, data: Array.from(byRouting.values()) });
|
|
} catch (error: any) {
|
|
logger.error("작업지시 자재투입 매핑 조회 실패", { error: error.message });
|
|
return res.status(500).json({ success: false, message: error.message });
|
|
}
|
|
}
|