This commit is contained in:
DDD1542
2026-03-19 17:18:14 +09:00
parent 8c946312fe
commit 5715e67ba9
13 changed files with 2744 additions and 0 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

@@ -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,
});
}
}