diff --git a/CUTTING_PLAN_PROGRESS.md b/CUTTING_PLAN_PROGRESS.md new file mode 100644 index 00000000..f9fd6750 --- /dev/null +++ b/CUTTING_PLAN_PROGRESS.md @@ -0,0 +1,109 @@ +# 절단계획 관리 작업 진행상황 + +> 마지막 업데이트: 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); +``` diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d9b2ece3..fc8fa263 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -155,6 +155,7 @@ import moldRoutes from "./routes/moldRoutes"; // 금형 관리 import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리 import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리 +import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리 import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트 import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별) import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형) @@ -379,6 +380,7 @@ app.use("/api/mold", moldRoutes); // 금형 관리 app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리 app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리 +app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리 app.use("/api/sales-report", salesReportRoutes); // 영업 리포트 app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장) app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지 diff --git a/backend-node/src/controllers/cuttingPlanController.ts b/backend-node/src/controllers/cuttingPlanController.ts new file mode 100644 index 00000000..53316214 --- /dev/null +++ b/backend-node/src/controllers/cuttingPlanController.ts @@ -0,0 +1,97 @@ +/** + * 절단계획 컨트롤러 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as svc from "../services/cuttingPlanService"; +import { logger } from "../utils/logger"; + +function wrap(res: Response, fn: () => Promise, errMsg: string) { + return fn().then( + (data) => res.json({ success: true, data }), + (e: any) => { + logger.error(errMsg, { error: e?.message }); + return res.status(500).json({ success: false, message: e?.message || errMsg }); + } + ); +} + +export function getMaterials(req: AuthenticatedRequest, res: Response) { + const companyCode = req.user!.companyCode; + const cutType = (req.query.cutType as string) || "area"; + return wrap(res, () => svc.getMaterials(companyCode, cutType), "원자재 조회 실패"); +} + +export function searchItems(req: AuthenticatedRequest, res: Response) { + const companyCode = req.user!.companyCode; + const keyword = req.query.keyword as string | undefined; + return wrap(res, () => svc.searchItems(companyCode, keyword), "품목 검색 실패"); +} + +export function getOrders(req: AuthenticatedRequest, res: Response) { + const companyCode = req.user!.companyCode; + const from = req.query.from as string | undefined; + const to = req.query.to as string | undefined; + const keyword = req.query.keyword as string | undefined; + const page = req.query.page ? parseInt(req.query.page as string, 10) : undefined; + const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined; + const excludeInPlan = req.query.excludeInPlan === "true"; + return wrap( + res, + () => svc.getOrders(companyCode, { from, to, keyword, page, limit, excludeInPlan }), + "수주 조회 실패" + ); +} + +export function getPlans(req: AuthenticatedRequest, res: Response) { + const companyCode = req.user!.companyCode; + const { from, to, planNo, status } = req.query; + return wrap(res, () => svc.getPlans(companyCode, { + from: from as string, to: to as string, + planNo: planNo as string, status: status as string, + }), "계획 목록 조회 실패"); +} + +export async function getPlanDetail(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const id = parseInt(req.params.id, 10); + const data = await svc.getPlanDetail(companyCode, id); + if (!data) return res.status(404).json({ success: false, message: "계획을 찾을 수 없습니다" }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("계획 상세 조회 실패", { error: e?.message }); + return res.status(500).json({ success: false, message: e?.message }); + } +} + +export function nextPlanNo(req: AuthenticatedRequest, res: Response) { + const companyCode = req.user!.companyCode; + return wrap(res, () => svc.nextPlanNo(companyCode), "계획번호 생성 실패"); +} + +export async function savePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId || "system"; + const data = await svc.savePlan(companyCode, userId, req.body); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("계획 저장 실패", { error: e?.message }); + return res.status(500).json({ success: false, message: e?.message }); + } +} + +export async function deletePlan(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const id = parseInt(req.params.id, 10); + const ok = await svc.deletePlan(companyCode, id); + if (!ok) return res.status(404).json({ success: false, message: "삭제 대상을 찾을 수 없습니다" }); + return res.json({ success: true }); + } catch (e: any) { + logger.error("계획 삭제 실패", { error: e?.message }); + return res.status(500).json({ success: false, message: e?.message }); + } +} diff --git a/backend-node/src/routes/cuttingPlanRoutes.ts b/backend-node/src/routes/cuttingPlanRoutes.ts new file mode 100644 index 00000000..825f3a50 --- /dev/null +++ b/backend-node/src/routes/cuttingPlanRoutes.ts @@ -0,0 +1,25 @@ +/** + * 절단계획 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as ctrl from "../controllers/cuttingPlanController"; + +const router = Router(); +router.use(authenticateToken); + +// 조회 소스 +router.get("/materials", ctrl.getMaterials); // 원자재 목록 +router.get("/items", ctrl.searchItems); // 품목 검색 +router.get("/orders", ctrl.getOrders); // 수주 목록 + +// 계획 +router.get("/next-plan-no", ctrl.nextPlanNo); +router.get("/plans", ctrl.getPlans); +router.get("/plans/:id", ctrl.getPlanDetail); +router.post("/plans", ctrl.savePlan); +router.put("/plans/:id", ctrl.savePlan); // id는 body에 담겨오거나 경로로 +router.delete("/plans/:id", ctrl.deletePlan); + +export default router; diff --git a/backend-node/src/services/cuttingPlanService.ts b/backend-node/src/services/cuttingPlanService.ts new file mode 100644 index 00000000..5c997ca0 --- /dev/null +++ b/backend-node/src/services/cuttingPlanService.ts @@ -0,0 +1,385 @@ +/** + * 절단계획 서비스 + * - 원자재 / 수주 / 품목 조회 + * - 절단계획 CRUD (헤더 + 품목 + 원판 + 피스 좌표) + */ + +import { getPool } from "../database/db"; + +// ───────────────────────────────────────────────────────── +// 조회: 원자재 (item_info.division = 'CAT_DIV_RAW_MAT') +// ───────────────────────────────────────────────────────── +export async function getMaterials(companyCode: string, cutType: string) { + const pool = getPool(); + const q = ` + SELECT + ii.id, ii.item_number, ii.item_name, + COALESCE(ii.width::numeric, 0) AS width, + COALESCE(ii.height::numeric, 0) AS height, + COALESCE(ii.thickness::numeric, 0) AS thickness, + ii.size, ii.material, + COALESCE(inv.stock, 0) AS stock, + COALESCE(inv.safety, 0) AS safety_stock + FROM item_info ii + LEFT JOIN ( + SELECT item_code, + SUM(COALESCE(NULLIF(current_qty,'')::numeric, 0)) AS stock, + SUM(COALESCE(NULLIF(safety_qty,'')::numeric, 0)) AS safety + FROM inventory_stock + WHERE company_code = $1 + GROUP BY item_code + ) inv ON inv.item_code = ii.item_number + WHERE ii.company_code = $1 + AND ii.division = 'CAT_DIV_RAW_MAT' + AND COALESCE(ii.status,'active') <> 'deleted' + ORDER BY ii.item_name + `; + const r = await pool.query(q, [companyCode]); + return r.rows.map((row: any) => ({ + ...row, + stock: Number(row.stock) || 0, + safety_stock: Number(row.safety_stock) || 0, + length: cutType === "length" ? Number(row.width || 0) : 0, + unit: cutType === "length" ? "개" : "장", + })); +} + +// ───────────────────────────────────────────────────────── +// 조회: 품목 검색 (완제품) +// ───────────────────────────────────────────────────────── +export async function searchItems(companyCode: string, keyword?: string) { + const pool = getPool(); + const params: any[] = [companyCode]; + let where = `company_code = $1 AND division <> 'CAT_DIV_RAW_MAT'`; + if (keyword) { + params.push(`%${keyword}%`); + where += ` AND (item_name ILIKE $2 OR item_number ILIKE $2)`; + } + const q = ` + SELECT id, item_number, item_name, size, + COALESCE(width::numeric,0) AS width, + COALESCE(height::numeric,0) AS height, + COALESCE(thickness::numeric,0) AS thickness + FROM item_info + WHERE ${where} + ORDER BY item_name + LIMIT 200 + `; + const r = await pool.query(q, params); + return r.rows; +} + +// ───────────────────────────────────────────────────────── +// 조회: 수주 소스 — 페이지네이션 + 계획/출하로 넘어간 건 제외 옵션 +// ───────────────────────────────────────────────────────── +export async function getOrders( + companyCode: string, + opts?: { from?: string; to?: string; page?: number; limit?: number; excludeInPlan?: boolean; keyword?: string } +) { + const pool = getPool(); + const params: any[] = [companyCode]; + let where = `so.company_code = $1 AND COALESCE(so.part_name,'') <> ''`; + if (opts?.from) { params.push(opts.from); where += ` AND so.order_date >= $${params.length}`; } + if (opts?.to) { params.push(opts.to); where += ` AND so.order_date <= $${params.length}`; } + if (opts?.keyword) { + params.push(`%${opts.keyword}%`); + where += ` AND (so.order_no ILIKE $${params.length} OR so.part_name ILIKE $${params.length} OR so.part_code ILIKE $${params.length})`; + } + + // 생산계획/출하계획으로 넘어간 수주만 제외 (절단계획은 본 화면에서 관리하므로 보여줌 → 배치번호로 식별) + if (opts?.excludeInPlan) { + where += ` + AND NOT EXISTS (SELECT 1 FROM production_plan_mng pp WHERE pp.order_no = so.order_no AND pp.company_code = so.company_code) + AND NOT EXISTS (SELECT 1 FROM shipment_plan sp WHERE sp.sales_order_id = so.id AND sp.company_code = so.company_code) + `; + } + + // 총 건수 + const cntQ = `SELECT COUNT(*)::int AS total FROM sales_order_mng so WHERE ${where}`; + const cntR = await pool.query(cntQ, params); + const total = cntR.rows[0]?.total || 0; + + // 페이지네이션 + const page = Math.max(1, opts?.page || 1); + const limit = Math.min(1000, Math.max(1, opts?.limit || 100)); + const offset = (page - 1) * limit; + + const q = ` + SELECT so.order_no, so.order_date, so.due_date, so.partner_id, + so.part_code, so.part_name, so.spec, so.material, + COALESCE(so.order_qty::numeric,0) AS order_qty, + COALESCE(so.balance_qty::numeric,0) AS balance_qty, + so.status, + ii.id AS item_id, + ii.item_name AS item_name, + cpm.id AS batch_id, + cpm.plan_no AS batch_no + FROM sales_order_mng so + LEFT JOIN item_info ii + ON ii.item_number = so.part_code + AND ii.company_code = so.company_code + LEFT JOIN cutting_plan_item cpi + ON cpi.src_no = so.order_no + LEFT JOIN cutting_plan_mng cpm + ON cpm.id = cpi.plan_id + AND cpm.company_code = so.company_code + WHERE ${where} + ORDER BY cpm.plan_no DESC NULLS LAST, so.order_date DESC NULLS LAST, so.order_no DESC + LIMIT ${limit} OFFSET ${offset} + `; + const r = await pool.query(q, params); + return { rows: r.rows, total, page, limit }; +} + +// ───────────────────────────────────────────────────────── +// 조회: 계획 목록 +// ───────────────────────────────────────────────────────── +export async function getPlans( + companyCode: string, + filter: { from?: string; to?: string; planNo?: string; status?: string } +) { + const pool = getPool(); + const params: any[] = [companyCode]; + let where = `company_code = $1`; + if (filter.from) { params.push(filter.from); where += ` AND plan_date_from >= $${params.length}`; } + if (filter.to) { params.push(filter.to); where += ` AND plan_date_to <= $${params.length}`; } + if (filter.planNo) { params.push(`%${filter.planNo}%`); where += ` AND plan_no ILIKE $${params.length}`; } + if (filter.status) { params.push(filter.status); where += ` AND status = $${params.length}`; } + const q = ` + SELECT id, plan_no, plan_date_from, plan_date_to, cut_type, pack_mode, calc_mode, + status, partner_id, total_sheets, util_rate, created_date + FROM cutting_plan_mng + WHERE ${where} + ORDER BY id DESC + LIMIT 200 + `; + const r = await pool.query(q, params); + return r.rows; +} + +// ───────────────────────────────────────────────────────── +// 조회: 계획 상세 (헤더 + 품목 + 원판 + 피스) +// ───────────────────────────────────────────────────────── +export async function getPlanDetail(companyCode: string, planId: number) { + const pool = getPool(); + const hdr = await pool.query( + `SELECT * FROM cutting_plan_mng WHERE id = $1 AND company_code = $2`, + [planId, companyCode] + ); + if (!hdr.rows.length) return null; + + // item_info JOIN으로 item_number/item_name 복원. item_id가 null인 옛 데이터는 src_no → sales_order_mng.part_code로 fallback. + const items = await pool.query( + `SELECT cpi.*, + COALESCE(ii_direct.item_number, ii_order.item_number) AS item_number, + COALESCE(ii_direct.item_name, ii_order.item_name, cpi.item_name) AS item_name_resolved + FROM cutting_plan_item cpi + LEFT JOIN item_info ii_direct ON ii_direct.id::text = cpi.item_id + LEFT JOIN sales_order_mng so ON so.order_no = cpi.src_no AND so.company_code = $2 + LEFT JOIN item_info ii_order ON ii_order.item_number = so.part_code AND ii_order.company_code = $2 + WHERE cpi.plan_id = $1 + ORDER BY cpi.seq, cpi.id`, + [planId, companyCode] + ); + const sheets = await pool.query(`SELECT * FROM cutting_plan_sheet WHERE plan_id = $1 ORDER BY sheet_no`, [planId]); + const sheetIds = sheets.rows.map((s: any) => s.id); + let placements: any[] = []; + if (sheetIds.length) { + const p = await pool.query( + `SELECT * FROM cutting_plan_placement WHERE sheet_id = ANY($1::int[]) ORDER BY sheet_id, placement_order, id`, + [sheetIds] + ); + placements = p.rows; + } + return { header: hdr.rows[0], items: items.rows, sheets: sheets.rows, placements }; +} + +// ───────────────────────────────────────────────────────── +// 다음 계획번호 생성: CP-YYYY-NNNN +// ───────────────────────────────────────────────────────── +export async function nextPlanNo(companyCode: string): Promise { + const pool = getPool(); + const y = new Date().getFullYear(); + const prefix = `CP-${y}-`; + const r = await pool.query( + `SELECT plan_no FROM cutting_plan_mng + WHERE company_code=$1 AND plan_no LIKE $2 + ORDER BY plan_no DESC LIMIT 1`, + [companyCode, `${prefix}%`] + ); + let next = 1; + if (r.rows.length) { + const last = r.rows[0].plan_no as string; + const tail = parseInt(last.substring(prefix.length), 10); + if (!isNaN(tail)) next = tail + 1; + } + return `${prefix}${String(next).padStart(4, "0")}`; +} + +// ───────────────────────────────────────────────────────── +// 계획 저장 (신규 또는 업데이트) — 헤더+자식 전체 대체 +// payload: { header, items, sheets, placements } +// sheets[i].placements[j] 구조도 허용 (중첩형) +// ───────────────────────────────────────────────────────── +export async function savePlan(companyCode: string, userId: string, payload: any) { + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + const h = payload.header || {}; + let planId: number; + + if (h.id) { + // UPDATE 헤더 + const up = await client.query( + `UPDATE cutting_plan_mng SET + plan_no=$1, plan_date_from=$2, plan_date_to=$3, + cut_type=$4, calc_mode=$5, pack_mode=$6, + mat_item_id=$7, mat_item_id_2=$8, + kerf=$9, margin=$10, min_remnant=$11, min_reuse=$12, + partner_id=$13, status=$14, + total_sheets=$15, total_pieces=$16, util_rate=$17, total_loss=$18, rotated_count=$19, + remarks=$20, updated_date=NOW(), updated_by=$21 + WHERE id=$22 AND company_code=$23 + RETURNING id`, + [ + h.plan_no, h.plan_date_from || null, h.plan_date_to || null, + h.cut_type || "area", h.calc_mode || "auto", h.pack_mode || "mixed", + h.mat_item_id || null, h.mat_item_id_2 || null, + h.kerf ?? 3, h.margin ?? 2, h.min_remnant ?? 100, h.min_reuse ?? 100, + h.partner_id || null, h.status || "draft", + h.total_sheets ?? null, h.total_pieces ?? null, h.util_rate ?? null, h.total_loss ?? null, h.rotated_count ?? null, + h.remarks || null, userId, + h.id, companyCode, + ] + ); + if (!up.rows.length) throw new Error("계획을 찾을 수 없거나 권한이 없습니다"); + planId = up.rows[0].id; + + // 자식 전체 삭제 후 재등록 + await client.query(`DELETE FROM cutting_plan_placement WHERE sheet_id IN (SELECT id FROM cutting_plan_sheet WHERE plan_id=$1)`, [planId]); + await client.query(`DELETE FROM cutting_plan_sheet WHERE plan_id=$1`, [planId]); + await client.query(`DELETE FROM cutting_plan_item WHERE plan_id=$1`, [planId]); + } else { + // INSERT 헤더 + const plan_no = h.plan_no || (await nextPlanNo(companyCode)); + const ins = await client.query( + `INSERT INTO cutting_plan_mng + (company_code, plan_no, plan_date_from, plan_date_to, + cut_type, calc_mode, pack_mode, + mat_item_id, mat_item_id_2, + kerf, margin, min_remnant, min_reuse, + partner_id, status, + total_sheets, total_pieces, util_rate, total_loss, rotated_count, + remarks, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + RETURNING id`, + [ + companyCode, plan_no, h.plan_date_from || null, h.plan_date_to || null, + h.cut_type || "area", h.calc_mode || "auto", h.pack_mode || "mixed", + h.mat_item_id || null, h.mat_item_id_2 || null, + h.kerf ?? 3, h.margin ?? 2, h.min_remnant ?? 100, h.min_reuse ?? 100, + h.partner_id || null, h.status || "draft", + h.total_sheets ?? null, h.total_pieces ?? null, h.util_rate ?? null, h.total_loss ?? null, h.rotated_count ?? null, + h.remarks || null, userId, + ] + ); + planId = ins.rows[0].id; + } + + // 품목 저장 — 원래 index (프론트 itemIdx) 를 id 매핑용으로 추적 + // 한 PlanItem이 여러 수주를 합산했을 때(src_orders.length > 1)는 수주별 row 분리 저장 + // (각 수주에 batch_no 연결되도록). 첫 row에만 piece 정보 + 나머지는 같은 메타 + qty 0. + const itemIdMap = new Map(); // 프론트 itemIdx → DB id (첫 row) + const items = payload.items || []; + for (let i = 0; i < items.length; i++) { + const it = items[i]; + const srcList: (string | null)[] = + Array.isArray(it.src_orders) && it.src_orders.length > 0 + ? it.src_orders + : [it.src_no || null]; + let firstId: number | null = null; + for (let k = 0; k < srcList.length; k++) { + const orderNo = srcList[k]; + const r = await client.query( + `INSERT INTO cutting_plan_item + (plan_id, seq, src_type, src_no, item_id, item_name, item_spec, + width, height, length, qty, dir, color, placed_qty) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + RETURNING id`, + [ + planId, it.seq ?? i + 1, + it.src_type || (orderNo ? "order" : "manual"), orderNo, + it.item_id || null, it.item_name || "", it.item_spec || null, + it.width ?? null, it.height ?? null, it.length ?? null, + k === 0 ? (it.qty ?? 0) : 0, // 첫 row에만 qty (배치/통계용) + it.dir || "무관", it.color || null, + k === 0 ? (it.placed_qty ?? 0) : 0, + ] + ); + if (firstId === null) firstId = r.rows[0].id; + } + if (firstId !== null) itemIdMap.set(i, firstId); + } + + // 원판(시트) + 배치 저장 — payload.sheets[i].placements[] 중첩형 지원 + const sheets = payload.sheets || []; + for (let s = 0; s < sheets.length; s++) { + const sh = sheets[s]; + const sr = await client.query( + `INSERT INTO cutting_plan_sheet + (plan_id, sheet_no, mat_item_id, mat_name, + mat_width, mat_height, mat_length, + util_rate, used_area, remnant_area, used_length, remnant_length, group_key, remnants) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) + RETURNING id`, + [ + planId, sh.sheet_no ?? s + 1, sh.mat_item_id || null, sh.mat_name || null, + sh.mat_width ?? null, sh.mat_height ?? null, sh.mat_length ?? null, + sh.util_rate ?? null, sh.used_area ?? null, sh.remnant_area ?? null, + sh.used_length ?? null, sh.remnant_length ?? null, sh.group_key || null, + Array.isArray(sh.remnants) ? JSON.stringify(sh.remnants) : null, + ] + ); + const sheetId = sr.rows[0].id; + + const placements = sh.placements || []; + for (let p = 0; p < placements.length; p++) { + const pl = placements[p]; + const planItemId = pl.plan_item_id ?? (pl.itemIdx != null ? itemIdMap.get(pl.itemIdx) : null); + await client.query( + `INSERT INTO cutting_plan_placement + (sheet_id, plan_item_id, x, y, w, h, start_x, seg_length, rotated, placement_order) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + [ + sheetId, planItemId || null, + pl.x ?? null, pl.y ?? null, pl.w ?? null, pl.h ?? null, + pl.start_x ?? null, pl.seg_length ?? null, + !!pl.rotated, pl.placement_order ?? p, + ] + ); + } + } + + await client.query("COMMIT"); + return { id: planId, plan_no: payload.header?.plan_no }; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +// ───────────────────────────────────────────────────────── +// 삭제 +// ───────────────────────────────────────────────────────── +export async function deletePlan(companyCode: string, planId: number) { + const pool = getPool(); + const r = await pool.query( + `DELETE FROM cutting_plan_mng WHERE id=$1 AND company_code=$2 RETURNING id`, + [planId, companyCode] + ); + return r.rows.length > 0; +} diff --git a/frontend/app/(main)/COMPANY_30/production/cutting-plan/WorkInstructionApplyModal.tsx b/frontend/app/(main)/COMPANY_30/production/cutting-plan/WorkInstructionApplyModal.tsx new file mode 100644 index 00000000..74765b15 --- /dev/null +++ b/frontend/app/(main)/COMPANY_30/production/cutting-plan/WorkInstructionApplyModal.tsx @@ -0,0 +1,266 @@ +"use client"; + +/** + * 절단계획 → 작업지시 적용 모달 + * 절단계획의 품목을 받아 기본 정보만 입력 후 작업지시 저장. + * 저장 시 마스터에 batch_no(=plan_no) / cutting_plan_id 를 함께 전달. + */ + +import { useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { CheckCircle2, Loader2, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, +} from "@/components/ui/dialog"; +import { + Select, SelectTrigger, SelectValue, SelectContent, SelectItem, +} from "@/components/ui/select"; +import { + Table, TableHeader, TableRow, TableHead, TableBody, TableCell, +} from "@/components/ui/table"; + +import { + previewWorkInstructionNo, saveWorkInstruction, + getEquipmentList, getEmployeeList, +} from "@/lib/api/workInstruction"; + +export interface WorkInstructionApplyItem { + itemCode: string; // 품목코드 (part_code) + itemName: string; + spec?: string; + qty: number; + remark?: string; + sourceTable?: string; // 기본 'cutting_plan' + sourceId?: string; // 수주번호 또는 cutting_plan_id +} + +export interface WorkInstructionApplyModalProps { + open: boolean; + onOpenChange: (v: boolean) => void; + initialItems: WorkInstructionApplyItem[]; + batchNo?: string | null; // 예: CP-2026-0001 + cuttingPlanId?: number | null; + onSaved?: (result: { id: string; workInstructionNo: string }) => void; +} + +const NV = "__none__"; +const nv = (v?: string | null) => (v && v.length ? v : NV); +const fromNv = (v: string) => (v === NV ? "" : v); + +export default function WorkInstructionApplyModal({ + open, onOpenChange, initialItems, batchNo, cuttingPlanId, onSaved, +}: WorkInstructionApplyModalProps) { + const [wiNo, setWiNo] = useState(""); + const [status, setStatus] = useState("일반"); + const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0, 10)); + const [endDate, setEndDate] = useState(""); + const [equipmentId, setEquipmentId] = useState(""); + const [workTeam, setWorkTeam] = useState(""); + const [worker, setWorker] = useState(""); + const [remark, setRemark] = useState(""); + const [items, setItems] = useState([]); + const [saving, setSaving] = useState(false); + + const [equipmentOptions, setEquipmentOptions] = useState<{ id: string; equipment_name: string }[]>([]); + const [workerOptions, setWorkerOptions] = useState<{ user_id: string; user_name: string; dept_name: string | null }[]>([]); + + // 모달이 열릴 때마다 기본값 재초기화 + WI 번호 프리뷰 + 셀렉트 옵션 로드 + useEffect(() => { + if (!open) return; + setItems(initialItems.map((x) => ({ ...x }))); + setStatus("일반"); setEndDate(""); setEquipmentId(""); setWorkTeam(""); setWorker(""); setRemark(""); + setStartDate(new Date().toISOString().slice(0, 10)); + + previewWorkInstructionNo().then((r) => { if (r.success) setWiNo(r.instructionNo); }).catch(() => {}); + getEquipmentList().then((r) => { if (r.success) setEquipmentOptions(r.data || []); }).catch(() => {}); + getEmployeeList().then((r) => { if (r.success) setWorkerOptions(r.data || []); }).catch(() => {}); + }, [open, initialItems]); + + const canSave = useMemo(() => items.length > 0 && items.every((i) => i.qty > 0), [items]); + + const handleSave = async () => { + if (!canSave) { toast.error("품목/수량을 확인해주세요"); return; } + setSaving(true); + try { + const payload = { + status, startDate, endDate, + equipmentId, workTeam, worker, remark, + batchNo: batchNo || null, + cuttingPlanId: cuttingPlanId ?? null, + items: items.map((i) => ({ + itemNumber: i.itemCode, itemCode: i.itemCode, partCode: i.itemCode, + qty: String(i.qty), remark: i.remark || "", + sourceTable: i.sourceTable || "cutting_plan", + sourceId: i.sourceId || (cuttingPlanId != null ? String(cuttingPlanId) : ""), + routing: null, + })), + }; + const r = await saveWorkInstruction(payload); + if (!r.success) { toast.error(r.message || "저장 실패"); return; } + toast.success(`작업지시 ${r.data?.workInstructionNo || wiNo} 등록 완료`); + onOpenChange(false); + onSaved?.(r.data); + } catch (e: any) { + toast.error(e?.message || "저장 실패"); + } finally { + setSaving(false); + } + }; + + return ( + + + + 작업지시 적용 확인 + + 기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요. + {batchNo ? 배치번호 {batchNo} : null} + + + +
+
+
+

작업지시 기본 정보

+
+
+ + +
+
+ + +
+
+ + setStartDate(e.target.value)} className="h-9" /> +
+
+ + setEndDate(e.target.value)} className="h-9" /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + setRemark(e.target.value)} /> +
+
+
+ +
+

품목 목록

+
+ + + + 순번 + 배치번호 + 품목코드 + 품목명 + 규격 + 수량 + 비고 + + + + + {items.map((item, idx) => ( + + {idx + 1} + {batchNo || "-"} + {item.itemCode || "-"} + {item.itemName || "-"} + {item.spec || "-"} + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))} + /> + + + setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))} + /> + + + + + + ))} + {items.length === 0 && ( + + + 품목이 없습니다 + + + )} + +
+
+
+
+
+ + + + + +
+
+ ); +} diff --git a/frontend/app/(main)/COMPANY_30/production/cutting-plan/page.tsx b/frontend/app/(main)/COMPANY_30/production/cutting-plan/page.tsx new file mode 100644 index 00000000..3ff13bd8 --- /dev/null +++ b/frontend/app/(main)/COMPANY_30/production/cutting-plan/page.tsx @@ -0,0 +1,2784 @@ +"use client"; + +import React, { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { + ResizableHandle, ResizablePanel, ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { + Search, RefreshCw, Save, Trash2, Zap, Package, Wrench, + Scissors, Ruler, LayoutGrid, Layers, Loader2, Plus, X, + RotateCcw, ClipboardList, CalendarClock, Truck, Maximize2, Pencil, +} from "lucide-react"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { apiClient } from "@/lib/api/client"; +import { + CutType, PackMode, Dir, Material, PlanItem, + AreaResult, LengthResult, Sheet, Pipe, Placement, Remnant, RemnantItem, RemnantGroup, + packArea, packAreaHomogeneous, packLength, + computeSheetGroups, computePipeGroups, + computeAreaStats, computeLengthStats, + computeRemnants, extractInitialRemnants, + computeRemnantGroups, computeGroupOutline, decomposeUnion, + COLORS, +} from "@/lib/cutting/packing"; +import WorkInstructionApplyModal, { WorkInstructionApplyItem } from "./WorkInstructionApplyModal"; + +// ───────────────────────────────────────────────────────── +// 타입 +// ───────────────────────────────────────────────────────── +interface OrderRow { + order_no: string; + customer?: string; + partner_id?: string; + part_code?: string; + part_name?: string; + spec?: string; + order_qty?: number; + due_date?: string; + status?: string; + width?: number; + height?: number; + length?: number; + type?: CutType; + item_id?: string; + item_name?: string; + batch_id?: number; + batch_no?: string; +} + +// spec 파싱 ("668*1318" 또는 "L750mm") +function parseSpec(spec?: string): { width?: number; height?: number; length?: number; type?: CutType } { + if (!spec) return {}; + const m1 = String(spec).match(/(\d+)\s*[*x×X]\s*(\d+)/); + if (m1) return { width: +m1[1], height: +m1[2], type: "area" }; + const m2 = String(spec).match(/L?\s*(\d+)\s*mm/i); + if (m2) return { length: +m2[1], type: "length" }; + return {}; +} + +// ───────────────────────────────────────────────────────── +// 메인 컴포넌트 +// ───────────────────────────────────────────────────────── +export default function CuttingPlanPage() { + // 검색 / 기본 상태 + const [dateFrom, setDateFrom] = useState(""); + const [dateTo, setDateTo] = useState(""); + const [planNoFilter, setPlanNoFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState(""); + + // 좌측 소스 탭 & 데이터 + const [leftTab, setLeftTab] = useState<"order" | "plan" | "ship">("order"); + const [orders, setOrders] = useState([]); + const [orderTotal, setOrderTotal] = useState(0); + const [orderPage, setOrderPage] = useState(1); + const [orderLimit] = useState(100); + const [orderKeyword, setOrderKeyword] = useState(""); + const [excludeInPlan, setExcludeInPlan] = useState(true); + const [checkedOrders, setCheckedOrders] = useState>(new Set()); + const [loadingOrders, setLoadingOrders] = useState(false); + + // 설정 상태 + const [cutType, setCutType] = useState("area"); + const [calcMode, setCalcMode] = useState<"auto" | "manual">("auto"); + const [packMode, setPackMode] = useState("mixed"); + const [kerf, setKerf] = useState(3); + const [margin, setMargin] = useState(0); + const [minRemnant, setMinRemnant] = useState(100); + const [minReuse, setMinReuse] = useState(100); + + // 원자재 + const [materials, setMaterials] = useState([]); + const [mat1Id, setMat1Id] = useState(""); + const [mat2Id, setMat2Id] = useState(""); + const [showMat2, setShowMat2] = useState(false); + const mat1 = useMemo(() => materials.find((m) => String(m.id) === mat1Id), [materials, mat1Id]); + const mat2 = useMemo(() => materials.find((m) => String(m.id) === mat2Id), [materials, mat2Id]); + + // 절단 품목 + const [planItems, setPlanItems] = useState([]); + // 배치 결과 + const [batchResult, setBatchResult] = useState(null); + // 우측 탭 + const [rightTab, setRightTab] = useState<"batch" | "remnant">("batch"); + + // 확대 편집 모달 + const [zoomSheetIdx, setZoomSheetIdx] = useState(null); + // 수동편집 / 자동복원 + const [manualEditMode, setManualEditMode] = useState(false); + const [originalBatchResult, setOriginalBatchResult] = useState(null); + // loadPlan 직후 useEffect가 자동 calculate()로 remnants를 덮어쓰는 것 방지 (한 번 소비) + const skipAutoRecalcRef = useRef(false); + // 작업지시 적용 모달 열림 상태 + const [isWIModalOpen, setIsWIModalOpen] = useState(false); + + // 저장 + const [currentPlanId, setCurrentPlanId] = useState(null); + const [currentPlanNo, setCurrentPlanNo] = useState(""); + const [saving, setSaving] = useState(false); + + // ─────────────────────────────────────────────────────── + // 데이터 로딩 + // ─────────────────────────────────────────────────────── + const loadMaterials = useCallback(async () => { + try { + const res = await apiClient.get(`/cutting-plan/materials?cutType=${cutType}`); + const raw = res.data?.data || []; + const list: Material[] = raw.map((r: any) => ({ + id: r.id, + code: r.item_number || r.id, + name: r.item_name, + width: +r.width || 0, + height: +r.height || 0, + length: cutType === "length" ? (+r.length || +r.width || 0) : 0, + stock: +r.stock || 0, + unit: cutType === "length" ? "개" : "장", + })); + setMaterials(list); + } catch (e: any) { + toast.error("원자재 조회 실패: " + (e?.message || "")); + } + }, [cutType]); + + const loadOrders = useCallback(async () => { + setLoadingOrders(true); + try { + const res = await apiClient.get("/cutting-plan/orders", { + params: { + from: dateFrom || undefined, + to: dateTo || undefined, + keyword: orderKeyword || undefined, + page: orderPage, + limit: orderLimit, + excludeInPlan: excludeInPlan ? "true" : undefined, + }, + }); + const payload = res.data?.data || {}; + const raw = payload.rows || []; + setOrderTotal(payload.total || 0); + const rows: OrderRow[] = raw.map((o: any) => { + const dims = parseSpec(o.spec); + const qty = +o.order_qty || 0; + const balance = +o.balance_qty || qty; + return { + order_no: o.order_no, + customer: o.partner_id || "-", + partner_id: o.partner_id, + part_code: o.part_code || "", + part_name: o.part_name || "-", + spec: o.spec || "", + order_qty: qty, + due_date: o.due_date ? String(o.due_date).substring(0, 10) : "", + status: balance <= 0 ? "완료" : "미계획", + type: dims.type || "area", + width: dims.width || 0, + height: dims.height || 0, + length: dims.length || 0, + item_id: o.item_id ? String(o.item_id) : undefined, + item_name: o.item_name || undefined, + batch_id: o.batch_id ?? undefined, + batch_no: o.batch_no ?? undefined, + }; + }); + setOrders(rows); + } catch (e: any) { + toast.error("수주 조회 실패: " + (e?.message || "")); + } finally { + setLoadingOrders(false); + } + }, [dateFrom, dateTo, orderKeyword, orderPage, orderLimit, excludeInPlan]); + + useEffect(() => { loadMaterials(); }, [loadMaterials]); + useEffect(() => { loadOrders(); }, [loadOrders]); + + // 절단유형 바뀌면 선택/결과 리셋 + useEffect(() => { + setMat1Id(""); + setMat2Id(""); + setShowMat2(false); + setPlanItems([]); + setBatchResult(null); + }, [cutType]); + + // ─────────────────────────────────────────────────────── + // 좌측 테이블: 수주 → 필터링 + // ─────────────────────────────────────────────────────── + // 수주 탭은 절단유형 무관하게 전체 표시 (실제 운영 데이터 반영) + const filteredOrders = useMemo(() => orders, [orders]); + + const toggleOrderAll = useCallback( + (checked: boolean) => { + setCheckedOrders(checked ? new Set(filteredOrders.map((o) => o.order_no)) : new Set()); + }, + [filteredOrders] + ); + + const toggleOrderOne = useCallback((orderNo: string) => { + setCheckedOrders((prev) => { + const next = new Set(prev); + if (next.has(orderNo)) next.delete(orderNo); + else next.add(orderNo); + return next; + }); + }, []); + + // ─────────────────────────────────────────────────────── + // 계획에 추가 + // ─────────────────────────────────────────────────────── + const addToPlan = useCallback(() => { + if (checkedOrders.size === 0) { + toast.error("추가할 항목을 선택하세요"); + return; + } + const newItems: PlanItem[] = []; + let skipped = 0; + checkedOrders.forEach((orderNo) => { + const o = orders.find((x) => x.order_no === orderNo); + if (!o) return; + // 중복 기준: 품목명 + 가로 + 세로 + 길이 (완전히 같은 규격만 중복으로 취급) + const sameKey = (p: PlanItem) => + p.name === o.part_name && + Math.abs((p.width || 0) - (o.width || 0)) < 0.1 && + Math.abs((p.height || 0) - (o.height || 0)) < 0.1 && + Math.abs((p.length || 0) - (o.length || 0)) < 0.1; + const existsInPlan = planItems.find(sameKey); + const existsInNew = newItems.find(sameKey); + if (existsInPlan || existsInNew) { + // 같은 규격이면 수량 합산 + 수주번호 추가 + const target = (existsInNew || existsInPlan!) as PlanItem & { srcOrders?: string[] }; + target.qty = (target.qty || 0) + (o.order_qty || 0); + target.srcOrders = [...(target.srcOrders || []), orderNo]; + skipped++; + return; + } + newItems.push({ + name: o.item_name || o.part_name || "-", + code: o.part_code || undefined, + item_id: o.item_id || undefined, + width: o.width || 0, + height: o.height || 0, + length: o.length || 0, + qty: o.order_qty || 0, + dir: "무관", + color: COLORS[(planItems.length + newItems.length) % COLORS.length], + placed: 0, + srcOrders: [orderNo], + } as PlanItem & { srcOrders?: string[] }); + }); + setPlanItems((prev) => [...prev, ...newItems]); + setCheckedOrders(new Set()); + setBatchResult(null); + const msgs: string[] = []; + if (newItems.length) msgs.push(`${newItems.length}개 품목 추가`); + if (skipped) msgs.push(`${skipped}건 수량 합산`); + toast.success(msgs.join(" · ") || "추가 없음"); + }, [checkedOrders, orders, planItems]); + + const updateItem = useCallback((idx: number, field: keyof PlanItem, value: any) => { + setPlanItems((prev) => { + const next = [...prev]; + const n: any = { ...next[idx] }; + n[field] = field === "dir" ? value : Number(value) || 0; + next[idx] = n; + return next; + }); + }, []); + + const removeItem = useCallback((idx: number) => { + setPlanItems((prev) => prev.filter((_, i) => i !== idx)); + setBatchResult(null); + }, []); + + const clearItems = useCallback(() => { + if (!planItems.length) return; + if (!confirm("모든 품목을 삭제하시겠습니까?")) return; + setPlanItems([]); + setBatchResult(null); + }, [planItems.length]); + + // ─────────────────────────────────────────────────────── + // 계산 실행 + // ─────────────────────────────────────────────────────── + const calculate = useCallback((overrideMode?: PackMode) => { + if (!planItems.length) { + toast.error("절단 품목을 먼저 추가하세요"); + return; + } + if (!mat1) { + toast.error("원자재를 선택하세요"); + return; + } + const mats = [mat1, mat2].filter(Boolean) as Material[]; + const mode = overrideMode ?? packMode; + + // 여유율 적용: qty × (1 + margin/100) 올림 + const marginRate = Math.max(0, margin || 0) / 100; + const effectiveItems = planItems.map((p) => ({ + ...p, + qty: marginRate > 0 ? Math.ceil((p.qty || 0) * (1 + marginRate)) : (p.qty || 0), + })); + + let result: AreaResult | LengthResult; + if (cutType === "area") { + result = mode === "homo" + ? packAreaHomogeneous(mats, effectiveItems, kerf) + : packArea(mats, effectiveItems, kerf); + (result as AreaResult).sheetGroups = computeSheetGroups((result as AreaResult).sheets); + } else { + result = packLength(mats, effectiveItems, kerf); + (result as LengthResult).pipeGroups = computePipeGroups((result as LengthResult).pipes); + } + + // 배치 수량 업데이트 + setPlanItems((prev) => { + const next = prev.map((p) => ({ ...p, placed: 0 })); + if (cutType === "area") { + (result as AreaResult).sheets.forEach((sh) => + sh.placements.forEach((p) => { + if (next[p.itemIdx]) next[p.itemIdx].placed = (next[p.itemIdx].placed || 0) + 1; + }) + ); + } else { + (result as LengthResult).pipes.forEach((pi) => + pi.segments.forEach((s) => { + if (next[s.itemIdx]) next[s.itemIdx].placed = (next[s.itemIdx].placed || 0) + 1; + }) + ); + } + return next; + }); + + setBatchResult(result); + setOriginalBatchResult(JSON.parse(JSON.stringify(result))); // 자동복원용 + setManualEditMode(false); + setRightTab("batch"); + const marginMsg = marginRate > 0 ? ` · 여유율 ${margin}% 포함` : ""; + toast.success( + (mode === "homo" ? "계산 완료 (동일 품목 우선)" : "계산 완료 (4전략 최적)") + marginMsg + ); + }, [cutType, packMode, mat1, mat2, kerf, margin, planItems]); + + // calcMode="auto"이고 이미 배치결과가 있으면 설정 변경 시 자동 재계산 + useEffect(() => { + if (skipAutoRecalcRef.current) { + skipAutoRecalcRef.current = false; + return; + } + if (calcMode !== "auto") return; + if (!batchResult) return; + calculate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [packMode, cutType, kerf, margin, mat1?.code, mat2?.code]); + + // 자투리 helper: sheet에 사용자 편집 자투리(remnants)가 있으면 사용, 없으면 자동 추출 (kerf 반영). + const getSheetRemnants = useCallback((sheet: Sheet): RemnantItem[] => { + return sheet.remnants || extractInitialRemnants(sheet, `s${sheet.id}-`, kerf); + }, [kerf]); + + // 자투리 status 토글 (sheet ID + remnant ID로 식별, batchResult 직접 수정) + const setRemnantStatusById = useCallback((sheetId: number, remId: string, status: "keep" | "discard") => { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh) => { + if (sh.id !== sheetId) return sh; + const cur = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf); + return { ...sh, remnants: cur.map((rm) => rm.id === remId ? { ...rm, status } : rm) }; + }); + return { ...r, sheets: newSheets }; + }); + }, [kerf]); + + // 모든 자투리 일괄 status 변경 + const setAllRemnantStatus = useCallback((status: "keep" | "discard") => { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh) => { + const cur = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf); + return { ...sh, remnants: cur.map((rm) => ({ ...rm, status })) }; + }); + return { ...r, sheets: newSheets }; + }); + }, [kerf]); + + // 그룹(여러 remnant) 단위 status 일괄 변경 + const setGroupRemnantStatus = useCallback((sheetId: number, remIds: string[], status: "keep" | "discard") => { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const idSet = new Set(remIds); + const newSheets = r.sheets.map((sh) => { + if (sh.id !== sheetId) return sh; + const cur = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf); + return { ...sh, remnants: cur.map((rm) => idSet.has(rm.id) ? { ...rm, status } : rm) }; + }); + return { ...r, sheets: newSheets }; + }); + }, [kerf]); + + // ─────────────────────────────────────────────────────── + // 자동복원 (수동편집/확대편집 취소 → 최초 자동 계산 결과로 복구) + // ─────────────────────────────────────────────────────── + const resetLayout = useCallback(() => { + if (!originalBatchResult) return; + const restored = JSON.parse(JSON.stringify(originalBatchResult)); + setBatchResult(restored); + setManualEditMode(false); + // 품목 placed 재계산 + setPlanItems((prev) => { + const next = prev.map((p) => ({ ...p, placed: 0 })); + if (cutType === "area") { + (restored as AreaResult).sheets.forEach((sh: Sheet) => + sh.placements.forEach((p) => { + if (next[p.itemIdx]) next[p.itemIdx].placed = (next[p.itemIdx].placed || 0) + 1; + }) + ); + } else { + (restored as LengthResult).pipes.forEach((pi: Pipe) => + pi.segments.forEach((s) => { + if (next[s.itemIdx]) next[s.itemIdx].placed = (next[s.itemIdx].placed || 0) + 1; + }) + ); + } + return next; + }); + toast.success("자동 배치로 복원되었습니다"); + }, [originalBatchResult, cutType]); + + // ─────────────────────────────────────────────────────── + // 저장된 절단계획 불러오기 (배치번호 클릭 시) + // ─────────────────────────────────────────────────────── + const loadPlan = useCallback(async (planId: number) => { + try { + const res = await apiClient.get(`/cutting-plan/plans/${planId}`); + const data = res.data?.data; + if (!data) { toast.error("계획을 찾을 수 없습니다"); return; } + const { header: h, items: rawItems, sheets: rawSheets, placements: rawPlacements } = data; + + // 복원한 batchResult(remnants 포함)가 자동 재계산 useEffect에 덮어쓰이지 않도록 한 번 스킵 + skipAutoRecalcRef.current = true; + + // 헤더 → state + setCurrentPlanId(h.id); + setCurrentPlanNo(h.plan_no || ""); + setDateFrom(h.plan_date_from ? String(h.plan_date_from).substring(0, 10) : ""); + setDateTo(h.plan_date_to ? String(h.plan_date_to).substring(0, 10) : ""); + setCutType((h.cut_type as CutType) || "area"); + setCalcMode((h.calc_mode as "auto" | "manual") || "auto"); + setPackMode((h.pack_mode as PackMode) || "mixed"); + if (h.mat_item_id) setMat1Id(String(h.mat_item_id)); + if (h.mat_item_id_2) { setMat2Id(String(h.mat_item_id_2)); setShowMat2(true); } + if (h.kerf != null) setKerf(+h.kerf); + if (h.margin != null) setMargin(+h.margin); + if (h.min_remnant != null) setMinRemnant(+h.min_remnant); + if (h.min_reuse != null) setMinReuse(+h.min_reuse); + + // items → planItems (같은 (item_name + W×H + L)로 합산 + srcOrders 모음) + // 백엔드 getPlanDetail이 item_info JOIN으로 item_number/item_name_resolved 내려줌. + const itemMap = new Map(); + const itemDbIdToKey = new Map(); // db item id → group key + (rawItems || []).forEach((it: any, idx: number) => { + const w = +it.width || 0, hh = +it.height || 0, len = +it.length || 0; + const resolvedName = it.item_name_resolved || it.item_name || "-"; + const resolvedCode = it.item_number || undefined; + const key = `${resolvedName}|${w}|${hh}|${len}`; + const exist = itemMap.get(key); + if (exist) { + exist.qty = (exist.qty || 0) + (+it.qty || 0); + if (it.src_no) exist.srcOrders.push(it.src_no); + if (!exist.code && resolvedCode) exist.code = resolvedCode; + if (!exist.item_id && it.item_id) exist.item_id = String(it.item_id); + } else { + itemMap.set(key, { + name: resolvedName, + code: resolvedCode, + item_id: it.item_id ? String(it.item_id) : undefined, + width: w, height: hh, length: len, + qty: +it.qty || 0, + dir: (it.dir as Dir) || "무관", + color: it.color || COLORS[itemMap.size % COLORS.length], + placed: +it.placed_qty || 0, + srcOrders: it.src_no ? [it.src_no] : [], + }); + } + itemDbIdToKey.set(it.id, key); + }); + const loadedPlanItems = [...itemMap.values()]; + setPlanItems(loadedPlanItems); + const keyToIdx = new Map(); + [...itemMap.keys()].forEach((k, i) => keyToIdx.set(k, i)); + + // sheets + placements → batchResult + if (h.cut_type === "length") { + const pipes: Pipe[] = (rawSheets || []).map((sh: any, si: number) => { + const segs = (rawPlacements || []) + .filter((p: any) => p.sheet_id === sh.id) + .map((p: any) => { + const itemKey = itemDbIdToKey.get(p.plan_item_id); + const itemIdx = itemKey ? (keyToIdx.get(itemKey) ?? 0) : 0; + const item = itemKey ? itemMap.get(itemKey) : undefined; + return { + len: +p.seg_length || 0, + color: item?.color || "#3b82f6", + name: item?.name || "-", + itemIdx, + startX: +p.start_x || 0, + }; + }); + return { + id: si + 1, + matLen: +sh.mat_length || 0, + matCode: sh.mat_name || "", + matName: sh.mat_name || "", + remaining: +sh.remnant_length || 0, + segments: segs, + }; + }); + setBatchResult({ pipes, pipeGroups: computePipeGroups(pipes) }); + } else { + const sheets: Sheet[] = (rawSheets || []).map((sh: any, si: number) => { + const placements: Placement[] = (rawPlacements || []) + .filter((p: any) => p.sheet_id === sh.id) + .map((p: any) => { + const itemKey = itemDbIdToKey.get(p.plan_item_id); + const itemIdx = itemKey ? (keyToIdx.get(itemKey) ?? 0) : 0; + const item = itemKey ? itemMap.get(itemKey) : undefined; + return { + x: +p.x || 0, y: +p.y || 0, w: +p.w || 0, h: +p.h || 0, + color: item?.color || "#3b82f6", + name: item?.name || "-", + itemIdx, + rotated: !!p.rotated, + }; + }); + return { + id: si + 1, + matW: +sh.mat_width || 0, matH: +sh.mat_height || 0, + matCode: sh.mat_name || "", matName: sh.mat_name || "", + shelves: [], placements, + remnants: Array.isArray(sh.remnants) ? (sh.remnants as RemnantItem[]) : undefined, + }; + }); + setBatchResult({ sheets, sheetGroups: computeSheetGroups(sheets) }); + } + toast.success(`배치 ${h.plan_no} 불러오기 완료`); + } catch (e: any) { + toast.error("불러오기 실패: " + (e?.response?.data?.message || e?.message || "")); + } + }, []); + + // ─────────────────────────────────────────────────────── + // 저장 + // ─────────────────────────────────────────────────────── + const savePlan = useCallback(async () => { + if (!planItems.length) { toast.error("품목을 먼저 추가하세요"); return; } + setSaving(true); + try { + const header: any = { + id: currentPlanId, + plan_no: currentPlanNo, + plan_date_from: dateFrom || null, + plan_date_to: dateTo || null, + cut_type: cutType, + calc_mode: calcMode, + pack_mode: packMode, + mat_item_id: mat1?.id || null, + mat_item_id_2: mat2?.id || null, + kerf, margin, min_remnant: minRemnant, min_reuse: minReuse, + status: "draft", + }; + + if (batchResult) { + if (cutType === "area") { + const stats = computeAreaStats(batchResult as AreaResult); + header.total_sheets = stats.count; + header.total_pieces = stats.totalPieces; + header.util_rate = +stats.util.toFixed(2); + header.total_loss = stats.loss; + header.rotated_count = stats.rotated; + } else { + const stats = computeLengthStats(batchResult as LengthResult); + header.total_sheets = stats.count; + header.total_pieces = stats.totalPieces; + header.util_rate = +stats.util.toFixed(2); + header.total_loss = stats.loss; + } + } + + const items = planItems.map((p, i) => { + const srcOrders = (p as PlanItem & { srcOrders?: string[] }).srcOrders || []; + return { + seq: i + 1, + src_type: srcOrders.length > 0 ? "order" : "manual", + src_no: srcOrders.length === 1 ? srcOrders[0] : null, + src_orders: srcOrders, + item_id: p.item_id || null, + item_name: p.name, + width: p.width || null, height: p.height || null, length: p.length || null, + qty: p.qty, dir: p.dir, color: p.color, placed_qty: p.placed || 0, + }; + }); + + const sheets: any[] = []; + if (batchResult) { + if (cutType === "area") { + const areaRes = batchResult as AreaResult; + // "×N 동일" 그룹: 대표 sheet에만 토글된 remnants가 있으므로 같은 그룹의 다른 sheet에도 복제 + // (개별 sheet에 자체 remnants가 이미 있으면 그대로 유지) + const groupRemBySheetIdx = new Map(); + const sheetGroups = (areaRes.sheetGroups && areaRes.sheetGroups.length > 0) + ? areaRes.sheetGroups + : computeSheetGroups(areaRes.sheets); + sheetGroups.forEach((g) => { + const repRem = g.representative.remnants; + if (repRem && repRem.length > 0) { + g.indices.forEach((i) => groupRemBySheetIdx.set(i, repRem)); + } + }); + areaRes.sheets.forEach((sh, si) => { + const usedA = sh.placements.reduce((s, p) => s + p.w * p.h, 0); + const effectiveRem = sh.remnants ?? groupRemBySheetIdx.get(si) ?? null; + sheets.push({ + sheet_no: si + 1, + mat_item_id: mat1?.id || null, + mat_name: sh.matName, + mat_width: sh.matW, mat_height: sh.matH, + used_area: usedA, + remnant_area: sh.matW * sh.matH - usedA, + util_rate: sh.matW * sh.matH > 0 ? +(usedA / (sh.matW * sh.matH) * 100).toFixed(2) : 0, + remnants: effectiveRem, + placements: sh.placements.map((p, pi) => ({ + itemIdx: p.itemIdx, + x: p.x, y: p.y, w: p.w, h: p.h, + rotated: !!p.rotated, + placement_order: pi, + })), + }); + }); + } else { + (batchResult as LengthResult).pipes.forEach((pipe, pi) => { + const usedL = pipe.segments.reduce((s, seg) => s + seg.len, 0); + sheets.push({ + sheet_no: pi + 1, + mat_item_id: mat1?.id || null, + mat_name: pipe.matName, + mat_length: pipe.matLen, + used_length: usedL, + remnant_length: Math.max(0, pipe.remaining), + util_rate: pipe.matLen > 0 ? +(usedL / pipe.matLen * 100).toFixed(2) : 0, + placements: pipe.segments.map((seg, si) => ({ + itemIdx: seg.itemIdx, + start_x: seg.startX, seg_length: seg.len, + placement_order: si, + })), + }); + }); + } + } + + const res = await apiClient.post("/cutting-plan/plans", { header, items, sheets }); + const data = res.data?.data; + if (data?.id) setCurrentPlanId(data.id); + if (data?.plan_no) setCurrentPlanNo(data.plan_no); + toast.success(`저장되었습니다 — 배치번호 ${data?.plan_no || currentPlanNo}`); + // 수주 목록 자동 새로고침 → 배치번호 표시 + loadOrders(); + } catch (e: any) { + toast.error("저장 실패: " + (e?.response?.data?.message || e?.message || "")); + } finally { + setSaving(false); + } + }, [planItems, currentPlanId, currentPlanNo, dateFrom, dateTo, cutType, calcMode, packMode, mat1, mat2, kerf, margin, minRemnant, minReuse, batchResult]); + + // ─────────────────────────────────────────────────────── + // UI Helpers + // ─────────────────────────────────────────────────────── + const statusBadge = (s?: string) => { + if (s === "미계획") return {s}; + if (s === "계획중") return {s}; + if (s === "완료") return {s}; + return {s || "-"}; + }; + + const stats = useMemo(() => { + if (!batchResult) return null; + if (cutType === "area") return computeAreaStats(batchResult as AreaResult); + return computeLengthStats(batchResult as LengthResult); + }, [batchResult, cutType]); + + // ─────────────────────────────────────────────────────── + // 렌더 + // ─────────────────────────────────────────────────────── + return ( +
+ {/* 브레드크럼 */} + + + {/* 검색 영역 */} +
+
+
+ + setDateFrom(e.target.value)} className="h-8 w-[140px] text-xs" /> + ~ + setDateTo(e.target.value)} className="h-8 w-[140px] text-xs" /> +
+
+
+ + setPlanNoFilter(e.target.value)} placeholder="CP-2026-" className="h-8 w-[130px] text-xs" /> +
+
+ + +
+
+ + +
+
+
+ + {/* 본문 - 좌우 패널 */} + + {/* 좌측: 소스 탭 */} + +
+ setLeftTab(v as any)} className="flex h-full flex-col"> +
+ + + 수주 + + + 생산계획 + + + 출하계획 + + +
+ + +
+
+ setOrderKeyword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { setOrderPage(1); loadOrders(); } }} + placeholder="수주번호/품목 검색" + className="h-7 text-xs flex-1" + /> + +
+
+ + + 총 {orderTotal.toLocaleString()}건 + {" | "}선택 {checkedOrders.size}건 + + +
+
+
+ {loadingOrders ? ( +
+ +
+ ) : filteredOrders.length === 0 ? ( +
+ +

수주 데이터가 없습니다

+
+ ) : ( + + + + + 0} + onCheckedChange={(c) => toggleOrderAll(!!c)} + className="h-4 w-4" + /> + + 수주번호 + 배치번호 + 거래처 + 품목명 + 규격 + 수량 + 납기 + 상태 + + + + {filteredOrders.map((o, idx) => { + const prev = idx > 0 ? filteredOrders[idx - 1] : null; + const isFirstOfBatch = o.batch_no && (!prev || prev.batch_no !== o.batch_no); + return ( + toggleOrderOne(o.order_no)} + > + e.stopPropagation()}> + toggleOrderOne(o.order_no)} + className="h-4 w-4" + /> + + {o.order_no} + e.stopPropagation()}> + {o.batch_no && o.batch_id ? ( + + ) : ( + - + )} + + {o.customer} + {o.part_name} + {o.spec} + {o.order_qty} + {o.due_date} + {statusBadge(o.status)} + + ); + })} + +
+ )} +
+ {/* 페이지네이션 */} + {orderTotal > orderLimit && ( +
+ + {((orderPage - 1) * orderLimit + 1).toLocaleString()}- + {Math.min(orderPage * orderLimit, orderTotal).toLocaleString()} / {orderTotal.toLocaleString()} + +
+ + + + {orderPage} / {Math.max(1, Math.ceil(orderTotal / orderLimit))} + + + +
+
+ )} +
+ + +
+ +

