품질관리 4메뉴 — wace_plm JSP + quality.xml MyBatis 1:1 재정합
Build and Push Images / build-and-push (push) Has been cancelled

수입검사 요청 (incoming-request):
 - 필터 12종 (품의서/발주서/프로젝트/품번/품명/공급업체/입고결과/제품구분/
   검사여부/요청현황/요청자/요청일범위)
 - 그리드 12컬럼 (proposal_no, purchase_order_no, project_no, product_name,
   part_no, part_name, partner_name, delivery_status, request_date,
   request_user_name, inspection_yn, request_status)
 - 백엔드: purchase_order_master + incoming_inspection_detail LEFT JOIN
   + sales_request_master + contract_mgmt + part_mng + user_info

수입검사 진행 (incoming-mgmt):
 - 필터 11종 (수입요청과 유사 + 검사일범위 + 검사현황)
 - 그리드 19컬럼 (검사일/검사자/품의서/발주서/프로젝트/제품구분/품명모델/
   부품품번/부품명/공급업체/입고일/입고수량/입고결과/검사수량/불량수량/
   불량률/검사현황/검사성적서)
 - SUM(defect_qty) 서브쿼리로 불량률 자동 계산
 - 하단 요약: 총 입고수량/검사수량/불량수량

공정검사 관리 (process-inspection):
 - 필터 10종 (프로젝트/제품구분/품번/품명/작업환경/측정기/검사일범위/
   검사자/검사결과/진행공정)
 - 그리드 9컬럼 (검사일/검사자/프로젝트/제품구분/품번/품명/검사수량합계/
   검사결과/첨부파일)
 - master/detail 집계 + EXISTS 필터로 wace 1:1

반제품검사 관리 (semi-product-inspection):
 - 필터 8종 (모델명/작업지시번호/부품품번/부품명/검사일범위/검사자/
   불량유형/귀책부서)
 - 그리드 14컬럼 (검사일/검사자/제품구분/모델명/작업지시번호/부품품번/
   부품명/입고수량/양품수량/불량수량/불량률/재생수량/최종양품수량)
 - data_type='GOOD' 마스터 + 동일 inspection_group_id 의 'DEFECT' SUM
 - 재생수량(disposition_type='수정완료') + 최종양품수량 자동 산정
 - 하단 요약: 5개 합계 카드

