Merge branch 'jskim-node' of http://39.117.244.52:3000/kjs/ERP-node into mhkim-node
This commit is contained in:
+26
-2
@@ -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
|
||||
* 배치 설정 업데이트
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 실행 (수동 실행과 동일한 로직)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user