- Introduced a new cutting plan management page for COMPANY_9, allowing users to manage cutting plans effectively. - Added a Work Instruction Apply Modal to facilitate the application of work instructions linked to cutting plans. - Enhanced data handling by incorporating additional fields such as condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, and condition_plc_data in relevant controllers and database interactions. - Updated UI components to support new features, including displaying batch numbers and item sizes in the work instruction page. These changes aim to improve the efficiency and usability of cutting plan and work instruction management processes.
906 lines
42 KiB
TypeScript
906 lines
42 KiB
TypeScript
/**
|
|
* 작업지시 컨트롤러 (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";
|
|
|
|
// 자동 마이그레이션: work_instruction_detail에 routing_version_id + 품목별 일정/설비/작업조/작업자 컬럼 추가
|
|
let _migrationDone = false;
|
|
async function ensureDetailRoutingColumn() {
|
|
if (_migrationDone) return;
|
|
try {
|
|
const pool = getPool();
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS routing_version_id VARCHAR(500)");
|
|
// 품목별 일정/설비/작업조/작업자 컬럼 (옵션 A — 다중선택 지원)
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS start_date VARCHAR(500)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS end_date VARCHAR(500)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS equipment_ids VARCHAR(1000)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS work_teams VARCHAR(200)");
|
|
await pool.query("ALTER TABLE work_instruction_detail ADD COLUMN IF NOT EXISTS workers VARCHAR(1000)");
|
|
_migrationDone = true;
|
|
} catch { /* 이미 존재하거나 권한 문제 시 무시 */ }
|
|
}
|
|
|
|
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
|
|
export async function getList(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
await ensureDetailRoutingColumn();
|
|
const companyCode = req.user!.companyCode;
|
|
const { dateFrom, dateTo, status, progressStatus, keyword, page, pageSize } = req.query;
|
|
|
|
// 페이지네이션 파라미터 파싱 (page 없으면 전체 반환 — 하위호환)
|
|
const pageNum = page ? Math.max(1, parseInt(page as string, 10) || 1) : null;
|
|
const sizeNum = pageSize ? Math.max(1, Math.min(1000, parseInt(pageSize as string, 10) || 20)) : null;
|
|
const paginated = pageNum !== null && sizeNum !== null;
|
|
|
|
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++;
|
|
}
|
|
// keyword 검색: wi 자체 필드 + detail.item_number 존재 여부로 EXISTS
|
|
if (keyword) {
|
|
conditions.push(`(
|
|
wi.work_instruction_no ILIKE $${idx}
|
|
OR wi.worker ILIKE $${idx}
|
|
OR EXISTS (
|
|
SELECT 1 FROM work_instruction_detail dd
|
|
LEFT JOIN item_info ii ON ii.item_number = dd.item_number AND ii.company_code = wi.company_code
|
|
WHERE dd.work_instruction_id = wi.id
|
|
AND (dd.item_number ILIKE $${idx} OR COALESCE(ii.item_name,'') ILIKE $${idx})
|
|
)
|
|
)`);
|
|
params.push(`%${keyword}%`);
|
|
idx++;
|
|
}
|
|
|
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
|
|
const pool = getPool();
|
|
|
|
// 페이지네이션 모드: WI 단위로 페이지 잘라낸 뒤 detail과 JOIN
|
|
if (paginated) {
|
|
// 1) 총 WI 개수 카운트
|
|
const countSql = `
|
|
SELECT COUNT(*)::int AS cnt
|
|
FROM work_instruction wi
|
|
${whereClause}
|
|
`;
|
|
const countRes = await pool.query(countSql, params);
|
|
const totalCount = countRes.rows[0]?.cnt ?? 0;
|
|
|
|
// 2) 현재 페이지 WI id 목록 (최신 생성순, 동일 created_date일 때 번호 내림차순)
|
|
const offset = (pageNum! - 1) * sizeNum!;
|
|
const pageSql = `
|
|
SELECT wi.id
|
|
FROM work_instruction wi
|
|
${whereClause}
|
|
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC
|
|
LIMIT ${sizeNum} OFFSET ${offset}
|
|
`;
|
|
const pageRes = await pool.query(pageSql, params);
|
|
const wiIds = pageRes.rows.map((r) => r.id);
|
|
|
|
if (wiIds.length === 0) {
|
|
return res.json({ success: true, data: [], totalCount, page: pageNum, pageSize: sizeNum });
|
|
}
|
|
|
|
// 3) 해당 WI들의 detail + 품목/설비/라우팅 JOIN
|
|
const dataSql = `
|
|
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,
|
|
wi.batch_no,
|
|
wi.cutting_plan_id,
|
|
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,
|
|
d.routing_version_id AS detail_routing_version_id,
|
|
d.start_date AS detail_start_date,
|
|
d.end_date AS detail_end_date,
|
|
d.equipment_ids AS detail_equipment_ids,
|
|
d.work_teams AS detail_work_teams,
|
|
d.workers AS detail_workers,
|
|
COALESCE(itm.item_name, '') AS item_name,
|
|
COALESCE(itm.type, '') AS item_type,
|
|
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, d.id) 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_id = wi.id
|
|
LEFT JOIN item_info itm
|
|
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
|
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
|
|
WHERE wi.id = ANY($1::varchar[])
|
|
ORDER BY wi.created_date DESC NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC
|
|
`;
|
|
const dataRes = await pool.query(dataSql, [wiIds]);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: dataRes.rows,
|
|
totalCount,
|
|
page: pageNum,
|
|
pageSize: sizeNum,
|
|
});
|
|
}
|
|
|
|
// 비페이지 모드 (하위호환): 기존 방식 유지, LATERAL만 LEFT JOIN으로 교체
|
|
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,
|
|
wi.batch_no,
|
|
wi.cutting_plan_id,
|
|
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,
|
|
d.routing_version_id AS detail_routing_version_id,
|
|
d.start_date AS detail_start_date,
|
|
d.end_date AS detail_end_date,
|
|
d.equipment_ids AS detail_equipment_ids,
|
|
d.work_teams AS detail_work_teams,
|
|
d.workers AS detail_workers,
|
|
COALESCE(itm.item_name, '') AS item_name,
|
|
COALESCE(itm.type, '') AS item_type,
|
|
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, d.id) 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_id = wi.id
|
|
LEFT JOIN item_info itm
|
|
ON itm.item_number = d.item_number AND itm.company_code = wi.company_code
|
|
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 NULLS LAST, wi.work_instruction_no DESC, d.created_date ASC, d.id ASC
|
|
`;
|
|
|
|
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 {
|
|
await ensureDetailRoutingColumn();
|
|
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, batchNo, cuttingPlanId } = 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, batch_no=COALESCE($11, batch_no), cutting_plan_id=COALESCE($12, cutting_plan_id), updated_date=NOW(), writer=$13 WHERE id=$14 AND company_code=$15`,
|
|
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId, editId, companyCode]
|
|
);
|
|
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=$1`, [wiId]);
|
|
} 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,batch_no,cutting_plan_id,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,NOW(),$15) RETURNING id`,
|
|
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, batchNo||null, cuttingPlanId||null, userId]
|
|
);
|
|
wiId = insertRes.rows[0].id;
|
|
}
|
|
|
|
let totalQty = 0;
|
|
let firstRouting: string | null = null;
|
|
for (const item of items) {
|
|
const itemRouting = item.routing || null;
|
|
if (!firstRouting && itemRouting) firstRouting = itemRouting;
|
|
totalQty += Number(item.qty || 0);
|
|
await client.query(
|
|
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,work_instruction_id,item_number,qty,remark,source_table,source_id,part_code,routing_version_id,start_date,end_date,equipment_ids,work_teams,workers,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,NOW(),$16)`,
|
|
[
|
|
companyCode,
|
|
wiNo,
|
|
wiId,
|
|
item.itemNumber||item.itemCode||"",
|
|
item.qty||"0",
|
|
item.remark||"",
|
|
item.sourceTable||"",
|
|
item.sourceId||"",
|
|
item.partCode||item.itemNumber||item.itemCode||"",
|
|
itemRouting,
|
|
item.startDate||"",
|
|
item.endDate||"",
|
|
item.equipmentIds||"",
|
|
item.workTeams||"",
|
|
item.workers||"",
|
|
userId,
|
|
]
|
|
);
|
|
}
|
|
|
|
// 마스터 qty/routing 자동 동기화 (디테일 합계 + 첫 번째 라우팅)
|
|
const effectiveRouting = routingVersionId || firstRouting;
|
|
await client.query(
|
|
`UPDATE work_instruction SET qty = $1, routing = COALESCE(routing, $2) WHERE id = $3`,
|
|
[String(totalQty), effectiveRouting, wiId]
|
|
);
|
|
|
|
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");
|
|
// 디테일 삭제 (id 기반)
|
|
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_id=ANY($1)`, [ids]);
|
|
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);
|
|
// work_instruction_detail에서 해당 계획에 이미 내린 작업지시 수량 합계 → applied_qty, remain_qty
|
|
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,
|
|
COALESCE(wi.applied_qty, 0) AS applied_qty,
|
|
(COALESCE(CAST(NULLIF(p.plan_qty::text, '') AS numeric), 0)
|
|
- COALESCE(wi.applied_qty, 0)) AS remain_qty
|
|
FROM production_plan_mng p
|
|
LEFT JOIN (
|
|
SELECT source_id,
|
|
SUM(COALESCE(CAST(NULLIF(qty, '') AS numeric), 0)) AS applied_qty
|
|
FROM work_instruction_detail
|
|
WHERE source_table = 'production_plan_mng'
|
|
AND company_code = $1
|
|
GROUP BY source_id
|
|
) wi ON wi.source_id = p.id::text
|
|
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 getRoutingVersionsBulk(req: AuthenticatedRequest, res: Response) {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { itemCodes } = req.body as { itemCodes: string[] };
|
|
|
|
if (!itemCodes || !Array.isArray(itemCodes) || itemCodes.length === 0) {
|
|
return res.json({ success: true, data: {} });
|
|
}
|
|
|
|
const pool = getPool();
|
|
const result: Record<string, { code: string; name: string }[]> = {};
|
|
|
|
// 청크 단위로 분할 (PostgreSQL placeholder 제한 대응)
|
|
const CHUNK_SIZE = 5000;
|
|
for (let ci = 0; ci < itemCodes.length; ci += CHUNK_SIZE) {
|
|
const chunk = itemCodes.slice(ci, ci + CHUNK_SIZE);
|
|
|
|
// 1. 기본 라우팅 버전 조회
|
|
const placeholders = chunk.map((_, i) => `$${i + 2}`).join(",");
|
|
const versionsResult = await pool.query(
|
|
`SELECT DISTINCT ON (item_code) id, item_code, version_name
|
|
FROM item_routing_version
|
|
WHERE company_code = $1 AND item_code IN (${placeholders})
|
|
ORDER BY item_code, is_default DESC, created_date DESC`,
|
|
[companyCode, ...chunk]
|
|
);
|
|
|
|
if (versionsResult.rows.length === 0) continue;
|
|
|
|
// 2. 라우팅 디테일 조회
|
|
const versionIds = versionsResult.rows.map((v: any) => v.id);
|
|
const vPlaceholders = versionIds.map((_: any, i: number) => `$${i + 2}`).join(",");
|
|
const detailsResult = await pool.query(
|
|
`SELECT rd.routing_version_id, 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.company_code = $1 AND rd.routing_version_id IN (${vPlaceholders})
|
|
ORDER BY rd.seq_no::integer`,
|
|
[companyCode, ...versionIds]
|
|
);
|
|
|
|
// 3. 매핑
|
|
const versionToItem: Record<string, string> = {};
|
|
for (const v of versionsResult.rows) {
|
|
versionToItem[v.id] = v.item_code;
|
|
}
|
|
for (const d of detailsResult.rows) {
|
|
const itemCode = versionToItem[d.routing_version_id];
|
|
if (!itemCode) continue;
|
|
if (!result[itemCode]) result[itemCode] = [];
|
|
result[itemCode].push({ code: d.process_code, name: d.process_name });
|
|
}
|
|
}
|
|
|
|
return res.json({ success: true, data: result });
|
|
} 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,
|
|
process_inspection_apply, equip_inspection_apply,
|
|
condition_unit, condition_base_value, condition_tolerance,
|
|
condition_auto_collect, condition_plc_data
|
|
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,
|
|
process_inspection_apply, equip_inspection_apply,
|
|
condition_unit, condition_base_value, condition_tolerance,
|
|
condition_auto_collect, condition_plc_data
|
|
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 (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
|
VALUES (gen_random_uuid()::text, $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 (id, 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, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
|
|
[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, origDetail.process_inspection_apply || null, origDetail.equip_inspection_apply || null, origDetail.condition_unit || null, origDetail.condition_base_value || null, origDetail.condition_tolerance || null, origDetail.condition_auto_collect || null, origDetail.condition_plc_data || null, 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]
|
|
);
|
|
|
|
// 새 데이터 삽입
|
|
// NOTE: wi_process_work_item / wi_process_work_item_detail.id 컬럼에 DEFAULT(gen_random_uuid()) 누락
|
|
// → id를 명시하지 않으면 NULL 저장되어 재조회 시 wi_work_item_id 매칭 실패(0건 반환)로 이어짐.
|
|
// 원본 테이블(process_work_item) DEFAULT와 동기되지 않은 스키마 이슈. 여기서 명시 바인딩으로 회피.
|
|
for (const wi of workItems) {
|
|
const wiResult = await client.query(
|
|
`INSERT INTO wi_process_work_item (id, company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
|
|
VALUES (gen_random_uuid()::text, $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 (id, 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, process_inspection_apply, equip_inspection_apply, condition_unit, condition_base_value, condition_tolerance, condition_auto_collect, condition_plc_data, writer)
|
|
VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
|
|
[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, d.process_inspection_apply || null, d.equip_inspection_apply || null, d.condition_unit || null, d.condition_base_value || null, d.condition_tolerance || null, d.condition_auto_collect || null, d.condition_plc_data || 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 });
|
|
}
|
|
}
|