- Added a new KPI controller to handle daily production data retrieval. - Created routes for accessing KPI data, specifically for daily production. - Developed frontend components for displaying daily production metrics, including charts and summary cards. - Implemented data fetching logic with date range filtering for production data. - Ensured proper loading states and error handling in the UI. This feature is part of TASK:ERP-022.
542 lines
21 KiB
TypeScript
542 lines
21 KiB
TypeScript
/**
|
|
* 공정정보관리 컨트롤러
|
|
* - 공정 마스터 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) {
|
|
// "Y" → "USE_Y"도 매칭, "N" → "USE_N"도 매칭
|
|
const useYnValue = String(useYn);
|
|
if (useYnValue === "Y" || useYnValue === "N") {
|
|
conditions.push(`use_yn IN ($${idx++}, $${idx++})`);
|
|
params.push(useYnValue, `USE_${useYnValue}`);
|
|
} else {
|
|
conditions.push(`use_yn = $${idx++}`);
|
|
params.push(useYnValue);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
// equipment_code 컬럼에 코드(legacy) 또는 id(신규)가 들어올 수 있어 두 경우 모두 매칭
|
|
const result = await pool.query(
|
|
`SELECT pe.*, em.equipment_name
|
|
FROM process_equipment pe
|
|
LEFT JOIN equipment_mng em
|
|
ON pe.company_code = em.company_code
|
|
AND (pe.equipment_code = em.equipment_code OR pe.equipment_code = em.id)
|
|
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_mng ${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]
|
|
);
|
|
|
|
const rows = result.rows;
|
|
const detailIds = rows.map((r: any) => r.id).filter(Boolean);
|
|
let idsByDetail: Record<string, string[]> = {};
|
|
let codesByDetail: Record<string, string[]> = {};
|
|
if (detailIds.length > 0) {
|
|
const mapRes = await pool.query(
|
|
`SELECT irs.routing_detail_id, irs.subcontractor_id, sm.subcontractor_code
|
|
FROM item_routing_subcontractor irs
|
|
LEFT JOIN subcontractor_mng sm ON irs.subcontractor_id = sm.id
|
|
WHERE irs.routing_detail_id = ANY($1::varchar[])
|
|
ORDER BY irs.seq_order`,
|
|
[detailIds]
|
|
);
|
|
for (const m of mapRes.rows) {
|
|
const key = String(m.routing_detail_id);
|
|
(idsByDetail[key] ||= []).push(m.subcontractor_id);
|
|
if (m.subcontractor_code) (codesByDetail[key] ||= []).push(m.subcontractor_code);
|
|
}
|
|
}
|
|
const enriched = rows.map((r: any) => {
|
|
const ids = idsByDetail[String(r.id)] || [];
|
|
const codes = codesByDetail[String(r.id)] || [];
|
|
// 레거시 폴백: 매핑이 비어있고 legacy 단일 컬럼(code)에 값이 있으면 code 배열로 반환
|
|
const legacyCodes = ids.length === 0 && r.outsource_supplier ? [r.outsource_supplier] : codes;
|
|
return {
|
|
...r,
|
|
outsource_supplier_ids: ids,
|
|
outsource_supplier_list: legacyCodes, // 하위호환 별칭 (code 배열)
|
|
};
|
|
});
|
|
|
|
return res.json({ success: true, data: enriched });
|
|
} 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_subcontractor
|
|
WHERE routing_detail_id IN (
|
|
SELECT id FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2
|
|
)`,
|
|
[versionId, companyCode]
|
|
);
|
|
|
|
// 기존 상세 삭제 후 재입력
|
|
await client.query(
|
|
`DELETE FROM item_routing_detail WHERE routing_version_id=$1 AND company_code=$2`,
|
|
[versionId, companyCode]
|
|
);
|
|
|
|
for (const d of details) {
|
|
const supplierIds: string[] = Array.isArray(d.outsource_supplier_ids)
|
|
? d.outsource_supplier_ids.filter((s: any) => typeof s === "string" && s.trim() !== "")
|
|
: [];
|
|
|
|
// legacy code 해석: 첫 번째 subcontractor_id → subcontractor_code 조회
|
|
let legacyCode = "";
|
|
if (supplierIds.length > 0) {
|
|
const codeRes = await client.query(
|
|
`SELECT subcontractor_code FROM subcontractor_mng WHERE id=$1 LIMIT 1`,
|
|
[supplierIds[0]]
|
|
);
|
|
legacyCode = codeRes.rows[0]?.subcontractor_code || "";
|
|
} else if (d.outsource_supplier) {
|
|
// 프론트가 아직 id 없이 code만 보낸 경우(레거시 호환)
|
|
legacyCode = d.outsource_supplier;
|
|
}
|
|
|
|
const insertRes = 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, execution_type, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
|
RETURNING id`,
|
|
[companyCode, versionId, d.seq_no, d.process_code, d.is_required || "Y", d.is_fixed_order || "Y", d.work_type || "내부", d.standard_time || "0", legacyCode, d.execution_type || null, writer]
|
|
);
|
|
const newDetailId = insertRes.rows[0].id;
|
|
|
|
for (let i = 0; i < supplierIds.length; i++) {
|
|
await client.query(
|
|
// 본서버 id 컬럼이 uuid 타입, 개발서버는 varchar — ::text 캐스팅하면 본서버에서 타입 불일치 오류 발생
|
|
`INSERT INTO item_routing_subcontractor (id, company_code, routing_detail_id, subcontractor_id, seq_order)
|
|
VALUES (gen_random_uuid(), $1, $2, $3, $4)`,
|
|
[companyCode, newDetailId, supplierIds[i], i]
|
|
);
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|