# 절단계획 관리 작업 진행상황

> 마지막 업데이트: 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<Record<string, "keep" | "discard">>(() => {
  const init: Record<string, "keep" | "discard"> = {};
  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);
```
