feat: add work instruction management features

- Introduced new routes and controllers for managing work instructions, including functionalities for listing, saving, and previewing work instruction numbers.
- Implemented company code filtering for multi-tenancy support, ensuring that users can only access their respective work instructions.
- Added a new Work Instruction page in the frontend for comprehensive management, including search and registration functionalities.

These changes aim to enhance the management of work instructions, facilitating better tracking and organization within the application.
This commit is contained in:
kjs
2026-03-19 17:52:17 +09:00
parent c81594b137
commit 460757e3a0
6 changed files with 1028 additions and 0 deletions

View File

@@ -0,0 +1,308 @@
/**
* 작업지시 컨트롤러 (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";
// ─── 작업지시 목록 조회 (detail 기준 행 반환) ───
export async function getList(req: AuthenticatedRequest, res: Response) {
try {
const companyCode = req.user!.companyCode;
const { dateFrom, dateTo, status, progressStatus, keyword } = req.query;
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++;
}
if (keyword) {
conditions.push(`(wi.work_instruction_no ILIKE $${idx} OR wi.worker ILIKE $${idx} OR COALESCE(itm.item_name,'') ILIKE $${idx} OR COALESCE(d.item_number,'') ILIKE $${idx})`);
params.push(`%${keyword}%`);
idx++;
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
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,
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,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.size, '') AS item_spec,
COALESCE(e.equipment_name, '') AS equipment_name,
COALESCE(e.equipment_code, '') AS equipment_code,
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
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, size FROM item_info
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
${whereClause}
ORDER BY wi.created_date DESC, d.created_date ASC
`;
const pool = getPool();
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 {
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;
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, updated_date=NOW(), writer=$10 WHERE id=$11 AND company_code=$12`,
[wiStatus||"일반", progressStatus||"", reason||"", startDate||"", endDate||"", equipmentId||"", workTeam||"", worker||"", remark||"", userId, editId, companyCode]
);
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [wiNo, companyCode]);
} 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,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]
);
wiId = insertRes.rows[0].id;
}
for (const item of items) {
await client.query(
`INSERT INTO work_instruction_detail (id,company_code,work_instruction_no,item_number,qty,remark,source_table,source_id,part_code,created_date,writer) VALUES (gen_random_uuid()::text,$1,$2,$3,$4,$5,$6,$7,$8,NOW(),$9)`,
[companyCode, wiNo, item.itemNumber||item.itemCode||"", item.qty||"0", item.remark||"", item.sourceTable||"", item.sourceId||"", item.partCode||item.itemNumber||item.itemCode||"", userId]
);
}
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");
const wiNos = await client.query(`SELECT work_instruction_no FROM work_instruction WHERE id=ANY($1) AND company_code=$2`, [ids, companyCode]);
for (const row of wiNos.rows) {
await client.query(`DELETE FROM work_instruction_detail WHERE work_instruction_no=$1 AND company_code=$2`, [row.work_instruction_no, companyCode]);
}
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);
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 FROM production_plan_mng p 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 }); }
}