Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node

This commit is contained in:
wace
2026-03-25 10:08:37 +09:00
188 changed files with 44078 additions and 3710 deletions
+26 -2
View File
@@ -144,6 +144,14 @@ import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
import designRoutes from "./routes/designRoutes"; // 설계 모듈 (DR/ECR/프로젝트/ECN)
import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현황
import receivingRoutes from "./routes/receivingRoutes"; // 입고관리
import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@@ -316,6 +324,8 @@ app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리
app.use("/api/production", productionRoutes); // 생산계획 관리
app.use("/api/material-status", materialStatusRoutes); // 자재현황
app.use("/api/process-info", processInfoRoutes); // 공정정보관리
app.use("/api/roles", roleRoutes); // 권한 그룹 관리
app.use("/api/departments", departmentRoutes); // 부서 관리
app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리
@@ -337,6 +347,12 @@ app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작
app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력
app.use("/api/mold", moldRoutes); // 금형 관리
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재고/구매/품질/설비/금형)
app.use("/api/design", designRoutes); // 설계 모듈
app.use("/api/receiving", receivingRoutes); // 입고관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
@@ -362,12 +378,20 @@ app.use(errorHandler);
const PORT = config.port;
const HOST = config.host;
app.listen(PORT, HOST, async () => {
const server = app.listen(PORT, HOST, async () => {
logger.info(`🚀 Server is running on ${HOST}:${PORT}`);
logger.info(`📊 Environment: ${config.nodeEnv}`);
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
// 비동기 초기화 작업 (에러가 발생해도 서버는 유지)
initializeServices().catch(err => {
logger.error('❌ 서비스 초기화 중 치명적 에러 발생:', err);
});
});
// 서비스 초기화 함수 분리
async function initializeServices() {
// 데이터베이스 마이그레이션 실행
try {
const {
@@ -435,6 +459,6 @@ app.listen(PORT, HOST, async () => {
} catch (error) {
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
}
});
}
export default app;
@@ -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 : "알 수 없는 오류",
});
}
}
}
@@ -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 });
}
}
@@ -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 });
}
}
@@ -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) {
@@ -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 });
}
}
@@ -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,
});
}
}
@@ -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]);
@@ -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 });
}
}
@@ -0,0 +1,23 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getProductionReportData,
getInventoryReportData,
getPurchaseReportData,
getQualityReportData,
getEquipmentReportData,
getMoldReportData,
} from "../controllers/analyticsReportController";
const router = Router();
router.use(authenticateToken);
router.get("/production/data", getProductionReportData);
router.get("/inventory/data", getInventoryReportData);
router.get("/purchase/data", getPurchaseReportData);
router.get("/quality/data", getQualityReportData);
router.get("/equipment/data", getEquipmentReportData);
router.get("/mold/data", getMoldReportData);
export default router;
@@ -7,6 +7,19 @@ import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
/**
* GET /api/batch-management/stats
* 배치 대시보드 통계 (전체/활성 배치 수, 오늘·어제 실행/실패 수)
* 반드시 /batch-configs 보다 위에 등록 (/:id로 잡히지 않도록)
*/
router.get("/stats", authenticateToken, BatchManagementController.getBatchStats);
/**
* GET /api/batch-management/node-flows
* 배치 설정에서 노드 플로우 선택용 목록 조회
*/
router.get("/node-flows", authenticateToken, BatchManagementController.getNodeFlows);
/**
* GET /api/batch-management/connections
* 사용 가능한 커넥션 목록 조회
@@ -55,6 +68,18 @@ router.get("/batch-configs", authenticateToken, BatchManagementController.getBat
*/
router.get("/batch-configs/:id", authenticateToken, BatchManagementController.getBatchConfigById);
/**
* GET /api/batch-management/batch-configs/:id/sparkline
* 해당 배치 최근 24시간 1시간 단위 실행 집계
*/
router.get("/batch-configs/:id/sparkline", authenticateToken, BatchManagementController.getBatchSparkline);
/**
* GET /api/batch-management/batch-configs/:id/recent-logs
* 해당 배치 최근 실행 로그 (최대 20건)
*/
router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementController.getBatchRecentLogs);
/**
* PUT /api/batch-management/batch-configs/:id
* 배치 설정 업데이트
+139 -38
View File
@@ -4,6 +4,7 @@ import { masterDetailExcelService } from "../services/masterDetailExcelService";
import { multiTableExcelService, TableChainConfig } from "../services/multiTableExcelService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { auditLogService } from "../services/auditLogService";
import { TableManagementService } from "../services/tableManagementService";
import { formatPgError } from "../utils/pgErrorUtil";
@@ -35,7 +36,7 @@ router.get(
console.log(`🔍 마스터-디테일 관계 조회: screenId=${screenId}`);
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
parseInt(screenId),
);
if (!relation) {
@@ -64,7 +65,7 @@ router.get(
error: error.message,
});
}
}
},
);
/**
@@ -90,7 +91,7 @@ router.post(
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
parseInt(screenId),
);
if (!relation) {
@@ -104,7 +105,7 @@ router.post(
const data = await masterDetailExcelService.getJoinedData(
relation,
companyCode,
filters
filters,
);
console.log(`✅ 마스터-디테일 데이터 조회 완료: ${data.data.length}`);
@@ -121,7 +122,7 @@ router.post(
error: error.message,
});
}
}
},
);
/**
@@ -144,11 +145,13 @@ router.post(
});
}
console.log(`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`);
console.log(
`📤 마스터-디테일 엑셀 업로드: screenId=${screenId}, rows=${data.length}`,
);
// 1. 마스터-디테일 관계 조회
const relation = await masterDetailExcelService.getMasterDetailRelation(
parseInt(screenId)
parseInt(screenId),
);
if (!relation) {
@@ -163,7 +166,7 @@ router.post(
relation,
data,
companyCode,
userId
userId,
);
console.log(`✅ 마스터-디테일 업로드 완료:`, {
@@ -194,7 +197,7 @@ router.post(
error: error.message,
});
}
}
},
);
/**
@@ -202,7 +205,7 @@ router.post(
* - 마스터 정보는 UI에서 선택
* - 디테일 정보만 엑셀에서 업로드
* - 채번 규칙을 통해 마스터 키 자동 생성
*
*
* POST /api/data/master-detail/upload-simple
*/
router.post(
@@ -210,7 +213,14 @@ router.post(
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { screenId, detailData, masterFieldValues, numberingRuleId, afterUploadFlowId, afterUploadFlows } = req.body;
const {
screenId,
detailData,
masterFieldValues,
numberingRuleId,
afterUploadFlowId,
afterUploadFlows,
} = req.body;
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
@@ -221,10 +231,17 @@ router.post(
});
}
console.log(`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`);
console.log(
`📤 마스터-디테일 간단 모드 업로드: screenId=${screenId}, rows=${detailData.length}`,
);
console.log(` 마스터 필드 값:`, masterFieldValues);
console.log(` 채번 규칙 ID:`, numberingRuleId);
console.log(` 업로드 후 제어:`, afterUploadFlows?.length > 0 ? `${afterUploadFlows.length}` : afterUploadFlowId || "없음");
console.log(
` 업로드 후 제어:`,
afterUploadFlows?.length > 0
? `${afterUploadFlows.length}`
: afterUploadFlowId || "없음",
);
// 업로드 실행
const result = await masterDetailExcelService.uploadSimple(
@@ -235,7 +252,7 @@ router.post(
companyCode,
userId,
afterUploadFlowId, // 업로드 후 제어 실행 (단일, 하위 호환성)
afterUploadFlows // 업로드 후 제어 실행 (다중)
afterUploadFlows, // 업로드 후 제어 실행 (다중)
);
console.log(`✅ 마스터-디테일 간단 모드 업로드 완료:`, {
@@ -260,7 +277,7 @@ router.post(
error: error.message,
});
}
}
},
);
// ================================
@@ -504,7 +521,7 @@ router.get(
parsedDataFilter,
enableEntityJoinFlag,
parsedDisplayColumns, // 🆕 표시 컬럼 전달
parsedDeduplication // 🆕 중복 제거 설정 전달
parsedDeduplication, // 🆕 중복 제거 설정 전달
);
if (!result.success) {
@@ -512,7 +529,7 @@ router.get(
}
console.log(
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`
`✅ 조인 데이터 조회 성공: ${result.data?.length || 0}개 항목`,
);
return res.json({
@@ -527,7 +544,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -616,7 +633,7 @@ router.get(
}
console.log(
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`
`✅ 데이터 조회 성공: ${tableName}, ${result.data?.length || 0}개 항목`,
);
// 페이징 정보 포함하여 반환
@@ -642,7 +659,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -684,7 +701,7 @@ router.get(
}
console.log(
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`
`✅ 컬럼 정보 조회 성공: ${tableName}, ${result.data?.length || 0}개 컬럼`,
);
return res.json(result);
@@ -696,7 +713,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -748,7 +765,8 @@ router.get(
}
// 🆕 primaryKeyColumn 파싱
const primaryKeyColumnStr = typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
const primaryKeyColumnStr =
typeof primaryKeyColumn === "string" ? primaryKeyColumn : undefined;
console.log(`🔍 레코드 상세 조회: ${tableName}/${id}`, {
enableEntityJoin: enableEntityJoinFlag,
@@ -762,7 +780,7 @@ router.get(
id,
enableEntityJoinFlag,
groupByColumnsArray,
primaryKeyColumnStr // 🆕 Primary Key 컬럼명 전달
primaryKeyColumnStr, // 🆕 Primary Key 컬럼명 전달
);
if (!result.success) {
@@ -790,7 +808,7 @@ router.get(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -844,7 +862,7 @@ router.post(
records,
req.user?.companyCode,
req.user?.userId,
deleteOrphans
deleteOrphans,
);
if (!result.success) {
@@ -856,7 +874,9 @@ router.post(
const deleted = result.data?.deleted || 0;
console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, {
inserted, updated, deleted,
inserted,
updated,
deleted,
});
const parts: string[] = [];
@@ -869,7 +889,10 @@ router.post(
companyCode: req.user?.companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: inserted > 0 && updated === 0 && deleted === 0 ? "BATCH_CREATE" : "UPDATE",
action:
inserted > 0 && updated === 0 && deleted === 0
? "BATCH_CREATE"
: "UPDATE",
resourceType: "DATA",
tableName,
summary: `${tableName} 테이블 배치 처리: ${parts.join(", ")}`,
@@ -895,7 +918,81 @@ router.post(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
* 설비 ID 일괄 검증 API
* POST /api/data/equipment_mng/validate
*
* 요청: { "equipmentIds": ["EQ_001", "EQ_002", "EQ_003"] }
* 응답: { "success": true, "data": [{ "equipment_id": "EQ_001", "equipment_name": "프레스 1호기" }, ...] }
*
* - 존재하는 설비만 반환 (존재하지 않는 ID는 응답에서 제외)
* - 멀티테넌시: company_code 필터링 적용
*/
router.post(
"/equipment_mng/validate",
authenticateToken,
async (req: AuthenticatedRequest, res) => {
try {
const { equipmentIds } = req.body;
if (!Array.isArray(equipmentIds) || equipmentIds.length === 0) {
return res.status(400).json({
success: false,
message: "equipmentIds 배열이 필요합니다.",
});
}
// 최대 500개 제한
if (equipmentIds.length > 500) {
return res.status(400).json({
success: false,
message: "한 번에 최대 500개까지 검증할 수 있습니다.",
});
}
const companyCode = req.user?.companyCode;
const pool = getPool();
// Parameterized query로 SQL Injection 방지
const placeholders = equipmentIds.map((_, i) => `$${i + 1}`).join(", ");
const params: any[] = [...equipmentIds];
let whereClause = `WHERE equipment_id IN (${placeholders})`;
// 멀티테넌시 필터링 (company_code가 '*'이 아닌 경우)
if (companyCode && companyCode !== "*") {
params.push(companyCode);
whereClause += ` AND company_code = $${params.length}`;
}
const query = `
SELECT equipment_id, equipment_name
FROM equipment_mng
${whereClause}
`;
const result = await pool.query(query, params);
console.log(
`✅ 설비 일괄 검증: ${result.rowCount}/${equipmentIds.length}개 확인`,
);
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
console.error("설비 일괄 검증 오류:", error);
return res.status(500).json({
success: false,
message: "설비 검증 중 오류가 발생했습니다.",
error: error.message,
});
}
},
);
/**
@@ -935,7 +1032,7 @@ router.post(
// 테이블에 company_code 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyCode = await dataService.checkColumnExists(
tableName,
"company_code"
"company_code",
);
if (hasCompanyCode && req.user?.companyCode) {
enrichedData.company_code = req.user.companyCode;
@@ -945,7 +1042,7 @@ router.post(
// 테이블에 company_name 컬럼이 있는지 확인하고 자동으로 추가
const hasCompanyName = await dataService.checkColumnExists(
tableName,
"company_name"
"company_name",
);
if (hasCompanyName && req.user?.companyName) {
enrichedData.company_name = req.user.companyName;
@@ -1001,7 +1098,7 @@ router.post(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -1086,7 +1183,7 @@ router.put(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
/**
@@ -1156,7 +1253,7 @@ router.post(
error: error.message,
});
}
}
},
);
/**
@@ -1179,12 +1276,16 @@ router.post(
});
}
console.log(`🗑️ 그룹 삭제:`, { tableName, filterConditions, userCompany });
console.log(`🗑️ 그룹 삭제:`, {
tableName,
filterConditions,
userCompany,
});
const result = await dataService.deleteGroupRecords(
tableName,
filterConditions,
userCompany // 회사 코드 전달
userCompany, // 회사 코드 전달
);
if (!result.success) {
@@ -1201,7 +1302,7 @@ router.post(
error: error.message,
});
}
}
},
);
router.delete(
@@ -1264,7 +1365,7 @@ router.delete(
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
},
);
export default router;
+56 -3
View File
@@ -13,7 +13,54 @@ import { auditLogService, getClientIp } from "../../services/auditLogService";
const router = Router();
/**
* 플로우 목록 조회
* flow_data에서 요약 정보 추출
*/
function extractFlowSummary(flowData: any) {
try {
const parsed = typeof flowData === "string" ? JSON.parse(flowData) : flowData;
const nodes = parsed?.nodes || [];
const edges = parsed?.edges || [];
const nodeTypes: Record<string, number> = {};
nodes.forEach((n: any) => {
const t = n.type || "unknown";
nodeTypes[t] = (nodeTypes[t] || 0) + 1;
});
// 미니 토폴로지용 간소화된 좌표 (0~1 정규화)
let topology = null;
if (nodes.length > 0) {
const xs = nodes.map((n: any) => n.position?.x || 0);
const ys = nodes.map((n: any) => n.position?.y || 0);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minY = Math.min(...ys), maxY = Math.max(...ys);
const rangeX = maxX - minX || 1;
const rangeY = maxY - minY || 1;
topology = {
nodes: nodes.map((n: any) => ({
id: n.id,
type: n.type,
x: (((n.position?.x || 0) - minX) / rangeX),
y: (((n.position?.y || 0) - minY) / rangeY),
})),
edges: edges.map((e: any) => [e.source, e.target]),
};
}
return {
nodeCount: nodes.length,
edgeCount: edges.length,
nodeTypes,
topology,
};
} catch {
return { nodeCount: 0, edgeCount: 0, nodeTypes: {}, topology: null };
}
}
/**
* 플로우 목록 조회 (summary 포함)
*/
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
try {
@@ -24,6 +71,7 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
flow_id as "flowId",
flow_name as "flowName",
flow_description as "flowDescription",
flow_data as "flowData",
company_code as "companyCode",
created_at as "createdAt",
updated_at as "updatedAt"
@@ -32,7 +80,6 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const params: any[] = [];
// 슈퍼 관리자가 아니면 회사별 필터링
if (userCompanyCode && userCompanyCode !== "*") {
sqlQuery += ` WHERE company_code = $1`;
params.push(userCompanyCode);
@@ -42,9 +89,15 @@ router.get("/", async (req: AuthenticatedRequest, res: Response) => {
const flows = await query(sqlQuery, params);
const flowsWithSummary = flows.map((flow: any) => {
const summary = extractFlowSummary(flow.flowData);
const { flowData, ...rest } = flow;
return { ...rest, summary };
});
return res.json({
success: true,
data: flows,
data: flowsWithSummary,
});
} catch (error) {
logger.error("플로우 목록 조회 실패:", error);
+67
View File
@@ -0,0 +1,67 @@
import express from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import {
getDesignRequestList, getDesignRequestDetail, createDesignRequest, updateDesignRequest, deleteDesignRequest, addRequestHistory,
getProjectList, getProjectDetail, createProject, updateProject, deleteProject,
getTasksByProject, createTask, updateTask, deleteTask,
getWorkLogsByTask, createWorkLog, deleteWorkLog,
createSubItem, updateSubItem, deleteSubItem,
createIssue, updateIssue,
getEcnList, createEcn, updateEcn, deleteEcn,
getMyWork,
createPurchaseReq, createCoopReq, addCoopResponse,
} from "../controllers/designController";
const router = express.Router();
router.use(authenticateToken);
// 설계의뢰/설변요청 (DR/ECR)
router.get("/requests", getDesignRequestList);
router.get("/requests/:id", getDesignRequestDetail);
router.post("/requests", createDesignRequest);
router.put("/requests/:id", updateDesignRequest);
router.delete("/requests/:id", deleteDesignRequest);
router.post("/requests/:id/history", addRequestHistory);
// 설계 프로젝트
router.get("/projects", getProjectList);
router.get("/projects/:id", getProjectDetail);
router.post("/projects", createProject);
router.put("/projects/:id", updateProject);
router.delete("/projects/:id", deleteProject);
// 프로젝트 태스크
router.get("/projects/:projectId/tasks", getTasksByProject);
router.post("/projects/:projectId/tasks", createTask);
router.put("/tasks/:taskId", updateTask);
router.delete("/tasks/:taskId", deleteTask);
// 작업일지
router.get("/tasks/:taskId/work-logs", getWorkLogsByTask);
router.post("/tasks/:taskId/work-logs", createWorkLog);
router.delete("/work-logs/:workLogId", deleteWorkLog);
// 태스크 하위항목
router.post("/tasks/:taskId/sub-items", createSubItem);
router.put("/sub-items/:subItemId", updateSubItem);
router.delete("/sub-items/:subItemId", deleteSubItem);
// 태스크 이슈
router.post("/tasks/:taskId/issues", createIssue);
router.put("/issues/:issueId", updateIssue);
// ECN (설변통보)
router.get("/ecn", getEcnList);
router.post("/ecn", createEcn);
router.put("/ecn/:id", updateEcn);
router.delete("/ecn/:id", deleteEcn);
// 나의 업무
router.get("/my-work", getMyWork);
// 구매요청 / 협업요청
router.post("/work-logs/:workLogId/purchase-reqs", createPurchaseReq);
router.post("/work-logs/:workLogId/coop-reqs", createCoopReq);
router.post("/coop-reqs/:coopReqId/responses", addCoopResponse);
export default router;
@@ -0,0 +1,22 @@
/**
* 자재현황 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as materialStatusController from "../controllers/materialStatusController";
const router = Router();
router.use(authenticateToken);
// 생산계획(작업지시) 목록 조회
router.get("/work-orders", materialStatusController.getWorkOrders);
// 자재소요 + 재고 현황 조회 (POST: planIds 배열 전달)
router.post("/materials", materialStatusController.getMaterialStatus);
// 창고 목록 조회
router.get("/warehouses", materialStatusController.getWarehouses);
export default router;
@@ -0,0 +1,45 @@
/**
* 공정정보관리 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/processInfoController";
const router = Router();
router.use(authenticateToken);
// 공정 마스터 CRUD
router.get("/processes", ctrl.getProcessList);
router.post("/processes", ctrl.createProcess);
router.put("/processes/:id", ctrl.updateProcess);
router.post("/processes/delete", ctrl.deleteProcesses);
// 공정별 설비 관리
router.get("/processes/:processCode/equipments", ctrl.getProcessEquipments);
router.post("/process-equipments", ctrl.addProcessEquipment);
router.delete("/process-equipments/:id", ctrl.removeProcessEquipment);
// 설비 목록 (드롭다운용)
router.get("/equipments", ctrl.getEquipmentList);
// 품목 목록 (라우팅 등록된 품목만)
router.get("/items", ctrl.getItemsForRouting);
// 전체 품목 검색 (등록 모달용)
router.get("/items/search-all", ctrl.searchAllItems);
// 라우팅 버전
router.get("/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.post("/routing-versions", ctrl.createRoutingVersion);
router.delete("/routing-versions/:id", ctrl.deleteRoutingVersion);
// 라우팅 상세
router.get("/routing-details/:versionId", ctrl.getRoutingDetails);
router.put("/routing-details/:versionId", ctrl.saveRoutingDetails);
// BOM 구성 자재 조회
router.get("/bom-materials/:itemCode", ctrl.getBomMaterials);
export default router;
@@ -16,6 +16,9 @@ router.get("/order-summary", productionController.getOrderSummary);
// 안전재고 부족분 조회
router.get("/stock-shortage", productionController.getStockShortage);
// 생산계획 목록 조회
router.get("/plans", productionController.getPlans);
// 생산계획 CRUD
router.get("/plan/:id", productionController.getPlanById);
router.put("/plan/:id", productionController.updatePlan);
@@ -0,0 +1,40 @@
/**
* 입고관리 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as receivingController from "../controllers/receivingController";
const router = Router();
router.use(authenticateToken);
// 입고 목록 조회
router.get("/list", receivingController.getList);
// 입고번호 자동생성
router.get("/generate-number", receivingController.generateNumber);
// 창고 목록 조회
router.get("/warehouses", receivingController.getWarehouses);
// 소스 데이터: 발주 (구매입고)
router.get("/source/purchase-orders", receivingController.getPurchaseOrders);
// 소스 데이터: 출하 (반품입고)
router.get("/source/shipments", receivingController.getShipments);
// 소스 데이터: 품목 (기타입고)
router.get("/source/items", receivingController.getItems);
// 입고 등록
router.post("/", receivingController.create);
// 입고 수정
router.put("/:id", receivingController.update);
// 입고 삭제
router.delete("/:id", receivingController.deleteReceiving);
export default router;
@@ -0,0 +1,12 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { getSalesReportData } from "../controllers/salesReportController";
const router = Router();
router.use(authenticateToken);
// 영업 리포트 원본 데이터 조회
router.get("/data", getSalesReportData);
export default router;
@@ -0,0 +1,21 @@
/**
* 출하지시 라우트
*/
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as shippingOrderController from "../controllers/shippingOrderController";
const router = Router();
router.use(authenticateToken);
router.get("/list", shippingOrderController.getList);
router.get("/preview-no", shippingOrderController.previewNextNo);
router.post("/save", shippingOrderController.save);
router.post("/delete", shippingOrderController.remove);
// 모달 왼쪽 패널 데이터 소스
router.get("/source/shipment-plan", shippingOrderController.getShipmentPlanSource);
router.get("/source/sales-order", shippingOrderController.getSalesOrderSource);
router.get("/source/item", shippingOrderController.getItemSource);
export default router;
@@ -10,10 +10,16 @@ const router = Router();
router.use(authenticateToken);
// 출하계획 목록 조회 (관리 화면용)
router.get("/list", shippingPlanController.getList);
// 품목별 집계 + 기존 출하계획 조회
router.get("/aggregate", shippingPlanController.getAggregate);
// 출하계획 일괄 저장
router.post("/batch", shippingPlanController.batchSave);
// 출하계획 단건 수정
router.put("/:id", shippingPlanController.updatePlan);
export default router;
@@ -0,0 +1,26 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import * as ctrl from "../controllers/workInstructionController";
const router = Router();
router.use(authenticateToken);
router.get("/list", ctrl.getList);
router.get("/preview-no", ctrl.previewNextNo);
router.post("/save", ctrl.save);
router.post("/delete", ctrl.remove);
router.get("/source/item", ctrl.getItemSource);
router.get("/source/sales-order", ctrl.getSalesOrderSource);
router.get("/source/production-plan", ctrl.getProductionPlanSource);
router.get("/equipment", ctrl.getEquipmentList);
router.get("/employees", ctrl.getEmployeeList);
// 라우팅 & 공정작업기준
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.put("/:wiNo/routing", ctrl.updateRouting);
router.get("/:wiNo/work-standard", ctrl.getWorkStandard);
router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard);
router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard);
router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
export default router;
@@ -122,20 +122,22 @@ export class BatchSchedulerService {
}
/**
* 배치 설정 실행
* 배치 설정 실행 - execution_type에 따라 매핑 또는 노드 플로우 실행
*/
static async executeBatchConfig(config: any) {
const startTime = new Date();
let executionLog: any = null;
try {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`);
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
// 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음)
if (!config.execution_type || config.execution_type === "mapping") {
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
}
}
}
@@ -165,12 +167,17 @@ export class BatchSchedulerService {
executionLog = executionLogResponse.data;
// 실제 배치 실행 로직 (수동 실행과 동일한 로직 사용)
const result = await this.executeBatchMappings(config);
let result: { totalRecords: number; successRecords: number; failedRecords: number };
if (config.execution_type === "node_flow") {
result = await this.executeNodeFlow(config);
} else {
result = await this.executeBatchMappings(config);
}
// 실행 로그 업데이트 (성공)
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "SUCCESS",
execution_status: result.failedRecords > 0 ? "PARTIAL" : "SUCCESS",
end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(),
total_records: result.totalRecords,
@@ -182,12 +189,10 @@ export class BatchSchedulerService {
`배치 실행 완료: ${config.batch_name} (처리된 레코드: ${result.totalRecords})`
);
// 성공 결과 반환
return result;
} catch (error) {
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
// 실행 로그 업데이트 (실패)
if (executionLog) {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED",
@@ -198,7 +203,6 @@ export class BatchSchedulerService {
});
}
// 실패 결과 반환
return {
totalRecords: 0,
successRecords: 0,
@@ -207,6 +211,43 @@ export class BatchSchedulerService {
}
}
/**
* 노드 플로우 실행 - NodeFlowExecutionService에 위임
*/
private static async executeNodeFlow(config: any) {
if (!config.node_flow_id) {
throw new Error("노드 플로우 ID가 설정되지 않았습니다.");
}
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const contextData: Record<string, any> = {
companyCode: config.company_code,
batchConfigId: config.id,
batchName: config.batch_name,
executionSource: "batch_scheduler",
...(config.node_flow_context || {}),
};
logger.info(
`노드 플로우 실행: flowId=${config.node_flow_id}, batch=${config.batch_name}`
);
const flowResult = await NodeFlowExecutionService.executeFlow(
config.node_flow_id,
contextData
);
// 노드 플로우 실행 결과를 배치 로그 형식으로 변환
return {
totalRecords: flowResult.summary.total,
successRecords: flowResult.summary.success,
failedRecords: flowResult.summary.failed,
};
}
/**
* 배치 매핑 실행 (수동 실행과 동일한 로직)
*/
+26 -7
View File
@@ -72,9 +72,12 @@ export class BatchService {
const total = parseInt(countResult[0].count);
const totalPages = Math.ceil(total / limit);
// 목록 조회
// 목록 조회 (최근 실행 정보 포함)
const configs = await query<any>(
`SELECT bc.*
`SELECT bc.*,
(SELECT bel.execution_status FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_status,
(SELECT bel.start_time FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_executed_at,
(SELECT bel.total_records FROM batch_execution_logs bel WHERE bel.batch_config_id = bc.id ORDER BY bel.start_time DESC LIMIT 1) as last_total_records
FROM batch_configs bc
${whereClause}
ORDER BY bc.created_date DESC
@@ -82,9 +85,6 @@ export class BatchService {
[...values, limit, offset]
);
// 매핑 정보 조회 (N+1 문제 해결을 위해 별도 쿼리 대신 여기서는 생략하고 상세 조회에서 처리)
// 하지만 목록에서도 간단한 정보는 필요할 수 있음
return {
success: true,
data: configs as BatchConfig[],
@@ -176,8 +176,8 @@ export class BatchService {
// 배치 설정 생성
const batchConfigResult = await client.query(
`INSERT INTO batch_configs
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW())
(batch_name, description, cron_schedule, is_active, company_code, save_mode, conflict_key, auth_service_name, data_array_path, execution_type, node_flow_id, node_flow_context, created_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
RETURNING *`,
[
data.batchName,
@@ -189,6 +189,9 @@ export class BatchService {
data.conflictKey || null,
data.authServiceName || null,
data.dataArrayPath || null,
data.executionType || "mapping",
data.nodeFlowId || null,
data.nodeFlowContext ? JSON.stringify(data.nodeFlowContext) : null,
userId,
]
);
@@ -332,6 +335,22 @@ export class BatchService {
updateFields.push(`data_array_path = $${paramIndex++}`);
updateValues.push(data.dataArrayPath || null);
}
if (data.executionType !== undefined) {
updateFields.push(`execution_type = $${paramIndex++}`);
updateValues.push(data.executionType);
}
if (data.nodeFlowId !== undefined) {
updateFields.push(`node_flow_id = $${paramIndex++}`);
updateValues.push(data.nodeFlowId || null);
}
if (data.nodeFlowContext !== undefined) {
updateFields.push(`node_flow_context = $${paramIndex++}`);
updateValues.push(
data.nodeFlowContext
? JSON.stringify(data.nodeFlowContext)
: null
);
}
// 배치 설정 업데이트
const batchConfigResult = await client.query(
@@ -952,13 +952,20 @@ export class NodeFlowExecutionService {
}
const schemaPrefix = schema ? `${schema}.` : "";
// WHERE 조건에서 field 값 조회를 위해 컨텍스트 데이터 전달
// sourceData(저장된 폼 데이터) + buttonContext(인증 정보) 병합
const contextForWhere = {
...(context.buttonContext || {}),
...(context.sourceData?.[0] || {}),
};
const whereResult = whereConditions
? this.buildWhereClause(whereConditions)
? this.buildWhereClause(whereConditions, contextForWhere)
: { clause: "", values: [] };
const sql = `SELECT * FROM ${schemaPrefix}${tableName} ${whereResult.clause}`;
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`);
logger.info(`📊 테이블 전체 데이터 조회 SQL: ${sql}`, { values: whereResult.values });
const result = await query(sql, whereResult.values);
@@ -35,6 +35,33 @@ export async function getOrderSummary(
const whereClause = conditions.join(" AND ");
// item_info에 lead_time 컬럼이 존재하는지 확인
const leadTimeColCheck = await pool.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = leadTimeColCheck.rows[0]?.has_lead_time === true;
const itemLeadTimeCte = hasLeadTime
? `item_lead_time AS (
SELECT
item_number,
id AS item_id,
COALESCE(lead_time, 0) AS lead_time
FROM item_info
WHERE company_code = $1
),`
: `item_lead_time AS (
SELECT
item_number,
id AS item_id,
0 AS lead_time
FROM item_info
WHERE company_code = $1
),`;
const query = `
WITH order_summary AS (
SELECT
@@ -49,6 +76,7 @@ export async function getOrderSummary(
WHERE ${whereClause}
GROUP BY so.part_code, so.part_name
),
${itemLeadTimeCte}
stock_info AS (
SELECT
item_code,
@@ -85,10 +113,12 @@ export async function getOrderSummary(
os.total_balance_qty + COALESCE(si.safety_stock, 0) - COALESCE(si.current_stock, 0)
- COALESCE(pi.existing_plan_qty, 0) - COALESCE(pi.in_progress_qty, 0),
0
) AS required_plan_qty
) AS required_plan_qty,
COALESCE(ilt.lead_time, 0) AS lead_time
FROM order_summary os
LEFT JOIN stock_info si ON os.item_code = si.item_code
LEFT JOIN plan_info pi ON os.item_code = pi.item_code
LEFT JOIN item_lead_time ilt ON (os.item_code = ilt.item_number OR os.item_code = ilt.item_id)
${options?.excludePlanned ? "WHERE COALESCE(pi.existing_plan_qty, 0) = 0" : ""}
ORDER BY os.item_code;
`;
@@ -155,6 +185,80 @@ export async function getStockShortage(companyCode: string) {
return result.rows;
}
// ─── 생산계획 목록 조회 ───
export async function getPlans(
companyCode: string,
options?: {
productType?: string;
status?: string;
startDate?: string;
endDate?: string;
itemCode?: string;
}
) {
const pool = getPool();
const conditions: string[] = ["p.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (companyCode !== "*") {
// 일반 회사: 자사 데이터만
} else {
// 최고관리자: 전체 데이터 (company_code 조건 제거)
conditions.length = 0;
}
if (options?.productType) {
conditions.push(`COALESCE(p.product_type, '완제품') = $${paramIdx}`);
params.push(options.productType);
paramIdx++;
}
if (options?.status && options.status !== "all") {
conditions.push(`p.status = $${paramIdx}`);
params.push(options.status);
paramIdx++;
}
if (options?.startDate) {
conditions.push(`p.end_date >= $${paramIdx}::date`);
params.push(options.startDate);
paramIdx++;
}
if (options?.endDate) {
conditions.push(`p.start_date <= $${paramIdx}::date`);
params.push(options.endDate);
paramIdx++;
}
if (options?.itemCode) {
conditions.push(`(p.item_code ILIKE $${paramIdx} OR p.item_name ILIKE $${paramIdx})`);
params.push(`%${options.itemCode}%`);
paramIdx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id, p.company_code, p.plan_no, p.plan_date,
p.item_code, p.item_name, p.product_type,
p.plan_qty, p.completed_qty, p.progress_rate,
p.start_date, p.end_date, p.due_date,
p.equipment_id, p.equipment_code, p.equipment_name,
p.status, p.priority, p.work_shift,
p.work_order_no, p.manager_name,
p.order_no, p.parent_plan_id, p.remarks,
p.hourly_capacity, p.daily_capacity, p.lead_time,
p.created_date, p.updated_date
FROM production_plan_mng p
${whereClause}
ORDER BY p.start_date ASC, p.item_code ASC
`;
const result = await pool.query(query, params);
logger.info("생산계획 목록 조회", { companyCode, count: result.rowCount });
return result.rows;
}
// ─── 생산계획 CRUD ───
export async function getPlanById(companyCode: string, planId: number) {
@@ -293,23 +397,47 @@ export async function previewSchedule(
}
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty;
// recalculate_unstarted가 true이면 기존 planned 삭제 후 재생성이므로,
// 프론트에서 이미 차감된 기존 계획 수량을 다시 더해줘야 정확한 필요 수량이 됨
if (options.recalculate_unstarted) {
const deletedQtyForItem = deletedSchedules
.filter((d: any) => d.item_code === item.item_code)
.reduce((sum: number, d: any) => sum + (parseFloat(d.plan_qty) || 0), 0);
requiredQty += deletedQtyForItem;
}
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 해당 품목의 수주 건수 확인
@@ -326,10 +454,11 @@ export async function previewSchedule(
required_qty: requiredQty,
daily_capacity: dailyCapacity,
hourly_capacity: item.hourly_capacity || 100,
production_days: productionDays,
production_days: itemLeadTime > 0 ? itemLeadTime : Math.ceil(requiredQty / dailyCapacity),
start_date: startDate.toISOString().split("T")[0],
end_date: endDate.toISOString().split("T")[0],
due_date: item.earliest_due_date,
lead_time: itemLeadTime,
order_count: orderCount,
status: "planned",
});
@@ -343,7 +472,7 @@ export async function previewSchedule(
};
logger.info("자동 스케줄 미리보기", { companyCode, summary });
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
export async function generateSchedule(
@@ -365,7 +494,21 @@ export async function generateSchedule(
const newSchedules: any[] = [];
for (const item of items) {
// 기존 미진행(planned) 스케줄 처리
// 삭제 전에 기존 planned 수량 먼저 조회
let deletedQtyForItem = 0;
if (options.recalculate_unstarted) {
const deletedQtyResult = await client.query(
`SELECT COALESCE(SUM(COALESCE(plan_qty::numeric, 0)), 0) AS deleted_qty
FROM production_plan_mng
WHERE company_code = $1 AND item_code = $2
AND COALESCE(product_type, '완제품') = $3
AND status = 'planned'`,
[companyCode, item.item_code, productType]
);
deletedQtyForItem = parseFloat(deletedQtyResult.rows[0].deleted_qty) || 0;
}
// 기존 미진행(planned) 스케줄 삭제
if (options.recalculate_unstarted) {
const deleteResult = await client.query(
`DELETE FROM production_plan_mng
@@ -389,27 +532,39 @@ export async function generateSchedule(
keptCount += parseInt(keptResult.rows[0].cnt, 10);
}
// 생산일수 계산
// 필요 수량 계산 (삭제된 planned 수량을 복원)
const dailyCapacity = item.daily_capacity || 800;
const requiredQty = item.required_qty;
const itemLeadTime = item.lead_time || 0;
let requiredQty = item.required_qty + deletedQtyForItem;
if (requiredQty <= 0) continue;
const productionDays = Math.ceil(requiredQty / dailyCapacity);
// 시작일 = 납기일 - 생산일수 - 안전리드타임
// 리드타임 기반 날짜 계산: 납기일 기준으로 리드타임만큼 역산
const dueDate = new Date(item.earliest_due_date);
const endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
const startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
let startDate: Date;
let endDate: Date;
if (itemLeadTime > 0) {
// 리드타임이 있으면: 종료일 = 납기일, 시작일 = 납기일 - 리드타임
endDate = new Date(dueDate);
startDate = new Date(dueDate);
startDate.setDate(startDate.getDate() - itemLeadTime);
} else {
// 리드타임이 없으면 기존 로직 (생산능력 기반)
const productionDays = Math.ceil(requiredQty / dailyCapacity);
endDate = new Date(dueDate);
endDate.setDate(endDate.getDate() - safetyLeadTime);
startDate = new Date(endDate);
startDate.setDate(startDate.getDate() - productionDays);
}
// 시작일이 오늘보다 이전이면 오늘로 조정
const today = new Date();
today.setHours(0, 0, 0, 0);
if (startDate < today) {
const duration = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
startDate.setTime(today.getTime());
endDate.setTime(startDate.getTime());
endDate.setDate(endDate.getDate() + productionDays);
endDate.setDate(endDate.getDate() + duration);
}
// 계획번호 생성 (YYYYMMDD-NNNN 형식)
@@ -576,13 +731,24 @@ async function getBomChildItems(
companyCode: string,
itemCode: string
) {
// item_info에 lead_time 컬럼 존재 여부 확인
const colCheck = await client.query(`
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'item_info' AND column_name = 'lead_time'
) AS has_lead_time
`);
const hasLeadTime = colCheck.rows[0]?.has_lead_time === true;
const leadTimeCol = hasLeadTime ? "COALESCE(ii.lead_time, 0)" : "0";
const bomQuery = `
SELECT
bd.child_item_id,
ii.item_name AS child_item_name,
ii.item_number AS child_item_code,
bd.quantity AS bom_qty,
bd.unit
bd.unit,
${leadTimeCol} AS child_lead_time
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 bd.company_code = ii.company_code
@@ -641,9 +807,12 @@ export async function previewSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = new Date(plan.start_date);
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
previews.push({
parent_plan_id: plan.id,
@@ -653,13 +822,14 @@ export async function previewSemiSchedule(
item_name: bomItem.child_item_name || bomItem.child_item_id,
plan_qty: requiredQty,
bom_qty: parseFloat(bomItem.bom_qty) || 1,
lead_time: childLeadTime,
start_date: semiStartDate.toISOString().split("T")[0],
end_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
due_date: typeof semiDueDate === "string"
? semiDueDate.split("T")[0]
: new Date(semiDueDate).toISOString().split("T")[0],
: semiEndDate.toISOString().split("T")[0],
product_type: "반제품",
status: "planned",
});
@@ -683,7 +853,7 @@ export async function previewSemiSchedule(
parent_count: plansResult.rowCount,
};
return { summary, previews, deletedSchedules, keptSchedules };
return { summary, schedules: previews, deletedSchedules, keptSchedules };
}
// ─── 반제품 계획 자동 생성 ───
@@ -740,10 +910,12 @@ export async function generateSemiSchedule(
if (requiredQty <= 0) continue;
// 반제품: 완제품 시작일 기준으로 해당 반제품의 리드타임만큼 역산
const childLeadTime = parseInt(bomItem.child_lead_time) || 1;
const semiDueDate = plan.start_date;
const semiEndDate = plan.start_date;
const semiStartDate = new Date(plan.start_date);
semiStartDate.setDate(semiStartDate.getDate() - (parseInt(plan.lead_time) || 1));
semiStartDate.setDate(semiStartDate.getDate() - childLeadTime);
// plan_no 생성 (PP-YYYYMMDD-SXXX 형식, S = 반제품)
const planNoResult = await client.query(
@@ -5,7 +5,7 @@ export interface BatchExecutionLog {
id?: number;
batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'PARTIAL';
start_time: Date;
end_time?: Date | null;
duration_ms?: number | null;
@@ -21,7 +21,7 @@ export interface BatchExecutionLog {
export interface CreateBatchExecutionLogRequest {
batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'PARTIAL';
start_time?: Date;
end_time?: Date | null;
duration_ms?: number | null;
@@ -35,7 +35,7 @@ export interface CreateBatchExecutionLogRequest {
}
export interface UpdateBatchExecutionLogRequest {
execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
execution_status?: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED' | 'PARTIAL';
end_time?: Date | null;
duration_ms?: number | null;
total_records?: number | null;
+21 -6
View File
@@ -79,6 +79,9 @@ export interface BatchMapping {
created_date?: Date;
}
// 배치 실행 타입: 기존 매핑 방식 또는 노드 플로우 실행
export type BatchExecutionType = "mapping" | "node_flow";
// 배치 설정 타입
export interface BatchConfig {
id?: number;
@@ -87,15 +90,21 @@ export interface BatchConfig {
cron_schedule: string;
is_active: "Y" | "N";
company_code?: string;
save_mode?: "INSERT" | "UPSERT"; // 저장 모드 (기본: INSERT)
conflict_key?: string; // UPSERT 시 충돌 기준 컬럼명
auth_service_name?: string; // REST API 인증에 사용할 토큰 서비스명
data_array_path?: string; // REST API 응답에서 데이터 배열 경로 (예: response, data.items)
save_mode?: "INSERT" | "UPSERT";
conflict_key?: string;
auth_service_name?: string;
data_array_path?: string;
execution_type?: BatchExecutionType;
node_flow_id?: number;
node_flow_context?: Record<string, any>;
created_by?: string;
created_date?: Date;
updated_by?: string;
updated_date?: Date;
batch_mappings?: BatchMapping[];
last_status?: string;
last_executed_at?: string;
last_total_records?: number;
}
export interface BatchConnectionInfo {
@@ -149,7 +158,10 @@ export interface CreateBatchConfigRequest {
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
dataArrayPath?: string;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
mappings: BatchMappingRequest[];
}
@@ -161,7 +173,10 @@ export interface UpdateBatchConfigRequest {
saveMode?: "INSERT" | "UPSERT";
conflictKey?: string;
authServiceName?: string;
dataArrayPath?: string; // REST API 응답에서 데이터 배열 경로
dataArrayPath?: string;
executionType?: BatchExecutionType;
nodeFlowId?: number;
nodeFlowContext?: Record<string, any>;
mappings?: BatchMappingRequest[];
}