Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
488
backend-node/src/controllers/analyticsReportController.ts
Normal file
488
backend-node/src/controllers/analyticsReportController.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) {
|
||||
if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx };
|
||||
return {
|
||||
condition: `${alias}.company_code = $${paramIdx}`,
|
||||
params: [companyCode],
|
||||
nextIdx: paramIdx + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) {
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = paramIdx;
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(`${dateExpr} >= $${idx}`);
|
||||
params.push(startDate);
|
||||
idx++;
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push(`${dateExpr} <= $${idx}`);
|
||||
params.push(endDate);
|
||||
idx++;
|
||||
}
|
||||
|
||||
return { conditions, params, nextIdx: idx };
|
||||
}
|
||||
|
||||
function buildWhereClause(conditions: string[]): string {
|
||||
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
}
|
||||
|
||||
function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] {
|
||||
const set = new Map<string, string>();
|
||||
rows.forEach((r: any) => {
|
||||
const val = r[field];
|
||||
if (val && val !== "미지정") set.set(val, r[labelField || field] || val);
|
||||
});
|
||||
return [...set.entries()].map(([value, label]) => ({ value, label }));
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 생산 리포트
|
||||
// ============================================
|
||||
export async function getProductionReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "wi", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(wi.start_date, wi.created_date::date::text) as date,
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
COALESCE(wi.worker, '미지정') as worker,
|
||||
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
|
||||
COALESCE(pr.production_qty, 0) as "prodQty",
|
||||
COALESCE(pr.defect_qty, 0) as "defectQty",
|
||||
0 as "runTime",
|
||||
0 as "downTime",
|
||||
wi.status,
|
||||
wi.company_code
|
||||
FROM work_instruction wi
|
||||
LEFT JOIN (
|
||||
SELECT wo_id, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
|
||||
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
|
||||
FROM production_record GROUP BY wo_id, company_code
|
||||
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (equipment_code, company_code)
|
||||
equipment_code, equipment_name, equipment_type, company_code
|
||||
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
|
||||
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
processes: extractFilterSet(dataRows, "process"),
|
||||
equipment: extractFilterSet(dataRows, "equipment"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
workers: extractFilterSet(dataRows, "worker"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("생산 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 재고 리포트
|
||||
// ============================================
|
||||
export async function getInventoryReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "ist", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(ist.updated_date, ist.created_date)::date::text as date,
|
||||
ist.item_code,
|
||||
COALESCE(ii.item_name, ist.item_code, '미지정') as item,
|
||||
COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse,
|
||||
'일반' as category,
|
||||
CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty",
|
||||
CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty",
|
||||
COALESCE(ih_in.in_qty, 0) as "inQty",
|
||||
COALESCE(ih_out.out_qty, 0) as "outQty",
|
||||
0 as "stockValue",
|
||||
GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric)
|
||||
- CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty",
|
||||
CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0
|
||||
AND COALESCE(ih_out.out_qty, 0) > 0
|
||||
THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric
|
||||
/ CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2)
|
||||
ELSE 0 END as "turnover",
|
||||
ist.company_code
|
||||
FROM inventory_stock ist
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code
|
||||
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
|
||||
AND ist.company_code = wi.company_code
|
||||
LEFT JOIN (
|
||||
SELECT item_code, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty
|
||||
FROM inventory_history WHERE transaction_type = 'IN'
|
||||
GROUP BY item_code, company_code
|
||||
) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code
|
||||
LEFT JOIN (
|
||||
SELECT item_code, company_code,
|
||||
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty
|
||||
FROM inventory_history WHERE transaction_type = 'OUT'
|
||||
GROUP BY item_code, company_code
|
||||
) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
warehouses: extractFilterSet(dataRows, "warehouse"),
|
||||
categories: [
|
||||
{ value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" },
|
||||
{ value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" },
|
||||
{ value: "일반", label: "일반" },
|
||||
],
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("재고 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 구매 리포트
|
||||
// ============================================
|
||||
export async function getPurchaseReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "po", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(po.order_date, po.created_date::date::text) as date,
|
||||
po.purchase_no,
|
||||
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
|
||||
COALESCE(po.item_name, po.item_code, '미지정') as item,
|
||||
po.item_code,
|
||||
COALESCE(po.manager, '미지정') as manager,
|
||||
po.status,
|
||||
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
|
||||
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
|
||||
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
|
||||
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
|
||||
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
|
||||
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
|
||||
1 as "orderCnt",
|
||||
po.company_code
|
||||
FROM purchase_order_mng po
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
suppliers: extractFilterSet(dataRows, "supplier"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
managers: extractFilterSet(dataRows, "manager"),
|
||||
statuses: extractFilterSet(dataRows, "status"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("구매 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 품질 리포트
|
||||
// ============================================
|
||||
export async function getQualityReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "pr", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx);
|
||||
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(pr.production_date, pr.created_date::date::text) as date,
|
||||
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
|
||||
'일반검사' as "defectType",
|
||||
COALESCE(wi.routing, '미지정') as process,
|
||||
COALESCE(pr.worker_name, '미지정') as inspector,
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty",
|
||||
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)
|
||||
- CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty",
|
||||
CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty",
|
||||
0 as "reworkQty",
|
||||
0 as "scrapQty",
|
||||
0 as "claimCnt",
|
||||
pr.company_code
|
||||
FROM production_record pr
|
||||
LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
defectTypes: [
|
||||
{ value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" },
|
||||
{ value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" },
|
||||
{ value: "일반검사", label: "일반검사" },
|
||||
],
|
||||
processes: extractFilterSet(dataRows, "process"),
|
||||
inspectors: extractFilterSet(dataRows, "inspector"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("품질 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설비 리포트
|
||||
// ============================================
|
||||
export async function getEquipmentReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "ei", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(ei.updated_date, ei.created_date)::date::text as date,
|
||||
ei.equipment_code,
|
||||
COALESCE(ei.equipment_name, ei.equipment_code) as equipment,
|
||||
COALESCE(ei.equipment_type, '미지정') as "equipType",
|
||||
COALESCE(ei.location, '미지정') as line,
|
||||
COALESCE(ui.user_name, ei.manager_id, '미지정') as manager,
|
||||
ei.status,
|
||||
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime",
|
||||
0 as "downTime",
|
||||
100 as "opRate",
|
||||
0 as "faultCnt",
|
||||
0 as "mtbf",
|
||||
0 as "mttr",
|
||||
0 as "maintCost",
|
||||
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty",
|
||||
ei.company_code
|
||||
FROM equipment_info ei
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info
|
||||
) ui ON ei.manager_id = ui.user_id
|
||||
${whereClause}
|
||||
ORDER BY equipment ASC
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
equipment: extractFilterSet(dataRows, "equipment"),
|
||||
equipTypes: extractFilterSet(dataRows, "equipType"),
|
||||
lines: extractFilterSet(dataRows, "line"),
|
||||
managers: extractFilterSet(dataRows, "manager"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("설비 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 금형 리포트
|
||||
// ============================================
|
||||
export async function getMoldReportData(req: any, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
const cf = buildCompanyFilter(companyCode, "mm", idx);
|
||||
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
COALESCE(mm.updated_date, mm.created_date)::date::text as date,
|
||||
mm.mold_code,
|
||||
COALESCE(mm.mold_name, mm.mold_code) as mold,
|
||||
COALESCE(mm.mold_type, mm.category, '미지정') as "moldType",
|
||||
COALESCE(ii.item_name, '미지정') as item,
|
||||
COALESCE(mm.manufacturer, '미지정') as maker,
|
||||
mm.operation_status as status,
|
||||
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt",
|
||||
CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot",
|
||||
CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0
|
||||
THEN ROUND(
|
||||
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0
|
||||
/ CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1)
|
||||
ELSE 0 END as "lifeRate",
|
||||
0 as "repairCnt",
|
||||
0 as "repairCost",
|
||||
0 as "prodQty",
|
||||
0 as "defectRate",
|
||||
CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse",
|
||||
mm.company_code
|
||||
FROM mold_mng mm
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info ORDER BY item_number, company_code, created_date DESC
|
||||
) ii ON mm.mold_code = ii.item_number AND mm.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY mold ASC
|
||||
`;
|
||||
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
molds: extractFilterSet(dataRows, "mold"),
|
||||
moldTypes: extractFilterSet(dataRows, "moldType"),
|
||||
items: extractFilterSet(dataRows, "item"),
|
||||
makers: extractFilterSet(dataRows, "maker"),
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("금형 리포트 조회 실패", { error: error.message });
|
||||
res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message });
|
||||
}
|
||||
}
|
||||
@@ -126,29 +126,41 @@ export class BatchManagementController {
|
||||
*/
|
||||
static async createBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { batchName, description, cronSchedule, mappings, isActive } =
|
||||
req.body;
|
||||
const {
|
||||
batchName, description, cronSchedule, mappings, isActive,
|
||||
executionType, nodeFlowId, nodeFlowContext,
|
||||
} = req.body;
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
if (
|
||||
!batchName ||
|
||||
!cronSchedule ||
|
||||
!mappings ||
|
||||
!Array.isArray(mappings)
|
||||
) {
|
||||
if (!batchName || !cronSchedule) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message:
|
||||
"필수 필드가 누락되었습니다. (batchName, cronSchedule, mappings)",
|
||||
message: "필수 필드가 누락되었습니다. (batchName, cronSchedule)",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.createBatchConfig({
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings,
|
||||
isActive: isActive !== undefined ? isActive : true,
|
||||
} as CreateBatchConfigRequest);
|
||||
// 노드 플로우 타입은 매핑 없이 생성 가능
|
||||
if (executionType !== "node_flow" && (!mappings || !Array.isArray(mappings))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "매핑 타입은 mappings 배열이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const batchConfig = await BatchService.createBatchConfig(
|
||||
{
|
||||
batchName,
|
||||
description,
|
||||
cronSchedule,
|
||||
mappings: mappings || [],
|
||||
isActive: isActive === false || isActive === "N" ? "N" : "Y",
|
||||
companyCode: companyCode || "",
|
||||
executionType: executionType || "mapping",
|
||||
nodeFlowId: nodeFlowId || null,
|
||||
nodeFlowContext: nodeFlowContext || null,
|
||||
} as CreateBatchConfigRequest,
|
||||
req.user?.userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
@@ -768,4 +780,287 @@ export class BatchManagementController {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 플로우 목록 조회 (배치 설정에서 플로우 선택용)
|
||||
* GET /api/batch-management/node-flows
|
||||
*/
|
||||
static async getNodeFlows(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
let flowQuery: string;
|
||||
let flowParams: any[] = [];
|
||||
|
||||
if (companyCode === "*") {
|
||||
flowQuery = `
|
||||
SELECT flow_id, flow_name, flow_description AS description, company_code,
|
||||
COALESCE(jsonb_array_length(
|
||||
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
|
||||
THEN (flow_data::jsonb -> 'nodes')
|
||||
ELSE '[]'::jsonb END
|
||||
), 0) AS node_count
|
||||
FROM node_flows
|
||||
ORDER BY flow_name
|
||||
`;
|
||||
} else {
|
||||
flowQuery = `
|
||||
SELECT flow_id, flow_name, flow_description AS description, company_code,
|
||||
COALESCE(jsonb_array_length(
|
||||
CASE WHEN flow_data IS NOT NULL AND flow_data::text != ''
|
||||
THEN (flow_data::jsonb -> 'nodes')
|
||||
ELSE '[]'::jsonb END
|
||||
), 0) AS node_count
|
||||
FROM node_flows
|
||||
WHERE company_code = $1
|
||||
ORDER BY flow_name
|
||||
`;
|
||||
flowParams = [companyCode];
|
||||
}
|
||||
|
||||
const result = await query(flowQuery, flowParams);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error("노드 플로우 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "노드 플로우 목록 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 대시보드 통계 조회
|
||||
* GET /api/batch-management/stats
|
||||
* totalBatches, activeBatches, todayExecutions, todayFailures, prevDayExecutions, prevDayFailures
|
||||
* 멀티테넌시: company_code 필터링 필수
|
||||
*/
|
||||
static async getBatchStats(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
|
||||
// 전체/활성 배치 수
|
||||
let configQuery: string;
|
||||
let configParams: any[] = [];
|
||||
if (companyCode === "*") {
|
||||
configQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
|
||||
FROM batch_configs
|
||||
`;
|
||||
} else {
|
||||
configQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE is_active = 'Y')::int AS active
|
||||
FROM batch_configs
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
configParams = [companyCode];
|
||||
}
|
||||
const configResult = await query<{ total: number; active: number }>(
|
||||
configQuery,
|
||||
configParams
|
||||
);
|
||||
|
||||
// 오늘/어제 실행·실패 수 (KST 기준 날짜)
|
||||
const logParams: any[] = [];
|
||||
let logWhere = "";
|
||||
if (companyCode && companyCode !== "*") {
|
||||
logWhere = " AND company_code = $1";
|
||||
logParams.push(companyCode);
|
||||
}
|
||||
const todayLogQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS today_executions,
|
||||
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS today_failures
|
||||
FROM batch_execution_logs
|
||||
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date
|
||||
${logWhere}
|
||||
`;
|
||||
const prevDayLogQuery = `
|
||||
SELECT
|
||||
COUNT(*)::int AS prev_executions,
|
||||
COUNT(*) FILTER (WHERE execution_status = 'FAILED')::int AS prev_failures
|
||||
FROM batch_execution_logs
|
||||
WHERE (start_time AT TIME ZONE 'Asia/Seoul')::date = (NOW() AT TIME ZONE 'Asia/Seoul')::date - INTERVAL '1 day'
|
||||
${logWhere}
|
||||
`;
|
||||
const [todayResult, prevResult] = await Promise.all([
|
||||
query<{ today_executions: number; today_failures: number }>(
|
||||
todayLogQuery,
|
||||
logParams
|
||||
),
|
||||
query<{ prev_executions: number; prev_failures: number }>(
|
||||
prevDayLogQuery,
|
||||
logParams
|
||||
),
|
||||
]);
|
||||
|
||||
const config = configResult[0];
|
||||
const today = todayResult[0];
|
||||
const prev = prevResult[0];
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalBatches: config?.total ?? 0,
|
||||
activeBatches: config?.active ?? 0,
|
||||
todayExecutions: today?.today_executions ?? 0,
|
||||
todayFailures: today?.today_failures ?? 0,
|
||||
prevDayExecutions: prev?.prev_executions ?? 0,
|
||||
prevDayFailures: prev?.prev_failures ?? 0,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 통계 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "배치 통계 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 24시간 스파크라인 (1시간 단위 집계)
|
||||
* GET /api/batch-management/batch-configs/:id/sparkline
|
||||
* 멀티테넌시: company_code 필터링 필수
|
||||
*/
|
||||
static async getBatchSparkline(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
const batchId = Number(id);
|
||||
if (!id || isNaN(batchId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 ID를 제공해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
const params: any[] = [batchId];
|
||||
let companyFilter = "";
|
||||
if (companyCode && companyCode !== "*") {
|
||||
companyFilter = " AND bel.company_code = $2";
|
||||
params.push(companyCode);
|
||||
}
|
||||
|
||||
// KST 기준 최근 24시간 1시간 단위 슬롯 + 집계 (generate_series로 24개 보장)
|
||||
const sparklineQuery = `
|
||||
WITH kst_slots AS (
|
||||
SELECT to_char(s, 'YYYY-MM-DD"T"HH24:00:00') AS hour
|
||||
FROM generate_series(
|
||||
(NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '23 hours',
|
||||
(NOW() AT TIME ZONE 'Asia/Seoul'),
|
||||
INTERVAL '1 hour'
|
||||
) AS s
|
||||
),
|
||||
agg AS (
|
||||
SELECT
|
||||
to_char(date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul')) AT TIME ZONE 'Asia/Seoul', 'YYYY-MM-DD"T"HH24:00:00') AS hour,
|
||||
COUNT(*) FILTER (WHERE bel.execution_status = 'SUCCESS')::int AS success,
|
||||
COUNT(*) FILTER (WHERE bel.execution_status = 'FAILED')::int AS failed
|
||||
FROM batch_execution_logs bel
|
||||
WHERE bel.batch_config_id = $1
|
||||
AND bel.start_time >= (NOW() AT TIME ZONE 'Asia/Seoul') - INTERVAL '24 hours'
|
||||
${companyFilter}
|
||||
GROUP BY date_trunc('hour', (bel.start_time AT TIME ZONE 'Asia/Seoul'))
|
||||
)
|
||||
SELECT
|
||||
k.hour,
|
||||
COALESCE(a.success, 0) AS success,
|
||||
COALESCE(a.failed, 0) AS failed
|
||||
FROM kst_slots k
|
||||
LEFT JOIN agg a ON k.hour = a.hour
|
||||
ORDER BY k.hour
|
||||
`;
|
||||
const data = await query<{
|
||||
hour: string;
|
||||
success: number;
|
||||
failed: number;
|
||||
}>(sparklineQuery, params);
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error("스파크라인 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "스파크라인 데이터 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치별 최근 실행 로그 (최대 20건)
|
||||
* GET /api/batch-management/batch-configs/:id/recent-logs
|
||||
* 멀티테넌시: company_code 필터링 필수
|
||||
*/
|
||||
static async getBatchRecentLogs(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user?.companyCode;
|
||||
const batchId = Number(id);
|
||||
const limit = Math.min(Number(req.query.limit) || 20, 20);
|
||||
if (!id || isNaN(batchId)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "올바른 배치 ID를 제공해주세요.",
|
||||
});
|
||||
}
|
||||
|
||||
let logsQuery: string;
|
||||
let logsParams: any[];
|
||||
if (companyCode === "*") {
|
||||
logsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
start_time AS started_at,
|
||||
end_time AS finished_at,
|
||||
execution_status AS status,
|
||||
total_records,
|
||||
success_records,
|
||||
failed_records,
|
||||
error_message,
|
||||
duration_ms
|
||||
FROM batch_execution_logs
|
||||
WHERE batch_config_id = $1
|
||||
ORDER BY start_time DESC
|
||||
LIMIT $2
|
||||
`;
|
||||
logsParams = [batchId, limit];
|
||||
} else {
|
||||
logsQuery = `
|
||||
SELECT
|
||||
id,
|
||||
start_time AS started_at,
|
||||
end_time AS finished_at,
|
||||
execution_status AS status,
|
||||
total_records,
|
||||
success_records,
|
||||
failed_records,
|
||||
error_message,
|
||||
duration_ms
|
||||
FROM batch_execution_logs
|
||||
WHERE batch_config_id = $1 AND company_code = $2
|
||||
ORDER BY start_time DESC
|
||||
LIMIT $3
|
||||
`;
|
||||
logsParams = [batchId, companyCode, limit];
|
||||
}
|
||||
|
||||
const result = await query(logsQuery, logsParams);
|
||||
return res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error("최근 실행 이력 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "최근 실행 이력 조회 실패",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
946
backend-node/src/controllers/designController.ts
Normal file
946
backend-node/src/controllers/designController.ts
Normal file
@@ -0,0 +1,946 @@
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { query, getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 회사코드 필터 조건 생성 헬퍼
|
||||
function companyFilter(companyCode: string, paramIndex: number, alias?: string): { condition: string; param: string; nextIndex: number } {
|
||||
const col = alias ? `${alias}.company_code` : "company_code";
|
||||
if (companyCode === "*") {
|
||||
return { condition: "", param: "", nextIndex: paramIndex };
|
||||
}
|
||||
return { condition: `${col} = $${paramIndex}`, param: companyCode, nextIndex: paramIndex + 1 };
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설계의뢰/설변요청 (DR/ECR) CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getDesignRequestList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { source_type, status, priority, search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let pi = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`r.company_code = $${pi}`);
|
||||
params.push(companyCode);
|
||||
pi++;
|
||||
}
|
||||
if (source_type) { conditions.push(`r.source_type = $${pi}`); params.push(source_type); pi++; }
|
||||
if (status) { conditions.push(`r.status = $${pi}`); params.push(status); pi++; }
|
||||
if (priority) { conditions.push(`r.priority = $${pi}`); params.push(priority); pi++; }
|
||||
if (search) {
|
||||
conditions.push(`(r.target_name ILIKE $${pi} OR r.request_no ILIKE $${pi} OR r.requester ILIKE $${pi})`);
|
||||
params.push(`%${search}%`);
|
||||
pi++;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `
|
||||
SELECT r.*,
|
||||
COALESCE(json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description)) FILTER (WHERE h.id IS NOT NULL), '[]') AS history,
|
||||
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
|
||||
FROM dsn_design_request r
|
||||
LEFT JOIN dsn_request_history h ON h.request_id = r.id
|
||||
${where}
|
||||
GROUP BY r.id
|
||||
ORDER BY r.created_date DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("설계의뢰 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDesignRequestDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
|
||||
const conditions = [`r.id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`r.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT r.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_request_history h WHERE h.request_id = r.id), '[]') AS history,
|
||||
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
|
||||
FROM dsn_design_request r
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("설계의뢰 상세 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
request_no, source_type, request_date, due_date, priority, status,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
impact, history,
|
||||
} = req.body;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO dsn_design_request (
|
||||
request_no, source_type, request_date, due_date, priority, status,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
writer, company_code
|
||||
) VALUES ($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)
|
||||
RETURNING *
|
||||
`;
|
||||
const result = await client.query(sql, [
|
||||
request_no, source_type || "dr", request_date, due_date, priority || "보통", status || "신규접수",
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency || "보통", reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
userId, companyCode,
|
||||
]);
|
||||
|
||||
const requestId = result.rows[0].id;
|
||||
|
||||
if (impact?.length) {
|
||||
for (const imp of impact) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
|
||||
[requestId, imp, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (history?.length) {
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[requestId, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("설계의뢰 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
request_no, source_type, request_date, due_date, priority, status, approval_step,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
impact, history,
|
||||
} = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const setClauses = [];
|
||||
const setParams: any[] = [];
|
||||
const fields: Record<string, any> = {
|
||||
request_no, source_type, request_date, due_date, priority, status, approval_step,
|
||||
target_name, customer, req_dept, requester, designer, order_no,
|
||||
design_type, spec, change_type, drawing_no, urgency, reason,
|
||||
content, apply_timing, review_memo, project_id, ecn_no,
|
||||
};
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) {
|
||||
setClauses.push(`${key} = $${pi}`);
|
||||
setParams.push(val);
|
||||
pi++;
|
||||
}
|
||||
}
|
||||
setClauses.push(`updated_date = now()`);
|
||||
|
||||
const sql = `UPDATE dsn_design_request SET ${setClauses.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`;
|
||||
const result = await client.query(sql, [...params, ...setParams]);
|
||||
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
|
||||
|
||||
if (impact !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_request_impact WHERE request_id = $1`, [id]);
|
||||
for (const imp of impact) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
|
||||
[id, imp, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (history !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_request_history WHERE request_id = $1`, [id]);
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[id, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("설계의뢰 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `DELETE FROM dsn_design_request WHERE ${conditions.join(" AND ")} RETURNING id`;
|
||||
const result = await query(sql, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("설계의뢰 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 이력 추가 (단건)
|
||||
export async function addRequestHistory(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { step, history_date, user_name, description } = req.body;
|
||||
|
||||
const sql = `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`;
|
||||
const result = await query(sql, [id, step, history_date, user_name, description, userId, companyCode]);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("의뢰 이력 추가 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 설계 프로젝트 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getProjectList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { status, search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let pi = 1;
|
||||
|
||||
if (companyCode !== "*") { conditions.push(`p.company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
if (status) { conditions.push(`p.status = $${pi}`); params.push(status); pi++; }
|
||||
if (search) {
|
||||
conditions.push(`(p.name ILIKE $${pi} OR p.project_no ILIKE $${pi} OR p.customer ILIKE $${pi})`);
|
||||
params.push(`%${search}%`);
|
||||
pi++;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `
|
||||
SELECT p.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object(
|
||||
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
|
||||
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
|
||||
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
|
||||
) ORDER BY t.sort_order, t.start_date)
|
||||
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
|
||||
) AS tasks
|
||||
FROM dsn_project p
|
||||
${where}
|
||||
ORDER BY p.created_date DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProjectDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
|
||||
const conditions = [`p.id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`p.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT p.*,
|
||||
COALESCE(
|
||||
(SELECT json_agg(json_build_object(
|
||||
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
|
||||
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
|
||||
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
|
||||
) ORDER BY t.sort_order, t.start_date)
|
||||
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
|
||||
) AS tasks
|
||||
FROM dsn_project p
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 상세 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, tasks } = req.body;
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO dsn_project (project_no, name, status, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
|
||||
[project_no, name, pStatus || "계획", pm, customer, start_date, end_date, source_no, description, progress || "0", parent_id, relation_type, userId, companyCode]
|
||||
);
|
||||
|
||||
const projectId = result.rows[0].id;
|
||||
if (tasks?.length) {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const t = tasks[i];
|
||||
await client.query(
|
||||
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
|
||||
[projectId, t.name, t.category, t.assignee, t.start_date, t.end_date, t.status || "대기", t.progress || "0", t.priority || "보통", t.remark, String(i), userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("프로젝트 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_project SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_project WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("프로젝트 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 프로젝트 태스크 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getTasksByProject(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { projectId } = req.params;
|
||||
|
||||
const conditions = [`t.project_id = $1`];
|
||||
const params: any[] = [projectId];
|
||||
if (companyCode !== "*") { conditions.push(`t.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT t.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'progress_before', w.progress_before, 'progress_after', w.progress_after, 'author', w.author, 'sub_item_id', w.sub_item_id) ORDER BY w.start_dt) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', i.id, 'title', i.title, 'status', i.status, 'priority', i.priority, 'description', i.description, 'registered_by', i.registered_by, 'registered_date', i.registered_date, 'resolved_date', i.resolved_date)) FROM dsn_task_issue i WHERE i.task_id = t.id), '[]') AS issues,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items
|
||||
FROM dsn_project_task t
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY t.sort_order, t.start_date
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { projectId } = req.params;
|
||||
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
|
||||
[projectId, name, category, assignee, start_date, end_date, status || "대기", progress || "0", priority || "보통", remark, sort_order || "0", userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { taskId } = req.params;
|
||||
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [taskId];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_project_task SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { taskId } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [taskId];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_project_task WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("태스크 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 작업일지 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getWorkLogsByTask(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { taskId } = req.params;
|
||||
|
||||
const conditions = [`w.task_id = $1`];
|
||||
const params: any[] = [taskId];
|
||||
if (companyCode !== "*") { conditions.push(`w.company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const sql = `
|
||||
SELECT w.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]') AS attachments,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', p.id, 'item', p.item, 'qty', p.qty, 'unit', p.unit, 'reason', p.reason, 'status', p.status)) FROM dsn_purchase_req p WHERE p.work_log_id = w.id), '[]') AS purchase_reqs,
|
||||
COALESCE((SELECT json_agg(json_build_object(
|
||||
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
|
||||
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
|
||||
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') AS coop_reqs
|
||||
FROM dsn_work_log w
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY w.start_dt DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("작업일지 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { taskId } = req.params;
|
||||
const { start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_work_log (task_id, start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
|
||||
[taskId, start_dt, end_dt, hours || "0", description, progress_before || "0", progress_after || "0", author, sub_item_id, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("작업일지 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { workLogId } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [workLogId];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_work_log WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "작업일지를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("작업일지 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 태스크 하위항목 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function createSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { taskId } = req.params;
|
||||
const { name, weight, progress, status } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_task_sub_item (task_id, name, weight, progress, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||
[taskId, name, weight || "0", progress || "0", status || "대기", userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("하위항목 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { subItemId } = req.params;
|
||||
const { name, weight, progress, status } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [subItemId];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { name, weight, progress, status };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_task_sub_item SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("하위항목 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { subItemId } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [subItemId];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_task_sub_item WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("하위항목 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 태스크 이슈 CRUD
|
||||
// ============================================
|
||||
|
||||
export async function createIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { taskId } = req.params;
|
||||
const { title, status, priority, description, registered_by, registered_date } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_task_issue (task_id, title, status, priority, description, registered_by, registered_date, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
|
||||
[taskId, title, status || "등록", priority || "보통", description, registered_by, registered_date, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("이슈 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { issueId } = req.params;
|
||||
const { title, status, priority, description, resolved_date } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [issueId];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { title, status, priority, description, resolved_date };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await query(`UPDATE dsn_task_issue SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "이슈를 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("이슈 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ECN (설변통보) CRUD
|
||||
// ============================================
|
||||
|
||||
export async function getEcnList(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { status, search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let pi = 1;
|
||||
|
||||
if (companyCode !== "*") { conditions.push(`e.company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
if (status) { conditions.push(`e.status = $${pi}`); params.push(status); pi++; }
|
||||
if (search) {
|
||||
conditions.push(`(e.ecn_no ILIKE $${pi} OR e.target ILIKE $${pi})`);
|
||||
params.push(`%${search}%`);
|
||||
pi++;
|
||||
}
|
||||
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const sql = `
|
||||
SELECT e.*,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'status', h.status, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_ecn_history h WHERE h.ecn_id = e.id), '[]') AS history,
|
||||
COALESCE((SELECT json_agg(nd.dept_name) FROM dsn_ecn_notify_dept nd WHERE nd.ecn_id = e.id), '[]') AS notify_depts
|
||||
FROM dsn_ecn e
|
||||
${where}
|
||||
ORDER BY e.created_date DESC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("ECN 목록 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO dsn_ecn (ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, writer, company_code)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
||||
[ecn_no, ecr_id, ecn_date, apply_date, status || "ECN발행", target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, userId, companyCode]
|
||||
);
|
||||
|
||||
const ecnId = result.rows[0].id;
|
||||
|
||||
if (notify_depts?.length) {
|
||||
for (const dept of notify_depts) {
|
||||
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [ecnId, dept, userId, companyCode]);
|
||||
}
|
||||
}
|
||||
|
||||
if (history?.length) {
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[ecnId, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("ECN 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
|
||||
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
let pi = 2;
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
|
||||
const sets: string[] = [];
|
||||
const fields: Record<string, any> = { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark };
|
||||
for (const [key, val] of Object.entries(fields)) {
|
||||
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
|
||||
}
|
||||
sets.push(`updated_date = now()`);
|
||||
|
||||
const result = await client.query(`UPDATE dsn_ecn SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
|
||||
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
|
||||
|
||||
if (notify_depts !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_ecn_notify_dept WHERE ecn_id = $1`, [id]);
|
||||
for (const dept of notify_depts) {
|
||||
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [id, dept, userId, companyCode]);
|
||||
}
|
||||
}
|
||||
if (history !== undefined) {
|
||||
await client.query(`DELETE FROM dsn_ecn_history WHERE ecn_id = $1`, [id]);
|
||||
for (const h of history) {
|
||||
await client.query(
|
||||
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||||
[id, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("ECN 수정 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const { id } = req.params;
|
||||
const conditions = [`id = $1`];
|
||||
const params: any[] = [id];
|
||||
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
|
||||
|
||||
const result = await query(`DELETE FROM dsn_ecn WHERE ${conditions.join(" AND ")} RETURNING id`, params);
|
||||
if (!result.length) { res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
logger.error("ECN 삭제 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 나의 업무 (My Work) - 로그인 사용자 기준
|
||||
// ============================================
|
||||
|
||||
export async function getMyWork(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userName = req.user!.userName;
|
||||
const { status, project_id } = req.query;
|
||||
|
||||
const conditions = [`t.assignee = $1`];
|
||||
const params: any[] = [userName];
|
||||
let pi = 2;
|
||||
|
||||
if (companyCode !== "*") { conditions.push(`t.company_code = $${pi}`); params.push(companyCode); pi++; }
|
||||
if (status) { conditions.push(`t.status = $${pi}`); params.push(status); pi++; }
|
||||
if (project_id) { conditions.push(`t.project_id = $${pi}`); params.push(project_id); pi++; }
|
||||
|
||||
const sql = `
|
||||
SELECT t.*,
|
||||
p.project_no, p.name AS project_name, p.customer AS project_customer, p.status AS project_status,
|
||||
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items,
|
||||
COALESCE((SELECT json_agg(json_build_object(
|
||||
'id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'sub_item_id', w.sub_item_id,
|
||||
'attachments', COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]'),
|
||||
'purchase_reqs', COALESCE((SELECT json_agg(json_build_object('id', pr.id, 'item', pr.item, 'qty', pr.qty, 'unit', pr.unit, 'reason', pr.reason, 'status', pr.status)) FROM dsn_purchase_req pr WHERE pr.work_log_id = w.id), '[]'),
|
||||
'coop_reqs', COALESCE((SELECT json_agg(json_build_object(
|
||||
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
|
||||
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
|
||||
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]')
|
||||
) ORDER BY w.start_dt DESC) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs
|
||||
FROM dsn_project_task t
|
||||
JOIN dsn_project p ON p.id = t.project_id
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY
|
||||
CASE t.status WHEN '진행중' THEN 1 WHEN '대기' THEN 2 WHEN '검토중' THEN 3 ELSE 4 END,
|
||||
t.end_date ASC
|
||||
`;
|
||||
const result = await query(sql, params);
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
logger.error("나의 업무 조회 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 구매요청 / 협업요청 CRUD (my-work에서 사용)
|
||||
// ============================================
|
||||
|
||||
export async function createPurchaseReq(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { workLogId } = req.params;
|
||||
const { item, qty, unit, reason, status } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_purchase_req (work_log_id, item, qty, unit, reason, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
|
||||
[workLogId, item, qty, unit, reason, status || "요청", userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("구매요청 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createCoopReq(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { workLogId } = req.params;
|
||||
const { to_user, to_dept, title, description, due_date } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_coop_req (work_log_id, to_user, to_dept, title, description, status, due_date, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
|
||||
[workLogId, to_user, to_dept, title, description, "요청", due_date, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("협업요청 생성 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCoopResponse(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode!;
|
||||
const userId = req.user!.userId;
|
||||
const { coopReqId } = req.params;
|
||||
const { response_date, user_name, content } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO dsn_coop_response (coop_req_id, response_date, user_name, content, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
|
||||
[coopReqId, response_date, user_name, content, userId, companyCode]
|
||||
);
|
||||
res.json({ success: true, data: result[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("협업응답 추가 오류", error);
|
||||
res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
352
backend-node/src/controllers/materialStatusController.ts
Normal file
352
backend-node/src/controllers/materialStatusController.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 자재현황 컨트롤러
|
||||
* - 생산계획(작업지시) 조회
|
||||
* - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회
|
||||
* - 창고 목록 조회
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { pool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ─── 생산계획(작업지시) 조회 ───
|
||||
|
||||
export async function getWorkOrders(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, itemCode, itemName } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
logger.info("최고 관리자 전체 작업지시 조회");
|
||||
} else {
|
||||
conditions.push(`p.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
conditions.push(`p.plan_date >= $${paramIndex}::date`);
|
||||
params.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
conditions.push(`p.plan_date <= $${paramIndex}::date`);
|
||||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (itemCode) {
|
||||
conditions.push(`p.item_code ILIKE $${paramIndex}`);
|
||||
params.push(`%${itemCode}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (itemName) {
|
||||
conditions.push(`p.item_name ILIKE $${paramIndex}`);
|
||||
params.push(`%${itemName}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
p.id,
|
||||
p.plan_no,
|
||||
p.item_code,
|
||||
p.item_name,
|
||||
p.plan_qty,
|
||||
p.completed_qty,
|
||||
p.plan_date,
|
||||
p.start_date,
|
||||
p.end_date,
|
||||
p.status,
|
||||
p.work_order_no,
|
||||
p.company_code
|
||||
FROM production_plan_mng p
|
||||
${whereClause}
|
||||
ORDER BY p.plan_date DESC, p.created_date DESC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("작업지시 조회 완료", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
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 getMaterialStatus(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { planIds, warehouseCode } = req.body;
|
||||
|
||||
if (!planIds || !Array.isArray(planIds) || planIds.length === 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, message: "작업지시를 선택해주세요." });
|
||||
}
|
||||
|
||||
// 1) 선택된 작업지시의 품목코드 + 수량 조회
|
||||
const planPlaceholders = planIds
|
||||
.map((_, i) => `$${i + 1}`)
|
||||
.join(",");
|
||||
let paramIndex = planIds.length + 1;
|
||||
|
||||
const companyCondition =
|
||||
companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`;
|
||||
const planParams: any[] = [...planIds];
|
||||
if (companyCode !== "*") {
|
||||
planParams.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const planQuery = `
|
||||
SELECT p.item_code, p.item_name, p.plan_qty
|
||||
FROM production_plan_mng p
|
||||
WHERE p.id IN (${planPlaceholders})
|
||||
${companyCondition}
|
||||
`;
|
||||
|
||||
const planResult = await pool.query(planQuery, planParams);
|
||||
|
||||
if (planResult.rowCount === 0) {
|
||||
return res.json({ success: true, data: [] });
|
||||
}
|
||||
|
||||
// 2) 해당 품목들의 BOM에서 필요 자재 목록 조회
|
||||
const itemCodes = planResult.rows.map((r: any) => r.item_code);
|
||||
const planQtyMap: Record<string, number> = {};
|
||||
for (const row of planResult.rows) {
|
||||
const code = row.item_code;
|
||||
planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0);
|
||||
}
|
||||
|
||||
const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(",");
|
||||
|
||||
// BOM 조인: bom -> bom_detail -> item_info (자재 정보)
|
||||
const bomCompanyCondition =
|
||||
companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`;
|
||||
const bomParams: any[] = [...itemCodes];
|
||||
if (companyCode !== "*") {
|
||||
bomParams.push(companyCode);
|
||||
}
|
||||
|
||||
const bomQuery = `
|
||||
SELECT
|
||||
b.item_code AS parent_item_code,
|
||||
b.base_qty AS bom_base_qty,
|
||||
bd.child_item_id,
|
||||
bd.quantity AS bom_qty,
|
||||
bd.unit AS bom_unit,
|
||||
bd.loss_rate,
|
||||
ii.item_name AS material_name,
|
||||
ii.item_number AS material_code,
|
||||
ii.unit AS material_unit
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code
|
||||
WHERE b.item_code IN (${itemPlaceholders})
|
||||
${bomCompanyCondition}
|
||||
ORDER BY b.item_code, bd.seq_no
|
||||
`;
|
||||
|
||||
const bomResult = await pool.query(bomQuery, bomParams);
|
||||
|
||||
// 3) 자재별 필요수량 계산
|
||||
interface MaterialNeed {
|
||||
childItemId: string;
|
||||
materialCode: string;
|
||||
materialName: string;
|
||||
unit: string;
|
||||
requiredQty: number;
|
||||
}
|
||||
|
||||
const materialMap: Record<string, MaterialNeed> = {};
|
||||
|
||||
for (const bomRow of bomResult.rows) {
|
||||
const parentQty = planQtyMap[bomRow.parent_item_code] || 0;
|
||||
const baseQty = Number(bomRow.bom_base_qty) || 1;
|
||||
const bomQty = Number(bomRow.bom_qty) || 0;
|
||||
const lossRate = Number(bomRow.loss_rate) || 0;
|
||||
|
||||
// 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100)
|
||||
const requiredQty =
|
||||
(parentQty / baseQty) * bomQty * (1 + lossRate / 100);
|
||||
|
||||
const key = bomRow.child_item_id;
|
||||
if (materialMap[key]) {
|
||||
materialMap[key].requiredQty += requiredQty;
|
||||
} else {
|
||||
materialMap[key] = {
|
||||
childItemId: bomRow.child_item_id,
|
||||
materialCode:
|
||||
bomRow.material_code || bomRow.child_item_id,
|
||||
materialName: bomRow.material_name || "알 수 없음",
|
||||
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
|
||||
requiredQty,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const materialIds = Object.keys(materialMap);
|
||||
|
||||
if (materialIds.length === 0) {
|
||||
return res.json({ success: true, data: [] });
|
||||
}
|
||||
|
||||
// 4) 재고 조회 (창고/위치별)
|
||||
const stockPlaceholders = materialIds
|
||||
.map((_, i) => `$${i + 1}`)
|
||||
.join(",");
|
||||
const stockParams: any[] = [...materialIds];
|
||||
let stockParamIdx = materialIds.length + 1;
|
||||
|
||||
const stockConditions: string[] = [
|
||||
`s.item_code IN (${stockPlaceholders})`,
|
||||
];
|
||||
|
||||
if (companyCode !== "*") {
|
||||
stockConditions.push(`s.company_code = $${stockParamIdx}`);
|
||||
stockParams.push(companyCode);
|
||||
stockParamIdx++;
|
||||
}
|
||||
|
||||
if (warehouseCode) {
|
||||
stockConditions.push(`s.warehouse_code = $${stockParamIdx}`);
|
||||
stockParams.push(warehouseCode);
|
||||
stockParamIdx++;
|
||||
}
|
||||
|
||||
const stockQuery = `
|
||||
SELECT
|
||||
s.item_code,
|
||||
s.warehouse_code,
|
||||
s.location_code,
|
||||
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
|
||||
FROM inventory_stock s
|
||||
WHERE ${stockConditions.join(" AND ")}
|
||||
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
|
||||
ORDER BY s.item_code, s.warehouse_code, s.location_code
|
||||
`;
|
||||
|
||||
const stockResult = await pool.query(stockQuery, stockParams);
|
||||
|
||||
// 5) 결과 조합
|
||||
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
|
||||
const stockByItem: Record<
|
||||
string,
|
||||
{ location: string; warehouse: string; qty: number }[]
|
||||
> = {};
|
||||
|
||||
for (const stockRow of stockResult.rows) {
|
||||
const code = stockRow.item_code;
|
||||
if (!stockByItem[code]) {
|
||||
stockByItem[code] = [];
|
||||
}
|
||||
stockByItem[code].push({
|
||||
location: stockRow.location_code || "",
|
||||
warehouse: stockRow.warehouse_code || "",
|
||||
qty: Number(stockRow.current_qty),
|
||||
});
|
||||
}
|
||||
|
||||
const resultData = materialIds.map((id) => {
|
||||
const material = materialMap[id];
|
||||
// inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음
|
||||
const locations =
|
||||
stockByItem[material.materialCode] ||
|
||||
stockByItem[id] ||
|
||||
[];
|
||||
|
||||
const totalCurrentQty = locations.reduce(
|
||||
(sum, loc) => sum + loc.qty,
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
code: material.materialCode,
|
||||
name: material.materialName,
|
||||
required: Math.round(material.requiredQty * 100) / 100,
|
||||
current: totalCurrentQty,
|
||||
unit: material.unit,
|
||||
locations,
|
||||
};
|
||||
});
|
||||
|
||||
logger.info("자재현황 조회 완료", {
|
||||
companyCode,
|
||||
planCount: planIds.length,
|
||||
materialCount: resultData.length,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: resultData });
|
||||
} catch (error: any) {
|
||||
logger.error("자재현황 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 창고 목록 조회 ───
|
||||
|
||||
export async function getWarehouses(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
query = `
|
||||
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
ORDER BY warehouse_code
|
||||
`;
|
||||
params = [];
|
||||
} else {
|
||||
query = `
|
||||
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1
|
||||
ORDER BY warehouse_code
|
||||
`;
|
||||
params = [companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("창고 목록 조회 완료", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
463
backend-node/src/controllers/processInfoController.ts
Normal file
463
backend-node/src/controllers/processInfoController.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 공정정보관리 컨트롤러
|
||||
* - 공정 마스터 CRUD
|
||||
* - 공정별 설비 관리
|
||||
* - 품목별 라우팅 관리
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { pool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 공정 마스터 CRUD
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export async function getProcessList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode, processName, processType, useYn } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (processCode) {
|
||||
conditions.push(`process_code ILIKE $${idx++}`);
|
||||
params.push(`%${processCode}%`);
|
||||
}
|
||||
if (processName) {
|
||||
conditions.push(`process_name ILIKE $${idx++}`);
|
||||
params.push(`%${processName}%`);
|
||||
}
|
||||
if (processType) {
|
||||
conditions.push(`process_type = $${idx++}`);
|
||||
params.push(processType);
|
||||
}
|
||||
if (useYn) {
|
||||
conditions.push(`use_yn = $${idx++}`);
|
||||
params.push(useYn);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM process_mng ${where} ORDER BY process_code`,
|
||||
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 createProcess(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
|
||||
|
||||
// 공정코드 자동 채번: PROC-001, PROC-002, ...
|
||||
const seqRes = await pool.query(
|
||||
`SELECT process_code FROM process_mng WHERE company_code = $1 AND process_code LIKE 'PROC-%' ORDER BY process_code DESC LIMIT 1`,
|
||||
[companyCode]
|
||||
);
|
||||
let nextNum = 1;
|
||||
if (seqRes.rowCount! > 0) {
|
||||
const lastCode = seqRes.rows[0].process_code;
|
||||
const numPart = parseInt(lastCode.replace("PROC-", ""), 10);
|
||||
if (!isNaN(numPart)) nextNum = numPart + 1;
|
||||
}
|
||||
const processCode = `PROC-${String(nextNum).padStart(3, "0")}`;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO process_mng (id, company_code, process_code, process_name, process_type, standard_time, worker_count, use_yn, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[companyCode, processCode, process_name, process_type, standard_time || "0", worker_count || "0", use_yn || "Y", writer]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProcess(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE process_mng SET process_name=$1, process_type=$2, standard_time=$3, worker_count=$4, use_yn=$5, updated_date=NOW()
|
||||
WHERE id=$6 AND company_code=$7 RETURNING *`,
|
||||
[process_name, process_type, standard_time, worker_count, use_yn, 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("공정 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProcesses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { ids } = req.body;
|
||||
|
||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "삭제할 공정을 선택해주세요." });
|
||||
}
|
||||
|
||||
const placeholders = ids.map((_: any, i: number) => `$${i + 1}`).join(",");
|
||||
// 설비 매핑도 삭제
|
||||
await pool.query(
|
||||
`DELETE FROM process_equipment WHERE process_code IN (SELECT process_code FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1})`,
|
||||
[...ids, companyCode]
|
||||
);
|
||||
const result = await pool.query(
|
||||
`DELETE FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1} RETURNING id`,
|
||||
[...ids, companyCode]
|
||||
);
|
||||
|
||||
return res.json({ success: true, deletedCount: result.rowCount });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 공정별 설비 관리
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export async function getProcessEquipments(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { processCode } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT pe.*, ei.equipment_name
|
||||
FROM process_equipment pe
|
||||
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
|
||||
WHERE pe.process_code = $1 AND pe.company_code = $2
|
||||
ORDER BY pe.equipment_code`,
|
||||
[processCode, 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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function addProcessEquipment(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { process_code, equipment_code } = req.body;
|
||||
|
||||
const dupCheck = await pool.query(
|
||||
`SELECT id FROM process_equipment WHERE process_code=$1 AND equipment_code=$2 AND company_code=$3`,
|
||||
[process_code, equipment_code, companyCode]
|
||||
);
|
||||
if (dupCheck.rowCount! > 0) {
|
||||
return res.status(400).json({ success: false, message: "이미 등록된 설비입니다." });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4) RETURNING *`,
|
||||
[companyCode, process_code, equipment_code, writer]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("공정 설비 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeProcessEquipment(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM process_equipment WHERE id=$1 AND company_code=$2`,
|
||||
[id, 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 getEquipmentList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
|
||||
const params = companyCode === "*" ? [] : [companyCode];
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
|
||||
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 getItemsForRouting(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { search } = req.query;
|
||||
|
||||
const conditions: string[] = ["i.company_code = rv.company_code"];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`i.company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(i.item_number ILIKE $${idx} OR i.item_name ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT i.id, i.item_number, i.item_name, i.size, i.unit, i.type
|
||||
FROM item_info i
|
||||
INNER JOIN item_routing_version rv ON rv.item_code = i.item_number AND rv.company_code = i.company_code
|
||||
${where}
|
||||
ORDER BY i.item_number LIMIT 200`,
|
||||
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 searchAllItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { search } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`company_code = $${idx++}`);
|
||||
params.push(companyCode);
|
||||
}
|
||||
if (search) {
|
||||
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await pool.query(
|
||||
`SELECT id, item_number, item_name, size, unit, type FROM item_info ${where} ORDER BY item_number LIMIT 200`,
|
||||
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 getRoutingVersions(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { itemCode } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM item_routing_version WHERE item_code=$1 AND company_code=$2 ORDER BY created_date`,
|
||||
[itemCode, 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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function createRoutingVersion(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { item_code, version_name, description, is_default } = req.body;
|
||||
|
||||
if (is_default) {
|
||||
await pool.query(
|
||||
`UPDATE item_routing_version SET is_default=false WHERE item_code=$1 AND company_code=$2`,
|
||||
[item_code, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[companyCode, item_code, version_name, description || "", is_default || false, writer]
|
||||
);
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 버전 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteRoutingVersion(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
|
||||
await pool.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
await pool.query(
|
||||
`DELETE FROM item_routing_version WHERE id=$1 AND company_code=$2`,
|
||||
[id, 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 getRoutingDetails(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { versionId } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT rd.*, pm.process_name
|
||||
FROM item_routing_detail rd
|
||||
LEFT JOIN process_mng pm ON rd.process_code = pm.process_code AND rd.company_code = pm.company_code
|
||||
WHERE rd.routing_version_id=$1 AND rd.company_code=$2
|
||||
ORDER BY CAST(rd.seq_no AS INTEGER)`,
|
||||
[versionId, 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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveRoutingDetails(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const writer = req.user!.userId;
|
||||
const { versionId } = req.params;
|
||||
const { details } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 기존 상세 삭제 후 재입력
|
||||
await client.query(
|
||||
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
||||
[versionId, companyCode]
|
||||
);
|
||||
|
||||
for (const d of details) {
|
||||
await client.query(
|
||||
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
|
||||
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
await client.query("ROLLBACK");
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("라우팅 상세 저장 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// BOM 구성 자재 조회 (품목코드 기반)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
export async function getBomMaterials(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 query = `
|
||||
SELECT
|
||||
bd.id,
|
||||
bd.child_item_id,
|
||||
bd.quantity,
|
||||
bd.unit as detail_unit,
|
||||
bd.process_type,
|
||||
i.item_name as child_item_name,
|
||||
i.item_number as child_item_code,
|
||||
i.type as child_item_type,
|
||||
i.unit as item_unit
|
||||
FROM bom b
|
||||
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
|
||||
LEFT JOIN item_info i ON bd.child_item_id = i.id AND bd.company_code = i.company_code
|
||||
WHERE b.item_code = $1 AND b.company_code = $2
|
||||
ORDER BY bd.seq_no ASC, bd.created_date ASC
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [itemCode, companyCode]);
|
||||
|
||||
logger.info("BOM 자재 조회 성공", { companyCode, itemCode, count: result.rowCount });
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("BOM 자재 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,28 @@ export async function getStockShortage(req: AuthenticatedRequest, res: Response)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 목록 조회 ───
|
||||
|
||||
export async function getPlans(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { productType, status, startDate, endDate, itemCode } = req.query;
|
||||
|
||||
const data = await productionService.getPlans(companyCode, {
|
||||
productType: productType as string,
|
||||
status: status as string,
|
||||
startDate: startDate as string,
|
||||
endDate: endDate as string,
|
||||
itemCode: itemCode as string,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
logger.error("생산계획 목록 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 생산계획 상세 조회 ───
|
||||
|
||||
export async function getPlanById(req: AuthenticatedRequest, res: Response) {
|
||||
|
||||
487
backend-node/src/controllers/receivingController.ts
Normal file
487
backend-node/src/controllers/receivingController.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
/**
|
||||
* 입고관리 컨트롤러
|
||||
*
|
||||
* 입고유형별 소스 테이블:
|
||||
* - 구매입고 → purchase_order_mng (발주)
|
||||
* - 반품입고 → shipment_instruction + shipment_instruction_detail (출하)
|
||||
* - 기타입고 → item_info (품목)
|
||||
*/
|
||||
|
||||
import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// 입고 목록 조회
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const {
|
||||
inbound_type,
|
||||
inbound_status,
|
||||
search_keyword,
|
||||
date_from,
|
||||
date_to,
|
||||
} = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`im.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (inbound_type && inbound_type !== "all") {
|
||||
conditions.push(`im.inbound_type = $${paramIdx}`);
|
||||
params.push(inbound_type);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (inbound_status && inbound_status !== "all") {
|
||||
conditions.push(`im.inbound_status = $${paramIdx}`);
|
||||
params.push(inbound_status);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (search_keyword) {
|
||||
conditions.push(
|
||||
`(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${search_keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
if (date_from) {
|
||||
conditions.push(`im.inbound_date >= $${paramIdx}::date`);
|
||||
params.push(date_from);
|
||||
paramIdx++;
|
||||
}
|
||||
if (date_to) {
|
||||
conditions.push(`im.inbound_date <= $${paramIdx}::date`);
|
||||
params.push(date_to);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
im.*,
|
||||
wh.warehouse_name
|
||||
FROM inbound_mng im
|
||||
LEFT JOIN warehouse_info wh
|
||||
ON im.warehouse_code = wh.warehouse_code
|
||||
AND im.company_code = wh.company_code
|
||||
${whereClause}
|
||||
ORDER BY im.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("입고 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
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 create(req: AuthenticatedRequest, res: Response) {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "입고 품목이 없습니다." });
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
const insertedRows: any[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO inbound_mng (
|
||||
company_code, inbound_number, inbound_type, inbound_date,
|
||||
reference_number, supplier_code, supplier_name,
|
||||
item_number, item_name, spec, material, unit,
|
||||
inbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
inbound_status, inspection_status,
|
||||
inspector, manager, memo,
|
||||
source_table, source_id,
|
||||
created_date, created_by, writer, status
|
||||
) VALUES (
|
||||
$1, $2, $3, $4::date,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15,
|
||||
$16, $17, $18,
|
||||
$19, $20,
|
||||
$21, $22, $23,
|
||||
$24, $25,
|
||||
NOW(), $26, $26, '입고'
|
||||
) RETURNING *`,
|
||||
[
|
||||
companyCode,
|
||||
inbound_number || item.inbound_number,
|
||||
item.inbound_type,
|
||||
inbound_date || item.inbound_date,
|
||||
item.reference_number || null,
|
||||
item.supplier_code || null,
|
||||
item.supplier_name || null,
|
||||
item.item_number || null,
|
||||
item.item_name || null,
|
||||
item.spec || null,
|
||||
item.material || null,
|
||||
item.unit || "EA",
|
||||
item.inbound_qty || 0,
|
||||
item.unit_price || 0,
|
||||
item.total_amount || 0,
|
||||
item.lot_number || null,
|
||||
warehouse_code || item.warehouse_code || null,
|
||||
location_code || item.location_code || null,
|
||||
item.inbound_status || "대기",
|
||||
item.inspection_status || "대기",
|
||||
inspector || item.inspector || null,
|
||||
manager || item.manager || null,
|
||||
memo || item.memo || null,
|
||||
item.source_table || null,
|
||||
item.source_id || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
insertedRows.push(result.rows[0]);
|
||||
|
||||
// 구매입고인 경우 발주의 received_qty 업데이트
|
||||
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
|
||||
await client.query(
|
||||
`UPDATE purchase_order_mng
|
||||
SET received_qty = CAST(
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
|
||||
),
|
||||
remain_qty = CAST(
|
||||
GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
||||
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
|
||||
),
|
||||
status = CASE
|
||||
WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1
|
||||
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
||||
THEN '입고완료'
|
||||
ELSE '부분입고'
|
||||
END,
|
||||
updated_date = NOW()
|
||||
WHERE id = $2 AND company_code = $3`,
|
||||
[item.inbound_qty || 0, item.source_id, companyCode]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("입고 등록 완료", {
|
||||
companyCode,
|
||||
userId,
|
||||
count: insertedRows.length,
|
||||
inbound_number,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: insertedRows,
|
||||
message: `${insertedRows.length}건 입고 등록 완료`,
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("입고 등록 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 입고 수정
|
||||
export async function update(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const {
|
||||
inbound_date, inbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
inbound_status, inspection_status,
|
||||
inspector, manager: mgr, memo,
|
||||
} = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`UPDATE inbound_mng SET
|
||||
inbound_date = COALESCE($1::date, inbound_date),
|
||||
inbound_qty = COALESCE($2, inbound_qty),
|
||||
unit_price = COALESCE($3, unit_price),
|
||||
total_amount = COALESCE($4, total_amount),
|
||||
lot_number = COALESCE($5, lot_number),
|
||||
warehouse_code = COALESCE($6, warehouse_code),
|
||||
location_code = COALESCE($7, location_code),
|
||||
inbound_status = COALESCE($8, inbound_status),
|
||||
inspection_status = COALESCE($9, inspection_status),
|
||||
inspector = COALESCE($10, inspector),
|
||||
manager = COALESCE($11, manager),
|
||||
memo = COALESCE($12, memo),
|
||||
updated_date = NOW(),
|
||||
updated_by = $13
|
||||
WHERE id = $14 AND company_code = $15
|
||||
RETURNING *`,
|
||||
[
|
||||
inbound_date, inbound_qty, unit_price, total_amount,
|
||||
lot_number, warehouse_code, location_code,
|
||||
inbound_status, inspection_status,
|
||||
inspector, mgr, memo,
|
||||
userId, id, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("입고 수정", { companyCode, userId, id });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("입고 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 입고 삭제
|
||||
export async function deleteReceiving(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { id } = req.params;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
|
||||
}
|
||||
|
||||
logger.info("입고 삭제", { companyCode, id });
|
||||
|
||||
return res.json({ success: true, message: "삭제 완료" });
|
||||
} catch (error: any) {
|
||||
logger.error("입고 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 구매입고용: 발주 데이터 조회 (미입고분)
|
||||
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
// 잔량이 있는 것만 조회
|
||||
conditions.push(
|
||||
`COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0`
|
||||
);
|
||||
conditions.push(`status NOT IN ('입고완료', '취소')`);
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, purchase_no, order_date, supplier_code, supplier_name,
|
||||
item_code, item_name, spec, material,
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
|
||||
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
|
||||
COALESCE(CAST(NULLIF(remain_qty, '') AS numeric),
|
||||
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
|
||||
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
|
||||
) AS remain_qty,
|
||||
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
|
||||
status, due_date
|
||||
FROM purchase_order_mng
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY order_date DESC, purchase_no`,
|
||||
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 getShipments(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["si.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
sid.id AS detail_id,
|
||||
si.id AS instruction_id,
|
||||
si.instruction_no,
|
||||
si.instruction_date,
|
||||
si.partner_id,
|
||||
si.status AS instruction_status,
|
||||
sid.item_code,
|
||||
sid.item_name,
|
||||
sid.spec,
|
||||
sid.material,
|
||||
COALESCE(sid.ship_qty, 0) AS ship_qty,
|
||||
COALESCE(sid.order_qty, 0) AS order_qty,
|
||||
sid.source_type
|
||||
FROM shipment_instruction si
|
||||
JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id
|
||||
AND si.company_code = sid.company_code
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY si.instruction_date DESC, si.instruction_no`,
|
||||
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 getItems(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword } = req.query;
|
||||
|
||||
const conditions: string[] = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let paramIdx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(
|
||||
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
|
||||
);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, item_number, item_name, size AS spec, material, unit,
|
||||
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
|
||||
FROM item_info
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY item_name`,
|
||||
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 generateNumber(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const prefix = `RCV-${yyyy}-`;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT inbound_number FROM inbound_mng
|
||||
WHERE company_code = $1 AND inbound_number LIKE $2
|
||||
ORDER BY inbound_number DESC LIMIT 1`,
|
||||
[companyCode, `${prefix}%`]
|
||||
);
|
||||
|
||||
let seq = 1;
|
||||
if (result.rows.length > 0) {
|
||||
const lastNo = result.rows[0].inbound_number;
|
||||
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
|
||||
if (!isNaN(lastSeq)) seq = lastSeq + 1;
|
||||
}
|
||||
|
||||
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
|
||||
|
||||
return res.json({ success: true, data: newNumber });
|
||||
} catch (error: any) {
|
||||
logger.error("입고번호 생성 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 창고 목록 조회
|
||||
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const pool = getPool();
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT warehouse_code, warehouse_name, warehouse_type
|
||||
FROM warehouse_info
|
||||
WHERE company_code = $1 AND status != '삭제'
|
||||
ORDER BY warehouse_name`,
|
||||
[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 });
|
||||
}
|
||||
}
|
||||
161
backend-node/src/controllers/salesReportController.ts
Normal file
161
backend-node/src/controllers/salesReportController.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { Response } from "express";
|
||||
import { query } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 영업 리포트 컨트롤러
|
||||
* - 수주 데이터를 기반으로 집계/분석용 원본 데이터를 반환
|
||||
* - 프론트엔드에서 그룹핑/집계/필터링 처리
|
||||
*/
|
||||
export async function getSalesReportData(
|
||||
req: any,
|
||||
res: Response
|
||||
): Promise<void> {
|
||||
try {
|
||||
const companyCode = req.user?.companyCode;
|
||||
if (!companyCode) {
|
||||
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
|
||||
return;
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
// 멀티테넌시: 최고관리자는 전체, 일반 회사는 자기 데이터만
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`som.company_code = $${paramIdx}`);
|
||||
params.push(companyCode);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
// 날짜 필터 (due_date 또는 order_date 기준)
|
||||
if (startDate) {
|
||||
conditions.push(
|
||||
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) >= $${paramIdx}`
|
||||
);
|
||||
params.push(startDate);
|
||||
paramIdx++;
|
||||
}
|
||||
if (endDate) {
|
||||
conditions.push(
|
||||
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) <= $${paramIdx}`
|
||||
);
|
||||
params.push(endDate);
|
||||
paramIdx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const dataQuery = `
|
||||
SELECT
|
||||
som.order_no,
|
||||
COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date,
|
||||
som.order_date,
|
||||
som.partner_id,
|
||||
COALESCE(cm.customer_name, som.partner_id, '미지정') as customer,
|
||||
sod.part_code,
|
||||
COALESCE(ii.item_name, sod.part_name, sod.part_code, '미지정') as item,
|
||||
CAST(COALESCE(NULLIF(sod.qty, ''), '0') AS numeric) as "orderQty",
|
||||
CAST(COALESCE(NULLIF(sod.ship_qty, ''), '0') AS numeric) as "shipQty",
|
||||
CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice",
|
||||
CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt",
|
||||
1 as "orderCount",
|
||||
som.status,
|
||||
som.company_code
|
||||
FROM sales_order_mng som
|
||||
JOIN sales_order_detail sod
|
||||
ON som.order_no = sod.order_no
|
||||
AND som.company_code = sod.company_code
|
||||
LEFT JOIN customer_mng cm
|
||||
ON som.partner_id = cm.customer_code
|
||||
AND som.company_code = cm.company_code
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT ON (item_number, company_code)
|
||||
item_number, item_name, company_code
|
||||
FROM item_info
|
||||
ORDER BY item_number, company_code, created_date DESC
|
||||
) ii
|
||||
ON sod.part_code = ii.item_number
|
||||
AND sod.company_code = ii.company_code
|
||||
${whereClause}
|
||||
ORDER BY date DESC NULLS LAST
|
||||
`;
|
||||
|
||||
// query()는 rows 배열을 직접 반환
|
||||
const dataRows = await query(dataQuery, params);
|
||||
|
||||
// 필터 옵션 조회 (거래처, 품목, 상태)
|
||||
const filterParams: any[] = [];
|
||||
let filterWhere = "";
|
||||
|
||||
if (companyCode !== "*") {
|
||||
filterWhere = `WHERE company_code = $1`;
|
||||
filterParams.push(companyCode);
|
||||
}
|
||||
|
||||
const statusWhere = filterWhere
|
||||
? `${filterWhere} AND status IS NOT NULL`
|
||||
: `WHERE status IS NOT NULL`;
|
||||
|
||||
const [customersRows, statusRows] = await Promise.all([
|
||||
query(
|
||||
`SELECT DISTINCT customer_code as value, customer_name as label
|
||||
FROM customer_mng ${filterWhere}
|
||||
ORDER BY customer_name`,
|
||||
filterParams
|
||||
),
|
||||
query(
|
||||
`SELECT DISTINCT status as value, status as label
|
||||
FROM sales_order_mng ${statusWhere}
|
||||
ORDER BY status`,
|
||||
filterParams
|
||||
),
|
||||
]);
|
||||
|
||||
// 품목은 데이터에서 추출 (실제 수주에 사용된 품목만)
|
||||
const itemSet = new Map<string, string>();
|
||||
dataRows.forEach((row: any) => {
|
||||
if (row.part_code && !itemSet.has(row.part_code)) {
|
||||
itemSet.set(row.part_code, row.item);
|
||||
}
|
||||
});
|
||||
const items = Array.from(itemSet.entries()).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
logger.info("영업 리포트 데이터 조회", {
|
||||
companyCode,
|
||||
rowCount: dataRows.length,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataRows,
|
||||
filterOptions: {
|
||||
customers: customersRows,
|
||||
items,
|
||||
statuses: statusRows,
|
||||
},
|
||||
totalCount: dataRows.length,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("영업 리포트 데이터 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: "영업 리포트 데이터 조회에 실패했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
482
backend-node/src/controllers/shippingOrderController.ts
Normal file
482
backend-node/src/controllers/shippingOrderController.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* 출하지시 컨트롤러 (shipment_instruction + shipment_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";
|
||||
|
||||
// ─── 출하지시 목록 조회 ───
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, customer, keyword } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (companyCode !== "*") {
|
||||
conditions.push(`si.company_code = $${idx}`);
|
||||
params.push(companyCode);
|
||||
idx++;
|
||||
}
|
||||
if (dateFrom) {
|
||||
conditions.push(`si.instruction_date >= $${idx}::date`);
|
||||
params.push(dateFrom);
|
||||
idx++;
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push(`si.instruction_date <= $${idx}::date`);
|
||||
params.push(dateTo);
|
||||
idx++;
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(`si.status = $${idx}`);
|
||||
params.push(status);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
si.*,
|
||||
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', sid.id,
|
||||
'item_code', sid.item_code,
|
||||
'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code),
|
||||
'spec', sid.spec,
|
||||
'material', sid.material,
|
||||
'order_qty', sid.order_qty,
|
||||
'plan_qty', sid.plan_qty,
|
||||
'ship_qty', sid.ship_qty,
|
||||
'source_type', sid.source_type,
|
||||
'shipment_plan_id', sid.shipment_plan_id,
|
||||
'sales_order_id', sid.sales_order_id,
|
||||
'detail_id', sid.detail_id
|
||||
)
|
||||
) FILTER (WHERE sid.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS items
|
||||
FROM shipment_instruction si
|
||||
LEFT JOIN customer_mng c
|
||||
ON si.partner_id = c.customer_code AND si.company_code = c.company_code
|
||||
LEFT JOIN shipment_instruction_detail sid
|
||||
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = sid.item_code AND company_code = si.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
${where}
|
||||
GROUP BY si.id, c.customer_name
|
||||
ORDER BY si.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount });
|
||||
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 instructionNo: string;
|
||||
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode, "shipment_instruction", "instruction_no"
|
||||
);
|
||||
if (rule) {
|
||||
instructionNo = 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 shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
|
||||
[companyCode, `SI-${today}-%`]
|
||||
);
|
||||
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
|
||||
instructionNo = `SI-${today}-${seq}`;
|
||||
}
|
||||
|
||||
return res.json({ success: true, instructionNo });
|
||||
} 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 {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const {
|
||||
id: editId,
|
||||
instructionDate,
|
||||
partnerId,
|
||||
status: orderStatus,
|
||||
memo,
|
||||
carrierName,
|
||||
vehicleNo,
|
||||
driverName,
|
||||
driverContact,
|
||||
arrivalTime,
|
||||
deliveryAddress,
|
||||
items,
|
||||
} = req.body;
|
||||
|
||||
if (!instructionDate) {
|
||||
return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" });
|
||||
}
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
|
||||
let instructionId: number;
|
||||
let instructionNo: string;
|
||||
|
||||
if (editId) {
|
||||
// 수정
|
||||
const check = await client.query(
|
||||
`SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`,
|
||||
[editId, companyCode]
|
||||
);
|
||||
if (check.rowCount === 0) {
|
||||
throw new Error("출하지시를 찾을 수 없습니다");
|
||||
}
|
||||
instructionId = editId;
|
||||
instructionNo = check.rows[0].instruction_no;
|
||||
|
||||
await client.query(
|
||||
`UPDATE shipment_instruction SET
|
||||
instruction_date = $1::date, partner_id = $2, status = $3, memo = $4,
|
||||
carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8,
|
||||
arrival_time = $9, delivery_address = $10,
|
||||
updated_date = NOW(), updated_by = $11
|
||||
WHERE id = $12 AND company_code = $13`,
|
||||
[
|
||||
instructionDate, partnerId, orderStatus || "READY", memo,
|
||||
carrierName, vehicleNo, driverName, driverContact,
|
||||
arrivalTime || null, deliveryAddress,
|
||||
userId, editId, companyCode,
|
||||
]
|
||||
);
|
||||
|
||||
// 기존 디테일 삭제 후 재삽입
|
||||
await client.query(
|
||||
`DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`,
|
||||
[editId, companyCode]
|
||||
);
|
||||
} else {
|
||||
// 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode, "shipment_instruction", "instruction_no"
|
||||
);
|
||||
if (rule) {
|
||||
instructionNo = await numberingRuleService.allocateCode(
|
||||
rule.ruleId, companyCode, { instruction_date: instructionDate }
|
||||
);
|
||||
logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo });
|
||||
} 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 shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
|
||||
[companyCode, `SI-${today}-%`]
|
||||
);
|
||||
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
|
||||
instructionNo = `SI-${today}-${seq}`;
|
||||
logger.info("폴백으로 출하지시번호 생성", { instructionNo });
|
||||
}
|
||||
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO shipment_instruction
|
||||
(company_code, instruction_no, instruction_date, partner_id, status, memo,
|
||||
carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address,
|
||||
created_date, created_by)
|
||||
VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13)
|
||||
RETURNING id`,
|
||||
[
|
||||
companyCode, instructionNo, instructionDate, partnerId,
|
||||
orderStatus || "READY", memo,
|
||||
carrierName, vehicleNo, driverName, driverContact,
|
||||
arrivalTime || null, deliveryAddress, userId,
|
||||
]
|
||||
);
|
||||
instructionId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
// 디테일 삽입
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO shipment_instruction_detail
|
||||
(company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id,
|
||||
item_code, item_name, spec, material, order_qty, plan_qty, ship_qty,
|
||||
source_type, created_date, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`,
|
||||
[
|
||||
companyCode, instructionId,
|
||||
item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null,
|
||||
item.itemCode, item.itemName, item.spec, item.material,
|
||||
item.orderQty || 0, item.planQty || 0, item.shipQty || 0,
|
||||
item.sourceType || "shipmentPlan", userId,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length });
|
||||
return res.json({ success: true, data: { id: instructionId, instructionNo } });
|
||||
} 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 || !Array.isArray(ids) || ids.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" });
|
||||
}
|
||||
|
||||
const pool = getPool();
|
||||
// CASCADE로 디테일도 자동 삭제
|
||||
const result = await pool.query(
|
||||
`DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`,
|
||||
[ids, companyCode]
|
||||
);
|
||||
|
||||
logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount });
|
||||
return res.json({ success: true, deletedCount: result.rowCount });
|
||||
} catch (error: any) {
|
||||
logger.error("출하지시 삭제 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하계획 목록 (모달 왼쪽 패널용) ───
|
||||
export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["sp.company_code = $1", "sp.status = 'READY'"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const fromClause = `
|
||||
FROM shipment_plan sp
|
||||
LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code
|
||||
LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no,
|
||||
COALESCE(m.order_no, d.order_no, '') AS order_no,
|
||||
COALESCE(d.part_code, m.part_code, '') AS item_code,
|
||||
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name,
|
||||
COALESCE(d.spec, m.spec, '') AS spec,
|
||||
COALESCE(m.material, '') AS material,
|
||||
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
|
||||
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
|
||||
sp.detail_id, sp.sales_order_id
|
||||
${fromClause}
|
||||
ORDER BY sp.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 소스 조회 실패", { error: error.message });
|
||||
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, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["d.company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.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++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`);
|
||||
params.push(`%${customer}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
const fromClause = `
|
||||
FROM sales_order_detail d
|
||||
LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code
|
||||
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
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const 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(m.material, '') AS material,
|
||||
COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty,
|
||||
COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty,
|
||||
COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name,
|
||||
COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code,
|
||||
m.id AS master_id
|
||||
${fromClause}
|
||||
ORDER BY d.created_date DESC
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} 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: pageStr, pageSize: pageSizeStr } = req.query;
|
||||
const page = Math.max(1, parseInt(pageStr as string) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const conditions = ["company_code = $1"];
|
||||
const params: any[] = [companyCode];
|
||||
let idx = 2;
|
||||
|
||||
if (keyword) {
|
||||
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.join(" AND ");
|
||||
|
||||
const pool = getPool();
|
||||
const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params);
|
||||
const totalCount = parseInt(countResult.rows[0].total);
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
item_number AS item_code, item_name,
|
||||
COALESCE(size, '') AS spec, COALESCE(material, '') AS material
|
||||
FROM item_info
|
||||
WHERE ${whereClause}
|
||||
ORDER BY item_name
|
||||
LIMIT $${idx} OFFSET $${idx + 1}
|
||||
`;
|
||||
params.push(pageSize, offset);
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
|
||||
} catch (error: any) {
|
||||
logger.error("품목 소스 조회 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,218 @@ async function getNormalizedOrders(
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하계획 목록 조회 (관리 화면용) ───
|
||||
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, customer, keyword } = req.query;
|
||||
|
||||
const conditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
// 멀티테넌시
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 전체 조회
|
||||
} else {
|
||||
conditions.push(`sp.company_code = $${paramIndex}`);
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
conditions.push(`sp.plan_date >= $${paramIndex}::date`);
|
||||
params.push(dateFrom);
|
||||
paramIndex++;
|
||||
}
|
||||
if (dateTo) {
|
||||
conditions.push(`sp.plan_date <= $${paramIndex}::date`);
|
||||
params.push(dateTo);
|
||||
paramIndex++;
|
||||
}
|
||||
if (status) {
|
||||
conditions.push(`sp.status = $${paramIndex}`);
|
||||
params.push(status);
|
||||
paramIndex++;
|
||||
}
|
||||
if (customer) {
|
||||
conditions.push(`(c.customer_name ILIKE $${paramIndex} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${paramIndex})`);
|
||||
params.push(`%${customer}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(
|
||||
COALESCE(m.order_no, d.order_no, '') ILIKE $${paramIndex}
|
||||
OR COALESCE(d.part_code, m.part_code, '') ILIKE $${paramIndex}
|
||||
OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${paramIndex}
|
||||
OR sp.shipment_plan_no ILIKE $${paramIndex}
|
||||
)`);
|
||||
params.push(`%${keyword}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
sp.id,
|
||||
sp.plan_date,
|
||||
sp.plan_qty,
|
||||
sp.status,
|
||||
sp.memo,
|
||||
sp.shipment_plan_no,
|
||||
sp.created_date,
|
||||
sp.created_by,
|
||||
sp.detail_id,
|
||||
sp.sales_order_id,
|
||||
sp.remain_qty,
|
||||
COALESCE(m.order_no, d.order_no, '') AS order_no,
|
||||
COALESCE(d.part_code, m.part_code, '') AS part_code,
|
||||
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS part_name,
|
||||
COALESCE(d.spec, m.spec, '') AS spec,
|
||||
COALESCE(m.material, '') AS material,
|
||||
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
|
||||
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
|
||||
COALESCE(d.due_date, m.due_date::text, '') AS due_date,
|
||||
COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty,
|
||||
COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS shipped_qty
|
||||
FROM shipment_plan sp
|
||||
LEFT JOIN sales_order_detail d
|
||||
ON sp.detail_id = d.id AND sp.company_code = d.company_code
|
||||
LEFT JOIN sales_order_mng m
|
||||
ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name FROM item_info
|
||||
WHERE item_number = COALESCE(d.part_code, m.part_code)
|
||||
AND company_code = sp.company_code
|
||||
LIMIT 1
|
||||
) i ON true
|
||||
LEFT JOIN customer_mng c
|
||||
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code
|
||||
AND sp.company_code = c.company_code
|
||||
${whereClause}
|
||||
ORDER BY sp.created_date DESC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
const result = await pool.query(query, params);
|
||||
|
||||
logger.info("출하계획 목록 조회", {
|
||||
companyCode,
|
||||
rowCount: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({ success: true, data: result.rows });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 목록 조회 실패", {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
});
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 출하계획 단건 수정 ───
|
||||
|
||||
export async function updatePlan(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
const { id } = req.params;
|
||||
const { planQty, planDate, memo } = req.body;
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
const check = await pool.query(
|
||||
`SELECT id, status FROM shipment_plan WHERE id = $1 AND company_code = $2`,
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
if (check.rowCount === 0) {
|
||||
return res.status(404).json({ success: false, message: "출하계획을 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
const setClauses: string[] = [];
|
||||
const updateParams: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (planQty !== undefined) {
|
||||
setClauses.push(`plan_qty = $${idx}`);
|
||||
updateParams.push(planQty);
|
||||
idx++;
|
||||
}
|
||||
if (planDate !== undefined) {
|
||||
setClauses.push(`plan_date = $${idx}::date`);
|
||||
updateParams.push(planDate);
|
||||
idx++;
|
||||
}
|
||||
if (memo !== undefined) {
|
||||
setClauses.push(`memo = $${idx}`);
|
||||
updateParams.push(memo);
|
||||
idx++;
|
||||
}
|
||||
|
||||
setClauses.push(`updated_date = NOW()`);
|
||||
setClauses.push(`updated_by = $${idx}`);
|
||||
updateParams.push(userId);
|
||||
idx++;
|
||||
|
||||
updateParams.push(id);
|
||||
updateParams.push(companyCode);
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE shipment_plan
|
||||
SET ${setClauses.join(", ")}
|
||||
WHERE id = $${idx - 1} AND company_code = $${idx}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
// 파라미터 인덱스 수정
|
||||
const finalParams: any[] = [];
|
||||
let pIdx = 1;
|
||||
const setClausesFinal: string[] = [];
|
||||
|
||||
if (planQty !== undefined) {
|
||||
setClausesFinal.push(`plan_qty = $${pIdx}`);
|
||||
finalParams.push(planQty);
|
||||
pIdx++;
|
||||
}
|
||||
if (planDate !== undefined) {
|
||||
setClausesFinal.push(`plan_date = $${pIdx}::date`);
|
||||
finalParams.push(planDate);
|
||||
pIdx++;
|
||||
}
|
||||
if (memo !== undefined) {
|
||||
setClausesFinal.push(`memo = $${pIdx}`);
|
||||
finalParams.push(memo);
|
||||
pIdx++;
|
||||
}
|
||||
setClausesFinal.push(`updated_date = NOW()`);
|
||||
setClausesFinal.push(`updated_by = $${pIdx}`);
|
||||
finalParams.push(userId);
|
||||
pIdx++;
|
||||
|
||||
finalParams.push(id);
|
||||
finalParams.push(companyCode);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE shipment_plan
|
||||
SET ${setClausesFinal.join(", ")}
|
||||
WHERE id = $${pIdx} AND company_code = $${pIdx + 1}
|
||||
RETURNING *`,
|
||||
finalParams
|
||||
);
|
||||
|
||||
logger.info("출하계획 수정", { companyCode, planId: id, userId });
|
||||
|
||||
return res.json({ success: true, data: result.rows[0] });
|
||||
} catch (error: any) {
|
||||
logger.error("출하계획 수정 실패", { error: error.message });
|
||||
return res.status(500).json({ success: false, message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 품목별 집계 + 기존 출하계획 조회 ───
|
||||
|
||||
export async function getAggregate(req: AuthenticatedRequest, res: Response) {
|
||||
@@ -333,8 +545,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
const savedPlans = [];
|
||||
|
||||
for (const plan of plans) {
|
||||
const { sourceId, planQty } = plan;
|
||||
const { sourceId, planQty, planDate } = plan;
|
||||
if (!sourceId || !planQty || planQty <= 0) continue;
|
||||
const planDateValue = planDate || null;
|
||||
|
||||
if (detectedSource === "detail") {
|
||||
// 디테일 소스: detail_id로 저장
|
||||
@@ -368,9 +581,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO shipment_plan
|
||||
(company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_DATE, 'READY', $5)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6)
|
||||
RETURNING *`,
|
||||
[companyCode, sourceId, detail.master_id, planQty, userId]
|
||||
[companyCode, sourceId, detail.master_id, planQty, planDateValue, userId]
|
||||
);
|
||||
savedPlans.push(insertRes.rows[0]);
|
||||
|
||||
@@ -410,9 +623,9 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO shipment_plan
|
||||
(company_code, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||
VALUES ($1, $2, $3, CURRENT_DATE, 'READY', $4)
|
||||
VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5)
|
||||
RETURNING *`,
|
||||
[companyCode, masterId, planQty, userId]
|
||||
[companyCode, masterId, planQty, planDateValue, userId]
|
||||
);
|
||||
savedPlans.push(insertRes.rows[0]);
|
||||
|
||||
|
||||
650
backend-node/src/controllers/workInstructionController.ts
Normal file
650
backend-node/src/controllers/workInstructionController.ts
Normal file
@@ -0,0 +1,650 @@
|
||||
/**
|
||||
* 작업지시 컨트롤러 (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";
|
||||
|
||||
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
||||
export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
|
||||
|
||||
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++;
|
||||
}
|
||||
if (keyword) {
|
||||
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
|
||||
params.push(`%${keyword}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
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,
|
||||
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,
|
||||
COALESCE(itm.item_name, '') AS item_name,
|
||||
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) 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_no = wi.work_instruction_no AND d.company_code = wi.company_code
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT item_name, size FROM item_info
|
||||
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
||||
) itm ON true
|
||||
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, d.created_date ASC
|
||||
`;
|
||||
|
||||
const pool = getPool();
|
||||
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 {
|
||||
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 } = req.body;
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
|
||||
}
|
||||
|
||||
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, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`,
|
||||
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode]
|
||||
);
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
|
||||
} 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,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`,
|
||||
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId]
|
||||
);
|
||||
wiId = insertRes.rows[0].id;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
await client.query(
|
||||
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
|
||||
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
|
||||
for (const row of wiNos.rows) {
|
||||
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]);
|
||||
}
|
||||
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);
|
||||
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 FROM production_plan_mng p 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 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]
|
||||
);
|
||||
|
||||
// 커스텀 작업기준이 있는지 확인
|
||||
const customCheck = await pool.query(
|
||||
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
|
||||
|
||||
const processes = [];
|
||||
for (const proc of processesResult.rows) {
|
||||
let workItems;
|
||||
|
||||
if (hasCustom) {
|
||||
// 커스텀 버전에서 조회
|
||||
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
|
||||
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
|
||||
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
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
|
||||
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");
|
||||
|
||||
// 기존 커스텀 데이터 삭제
|
||||
const existingItems = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[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`,
|
||||
[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 (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
||||
VALUES ($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 (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, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
[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, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
// 해당 공정의 기존 커스텀 데이터 삭제
|
||||
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`,
|
||||
[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`,
|
||||
[wiNo, routingDetailId, companyCode]
|
||||
);
|
||||
|
||||
// 새 데이터 삽입
|
||||
for (const wi of workItems) {
|
||||
const wiResult = await client.query(
|
||||
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
||||
VALUES ($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 (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, writer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
|
||||
[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, userId]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("작업지시 공정작업기준 저장 실패", { error: error.message });
|
||||
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 { wiNo } = req.params;
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const items = await client.query(
|
||||
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
|
||||
[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`,
|
||||
[wiNo, companyCode]
|
||||
);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user