fix: MES 체크리스트 자동 복사 + 구조적 버그 3건 수정

분할 접수/재작업 카드 생성 시 체크리스트가 복사되지 않던 문제를 해결하고,
공정 흐름 모달과 카드 클릭 이벤트 격리 버그를 수정한다.
[체크리스트 자동 복사]
- copyChecklistToSplit 공통 헬퍼 함수 추출
  (createWorkProcesses/acceptProcess/saveResult 3곳에서 통합 사용)
- routing_detail_id 존재 시: process_work_item 템플릿에서 복사
- routing_detail_id 부재 시: 마스터 process_work_result에서 구조만 복사
[ChecklistItem 렌더링 수정]
- 레거시 detail_type(inspect_numeric/inspect_ox 등)을 정규화하여
  detail_type=inspect + input_type 자동 파싱으로 InspectRouter 라우팅
- info 타입 렌더러 추가
[공정 흐름 모달 이벤트 격리]
- Dialog 래퍼에 onClick/onPointerDown stopPropagation 추가
  (모달 닫기 시 부모 카드 onClick 전파 차단)
[카드 클릭 진행 탭 제한]
- handleCardSelect에 VIRTUAL_SUB_STATUS 코드 가드 추가
  (in_progress가 아닌 카드는 작업상세 모달 열림 차단)
- 재작업 카드 포함 접수가능 탭에서 상세 진입 방지
This commit is contained in:
SeongHyun Kim 2026-03-18 18:26:54 +09:00
parent 9d164d08af
commit 5d12bef5e5
4 changed files with 128 additions and 43 deletions

View File

