Files
vexplor/backend-node/src/controllers/materialStatusController.ts
kmh 4e4088eb71 feat: 입고/자재현황/분석리포트 컨트롤러 및 프론트엔드 개선
- receivingController: 헤더-디테일 JOIN 구조로 변경, 검색/조회 로직 개선
- materialStatusController: work_instruction 테이블 기반으로 쿼리 수정
- analyticsReportController: 구매 리포트 company_code 필터링 로직 개선
- material-status 페이지: COMPANY_29/COMPANY_7 프론트엔드 업데이트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:01:26 +09:00

363 lines
11 KiB
TypeScript

/**
* 자재현황 컨트롤러
* - 작업지시(work_instruction + work_instruction_detail) 조회
* - 선택된 작업지시의 BOM 기반 자재소요량 + 재고 현황 조회
* - 창고 목록 조회
*/
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { pool } from "../database/db";
import { logger } from "../utils/logger";
// ─── 작업지시 조회 (work_instruction + work_instruction_detail) ───
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(`wi.company_code = $${paramIndex}`);
params.push(companyCode);
paramIndex++;
}
if (dateFrom) {
conditions.push(`wi.start_date::date >= $${paramIndex}::date`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
conditions.push(`wi.start_date::date <= $${paramIndex}::date`);
params.push(dateTo);
paramIndex++;
}
if (itemCode) {
conditions.push(`d.item_number ILIKE $${paramIndex}`);
params.push(`%${itemCode}%`);
paramIndex++;
}
if (itemName) {
conditions.push(`COALESCE(itm.item_name, '') ILIKE $${paramIndex}`);
params.push(`%${itemName}%`);
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const query = `
SELECT
d.id,
wi.work_instruction_no AS plan_no,
d.item_number AS item_code,
COALESCE(itm.item_name, '') AS item_name,
d.qty AS plan_qty,
wi.completed_qty,
wi.start_date AS plan_date,
wi.start_date,
wi.end_date,
wi.status,
wi.work_instruction_no AS work_order_no,
wi.company_code
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 FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
${whereClause}
ORDER BY wi.start_date DESC, wi.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 d.company_code = $${paramIndex}`;
const planParams: any[] = [...planIds];
if (companyCode !== "*") {
planParams.push(companyCode);
paramIndex++;
}
const planQuery = `
SELECT d.item_number AS item_code, COALESCE(itm.item_name, '') AS item_name, d.qty AS plan_qty
FROM work_instruction_detail d
LEFT JOIN LATERAL (
SELECT item_name FROM item_info
WHERE item_number = d.item_number AND company_code = d.company_code LIMIT 1
) itm ON true
WHERE d.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 });
}
}