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

This commit is contained in:
wace
2026-03-25 10:08:37 +09:00
188 changed files with 44078 additions and 3710 deletions

View File

@@ -0,0 +1,488 @@
import { Response } from "express";
import { query } from "../database/db";
import { logger } from "../utils/logger";
function buildCompanyFilter(companyCode: string, alias: string, paramIdx: number) {
if (companyCode === "*") return { condition: "", params: [] as any[], nextIdx: paramIdx };
return {
condition: `${alias}.company_code = $${paramIdx}`,
params: [companyCode],
nextIdx: paramIdx + 1,
};
}
function buildDateFilter(startDate: string | undefined, endDate: string | undefined, dateExpr: string, paramIdx: number) {
const conditions: string[] = [];
const params: any[] = [];
let idx = paramIdx;
if (startDate) {
conditions.push(`${dateExpr} >= $${idx}`);
params.push(startDate);
idx++;
}
if (endDate) {
conditions.push(`${dateExpr} <= $${idx}`);
params.push(endDate);
idx++;
}
return { conditions, params, nextIdx: idx };
}
function buildWhereClause(conditions: string[]): string {
return conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
}
function extractFilterSet(rows: any[], field: string, labelField?: string): { value: string; label: string }[] {
const set = new Map<string, string>();
rows.forEach((r: any) => {
const val = r[field];
if (val && val !== "미지정") set.set(val, r[labelField || field] || val);
});
return [...set.entries()].map(([value, label]) => ({ value, label }));
}
// ============================================
// 생산 리포트
// ============================================
export async function getProductionReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "wi", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(wi.start_date, wi.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(wi.start_date, wi.created_date::date::text) as date,
COALESCE(wi.routing, '미지정') as process,
COALESCE(ei.equipment_name, wi.equipment_id, '미지정') as equipment,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
COALESCE(wi.worker, '미지정') as worker,
CAST(COALESCE(NULLIF(wi.qty, ''), '0') AS numeric) as "planQty",
COALESCE(pr.production_qty, 0) as "prodQty",
COALESCE(pr.defect_qty, 0) as "defectQty",
0 as "runTime",
0 as "downTime",
wi.status,
wi.company_code
FROM work_instruction wi
LEFT JOIN (
SELECT wo_id, company_code,
SUM(CAST(COALESCE(NULLIF(production_qty, ''), '0') AS numeric)) as production_qty,
SUM(CAST(COALESCE(NULLIF(defect_qty, ''), '0') AS numeric)) as defect_qty
FROM production_record GROUP BY wo_id, company_code
) pr ON wi.id = pr.wo_id AND wi.company_code = pr.company_code
LEFT JOIN (
SELECT DISTINCT ON (equipment_code, company_code)
equipment_code, equipment_name, equipment_type, company_code
FROM equipment_info ORDER BY equipment_code, company_code, created_date DESC
) ei ON wi.equipment_id = ei.equipment_code AND wi.company_code = ei.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("생산 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
processes: extractFilterSet(dataRows, "process"),
equipment: extractFilterSet(dataRows, "equipment"),
items: extractFilterSet(dataRows, "item"),
workers: extractFilterSet(dataRows, "worker"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("생산 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "생산 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 재고 리포트
// ============================================
export async function getInventoryReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "ist", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(ist.updated_date, ist.created_date)::date::text as date,
ist.item_code,
COALESCE(ii.item_name, ist.item_code, '미지정') as item,
COALESCE(wi.warehouse_name, ist.warehouse_code, '미지정') as warehouse,
'일반' as category,
CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) as "currentQty",
CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric) as "safetyQty",
COALESCE(ih_in.in_qty, 0) as "inQty",
COALESCE(ih_out.out_qty, 0) as "outQty",
0 as "stockValue",
GREATEST(CAST(COALESCE(NULLIF(ist.safety_qty::text, ''), '0') AS numeric)
- CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric), 0) as "shortageQty",
CASE WHEN CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '0') AS numeric) > 0
AND COALESCE(ih_out.out_qty, 0) > 0
THEN ROUND(COALESCE(ih_out.out_qty, 0)::numeric
/ CAST(COALESCE(NULLIF(ist.current_qty::text, ''), '1') AS numeric), 2)
ELSE 0 END as "turnover",
ist.company_code
FROM inventory_stock ist
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON ist.item_code = ii.item_number AND ist.company_code = ii.company_code
LEFT JOIN warehouse_info wi ON ist.warehouse_code = wi.warehouse_code
AND ist.company_code = wi.company_code
LEFT JOIN (
SELECT item_code, company_code,
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as in_qty
FROM inventory_history WHERE transaction_type = 'IN'
GROUP BY item_code, company_code
) ih_in ON ist.item_code = ih_in.item_code AND ist.company_code = ih_in.company_code
LEFT JOIN (
SELECT item_code, company_code,
SUM(CAST(COALESCE(NULLIF(quantity::text, ''), '0') AS numeric)) as out_qty
FROM inventory_history WHERE transaction_type = 'OUT'
GROUP BY item_code, company_code
) ih_out ON ist.item_code = ih_out.item_code AND ist.company_code = ih_out.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("재고 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
items: extractFilterSet(dataRows, "item"),
warehouses: extractFilterSet(dataRows, "warehouse"),
categories: [
{ value: "원자재", label: "원자재" }, { value: "부자재", label: "부자재" },
{ value: "반제품", label: "반제품" }, { value: "완제품", label: "완제품" },
{ value: "일반", label: "일반" },
],
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("재고 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "재고 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 구매 리포트
// ============================================
export async function getPurchaseReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "po", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(po.order_date, po.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(po.order_date, po.created_date::date::text) as date,
po.purchase_no,
COALESCE(po.supplier_name, po.supplier_code, '미지정') as supplier,
COALESCE(po.item_name, po.item_code, '미지정') as item,
po.item_code,
COALESCE(po.manager, '미지정') as manager,
po.status,
CAST(COALESCE(NULLIF(po.order_qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric) as "receiveQty",
CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(po.amount, ''), '0') AS numeric) as "orderAmt",
CAST(COALESCE(NULLIF(po.received_qty, ''), '0') AS numeric)
* CAST(COALESCE(NULLIF(po.unit_price, ''), '0') AS numeric) as "receiveAmt",
1 as "orderCnt",
po.company_code
FROM purchase_order_mng po
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("구매 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
suppliers: extractFilterSet(dataRows, "supplier"),
items: extractFilterSet(dataRows, "item"),
managers: extractFilterSet(dataRows, "manager"),
statuses: extractFilterSet(dataRows, "status"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("구매 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "구매 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 품질 리포트
// ============================================
export async function getQualityReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "pr", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const df = buildDateFilter(startDate, endDate, "COALESCE(pr.production_date, pr.created_date::date::text)", idx);
conditions.push(...df.conditions); params.push(...df.params); idx = df.nextIdx;
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(pr.production_date, pr.created_date::date::text) as date,
COALESCE(ii.item_name, wi.item_id, '미지정') as item,
'일반검사' as "defectType",
COALESCE(wi.routing, '미지정') as process,
COALESCE(pr.worker_name, '미지정') as inspector,
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric) as "inspQty",
CAST(COALESCE(NULLIF(pr.production_qty, ''), '0') AS numeric)
- CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "passQty",
CAST(COALESCE(NULLIF(pr.defect_qty, ''), '0') AS numeric) as "defectQty",
0 as "reworkQty",
0 as "scrapQty",
0 as "claimCnt",
pr.company_code
FROM production_record pr
LEFT JOIN work_instruction wi ON pr.wo_id = wi.id AND pr.company_code = wi.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON wi.item_id = ii.item_number AND wi.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
const dataRows = await query(dataQuery, params);
logger.info("품질 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
items: extractFilterSet(dataRows, "item"),
defectTypes: [
{ value: "외관불량", label: "외관불량" }, { value: "치수불량", label: "치수불량" },
{ value: "기능불량", label: "기능불량" }, { value: "재질불량", label: "재질불량" },
{ value: "일반검사", label: "일반검사" },
],
processes: extractFilterSet(dataRows, "process"),
inspectors: extractFilterSet(dataRows, "inspector"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("품질 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "품질 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 설비 리포트
// ============================================
export async function getEquipmentReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "ei", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(ei.updated_date, ei.created_date)::date::text as date,
ei.equipment_code,
COALESCE(ei.equipment_name, ei.equipment_code) as equipment,
COALESCE(ei.equipment_type, '미지정') as "equipType",
COALESCE(ei.location, '미지정') as line,
COALESCE(ui.user_name, ei.manager_id, '미지정') as manager,
ei.status,
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "runTime",
0 as "downTime",
100 as "opRate",
0 as "faultCnt",
0 as "mtbf",
0 as "mttr",
0 as "maintCost",
CAST(COALESCE(NULLIF(ei.capacity_per_day::text, ''), '0') AS numeric) as "prodQty",
ei.company_code
FROM equipment_info ei
LEFT JOIN (
SELECT DISTINCT ON (user_id) user_id, user_name FROM user_info
) ui ON ei.manager_id = ui.user_id
${whereClause}
ORDER BY equipment ASC
`;
const dataRows = await query(dataQuery, params);
logger.info("설비 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
equipment: extractFilterSet(dataRows, "equipment"),
equipTypes: extractFilterSet(dataRows, "equipType"),
lines: extractFilterSet(dataRows, "line"),
managers: extractFilterSet(dataRows, "manager"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("설비 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "설비 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}
// ============================================
// 금형 리포트
// ============================================
export async function getMoldReportData(req: any, res: Response): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) { res.status(401).json({ success: false, message: "인증 정보가 없습니다" }); return; }
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
const cf = buildCompanyFilter(companyCode, "mm", idx);
if (cf.condition) { conditions.push(cf.condition); params.push(...cf.params); idx = cf.nextIdx; }
const whereClause = buildWhereClause(conditions);
const dataQuery = `
SELECT
COALESCE(mm.updated_date, mm.created_date)::date::text as date,
mm.mold_code,
COALESCE(mm.mold_name, mm.mold_code) as mold,
COALESCE(mm.mold_type, mm.category, '미지정') as "moldType",
COALESCE(ii.item_name, '미지정') as item,
COALESCE(mm.manufacturer, '미지정') as maker,
mm.operation_status as status,
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) as "shotCnt",
CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) as "guaranteeShot",
CASE WHEN CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '0') AS numeric) > 0
THEN ROUND(
CAST(COALESCE(NULLIF(mm.shot_count::text, ''), '0') AS numeric) * 100.0
/ CAST(COALESCE(NULLIF(mm.warranty_shot_count::text, ''), '1') AS numeric), 1)
ELSE 0 END as "lifeRate",
0 as "repairCnt",
0 as "repairCost",
0 as "prodQty",
0 as "defectRate",
CAST(COALESCE(NULLIF(mm.cavity_count::text, ''), '0') AS numeric) as "cavityUse",
mm.company_code
FROM mold_mng mm
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info ORDER BY item_number, company_code, created_date DESC
) ii ON mm.mold_code = ii.item_number AND mm.company_code = ii.company_code
${whereClause}
ORDER BY mold ASC
`;
const dataRows = await query(dataQuery, params);
logger.info("금형 리포트 데이터 조회", { companyCode, rowCount: dataRows.length });
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
molds: extractFilterSet(dataRows, "mold"),
moldTypes: extractFilterSet(dataRows, "moldType"),
items: extractFilterSet(dataRows, "item"),
makers: extractFilterSet(dataRows, "maker"),
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("금형 리포트 조회 실패", { error: error.message });
res.status(500).json({ success: false, message: "금형 리포트 데이터 조회에 실패했습니다", error: error.message });
}
}

View File

@@ -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 : "알 수 없는 오류",
});
}
}
}

View File

@@ -0,0 +1,946 @@
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, getPool } from "../database/db";
import { logger } from "../utils/logger";
// 회사코드 필터 조건 생성 헬퍼
function companyFilter(companyCode: string, paramIndex: number, alias?: string): { condition: string; param: string; nextIndex: number } {
const col = alias ? `${alias}.company_code` : "company_code";
if (companyCode === "*") {
return { condition: "", param: "", nextIndex: paramIndex };
}
return { condition: `${col} = $${paramIndex}`, param: companyCode, nextIndex: paramIndex + 1 };
}
// ============================================
// 설계의뢰/설변요청 (DR/ECR) CRUD
// ============================================
export async function getDesignRequestList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { source_type, status, priority, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") {
conditions.push(`r.company_code = $${pi}`);
params.push(companyCode);
pi++;
}
if (source_type) { conditions.push(`r.source_type = $${pi}`); params.push(source_type); pi++; }
if (status) { conditions.push(`r.status = $${pi}`); params.push(status); pi++; }
if (priority) { conditions.push(`r.priority = $${pi}`); params.push(priority); pi++; }
if (search) {
conditions.push(`(r.target_name ILIKE $${pi} OR r.request_no ILIKE $${pi} OR r.requester ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT r.*,
COALESCE(json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description)) FILTER (WHERE h.id IS NOT NULL), '[]') AS history,
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
FROM dsn_design_request r
LEFT JOIN dsn_request_history h ON h.request_id = r.id
${where}
GROUP BY r.id
ORDER BY r.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("설계의뢰 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getDesignRequestDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`r.id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`r.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT r.*,
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'step', h.step, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_request_history h WHERE h.request_id = r.id), '[]') AS history,
COALESCE((SELECT json_agg(i.impact_type) FROM dsn_request_impact i WHERE i.request_id = r.id), '[]') AS impact
FROM dsn_design_request r
WHERE ${conditions.join(" AND ")}
`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("설계의뢰 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const {
request_no, source_type, request_date, due_date, priority, status,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
impact, history,
} = req.body;
const sql = `
INSERT INTO dsn_design_request (
request_no, source_type, request_date, due_date, priority, status,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
writer, company_code
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25)
RETURNING *
`;
const result = await client.query(sql, [
request_no, source_type || "dr", request_date, due_date, priority || "보통", status || "신규접수",
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency || "보통", reason,
content, apply_timing, review_memo, project_id, ecn_no,
userId, companyCode,
]);
const requestId = result.rows[0].id;
if (impact?.length) {
for (const imp of impact) {
await client.query(
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
[requestId, imp, userId, companyCode]
);
}
}
if (history?.length) {
for (const h of history) {
await client.query(
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[requestId, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("설계의뢰 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const {
request_no, source_type, request_date, due_date, priority, status, approval_step,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
impact, history,
} = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const setClauses = [];
const setParams: any[] = [];
const fields: Record<string, any> = {
request_no, source_type, request_date, due_date, priority, status, approval_step,
target_name, customer, req_dept, requester, designer, order_no,
design_type, spec, change_type, drawing_no, urgency, reason,
content, apply_timing, review_memo, project_id, ecn_no,
};
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) {
setClauses.push(`${key} = $${pi}`);
setParams.push(val);
pi++;
}
}
setClauses.push(`updated_date = now()`);
const sql = `UPDATE dsn_design_request SET ${setClauses.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`;
const result = await client.query(sql, [...params, ...setParams]);
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
if (impact !== undefined) {
await client.query(`DELETE FROM dsn_request_impact WHERE request_id = $1`, [id]);
for (const imp of impact) {
await client.query(
`INSERT INTO dsn_request_impact (request_id, impact_type, writer, company_code) VALUES ($1,$2,$3,$4)`,
[id, imp, userId, companyCode]
);
}
}
if (history !== undefined) {
await client.query(`DELETE FROM dsn_request_history WHERE request_id = $1`, [id]);
for (const h of history) {
await client.query(
`INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, h.step, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("설계의뢰 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function deleteDesignRequest(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const sql = `DELETE FROM dsn_design_request WHERE ${conditions.join(" AND ")} RETURNING id`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "의뢰를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("설계의뢰 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// 이력 추가 (단건)
export async function addRequestHistory(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const { step, history_date, user_name, description } = req.body;
const sql = `INSERT INTO dsn_request_history (request_id, step, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`;
const result = await query(sql, [id, step, history_date, user_name, description, userId, companyCode]);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("의뢰 이력 추가 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 설계 프로젝트 CRUD
// ============================================
export async function getProjectList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { status, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") { conditions.push(`p.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`p.status = $${pi}`); params.push(status); pi++; }
if (search) {
conditions.push(`(p.name ILIKE $${pi} OR p.project_no ILIKE $${pi} OR p.customer ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT p.*,
COALESCE(
(SELECT json_agg(json_build_object(
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
) ORDER BY t.sort_order, t.start_date)
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
) AS tasks
FROM dsn_project p
${where}
ORDER BY p.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("프로젝트 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function getProjectDetail(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`p.id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`p.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT p.*,
COALESCE(
(SELECT json_agg(json_build_object(
'id', t.id, 'name', t.name, 'category', t.category, 'assignee', t.assignee,
'start_date', t.start_date, 'end_date', t.end_date, 'status', t.status,
'progress', t.progress, 'priority', t.priority, 'remark', t.remark, 'sort_order', t.sort_order
) ORDER BY t.sort_order, t.start_date)
FROM dsn_project_task t WHERE t.project_id = p.id), '[]'
) AS tasks
FROM dsn_project p
WHERE ${conditions.join(" AND ")}
`;
const result = await query(sql, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("프로젝트 상세 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createProject(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, tasks } = req.body;
const result = await client.query(
`INSERT INTO dsn_project (project_no, name, status, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
[project_no, name, pStatus || "계획", pm, customer, start_date, end_date, source_no, description, progress || "0", parent_id, relation_type, userId, companyCode]
);
const projectId = result.rows[0].id;
if (tasks?.length) {
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
await client.query(
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)`,
[projectId, t.name, t.category, t.assignee, t.start_date, t.end_date, t.status || "대기", t.progress || "0", t.priority || "보통", t.remark, String(i), userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("프로젝트 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type } = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { project_no, name, status: pStatus, pm, customer, start_date, end_date, source_no, description, progress, parent_id, relation_type };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_project SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("프로젝트 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_project WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "프로젝트를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("프로젝트 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 프로젝트 태스크 CRUD
// ============================================
export async function getTasksByProject(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { projectId } = req.params;
const conditions = [`t.project_id = $1`];
const params: any[] = [projectId];
if (companyCode !== "*") { conditions.push(`t.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT t.*,
COALESCE((SELECT json_agg(json_build_object('id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'progress_before', w.progress_before, 'progress_after', w.progress_after, 'author', w.author, 'sub_item_id', w.sub_item_id) ORDER BY w.start_dt) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs,
COALESCE((SELECT json_agg(json_build_object('id', i.id, 'title', i.title, 'status', i.status, 'priority', i.priority, 'description', i.description, 'registered_by', i.registered_by, 'registered_date', i.registered_date, 'resolved_date', i.resolved_date)) FROM dsn_task_issue i WHERE i.task_id = t.id), '[]') AS issues,
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items
FROM dsn_project_task t
WHERE ${conditions.join(" AND ")}
ORDER BY t.sort_order, t.start_date
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("태스크 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { projectId } = req.params;
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
const result = await query(
`INSERT INTO dsn_project_task (project_id, name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
[projectId, name, category, assignee, start_date, end_date, status || "대기", progress || "0", priority || "보통", remark, sort_order || "0", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("태스크 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order } = req.body;
const conditions = [`id = $1`];
const params: any[] = [taskId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { name, category, assignee, start_date, end_date, status, progress, priority, remark, sort_order };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_project_task SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("태스크 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [taskId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_project_task WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "태스크를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("태스크 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 작업일지 CRUD
// ============================================
export async function getWorkLogsByTask(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { taskId } = req.params;
const conditions = [`w.task_id = $1`];
const params: any[] = [taskId];
if (companyCode !== "*") { conditions.push(`w.company_code = $2`); params.push(companyCode); }
const sql = `
SELECT w.*,
COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]') AS attachments,
COALESCE((SELECT json_agg(json_build_object('id', p.id, 'item', p.item, 'qty', p.qty, 'unit', p.unit, 'reason', p.reason, 'status', p.status)) FROM dsn_purchase_req p WHERE p.work_log_id = w.id), '[]') AS purchase_reqs,
COALESCE((SELECT json_agg(json_build_object(
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]') AS coop_reqs
FROM dsn_work_log w
WHERE ${conditions.join(" AND ")}
ORDER BY w.start_dt DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("작업일지 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id } = req.body;
const result = await query(
`INSERT INTO dsn_work_log (task_id, start_dt, end_dt, hours, description, progress_before, progress_after, author, sub_item_id, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,
[taskId, start_dt, end_dt, hours || "0", description, progress_before || "0", progress_after || "0", author, sub_item_id, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("작업일지 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteWorkLog(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { workLogId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [workLogId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_work_log WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "작업일지를 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("작업일지 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 태스크 하위항목 CRUD
// ============================================
export async function createSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { name, weight, progress, status } = req.body;
const result = await query(
`INSERT INTO dsn_task_sub_item (task_id, name, weight, progress, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
[taskId, name, weight || "0", progress || "0", status || "대기", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("하위항목 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { subItemId } = req.params;
const { name, weight, progress, status } = req.body;
const conditions = [`id = $1`];
const params: any[] = [subItemId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { name, weight, progress, status };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_task_sub_item SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("하위항목 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteSubItem(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { subItemId } = req.params;
const conditions = [`id = $1`];
const params: any[] = [subItemId];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_task_sub_item WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "하위항목을 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("하위항목 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 태스크 이슈 CRUD
// ============================================
export async function createIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { taskId } = req.params;
const { title, status, priority, description, registered_by, registered_date } = req.body;
const result = await query(
`INSERT INTO dsn_task_issue (task_id, title, status, priority, description, registered_by, registered_date, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[taskId, title, status || "등록", priority || "보통", description, registered_by, registered_date, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("이슈 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function updateIssue(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { issueId } = req.params;
const { title, status, priority, description, resolved_date } = req.body;
const conditions = [`id = $1`];
const params: any[] = [issueId];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { title, status, priority, description, resolved_date };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await query(`UPDATE dsn_task_issue SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.length) { res.status(404).json({ success: false, message: "이슈를 찾을 수 없습니다." }); return; }
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("이슈 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// ECN (설변통보) CRUD
// ============================================
export async function getEcnList(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { status, search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let pi = 1;
if (companyCode !== "*") { conditions.push(`e.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`e.status = $${pi}`); params.push(status); pi++; }
if (search) {
conditions.push(`(e.ecn_no ILIKE $${pi} OR e.target ILIKE $${pi})`);
params.push(`%${search}%`);
pi++;
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT e.*,
COALESCE((SELECT json_agg(json_build_object('id', h.id, 'status', h.status, 'history_date', h.history_date, 'user_name', h.user_name, 'description', h.description) ORDER BY h.created_date) FROM dsn_ecn_history h WHERE h.ecn_id = e.id), '[]') AS history,
COALESCE((SELECT json_agg(nd.dept_name) FROM dsn_ecn_notify_dept nd WHERE nd.ecn_id = e.id), '[]') AS notify_depts
FROM dsn_ecn e
${where}
ORDER BY e.created_date DESC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("ECN 목록 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
const result = await client.query(
`INSERT INTO dsn_ecn (ecn_no, ecr_id, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, writer, company_code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[ecn_no, ecr_id, ecn_date, apply_date, status || "ECN발행", target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, userId, companyCode]
);
const ecnId = result.rows[0].id;
if (notify_depts?.length) {
for (const dept of notify_depts) {
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [ecnId, dept, userId, companyCode]);
}
}
if (history?.length) {
for (const h of history) {
await client.query(
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[ecnId, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("ECN 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function updateEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { id } = req.params;
const { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark, notify_depts, history } = req.body;
const conditions = [`id = $1`];
const params: any[] = [id];
let pi = 2;
if (companyCode !== "*") { conditions.push(`company_code = $${pi}`); params.push(companyCode); pi++; }
const sets: string[] = [];
const fields: Record<string, any> = { ecn_no, ecn_date, apply_date, status, target, drawing_before, drawing_after, designer, before_content, after_content, reason, remark };
for (const [key, val] of Object.entries(fields)) {
if (val !== undefined) { sets.push(`${key} = $${pi}`); params.push(val); pi++; }
}
sets.push(`updated_date = now()`);
const result = await client.query(`UPDATE dsn_ecn SET ${sets.join(", ")} WHERE ${conditions.join(" AND ")} RETURNING *`, params);
if (!result.rowCount) { await client.query("ROLLBACK"); res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
if (notify_depts !== undefined) {
await client.query(`DELETE FROM dsn_ecn_notify_dept WHERE ecn_id = $1`, [id]);
for (const dept of notify_depts) {
await client.query(`INSERT INTO dsn_ecn_notify_dept (ecn_id, dept_name, writer, company_code) VALUES ($1,$2,$3,$4)`, [id, dept, userId, companyCode]);
}
}
if (history !== undefined) {
await client.query(`DELETE FROM dsn_ecn_history WHERE ecn_id = $1`, [id]);
for (const h of history) {
await client.query(
`INSERT INTO dsn_ecn_history (ecn_id, status, history_date, user_name, description, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[id, h.status, h.history_date, h.user_name, h.description, userId, companyCode]
);
}
}
await client.query("COMMIT");
res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("ECN 수정 오류", error);
res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
export async function deleteEcn(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const { id } = req.params;
const conditions = [`id = $1`];
const params: any[] = [id];
if (companyCode !== "*") { conditions.push(`company_code = $2`); params.push(companyCode); }
const result = await query(`DELETE FROM dsn_ecn WHERE ${conditions.join(" AND ")} RETURNING id`, params);
if (!result.length) { res.status(404).json({ success: false, message: "ECN을 찾을 수 없습니다." }); return; }
res.json({ success: true });
} catch (error: any) {
logger.error("ECN 삭제 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 나의 업무 (My Work) - 로그인 사용자 기준
// ============================================
export async function getMyWork(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userName = req.user!.userName;
const { status, project_id } = req.query;
const conditions = [`t.assignee = $1`];
const params: any[] = [userName];
let pi = 2;
if (companyCode !== "*") { conditions.push(`t.company_code = $${pi}`); params.push(companyCode); pi++; }
if (status) { conditions.push(`t.status = $${pi}`); params.push(status); pi++; }
if (project_id) { conditions.push(`t.project_id = $${pi}`); params.push(project_id); pi++; }
const sql = `
SELECT t.*,
p.project_no, p.name AS project_name, p.customer AS project_customer, p.status AS project_status,
COALESCE((SELECT json_agg(json_build_object('id', s.id, 'name', s.name, 'weight', s.weight, 'progress', s.progress, 'status', s.status) ORDER BY s.created_date) FROM dsn_task_sub_item s WHERE s.task_id = t.id), '[]') AS sub_items,
COALESCE((SELECT json_agg(json_build_object(
'id', w.id, 'start_dt', w.start_dt, 'end_dt', w.end_dt, 'hours', w.hours, 'description', w.description, 'sub_item_id', w.sub_item_id,
'attachments', COALESCE((SELECT json_agg(json_build_object('id', a.id, 'file_name', a.file_name, 'file_type', a.file_type, 'file_size', a.file_size)) FROM dsn_work_attachment a WHERE a.work_log_id = w.id), '[]'),
'purchase_reqs', COALESCE((SELECT json_agg(json_build_object('id', pr.id, 'item', pr.item, 'qty', pr.qty, 'unit', pr.unit, 'reason', pr.reason, 'status', pr.status)) FROM dsn_purchase_req pr WHERE pr.work_log_id = w.id), '[]'),
'coop_reqs', COALESCE((SELECT json_agg(json_build_object(
'id', c.id, 'to_user', c.to_user, 'to_dept', c.to_dept, 'title', c.title, 'description', c.description, 'status', c.status, 'due_date', c.due_date,
'responses', COALESCE((SELECT json_agg(json_build_object('id', cr.id, 'response_date', cr.response_date, 'user_name', cr.user_name, 'content', cr.content)) FROM dsn_coop_response cr WHERE cr.coop_req_id = c.id), '[]')
)) FROM dsn_coop_req c WHERE c.work_log_id = w.id), '[]')
) ORDER BY w.start_dt DESC) FROM dsn_work_log w WHERE w.task_id = t.id), '[]') AS work_logs
FROM dsn_project_task t
JOIN dsn_project p ON p.id = t.project_id
WHERE ${conditions.join(" AND ")}
ORDER BY
CASE t.status WHEN '진행중' THEN 1 WHEN '대기' THEN 2 WHEN '검토중' THEN 3 ELSE 4 END,
t.end_date ASC
`;
const result = await query(sql, params);
res.json({ success: true, data: result });
} catch (error: any) {
logger.error("나의 업무 조회 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
// ============================================
// 구매요청 / 협업요청 CRUD (my-work에서 사용)
// ============================================
export async function createPurchaseReq(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { workLogId } = req.params;
const { item, qty, unit, reason, status } = req.body;
const result = await query(
`INSERT INTO dsn_purchase_req (work_log_id, item, qty, unit, reason, status, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[workLogId, item, qty, unit, reason, status || "요청", userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("구매요청 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function createCoopReq(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { workLogId } = req.params;
const { to_user, to_dept, title, description, due_date } = req.body;
const result = await query(
`INSERT INTO dsn_coop_req (work_log_id, to_user, to_dept, title, description, status, due_date, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`,
[workLogId, to_user, to_dept, title, description, "요청", due_date, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("협업요청 생성 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}
export async function addCoopResponse(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const companyCode = req.user!.companyCode!;
const userId = req.user!.userId;
const { coopReqId } = req.params;
const { response_date, user_name, content } = req.body;
const result = await query(
`INSERT INTO dsn_coop_response (coop_req_id, response_date, user_name, content, writer, company_code) VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`,
[coopReqId, response_date, user_name, content, userId, companyCode]
);
res.json({ success: true, data: result[0] });
} catch (error: any) {
logger.error("협업응답 추가 오류", error);
res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -0,0 +1,352 @@
/**
* 자재현황 컨트롤러
* - 생산계획(작업지시) 조회
* - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회
* - 창고 목록 조회
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 생산계획(작업지시) 조회 ───
export async function getWorkOrders(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, itemCode, itemName } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (companyCode === "*") {
logger.info("최고 관리자 전체 작업지시 조회");
} else {
conditions.push(`p.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (dateFrom) {
conditions.push(`p.plan_date >= $${paramIndex}::date`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
conditions.push(`p.plan_date <= $${paramIndex}::date`);
params.push(dateTo);
paramIndex++;
}
if (itemCode) {
conditions.push(`p.item_code ILIKE $${paramIndex}`);
params.push(`%${itemCode}%`);
paramIndex++;
}
if (itemName) {
conditions.push(`p.item_name ILIKE $${paramIndex}`);
params.push(`%${itemName}%`);
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
p.id,
p.plan_no,
p.item_code,
p.item_name,
p.plan_qty,
p.completed_qty,
p.plan_date,
p.start_date,
p.end_date,
p.status,
p.work_order_no,
p.company_code
FROM production_plan_mng p
${whereClause}
ORDER BY p.plan_date DESC, p.created_date DESC
`;
const result = await pool.query(query, params);
logger.info("작업지시 조회 완료", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("작업지시 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 선택된 작업지시의 자재소요 + 재고 현황 조회 ───
export async function getMaterialStatus(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
const { planIds, warehouseCode } = req.body;
if (!planIds || !Array.isArray(planIds) || planIds.length === 0) {
return res
.status(400)
.json({ success: false, message: "작업지시를 선택해주세요." });
}
// 1) 선택된 작업지시의 품목코드 + 수량 조회
const planPlaceholders = planIds
.map((_, i) => `$${i + 1}`)
.join(",");
let paramIndex = planIds.length + 1;
const companyCondition =
companyCode === "*" ? "" : `AND p.company_code = $${paramIndex}`;
const planParams: any[] = [...planIds];
if (companyCode !== "*") {
planParams.push(companyCode);
paramIndex++;
}
const planQuery = `
SELECT p.item_code, p.item_name, p.plan_qty
FROM production_plan_mng p
WHERE p.id IN (${planPlaceholders})
${companyCondition}
`;
const planResult = await pool.query(planQuery, planParams);
if (planResult.rowCount === 0) {
return res.json({ success: true, data: [] });
}
// 2) 해당 품목들의 BOM에서 필요 자재 목록 조회
const itemCodes = planResult.rows.map((r: any) => r.item_code);
const planQtyMap: Record<string, number> = {};
for (const row of planResult.rows) {
const code = row.item_code;
planQtyMap[code] = (planQtyMap[code] || 0) + Number(row.plan_qty || 0);
}
const itemPlaceholders = itemCodes.map((_: any, i: number) => `$${i + 1}`).join(",");
// BOM 조인: bom -> bom_detail -> item_info (자재 정보)
const bomCompanyCondition =
companyCode === "*" ? "" : `AND b.company_code = $${itemCodes.length + 1}`;
const bomParams: any[] = [...itemCodes];
if (companyCode !== "*") {
bomParams.push(companyCode);
}
const bomQuery = `
SELECT
b.item_code AS parent_item_code,
b.base_qty AS bom_base_qty,
bd.child_item_id,
bd.quantity AS bom_qty,
bd.unit AS bom_unit,
bd.loss_rate,
ii.item_name AS material_name,
ii.item_number AS material_code,
ii.unit AS material_unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info ii ON bd.child_item_id = ii.id AND b.company_code = ii.company_code
WHERE b.item_code IN (${itemPlaceholders})
${bomCompanyCondition}
ORDER BY b.item_code, bd.seq_no
`;
const bomResult = await pool.query(bomQuery, bomParams);
// 3) 자재별 필요수량 계산
interface MaterialNeed {
childItemId: string;
materialCode: string;
materialName: string;
unit: string;
requiredQty: number;
}
const materialMap: Record<string, MaterialNeed> = {};
for (const bomRow of bomResult.rows) {
const parentQty = planQtyMap[bomRow.parent_item_code] || 0;
const baseQty = Number(bomRow.bom_base_qty) || 1;
const bomQty = Number(bomRow.bom_qty) || 0;
const lossRate = Number(bomRow.loss_rate) || 0;
// 필요수량 = (생산수량 / BOM기준수량) * BOM자재수량 * (1 + 로스율/100)
const requiredQty =
(parentQty / baseQty) * bomQty * (1 + lossRate / 100);
const key = bomRow.child_item_id;
if (materialMap[key]) {
materialMap[key].requiredQty += requiredQty;
} else {
materialMap[key] = {
childItemId: bomRow.child_item_id,
materialCode:
bomRow.material_code || bomRow.child_item_id,
materialName: bomRow.material_name || "알 수 없음",
unit: bomRow.bom_unit || bomRow.material_unit || "EA",
requiredQty,
};
}
}
const materialIds = Object.keys(materialMap);
if (materialIds.length === 0) {
return res.json({ success: true, data: [] });
}
// 4) 재고 조회 (창고/위치별)
const stockPlaceholders = materialIds
.map((_, i) => `$${i + 1}`)
.join(",");
const stockParams: any[] = [...materialIds];
let stockParamIdx = materialIds.length + 1;
const stockConditions: string[] = [
`s.item_code IN (${stockPlaceholders})`,
];
if (companyCode !== "*") {
stockConditions.push(`s.company_code = $${stockParamIdx}`);
stockParams.push(companyCode);
stockParamIdx++;
}
if (warehouseCode) {
stockConditions.push(`s.warehouse_code = $${stockParamIdx}`);
stockParams.push(warehouseCode);
stockParamIdx++;
}
const stockQuery = `
SELECT
s.item_code,
s.warehouse_code,
s.location_code,
COALESCE(CAST(s.current_qty AS NUMERIC), 0) AS current_qty
FROM inventory_stock s
WHERE ${stockConditions.join(" AND ")}
AND COALESCE(CAST(s.current_qty AS NUMERIC), 0) > 0
ORDER BY s.item_code, s.warehouse_code, s.location_code
`;
const stockResult = await pool.query(stockQuery, stockParams);
// 5) 결과 조합
// item_code 기준 재고 맵핑 (inventory_stock.item_code는 item_info.item_number 또는 item_info.id일 수 있음)
const stockByItem: Record<
string,
{ location: string; warehouse: string; qty: number }[]
> = {};
for (const stockRow of stockResult.rows) {
const code = stockRow.item_code;
if (!stockByItem[code]) {
stockByItem[code] = [];
}
stockByItem[code].push({
location: stockRow.location_code || "",
warehouse: stockRow.warehouse_code || "",
qty: Number(stockRow.current_qty),
});
}
const resultData = materialIds.map((id) => {
const material = materialMap[id];
// inventory_stock의 item_code가 item_number 또는 child_item_id일 수 있음
const locations =
stockByItem[material.materialCode] ||
stockByItem[id] ||
[];
const totalCurrentQty = locations.reduce(
(sum, loc) => sum + loc.qty,
0
);
return {
code: material.materialCode,
name: material.materialName,
required: Math.round(material.requiredQty * 100) / 100,
current: totalCurrentQty,
unit: material.unit,
locations,
};
});
logger.info("자재현황 조회 완료", {
companyCode,
planCount: planIds.length,
materialCount: resultData.length,
});
return res.json({ success: true, data: resultData });
} catch (error: any) {
logger.error("자재현황 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 창고 목록 조회 ───
export async function getWarehouses(
req: AuthenticatedRequest,
res: Response
) {
try {
const companyCode = req.user!.companyCode;
let query: string;
let params: any[];
if (companyCode === "*") {
query = `
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
ORDER BY warehouse_code
`;
params = [];
} else {
query = `
SELECT DISTINCT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1
ORDER BY warehouse_code
`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info("창고 목록 조회 완료", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -0,0 +1,463 @@
/**
* 공정정보관리 컨트롤러
* - 공정 마스터 CRUD
* - 공정별 설비 관리
* - 품목별 라우팅 관리
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ═══════════════════════════════════════════
// 공정 마스터 CRUD
// ═══════════════════════════════════════════
export async function getProcessList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { processCode, processName, processType, useYn } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`company_code = $${idx++}`);
params.push(companyCode);
}
if (processCode) {
conditions.push(`process_code ILIKE $${idx++}`);
params.push(`%${processCode}%`);
}
if (processName) {
conditions.push(`process_name ILIKE $${idx++}`);
params.push(`%${processName}%`);
}
if (processType) {
conditions.push(`process_type = $${idx++}`);
params.push(processType);
}
if (useYn) {
conditions.push(`use_yn = $${idx++}`);
params.push(useYn);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT * FROM process_mng ${where} ORDER BY process_code`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("공정 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function createProcess(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
// 공정코드 자동 채번: PROC-001, PROC-002, ...
const seqRes = await pool.query(
`SELECT process_code FROM process_mng WHERE company_code = $1 AND process_code LIKE 'PROC-%' ORDER BY process_code DESC LIMIT 1`,
[companyCode]
);
let nextNum = 1;
if (seqRes.rowCount! > 0) {
const lastCode = seqRes.rows[0].process_code;
const numPart = parseInt(lastCode.replace("PROC-", ""), 10);
if (!isNaN(numPart)) nextNum = numPart + 1;
}
const processCode = `PROC-${String(nextNum).padStart(3, "0")}`;
const result = await pool.query(
`INSERT INTO process_mng (id, company_code, process_code, process_name, process_type, standard_time, worker_count, use_yn, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
[companyCode, processCode, process_name, process_type, standard_time || "0", worker_count || "0", use_yn || "Y", writer]
);
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("공정 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function updateProcess(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const { process_name, process_type, standard_time, worker_count, use_yn } = req.body;
const result = await pool.query(
`UPDATE process_mng SET process_name=$1, process_type=$2, standard_time=$3, worker_count=$4, use_yn=$5, updated_date=NOW()
WHERE id=$6 AND company_code=$7 RETURNING *`,
[process_name, process_type, standard_time, worker_count, use_yn, id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "공정을 찾을 수 없습니다." });
}
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("공정 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteProcesses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: "삭제할 공정을 선택해주세요." });
}
const placeholders = ids.map((_: any, i: number) => `$${i + 1}`).join(",");
// 설비 매핑도 삭제
await pool.query(
`DELETE FROM process_equipment WHERE process_code IN (SELECT process_code FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1})`,
[...ids, companyCode]
);
const result = await pool.query(
`DELETE FROM process_mng WHERE id IN (${placeholders}) AND company_code = $${ids.length + 1} RETURNING id`,
[...ids, companyCode]
);
return res.json({ success: true, deletedCount: result.rowCount });
} catch (error: any) {
logger.error("공정 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// 공정별 설비 관리
// ═══════════════════════════════════════════
export async function getProcessEquipments(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { processCode } = req.params;
const result = await pool.query(
`SELECT pe.*, ei.equipment_name
FROM process_equipment pe
LEFT JOIN equipment_info ei ON pe.equipment_code = ei.equipment_code AND pe.company_code = ei.company_code
WHERE pe.process_code = $1 AND pe.company_code = $2
ORDER BY pe.equipment_code`,
[processCode, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("공정 설비 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function addProcessEquipment(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { process_code, equipment_code } = req.body;
const dupCheck = await pool.query(
`SELECT id FROM process_equipment WHERE process_code=$1 AND equipment_code=$2 AND company_code=$3`,
[process_code, equipment_code, companyCode]
);
if (dupCheck.rowCount! > 0) {
return res.status(400).json({ success: false, message: "이미 등록된 설비입니다." });
}
const result = await pool.query(
`INSERT INTO process_equipment (id, company_code, process_code, equipment_code, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4) RETURNING *`,
[companyCode, process_code, equipment_code, writer]
);
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("공정 설비 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function removeProcessEquipment(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await pool.query(
`DELETE FROM process_equipment WHERE id=$1 AND company_code=$2`,
[id, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("공정 설비 제거 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const condition = companyCode === "*" ? "" : `WHERE company_code = $1`;
const params = companyCode === "*" ? [] : [companyCode];
const result = await pool.query(
`SELECT id, equipment_code, equipment_name FROM equipment_info ${condition} ORDER BY equipment_code`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("설비 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// 품목별 라우팅 관리
// ═══════════════════════════════════════════
export async function getItemsForRouting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search } = req.query;
const conditions: string[] = ["i.company_code = rv.company_code"];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`i.company_code = $${idx++}`);
params.push(companyCode);
}
if (search) {
conditions.push(`(i.item_number ILIKE $${idx} OR i.item_name ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT DISTINCT i.id, i.item_number, i.item_name, i.size, i.unit, i.type
FROM item_info i
INNER JOIN item_routing_version rv ON rv.item_code = i.item_number AND rv.company_code = i.company_code
${where}
ORDER BY i.item_number LIMIT 200`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("라우팅 등록 품목 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function searchAllItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { search } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`company_code = $${idx++}`);
params.push(companyCode);
}
if (search) {
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
params.push(`%${search}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const result = await pool.query(
`SELECT id, item_number, item_name, size, unit, type FROM item_info ${where} ORDER BY item_number LIMIT 200`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("전체 품목 검색 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
const result = await pool.query(
`SELECT * FROM item_routing_version WHERE item_code=$1 AND company_code=$2 ORDER BY created_date`,
[itemCode, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("라우팅 버전 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function createRoutingVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { item_code, version_name, description, is_default } = req.body;
if (is_default) {
await pool.query(
`UPDATE item_routing_version SET is_default=false WHERE item_code=$1 AND company_code=$2`,
[item_code, companyCode]
);
}
const result = await pool.query(
`INSERT INTO item_routing_version (id, company_code, item_code, version_name, description, is_default, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6) RETURNING *`,
[companyCode, item_code, version_name, description || "", is_default || false, writer]
);
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("라우팅 버전 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function deleteRoutingVersion(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
await pool.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
[id, companyCode]
);
await pool.query(
`DELETE FROM item_routing_version WHERE id=$1 AND company_code=$2`,
[id, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("라우팅 버전 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function getRoutingDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { versionId } = req.params;
const result = await pool.query(
`SELECT rd.*, pm.process_name
FROM item_routing_detail rd
LEFT JOIN process_mng pm ON rd.process_code = pm.process_code AND rd.company_code = pm.company_code
WHERE rd.routing_version_id=$1 AND rd.company_code=$2
ORDER BY CAST(rd.seq_no AS INTEGER)`,
[versionId, companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("라우팅 상세 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
export async function saveRoutingDetails(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const writer = req.user!.userId;
const { versionId } = req.params;
const { details } = req.body;
const client = await pool.connect();
try {
await client.query("BEGIN");
// 기존 상세 삭제 후 재입력
await client.query(
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
[versionId, companyCode]
);
for (const d of details) {
await client.query(
`INSERT INTO item_routing_detail (id, company_code, routing_version_id, seq_no, process_code, is_required, is_fixed_order, work_type, standard_time, outsource_supplier, writer)
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", d.outsource_supplier || "", writer]
);
}
await client.query("COMMIT");
return res.json({ success: true });
} catch (err) {
await client.query("ROLLBACK");
throw err;
} finally {
client.release();
}
} catch (error: any) {
logger.error("라우팅 상세 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ═══════════════════════════════════════════
// BOM 구성 자재 조회 (품목코드 기반)
// ═══════════════════════════════════════════
export async function getBomMaterials(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
if (!itemCode) {
return res.status(400).json({ success: false, message: "itemCode는 필수입니다" });
}
const query = `
SELECT
bd.id,
bd.child_item_id,
bd.quantity,
bd.unit as detail_unit,
bd.process_type,
i.item_name as child_item_name,
i.item_number as child_item_code,
i.type as child_item_type,
i.unit as item_unit
FROM bom b
JOIN bom_detail bd ON b.id = bd.bom_id AND b.company_code = bd.company_code
LEFT JOIN item_info i ON bd.child_item_id = i.id AND bd.company_code = i.company_code
WHERE b.item_code = $1 AND b.company_code = $2
ORDER BY bd.seq_no ASC, bd.created_date ASC
`;
const result = await pool.query(query, [itemCode, companyCode]);
logger.info("BOM 자재 조회 성공", { companyCode, itemCode, count: result.rowCount });
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("BOM 자재 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -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) {

View File

@@ -0,0 +1,487 @@
/**
* 입고관리 컨트롤러
*
* 입고유형별 소스 테이블:
* - 구매입고 → purchase_order_mng (발주)
* - 반품입고 → shipment_instruction + shipment_instruction_detail (출하)
* - 기타입고 → item_info (품목)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
// 입고 목록 조회
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const {
inbound_type,
inbound_status,
search_keyword,
date_from,
date_to,
} = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
if (companyCode === "*") {
// 최고 관리자: 전체 조회
} else {
conditions.push(`im.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
if (inbound_type && inbound_type !== "all") {
conditions.push(`im.inbound_type = $${paramIdx}`);
params.push(inbound_type);
paramIdx++;
}
if (inbound_status && inbound_status !== "all") {
conditions.push(`im.inbound_status = $${paramIdx}`);
params.push(inbound_status);
paramIdx++;
}
if (search_keyword) {
conditions.push(
`(im.inbound_number ILIKE $${paramIdx} OR im.item_name ILIKE $${paramIdx} OR im.item_number ILIKE $${paramIdx} OR im.supplier_name ILIKE $${paramIdx} OR im.reference_number ILIKE $${paramIdx})`
);
params.push(`%${search_keyword}%`);
paramIdx++;
}
if (date_from) {
conditions.push(`im.inbound_date >= $${paramIdx}::date`);
params.push(date_from);
paramIdx++;
}
if (date_to) {
conditions.push(`im.inbound_date <= $${paramIdx}::date`);
params.push(date_to);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
im.*,
wh.warehouse_name
FROM inbound_mng im
LEFT JOIN warehouse_info wh
ON im.warehouse_code = wh.warehouse_code
AND im.company_code = wh.company_code
${whereClause}
ORDER BY im.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("입고 목록 조회", {
companyCode,
rowCount: result.rowCount,
});
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("입고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고 등록 (다건)
export async function create(req: AuthenticatedRequest, res: Response) {
const pool = getPool();
const client = await pool.connect();
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { items, inbound_number, inbound_date, warehouse_code, location_code, inspector, manager, memo } = req.body;
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ success: false, message: "입고 품목이 없습니다." });
}
await client.query("BEGIN");
const insertedRows: any[] = [];
for (const item of items) {
const result = await client.query(
`INSERT INTO inbound_mng (
company_code, inbound_number, inbound_type, inbound_date,
reference_number, supplier_code, supplier_name,
item_number, item_name, spec, material, unit,
inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager, memo,
source_table, source_id,
created_date, created_by, writer, status
) VALUES (
$1, $2, $3, $4::date,
$5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15,
$16, $17, $18,
$19, $20,
$21, $22, $23,
$24, $25,
NOW(), $26, $26, '입고'
) RETURNING *`,
[
companyCode,
inbound_number || item.inbound_number,
item.inbound_type,
inbound_date || item.inbound_date,
item.reference_number || null,
item.supplier_code || null,
item.supplier_name || null,
item.item_number || null,
item.item_name || null,
item.spec || null,
item.material || null,
item.unit || "EA",
item.inbound_qty || 0,
item.unit_price || 0,
item.total_amount || 0,
item.lot_number || null,
warehouse_code || item.warehouse_code || null,
location_code || item.location_code || null,
item.inbound_status || "대기",
item.inspection_status || "대기",
inspector || item.inspector || null,
manager || item.manager || null,
memo || item.memo || null,
item.source_table || null,
item.source_id || null,
userId,
]
);
insertedRows.push(result.rows[0]);
// 구매입고인 경우 발주의 received_qty 업데이트
if (item.inbound_type === "구매입고" && item.source_id && item.source_table === "purchase_order_mng") {
await client.query(
`UPDATE purchase_order_mng
SET received_qty = CAST(
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1 AS text
),
remain_qty = CAST(
GREATEST(COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) - $1, 0) AS text
),
status = CASE
WHEN COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) + $1
>= COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
THEN '입고완료'
ELSE '부분입고'
END,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[item.inbound_qty || 0, item.source_id, companyCode]
);
}
}
await client.query("COMMIT");
logger.info("입고 등록 완료", {
companyCode,
userId,
count: insertedRows.length,
inbound_number,
});
return res.json({
success: true,
data: insertedRows,
message: `${insertedRows.length}건 입고 등록 완료`,
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("입고 등록 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
} finally {
client.release();
}
}
// 입고 수정
export async function update(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id } = req.params;
const {
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, manager: mgr, memo,
} = req.body;
const pool = getPool();
const result = await pool.query(
`UPDATE inbound_mng SET
inbound_date = COALESCE($1::date, inbound_date),
inbound_qty = COALESCE($2, inbound_qty),
unit_price = COALESCE($3, unit_price),
total_amount = COALESCE($4, total_amount),
lot_number = COALESCE($5, lot_number),
warehouse_code = COALESCE($6, warehouse_code),
location_code = COALESCE($7, location_code),
inbound_status = COALESCE($8, inbound_status),
inspection_status = COALESCE($9, inspection_status),
inspector = COALESCE($10, inspector),
manager = COALESCE($11, manager),
memo = COALESCE($12, memo),
updated_date = NOW(),
updated_by = $13
WHERE id = $14 AND company_code = $15
RETURNING *`,
[
inbound_date, inbound_qty, unit_price, total_amount,
lot_number, warehouse_code, location_code,
inbound_status, inspection_status,
inspector, mgr, memo,
userId, id, companyCode,
]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "입고 데이터를 찾을 수 없습니다." });
}
logger.info("입고 수정", { companyCode, userId, id });
return res.json({ success: true, data: result.rows[0] });
} catch (error: any) {
logger.error("입고 수정 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고 삭제
export async function deleteReceiving(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { id } = req.params;
const pool = getPool();
const result = await pool.query(
`DELETE FROM inbound_mng WHERE id = $1 AND company_code = $2 RETURNING id`,
[id, companyCode]
);
if (result.rowCount === 0) {
return res.status(404).json({ success: false, message: "데이터를 찾을 수 없습니다." });
}
logger.info("입고 삭제", { companyCode, id });
return res.json({ success: true, message: "삭제 완료" });
} catch (error: any) {
logger.error("입고 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 구매입고용: 발주 데이터 조회 (미입고분)
export async function getPurchaseOrders(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
// 잔량이 있는 것만 조회
conditions.push(
`COALESCE(CAST(NULLIF(remain_qty, '') AS numeric), COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) - COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)) > 0`
);
conditions.push(`status NOT IN ('입고완료', '취소')`);
if (keyword) {
conditions.push(
`(purchase_no ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx} OR item_code ILIKE $${paramIdx} OR supplier_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, purchase_no, order_date, supplier_code, supplier_name,
item_code, item_name, spec, material,
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0) AS order_qty,
COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0) AS received_qty,
COALESCE(CAST(NULLIF(remain_qty, '') AS numeric),
COALESCE(CAST(NULLIF(order_qty, '') AS numeric), 0)
- COALESCE(CAST(NULLIF(received_qty, '') AS numeric), 0)
) AS remain_qty,
COALESCE(CAST(NULLIF(unit_price, '') AS numeric), 0) AS unit_price,
status, due_date
FROM purchase_order_mng
WHERE ${conditions.join(" AND ")}
ORDER BY order_date DESC, purchase_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("발주 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 반품입고용: 출하 데이터 조회
export async function getShipments(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
sid.id AS detail_id,
si.id AS instruction_id,
si.instruction_no,
si.instruction_date,
si.partner_id,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
sid.spec,
sid.material,
COALESCE(sid.ship_qty, 0) AS ship_qty,
COALESCE(sid.order_qty, 0) AS order_qty,
sid.source_type
FROM shipment_instruction si
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
WHERE ${conditions.join(" AND ")}
ORDER BY si.instruction_date DESC, si.instruction_no`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 기타입고용: 품목 데이터 조회
export async function getItems(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const conditions: string[] = ["company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (keyword) {
conditions.push(
`(item_number ILIKE $${paramIdx} OR item_name ILIKE $${paramIdx})`
);
params.push(`%${keyword}%`);
paramIdx++;
}
const pool = getPool();
const result = await pool.query(
`SELECT
id, item_number, item_name, size AS spec, material, unit,
COALESCE(CAST(NULLIF(standard_price, '') AS numeric), 0) AS standard_price
FROM item_info
WHERE ${conditions.join(" AND ")}
ORDER BY item_name`,
params
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("품목 데이터 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 입고번호 자동생성
export async function generateNumber(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const today = new Date();
const yyyy = today.getFullYear();
const prefix = `RCV-${yyyy}-`;
const result = await pool.query(
`SELECT inbound_number FROM inbound_mng
WHERE company_code = $1 AND inbound_number LIKE $2
ORDER BY inbound_number DESC LIMIT 1`,
[companyCode, `${prefix}%`]
);
let seq = 1;
if (result.rows.length > 0) {
const lastNo = result.rows[0].inbound_number;
const lastSeq = parseInt(lastNo.replace(prefix, ""), 10);
if (!isNaN(lastSeq)) seq = lastSeq + 1;
}
const newNumber = `${prefix}${String(seq).padStart(4, "0")}`;
return res.json({ success: true, data: newNumber });
} catch (error: any) {
logger.error("입고번호 생성 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// 창고 목록 조회
export async function getWarehouses(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const result = await pool.query(
`SELECT warehouse_code, warehouse_name, warehouse_type
FROM warehouse_info
WHERE company_code = $1 AND status != '삭제'
ORDER BY warehouse_name`,
[companyCode]
);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("창고 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -0,0 +1,161 @@
import { Response } from "express";
import { query } from "../database/db";
import { logger } from "../utils/logger";
/**
* 영업 리포트 컨트롤러
* - 수주 데이터를 기반으로 집계/분석용 원본 데이터를 반환
* - 프론트엔드에서 그룹핑/집계/필터링 처리
*/
export async function getSalesReportData(
req: any,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
if (!companyCode) {
res.status(401).json({ success: false, message: "인증 정보가 없습니다" });
return;
}
const { startDate, endDate } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let paramIdx = 1;
// 멀티테넌시: 최고관리자는 전체, 일반 회사는 자기 데이터만
if (companyCode !== "*") {
conditions.push(`som.company_code = $${paramIdx}`);
params.push(companyCode);
paramIdx++;
}
// 날짜 필터 (due_date 또는 order_date 기준)
if (startDate) {
conditions.push(
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) >= $${paramIdx}`
);
params.push(startDate);
paramIdx++;
}
if (endDate) {
conditions.push(
`COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) <= $${paramIdx}`
);
params.push(endDate);
paramIdx++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const dataQuery = `
SELECT
som.order_no,
COALESCE(sod.due_date, som.order_date::text, som.created_date::date::text) as date,
som.order_date,
som.partner_id,
COALESCE(cm.customer_name, som.partner_id, '미지정') as customer,
sod.part_code,
COALESCE(ii.item_name, sod.part_name, sod.part_code, '미지정') as item,
CAST(COALESCE(NULLIF(sod.qty, ''), '0') AS numeric) as "orderQty",
CAST(COALESCE(NULLIF(sod.ship_qty, ''), '0') AS numeric) as "shipQty",
CAST(COALESCE(NULLIF(sod.unit_price, ''), '0') AS numeric) as "unitPrice",
CAST(COALESCE(NULLIF(sod.amount, ''), '0') AS numeric) as "orderAmt",
1 as "orderCount",
som.status,
som.company_code
FROM sales_order_mng som
JOIN sales_order_detail sod
ON som.order_no = sod.order_no
AND som.company_code = sod.company_code
LEFT JOIN customer_mng cm
ON som.partner_id = cm.customer_code
AND som.company_code = cm.company_code
LEFT JOIN (
SELECT DISTINCT ON (item_number, company_code)
item_number, item_name, company_code
FROM item_info
ORDER BY item_number, company_code, created_date DESC
) ii
ON sod.part_code = ii.item_number
AND sod.company_code = ii.company_code
${whereClause}
ORDER BY date DESC NULLS LAST
`;
// query()는 rows 배열을 직접 반환
const dataRows = await query(dataQuery, params);
// 필터 옵션 조회 (거래처, 품목, 상태)
const filterParams: any[] = [];
let filterWhere = "";
if (companyCode !== "*") {
filterWhere = `WHERE company_code = $1`;
filterParams.push(companyCode);
}
const statusWhere = filterWhere
? `${filterWhere} AND status IS NOT NULL`
: `WHERE status IS NOT NULL`;
const [customersRows, statusRows] = await Promise.all([
query(
`SELECT DISTINCT customer_code as value, customer_name as label
FROM customer_mng ${filterWhere}
ORDER BY customer_name`,
filterParams
),
query(
`SELECT DISTINCT status as value, status as label
FROM sales_order_mng ${statusWhere}
ORDER BY status`,
filterParams
),
]);
// 품목은 데이터에서 추출 (실제 수주에 사용된 품목만)
const itemSet = new Map<string, string>();
dataRows.forEach((row: any) => {
if (row.part_code && !itemSet.has(row.part_code)) {
itemSet.set(row.part_code, row.item);
}
});
const items = Array.from(itemSet.entries()).map(([value, label]) => ({
value,
label,
}));
logger.info("영업 리포트 데이터 조회", {
companyCode,
rowCount: dataRows.length,
startDate,
endDate,
});
res.status(200).json({
success: true,
data: {
rows: dataRows,
filterOptions: {
customers: customersRows,
items,
statuses: statusRows,
},
totalCount: dataRows.length,
},
});
} catch (error: any) {
logger.error("영업 리포트 데이터 조회 실패", {
error: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: "영업 리포트 데이터 조회에 실패했습니다",
error: error.message,
});
}
}

View File

@@ -0,0 +1,482 @@
/**
* 출하지시 컨트롤러 (shipment_instruction + shipment_instruction_detail)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
// ─── 출하지시 목록 조회 ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, customer, keyword } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`si.company_code = $${idx}`);
params.push(companyCode);
idx++;
}
if (dateFrom) {
conditions.push(`si.instruction_date >= $${idx}::date`);
params.push(dateFrom);
idx++;
}
if (dateTo) {
conditions.push(`si.instruction_date <= $${idx}::date`);
params.push(dateTo);
idx++;
}
if (status) {
conditions.push(`si.status = $${idx}`);
params.push(status);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR si.partner_id ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
if (keyword) {
conditions.push(`(si.instruction_no ILIKE $${idx} OR si.memo ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
si.*,
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
COALESCE(
json_agg(
json_build_object(
'id', sid.id,
'item_code', sid.item_code,
'item_name', COALESCE(i.item_name, sid.item_name, sid.item_code),
'spec', sid.spec,
'material', sid.material,
'order_qty', sid.order_qty,
'plan_qty', sid.plan_qty,
'ship_qty', sid.ship_qty,
'source_type', sid.source_type,
'shipment_plan_id', sid.shipment_plan_id,
'sales_order_id', sid.sales_order_id,
'detail_id', sid.detail_id
)
) FILTER (WHERE sid.id IS NOT NULL),
'[]'
) AS items
FROM shipment_instruction si
LEFT JOIN customer_mng c
ON si.partner_id = c.customer_code AND si.company_code = c.company_code
LEFT JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id AND si.company_code = sid.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = sid.item_code AND company_code = si.company_code
LIMIT 1
) i ON true
${where}
GROUP BY si.id, c.customer_name
ORDER BY si.created_date DESC
`;
const pool = getPool();
const result = await pool.query(query, params);
logger.info("출하지시 목록 조회", { companyCode, count: result.rowCount });
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("출하지시 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 다음 출하지시번호 미리보기 ───
export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
let instructionNo: string;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode, "shipment_instruction", "instruction_no"
);
if (rule) {
instructionNo = await numberingRuleService.previewCode(
rule.ruleId, companyCode, {}
);
} else {
throw new Error("채번 규칙 없음");
}
} catch {
const pool = getPool();
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await pool.query(
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
[companyCode, `SI-${today}-%`]
);
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
instructionNo = `SI-${today}-${seq}`;
}
return res.json({ success: true, instructionNo });
} catch (error: any) {
logger.error("출하지시번호 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하지시 저장 (신규/수정) ───
export async function save(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
id: editId,
instructionDate,
partnerId,
status: orderStatus,
memo,
carrierName,
vehicleNo,
driverName,
driverContact,
arrivalTime,
deliveryAddress,
items,
} = req.body;
if (!instructionDate) {
return res.status(400).json({ success: false, message: "출하지시일은 필수입니다" });
}
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
}
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let instructionId: number;
let instructionNo: string;
if (editId) {
// 수정
const check = await client.query(
`SELECT id, instruction_no FROM shipment_instruction WHERE id = $1 AND company_code = $2`,
[editId, companyCode]
);
if (check.rowCount === 0) {
throw new Error("출하지시를 찾을 수 없습니다");
}
instructionId = editId;
instructionNo = check.rows[0].instruction_no;
await client.query(
`UPDATE shipment_instruction SET
instruction_date = $1::date, partner_id = $2, status = $3, memo = $4,
carrier_name = $5, vehicle_no = $6, driver_name = $7, driver_contact = $8,
arrival_time = $9, delivery_address = $10,
updated_date = NOW(), updated_by = $11
WHERE id = $12 AND company_code = $13`,
[
instructionDate, partnerId, orderStatus || "READY", memo,
carrierName, vehicleNo, driverName, driverContact,
arrivalTime || null, deliveryAddress,
userId, editId, companyCode,
]
);
// 기존 디테일 삭제 후 재삽입
await client.query(
`DELETE FROM shipment_instruction_detail WHERE instruction_id = $1 AND company_code = $2`,
[editId, companyCode]
);
} else {
// 신규 - 채번 규칙이 있으면 사용, 없으면 자체 생성
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(
companyCode, "shipment_instruction", "instruction_no"
);
if (rule) {
instructionNo = await numberingRuleService.allocateCode(
rule.ruleId, companyCode, { instruction_date: instructionDate }
);
logger.info("채번 규칙으로 출하지시번호 생성", { ruleId: rule.ruleId, instructionNo });
} else {
throw new Error("채번 규칙 없음 - 폴백");
}
} catch {
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await client.query(
`SELECT COUNT(*) + 1 AS seq FROM shipment_instruction WHERE company_code = $1 AND instruction_no LIKE $2`,
[companyCode, `SI-${today}-%`]
);
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
instructionNo = `SI-${today}-${seq}`;
logger.info("폴백으로 출하지시번호 생성", { instructionNo });
}
const insertRes = await client.query(
`INSERT INTO shipment_instruction
(company_code, instruction_no, instruction_date, partner_id, status, memo,
carrier_name, vehicle_no, driver_name, driver_contact, arrival_time, delivery_address,
created_date, created_by)
VALUES ($1, $2, $3::date, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW(), $13)
RETURNING id`,
[
companyCode, instructionNo, instructionDate, partnerId,
orderStatus || "READY", memo,
carrierName, vehicleNo, driverName, driverContact,
arrivalTime || null, deliveryAddress, userId,
]
);
instructionId = insertRes.rows[0].id;
}
// 디테일 삽입
for (const item of items) {
await client.query(
`INSERT INTO shipment_instruction_detail
(company_code, instruction_id, shipment_plan_id, sales_order_id, detail_id,
item_code, item_name, spec, material, order_qty, plan_qty, ship_qty,
source_type, created_date, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14)`,
[
companyCode, instructionId,
item.shipmentPlanId || null, item.salesOrderId || null, item.detailId || null,
item.itemCode, item.itemName, item.spec, item.material,
item.orderQty || 0, item.planQty || 0, item.shipQty || 0,
item.sourceType || "shipmentPlan", userId,
]
);
}
await client.query("COMMIT");
logger.info("출하지시 저장 완료", { companyCode, instructionId, instructionNo, itemCount: items.length });
return res.json({ success: true, data: { id: instructionId, instructionNo } });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("출하지시 저장 실패", { error: error.message, stack: error.stack });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하지시 삭제 ───
export async function remove(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return res.status(400).json({ success: false, message: "삭제할 ID가 필요합니다" });
}
const pool = getPool();
// CASCADE로 디테일도 자동 삭제
const result = await pool.query(
`DELETE FROM shipment_instruction WHERE id = ANY($1::int[]) AND company_code = $2 RETURNING id`,
[ids, companyCode]
);
logger.info("출하지시 삭제", { companyCode, deletedCount: result.rowCount });
return res.json({ success: true, deletedCount: result.rowCount });
} catch (error: any) {
logger.error("출하지시 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 출하계획 목록 (모달 왼쪽 패널용) ───
export async function getShipmentPlanSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["sp.company_code = $1", "sp.status = 'READY'"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(COALESCE(d.part_code, m.part_code, '') ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, m.part_name, '') ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(m.partner_id, d.delivery_partner_code, '') ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const fromClause = `
FROM shipment_plan sp
LEFT JOIN sales_order_detail d ON sp.detail_id = d.id AND sp.company_code = d.company_code
LEFT JOIN sales_order_mng m ON sp.sales_order_id = m.id AND sp.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = COALESCE(d.part_code, m.part_code) AND company_code = sp.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(m.partner_id, d.delivery_partner_code) = c.customer_code AND sp.company_code = c.company_code
WHERE ${whereClause}
`;
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
sp.id, sp.plan_qty, sp.plan_date, sp.status, sp.shipment_plan_no,
COALESCE(m.order_no, d.order_no, '') AS order_no,
COALESCE(d.part_code, m.part_code, '') AS item_code,
COALESCE(i.item_name, d.part_name, m.part_name, COALESCE(d.part_code, m.part_code, '')) AS item_name,
COALESCE(d.spec, m.spec, '') AS spec,
COALESCE(m.material, '') AS material,
COALESCE(c.customer_name, m.partner_id, d.delivery_partner_code, '') AS customer_name,
COALESCE(m.partner_id, d.delivery_partner_code, '') AS partner_code,
sp.detail_id, sp.sales_order_id
${fromClause}
ORDER BY sp.created_date DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("출하계획 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 수주 목록 (모달 왼쪽 패널용) ───
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, customer, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["d.company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
if (customer) {
conditions.push(`(c.customer_name ILIKE $${idx} OR COALESCE(d.delivery_partner_code, m.partner_id, '') ILIKE $${idx})`);
params.push(`%${customer}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const fromClause = `
FROM sales_order_detail d
LEFT JOIN sales_order_mng m ON d.order_no = m.order_no AND d.company_code = m.company_code
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = d.part_code AND company_code = d.company_code
LIMIT 1
) i ON true
LEFT JOIN customer_mng c
ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code AND d.company_code = c.company_code
WHERE ${whereClause}
`;
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
d.id, d.order_no, d.part_code AS item_code,
COALESCE(i.item_name, d.part_name, d.part_code) AS item_name,
COALESCE(d.spec, '') AS spec, COALESCE(m.material, '') AS material,
COALESCE(NULLIF(d.qty,'')::numeric, 0) AS qty,
COALESCE(NULLIF(d.balance_qty,'')::numeric, 0) AS balance_qty,
COALESCE(c.customer_name, COALESCE(d.delivery_partner_code, m.partner_id, '')) AS customer_name,
COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code,
m.id AS master_id
${fromClause}
ORDER BY d.created_date DESC
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("수주 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 품목 목록 (모달 왼쪽 패널용) ───
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: pageStr, pageSize: pageSizeStr } = req.query;
const page = Math.max(1, parseInt(pageStr as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pageSizeStr as string) || 20));
const offset = (page - 1) * pageSize;
const conditions = ["company_code = $1"];
const params: any[] = [companyCode];
let idx = 2;
if (keyword) {
conditions.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.join(" AND ");
const pool = getPool();
const countResult = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${whereClause}`, params);
const totalCount = parseInt(countResult.rows[0].total);
const query = `
SELECT
item_number AS item_code, item_name,
COALESCE(size, '') AS spec, COALESCE(material, '') AS material
FROM item_info
WHERE ${whereClause}
ORDER BY item_name
LIMIT $${idx} OFFSET $${idx + 1}
`;
params.push(pageSize, offset);
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows, totalCount, page, pageSize });
} catch (error: any) {
logger.error("품목 소스 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}

View File

@@ -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]);

View File

@@ -0,0 +1,650 @@
/**
* 작업지시 컨트롤러 (work_instruction + work_instruction_detail)
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { numberingRuleService } from "../services/numberingRuleService";
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (companyCode !== "*") {
conditions.push(`wi.company_code = $${idx}`);
params.push(companyCode);
idx++;
}
if (dateFrom) {
conditions.push(`wi.start_date >= $${idx}`);
params.push(dateFrom);
idx++;
}
if (dateTo) {
conditions.push(`wi.end_date <= $${idx}`);
params.push(dateTo);
idx++;
}
if (status && status !== "all") {
conditions.push(`wi.status = $${idx}`);
params.push(status);
idx++;
}
if (progressStatus && progressStatus !== "all") {
conditions.push(`wi.progress_status = $${idx}`);
params.push(progressStatus);
idx++;
}
if (keyword) {
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
wi.id AS wi_id,
wi.work_instruction_no,
wi.status,
wi.progress_status,
wi.qty AS total_qty,
wi.completed_qty,
wi.start_date,
wi.end_date,
wi.equipment_id,
wi.work_team,
wi.worker,
wi.remark AS wi_remark,
wi.created_date,
d.id AS detail_id,
d.item_number,
d.qty AS detail_qty,
d.remark AS detail_remark,
d.part_code,
d.source_table,
d.source_id,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.size, '') AS item_spec,
COALESCE(e.equipment_name, '') AS equipment_name,
COALESCE(e.equipment_code, '') AS equipment_code,
wi.routing AS routing_version_id,
COALESCE(rv.version_name, '') AS routing_name,
ROW_NUMBER() OVER (PARTITION BY wi.work_instruction_no ORDER BY d.created_date) AS detail_seq,
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
FROM work_instruction wi
INNER JOIN work_instruction_detail d
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
LEFT JOIN LATERAL (
SELECT item_name, size FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
LEFT JOIN item_routing_version rv ON wi.routing = rv.id AND rv.company_code = wi.company_code
${whereClause}
ORDER BY wi.created_date DESC, d.created_date ASC
`;
const pool = getPool();
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("작업지시 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 다음 작업지시번호 미리보기 ───
export async function previewNextNo(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
let wiNo: string;
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
if (rule) {
wiNo = await numberingRuleService.previewCode(rule.ruleId, companyCode, {});
} else { throw new Error("채번 규칙 없음"); }
} catch {
const pool = getPool();
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await pool.query(
`SELECT COUNT(*) + 1 AS seq FROM work_instruction WHERE company_code = $1 AND work_instruction_no LIKE $2`,
[companyCode, `WI-${today}-%`]
);
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
}
return res.json({ success: true, instructionNo: wiNo });
} catch (error: any) {
logger.error("작업지시번호 미리보기 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 저장 (신규/수정) ───
export async function save(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
}
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
let wiId: string;
let wiNo: string;
if (editId) {
const check = await client.query(`SELECT id, work_instruction_no FROM work_instruction WHERE id = $1 AND company_code = $2`, [editId, companyCode]);
if (check.rowCount === 0) throw new Error("작업지시를 찾을 수 없습니다");
wiId = editId;
wiNo = check.rows[0].work_instruction_no;
await client.query(
`UPDATE work_instruction SET status=$1, progress_status=$2, reason=$3, start_date=$4, end_date=$5, equipment_id=$6, work_team=$7, worker=$8, remark=$9, routing=$10, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`,
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode]
);
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
} else {
try {
const rule = await numberingRuleService.getNumberingRuleByColumn(companyCode, "work_instruction", "work_instruction_no");
if (rule) { wiNo = await numberingRuleService.allocateCode(rule.ruleId, companyCode, {}); }
else { throw new Error("채번 규칙 없음 - 폴백"); }
} catch {
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
const seqRes = await client.query(`SELECT COUNT(*)+1 AS seq FROM work_instruction WHERE company_code=$1 AND work_instruction_no LIKE $2`, [companyCode, `WI-${today}-%`]);
wiNo = `WI-${today}-${String(seqRes.rows[0].seq).padStart(3, "0")}`;
}
const insertRes = await client.query(
`INSERT INTO work_instruction (id,company_code,work_instruction_no,status,progress_status,reason,start_date,end_date,equipment_id,work_team,worker,remark,routing,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`,
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId]
);
wiId = insertRes.rows[0].id;
}
for (const item of items) {
await client.query(
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
);
}
await client.query("COMMIT");
return res.json({ success: true, data: { id: wiId, workInstructionNo: wiNo } });
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
finally { client.release(); }
} catch (error: any) {
logger.error("작업지시 저장 실패", { error: error.message, stack: error.stack });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 삭제 ───
export async function remove(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { ids } = req.body;
if (!ids || ids.length === 0) return res.status(400).json({ success: false, message: "삭제할 항목을 선택해주세요" });
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
for (const row of wiNos.rows) {
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]);
}
const result = await client.query(`DELETE FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
await client.query("COMMIT");
return res.json({ success: true, deletedCount: result.rowCount });
} catch (txErr) { await client.query("ROLLBACK"); throw txErr; }
finally { client.release(); }
} catch (error: any) {
logger.error("작업지시 삭제 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 품목 소스 (페이징) ───
export async function getItemSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: ps, pageSize: pss } = req.query;
const page = Math.max(1, parseInt(ps as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
const offset = (page - 1) * pageSize;
const conds = ["company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
if (keyword) { conds.push(`(item_number ILIKE $${idx} OR item_name ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
const w = conds.join(" AND ");
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM item_info WHERE ${w}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT id, item_number AS item_code, item_name, COALESCE(size,'') AS spec FROM item_info WHERE ${w} ORDER BY item_name LIMIT $${idx} OFFSET $${idx+1}`, params);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 수주 소스 (페이징) ───
export async function getSalesOrderSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: ps, pageSize: pss } = req.query;
const page = Math.max(1, parseInt(ps as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
const offset = (page - 1) * pageSize;
const conds = ["d.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
if (keyword) { conds.push(`(d.part_code ILIKE $${idx} OR COALESCE(i.item_name, d.part_name, d.part_code) ILIKE $${idx} OR d.order_no ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
const fromClause = `FROM sales_order_detail d LEFT JOIN LATERAL (SELECT item_name FROM item_info WHERE item_number = d.part_code AND company_code = d.company_code LIMIT 1) i ON true WHERE ${conds.join(" AND ")}`;
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total ${fromClause}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT d.id, d.order_no, d.part_code AS item_code, COALESCE(i.item_name, d.part_name, d.part_code) AS item_name, COALESCE(d.spec,'') AS spec, COALESCE(NULLIF(d.qty,'')::numeric,0) AS qty, d.due_date ${fromClause} ORDER BY d.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 생산계획 소스 (페이징) ───
export async function getProductionPlanSource(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { keyword, page: ps, pageSize: pss } = req.query;
const page = Math.max(1, parseInt(ps as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(pss as string) || 20));
const offset = (page - 1) * pageSize;
const conds = ["p.company_code = $1"]; const params: any[] = [companyCode]; let idx = 2;
if (keyword) { conds.push(`(p.plan_no ILIKE $${idx} OR p.item_code ILIKE $${idx} OR COALESCE(p.item_name,'') ILIKE $${idx})`); params.push(`%${keyword}%`); idx++; }
const w = conds.join(" AND ");
const pool = getPool();
const cnt = await pool.query(`SELECT COUNT(*) AS total FROM production_plan_mng p WHERE ${w}`, params);
params.push(pageSize, offset);
const rows = await pool.query(`SELECT p.id, p.plan_no, p.item_code, COALESCE(p.item_name,'') AS item_name, COALESCE(p.plan_qty,0) AS plan_qty, p.start_date, p.end_date, p.status, COALESCE(p.equipment_name,'') AS equipment_name FROM production_plan_mng p WHERE ${w} ORDER BY p.created_date DESC LIMIT $${idx} OFFSET $${idx+1}`, params);
return res.json({ success: true, data: rows.rows, totalCount: parseInt(cnt.rows[0].total), page, pageSize });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 사원 목록 (작업자 Select용) ───
export async function getEmployeeList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
let query: string;
let params: any[];
if (companyCode !== "*") {
query = `SELECT user_id, user_name, dept_name FROM user_info WHERE company_code = $1 AND company_code != '*' ORDER BY user_name`;
params = [companyCode];
} else {
query = `SELECT user_id, user_name, dept_name, company_code FROM user_info WHERE company_code != '*' ORDER BY user_name`;
params = [];
}
const result = await pool.query(query, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) {
logger.error("사원 목록 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 설비 목록 (Select용) ───
export async function getEquipmentList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const pool = getPool();
const cond = companyCode !== "*" ? "WHERE company_code = $1" : "";
const params = companyCode !== "*" ? [companyCode] : [];
const result = await pool.query(`SELECT id, equipment_code, equipment_name FROM equipment_mng ${cond} ORDER BY equipment_name`, params);
return res.json({ success: true, data: result.rows });
} catch (error: any) { return res.status(500).json({ success: false, message: error.message }); }
}
// ─── 품목의 라우팅 버전 + 공정 조회 ───
export async function getRoutingVersions(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { itemCode } = req.params;
const pool = getPool();
const versionsResult = await pool.query(
`SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default
FROM item_routing_version
WHERE item_code = $1 AND company_code = $2
ORDER BY is_default DESC, created_date DESC`,
[itemCode, companyCode]
);
const routings = [];
for (const version of versionsResult.rows) {
const detailsResult = await pool.query(
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
rd.is_required, rd.work_type,
COALESCE(p.process_name, rd.process_code) AS process_name
FROM item_routing_detail rd
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY rd.seq_no::integer`,
[version.id, companyCode]
);
routings.push({ ...version, processes: detailsResult.rows });
}
return res.json({ success: true, data: routings });
} catch (error: any) {
logger.error("라우팅 버전 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 라우팅 변경 ───
export async function updateRouting(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const { routingVersionId } = req.body;
const pool = getPool();
await pool.query(
`UPDATE work_instruction SET routing = $1, updated_date = NOW() WHERE work_instruction_no = $2 AND company_code = $3`,
[routingVersionId || null, wiNo, companyCode]
);
return res.json({ success: true });
} catch (error: any) {
logger.error("라우팅 변경 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 전용 공정작업기준 조회 ───
export async function getWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const { routingVersionId } = req.query;
const pool = getPool();
if (!routingVersionId) {
return res.status(400).json({ success: false, message: "routingVersionId 필요" });
}
// 라우팅 디테일(공정) 목록 조회
const processesResult = await pool.query(
`SELECT rd.id AS routing_detail_id, rd.seq_no, rd.process_code,
COALESCE(p.process_name, rd.process_code) AS process_name
FROM item_routing_detail rd
LEFT JOIN process_mng p ON p.process_code = rd.process_code AND p.company_code = rd.company_code
WHERE rd.routing_version_id = $1 AND rd.company_code = $2
ORDER BY rd.seq_no::integer`,
[routingVersionId, companyCode]
);
// 커스텀 작업기준이 있는지 확인
const customCheck = await pool.query(
`SELECT COUNT(*) AS cnt FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
const hasCustom = parseInt(customCheck.rows[0].cnt) > 0;
const processes = [];
for (const proc of processesResult.rows) {
let workItems;
if (hasCustom) {
// 커스텀 버전에서 조회
const wiResult = await pool.query(
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
(SELECT COUNT(*) FROM wi_process_work_item_detail d WHERE d.wi_work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
FROM wi_process_work_item wi
WHERE wi.work_instruction_no = $1 AND wi.routing_detail_id = $2 AND wi.company_code = $3
ORDER BY wi.work_phase, wi.sort_order`,
[wiNo, proc.routing_detail_id, companyCode]
);
workItems = wiResult.rows;
// 각 work_item의 상세도 로드
for (const wi of workItems) {
const detailsResult = await pool.query(
`SELECT id, wi_work_item_id AS work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields
FROM wi_process_work_item_detail
WHERE wi_work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
[wi.id, companyCode]
);
wi.details = detailsResult.rows;
}
} else {
// 원본에서 조회
const origResult = await pool.query(
`SELECT wi.id, wi.routing_detail_id, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description,
(SELECT COUNT(*) FROM process_work_item_detail d WHERE d.work_item_id = wi.id AND d.company_code = wi.company_code)::integer AS detail_count
FROM process_work_item wi
WHERE wi.routing_detail_id = $1 AND wi.company_code = $2
ORDER BY wi.work_phase, wi.sort_order`,
[proc.routing_detail_id, companyCode]
);
workItems = origResult.rows;
for (const wi of workItems) {
const detailsResult = await pool.query(
`SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
duration_minutes, input_type, lookup_target, display_fields
FROM process_work_item_detail
WHERE work_item_id = $1 AND company_code = $2
ORDER BY sort_order`,
[wi.id, companyCode]
);
wi.details = detailsResult.rows;
}
}
processes.push({
...proc,
workItems,
});
}
return res.json({ success: true, data: { processes, isCustom: hasCustom } });
} catch (error: any) {
logger.error("작업지시 공정작업기준 조회 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 원본 공정작업기준 -> 작업지시 전용 복사 ───
export async function copyWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { wiNo } = req.params;
const { routingVersionId } = req.body;
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 기존 커스텀 데이터 삭제
const existingItems = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
for (const row of existingItems.rows) {
await client.query(
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
[row.id, companyCode]
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
// 라우팅 디테일 목록 조회
const routingDetails = await client.query(
`SELECT id FROM item_routing_detail WHERE routing_version_id = $1 AND company_code = $2`,
[routingVersionId, companyCode]
);
// 각 공정(routing_detail)별 원본 작업항목 복사
for (const rd of routingDetails.rows) {
const origItems = await client.query(
`SELECT * FROM process_work_item WHERE routing_detail_id = $1 AND company_code = $2`,
[rd.id, companyCode]
);
for (const origItem of origItems.rows) {
const newItemResult = await client.query(
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[companyCode, wiNo, rd.id, origItem.work_phase, origItem.title, origItem.is_required, origItem.sort_order, origItem.description, origItem.id, userId]
);
const newItemId = newItemResult.rows[0].id;
// 상세 복사
const origDetails = await client.query(
`SELECT * FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2`,
[origItem.id, companyCode]
);
for (const origDetail of origDetails.rows) {
await client.query(
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[companyCode, newItemId, origDetail.detail_type, origDetail.content, origDetail.is_required, origDetail.sort_order, origDetail.remark, origDetail.inspection_code, origDetail.inspection_method, origDetail.unit, origDetail.lower_limit, origDetail.upper_limit, origDetail.duration_minutes, origDetail.input_type, origDetail.lookup_target, origDetail.display_fields, userId]
);
}
}
}
await client.query("COMMIT");
logger.info("공정작업기준 복사 완료", { companyCode, wiNo, routingVersionId });
return res.json({ success: true });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("공정작업기준 복사 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 전용 공정작업기준 저장 (일괄) ───
export async function saveWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { wiNo } = req.params;
const { routingDetailId, workItems } = req.body;
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// 해당 공정의 기존 커스텀 데이터 삭제
const existing = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
[wiNo, routingDetailId, companyCode]
);
for (const row of existing.rows) {
await client.query(
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
[row.id, companyCode]
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND routing_detail_id = $2 AND company_code = $3`,
[wiNo, routingDetailId, companyCode]
);
// 새 데이터 삽입
for (const wi of workItems) {
const wiResult = await client.query(
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[companyCode, wiNo, routingDetailId, wi.work_phase, wi.title, wi.is_required, wi.sort_order, wi.description || null, wi.source_work_item_id || null, userId]
);
const newId = wiResult.rows[0].id;
if (wi.details && Array.isArray(wi.details)) {
for (const d of wi.details) {
await client.query(
`INSERT INTO wi_process_work_item_detail (company_code, wi_work_item_id, detail_type, content, is_required, sort_order, remark, inspection_code, inspection_method, unit, lower_limit, upper_limit, duration_minutes, input_type, lookup_target, display_fields, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[companyCode, newId, d.detail_type, d.content, d.is_required, d.sort_order, d.remark || null, d.inspection_code || null, d.inspection_method || null, d.unit || null, d.lower_limit || null, d.upper_limit || null, d.duration_minutes || null, d.input_type || null, d.lookup_target || null, d.display_fields || null, userId]
);
}
}
}
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 저장 완료", { companyCode, wiNo, routingDetailId });
return res.json({ success: true });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("작업지시 공정작업기준 저장 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}
// ─── 작업지시 전용 커스텀 데이터 삭제 (원본으로 초기화) ───
export async function resetWorkStandard(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { wiNo } = req.params;
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const items = await client.query(
`SELECT id FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
for (const row of items.rows) {
await client.query(
`DELETE FROM wi_process_work_item_detail WHERE wi_work_item_id = $1 AND company_code = $2`,
[row.id, companyCode]
);
}
await client.query(
`DELETE FROM wi_process_work_item WHERE work_instruction_no = $1 AND company_code = $2`,
[wiNo, companyCode]
);
await client.query("COMMIT");
logger.info("작업지시 공정작업기준 초기화", { companyCode, wiNo });
return res.json({ success: true });
} catch (txErr) {
await client.query("ROLLBACK");
throw txErr;
} finally {
client.release();
}
} catch (error: any) {
logger.error("작업지시 공정작업기준 초기화 실패", { error: error.message });
return res.status(500).json({ success: false, message: error.message });
}
}