생산계획 데이터가 없습니다

+
+
+ +
+ +

출하계획 데이터가 없습니다

+
+
+
+
+
+ + + + {/* 우측 */} + +
+ {/* 설정 카드 */} +
+ {/* 토글 row */} +
+
+ + setCutType(v as CutType)} + options={[ + { value: "area", label: "면적형 (판재)", icon: }, + { value: "length", label: "길이형 (파이프)", icon: }, + ]} + activeColor="blue" + /> +
+
+
+ + setCalcMode(v as any)} + options={[ + { value: "auto", label: "자동", icon: }, + { value: "manual", label: "수동", icon: }, + ]} + activeColor="green" + /> +
+
+
+ + { + const nm = v as PackMode; + setPackMode(nm); + if (calcMode === "auto" && batchResult) calculate(nm); + }} + options={[ + { value: "mixed", label: "혼합 최적", icon: }, + { value: "homo", label: "동일 품목 우선", icon: }, + ]} + activeColor="orange" + /> +
+
+
+ + setKerf(+e.target.value)} className="h-7 w-[56px] text-xs px-1.5" /> + mm +
+
+ + setMargin(+e.target.value)} className="h-7 w-[56px] text-xs px-1.5" /> + % +
+
+
+ + {/* 원자재 선택 */} +
+ + + {mat1 && ( +
+ + {cutType === "area" ? `${mat1.width}×${mat1.height}` : `${mat1.length}`} + + · + = 10 ? "text-success" : "text-warning")}> + {(mat1.stock || 0)}{mat1.unit || "장"} + +
+ )} + {/* 원자재 2 (인라인 초-컴팩트) */} + {showMat2 ? ( + <> + + + {mat2 && ( +
+ + {cutType === "area" ? `${mat2.width}×${mat2.height}` : `${mat2.length}`} + + · + = 10 ? "text-success" : "text-warning")}> + {(mat2.stock || 0)}{mat2.unit || "장"} + +
+ )} + + + ) : ( + + )} +
+ + setMinRemnant(+e.target.value)} className="h-7 w-[60px] text-xs px-1.5" /> + mm↑ +
+
+
+ + {/* 품목 목록 + 결과 (수직 리사이즈) */} + + +
+
+
+ + 절단 품목 목록 + + {planItems.length}건 + +
+
+ + +
+
+
+ {planItems.length === 0 ? ( +
+ +

좌측에서 항목 선택 후 [계획 추가]를 클릭하세요

+
+ ) : ( + + + + No + 품목명 + + {cutType === "area" ? "가로(W)" : "길이(L)"} + + {cutType === "area" && 세로(H)} + 수량 + {cutType === "area" && 방향} + 배치결과 + + + + + {planItems.map((item, i) => ( + + {i + 1} + +
+ + {item.name} +
+
+ + {cutType === "area" ? item.width : (item.length || 0)} + + {cutType === "area" && ( + + {item.height} + + )} + + updateItem(i, "qty", e.target.value)} className="h-7 text-xs px-1.5" /> + + {cutType === "area" && ( + + + + )} + + {item.placed !== undefined && item.placed > 0 ? ( + + {item.placed}개 + + ) : ( + 미배치 + )} + + + + +
+ ))} +
+
+ )} +
+
+
+ + + + + {/* 결과 카드 */} +
+ setRightTab(v as any)} className="flex flex-col h-full"> +
+ + + 배치 계획 + + + 자투리 관리 + + + {batchResult && rightTab === "batch" && ( +
+ + +
+ )} +
+ + {/* 배치 계획 */} + + {batchResult ? ( + <> + {/* 범례 */} +
+ {planItems.filter((p) => (p.placed || 0) > 0).map((p, i) => ( +
+
+ {p.name} +
+ ))} +
+
+ 자투리 +
+
+
+ 회전배치 +
+
+ {/* 통계 */} + {stats && ( +
+ + + + + {cutType === "area" && } +
+ )} + {manualEditMode && ( +
+ + 수동편집 모드 + — 피스를 드래그하여 이동 | 더블클릭하여 회전 +
+ )} +
+ {cutType === "area" + ? { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh, i) => i === si ? { ...sh, placements, remnants: undefined } : sh); + return { ...r, sheets: newSheets, sheetGroups: computeSheetGroups(newSheets) }; + }); + }} + /> + : + } +
+ + ) : ( +
+ +

원자재와 품목을 설정하고 [계산실행]을 클릭하세요

+
+ )} + + + {/* 자투리 관리 */} + + + + +
+ + + + {/* 하단 버튼바 */} +
+
+ {currentPlanNo && ( + + {currentPlanNo} + + )} +
+
+ + +
+
+
+ + + + {/* 확대 편집 모달 */} + {zoomSheetIdx !== null && batchResult && cutType === "area" && (batchResult as AreaResult).sheets[zoomSheetIdx] && ( + { + setBatchResult((prev) => { + if (!prev) return prev; + const r = prev as AreaResult; + const newSheets = r.sheets.map((sh, i) => + i === zoomSheetIdx ? { ...sh, placements: newPlacements, remnants: newRemnants } : sh + ); + return { ...r, sheets: newSheets, sheetGroups: computeSheetGroups(newSheets) }; + }); + // 배치 수량 재계산 + setPlanItems((prev) => { + const next = prev.map((p) => ({ ...p, placed: 0 })); + (batchResult as AreaResult).sheets.forEach((sh, i) => { + const ps = i === zoomSheetIdx ? newPlacements : sh.placements; + ps.forEach((p) => { + if (next[p.itemIdx]) next[p.itemIdx].placed = (next[p.itemIdx].placed || 0) + 1; + }); + }); + return next; + }); + setZoomSheetIdx(null); + toast.success("편집 내용이 반영되었습니다"); + }} + onClose={() => setZoomSheetIdx(null)} + /> + )} + + {/* ── 작업지시 적용 모달 (절단계획 → 작업지시) ── */} + ((p) => { + const spec = cutType === "length" && p.length + ? `L${p.length}mm` + : `${p.width || 0}×${p.height || 0}`; + const srcOrders = ((p as PlanItem & { srcOrders?: string[] }).srcOrders) || []; + return { + itemCode: p.code || "", + itemName: p.name, + spec, + qty: p.qty || 0, + sourceTable: "cutting_plan", + sourceId: srcOrders[0] || (currentPlanId != null ? String(currentPlanId) : ""), + }; + })} + /> +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 토글 그룹 (shadcn Button 기반 2~3지선다) +// ───────────────────────────────────────────────────────── +function ToggleGroup({ + value, onChange, options, activeColor = "blue", +}: { + value: string; + onChange: (v: string) => void; + options: { value: string; label: string; icon?: React.ReactNode }[]; + activeColor?: "blue" | "green" | "orange"; +}) { + const activeCls = + activeColor === "blue" ? "bg-primary text-primary-foreground hover:bg-primary/90" + : activeColor === "green" ? "bg-emerald-600 text-white hover:bg-emerald-700" + : "bg-orange-500 text-white hover:bg-orange-600"; + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 통계 박스 +// ───────────────────────────────────────────────────────── +function StatBox({ + label, value, unit, color, +}: { + label: string; + value: number | string; + unit?: string; + color?: "primary" | "success" | "warning"; +}) { + const valCls = + color === "primary" ? "text-primary" + : color === "success" ? "text-success" + : color === "warning" ? "text-warning" + : "text-foreground"; + return ( +
+ {label} +
+ {value} + {unit && {unit}} +
+
+ ); +} + +// ───────────────────────────────────────────────────────── +// 면적형 배치 시각화 +// ───────────────────────────────────────────────────────── +function AreaBatchView({ + result, onOpenZoom, manualEditMode = false, planItems = [], onUpdatePlacements, kerf = 0, + getSheetRemnants, onToggleRemnant, onToggleGroup, +}: { + result: AreaResult; + onOpenZoom: (sheetIdx: number) => void; + manualEditMode?: boolean; + planItems?: PlanItem[]; + onUpdatePlacements?: (sheetIdx: number, placements: Placement[]) => void; + kerf?: number; + getSheetRemnants: (sheet: Sheet) => RemnantItem[]; + onToggleRemnant: (sheetId: number, remId: string, status: "keep" | "discard") => void; + onToggleGroup: (sheetId: number, remIds: string[], status: "keep" | "discard") => void; +}) { + const groups = result.sheetGroups || result.sheets.map((s, si) => ({ + count: 1, repIdx: si, representative: s, indices: [si], + })); + const totalSheets = result.sheets.length; + const hasGroups = groups.some((g) => g.count > 1); + const DW = 240; + + if (!result.sheets.length) return null; + + return ( +
+ {hasGroups && ( +
+ 📐 총 {totalSheets}장 필요 + + {groups.length}가지 배치 패턴으로 합산 표시 + {groups.filter((g) => g.count > 1).map((g) => ( + ×{g.count} 동일 + ))} +
+ )} +
+ {groups.map((g) => { + const sheet = g.representative; + const scale = DW / sheet.matW; + const DH = Math.round(sheet.matH * scale); + const first = Math.min(...g.indices) + 1; + const last = Math.max(...g.indices) + 1; + return ( +
+
+ #{g.count > 1 ? `${first}~${last} (×${g.count})` : first} {sheet.matName} +
+
+ {/* 확대 버튼 */} + + {(() => { + const remnants = getSheetRemnants(sheet); + const groups = computeRemnantGroups(remnants); + return groups.map((g) => { + const keepCount = g.rects.filter((r) => r.status === "keep").length; + const status: "keep" | "discard" | "mixed" = + keepCount === g.rects.length ? "keep" : + keepCount === 0 ? "discard" : "mixed"; + const outline = computeGroupOutline(g.rects); + const outlineColor = + status === "keep" ? "rgba(37,99,235,0.85)" : + status === "discard" ? "rgba(245,158,11,0.7)" : + "rgba(139,92,246,0.85)"; + const largest = g.rects.reduce((a, b) => a.w * a.h > b.w * b.h ? a : b); + const remIds = g.rects.map((r) => r.id); + const groupNewStatus: "keep" | "discard" = status === "keep" ? "discard" : "keep"; + return ( + + {g.rects.map((rm) => { + const rmKeep = rm.status === "keep"; + return ( +
{ + e.stopPropagation(); + if (e.shiftKey) { + onToggleRemnant(sheet.id, rm.id, rmKeep ? "discard" : "keep"); + } else { + onToggleGroup(sheet.id, remIds, groupNewStatus); + } + }} + title={`${rmKeep ? "[보관]" : "[폐기]"} ${rm.w}×${rm.h} — 클릭: 그룹 토글 / Shift+클릭: 이 조각만`} + className={cn( + "absolute cursor-pointer hover:brightness-110", + rmKeep ? "bg-blue-300/35" : "bg-amber-300/20" + )} + style={{ + left: Math.round(rm.x * scale), + top: Math.round(rm.y * scale), + width: Math.max(2, Math.round(rm.w * scale)), + height: Math.max(2, Math.round(rm.h * scale)), + border: g.rects.length > 1 ? "1px solid rgba(255,255,255,0.4)" : "none", + }} + /> + ); + })} + + {outline.map((path, pi) => { + const d = path.points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x * scale} ${p.y * scale}`) + .join(" ") + " Z"; + return ( + + ); + })} + + {/* 그룹 라벨 — 가장 큰 자투리 사각형 좌상단 (확대버튼/그룹배지 회피) */} +
+ + {status === "keep" ? "보관" : status === "discard" ? "폐기" : `혼합 ${keepCount}/${g.rects.length}`} + +
+ + ); + }); + })()} + {sheet.placements.map((p, pi) => { + const px = Math.round(p.x * scale); + const py = Math.round(p.y * scale); + const pw = Math.max(3, Math.round(p.w * scale)); + const ph = Math.max(3, Math.round(p.h * scale)); + const si = g.repIdx; + + const collidesWith = ( + list: Placement[], idx: number, + x: number, y: number, w: number, h: number + ) => { + for (let i = 0; i < list.length; i++) { + if (i === idx) continue; + const o = list[i]; + if ( + x < o.x + o.w + kerf && + x + w + kerf > o.x && + y < o.y + o.h + kerf && + y + h + kerf > o.y + ) return true; + } + return false; + }; + + const startDrag = (e: React.MouseEvent) => { + if (!manualEditMode || !onUpdatePlacements) return; + e.preventDefault(); e.stopPropagation(); + const startMX = e.clientX, startMY = e.clientY; + const origX = p.x, origY = p.y; + let curX = origX, curY = origY; + const others = sheet.placements.filter((_, i) => i !== pi); + // X 방향 이동 — 인접 piece와 kerf 간격으로 정확히 snap + const slideX = (from: number, to: number, y: number) => { + if (from === to) return from; + const direction = to > from ? 1 : -1; + if (direction > 0) { + let bound = sheet.matW - p.w; + for (const o of others) { + if (o.y + o.h + kerf > y && o.y - kerf < y + p.h) { + const limit = o.x - p.w - kerf; + if (limit >= from && limit < bound) bound = limit; + } + } + return Math.max(from, Math.min(to, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.y + o.h + kerf > y && o.y - kerf < y + p.h) { + const limit = o.x + o.w + kerf; + if (limit <= from && limit > bound) bound = limit; + } + } + return Math.min(from, Math.max(to, bound)); + } + }; + const slideY = (x: number, from: number, to: number) => { + if (from === to) return from; + const direction = to > from ? 1 : -1; + if (direction > 0) { + let bound = sheet.matH - p.h; + for (const o of others) { + if (o.x + o.w + kerf > x && o.x - kerf < x + p.w) { + const limit = o.y - p.h - kerf; + if (limit >= from && limit < bound) bound = limit; + } + } + return Math.max(from, Math.min(to, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.x + o.w + kerf > x && o.x - kerf < x + p.w) { + const limit = o.y + o.h + kerf; + if (limit <= from && limit > bound) bound = limit; + } + } + return Math.min(from, Math.max(to, bound)); + } + }; + const onMove = (ev: MouseEvent) => { + const dx = (ev.clientX - startMX) / scale; + const dy = (ev.clientY - startMY) / scale; + const tx = Math.max(0, Math.min(sheet.matW - p.w, origX + dx)); + const ty = Math.max(0, Math.min(sheet.matH - p.h, origY + dy)); + curX = slideX(curX, tx, curY); + curY = slideY(curX, curY, ty); + const nextPlacements = sheet.placements.map((pp, idx) => + idx === pi ? { ...pp, x: curX, y: curY } : pp + ); + onUpdatePlacements(si, nextPlacements); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + // 최종 방어선: 최종 위치 겹침 시 원위치 복구 + if (collidesWith(sheet.placements, pi, curX, curY, p.w, p.h)) { + const restored = sheet.placements.map((pp, idx) => + idx === pi ? { ...pp, x: origX, y: origY } : pp + ); + onUpdatePlacements(si, restored); + toast.error("다른 조각과 겹쳐 원위치로 복구"); + } + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + const rotateHere = (e: React.MouseEvent) => { + if (!manualEditMode || !onUpdatePlacements) return; + e.stopPropagation(); + const item = planItems[p.itemIdx]; + if (item && item.dir !== "무관") { toast.error("방향 지정 품목은 회전 불가"); return; } + const nw = p.h, nh = p.w; + const nx = Math.max(0, Math.min(sheet.matW - nw, p.x)); + const ny = Math.max(0, Math.min(sheet.matH - nh, p.y)); + if (collidesWith(sheet.placements, pi, nx, ny, nw, nh)) { + toast.error("회전 시 다른 조각과 겹칩니다"); return; + } + const nextPlacements = sheet.placements.map((pp, idx) => + idx === pi + ? { ...pp, w: nw, h: nh, rotated: !pp.rotated, x: nx, y: ny } + : pp + ); + onUpdatePlacements(si, nextPlacements); + }; + + return ( +
+ {pw > 50 && ph > 14 ? `${p.w}×${p.h}` : ""} +
+ ); + })} + {g.count > 1 && ( +
+ × {g.count}장 동일 +
+ )} +
+
+ ); + })} +
+
+ ); +} + +// ───────────────────────────────────────────────────────── +// 확대 편집 모달 (드래그 · 회전 · X/Y 입력 · 여백 표시) +// ───────────────────────────────────────────────────────── +function ZoomEditorModal({ + sheet, sheetIdx, planItems, kerf, onApply, onClose, +}: { + sheet: Sheet; + sheetIdx: number; + planItems: PlanItem[]; + kerf: number; + onApply: (placements: typeof sheet.placements, remnants: RemnantItem[]) => void; + onClose: () => void; +}) { + // 로컬 복사본 (닫기 시 원복, 반영 시 상위로) + const [placements, setPlacements] = useState(() => sheet.placements.map((p) => ({ ...p }))); + const [selectedPI, setSelectedPI] = useState(null); + const [scale, setScale] = useState(0.5); + const [inputX, setInputX] = useState(""); + const [inputY, setInputY] = useState(""); + + // 자투리 status 오버라이드 (위치 기반 키, zoom 안에서만 보존) + 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; + }); + + // 자투리 그룹 선택 + 사용자 분할 override (선언 순서: useMemo보다 먼저) + const [selectedGroupId, setSelectedGroupId] = useState(null); + const [groupSplitOverrides, setGroupSplitOverrides] = useState>({}); + + // placements 변경 시 자투리 자동 재추출 + groupSplitOverrides 적용 + statusOverrides 매핑 + // 각 자투리에 groupKey 메타 부여 (자동 추출 그룹의 canonical key) + const localRemnants: (RemnantItem & { groupKey: string })[] = useMemo(() => { + const fresh = extractInitialRemnants({ ...sheet, placements, remnants: undefined }, `s${sheet.id}-`, kerf); + const groups = computeRemnantGroups(fresh); + const result: (RemnantItem & { groupKey: string })[] = []; + groups.forEach((g, gi) => { + const key = [...g.rects].sort((a, b) => a.y - b.y || a.x - b.x).map((r) => `${r.x},${r.y},${r.w},${r.h}`).join("|"); + const override = groupSplitOverrides[key]; + if (override && override.length > 0) { + override.forEach((r, ri) => { + const k = `${r.x}|${r.y}|${r.w}|${r.h}`; + result.push({ + id: `s${sheet.id}-g${gi}-${ri}`, + x: r.x, y: r.y, w: r.w, h: r.h, + status: statusOverrides[k] || "discard", + groupKey: key, + }); + }); + } else { + g.rects.forEach((rm) => { + const k = `${rm.x}|${rm.y}|${rm.w}|${rm.h}`; + result.push({ ...rm, status: statusOverrides[k] || "discard", groupKey: key }); + }); + } + }); + return result; + }, [placements, sheet, statusOverrides, groupSplitOverrides, kerf]); + + // 자투리 그룹화 — groupKey 기반 (자동 추출 그룹 ID 보존, override 적용 후에도 매칭 정확) + const remnantGroups = useMemo(() => { + const map = new Map(); + localRemnants.forEach((rm) => { + const arr = map.get(rm.groupKey) || []; + arr.push(rm); + map.set(rm.groupKey, arr); + }); + return [...map.entries()].map(([key, rects], gi) => { + const keepCount = rects.filter((r) => r.status === "keep").length; + const discardCount = rects.length - keepCount; + const status: "keep" | "discard" | "mixed" = + keepCount === rects.length ? "keep" : + keepCount === 0 ? "discard" : "mixed"; + const outline = computeGroupOutline(rects); + const totalArea = rects.reduce((s, r) => s + r.w * r.h, 0); + const minX = Math.min(...rects.map((r) => r.x)); + const minY = Math.min(...rects.map((r) => r.y)); + const maxX = Math.max(...rects.map((r) => r.x + r.w)); + const maxY = Math.max(...rects.map((r) => r.y + r.h)); + return { + groupId: gi, + groupKey: key, + rects, + status, keepCount, discardCount, + outline, totalArea, + bbox: { x: minX, y: minY, w: maxX - minX, h: maxY - minY }, + }; + }); + }, [localRemnants]); + + // 그룹 전체 토글 + const toggleGroup = (group: { rects: RemnantItem[]; status: "keep" | "discard" }) => { + const newStatus: "keep" | "discard" = group.status === "keep" ? "discard" : "keep"; + setStatusOverrides((prev) => { + const next = { ...prev }; + group.rects.forEach((rm) => { + next[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = newStatus; + }); + return next; + }); + }; + + // 분할 옵션 미리 계산 (현재 그룹 자투리 → 3가지 분할 결과) + const computeSplitOptions = (rects: RemnantItem[]) => { + const baseRects = rects.map((r) => ({ x: r.x, y: r.y, w: r.w, h: r.h })); + return { + h: decomposeUnion(baseRects, "h"), + v: decomposeUnion(baseRects, "v"), + max: decomposeUnion(baseRects, "max"), + }; + }; + + // 분할 옵션 적용 — 그룹 영역의 자투리를 새 사각형으로 교체 (groupKey는 자동 추출 키) + const applySplitToGroup = ( + group: { rects: RemnantItem[]; groupKey: string; status: "keep" | "discard" | "mixed" }, + newRects: { x: number; y: number; w: number; h: number }[] + ) => { + setGroupSplitOverrides((prev) => ({ ...prev, [group.groupKey]: newRects })); + // status: 그룹이 모두 keep이면 keep, 아니면 discard (mixed였으면 분할 후 다시 결정) + const newStatus: "keep" | "discard" = group.status === "keep" ? "keep" : "discard"; + setStatusOverrides((prev) => { + const next = { ...prev }; + newRects.forEach((r) => { + next[`${r.x}|${r.y}|${r.w}|${r.h}`] = newStatus; + }); + return next; + }); + setSelectedGroupId(null); + }; + + const areaRef = useRef(null); + const dragRef = useRef<{ + pi: number; origX: number; origY: number; startMX: number; startMY: number; + } | null>(null); + + // 모달 열릴 때 배율 계산 + useEffect(() => { + const calc = () => { + const area = areaRef.current; + if (!area) return; + const aw = Math.max(300, area.clientWidth - 36); + const ah = Math.max(200, area.clientHeight - 36); + let s = Math.min(aw / sheet.matW, ah / sheet.matH); + if (s > 1.5) s = 1.5; + if (s < 0.04) s = 0.04; + setScale(s); + }; + const t = setTimeout(calc, 50); + window.addEventListener("resize", calc); + return () => { clearTimeout(t); window.removeEventListener("resize", calc); }; + }, [sheet.matW, sheet.matH]); + + // 선택된 피스 정보 + const selected = selectedPI !== null ? placements[selectedPI] : null; + useEffect(() => { + if (selected) { + setInputX(String(Math.round(selected.x))); + setInputY(String(Math.round(selected.y))); + } else { + setInputX(""); setInputY(""); + } + }, [selectedPI]); + + // 겹침 체크 (kerf 여유 포함) + const collides = ( + list: typeof placements, + idx: number, + x: number, y: number, w: number, h: number + ) => { + for (let i = 0; i < list.length; i++) { + if (i === idx) continue; + const o = list[i]; + if ( + x < o.x + o.w + kerf && + x + w + kerf > o.x && + y < o.y + o.h + kerf && + y + h + kerf > o.y + ) return true; + } + return false; + }; + + // 드래그 + const handleMouseDown = (e: React.MouseEvent, pi: number) => { + e.preventDefault(); e.stopPropagation(); + setSelectedPI(pi); + const p = placements[pi]; + dragRef.current = { + pi, origX: p.x, origY: p.y, + startMX: e.clientX, startMY: e.clientY, + }; + const onMove = (ev: MouseEvent) => { + const drag = dragRef.current; + if (!drag) return; + const dx = (ev.clientX - drag.startMX) / scale; + const dy = (ev.clientY - drag.startMY) / scale; + setPlacements((prev) => { + if (drag.pi >= prev.length) return prev; + const p = { ...prev[drag.pi] }; + const tx = Math.max(0, Math.min(sheet.matW - p.w, drag.origX + dx)); + const ty = Math.max(0, Math.min(sheet.matH - p.h, drag.origY + dy)); + const others = prev.filter((_, i) => i !== drag.pi); + // X 방향: 인접 piece 변에서 정확히 kerf 거리에 snap + if (tx !== p.x) { + if (tx > p.x) { + let bound = sheet.matW - p.w; + for (const o of others) { + if (o.y + o.h + kerf > p.y && o.y - kerf < p.y + p.h) { + const limit = o.x - p.w - kerf; + if (limit >= p.x && limit < bound) bound = limit; + } + } + p.x = Math.max(p.x, Math.min(tx, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.y + o.h + kerf > p.y && o.y - kerf < p.y + p.h) { + const limit = o.x + o.w + kerf; + if (limit <= p.x && limit > bound) bound = limit; + } + } + p.x = Math.min(p.x, Math.max(tx, bound)); + } + } + // Y 방향: 동일하게 snap + if (ty !== p.y) { + if (ty > p.y) { + let bound = sheet.matH - p.h; + for (const o of others) { + if (o.x + o.w + kerf > p.x && o.x - kerf < p.x + p.w) { + const limit = o.y - p.h - kerf; + if (limit >= p.y && limit < bound) bound = limit; + } + } + p.y = Math.max(p.y, Math.min(ty, bound)); + } else { + let bound = 0; + for (const o of others) { + if (o.x + o.w + kerf > p.x && o.x - kerf < p.x + p.w) { + const limit = o.y + o.h + kerf; + if (limit <= p.y && limit > bound) bound = limit; + } + } + p.y = Math.min(p.y, Math.max(ty, bound)); + } + } + const next = [...prev]; + next[drag.pi] = p; + return next; + }); + }; + const onUp = () => { + dragRef.current = null; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + // X/Y 입력 반영 + const applyInput = (axis: "x" | "y", value: string) => { + if (selectedPI === null) return; + const v = parseFloat(value) || 0; + setPlacements((prev) => { + const p = { ...prev[selectedPI] }; + const nx = axis === "x" ? Math.max(0, Math.min(sheet.matW - p.w, v)) : p.x; + const ny = axis === "y" ? Math.max(0, Math.min(sheet.matH - p.h, v)) : p.y; + if (collides(prev, selectedPI, nx, ny, p.w, p.h)) { + toast.error("다른 조각과 겹칩니다"); + return prev; + } + p.x = nx; p.y = ny; + const next = [...prev]; + next[selectedPI] = p; + return next; + }); + }; + + // 회전 + const rotateSelected = () => { + if (selectedPI === null) return; + const item = planItems[placements[selectedPI].itemIdx]; + if (item && item.dir !== "무관") { toast.error("방향 지정 품목은 회전 불가"); return; } + setPlacements((prev) => { + const p = { ...prev[selectedPI] }; + const nw = p.h, nh = p.w; + const nx = Math.max(0, Math.min(sheet.matW - nw, p.x)); + const ny = Math.max(0, Math.min(sheet.matH - nh, p.y)); + if (collides(prev, selectedPI, nx, ny, nw, nh)) { + toast.error("회전 시 다른 조각과 겹칩니다"); + return prev; + } + p.w = nw; p.h = nh; p.x = nx; p.y = ny; p.rotated = !p.rotated; + const next = [...prev]; + next[selectedPI] = p; + return next; + }); + }; + + // 삭제 + const deleteSelected = () => { + if (selectedPI === null) return; + setPlacements((prev) => prev.filter((_, i) => i !== selectedPI)); + setSelectedPI(null); + }; + + // 여백 계산 + const gapL = selected ? Math.round(selected.x) : 0; + const gapR = selected ? Math.round(sheet.matW - selected.x - selected.w) : 0; + const gapT = selected ? Math.round(selected.y) : 0; + const gapB = selected ? Math.round(sheet.matH - selected.y - selected.h) : 0; + + // 격자 간격 + const gridMM = sheet.matW >= 2000 ? 200 : 100; + + const DW = Math.round(sheet.matW * scale); + const DH = Math.round(sheet.matH * scale); + + return ( + !o && onClose()}> + + + + + #{sheetIdx + 1} {sheet.matName} ({sheet.matW}×{sheet.matH}mm) — 확대 편집 + +
+ 배율: {(scale * 100).toFixed(0)}% + + +
+
+ +
+ {/* 원판 영역 */} +
+
+ {/* 격자 (SVG) */} + + {Array.from({ length: Math.floor(sheet.matW / gridMM) }, (_, i) => (i + 1) * gridMM).map((mm) => ( + + + {mm} + + ))} + {Array.from({ length: Math.floor(sheet.matH / gridMM) }, (_, i) => (i + 1) * gridMM).map((mm) => ( + + + {mm} + + ))} + + {/* 시트 라벨 */} +
+ {sheet.matW}×{sheet.matH}mm +
+ {/* 자투리 그룹 — 같은 그룹은 한 도형으로 표시 (fill + polygon outline) */} + {remnantGroups.map((g) => { + const isSelected = selectedGroupId === g.groupId; + const outlineColor = + g.status === "keep" ? "rgba(37,99,235,0.85)" : + g.status === "discard" ? "rgba(245,158,11,0.8)" : + "rgba(139,92,246,0.85)"; // mixed: violet + return ( + + {/* 그룹 내 사각형 fill — 각 조각의 status별 색상 (개별 표시) */} + {g.rects.map((rm) => { + const rx = Math.round(rm.x * scale); + const ry = Math.round(rm.y * scale); + const rw = Math.max(2, Math.round(rm.w * scale)); + const rh = Math.max(2, Math.round(rm.h * scale)); + const rmKeep = rm.status === "keep"; + const groupNewStatus: "keep" | "discard" = g.status === "keep" ? "discard" : "keep"; + return ( +
{ + e.stopPropagation(); + setSelectedPI(null); + setSelectedGroupId(g.groupId); + // Shift+클릭 = 그 조각만 토글, 일반 클릭 = 그룹 전체 토글 + if (e.shiftKey) { + setStatusOverrides((prev) => ({ + ...prev, + [`${rm.x}|${rm.y}|${rm.w}|${rm.h}`]: rmKeep ? "discard" : "keep", + })); + } else { + setStatusOverrides((prev) => { + const next = { ...prev }; + g.rects.forEach((r) => { + next[`${r.x}|${r.y}|${r.w}|${r.h}`] = groupNewStatus; + }); + return next; + }); + } + }} + title={`${rmKeep ? "[보관]" : "[폐기]"} ${rm.w}×${rm.h} — 클릭: 그룹 토글 / Shift+클릭: 이 조각만`} + className={cn( + "absolute cursor-pointer transition-colors hover:brightness-110", + rmKeep ? "bg-blue-300/40" : "bg-amber-300/25", + isSelected && "brightness-125" + )} + style={{ + left: rx, top: ry, width: rw, height: rh, + border: g.rects.length > 1 ? "1px solid rgba(255,255,255,0.5)" : "none", + }} + /> + ); + })} + {/* 그룹 외곽선 polygon (SVG) */} + + {g.outline.map((path, pi) => { + const d = path.points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x * scale} ${p.y * scale}`) + .join(" ") + " Z"; + return ( + + ); + })} + + {/* 그룹 라벨 — 가장 큰 자투리 사각형 안 좌상단 (piece와 안 겹침) */} + {(() => { + const largest = g.rects.reduce((a, b) => a.w * a.h > b.w * b.h ? a : b); + return ( +
+ + {g.status === "keep" ? "보관" : g.status === "discard" ? "폐기" : `혼합 ${g.keepCount}/${g.rects.length}`} + {g.rects.length > 1 && ` · ${g.rects.length}조각`} + +
+ ); + })()} + + ); + })} + {/* 피스 */} + {placements.map((p, pi) => { + const px = Math.round(p.x * scale); + const py = Math.round(p.y * scale); + const pw = Math.max(5, Math.round(p.w * scale)); + const ph = Math.max(5, Math.round(p.h * scale)); + const isSel = pi === selectedPI; + return ( +
handleMouseDown(e, pi)} + onDoubleClick={(e) => { e.stopPropagation(); setSelectedPI(pi); rotateSelected(); }} + className={cn( + "absolute flex items-center justify-center overflow-hidden cursor-grab select-none", + isSel && "ring-4 ring-blue-600 ring-offset-1 z-30" + )} + style={{ + left: px, top: py, width: pw, height: ph, + background: p.color, opacity: 0.88, + border: p.rotated ? "2px solid rgba(255,255,255,0.9)" : "1px solid rgba(255,255,255,0.5)", + }} + > + {pw > 40 && ph > 22 && ( +
+
{p.name}
+
{p.w}×{p.h}mm
+
+ )} + {p.rotated && ( + + )} +
+ ); + })} +
+
+ + {/* 사이드 패널 */} +
+
+
📌 선택된 피스
+ {selected ? ( +
+
{selected.name}
+
+ + { setInputX(e.target.value); applyInput("x", e.target.value); }} + className="h-7 w-[80px] text-xs" /> + mm +
+
+ + { setInputY(e.target.value); applyInput("y", e.target.value); }} + className="h-7 w-[80px] text-xs" /> + mm +
+
+ 크기: {selected.w}×{selected.h}mm{selected.rotated ? " [회전됨]" : ""} +
+
+ ) : ( +
피스를 클릭하여 선택하세요
+ )} +
+
+
📏 원판 내 여백
+
+
좌: {selected ? `${gapL}mm` : "-"}
+
우: {selected ? `${gapR}mm` : "-"}
+
상: {selected ? `${gapT}mm` : "-"}
+
하: {selected ? `${gapB}mm` : "-"}
+
+
+ {/* 자투리 관리 패널 — 항상 표시 */} +
+
+ ✂️ 자투리 그룹 {remnantGroups.length}개 +
+ {remnantGroups.length === 0 ? ( +
자투리가 없습니다
+ ) : ( +
+ {remnantGroups.map((g) => { + const isSelected = selectedGroupId === g.groupId; + const groupNewStatus: "keep" | "discard" = g.status === "keep" ? "discard" : "keep"; + return ( +
{ setSelectedGroupId(g.groupId); setSelectedPI(null); }} + className={cn( + "flex items-center gap-1.5 rounded px-1.5 py-1 cursor-pointer text-[10px]", + isSelected ? "bg-red-100 ring-1 ring-red-400" : "hover:bg-muted" + )} + > + + {g.status === "keep" ? "보관" : g.status === "discard" ? "폐기" : `혼합${g.keepCount}/${g.rects.length}`} + + {g.rects.length}조각 · {g.totalArea.toLocaleString()}mm² + +
+ ); + })} +
+ )} +
+ {/* 선택된 자투리 그룹 옵션 */} + {selectedGroupId !== null && (() => { + const g = remnantGroups.find((x) => x.groupId === selectedGroupId); + if (!g) return null; + const options = computeSplitOptions(g.rects); + const hasOverride = !!groupSplitOverrides[g.groupKey]; + const setAllInGroup = (status: "keep" | "discard") => { + setStatusOverrides((prev) => { + const next = { ...prev }; + g.rects.forEach((rm) => { + next[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = status; + }); + return next; + }); + }; + const toggleSingle = (rm: RemnantItem) => { + setStatusOverrides((prev) => ({ + ...prev, + [`${rm.x}|${rm.y}|${rm.w}|${rm.h}`]: rm.status === "keep" ? "discard" : "keep", + })); + }; + return ( +
+
+ ✂️ 선택된 자투리 그룹 + +
+
+ {g.rects.length}조각 · 합 {g.totalArea.toLocaleString()}mm² · BBox {g.bbox.w}×{g.bbox.h} + {g.status === "mixed" && · 보관 {g.keepCount}/{g.rects.length}} +
+ {/* 그룹 일괄 토글 */} +
+ + +
+ {/* 조각별 개별 토글 */} +
조각별 보관/폐기 ({g.rects.length}개)
+
+ {g.rects.map((rm) => ( + + ))} +
+
분할 방식 적용
+
+ + + +
+ {hasOverride && ( + + )} +
+ ); + })()} +
+
+ 📋 피스 목록 {placements.length}개 +
+
+ {placements.map((p, pi) => ( +
setSelectedPI(pi)} + className={cn( + "flex items-center gap-1.5 rounded px-1.5 py-1 cursor-pointer text-[11px]", + pi === selectedPI ? "bg-primary/20 font-bold" : "hover:bg-muted" + )} + > +
+ {p.name} + {p.w}×{p.h} + {p.rotated && } +
+ ))} +
+
+
+
+ + + + 드래그: 이동 | 더블클릭: 회전 | X/Y 입력: 정밀 이동 + +
+ + +
+
+ +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 길이형 배치 시각화 +// ───────────────────────────────────────────────────────── +function LengthBatchView({ result }: { result: LengthResult }) { + const groups = result.pipeGroups || result.pipes.map((p, pi) => ({ + count: 1, repIdx: pi, representative: p, indices: [pi], + })); + const totalPipes = result.pipes.length; + const hasGroups = groups.some((g) => g.count > 1); + const DW = 560; + + if (!result.pipes.length) return null; + + return ( +
+ {hasGroups && ( +
+ 📏 총 {totalPipes}개 필요 + + {groups.length}가지 절단 패턴 +
+ )} + {groups.map((g) => { + const pipe = g.representative; + const scale = DW / pipe.matLen; + const first = Math.min(...g.indices) + 1; + const last = Math.max(...g.indices) + 1; + const rem = Math.max(0, pipe.remaining); + return ( +
+
+ 파이프 #{g.count > 1 ? `${first}~${last}` : first} + ({pipe.matName}) + {g.count > 1 && ( + ×{g.count}개 동일 + )} + 잔재: {rem}mm ({((rem/pipe.matLen)*100).toFixed(1)}%) +
+
+ {pipe.segments.map((seg, si) => { + const sw = Math.max(2, Math.round(seg.len * scale)); + return ( +
+ {sw > 40 ? `${seg.len}mm` : ""} +
+ ); + })} + {rem > 0 && ( +
+ {Math.max(2, Math.round(rem * scale)) > 30 ? `${rem}mm` : ""} +
+ )} +
+
+ ); + })} +
+ ); +} + +// ───────────────────────────────────────────────────────── +// 자투리 관리 뷰 +// ───────────────────────────────────────────────────────── +function RemnantView({ + batchResult, cutType, minReuse, setMinReuse, getSheetRemnants, onToggleGroupStatus, onSetAllStatus, +}: { + batchResult: AreaResult | LengthResult | null; + cutType: CutType; + minReuse: number; + setMinReuse: (n: number) => void; + getSheetRemnants: (sheet: Sheet) => RemnantItem[]; + onToggleGroupStatus: (sheetId: number, remIds: string[], status: "keep" | "discard") => void; + onSetAllStatus: (status: "keep" | "discard") => void; +}) { + const rows = useMemo(() => { + if (!batchResult) return []; + if (cutType === "area") { + const r = batchResult as AreaResult; + // sheet별로 자투리 그룹화 → 그룹 단위 row 생성 + type Row = { + sheetId: number; sheetIdx: number; remIds: string[]; + matName: string; spec: string; pieces: number; + totalArea: number; bboxW: number; bboxH: number; + canReuse: boolean; status: "keep" | "discard"; + }; + const list: Row[] = []; + r.sheets.forEach((sheet, si) => { + const rems = getSheetRemnants(sheet); + const groups = computeRemnantGroups(rems); + groups.forEach((g) => { + // 한 그룹이 keep/discard 혼합일 수 있으므로 status 별로 분리하여 row 생성 + (["keep", "discard"] as const).forEach((st) => { + const rects = g.rects.filter((x) => x.status === st); + if (rects.length === 0) return; + const totalArea = rects.reduce((s, x) => s + x.w * x.h, 0); + const minX = Math.min(...rects.map((x) => x.x)); + const minY = Math.min(...rects.map((x) => x.y)); + const maxX = Math.max(...rects.map((x) => x.x + x.w)); + const maxY = Math.max(...rects.map((x) => x.y + x.h)); + const bboxW = maxX - minX; + const bboxH = maxY - minY; + const spec = rects.length === 1 + ? `${rects[0].w}×${rects[0].h}` + : `${bboxW}×${bboxH} (${rects.length}조각 합 ${totalArea.toLocaleString()}mm²)`; + const minDim = rects.length === 1 ? Math.min(rects[0].w, rects[0].h) : Math.min(bboxW, bboxH); + const canReuse = minDim >= minReuse; + list.push({ + sheetId: sheet.id, sheetIdx: si, + remIds: rects.map((x) => x.id), + matName: sheet.matName, + spec, pieces: rects.length, + totalArea, bboxW, bboxH, + canReuse, + status: st, + }); + }); + }); + }); + list.sort((a, b) => b.totalArea - a.totalArea); + return list.map((row, gi) => ({ + no: gi + 1, + sheetId: row.sheetId, + remIds: row.remIds, + range: `Sheet #${row.sheetIdx + 1}`, + count: row.pieces, + matName: row.matName, + spec: row.spec, + remVal: row.totalArea.toLocaleString(), + totalRemVal: row.totalArea.toLocaleString(), + util: "-", + canReuse: row.canReuse, + status: row.status, + })); + } else { + const r = batchResult as LengthResult; + const groups = r.pipeGroups || r.pipes.map((p, pi) => ({ count: 1, repIdx: pi, representative: p, indices: [pi] })); + return groups.map((g, gi) => { + const pipe = g.representative; + const remLen = Math.max(0, pipe.remaining); + const usedLen = pipe.segments.reduce((s, seg) => s + seg.len, 0); + const util = pipe.matLen > 0 ? (usedLen / pipe.matLen) * 100 : 0; + const canReuse = remLen >= minReuse; + const first = Math.min(...g.indices) + 1; + const last = Math.max(...g.indices) + 1; + return { + no: gi + 1, + sheetId: 0, + remIds: [] as string[], + range: g.count > 1 ? `#${first}~#${last}` : `#${first}`, + count: g.count, + matName: pipe.matName, + spec: `${remLen}mm`, + remVal: remLen.toLocaleString(), + totalRemVal: (remLen * g.count).toLocaleString(), + util: util.toFixed(1), + canReuse, + status: "discard" as const, + }; + }); + } + }, [batchResult, cutType, minReuse, getSheetRemnants]); + + const summary = useMemo(() => { + const keep = rows.filter((r) => r.status === "keep"); + const discard = rows.filter((r) => r.status !== "keep"); + return { + keepKinds: keep.length, + keepCount: keep.length, + discardKinds: discard.length, + discardCount: discard.length, + }; + }, [rows]); + + return ( +
+
+
+
+ + 자투리 목록 + {rows.length}개 +
+ {cutType === "area" && rows.length > 0 && ( + <> +
+ + 보관 {summary.keepCount}개 + + + 폐기 {summary.discardCount}개 + + + )} +
+
+ {cutType === "area" && rows.length > 0 && ( + <> + + +
+ + )} + + setMinReuse(+e.target.value)} className="h-7 w-[70px] text-xs" /> + mm 이상 +
+
+
+ {rows.length === 0 ? ( +
+ +

계산 실행 후 자투리 정보가 표시됩니다

+
+ ) : ( + + + + No + {cutType === "length" ? "파이프번호" : "원판"} + 원자재명 + {cutType === "length" ? "잔재 길이" : "자투리 규격"} + {cutType === "length" ? "손실 길이" : "단위 면적"} + {cutType === "length" ? "이용률" : "총 면적"} + 재사용 + {cutType === "area" && 처리} + + + + {rows.map((r) => ( + + {r.no} + + {r.range} + {cutType === "length" && r.count > 1 && ×{r.count}} + + {r.matName} + {r.spec} + + {r.remVal} + + + {cutType === "length" ? ( + <> +
{r.util}%
+
+
+
+ + ) : ( + {r.totalRemVal} + )} + + + + {r.canReuse ? "가능" : "불가"} + + + {cutType === "area" && ( + + + + )} + + ))} + +
+ )} +
+
+ ); +} diff --git a/frontend/components/layout/AdminPageRenderer.tsx b/frontend/components/layout/AdminPageRenderer.tsx index e47509a9..49ed4e82 100644 --- a/frontend/components/layout/AdminPageRenderer.tsx +++ b/frontend/components/layout/AdminPageRenderer.tsx @@ -386,6 +386,7 @@ const ADMIN_PAGE_REGISTRY: Record> = { "/COMPANY_30/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_30/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_30/production/plan-management/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/production/bom": dynamic(() => import("@/app/(main)/COMPANY_30/production/bom/page"), { ssr: false, loading: LoadingFallback }), + "/COMPANY_30/production/cutting-plan": dynamic(() => import("@/app/(main)/COMPANY_30/production/cutting-plan/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/equipment/info": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/info/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/equipment/inspection-record": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/inspection-record/page"), { ssr: false, loading: LoadingFallback }), "/COMPANY_30/equipment/plc-settings": dynamic(() => import("@/app/(main)/COMPANY_30/equipment/plc-settings/page"), { ssr: false, loading: LoadingFallback }), @@ -613,6 +614,7 @@ const DYNAMIC_ADMIN_IMPORTS: Record Promise> = { "/COMPANY_30/production/work-instruction": () => import("@/app/(main)/COMPANY_30/production/work-instruction/page"), "/COMPANY_30/production/plan-management": () => import("@/app/(main)/COMPANY_30/production/plan-management/page"), "/COMPANY_30/production/bom": () => import("@/app/(main)/COMPANY_30/production/bom/page"), + "/COMPANY_30/production/cutting-plan": () => import("@/app/(main)/COMPANY_30/production/cutting-plan/page"), "/COMPANY_30/equipment/info": () => import("@/app/(main)/COMPANY_30/equipment/info/page"), "/COMPANY_30/equipment/plc-settings": () => import("@/app/(main)/COMPANY_30/equipment/plc-settings/page"), "/COMPANY_30/monitoring/production": () => import("@/app/(main)/COMPANY_30/monitoring/production/page"), diff --git a/frontend/lib/cutting/packing.ts b/frontend/lib/cutting/packing.ts new file mode 100644 index 00000000..88e13351 --- /dev/null +++ b/frontend/lib/cutting/packing.ts @@ -0,0 +1,977 @@ +/** + * 절단 배치(Bin Packing) 알고리즘 + * - 면적형 2D: 2-stage Guillotine (FFDH 변형) — 직선 절단 보장 (유리/판재 절단) + * - 면적형 동일품목 우선 배치 — 각 품목별로 독립된 원판 (다른 품목과 안 섞임) + * - 길이형 1D: FFD (packLength) + */ + +export type CutType = "area" | "length"; +export type PackMode = "mixed" | "homo"; +export type Dir = "무관" | "가로" | "세로"; + +export interface Material { + code: string; + name: string; + id?: string; + width?: number; + height?: number; + length?: number; + stock?: number; + unit?: string; +} + +export interface PlanItem { + name: string; // 품목명 (item_info.item_name) + code?: string; // 품목코드 (item_info.item_number) + item_id?: string; // item_info.id (cutting_plan_item.item_id 저장용) + width: number; + height: number; + length?: number; + qty: number; + dir: Dir; + color: string; + placed?: number; +} + +export interface Placement { + x: number; + y: number; + w: number; + h: number; + color: string; + name: string; + itemIdx: number; + rotated: boolean; +} + +export interface Shelf { + y: number; + h: number; + currentX: number; +} + +export interface RemnantItem { + id: string; + x: number; + y: number; + w: number; + h: number; + status: "keep" | "discard"; +} + +export interface Sheet { + id: number; + matW: number; + matH: number; + matCode: string; + matName: string; + shelves: Shelf[]; + placements: Placement[]; + // 사용자가 편집한 자투리. undefined이면 placements로부터 자동 추출 (extractInitialRemnants). + remnants?: RemnantItem[]; +} + +export interface Segment { + len: number; + color: string; + name: string; + itemIdx: number; + startX: number; +} + +export interface Pipe { + id: number; + matLen: number; + matCode: string; + matName: string; + remaining: number; + segments: Segment[]; +} + +export interface SheetGroup { + count: number; + repIdx: number; + representative: Sheet; + indices: number[]; +} + +export interface PipeGroup { + count: number; + repIdx: number; + representative: Pipe; + indices: number[]; +} + +export interface AreaResult { + sheets: Sheet[]; + sheetGroups?: SheetGroup[]; +} + +export interface LengthResult { + pipes: Pipe[]; + pipeGroups?: PipeGroup[]; +} + +export const COLORS = [ + "#3b82f6", "#10b981", "#f59e0b", "#8b5cf6", "#ef4444", + "#06b6d4", "#ec4899", "#84cc16", "#f97316", "#0ea5e9", +]; + +// ───────────────────────────────────────────────────────── +// 면적형 2D 배치 — 2-stage Guillotine (FFDH 기반) +// ───────────────────────────────────────────────────────── +// 모든 절단선이 원판 가장자리에서 가장자리까지 직선으로 이어지도록 보장. +// 1단계: 원판을 가로 shelf로 분할 (shelf 간 = 가로 직선 절단) +// 2단계: 각 shelf 안에서 piece들을 좌→우 배치 (shelf 내 = 세로 직선 절단) + +type PieceInput = { + w: number; h: number; canRot: boolean; + color: string; name: string; itemIdx: number; +}; + +type FitResult = { w: number; h: number; rotated: boolean }; + +// shelf 시작 piece용 — 둘 다 fit이면 height 큰 쪽 (shelf height 최대화) +function fitForShelfStart(p: PieceInput, maxH: number, maxW: number): FitResult | null { + const a = p.w <= maxW && p.h <= maxH ? { w: p.w, h: p.h, rotated: false } : null; + const b = p.canRot && p.h <= maxW && p.w <= maxH ? { w: p.h, h: p.w, rotated: true } : null; + if (a && b) return a.h >= b.h ? a : b; + return a || b; +} + +// shelf 내부 piece용 — 둘 다 fit이면 width 작은 쪽 (남은 width 보존) +function fitInShelf(p: PieceInput, shelfH: number, availW: number): FitResult | null { + const a = p.w <= availW && p.h <= shelfH ? { w: p.w, h: p.h, rotated: false } : null; + const b = p.canRot && p.h <= availW && p.w <= shelfH ? { w: p.h, h: p.w, rotated: true } : null; + if (a && b) return a.w <= b.w ? a : b; + return a || b; +} + +function packGuillotineSingle( + primary: Material, pieces: PieceInput[], kerf: number, + sortFn: (a: PieceInput, b: PieceInput) => number, startId: number +): Sheet[] { + const matW = primary.width || 0; + const matH = primary.height || 0; + if (!matW || !matH || !pieces.length) return []; + + const remaining = [...pieces].sort(sortFn); + const sheets: Sheet[] = []; + let sheetId = startId; + + while (remaining.length > 0) { + const sheet: Sheet = { + id: sheetId++, matW, matH, + matCode: primary.code, matName: primary.name, + shelves: [], placements: [], + }; + let currentY = 0; + let placedAny = false; + + while (currentY < matH) { + const remH = matH - currentY; + let firstIdx = -1; + let firstFit: FitResult | null = null; + for (let i = 0; i < remaining.length; i++) { + const fit = fitForShelfStart(remaining[i], remH, matW); + if (fit) { firstIdx = i; firstFit = fit; break; } + } + if (firstIdx === -1 || !firstFit) break; + + const first = remaining[firstIdx]; + const shelfH = firstFit.h; + sheet.placements.push({ + x: 0, y: currentY, w: firstFit.w, h: firstFit.h, + color: first.color, name: first.name, itemIdx: first.itemIdx, + rotated: firstFit.rotated, + }); + let currentX = firstFit.w + kerf; + remaining.splice(firstIdx, 1); + placedAny = true; + + let i = 0; + while (i < remaining.length) { + const availW = matW - currentX; + if (availW <= 0) break; + const fit = fitInShelf(remaining[i], shelfH, availW); + if (!fit) { i++; continue; } + const p = remaining[i]; + sheet.placements.push({ + x: currentX, y: currentY, w: fit.w, h: fit.h, + color: p.color, name: p.name, itemIdx: p.itemIdx, + rotated: fit.rotated, + }); + currentX += fit.w + kerf; + remaining.splice(i, 1); + } + + currentY += shelfH + kerf; + } + + if (!placedAny) break; + sheets.push(sheet); + } + + return sheets; +} + +// 4가지 정렬 전략 비교 후 best 선택 (원판 수 최소 → 이용률 최대) +function runGuillotine(primary: Material, pieces: PieceInput[], kerf: number, startId: number = 1): Sheet[] { + const matW = primary.width || 0; + const matH = primary.height || 0; + if (!matW || !matH || !pieces.length) return []; + const totalArea = matW * matH; + + const sorts = [ + (a: PieceInput, b: PieceInput) => Math.max(b.w, b.h) - Math.max(a.w, a.h), // 긴 변 큰 순 + (a: PieceInput, b: PieceInput) => b.h - a.h || b.w - a.w, // 높이 큰 순 + (a: PieceInput, b: PieceInput) => b.w - a.w || b.h - a.h, // 폭 큰 순 + (a: PieceInput, b: PieceInput) => b.w * b.h - a.w * a.h, // 면적 큰 순 + ]; + + let best: Sheet[] = []; + let bestScore: [number, number] = [Infinity, -Infinity]; + + for (const sortFn of sorts) { + const sheets = packGuillotineSingle(primary, pieces, kerf, sortFn, startId); + if (!sheets.length) continue; + const count = sheets.length; + const usedArea = sheets.reduce((s, sh) => s + sh.placements.reduce((ss, p) => ss + p.w * p.h, 0), 0); + const util = usedArea / (count * totalArea); + const score: [number, number] = [count, -util]; + if ( + score[0] < bestScore[0] || + (score[0] === bestScore[0] && score[1] < bestScore[1]) + ) { + best = sheets; + bestScore = score; + } + } + return best; +} + +function expandPieces(items: PlanItem[]): PieceInput[] { + const out: PieceInput[] = []; + items.forEach((item, itemIdx) => { + const qty = parseInt(String(item.qty)) || 0; + for (let i = 0; i < qty; i++) { + out.push({ + w: item.width, h: item.height, + canRot: item.dir === "무관", + color: item.color, name: item.name, itemIdx, + }); + } + }); + return out; +} + +// ───────────────────────────────────────────────────────── +// packArea — 혼합 최적 (모든 품목 합쳐서 guillotine packing) +// ───────────────────────────────────────────────────────── +export function packArea(mats: Material[], items: PlanItem[], kerf: number): AreaResult { + if (!mats.length || !items.length) return { sheets: [] }; + const primary = mats[0]; + const pieces = expandPieces(items); + return { sheets: runGuillotine(primary, pieces, kerf, 1) }; +} + +// ───────────────────────────────────────────────────────── +// packAreaHomogeneous — 동일 품목 우선 + 잔여는 혼합 최적 +// ───────────────────────────────────────────────────────── +// 1) 각 품목별로 한 판 가득 채울 수 있는 만큼 단독 판 생성 (floor(qty / maxCap)) +// 2) 가득 못 채우는 잔여 수량은 모든 품목 모아서 혼합 packing (효율 추구) +export function packAreaHomogeneous(mats: Material[], items: PlanItem[], kerf: number): AreaResult { + if (!mats.length || !items.length) return { sheets: [] }; + const primary = mats[0]; + const matW = primary.width || 0; + const matH = primary.height || 0; + if (!matW || !matH) return { sheets: [] }; + + const allSheets: Sheet[] = []; + let sheetId = 1; + const remQty = items.map((i) => parseInt(String(i.qty)) || 0); + + // 1) 각 품목 가득 찬 단독 판 생성 + items.forEach((item, idx) => { + const qty = remQty[idx]; + if (!qty || !item.width || !item.height) return; + + // 실제 maxCap 측정 — runGuillotine 8전략으로 한 판 최대 수용량 + const itemArea = item.width * item.height; + const upper = Math.max(1, Math.ceil((matW * matH) / itemArea) + 5); + const testPieces: PieceInput[] = []; + const trialN = Math.min(qty, upper); + for (let i = 0; i < trialN; i++) { + testPieces.push({ + w: item.width, h: item.height, + canRot: item.dir === "무관", + color: item.color, name: item.name, itemIdx: idx, + }); + } + const trialSheets = runGuillotine(primary, testPieces, kerf, 0); + const maxCap = trialSheets[0]?.placements.length || 0; + if (maxCap === 0) return; + + const fullSheets = Math.floor(qty / maxCap); + if (fullSheets === 0) return; // 한 판도 못 채움 → 전부 잔여로 + + const usedQty = fullSheets * maxCap; + const fullPieces: PieceInput[] = []; + for (let i = 0; i < usedQty; i++) { + fullPieces.push({ + w: item.width, h: item.height, + canRot: item.dir === "무관", + color: item.color, name: item.name, itemIdx: idx, + }); + } + const itemSheets = runGuillotine(primary, fullPieces, kerf, sheetId); + sheetId += itemSheets.length; + allSheets.push(...itemSheets); + remQty[idx] = qty - usedQty; + }); + + // 2) 잔여 수량을 모아서 혼합 packing (효율 추구) + const remPieces: PieceInput[] = []; + items.forEach((item, idx) => { + const q = remQty[idx]; + if (!q || !item.width || !item.height) return; + for (let i = 0; i < q; i++) { + remPieces.push({ + w: item.width, h: item.height, + canRot: item.dir === "무관", + color: item.color, name: item.name, itemIdx: idx, + }); + } + }); + if (remPieces.length) { + const mixSheets = runGuillotine(primary, remPieces, kerf, sheetId); + allSheets.push(...mixSheets); + } + + return { sheets: allSheets }; +} + +// ───────────────────────────────────────────────────────── +// 자투리 형태 추출 (직사각형 단위) +// ───────────────────────────────────────────────────────── +// 원판에서 빈 공간을 직사각형 자투리들로 분할. +// guillotine 배치 결과를 가정하고 shelf 우측 / shelf 내부 piece 위 / 마지막 shelf 아래 자투리 추출. + +export interface Remnant { + x: number; + y: number; + w: number; + h: number; +} + +export function computeRemnants(sheet: Sheet, kerf: number = 0): Remnant[] { + const matW = sheet.matW; + const matH = sheet.matH; + if (!sheet.placements.length) { + return [{ x: 0, y: 0, w: matW, h: matH }]; + } + + // piece 점유 영역을 kerf 만큼 확장 (칼날 폭은 자투리 아님) + // 가장자리(0, matW, matH)는 자르지 않으므로 그쪽으로는 확장하지 않음 + const expanded = sheet.placements.map((p) => ({ + x: Math.max(0, p.x - kerf), + y: Math.max(0, p.y - kerf), + x2: Math.min(matW, p.x + p.w + kerf), + y2: Math.min(matH, p.y + p.h + kerf), + })); + + // 1) 모든 piece의 y 경계로 horizontal strip 분할 (확장된 좌표) + const ySet = new Set([0, matH]); + for (const p of expanded) { + if (p.y > 0 && p.y < matH) ySet.add(p.y); + if (p.y2 > 0 && p.y2 < matH) ySet.add(p.y2); + } + const ys = [...ySet].sort((a, b) => a - b); + + // 2) 각 strip 안에서 piece가 차지하지 않는 x 구간 추출 + const stripRects: Remnant[] = []; + for (let i = 0; i < ys.length - 1; i++) { + const y1 = ys[i]; + const y2 = ys[i + 1]; + const sh = y2 - y1; + if (sh < 1) continue; + + // 이 strip에 걸치는 piece들의 x 범위 (확장된 좌표) + const occupied: { x1: number; x2: number }[] = []; + for (const p of expanded) { + if (p.y < y2 && p.y2 > y1) { + occupied.push({ x1: p.x, x2: p.x2 }); + } + } + occupied.sort((a, b) => a.x1 - b.x1); + + // 빈 x 구간 찾기 (occupied 사이의 gap + 좌우 끝) + let cursor = 0; + for (const o of occupied) { + if (o.x1 > cursor + 0.5) { + stripRects.push({ x: cursor, y: y1, w: o.x1 - cursor, h: sh }); + } + cursor = Math.max(cursor, o.x2); + } + if (cursor < matW - 0.5) { + stripRects.push({ x: cursor, y: y1, w: matW - cursor, h: sh }); + } + } + + // 3) 세로 인접 + 같은 x 범위 자투리 합치기 (반복 적용) + const verticallyMerged = mergeAdjacent(stripRects, "vertical"); + // 4) 가로 인접 + 같은 y 범위 자투리 합치기 (위에서 합쳐진 결과 위에) + const finalRects = mergeAdjacent(verticallyMerged, "horizontal"); + + // 너무 좁은 조각 제거 (kerf 이하는 절단 시 사라지는 영역, 미세 오차) + const minDim = Math.max(1, kerf); + return finalRects.filter((r) => r.w > minDim && r.h > minDim); +} + +// 인접 사각형 합치기 — direction별로 같은 축 정렬되고 인접하면 병합 +function mergeAdjacent(rects: Remnant[], direction: "vertical" | "horizontal"): Remnant[] { + const out: Remnant[] = rects.map((r) => ({ ...r })); + let changed = true; + while (changed) { + changed = false; + for (let i = 0; i < out.length && !changed; i++) { + for (let j = i + 1; j < out.length; j++) { + const a = out[i]; + const b = out[j]; + if (direction === "vertical") { + // 같은 x 범위 + 세로 인접 (a 아래에 b 또는 b 아래에 a) + if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.w - b.w) < 0.5) { + if (Math.abs(a.y + a.h - b.y) < 0.5) { + out[i] = { x: a.x, y: a.y, w: a.w, h: a.h + b.h }; + out.splice(j, 1); + changed = true; + break; + } + if (Math.abs(b.y + b.h - a.y) < 0.5) { + out[i] = { x: a.x, y: b.y, w: a.w, h: a.h + b.h }; + out.splice(j, 1); + changed = true; + break; + } + } + } else { + // 같은 y 범위 + 가로 인접 + if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.h - b.h) < 0.5) { + if (Math.abs(a.x + a.w - b.x) < 0.5) { + out[i] = { x: a.x, y: a.y, w: a.w + b.w, h: a.h }; + out.splice(j, 1); + changed = true; + break; + } + if (Math.abs(b.x + b.w - a.x) < 0.5) { + out[i] = { x: b.x, y: a.y, w: a.w + b.w, h: a.h }; + out.splice(j, 1); + changed = true; + break; + } + } + } + } + } + } + return out; +} + +// computeRemnants 결과에 ID + 기본 status를 부여해서 RemnantItem[]로 변환. +// 사용자가 자투리를 편집하지 않은 sheet에서 초기값 생성용. +export function extractInitialRemnants(sheet: Sheet, idPrefix: string = "", kerf: number = 0): RemnantItem[] { + return computeRemnants(sheet, kerf).map((rm, i) => ({ + id: `${idPrefix}r${i}`, + x: rm.x, + y: rm.y, + w: rm.w, + h: rm.h, + status: "discard", + })); +} + +// ───────────────────────────────────────────────────────── +// 자투리 그룹화 — 변 공유하는 자투리들을 한 덩어리로 묶음 (Union-Find) +// ───────────────────────────────────────────────────────── + +export interface RemnantGroup { + groupId: number; + rects: RemnantItem[]; +} + +function sharesEdgeBox( + a: { x: number; y: number; w: number; h: number }, + b: { x: number; y: number; w: number; h: number } +): boolean { + const eps = 0.5; + // 가로 인접 (수직 변 공유) + const horizAdj = Math.abs(a.x + a.w - b.x) < eps || Math.abs(b.x + b.w - a.x) < eps; + const yOverlap = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y); + if (horizAdj && yOverlap > eps) return true; + // 세로 인접 (수평 변 공유) + const vertAdj = Math.abs(a.y + a.h - b.y) < eps || Math.abs(b.y + b.h - a.y) < eps; + const xOverlap = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x); + if (vertAdj && xOverlap > eps) return true; + return false; +} + +export function computeRemnantGroups(rects: RemnantItem[]): RemnantGroup[] { + const n = rects.length; + if (n === 0) return []; + const parent = Array.from({ length: n }, (_, i) => i); + const find = (i: number): number => { + while (parent[i] !== i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; + }; + const union = (i: number, j: number) => { + const ri = find(i), rj = find(j); + if (ri !== rj) parent[ri] = rj; + }; + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + if (sharesEdgeBox(rects[i], rects[j])) union(i, j); + } + } + const groupMap = new Map(); + for (let i = 0; i < n; i++) { + const root = find(i); + const arr = groupMap.get(root) || []; + arr.push(rects[i]); + groupMap.set(root, arr); + } + return [...groupMap.values()].map((arr, gi) => ({ groupId: gi, rects: arr })); +} + +// ───────────────────────────────────────────────────────── +// 그룹 외곽선 (polygon) 계산 — 사각형들의 union outline +// ───────────────────────────────────────────────────────── +// 알고리즘: 모든 사각형의 4변을 방향성(시계방향) segment로 추출 → +// 격자점 기준 unit segment로 분해 → 같은 위치 반대 방향 segment 쌍 제거 → +// 남은 segment를 head-tail 매칭으로 polygon path 구성 (구멍 포함 가능). + +export interface OutlinePath { + points: { x: number; y: number }[]; +} + +export function computeGroupOutline(rects: { x: number; y: number; w: number; h: number }[]): OutlinePath[] { + if (rects.length === 0) return []; + + // 1) x, y 격자 좌표 모음 + const xSet = new Set(); + const ySet = new Set(); + rects.forEach((r) => { + xSet.add(r.x); xSet.add(r.x + r.w); + ySet.add(r.y); ySet.add(r.y + r.h); + }); + const xs = [...xSet].sort((a, b) => a - b); + const ys = [...ySet].sort((a, b) => a - b); + const xi = (x: number) => xs.indexOf(x); + const yi = (y: number) => ys.indexOf(y); + + // 2) 격자 셀 occupancy (셀 [i, j] 가 채워져 있는지) + const occ: boolean[][] = []; + for (let j = 0; j < ys.length - 1; j++) { + const row: boolean[] = []; + const cy = (ys[j] + ys[j + 1]) / 2; + for (let i = 0; i < xs.length - 1; i++) { + const cx = (xs[i] + xs[i + 1]) / 2; + const o = rects.some((r) => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h); + row.push(o); + } + occ.push(row); + } + + // 3) 각 셀의 4변 중 외부 경계인 것만 directed edge로 등록 (시계방향) + // 격자점은 (xs[i], ys[j]). 셀 [i, j]는 (xs[i], ys[j])-(xs[i+1], ys[j+1]). + // 셀이 채워져 있고 인접 셀이 비어있으면 그 변은 외곽. + type EdgeKey = string; + const edges = new Map(); + const addEdge = (fi: number, fj: number, ti: number, tj: number) => { + const key = `${fi},${fj}-${ti},${tj}`; + edges.set(key, { from: [fi, fj], to: [ti, tj] }); + }; + const cellOcc = (i: number, j: number) => + j >= 0 && j < occ.length && i >= 0 && i < occ[0].length ? occ[j][i] : false; + + for (let j = 0; j < occ.length; j++) { + for (let i = 0; i < occ[0].length; i++) { + if (!occ[j][i]) continue; + // top edge (셀 위쪽이 비어있음 → 시계방향: left→right at y=ys[j]) + if (!cellOcc(i, j - 1)) addEdge(i, j, i + 1, j); + // right edge (셀 오른쪽이 비어있음 → 시계방향: top→bottom at x=xs[i+1]) + if (!cellOcc(i + 1, j)) addEdge(i + 1, j, i + 1, j + 1); + // bottom edge (셀 아래가 비어있음 → 시계방향: right→left at y=ys[j+1]) + if (!cellOcc(i, j + 1)) addEdge(i + 1, j + 1, i, j + 1); + // left edge (셀 왼쪽이 비어있음 → 시계방향: bottom→top at x=xs[i]) + if (!cellOcc(i - 1, j)) addEdge(i, j + 1, i, j); + } + } + + // 4) 같은 from 격자점에서 시작하는 edge 인덱스 + const fromMap = new Map(); + edges.forEach((e) => { + const k = `${e.from[0]},${e.from[1]}`; + const arr = fromMap.get(k) || []; + arr.push(e); + fromMap.set(k, arr); + }); + + // 5) edge들을 head-tail로 연결해서 닫힌 polygon들 추출 + const paths: OutlinePath[] = []; + const used = new Set(); + edges.forEach((startEdge, startKey) => { + if (used.has(startKey)) return; + const points: { x: number; y: number }[] = []; + let cur = startEdge; + let curKey = startKey; + while (cur && !used.has(curKey)) { + used.add(curKey); + points.push({ x: xs[cur.from[0]], y: ys[cur.from[1]] }); + const nextKeyPrefix = `${cur.to[0]},${cur.to[1]}`; + const candidates = fromMap.get(nextKeyPrefix) || []; + const next = candidates.find((e) => !used.has(`${e.from[0]},${e.from[1]}-${e.to[0]},${e.to[1]}`)); + if (!next) break; + cur = next; + curKey = `${next.from[0]},${next.from[1]}-${next.to[0]},${next.to[1]}`; + } + if (points.length >= 3) paths.push({ points: simplifyCollinear(points) }); + }); + return paths; +} + +// ───────────────────────────────────────────────────────── +// 자투리 그룹 재분할 — 그룹의 union 영역을 다른 전략으로 직사각형 분해 +// ───────────────────────────────────────────────────────── +// strategy: +// "h" — 가로 strip 우선 (기본 자동 추출과 같음, 세로 인접 합침) +// "v" — 세로 strip 우선 (가로 인접 합침) +// "max" — Maximum Empty Rectangle 반복 (가장 큰 사각형부터 추출) + +export function decomposeUnion( + rects: { x: number; y: number; w: number; h: number }[], + strategy: "h" | "v" | "max" +): { x: number; y: number; w: number; h: number }[] { + if (rects.length === 0) return []; + + // 격자 좌표 + const xs = [...new Set(rects.flatMap((r) => [r.x, r.x + r.w]))].sort((a, b) => a - b); + const ys = [...new Set(rects.flatMap((r) => [r.y, r.y + r.h]))].sort((a, b) => a - b); + + // 격자 occupancy + const cols = xs.length - 1; + const rows = ys.length - 1; + const occ: boolean[][] = []; + for (let j = 0; j < rows; j++) { + const row: boolean[] = []; + const cy = (ys[j] + ys[j + 1]) / 2; + for (let i = 0; i < cols; i++) { + const cx = (xs[i] + xs[i + 1]) / 2; + const o = rects.some((r) => cx >= r.x && cx <= r.x + r.w && cy >= r.y && cy <= r.y + r.h); + row.push(o); + } + occ.push(row); + } + + if (strategy === "h") { + // 각 row strip에서 occupied 셀들을 가로 인접으로 합침 + const out: { x: number; y: number; w: number; h: number }[] = []; + for (let j = 0; j < rows; j++) { + let i = 0; + while (i < cols) { + if (!occ[j][i]) { i++; continue; } + let i2 = i; + while (i2 < cols && occ[j][i2]) i2++; + out.push({ x: xs[i], y: ys[j], w: xs[i2] - xs[i], h: ys[j + 1] - ys[j] }); + i = i2; + } + } + return mergeAdjacentLocal(out, "vertical"); + } + + if (strategy === "v") { + // 각 column strip에서 occupied 셀들을 세로 인접으로 합침 + const out: { x: number; y: number; w: number; h: number }[] = []; + for (let i = 0; i < cols; i++) { + let j = 0; + while (j < rows) { + if (!occ[j][i]) { j++; continue; } + let j2 = j; + while (j2 < rows && occ[j2][i]) j2++; + out.push({ x: xs[i], y: ys[j], w: xs[i + 1] - xs[i], h: ys[j2] - ys[j] }); + j = j2; + } + } + return mergeAdjacentLocal(out, "horizontal"); + } + + // "max" — Maximum Empty Rectangle 반복 + const out: { x: number; y: number; w: number; h: number }[] = []; + const occCopy = occ.map((row) => [...row]); + while (true) { + const best = findLargestRectangleInGrid(occCopy, xs, ys); + if (!best || best.w * best.h < 1) break; + out.push(best); + // 사용한 영역 점유 해제 + for (let j = 0; j < rows; j++) { + for (let i = 0; i < cols; i++) { + const cx = (xs[i] + xs[i + 1]) / 2; + const cy = (ys[j] + ys[j + 1]) / 2; + if (cx >= best.x && cx <= best.x + best.w && cy >= best.y && cy <= best.y + best.h) { + occCopy[j][i] = false; + } + } + } + } + return out; +} + +// 격자에서 가장 큰 직사각형 (모든 셀 occupied) 찾기 — Histogram 기반 O(rows × cols) +function findLargestRectangleInGrid( + occ: boolean[][], + xs: number[], + ys: number[] +): { x: number; y: number; w: number; h: number } | null { + const rows = occ.length; + if (rows === 0) return null; + const cols = occ[0].length; + if (cols === 0) return null; + + // 각 셀 위쪽 연속 occupied 셀 수 (히스토그램) + const heights: number[][] = []; + for (let j = 0; j < rows; j++) { + const row: number[] = []; + for (let i = 0; i < cols; i++) { + row.push(occ[j][i] ? (j > 0 ? heights[j - 1][i] + 1 : 1) : 0); + } + heights.push(row); + } + + let bestArea = 0; + let bestRect: { x: number; y: number; w: number; h: number } | null = null; + + for (let j = 0; j < rows; j++) { + // 각 행마다 히스토그램에서 가장 큰 직사각형 (실제 mm 면적 기준) + const stack: number[] = []; + for (let i = 0; i <= cols; i++) { + const cur = i === cols ? 0 : heights[j][i]; + while (stack.length && (i === cols || heights[j][stack[stack.length - 1]] > cur)) { + const top = stack.pop()!; + const left = stack.length ? stack[stack.length - 1] + 1 : 0; + const right = i - 1; + const cellH = heights[j][top]; + // 실제 좌표 + const x = xs[left]; + const w = xs[right + 1] - xs[left]; + const y = ys[j - cellH + 1]; + const h = ys[j + 1] - ys[j - cellH + 1]; + const area = w * h; + if (area > bestArea) { + bestArea = area; + bestRect = { x, y, w, h }; + } + } + stack.push(i); + } + } + return bestRect; +} + +// 인접 합치기 (decomposeUnion 전용 로컬 버전 — coord 비교만) +function mergeAdjacentLocal( + rects: { x: number; y: number; w: number; h: number }[], + direction: "vertical" | "horizontal" +): { x: number; y: number; w: number; h: number }[] { + const out = rects.map((r) => ({ ...r })); + let changed = true; + while (changed) { + changed = false; + for (let i = 0; i < out.length && !changed; i++) { + for (let j = i + 1; j < out.length; j++) { + const a = out[i]; + const b = out[j]; + if (direction === "vertical") { + if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.w - b.w) < 0.5) { + if (Math.abs(a.y + a.h - b.y) < 0.5) { + out[i] = { x: a.x, y: a.y, w: a.w, h: a.h + b.h }; + out.splice(j, 1); + changed = true; + break; + } + if (Math.abs(b.y + b.h - a.y) < 0.5) { + out[i] = { x: a.x, y: b.y, w: a.w, h: a.h + b.h }; + out.splice(j, 1); + changed = true; + break; + } + } + } else { + if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.h - b.h) < 0.5) { + if (Math.abs(a.x + a.w - b.x) < 0.5) { + out[i] = { x: a.x, y: a.y, w: a.w + b.w, h: a.h }; + out.splice(j, 1); + changed = true; + break; + } + if (Math.abs(b.x + b.w - a.x) < 0.5) { + out[i] = { x: b.x, y: a.y, w: a.w + b.w, h: a.h }; + out.splice(j, 1); + changed = true; + break; + } + } + } + } + } + } + return out; +} + +// 같은 직선 위 연속점 제거 (꼭짓점만 남김) +function simplifyCollinear(points: { x: number; y: number }[]): { x: number; y: number }[] { + if (points.length < 3) return points; + const out: { x: number; y: number }[] = []; + const n = points.length; + for (let i = 0; i < n; i++) { + const prev = points[(i - 1 + n) % n]; + const cur = points[i]; + const next = points[(i + 1) % n]; + const collinearH = prev.y === cur.y && cur.y === next.y; + const collinearV = prev.x === cur.x && cur.x === next.x; + if (!collinearH && !collinearV) out.push(cur); + } + return out.length >= 3 ? out : points; +} + +// ───────────────────────────────────────────────────────── +// 길이형 1D 배치 (FFD) +// ───────────────────────────────────────────────────────── + +export function packLength(mats: Material[], items: PlanItem[], kerf: number): LengthResult { + const pieces: { len: number; name: string; color: string; itemIdx: number }[] = []; + items.forEach((item, idx) => { + const q = parseInt(String(item.qty)) || 0; + const len = item.length || 0; + for (let i = 0; i < q; i++) pieces.push({ len, name: item.name, color: item.color, itemIdx: idx }); + }); + pieces.sort((a, b) => b.len - a.len); + + const pipes: Pipe[] = []; + for (const p of pieces) { + let placed = false; + for (const pipe of pipes) { + if (pipe.remaining >= p.len) { + pipe.segments.push({ + len: p.len, color: p.color, name: p.name, itemIdx: p.itemIdx, + startX: pipe.matLen - pipe.remaining, + }); + pipe.remaining -= p.len + kerf; + placed = true; + break; + } + } + if (!placed) { + const mat = mats.find((m) => m && (m.length || 0) >= p.len); + if (!mat) continue; + pipes.push({ + id: pipes.length + 1, + matLen: mat.length || 0, + matCode: mat.code, + matName: mat.name, + remaining: (mat.length || 0) - p.len - kerf, + segments: [{ len: p.len, color: p.color, name: p.name, itemIdx: p.itemIdx, startX: 0 }], + }); + } + } + return { pipes }; +} + +// ───────────────────────────────────────────────────────── +// 동일 배치 그룹화 +// ───────────────────────────────────────────────────────── + +export function computeSheetGroups(sheets: Sheet[]): SheetGroup[] { + const groups: SheetGroup[] = []; + const used = new Set(); + sheets.forEach((sheet, si) => { + if (used.has(si)) return; + const indices = [si]; + sheets.forEach((other, oi) => { + if (oi <= si || used.has(oi)) return; + if (isSameSheetLayout(sheet, other)) { indices.push(oi); used.add(oi); } + }); + used.add(si); + groups.push({ count: indices.length, repIdx: si, representative: sheet, indices }); + }); + return groups; +} + +function isSameSheetLayout(a: Sheet, b: Sheet): boolean { + if (a.matCode !== b.matCode || a.placements.length !== b.placements.length) return false; + return a.placements.every((pa, i) => { + const pb = b.placements[i]; + return ( + pa.itemIdx === pb.itemIdx && pa.w === pb.w && pa.h === pb.h && + Math.abs(pa.x - pb.x) < 0.5 && Math.abs(pa.y - pb.y) < 0.5 + ); + }); +} + +export function computePipeGroups(pipes: Pipe[]): PipeGroup[] { + const groups: PipeGroup[] = []; + const used = new Set(); + pipes.forEach((pipe, pi) => { + if (used.has(pi)) return; + const indices = [pi]; + pipes.forEach((other, oi) => { + if (oi <= pi || used.has(oi)) return; + if (isSamePipeLayout(pipe, other)) { indices.push(oi); used.add(oi); } + }); + used.add(pi); + groups.push({ count: indices.length, repIdx: pi, representative: pipe, indices }); + }); + return groups; +} + +function isSamePipeLayout(a: Pipe, b: Pipe): boolean { + if (a.matCode !== b.matCode || a.segments.length !== b.segments.length) return false; + return a.segments.every((sa, i) => { + const sb = b.segments[i]; + return sa.itemIdx === sb.itemIdx && sa.len === sb.len; + }); +} + +// ───────────────────────────────────────────────────────── +// 통계 +// ───────────────────────────────────────────────────────── + +export function computeAreaStats(result: AreaResult) { + const sheets = result.sheets; + const count = sheets.length; + const totalPieces = sheets.reduce((s, sh) => s + sh.placements.length, 0); + const rotated = sheets.reduce( + (s, sh) => s + sh.placements.filter((p) => p.rotated).length, 0 + ); + const totalMat = sheets.reduce((s, sh) => s + sh.matW * sh.matH, 0); + const usedArea = sheets.reduce( + (s, sh) => s + sh.placements.reduce((ss, p) => ss + p.w * p.h, 0), 0 + ); + const util = totalMat > 0 ? (usedArea / totalMat) * 100 : 0; + const loss = totalMat - usedArea; + return { count, totalPieces, rotated, util, loss, usedArea, totalMat }; +} + +export function computeLengthStats(result: LengthResult) { + const pipes = result.pipes; + const count = pipes.length; + const totalPieces = pipes.reduce((s, p) => s + p.segments.length, 0); + const totalLen = pipes.reduce((s, p) => s + p.matLen, 0); + const usedLen = pipes.reduce( + (s, p) => s + p.segments.reduce((ss, seg) => ss + seg.len, 0), 0 + ); + const util = totalLen > 0 ? (usedLen / totalLen) * 100 : 0; + const loss = totalLen - usedLen; + return { count, totalPieces, util, loss, usedLen, totalLen }; +}