# 절단계획 관리 작업 진행상황 > 마지막 업데이트: 2026-04-21 > 작업자: gbpark (COMPANY_30 / 중앙안전유) ## 작업 개요 유리 절단 최적화를 위한 절단계획 관리 페이지 구축. Guillotine 절단 + 자투리 관리 + 배치번호 추적. ## 핵심 파일 - **알고리즘**: `frontend/lib/cutting/packing.ts` - **메인 페이지**: `frontend/app/(main)/COMPANY_30/production/cutting-plan/page.tsx` - **백엔드 서비스**: `backend-node/src/services/cuttingPlanService.ts` - **컨트롤러**: `backend-node/src/controllers/cuttingPlanController.ts` - **라우트**: `backend-node/src/routes/cuttingPlanRoutes.ts` ## 완료된 작업 ### 1. 알고리즘 - [x] 혼합 최적 / 동일 품목 우선 알고리즘 분리 (구조 자체가 다름) - [x] MaxRects → 2-stage Guillotine FFDH 전환 (직선 절단만 가능하도록) - [x] `packAreaHomogeneous`: 실제 maxCap 측정 후 전용 sheet + 잔여물 혼합 방식 ### 2. 자투리(Remnant) 관리 - [x] Plane sweep 기반 빈 영역 검출 (`computeRemnants`) - [x] kerf 너비 false positive 필터링 (`w > minDim && h > minDim`) - [x] Union-Find으로 인접 자투리 그룹화 - [x] 그룹 폴리곤 외곽선 계산 (`computeGroupOutline`) - [x] 그룹 분할: 가로/세로/큰 사각형 우선 (`decomposeUnion`) - [x] 자투리 개별 상태 토글 (보관/폐기), shift+click 지원 - [x] 수동 편집 화면 + 확대(Zoom) 화면 모두에서 토글 가능 ### 3. 드래그 동작 - [x] kerf 고려한 nearest-limit 스냅 (수동편집 + 확대편집) ### 4. 배치번호(Batch No) 추적 - [x] DB: cutting_plan_item.src_no → 수주 LEFT JOIN - [x] `getOrders` SQL 수정: `cpm.id AS batch_id, cpm.plan_no AS batch_no` - [x] `savePlan`: src_orders 배열로 처리, 첫 행만 qty 보유 - [x] excludeInPlan 수정: production_plan + shipment_plan만 제외 (cutting_plan은 중복 허용) - [x] 배치번호 클릭 → 전체 계획 로드 (`loadPlan`) ## 미해결 이슈 (다음 세션 시작점) ### **자투리 "보관" 상태 영속화** (2026-04-21 1차 처리 완료, UI 검증 대기) **수정 내용**: - ✅ `cutting_plan_sheet`에 `remnants JSONB` 컬럼 추가 (ALTER 실행 완료, vexplor_dev) - ✅ `backend-node/src/services/cuttingPlanService.ts` — `savePlan` INSERT에 `remnants` 파라미터 14번째 추가 (`JSON.stringify`) - ✅ `getPlanDetail`은 `SELECT *`이므로 자동 포함 (pg가 JSONB → JS 배열 자동 파싱) - ✅ `frontend/app/(main)/COMPANY_30/production/cutting-plan/page.tsx` - `savePlan` payload area sheets에 `remnants: sh.remnants ?? null` 추가 - `loadPlan` area Sheet 복원 시 `remnants: Array.isArray(sh.remnants) ? ... : undefined` 추가 **UI 검증 (사용자 확인 완료)**: - DB 저장/복원 동작. 단, 배치번호 클릭 한 번으로는 안 보이고 두 번째 클릭에서야 복원되는 증상. **추가 원인 & 조치 (2026-04-21 2차)**: - page.tsx line ~364 useEffect(`[packMode, cutType, kerf, margin, mat1.code, mat2.code]`)가 loadPlan의 `setCutType/setKerf/...`에 반응해 `calculate()`를 호출 → 방금 복원한 remnants를 덮어씀. - 2번째 클릭엔 state가 같아 useEffect 안 돌고 그대로 유지. - **수정**: `skipAutoRecalcRef` (useRef) 도입. loadPlan 시작 시 true 세팅, 직후 useEffect에서 1회 소비 후 false. - 관련 위치: page.tsx (`const skipAutoRecalcRef = useRef(false)` 선언부, 재계산 useEffect 상단 가드, loadPlan 초입 세팅). **인프라 이슈 (참고)**: - 사무실 우분투 `pms-backend-mac` 컨테이너는 Syncthing이 파일 교체 순간 cwd가 사라지며 nodemon이 `uv_cwd` ENOENT로 죽을 수 있음. Hot reload 이상 시 `docker restart pms-backend-mac`. **자투리 관리 탭 집계 수정 (2026-04-21 3차)**: - 증상: 배치 계획 탭에서 "혼합 1/2"로 표시된 그룹이 자투리 관리 탭에서는 "보관 0개"로 잘못 집계. - 원인 1 (표시): `RemnantView`가 그룹 단위 row 생성 + `allKeep = every("keep")` 기준 → 혼합 그룹이 전부 discard로 분류. - **수정**: `groups.forEach` 내부에서 rects를 `status` 기준으로 split하여 keep/discard 각각 row 생성. - 원인 2 (저장): `sheetGroups`의 대표 sheet에만 `remnants`가 있고 비대표 5장은 null → 로드 시 비대표는 기본 "discard"로 집계. - **수정**: `savePlan` payload 빌드 시 `sheetGroups.representative.remnants`를 같은 그룹 indices에 복제. 개별 sheet가 자체 remnants를 갖고 있으면 그대로 유지. **남은 잠재 이슈 (확대편집 모달 관련)**: 1. `ZoomEditorModal`의 `statusOverrides`는 lazy init이라 sheet.remnants 변경 시 stale 2. piece drag 핸들러에서 `remnants: undefined` 설정 → 수동 결정 손실 3. 비대표 sheet 개별 편집 시 그룹 전파 기본값이 그 sheet를 덮지는 않지만, 대표 편집이 이후에 오면 비대표 개별 편집이 덮어질 여지 (현재는 대표에서만 편집 UI 제공이라 문제 없음) ## 보류 작업 (사용자가 나중에 진행) - [ ] 중앙 품목 페이지 헤더의 필터/정렬 기능을 절단계획 수주 탭에 적용 - [ ] Step 3: 마우스로 절단선 직접 그리기 (interactive cutting) - [ ] Step 4: 드래그로 새 자투리 생성 (manual remnant drawing) ## DB 검증 정보 (vexplor_dev) - 서버: `211.115.91.141:11134/vexplor_dev` - 검증된 데이터: `CP-2026-0001` (5 수주), `CP-2026-0002` (4 수주) - 구조: cutting_plan_mng → cutting_plan_item(src_no=수주FK) → cutting_plan_sheet → cutting_plan_placement - `cutting_plan_sheet.remnants JSONB` (2026-04-21 추가): `[{id,x,y,w,h,status:"keep"|"discard"}]` ## 핵심 코드 스니펫 **ZoomEditorModal statusOverrides 초기화** (page.tsx): ```typescript const [statusOverrides, setStatusOverrides] = useState>(() => { const init: Record = {}; if (sheet.remnants) { sheet.remnants.forEach((rm) => { init[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = rm.status; }); } return init; }); ``` **자투리 필터 (packing.ts)**: ```typescript const minDim = Math.max(1, kerf); return finalRects.filter((r) => r.w > minDim && r.h > minDim); ```