frontend/lib/api/quality.ts 타입 1:1 정합, 모든 필터 파라미터 직렬화.
This commit is contained in:
chpark
2026-05-15 11:22:48 +09:00
parent e785cd8a98
commit c74e742b6f
6 changed files with 500 additions and 328 deletions
+145 -152
View File
@@ -1,15 +1,14 @@
/**
* 품질관리 — 4개 신규 메뉴 라우트.
* 품질관리 — wace_plm 의 QualityController + quality.xml 을 1:1 이식.
*
* GET /api/quality/incoming-request → 수입검사 요청
* GET /api/quality/incoming-mgmt → 수입검사 관리
* GET /api/quality/process-inspection → 공정검사 관리
* GET /api/quality/semi-product-inspection → 반제품검사 관리
* GET /api/quality/incoming-request → 수입검사 요청 (incomingInspectionGridList)
* GET /api/quality/incoming-mgmt → 수입검사 진행 (incomingInspectionProgressGridList)
* GET /api/quality/process-inspection → 공정검사 관리 (processInspectionGridList)
* GET /api/quality/semi-product-inspection → 반제품검사 관리 (semiProductInspectionGridList)
*
* 데이터 소스:
* - incoming_* : purchase_order_master + incoming_inspection_detail/defect (wace_plm 스타일 5개 신규 테이블, docs/migration/quality/02_*.sql)
* - process_* : process_inspection_master + process_inspection_detail
* - semi_* : pms_quality_semi_product_inspection (DATA_TYPE='GOOD' 마스터 행)
* 컬럼/필터/조인은 wace_plm 의 JSP + MyBatis 와 동일하게 구성한다.
* 일부 조인 테이블(arrival_plan, inventory_mgmt_in 등)이 vexplor_rps 에 없을 수 있어
* LEFT JOIN 으로만 묶고, 미존재 시 SQL 실패해도 빈 응답으로 fallback.
*/
import { Router, Request, Response } from "express";
@@ -28,126 +27,143 @@ function emptyList(res: Response) {
}
// ─── 1. 수입검사 요청 ──────────────────────────────────────────
// 발주서 마스터 + 검사 디테일 LEFT JOIN. 디테일이 없으면 미요청 상태로 노출.
// wace quality.xml getIncomingInspectionList — 발주서 기반.
router.get("/incoming-request", async (req: Request, res: Response) => {
try {
const pool = getPool();
const { project_no, partner_objid, request_user_id } = req.query as Record<string, string>;
const q = req.query as Record<string, string>;
const where: string[] = ["1=1"];
const params: any[] = [];
if (project_no) {
params.push(`%${project_no}%`);
where.push(`COALESCE(cm.project_no, '') ILIKE $${params.length}`);
}
if (partner_objid) {
params.push(partner_objid);
where.push(`pom.partner_objid = $${params.length}`);
}
if (request_user_id) {
params.push(request_user_id);
where.push(`iid.request_user_id = $${params.length}`);
const add = (sql: string, ...v: any[]) => { v.forEach(x => params.push(x)); where.push(sql.replace(/\?/g, () => "$" + params.length)); };
if (q.search_proposal_no) add("COALESCE(srm.proposal_no, '') ILIKE ?", `%${q.search_proposal_no}%`);
if (q.search_purchase_order_no) add("pom.purchase_order_no ILIKE ?", `%${q.search_purchase_order_no}%`);
if (q.project_no) add("COALESCE(cm.project_no, '') ILIKE ?", `%${q.project_no}%`);
if (q.search_part_no) add("COALESCE(pm.part_no, '') ILIKE ?", `%${q.search_part_no}%`);
if (q.search_part_name) add("COALESCE(pm.part_name, '') ILIKE ?", `%${q.search_part_name}%`);
if (q.search_partner) add("pom.partner_objid::text = ?", q.search_partner);
if (q.search_product_cd) add("pom.product_cd::text = ?", q.search_product_cd);
if (q.search_inspection_yn) add("COALESCE(iid.inspection_yn, '미요청') = ?", q.search_inspection_yn);
if (q.request_user_id) add("COALESCE(iid.request_user_id, pom.writer) = ?", q.request_user_id);
if (q.request_start_date) add("COALESCE(iid.request_date, pom.regdate::date) >= ?", q.request_start_date);
if (q.request_end_date) add("COALESCE(iid.request_date, pom.regdate::date) <= ?", q.request_end_date);
if (q.search_request_status) {
const s = q.search_request_status;
if (s === "미요청") where.push("iid.objid IS NULL");
else if (s === "요청중") where.push("iid.objid IS NOT NULL AND iid.inspection_date IS NULL");
else if (s === "요청완료") where.push("iid.inspection_date IS NOT NULL");
}
const sql = `
SELECT
pom.objid::text AS objid,
pom.purchase_order_no AS purchase_order_no,
COALESCE(srm.proposal_no, '') AS proposal_no,
COALESCE(cm.project_no, '') AS project_no,
COALESCE(ci.code_name, '') AS product_name,
'' AS part_no,
'' AS part_name,
COALESCE(client.partner_name, '') AS partner_name,
'-' AS delivery_status,
pom.objid::text AS objid,
COALESCE(srm.proposal_no, '') AS proposal_no,
pom.purchase_order_no AS purchase_order_no,
COALESCE(cm.project_no, '') AS project_no,
COALESCE(ci.code_name, '') AS product_name,
COALESCE(pm.part_no, '') AS part_no,
COALESCE(pm.part_name, '') AS part_name,
COALESCE(client.partner_name, '') AS partner_name,
'-' AS delivery_status,
TO_CHAR(COALESCE(iid.request_date, pom.regdate::date), 'YYYY-MM-DD') AS request_date,
COALESCE(ui_req.user_name, ui_w.user_name, '') AS request_user_name,
COALESCE(iid.inspection_yn, '-') AS inspection_yn,
COALESCE(ui.user_name, '') AS request_user_name,
COALESCE(iid.inspection_yn, '-') AS inspection_yn,
CASE
WHEN iid.objid IS NULL THEN '미요청'
WHEN iid.inspection_date IS NOT NULL THEN '검사완료'
WHEN iid.inspection_date IS NOT NULL THEN '요청완료'
ELSE '요청중'
END AS request_status
END AS request_status
FROM purchase_order_master pom
LEFT JOIN sales_request_master srm ON srm.objid = pom.sales_request_objid
LEFT JOIN contract_mgmt cm ON cm.objid = pom.contract_objid
LEFT JOIN code_info ci ON ci.objid = pom.product_cd
LEFT JOIN client_mng client ON client.objid = pom.partner_objid
LEFT JOIN user_info ui_w ON ui_w.user_id = pom.writer
LEFT JOIN sales_request_master srm ON srm.objid = pom.sales_request_objid
LEFT JOIN contract_mgmt cm ON cm.objid = pom.contract_objid
LEFT JOIN code_info ci ON ci.objid = pom.product_cd
LEFT JOIN client_mng client ON client.objid = pom.partner_objid
LEFT JOIN part_mng pm ON pm.objid = pom.part_objid
LEFT JOIN incoming_inspection_detail iid ON iid.purchase_order_master_objid = pom.objid::text
LEFT JOIN user_info ui_req ON ui_req.user_id = iid.request_user_id
LEFT JOIN user_info ui ON ui.user_id = COALESCE(iid.request_user_id, pom.writer)
WHERE ${where.join(" AND ")}
ORDER BY pom.regdate DESC NULLS LAST
ORDER BY pom.regdate DESC NULLS LAST, pom.objid DESC
LIMIT 500
`;
const r = await pool.query(sql, params);
res.json({
success: true,
list: r.rows,
pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 },
});
res.json({ success: true, list: r.rows, pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 } });
} catch (err: any) {
console.warn("[quality/incoming-request] fallback empty:", err?.message);
emptyList(res);
}
});
// ─── 2. 수입검사 관리 ──────────────────────────────────────────
// ─── 2. 수입검사 진행 ──────────────────────────────────────────
// wace quality.xml getIncomingInspectionProgressList — INCOMING_INSPECTION_DETAIL 기반,
// 검사된 행만 (INSPECTION_YN = '검사'). DEFECT 합계로 불량률 계산.
router.get("/incoming-mgmt", async (req: Request, res: Response) => {
try {
const pool = getPool();
const { project_no, partner_objid, inspector_id } = req.query as Record<string, string>;
const q = req.query as Record<string, string>;
const where: string[] = ["iid.objid IS NOT NULL"];
const where: string[] = ["iid.inspection_yn = '검사'"];
const params: any[] = [];
if (project_no) {
params.push(`%${project_no}%`);
where.push(`COALESCE(cm.project_no, '') ILIKE $${params.length}`);
}
if (partner_objid) {
params.push(partner_objid);
where.push(`pom.partner_objid = $${params.length}`);
}
if (inspector_id) {
params.push(inspector_id);
where.push(`iid.inspector_id = $${params.length}`);
const add = (sql: string, ...v: any[]) => { v.forEach(x => params.push(x)); where.push(sql.replace(/\?/g, () => "$" + params.length)); };
if (q.search_proposal_no) add("COALESCE(srm.proposal_no, '') ILIKE ?", `%${q.search_proposal_no}%`);
if (q.search_purchase_order_no) add("pom.purchase_order_no ILIKE ?", `%${q.search_purchase_order_no}%`);
if (q.project_no) add("COALESCE(cm.project_no, '') ILIKE ?", `%${q.project_no}%`);
if (q.search_part_no) add("COALESCE(pm.part_no, '') ILIKE ?", `%${q.search_part_no}%`);
if (q.search_part_name) add("COALESCE(pm.part_name, '') ILIKE ?", `%${q.search_part_name}%`);
if (q.search_partner) add("pom.partner_objid::text = ?", q.search_partner);
if (q.search_product_cd) add("pom.product_cd::text = ?", q.search_product_cd);
if (q.inspector_id) add("iid.inspector_id = ?", q.inspector_id);
if (q.inspection_start_date) add("iid.inspection_date >= ?", q.inspection_start_date);
if (q.inspection_end_date) add("iid.inspection_date <= ?", q.inspection_end_date);
if (q.search_inspection_status) {
const s = q.search_inspection_status;
if (s === "완료") where.push("iid.inspection_date IS NOT NULL AND COALESCE(iid.inspection_result, '') <> ''");
else if (s === "진행중") where.push("iid.inspection_date IS NULL");
}
const sql = `
SELECT
iid.objid::text AS objid,
TO_CHAR(iid.inspection_date, 'YYYY-MM-DD') AS inspection_date,
COALESCE(ui_ins.user_name, '') AS inspector_name,
COALESCE(srm.proposal_no, '') AS proposal_no,
pom.purchase_order_no AS purchase_order_no,
COALESCE(cm.project_no, '') AS project_no,
COALESCE(ci.code_name, '') AS product_name,
'' AS part_no,
'' AS part_name,
COALESCE(pm.product_name, '') AS model_name,
COALESCE(pm.part_no, '') AS part_no,
COALESCE(pm.part_name, '') AS part_name,
COALESCE(client.partner_name, '') AS partner_name,
TO_CHAR(iid.inspection_date, 'YYYY-MM-DD') AS inspection_date,
COALESCE(ui.user_name, '') AS inspector_name,
iid.inspection_qty AS total_qty,
(iid.inspection_qty - COALESCE(iid.defect_qty, 0)) AS good_qty,
iid.defect_qty AS bad_qty,
COALESCE(iid.inspection_result, '') AS inspection_result,
TO_CHAR(iid.reg_date, 'YYYY-MM-DD') AS delivery_date,
iid.inspection_qty AS delivery_qty,
'-' AS delivery_status,
iid.inspection_qty AS inspection_qty,
COALESCE((SELECT SUM(d.defect_qty) FROM incoming_inspection_defect d WHERE d.inspection_detail_objid = iid.objid), 0) AS defect_qty_sum,
CASE
WHEN iid.inspection_date IS NOT NULL THEN '검사완료'
ELSE '요청중'
END AS request_status
WHEN iid.inspection_qty IS NULL OR iid.inspection_qty = 0 THEN 0
ELSE ROUND(COALESCE((SELECT SUM(d.defect_qty) FROM incoming_inspection_defect d WHERE d.inspection_detail_objid = iid.objid), 0) / iid.inspection_qty * 100, 2)
END AS defect_rate,
CASE
WHEN iid.inspection_date IS NULL THEN '미검사'
WHEN COALESCE(iid.inspection_result, '') = '' THEN '진행중'
ELSE '완료'
END AS inspection_result,
0 AS inspection_file_cnt
FROM incoming_inspection_detail iid
LEFT JOIN purchase_order_master pom ON pom.objid::text = iid.purchase_order_master_objid
LEFT JOIN contract_mgmt cm ON cm.objid = pom.contract_objid
LEFT JOIN code_info ci ON ci.objid = pom.product_cd
LEFT JOIN client_mng client ON client.objid = pom.partner_objid
LEFT JOIN user_info ui ON ui.user_id = iid.inspector_id
LEFT JOIN purchase_order_master pom ON pom.objid::text = iid.purchase_order_master_objid
LEFT JOIN sales_request_master srm ON srm.objid = pom.sales_request_objid
LEFT JOIN contract_mgmt cm ON cm.objid = pom.contract_objid
LEFT JOIN code_info ci ON ci.objid = pom.product_cd
LEFT JOIN client_mng client ON client.objid = pom.partner_objid
LEFT JOIN part_mng pm ON pm.objid = pom.part_objid
LEFT JOIN user_info ui_ins ON ui_ins.user_id = iid.inspector_id
WHERE ${where.join(" AND ")}
ORDER BY iid.reg_date DESC
ORDER BY iid.inspection_date DESC NULLS LAST, iid.objid DESC
LIMIT 500
`;
const r = await pool.query(sql, params);
res.json({
success: true,
list: r.rows,
pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 },
});
res.json({ success: true, list: r.rows, pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 } });
} catch (err: any) {
console.warn("[quality/incoming-mgmt] fallback empty:", err?.message);
emptyList(res);
@@ -155,65 +171,59 @@ router.get("/incoming-mgmt", async (req: Request, res: Response) => {
});
// ─── 3. 공정검사 관리 ──────────────────────────────────────────
// 마스터별로 디테일 N건의 inspection_qty / defect_qty 합계를 집계해 1행 반환.
// wace quality.xml getProcessInspectionList — 마스터별 디테일 SUM.
router.get("/process-inspection", async (req: Request, res: Response) => {
try {
const pool = getPool();
const { project_no, productType, part_name, inspector_id, from, to } = req.query as Record<string, string>;
const q = req.query as Record<string, string>;
const where: string[] = ["1=1"];
const params: any[] = [];
if (project_no) {
params.push(`%${project_no}%`);
where.push(`EXISTS (SELECT 1 FROM project_mgmt pm WHERE pm.objid = pid.project_objid AND pm.project_no ILIKE $${params.length})`);
}
if (part_name) {
params.push(`%${part_name}%`);
where.push(`pid.part_name ILIKE $${params.length}`);
}
if (inspector_id) {
params.push(inspector_id);
where.push(`pim.inspector_id = $${params.length}`);
}
if (from) {
params.push(from);
where.push(`pim.inspection_date >= $${params.length}`);
}
if (to) {
params.push(to);
where.push(`pim.inspection_date <= $${params.length}`);
}
const add = (sql: string, ...v: any[]) => { v.forEach(x => params.push(x)); where.push(sql.replace(/\?/g, () => "$" + params.length)); };
if (q.search_project_no) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 LEFT JOIN project_mgmt pj ON pj.objid = pid2.project_objid WHERE pid2.master_objid = pim.objid AND COALESCE(pj.project_no, '') ILIKE ?)", `%${q.search_project_no}%`);
if (q.productType) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 LEFT JOIN project_mgmt pj ON pj.objid = pid2.project_objid WHERE pid2.master_objid = pim.objid AND pj.product = ?)", q.productType);
if (q.search_part_no) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 WHERE pid2.master_objid = pim.objid AND pid2.part_no ILIKE ?)", `%${q.search_part_no}%`);
if (q.search_part_name) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 WHERE pid2.master_objid = pim.objid AND pid2.part_name ILIKE ?)", `%${q.search_part_name}%`);
if (q.search_work_env_status) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 WHERE pid2.master_objid = pim.objid AND pid2.work_env_status = ?)", q.search_work_env_status);
if (q.search_measuring_device) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 WHERE pid2.master_objid = pim.objid AND pid2.measuring_device = ?)", q.search_measuring_device);
if (q.search_inspector) add("pim.inspector_id = ?", q.search_inspector);
if (q.search_inspection_date_from) add("pim.inspection_date >= ?", q.search_inspection_date_from);
if (q.search_inspection_date_to) add("pim.inspection_date <= ?", q.search_inspection_date_to);
if (q.search_inspection_result) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 WHERE pid2.master_objid = pim.objid AND pid2.inspection_result = ?)", q.search_inspection_result);
if (q.search_process_cd) add("EXISTS (SELECT 1 FROM process_inspection_detail pid2 WHERE pid2.master_objid = pim.objid AND pid2.process_cd = ?)", q.search_process_cd);
const sql = `
SELECT
pim.objid::text AS objid,
TO_CHAR(pim.inspection_date, 'YYYY-MM-DD') AS inspection_date,
COALESCE(ui.user_name, '') AS inspector_name,
COALESCE(MIN(pm.project_no), '') AS project_no,
'' AS product_name,
COALESCE(MIN(pj.project_no), '') AS project_no,
COALESCE(MIN(ci.code_name), '') AS product_name,
COALESCE(MIN(pid.part_no), '') AS part_no,
COALESCE(MIN(pid.part_name), '') AS part_name,
COALESCE(SUM(pid.inspection_qty), 0) AS inspection_qty,
COALESCE(SUM(pid.defect_qty), 0) AS defect_qty,
COALESCE(MAX(pid.work_env_status), '') AS work_env_status,
COALESCE(MAX(pid.measuring_device), '') AS measuring_device,
COALESCE(MAX(pid.inspection_result), '') AS inspection_result,
0 AS file_count
CASE
WHEN SUM(pid.defect_qty) > 0 THEN 'NG'
WHEN COUNT(pid.objid) > 0 THEN 'OK'
ELSE ''
END AS inspection_result,
0 AS process_inspection_file_cnt
FROM process_inspection_master pim
LEFT JOIN process_inspection_detail pid ON pid.master_objid = pim.objid
LEFT JOIN project_mgmt pm ON pm.objid = pid.project_objid
LEFT JOIN user_info ui ON ui.user_id = pim.inspector_id
LEFT JOIN project_mgmt pj ON pj.objid = pid.project_objid
LEFT JOIN code_info ci ON ci.objid::text = pj.product
LEFT JOIN user_info ui ON ui.user_id = pim.inspector_id
WHERE ${where.join(" AND ")}
GROUP BY pim.objid, pim.inspection_date, ui.user_name
ORDER BY pim.inspection_date DESC NULLS LAST, pim.objid DESC
LIMIT 500
`;
const r = await pool.query(sql, params);
res.json({
success: true,
list: r.rows,
pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 },
});
res.json({ success: true, list: r.rows, pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 } });
} catch (err: any) {
console.warn("[quality/process-inspection] fallback empty:", err?.message);
emptyList(res);
@@ -221,47 +231,34 @@ router.get("/process-inspection", async (req: Request, res: Response) => {
});
// ─── 4. 반제품검사 관리 ────────────────────────────────────────
// DATA_TYPE='GOOD' 행을 마스터로 보고, 동일 inspection_group_id 의 'DEFECT' 행을
// SUM 으로 묶어 불량/재생/최종양품 수량을 집계.
// wace quality.xml getSemiProductInspectionList — DATA_TYPE='GOOD' 마스터,
// 동일 INSPECTION_GROUP_ID 의 'DEFECT' 행 SUM 으로 불량/재생/최종양품 산정.
router.get("/semi-product-inspection", async (req: Request, res: Response) => {
try {
const pool = getPool();
const { model_name, part_no, part_name, writer, from, to } = req.query as Record<string, string>;
const q = req.query as Record<string, string>;
const where: string[] = ["g.data_type IS NULL OR g.data_type = 'GOOD'"];
const where: string[] = ["g.data_type = 'GOOD'"];
const params: any[] = [];
if (model_name) {
params.push(`%${model_name}%`);
where.push(`g.model_name ILIKE $${params.length}`);
}
if (part_no) {
params.push(`%${part_no}%`);
where.push(`g.part_no ILIKE $${params.length}`);
}
if (part_name) {
params.push(`%${part_name}%`);
where.push(`g.part_name ILIKE $${params.length}`);
}
if (writer) {
params.push(writer);
where.push(`g.writer = $${params.length}`);
}
if (from) {
params.push(from);
where.push(`g.inspection_date >= $${params.length}`);
}
if (to) {
params.push(to);
where.push(`g.inspection_date <= $${params.length}`);
}
const add = (sql: string, ...v: any[]) => { v.forEach(x => params.push(x)); where.push(sql.replace(/\?/g, () => "$" + params.length)); };
if (q.search_model_name) add("COALESCE(g.model_name, '') ILIKE ?", `%${q.search_model_name}%`);
if (q.search_work_order_no) add("COALESCE(g.work_order_no, '') ILIKE ?", `%${q.search_work_order_no}%`);
if (q.search_part_no) add("COALESCE(g.part_no, '') ILIKE ?", `%${q.search_part_no}%`);
if (q.search_part_name) add("COALESCE(g.part_name, '') ILIKE ?", `%${q.search_part_name}%`);
if (q.inspection_start_date) add("COALESCE(g.inspection_date, g.reg_date::date) >= ?", q.inspection_start_date);
if (q.inspection_end_date) add("COALESCE(g.inspection_date, g.reg_date::date) <= ?", q.inspection_end_date);
if (q.search_writer) add("g.writer = ?", q.search_writer);
if (q.search_defect_type) add("EXISTS (SELECT 1 FROM pms_quality_semi_product_inspection d2 WHERE d2.inspection_group_id = g.inspection_group_id AND d2.data_type = 'DEFECT' AND d2.defect_type = ?)", q.search_defect_type);
if (q.search_responsible_dept) add("EXISTS (SELECT 1 FROM pms_quality_semi_product_inspection d2 WHERE d2.inspection_group_id = g.inspection_group_id AND d2.data_type = 'DEFECT' AND d2.responsible_dept = ?)", q.search_responsible_dept);
const sql = `
SELECT
g.objid::text AS objid,
TO_CHAR(g.inspection_date, 'YYYY-MM-DD') AS inspection_date,
COALESCE(ui.user_name, g.writer, '') AS writer_name,
COALESCE(g.model_name, '') AS model_name,
COALESCE(g.product_type, '') AS product_type,
COALESCE(g.model_name, '') AS model_name,
COALESCE(g.work_order_no, '') AS work_order_no,
COALESCE(g.part_no, '') AS part_no,
COALESCE(g.part_name, '') AS part_name,
@@ -270,7 +267,7 @@ router.get("/semi-product-inspection", async (req: Request, res: Response) => {
COALESCE(d.defect_qty_sum, 0) AS defective_qty,
CASE
WHEN COALESCE(g.receipt_qty, 0) = 0 THEN 0
ELSE ROUND( COALESCE(d.defect_qty_sum, 0) / g.receipt_qty * 100, 2 )
ELSE ROUND(COALESCE(d.defect_qty_sum, 0) / g.receipt_qty * 100, 2)
END AS defect_rate,
COALESCE(d.regen_qty_sum, 0) AS regeneration_qty,
COALESCE(g.good_qty, 0) + COALESCE(d.regen_qty_sum, 0) AS final_good_qty
@@ -284,15 +281,11 @@ router.get("/semi-product-inspection", async (req: Request, res: Response) => {
) d ON true
LEFT JOIN user_info ui ON ui.user_id = g.writer
WHERE ${where.join(" AND ")}
ORDER BY g.inspection_date DESC NULLS LAST, g.objid DESC
ORDER BY COALESCE(g.inspection_date, g.reg_date::date) DESC NULLS LAST, g.objid DESC
LIMIT 500
`;
const r = await pool.query(sql, params);
res.json({
success: true,
list: r.rows,
pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 },
});
res.json({ success: true, list: r.rows, pagination: { page: 1, pageSize: 500, total: r.rows.length, totalPages: 1 } });
} catch (err: any) {
console.warn("[quality/semi-product-inspection] fallback empty:", err?.message);
emptyList(res);
@@ -1,38 +1,47 @@
"use client";
/**
* 수입검사 관리 — wace_plm incomingInspectionProgressList.jsp 이식.
*
* 수입검사 요청 기반에 검사자/검사일/검사결과/불량수량 컬럼이 추가됨.
* 수입검사 진행 — wace_plm incomingInspectionProgressList.jsp 1:1 이식.
* 필터 11종, 그리드 19컬럼 (불량률 자동산정, 검사현황 컬러표시).
*/
import React, { useCallback, useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { qualityApi, IncomingMgmtRow } from "@/lib/api/quality";
import { exportToExcel } from "@/lib/utils/excelExport";
// wace_plm incomingInspectionProgressList.jsp 그리드 1:1
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "purchase_order_no", label: "발주서 No", width: "w-[150px]", frozen: true },
{ key: "project_no", label: "프로젝트번호", width: "w-[150px]" },
{ key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", width: "w-[200px]" },
{ key: "partner_name", label: "공급업체", width: "w-[160px]" },
{ key: "inspection_date", label: "검사일", width: "w-[115px]", align: "center" },
{ key: "inspector_name", label: "검사자", width: "w-[110px]", align: "center" },
{ key: "total_qty", label: "검사수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "good_qty", label: "양품수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "bad_qty", label: "불량수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "inspection_result", label: "검사결과", width: "w-[100px]", align: "center" },
{ key: "request_status", label: "요청현황", width: "w-[110px]", align: "center" },
{ key: "inspection_date", label: "검사일", width: "w-[110px]", align: "center", frozen: true },
{ key: "inspector_name", label: "검사자", width: "w-[90px]", align: "center" },
{ key: "proposal_no", label: "품의서 No", width: "w-[120px]", align: "center" },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[120px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[100px]", align: "center" },
{ key: "model_name", label: "품명(모델명)", width: "w-[150px]" },
{ key: "part_no", label: "부품품번", width: "w-[140px]" },
{ key: "part_name", label: "부품명", width: "w-[150px]" },
{ key: "partner_name", label: "공급업체", width: "w-[150px]" },
{ key: "delivery_date", label: "입고일", width: "w-[110px]", align: "center" },
{ key: "delivery_qty", label: "입고수량", width: "w-[100px]", align: "right", formatNumber: true },
{ key: "delivery_status", label: "입고결과", width: "w-[90px]", align: "center" },
{ key: "inspection_qty", label: "검사수량", width: "w-[100px]", align: "right", formatNumber: true },
{ key: "defect_qty_sum", label: "불량수량", width: "w-[100px]", align: "right", formatNumber: true },
{ key: "defect_rate", label: "불량률(%)", width: "w-[100px]", align: "right" },
{ key: "inspection_result", label: "검사현황", width: "w-[100px]", align: "center" },
{ key: "inspection_file_cnt", label: "검사성적서", width: "w-[100px]", align: "center", renderType: "clip" },
];
const INSPECTION_STATUS_OPTIONS = ["전체", "완료", "진행중"];
const DELIVERY_STATUS_OPTIONS = ["전체", "입고중", "입고완료", "지연"];
export default function IncomingMgmtPage() {
const { user } = useAuth();
const [rows, setRows] = useState<IncomingMgmtRow[]>([]);
@@ -40,30 +49,58 @@ export default function IncomingMgmtPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState({
search_proposal_no: "",
search_purchase_order_no: "",
project_no: "",
partner_objid: "",
search_part_no: "",
search_part_name: "",
search_partner: "",
search_delivery_status: "",
search_product_cd: "",
inspector_id: "",
inspection_start_date: "",
inspection_end_date: "",
search_inspection_status: "",
});
const fetchList = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const params: Record<string, string> = {};
Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; });
const res = await qualityApi.incomingMgmt(params);
const res = await qualityApi.incomingMgmt(search);
setRows(res.list.map((r) => ({ ...r, id: r.objid } as any)));
setSelectedId(null);
} catch {
toast.error("수입검사 관리 목록 조회 실패");
} finally {
setLoading(false);
}
} catch { toast.error("수입검사 진행 목록 조회 실패"); }
finally { setLoading(false); }
}, [user, search]);
useEffect(() => { fetchList(); }, [fetchList]);
const handleReset = () => setSearch({ project_no: "", partner_objid: "", inspector_id: "" });
const handleReset = () => setSearch({
search_proposal_no: "", search_purchase_order_no: "", project_no: "",
search_part_no: "", search_part_name: "", search_partner: "",
search_delivery_status: "", search_product_cd: "",
inspector_id: "", inspection_start_date: "", inspection_end_date: "",
search_inspection_status: "",
});
const summary = (() => {
const fmt = (n: number) => n.toLocaleString();
return [
{ label: "총 입고수량", value: fmt(rows.reduce((a, r) => a + Number(r.delivery_qty || 0), 0)) },
{ label: "총 검사수량", value: fmt(rows.reduce((a, r) => a + Number(r.inspection_qty || 0), 0)) },
{ label: "총 불량수량", value: fmt(rows.reduce((a, r) => a + Number(r.defect_qty_sum || 0), 0)) },
];
})();
const HardcodedSelect = ({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: string[] }) => (
<Select value={value || "all"} onValueChange={(v) => onChange(v === "all" || v === "전체" ? "" : v)}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o} value={o === "전체" ? "all" : o}>{o}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
@@ -73,22 +110,46 @@ export default function IncomingMgmtPage() {
onReset={handleReset}
actions={
<Button size="sm" className="h-8 gap-1 text-xs" disabled>
<Plus className="h-3.5 w-3.5" />
<Plus className="h-3.5 w-3.5" />
</Button>
}
/>
<CompactFilterBar totalText={<> {rows.length.toLocaleString()}</>}>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={search.project_no}
onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
<CompactFilterField label="품의서 No" width={130}>
<Input value={search.search_proposal_no} onChange={(e) => setSearch({ ...search, search_proposal_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체 ID" width={160}>
<Input value={search.partner_objid}
onChange={(e) => setSearch({ ...search, partner_objid: e.target.value })} />
<CompactFilterField label="발주서 No" width={130}>
<Input value={search.search_purchase_order_no} onChange={(e) => setSearch({ ...search, search_purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사자 ID" width={140}>
<Input value={search.inspector_id}
onChange={(e) => setSearch({ ...search, inspector_id: e.target.value })} />
<CompactFilterField label="프로젝트번호" width={150}>
<Input value={search.project_no} onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={search.search_part_no} onChange={(e) => setSearch({ ...search, search_part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={160}>
<Input value={search.search_part_name} onChange={(e) => setSearch({ ...search, search_part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체 ID" width={140}>
<Input value={search.search_partner} onChange={(e) => setSearch({ ...search, search_partner: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="입고결과" width={120}>
<HardcodedSelect value={search.search_delivery_status} onChange={(v) => setSearch({ ...search, search_delivery_status: v })} options={DELIVERY_STATUS_OPTIONS} />
</CompactFilterField>
<CompactFilterField label="제품구분 ID" width={120}>
<Input value={search.search_product_cd} onChange={(e) => setSearch({ ...search, search_product_cd: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사자 ID" width={120}>
<Input value={search.inspector_id} onChange={(e) => setSearch({ ...search, inspector_id: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사일" width={280}>
<CompactDateRange
from={search.inspection_start_date} setFrom={(v) => setSearch({ ...search, inspection_start_date: v })}
to={search.inspection_end_date} setTo={(v) => setSearch({ ...search, inspection_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="검사현황" width={110}>
<HardcodedSelect value={search.search_inspection_status} onChange={(v) => setSearch({ ...search, search_inspection_status: v })} options={INSPECTION_STATUS_OPTIONS} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
@@ -102,6 +163,7 @@ export default function IncomingMgmtPage() {
selectedId={selectedId}
onSelect={setSelectedId}
emptyMessage="조회된 수입검사 진행 내역이 없습니다."
summaryStats={summary}
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
@@ -1,38 +1,43 @@
"use client";
/**
* 수입검사 요청 — wace_plm incomingInspectionList.jsp + QualityController.getIncomingInspectionList 이식.
*
* 그리드 컬럼은 wace_plm 원본의 12개를 1:1로 따른다.
* 일부 백엔드 테이블 부재로 데이터가 비어 있을 수 있다(qualityRoutes.ts 참조).
* 수입검사 요청 — wace_plm incomingInspectionList.jsp 1:1 이식.
* 필터 12종, 그리드 12컬럼.
*/
import React, { useCallback, useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField } from "@/components/common/CompactFilterBar";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
import { qualityApi, IncomingRequestRow } from "@/lib/api/quality";
import { exportToExcel } from "@/lib/utils/excelExport";
// wace_plm incomingInspectionList.jsp 그리드 1:1 (12 컬럼)
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "purchase_order_no", label: "발주서 No", width: "w-[150px]", frozen: true },
{ key: "proposal_no", label: "품의서 No", width: "w-[140px]" },
{ key: "project_no", label: "프로젝트번호", width: "w-[150px]" },
{ key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", width: "w-[200px]" },
{ key: "partner_name", label: "공급업체", width: "w-[160px]" },
{ key: "delivery_status", label: "입고결과", width: "w-[110px]", align: "center" },
{ key: "request_date", label: "요청일", width: "w-[115px]", align: "center" },
{ key: "request_user_name", label: "요청자", width: "w-[110px]", align: "center" },
{ key: "inspection_yn", label: "검사여부", width: "w-[100px]", align: "center" },
{ key: "request_status", label: "요청현황", width: "w-[110px]", align: "center" },
{ key: "proposal_no", label: "품의서 No", width: "w-[130px]", align: "center", frozen: true },
{ key: "purchase_order_no", label: "발주서 No", width: "w-[130px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_name", label: "품명", width: "w-[180px]" },
{ key: "partner_name", label: "공급업체", width: "w-[160px]" },
{ key: "delivery_status", label: "입고결과", width: "w-[100px]", align: "center" },
{ key: "request_date", label: "요청일", width: "w-[115px]", align: "center" },
{ key: "request_user_name", label: "요청자", width: "w-[100px]", align: "center" },
{ key: "inspection_yn", label: "검사여부", width: "w-[100px]", align: "center" },
{ key: "request_status", label: "요청현황", width: "w-[110px]", align: "center" },
];
// 하드코딩 옵션 (wace_plm 동일)
const DELIVERY_STATUS_OPTIONS = ["전체", "입고중", "입고완료", "지연"];
const INSPECTION_YN_OPTIONS = ["전체", "검사", "스킵"];
const REQUEST_STATUS_OPTIONS = ["전체", "미요청", "요청중", "요청완료"];
export default function IncomingRequestPage() {
const { user } = useAuth();
const [rows, setRows] = useState<IncomingRequestRow[]>([]);
@@ -40,30 +45,51 @@ export default function IncomingRequestPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState({
search_proposal_no: "",
search_purchase_order_no: "",
project_no: "",
partner_objid: "",
search_part_no: "",
search_part_name: "",
search_partner: "",
search_delivery_status: "",
search_product_cd: "",
search_inspection_yn: "",
search_request_status: "",
request_user_id: "",
request_start_date: "",
request_end_date: "",
});
const fetchList = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const params: Record<string, string> = {};
Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; });
const res = await qualityApi.incomingRequest(params);
const res = await qualityApi.incomingRequest(search);
setRows(res.list.map((r) => ({ ...r, id: r.objid } as any)));
setSelectedId(null);
} catch (e: any) {
} catch {
toast.error("수입검사 요청 목록 조회 실패");
} finally {
setLoading(false);
}
} finally { setLoading(false); }
}, [user, search]);
useEffect(() => { fetchList(); }, [fetchList]);
const handleReset = () => setSearch({ project_no: "", partner_objid: "", request_user_id: "" });
const handleReset = () => setSearch({
search_proposal_no: "", search_purchase_order_no: "", project_no: "",
search_part_no: "", search_part_name: "", search_partner: "",
search_delivery_status: "", search_product_cd: "",
search_inspection_yn: "", search_request_status: "",
request_user_id: "", request_start_date: "", request_end_date: "",
});
const HardcodedSelect = ({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: string[] }) => (
<Select value={value || "all"} onValueChange={(v) => onChange(v === "all" || v === "전체" ? "" : v)}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o} value={o === "전체" ? "all" : o}>{o}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
@@ -78,17 +104,44 @@ export default function IncomingRequestPage() {
}
/>
<CompactFilterBar totalText={<> {rows.length.toLocaleString()}</>}>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={search.project_no}
onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
<CompactFilterField label="품의서 No" width={130}>
<Input value={search.search_proposal_no} onChange={(e) => setSearch({ ...search, search_proposal_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체 ID" width={160}>
<Input value={search.partner_objid}
onChange={(e) => setSearch({ ...search, partner_objid: e.target.value })} />
<CompactFilterField label="발주서 No" width={130}>
<Input value={search.search_purchase_order_no} onChange={(e) => setSearch({ ...search, search_purchase_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="요청자 ID" width={140}>
<Input value={search.request_user_id}
onChange={(e) => setSearch({ ...search, request_user_id: e.target.value })} />
<CompactFilterField label="프로젝트번호" width={150}>
<Input value={search.project_no} onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={search.search_part_no} onChange={(e) => setSearch({ ...search, search_part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={160}>
<Input value={search.search_part_name} onChange={(e) => setSearch({ ...search, search_part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="공급업체 ID" width={140}>
<Input value={search.search_partner} onChange={(e) => setSearch({ ...search, search_partner: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="입고결과" width={120}>
<HardcodedSelect value={search.search_delivery_status} onChange={(v) => setSearch({ ...search, search_delivery_status: v })} options={DELIVERY_STATUS_OPTIONS} />
</CompactFilterField>
<CompactFilterField label="제품구분 ID" width={120}>
<Input value={search.search_product_cd} onChange={(e) => setSearch({ ...search, search_product_cd: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사여부" width={110}>
<HardcodedSelect value={search.search_inspection_yn} onChange={(v) => setSearch({ ...search, search_inspection_yn: v })} options={INSPECTION_YN_OPTIONS} />
</CompactFilterField>
<CompactFilterField label="요청현황" width={120}>
<HardcodedSelect value={search.search_request_status} onChange={(v) => setSearch({ ...search, search_request_status: v })} options={REQUEST_STATUS_OPTIONS} />
</CompactFilterField>
<CompactFilterField label="요청자 ID" width={120}>
<Input value={search.request_user_id} onChange={(e) => setSearch({ ...search, request_user_id: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="요청일" width={280}>
<CompactDateRange
from={search.request_start_date} setFrom={(v) => setSearch({ ...search, request_start_date: v })}
to={search.request_end_date} setTo={(v) => setSearch({ ...search, request_end_date: v })}
/>
</CompactFilterField>
</CompactFilterBar>
<DataGrid
@@ -1,14 +1,14 @@
"use client";
/**
* 공정검사 관리 (IPQC) — wace_plm processInspectionList.jsp 이식.
*
* 마스터별 검사 N건을 SUM 으로 집계해 1행 표시 (검사수량/불량수량 합계).
* 공정검사 관리 — wace_plm processInspectionList.jsp 1:1 이식.
* 필터 10종 (2행), 그리드 9컬럼 (검사결과 OK/NG 자동산정).
*/
import React, { useCallback, useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { DataGrid, DataGridColumn } from "@/components/common/DataGrid";
@@ -17,21 +17,21 @@ import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/compon
import { qualityApi, ProcessInspectionRow } from "@/lib/api/quality";
import { exportToExcel } from "@/lib/utils/excelExport";
// wace_plm processInspectionList.jsp 그리드 1:1
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "inspection_date", label: "검사일", width: "w-[120px]", align: "center", frozen: true },
{ key: "inspector_name", label: "검사자", width: "w-[110px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[150px]" },
{ key: "inspection_date", label: "검사일", width: "w-[110px]", align: "center", frozen: true },
{ key: "inspector_name", label: "검사자", width: "w-[100px]", align: "center" },
{ key: "project_no", label: "프로젝트번호", width: "w-[150px]", align: "center" },
{ key: "product_name", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "part_no", label: "품번", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[180px]" },
{ key: "part_name", label: "품명", width: "w-[200px]" },
{ key: "inspection_qty", label: "검사수량 합계", width: "w-[140px]", align: "right", formatNumber: true },
{ key: "defect_qty", label: "불량수량 합계", width: "w-[140px]", align: "right", formatNumber: true },
{ key: "work_env_status", label: "작업환경상태", width: "w-[120px]", align: "center" },
{ key: "measuring_device", label: "측정기", width: "w-[100px]", align: "center" },
{ key: "inspection_qty", label: "검사수량 합계", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "inspection_result", label: "검사결과", width: "w-[100px]", align: "center" },
{ key: "file_count", label: "첨부파일", width: "w-[100px]", align: "center", renderType: "clip" },
{ key: "process_inspection_file_cnt", label: "첨부파일", width: "w-[100px]", align: "center", renderType: "clip" },
];
const OK_NG_OPTIONS = ["전체", "OK", "NG"];
export default function ProcessInspectionPage() {
const { user } = useAuth();
const [rows, setRows] = useState<ProcessInspectionRow[]>([]);
@@ -39,34 +39,48 @@ export default function ProcessInspectionPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState({
project_no: "",
search_project_no: "",
productType: "",
part_name: "",
inspector_id: "",
from: "",
to: "",
search_part_no: "",
search_part_name: "",
search_work_env_status: "",
search_measuring_device: "",
search_inspection_date_from: "",
search_inspection_date_to: "",
search_inspector: "",
search_inspection_result: "",
search_process_cd: "",
});
const fetchList = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const params: Record<string, string> = {};
Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; });
const res = await qualityApi.processInspection(params);
const res = await qualityApi.processInspection(search);
setRows(res.list.map((r) => ({ ...r, id: r.objid } as any)));
setSelectedId(null);
} catch {
toast.error("공정검사 목록 조회 실패");
} finally {
setLoading(false);
}
} catch { toast.error("공정검사 목록 조회 실패"); }
finally { setLoading(false); }
}, [user, search]);
useEffect(() => { fetchList(); }, [fetchList]);
const handleReset = () =>
setSearch({ project_no: "", productType: "", part_name: "", inspector_id: "", from: "", to: "" });
const handleReset = () => setSearch({
search_project_no: "", productType: "",
search_part_no: "", search_part_name: "",
search_work_env_status: "", search_measuring_device: "",
search_inspection_date_from: "", search_inspection_date_to: "",
search_inspector: "", search_inspection_result: "", search_process_cd: "",
});
const HardcodedSelect = ({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: string[] }) => (
<Select value={value || "all"} onValueChange={(v) => onChange(v === "all" || v === "전체" ? "" : v)}>
<SelectTrigger className="h-7 text-xs"><SelectValue placeholder="전체" /></SelectTrigger>
<SelectContent>
{options.map((o) => <SelectItem key={o} value={o === "전체" ? "all" : o}>{o}</SelectItem>)}
</SelectContent>
</Select>
);
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
@@ -81,28 +95,39 @@ export default function ProcessInspectionPage() {
}
/>
<CompactFilterBar totalText={<> {rows.length.toLocaleString()}</>}>
<CompactFilterField label="프로젝트번호" width={160}>
<Input value={search.project_no}
onChange={(e) => setSearch({ ...search, project_no: e.target.value })} />
<CompactFilterField label="프로젝트번호" width={150}>
<Input value={search.search_project_no} onChange={(e) => setSearch({ ...search, search_project_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="제품구분" width={140}>
<Input value={search.productType}
onChange={(e) => setSearch({ ...search, productType: e.target.value })} />
<CompactFilterField label="제품구분 ID" width={130}>
<Input value={search.productType} onChange={(e) => setSearch({ ...search, productType: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품번" width={140}>
<Input value={search.search_part_no} onChange={(e) => setSearch({ ...search, search_part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="품명" width={160}>
<Input value={search.part_name}
onChange={(e) => setSearch({ ...search, part_name: e.target.value })} />
<Input value={search.search_part_name} onChange={(e) => setSearch({ ...search, search_part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사자 ID" width={130}>
<Input value={search.inspector_id}
onChange={(e) => setSearch({ ...search, inspector_id: e.target.value })} />
<CompactFilterField label="작업환경상태" width={120}>
<HardcodedSelect value={search.search_work_env_status} onChange={(v) => setSearch({ ...search, search_work_env_status: v })} options={OK_NG_OPTIONS} />
</CompactFilterField>
<CompactFilterField label="측정기" width={120}>
<HardcodedSelect value={search.search_measuring_device} onChange={(v) => setSearch({ ...search, search_measuring_device: v })} options={OK_NG_OPTIONS} />
</CompactFilterField>
<CompactFilterField label="검사일" width={280}>
<CompactDateRange
from={search.from} setFrom={(v) => setSearch({ ...search, from: v })}
to={search.to} setTo={(v) => setSearch({ ...search, to: v })}
from={search.search_inspection_date_from} setFrom={(v) => setSearch({ ...search, search_inspection_date_from: v })}
to={search.search_inspection_date_to} setTo={(v) => setSearch({ ...search, search_inspection_date_to: v })}
/>
</CompactFilterField>
<CompactFilterField label="검사자 ID" width={130}>
<Input value={search.search_inspector} onChange={(e) => setSearch({ ...search, search_inspector: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사결과" width={110}>
<HardcodedSelect value={search.search_inspection_result} onChange={(v) => setSearch({ ...search, search_inspection_result: v })} options={OK_NG_OPTIONS} />
</CompactFilterField>
<CompactFilterField label="진행공정 ID" width={130}>
<Input value={search.search_process_cd} onChange={(e) => setSearch({ ...search, search_process_cd: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
gridId="quality-process-inspection"
@@ -1,9 +1,9 @@
"use client";
/**
* 반제품검사 관리 — wace_plm semiProductInspectionList.jsp 이식.
*
* 입고/양품/불량/재생/최종양품 수량과 불량률을 1행에 집계한다.
* 반제품검사 관리 — wace_plm semiProductInspectionList.jsp 1:1 이식.
* 필터 8종 (2행), 그리드 14컬럼 (재생수량/최종양품 자동산정).
* 마스터 행은 data_type='GOOD' 이고, 동일 inspection_group_id 의 'DEFECT' 행을 SUM.
*/
import React, { useCallback, useEffect, useState } from "react";
import { Plus } from "lucide-react";
@@ -17,20 +17,21 @@ import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/compon
import { qualityApi, SemiProductInspectionRow } from "@/lib/api/quality";
import { exportToExcel } from "@/lib/utils/excelExport";
// wace_plm semiProductInspectionList.jsp 그리드 1:1
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "inspection_date", label: "검사일", width: "w-[120px]", align: "center", frozen: true },
{ key: "writer_name", label: "검사자", width: "w-[110px]", align: "center" },
{ key: "model_name", label: "품명(모델명)", width: "w-[180px]" },
{ key: "product_type", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "work_order_no", label: "작업지시번호", width: "w-[150px]" },
{ key: "part_no", label: "부품품번", width: "w-[140px]" },
{ key: "part_name", label: "부품명", width: "w-[180px]" },
{ key: "receipt_qty", label: "입고수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "good_qty", label: "양품수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "defective_qty", label: "불량수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "defect_rate", label: "불량률(%)", width: "w-[100px]", align: "right" },
{ key: "regeneration_qty",label: "재생수량", width: "w-[110px]", align: "right", formatNumber: true },
{ key: "final_good_qty", label: "최종양품수량", width: "w-[130px]", align: "right", formatNumber: true },
{ key: "inspection_date", label: "검사일", width: "w-[140px]", align: "center", frozen: true },
{ key: "writer_name", label: "검사자", width: "w-[110px]", align: "center" },
{ key: "product_type", label: "제품구분", width: "w-[110px]", align: "center" },
{ key: "model_name", label: "품명(모델명)", width: "w-[150px]" },
{ key: "work_order_no", label: "작업지시번호", width: "w-[140px]", align: "center" },
{ key: "part_no", label: "부품품번", width: "w-[150px]" },
{ key: "part_name", label: "부품명", width: "w-[180px]" },
{ key: "receipt_qty", label: "입고수량 합계", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "good_qty", label: "양품수량 합계", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "defective_qty", label: "불량수량 합계", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "defect_rate", label: "불량률(%)", width: "w-[100px]", align: "right" },
{ key: "regeneration_qty",label: "재생수량 합계", width: "w-[120px]", align: "right", formatNumber: true },
{ key: "final_good_qty", label: "최종양품수량 합계", width: "w-[140px]", align: "right", formatNumber: true },
];
export default function SemiProductInspectionPage() {
@@ -40,34 +41,47 @@ export default function SemiProductInspectionPage() {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState({
model_name: "",
part_no: "",
part_name: "",
writer: "",
from: "",
to: "",
search_model_name: "",
search_work_order_no: "",
search_part_no: "",
search_part_name: "",
inspection_start_date: "",
inspection_end_date: "",
search_writer: "",
search_defect_type: "",
search_responsible_dept: "",
});
const fetchList = useCallback(async () => {
if (!user) return;
setLoading(true);
try {
const params: Record<string, string> = {};
Object.entries(search).forEach(([k, v]) => { if (v) params[k] = v; });
const res = await qualityApi.semiProductInspection(params);
const res = await qualityApi.semiProductInspection(search);
setRows(res.list.map((r) => ({ ...r, id: r.objid } as any)));
setSelectedId(null);
} catch {
toast.error("반제품검사 목록 조회 실패");
} finally {
setLoading(false);
}
} catch { toast.error("반제품검사 목록 조회 실패"); }
finally { setLoading(false); }
}, [user, search]);
useEffect(() => { fetchList(); }, [fetchList]);
const handleReset = () =>
setSearch({ model_name: "", part_no: "", part_name: "", writer: "", from: "", to: "" });
const handleReset = () => setSearch({
search_model_name: "", search_work_order_no: "",
search_part_no: "", search_part_name: "",
inspection_start_date: "", inspection_end_date: "",
search_writer: "", search_defect_type: "", search_responsible_dept: "",
});
const summary = (() => {
const fmt = (n: number) => n.toLocaleString();
return [
{ label: "총 입고수량", value: fmt(rows.reduce((a, r) => a + Number(r.receipt_qty || 0), 0)) },
{ label: "총 양품수량", value: fmt(rows.reduce((a, r) => a + Number(r.good_qty || 0), 0)) },
{ label: "총 불량수량", value: fmt(rows.reduce((a, r) => a + Number(r.defective_qty || 0), 0)) },
{ label: "총 재생수량", value: fmt(rows.reduce((a, r) => a + Number(r.regeneration_qty || 0), 0)) },
{ label: "총 최종양품수량", value: fmt(rows.reduce((a, r) => a + Number(r.final_good_qty || 0), 0)) },
];
})();
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
@@ -82,28 +96,33 @@ export default function SemiProductInspectionPage() {
}
/>
<CompactFilterBar totalText={<> {rows.length.toLocaleString()}</>}>
<CompactFilterField label="모델명" width={160}>
<Input value={search.model_name}
onChange={(e) => setSearch({ ...search, model_name: e.target.value })} />
<CompactFilterField label="품명(모델명)" width={160}>
<Input value={search.search_model_name} onChange={(e) => setSearch({ ...search, search_model_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="부품품번" width={140}>
<Input value={search.part_no}
onChange={(e) => setSearch({ ...search, part_no: e.target.value })} />
<CompactFilterField label="작업지시번호" width={150}>
<Input value={search.search_work_order_no} onChange={(e) => setSearch({ ...search, search_work_order_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="부품품번" width={150}>
<Input value={search.search_part_no} onChange={(e) => setSearch({ ...search, search_part_no: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="부품명" width={160}>
<Input value={search.part_name}
onChange={(e) => setSearch({ ...search, part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사자 ID" width={130}>
<Input value={search.writer}
onChange={(e) => setSearch({ ...search, writer: e.target.value })} />
<Input value={search.search_part_name} onChange={(e) => setSearch({ ...search, search_part_name: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="검사일" width={280}>
<CompactDateRange
from={search.from} setFrom={(v) => setSearch({ ...search, from: v })}
to={search.to} setTo={(v) => setSearch({ ...search, to: v })}
from={search.inspection_start_date} setFrom={(v) => setSearch({ ...search, inspection_start_date: v })}
to={search.inspection_end_date} setTo={(v) => setSearch({ ...search, inspection_end_date: v })}
/>
</CompactFilterField>
<CompactFilterField label="검사자 ID" width={120}>
<Input value={search.search_writer} onChange={(e) => setSearch({ ...search, search_writer: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="불량유형 ID" width={130}>
<Input value={search.search_defect_type} onChange={(e) => setSearch({ ...search, search_defect_type: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="귀책부서 ID" width={130}>
<Input value={search.search_responsible_dept} onChange={(e) => setSearch({ ...search, search_responsible_dept: e.target.value })} />
</CompactFilterField>
</CompactFilterBar>
<DataGrid
gridId="quality-semi-product-inspection"
@@ -116,6 +135,7 @@ export default function SemiProductInspectionPage() {
selectedId={selectedId}
onSelect={setSelectedId}
emptyMessage="조회된 반제품검사 내역이 없습니다."
summaryStats={summary}
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
+37 -18
View File
@@ -1,13 +1,13 @@
/**
* 품질관리 API 클라이언트 — 4개 메뉴 list 엔드포인트.
* 품질관리 API 클라이언트 — wace_plm quality.xml 1:1.
* 백엔드: backend-node/src/routes/qualityRoutes.ts
*/
import { apiClient } from "./client";
export interface IncomingRequestRow {
objid: string;
purchase_order_no: string;
proposal_no?: string;
purchase_order_no?: string;
project_no?: string;
product_name?: string;
part_no?: string;
@@ -20,13 +20,26 @@ export interface IncomingRequestRow {
request_status?: string;
}
export interface IncomingMgmtRow extends IncomingRequestRow {
inspector_name?: string;
export interface IncomingMgmtRow {
objid: string;
inspection_date?: string;
total_qty?: number;
good_qty?: number;
bad_qty?: number;
inspector_name?: string;
proposal_no?: string;
purchase_order_no?: string;
project_no?: string;
product_name?: string;
model_name?: string;
part_no?: string;
part_name?: string;
partner_name?: string;
delivery_date?: string;
delivery_qty?: number;
delivery_status?: string;
inspection_qty?: number;
defect_qty_sum?: number;
defect_rate?: number;
inspection_result?: string;
inspection_file_cnt?: number;
}
export interface ProcessInspectionRow {
@@ -42,15 +55,15 @@ export interface ProcessInspectionRow {
work_env_status?: string;
measuring_device?: string;
inspection_result?: string;
file_count?: number;
process_inspection_file_cnt?: number;
}
export interface SemiProductInspectionRow {
objid: string;
inspection_date?: string;
writer_name?: string;
model_name?: string;
product_type?: string;
model_name?: string;
work_order_no?: string;
part_no?: string;
part_name?: string;
@@ -68,13 +81,19 @@ interface ListResp<T> {
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}
export const qualityApi = {
incomingRequest: async (params: Record<string, string> = {}): Promise<ListResp<IncomingRequestRow>> =>
(await apiClient.get("/quality/incoming-request", { params })).data,
incomingMgmt: async (params: Record<string, string> = {}): Promise<ListResp<IncomingMgmtRow>> =>
(await apiClient.get("/quality/incoming-mgmt", { params })).data,
processInspection: async (params: Record<string, string> = {}): Promise<ListResp<ProcessInspectionRow>> =>
(await apiClient.get("/quality/process-inspection", { params })).data,
semiProductInspection: async (params: Record<string, string> = {}): Promise<ListResp<SemiProductInspectionRow>> =>
(await apiClient.get("/quality/semi-product-inspection", { params })).data,
const buildParams = (obj: Record<string, string | undefined>): Record<string, string> => {
const p: Record<string, string> = {};
Object.entries(obj).forEach(([k, v]) => { if (v) p[k] = v; });
return p;
};
export const qualityApi = {
incomingRequest: async (params: Record<string, string | undefined> = {}): Promise<ListResp<IncomingRequestRow>> =>
(await apiClient.get("/quality/incoming-request", { params: buildParams(params) })).data,
incomingMgmt: async (params: Record<string, string | undefined> = {}): Promise<ListResp<IncomingMgmtRow>> =>
(await apiClient.get("/quality/incoming-mgmt", { params: buildParams(params) })).data,
processInspection: async (params: Record<string, string | undefined> = {}): Promise<ListResp<ProcessInspectionRow>> =>
(await apiClient.get("/quality/process-inspection", { params: buildParams(params) })).data,
semiProductInspection: async (params: Record<string, string | undefined> = {}): Promise<ListResp<SemiProductInspectionRow>> =>
(await apiClient.get("/quality/semi-product-inspection", { params: buildParams(params) })).data,
};