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:
parent
9d164d08af
commit
5d12bef5e5
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue