feat: implement routing and work standard management features

- Added new API endpoints for retrieving routing versions and managing work standards associated with work instructions.
- Implemented functionality to update routing versions for work instructions, enhancing the flexibility of the work instruction management process.
- Introduced a new modal for editing work standards, allowing users to manage detailed work items and processes effectively.
- Updated frontend components to integrate routing and work standard functionalities, improving user experience and data management.

These changes aim to enhance the management of work instructions and their associated processes, facilitating better tracking and organization within the application.
This commit is contained in:
kjs
2026-03-20 14:18:44 +09:00
parent 199fa60ef5
commit a5eba3a4ca
6 changed files with 1120 additions and 16 deletions

View File

@@ -76,6 +76,8 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
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) AS detail_seq,
COUNT(*) OVER (PARTITION BY wi.work_instruction_no) AS detail_count
FROM work_instruction wi
@@ -86,6 +88,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
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, d.created_date ASC
`;
@@ -130,7 +133,7 @@ export async function save(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items } = req.body;
const { id: editId, status: wiStatus, progressStatus, reason, startDate, endDate, equipmentId, workTeam, worker, remark, items, routing: routingVersionId } = req.body;
if (!items || items.length === 0) {
return res.status(400).json({ success: false, message: "품목을 선택해주세요" });
@@ -149,8 +152,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
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, updated_date=NOW(), writer=$10 WHERE id=$11 AND company_code=$12`,
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId, editId, companyCode]
`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, updated_date=NOW(), writer=$11 WHERE id=$12 AND company_code=$13`,
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId, editId, companyCode]
);
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
} else {
@@ -164,8 +167,8 @@ export async function save(req: AuthenticatedRequest, res: Response) {
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,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,NOW(),$12) RETURNING id`,
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId]
`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,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,NOW(),$13) RETURNING id`,
[companyCode, wiNo, wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", routingVersionId||null, userId]
);
wiId = insertRes.rows[0].id;
}
@@ -306,3 +309,342 @@ export async function getEquipmentList(req: AuthenticatedRequest, res: Response)
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 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
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
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 (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
VALUES ($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 (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, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[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, 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]
);
// 새 데이터 삽입
for (const wi of workItems) {
const wiResult = await client.query(
`INSERT INTO wi_process_work_item (company_code, work_instruction_no, routing_detail_id, work_phase, title, is_required, sort_order, description, source_work_item_id, writer)
VALUES ($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 (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, writer)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
[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, 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 });
}
}

View File

@@ -15,4 +15,12 @@ router.get("/source/production-plan", ctrl.getProductionPlanSource);
router.get("/equipment", ctrl.getEquipmentList);
router.get("/employees", ctrl.getEmployeeList);
// 라우팅 & 공정작업기준
router.get("/:wiNo/routing-versions/:itemCode", ctrl.getRoutingVersions);
router.put("/:wiNo/routing", ctrl.updateRouting);
router.get("/:wiNo/work-standard", ctrl.getWorkStandard);
router.post("/:wiNo/work-standard/copy", ctrl.copyWorkStandard);
router.put("/:wiNo/work-standard/save", ctrl.saveWorkStandard);
router.delete("/:wiNo/work-standard/reset", ctrl.resetWorkStandard);
export default router;