- 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>
363 lines
11 KiB
TypeScript
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 });
|
|
}
|
|
}
|