From 450247026c0ea2153cd014ad7b1631d03eefdd2b Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Tue, 7 Apr 2026 12:06:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20POP=20=EC=9E=91=EC=97=85=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=B2=B4=ED=81=AC=EB=A6=AC=EC=8A=A4=ED=8A=B8=20UI?= =?UTF-8?q?=EB=A5=BC=20judgment=5Fcriteria=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: /api/pop/production/checklist-items/:processId 신규 추가 process_work_result ⟕ inspection_standard(judgment_criteria) LEFT JOIN으로 한 번에 반환 (inspection_code 미설정 시 null로 안전) - 프론트: resolveInputType + normalizeByJudgmentCriteria + normalizeByDetailType 1순위: judgment_criteria (CAT_JC_01~04) → inspect/numeric_range/ox/select/text 2순위: detail_type 폴백 - checklist/equip_inspection → check(토글) - inspection/equip_condition/production_result → inspect(숫자 키패드) - lookup → input(텍스트) - material_input → material(BOM 자재) 기존 canonical 값은 건드리지 않아 회귀 없음 - fetchData는 전용 API 사용 + 실패 시 기존 dataApi.getTableData로 폴백 --- .../controllers/popProductionController.ts | 81 +++++++++ .../src/routes/popProductionRoutes.ts | 2 + .../PopWorkDetailComponent.tsx | 162 +++++++++++++++++- 3 files changed, 238 insertions(+), 7 deletions(-) diff --git a/backend-node/src/controllers/popProductionController.ts b/backend-node/src/controllers/popProductionController.ts index e118921b..3100adef 100644 --- a/backend-node/src/controllers/popProductionController.ts +++ b/backend-node/src/controllers/popProductionController.ts @@ -2969,3 +2969,84 @@ export const getMaterialInputs = async (req: AuthenticatedRequest, res: Response return res.status(500).json({ success: false, message: error.message }); } }; + +/** + * 체크리스트 조회 (judgment_criteria 조인 포함) + * + * process_work_result를 조회하면서 inspection_standard.judgment_criteria를 + * LEFT JOIN으로 같이 반환한다. + * + * UI는 프론트의 resolveInputType()에서 + * 1순위: judgment_criteria (CAT_JC_01~04) + * 2순위: detail_type 폴백 + * 으로 입력 UI를 결정한다. + */ +export const getChecklistItems = async ( + req: AuthenticatedRequest, + res: Response +) => { + const pool = getPool(); + try { + const companyCode = req.user!.companyCode; + const { processId } = req.params; + if (!processId) { + return res + .status(400) + .json({ success: false, message: "processId 필수" }); + } + + const result = await pool.query( + `SELECT + pwr.id, + pwr.company_code, + pwr.work_order_process_id, + pwr.source_work_item_id, + pwr.source_detail_id, + pwr.work_phase, + pwr.item_title, + pwr.item_sort_order, + pwr.detail_content, + pwr.detail_type, + pwr.detail_sort_order, + pwr.is_required, + pwr.inspection_code, + pwr.inspection_method, + pwr.unit, + pwr.lower_limit, + pwr.upper_limit, + pwr.input_type, + pwr.lookup_target, + pwr.display_fields, + pwr.duration_minutes, + pwr.status, + pwr.result_value, + pwr.is_passed, + pwr.remark, + pwr.recorded_by, + pwr.recorded_at, + pwr.started_at, + pwr.group_started_at, + pwr.group_paused_at, + pwr.group_total_paused_time, + pwr.group_completed_at, + ist.judgment_criteria + FROM process_work_result pwr + LEFT JOIN inspection_standard ist + ON pwr.inspection_code = ist.inspection_code + AND pwr.company_code = ist.company_code + WHERE pwr.work_order_process_id = $1 + AND pwr.company_code = $2 + ORDER BY + COALESCE(NULLIF(pwr.item_sort_order, '')::int, 0), + COALESCE(NULLIF(pwr.detail_sort_order, '')::int, 0)`, + [processId, companyCode] + ); + + return res.json({ success: true, data: result.rows }); + } catch (error: any) { + logger.error("[pop/production] checklist-items 조회 오류:", error); + return res + .status(500) + .json({ success: false, message: error.message }); + } +}; diff --git a/backend-node/src/routes/popProductionRoutes.ts b/backend-node/src/routes/popProductionRoutes.ts index ff383f23..36821e5b 100644 --- a/backend-node/src/routes/popProductionRoutes.ts +++ b/backend-node/src/routes/popProductionRoutes.ts @@ -22,6 +22,7 @@ import { getBomMaterials, saveMaterialInput, getMaterialInputs, + getChecklistItems, } from "../controllers/popProductionController"; const router = Router(); @@ -49,5 +50,6 @@ router.get("/rework-history/:woId", getReworkHistory); router.get("/bom-materials/:processId", getBomMaterials); router.post("/material-input", saveMaterialInput); router.get("/material-inputs/:processId", getMaterialInputs); +router.get("/checklist-items/:processId", getChecklistItems); export default router; diff --git a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx index 3ae24070..d6ace35f 100644 --- a/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-work-detail/PopWorkDetailComponent.tsx @@ -56,6 +56,141 @@ interface WorkResultRow { group_completed_at: string | null; inspection_method: string | null; unit: string | null; + inspection_code?: string | null; + judgment_criteria?: string | null; +} + +// ======================================== +// 입력 UI 타입 해석 +// ======================================== +// +// 1순위: inspection_standard.judgment_criteria (CAT_JC_01~04) +// - CAT_JC_01: 수치(범위) → "number" (숫자 키패드) +// - CAT_JC_02: 텍스트입력 → "text" (텍스트 입력) +// - CAT_JC_03: O/X → "checkbox" (큰 토글 버튼) +// - CAT_JC_04: 선택형 → "select" (선택 버튼) +// +// 2순위: detail_type 폴백 (inspection_code가 없거나 judgment_criteria가 비어있을 때) +type ResolvedInputType = "number" | "text" | "checkbox" | "select"; + +function resolveInputType(item: { + judgment_criteria?: string | null; + detail_type?: string | null; + input_type?: string | null; +}): ResolvedInputType { + // 1순위: judgment_criteria + if (item.judgment_criteria) { + switch (item.judgment_criteria) { + case "CAT_JC_01": + return "number"; + case "CAT_JC_02": + return "text"; + case "CAT_JC_03": + return "checkbox"; + case "CAT_JC_04": + return "select"; + } + } + // 2순위: detail_type 폴백 + switch (item.detail_type) { + case "checklist": + case "check": + case "procedure": + case "equip_inspection": + return "checkbox"; + case "inspection": + case "equip_condition": + case "production_result": + return "number"; + case "input": + return "text"; + case "lookup": + return "text"; + default: + // 마지막 보루: 기존 input_type 값 유지 (number/checkbox가 들어있을 수 있음) + if (item.input_type === "number") return "number"; + if (item.input_type === "checkbox") return "checkbox"; + if (item.input_type === "select") return "select"; + return "text"; + } +} + +/** + * judgment_criteria가 있는 경우 item을 "inspect" 라우터가 올바르게 + * 분기하도록 detail_type/input_type을 정규화한다. + * + * CAT_JC_01(수치) → detail_type=inspect, input_type=numeric_range + * CAT_JC_02(텍스트) → detail_type=inspect, input_type=text + * CAT_JC_03(O/X) → detail_type=inspect, input_type=ox + * CAT_JC_04(선택형) → detail_type=inspect, input_type=select + * + * judgment_criteria가 없으면 원본을 그대로 반환(detail_type 폴백). + */ +function normalizeByJudgmentCriteria(item: WorkResultRow): WorkResultRow { + if (!item.judgment_criteria) return item; + let inputType: string | null = null; + switch (item.judgment_criteria) { + case "CAT_JC_01": + inputType = "numeric_range"; + break; + case "CAT_JC_02": + inputType = "text"; + break; + case "CAT_JC_03": + inputType = "ox"; + break; + case "CAT_JC_04": + inputType = "select"; + break; + default: + return item; + } + return { ...item, detail_type: "inspect", input_type: inputType }; +} + +/** + * detail_type 폴백 정규화. + * DB에 실제로 들어있는 원본 detail_type 값(`checklist`, `equip_inspection`, + * `inspection`, `production_result`, `lookup`, `material_input` 등)을 + * 기존 라우터가 알고 있는 canonical 값(`check`, `inspect`, `input`, `material`)으로 + * 매핑한다. + * + * 이 단계는 judgment_criteria 정규화가 일어나지 않은 경우에만 영향을 준다. + */ +function normalizeByDetailType(item: WorkResultRow): WorkResultRow { + const dt = item.detail_type ?? ""; + // 이미 canonical 타입 or inspect_* 레거시이면 그대로 둔다 + if ( + dt === "check" || + dt === "input" || + dt === "procedure" || + dt === "material" || + dt === "result" || + dt === "info" || + dt === "" || + dt.startsWith("inspect") + ) { + return item; + } + switch (dt) { + // 토글(확인/미확인) — checklist/장비점검/절차류 + case "checklist": + case "equip_inspection": + return { ...item, detail_type: "check" }; + // 숫자 키패드 — 검사/장비상태/생산실적 + case "inspection": + case "equip_condition": + case "production_result": + return { ...item, detail_type: "inspect", input_type: item.input_type || "numeric_range" }; + // 텍스트 — 자유입력/조회 + case "lookup": + return { ...item, detail_type: "input", input_type: item.input_type || "text" }; + // 자재 투입 — BOM 자재 목록 + case "material_input": + return { ...item, detail_type: "material" }; + default: + return item; + } } interface GroupTimerState { @@ -537,16 +672,24 @@ export function PopWorkDetailComponent({ try { setLoading(true); const [resultRes, processRes] = await Promise.all([ - dataApi.getTableData("process_work_result", { - size: 500, - filters: { work_order_process_id: workOrderProcessId }, - }), + // POP 전용 API: inspection_standard.judgment_criteria 조인 포함 + apiClient + .get(`/pop/production/checklist-items/${workOrderProcessId}`) + .then((res) => ({ data: (res.data?.data ?? []) as WorkResultRow[] })) + .catch(async () => { + // 폴백: 전용 API가 없는 환경에서는 기존 일반 조회 사용 + const fb = await dataApi.getTableData("process_work_result", { + size: 500, + filters: { work_order_process_id: workOrderProcessId }, + }); + return { data: (fb.data ?? []) as unknown as WorkResultRow[] }; + }), dataApi.getTableData("work_order_process", { size: 1, filters: { id: workOrderProcessId }, }), ]); - setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]); + setAllResults(resultRes.data); const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null; setProcessData(proc); if (proc) { @@ -3074,8 +3217,11 @@ interface ChecklistItemProps { onSave: (rowId: string, resultValue: string, isPassed: string | null, newStatus: string) => void; } -function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) { +function ChecklistItem({ item: rawItem, saving, disabled, onSave }: ChecklistItemProps) { const isDisabled = disabled || saving; + // 1순위: judgment_criteria 기반 정규화 (inspection_standard 조인 결과) + // 2순위: detail_type 폴백 정규화 (원본 DB 값 → canonical 라우터 값) + const item = normalizeByDetailType(normalizeByJudgmentCriteria(rawItem)); const dt = item.detail_type ?? ""; // "inspect_numeric" 등 레거시 형식 → "inspect"로 정규화, 접미사를 input_type 폴백으로 사용 @@ -3119,8 +3265,10 @@ function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) { // 체크리스트 입력부 (우측 영역 전용 - 분할 레이아웃) // ======================================== -function ChecklistItemInput({ item, saving, disabled, onSave }: ChecklistItemProps) { +function ChecklistItemInput({ item: rawItem, saving, disabled, onSave }: ChecklistItemProps) { const isDisabled = disabled || saving; + // 1순위: judgment_criteria, 2순위: detail_type 폴백 + const item = normalizeByDetailType(normalizeByJudgmentCriteria(rawItem)); const dt = item.detail_type ?? ""; if (dt.startsWith("inspect")) {