feat: POP 작업상세 체크리스트 UI를 judgment_criteria 기반으로 매핑

- 백엔드: /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로 폴백
This commit is contained in:
SeongHyun Kim
2026-04-07 12:06:01 +09:00
parent 9361b2484a
commit 450247026c
3 changed files with 238 additions and 7 deletions

View File

@@ -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 });
}
};

View File

@@ -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;

View File

@@ -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")) {