@ -11,6 +11,79 @@ interface DefectDetailItem {
disposition: string;
}
/**
*
* / .
*
* 전략: routingDetailId가 릿,
*/
async function copyChecklistToSplit(
client: { query: (text: string, values?: any[]) => Promise<any> },
masterProcessId: string,
newProcessId: string,
routingDetailId: string | null,
companyCode: string,
userId: string
): Promise<number> {
// A. routing_detail_id가 있으면 원본 템플릿(process_work_item + detail)에서 복사
if (routingDetailId) {
const result = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
pwi.company_code, $1,
pwi.id, pwd.id,
pwi.work_phase, pwi.title, pwi.sort_order::text,
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
'pending', $2
FROM process_work_item pwi
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
AND pwd.company_code = pwi.company_code
WHERE pwi.routing_detail_id = $3
AND pwi.company_code = $4
ORDER BY pwi.sort_order, pwd.sort_order`,
[newProcessId, userId, routingDetailId, companyCode]
);
return result.rowCount ?? 0;
}
// B. routing_detail_id가 없으면 마스터 행의 process_work_result에서 구조만 복사 (타이머/결과값 초기화)
const result = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
company_code, $1,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
'pending', $2
FROM process_work_result
WHERE work_order_process_id = $3
AND company_code = $4
ORDER BY item_sort_order, detail_sort_order`,
[newProcessId, userId, masterProcessId, companyCode]
);
return result.rowCount ?? 0;
}
/**
* D-BE1: 작업지시
* PC에서 . 1 work_order_process + process_work_result .
@ -117,36 +190,10 @@ export const createWorkProcesses = async (
);
const wopId = wopResult.rows[0].id;
// 3. process_work_result INSERT (스냅샷 복사)
// process_work_item + process_work_item_detail에서 해당 routing_detail의 항목 조회 후 복사
const snapshotResult = await client.query(
`INSERT INTO process_work_result (
company_code, work_order_process_id,
source_work_item_id, source_detail_id,
work_phase, item_title, item_sort_order,
detail_content, detail_type, detail_sort_order, is_required,
inspection_code, inspection_method, unit, lower_limit, upper_limit,
input_type, lookup_target, display_fields, duration_minutes,
status, writer
)
SELECT
pwi.company_code, $1,
pwi.id, pwd.id,
pwi.work_phase, pwi.title, pwi.sort_order::text,
pwd.content, pwd.detail_type, pwd.sort_order::text, pwd.is_required,
pwd.inspection_code, pwd.inspection_method, pwd.unit, pwd.lower_limit, pwd.upper_limit,
pwd.input_type, pwd.lookup_target, pwd.display_fields, pwd.duration_minutes::text,
'pending', $2
FROM process_work_item pwi
JOIN process_work_item_detail pwd ON pwd.work_item_id = pwi.id
AND pwd.company_code = pwi.company_code
WHERE pwi.routing_detail_id = $3
AND pwi.company_code = $4
ORDER BY pwi.sort_order, pwd.sort_order`,
[wopId, userId, rd.id, companyCode]
// 3. process_work_result INSERT (공통 함수로 체크리스트 복사)
const checklistCount = await copyChecklistToSplit(
client, wopId, wopId, rd.id, companyCode, userId
);
const checklistCount = snapshotResult.rowCount ?? 0;
totalChecklists += checklistCount;
processes.push({
@ -750,11 +797,19 @@ export const saveResult = async (
masterId, companyCode, userId,
]
);
logger.info("[pop/production] 재작업 카드 자동 생성", {
reworkId: reworkInsert.rows[0]?.id,
sourceId: work_order_process_id,
reworkQty: totalReworkQty,
});
// 재작업 카드에 체크리스트 복사
const reworkId = reworkInsert.rows[0]?.id;
if (reworkId) {
const reworkChecklistCount = await copyChecklistToSplit(
pool, masterId, reworkId, proc.routing_detail_id, companyCode, userId
);
logger.info("[pop/production] 재작업 카드 자동 생성", {
reworkId,
sourceId: work_order_process_id,
reworkQty: totalReworkQty,
checklistCount: reworkChecklistCount,
});
}
}
}
@ -1406,16 +1461,22 @@ export const acceptProcess = async (req: AuthenticatedRequest, res: Response) =>
]
);
// 분할 행에 체크리스트 복사 (마스터의 routing_detail_id 또는 마스터의 기존 체크리스트에서)
const splitId = result.rows[0].id;
const checklistCount = await copyChecklistToSplit(
pool, masterId, splitId, row.routing_detail_id, companyCode, userId
);
// 마스터는 항상 acceptable 유지 (completed 전환은 saveResult에서 처리)
// availableQty=0이면 프론트에서 접수 버튼이 숨겨지므로 상태 변경 불필요
const newTotalInput = currentTotalInput + qty;
logger.info("[pop/production] accept-process 분할 접수 완료", {
companyCode, userId, masterId,
splitId: result.rows[0].id,
splitId,
acceptedQty: qty,
totalAccepted: newTotalInput,
prevGoodQty,
checklistCount,
});
return res.json({

View File

@ -278,17 +278,22 @@ export function PopCardListV2Component({
}, [publish, setSharedData]);
const handleCardSelect = useCallback((row: RowData) => {
// 복제 카드(접수가능 가상)는 클릭 시 모달을 열지 않음 - 접수 버튼으로만 동작
if (row.__isAcceptClone) return;
if (effectiveConfig?.cardClickAction === "modal-open" && effectiveConfig?.cardClickModalConfig?.screenId) {
const mc = effectiveConfig.cardClickModalConfig;
// 작업상세는 "진행(in_progress)" 탭 카드만 열 수 있음
const subStatus = row[VIRTUAL_SUB_STATUS] as string | undefined;
if (subStatus && subStatus !== "in_progress") return;
if (mc.condition && mc.condition.type !== "always") {
const processFlow = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined;
const currentProcess = processFlow?.find((s) => s.isCurrent);
if (mc.condition.type === "timeline-status") {
const condVal = mc.condition.value;
const curStatus = currentProcess?.status;
const curStatus = subStatus || (() => {
const pf = row.__processFlow__ as { isCurrent: boolean; status?: string }[] | undefined;
return pf?.find((s) => s.isCurrent)?.status;
})();
if (Array.isArray(condVal)) {
if (!curStatus || !condVal.includes(curStatus)) return;
} else {

View File

@ -522,6 +522,8 @@ function TimelineCell({ cell, row }: CellRendererProps) {
})}
</div>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
@ -595,6 +597,7 @@ function TimelineCell({ cell, row }: CellRendererProps) {
</div>
</DialogContent>
</Dialog>
</div>
</>
);
}

View File

@ -1690,12 +1690,22 @@ interface ChecklistItemProps {
function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
const isDisabled = disabled || saving;
const dt = item.detail_type ?? "";
switch (item.detail_type) {
// "inspect_numeric" 등 레거시 형식 → "inspect"로 정규화, 접미사를 input_type 폴백으로 사용
if (dt.startsWith("inspect")) {
const normalized = { ...item, detail_type: "inspect" } as WorkResultRow;
if (!normalized.input_type && dt.includes("_")) {
const suffix = dt.split("_").slice(1).join("_");
const typeMap: Record<string, string> = { numeric: "numeric_range", ox: "ox", text: "text", select: "select" };
normalized.input_type = typeMap[suffix] ?? suffix;
}
return <InspectRouter item={normalized} disabled={isDisabled} saving={saving} onSave={onSave} />;
}
switch (dt) {
case "check":
return <CheckItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "inspect":
return <InspectRouter item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "input":
return <InputItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "procedure":
@ -1704,6 +1714,12 @@ function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
return <MaterialItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "result":
return <ResultInputItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
case "info":
return (
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-sm">
{item.detail_label || item.detail_content}
</div>
);
default:
return (
<div className="rounded-lg border p-3 text-sm text-muted-foreground">