feat: Add cutting plan management for COMPANY_30
- Cutting optimization (Guillotine FFDH) with mixed/homogeneous modes - Remnant management with persistence (cutting_plan_sheet.remnants JSONB) - Work instruction creation linked via batch_no/cutting_plan_id Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Record<string, "keep" | "discard">>(() => {
|
||||
const init: Record<string, "keep" | "discard"> = {};
|
||||
if (sheet.remnants) {
|
||||
sheet.remnants.forEach((rm) => {
|
||||
init[`${rm.x}|${rm.y}|${rm.w}|${rm.h}`] = rm.status;
|
||||
});
|
||||
}
|
||||
return init;
|
||||
});
|
||||
```
|
||||
|
||||
**자투리 필터 (packing.ts)**:
|
||||
```typescript
|
||||
const minDim = Math.max(1, kerf);
|
||||
return finalRects.filter((r) => r.w > minDim && r.h > minDim);
|
||||
```
|
||||
@@ -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); // 시스템 공지
|
||||
|
||||
@@ -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<any>, 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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<string> {
|
||||
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<number, number>(); // 프론트 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;
|
||||
}
|
||||
@@ -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<WorkInstructionApplyItem[]>([]);
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[1100px] w-[95vw] max-h-[90vh] flex flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>작업지시 적용 확인</DialogTitle>
|
||||
<DialogDescription>
|
||||
기본 정보를 입력하고 '최종 적용' 버튼을 눌러주세요.
|
||||
{batchNo ? <span className="ml-2 text-primary font-medium">배치번호 {batchNo}</span> : null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-5">
|
||||
<div className="bg-muted/30 border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground">작업지시 기본 정보</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">작업지시번호</Label>
|
||||
<Input value={wiNo} readOnly className="h-9 bg-muted cursor-not-allowed font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">상태</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="일반">일반</SelectItem>
|
||||
<SelectItem value="긴급">긴급</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">시작일</Label>
|
||||
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">완료예정일</Label>
|
||||
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">설비</Label>
|
||||
<Select value={nv(equipmentId)} onValueChange={(v) => setEquipmentId(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="설비 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NV}>선택 안 함</SelectItem>
|
||||
{equipmentOptions.map((eq) => (
|
||||
<SelectItem key={eq.id} value={eq.id}>{eq.equipment_name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">작업조</Label>
|
||||
<Select value={nv(workTeam)} onValueChange={(v) => setWorkTeam(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="작업조" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NV}>선택 안 함</SelectItem>
|
||||
<SelectItem value="주간">주간</SelectItem>
|
||||
<SelectItem value="야간">야간</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">작업자</Label>
|
||||
<Select value={nv(worker)} onValueChange={(v) => setWorker(fromNv(v))}>
|
||||
<SelectTrigger className="h-9"><SelectValue placeholder="작업자 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NV}>선택 안 함</SelectItem>
|
||||
{workerOptions.map((w) => (
|
||||
<SelectItem key={w.user_id} value={w.user_id}>
|
||||
{w.user_name}{w.dept_name ? ` · ${w.dept_name}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[11px] font-semibold text-muted-foreground">비고</Label>
|
||||
<Input className="h-9" placeholder="비고를 입력해주세요" value={remark} onChange={(e) => setRemark(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg p-5">
|
||||
<h4 className="text-[13px] font-bold pb-2 border-b text-foreground mb-3">품목 목록</h4>
|
||||
<div className="overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow className="bg-muted hover:bg-muted">
|
||||
<TableHead className="w-[50px] text-[11px] font-bold text-muted-foreground">순번</TableHead>
|
||||
<TableHead className="w-[130px] text-[11px] font-bold text-muted-foreground">배치번호</TableHead>
|
||||
<TableHead className="w-[110px] text-[11px] font-bold text-muted-foreground">품목코드</TableHead>
|
||||
<TableHead className="text-[11px] font-bold text-muted-foreground">품목명</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold text-muted-foreground">규격</TableHead>
|
||||
<TableHead className="w-[100px] text-[11px] font-bold text-muted-foreground">수량</TableHead>
|
||||
<TableHead className="text-[11px] font-bold text-muted-foreground">비고</TableHead>
|
||||
<TableHead className="w-[40px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell className="text-[13px] text-center">{idx + 1}</TableCell>
|
||||
<TableCell className="text-[13px] font-mono text-primary">{batchNo || "-"}</TableCell>
|
||||
<TableCell className="text-[13px] font-medium">{item.itemCode || "-"}</TableCell>
|
||||
<TableCell className="text-sm">{item.itemName || "-"}</TableCell>
|
||||
<TableCell className="text-[13px]">{item.spec || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number" className="h-7 text-[13px] w-24"
|
||||
value={item.qty}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, qty: Number(e.target.value) } : it))}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
className="h-7 text-[13px]" placeholder="비고"
|
||||
value={item.remark || ""}
|
||||
onChange={(e) => setItems((prev) => prev.map((it, i) => i === idx ? { ...it, remark: e.target.value } : it))}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6"
|
||||
onClick={() => setItems((prev) => prev.filter((_, i) => i !== idx))}>
|
||||
<X className="w-3 h-3 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground text-[12px] py-6">
|
||||
품목이 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={!canSave || saving}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> : <CheckCircle2 className="w-4 h-4 mr-1.5" />}
|
||||
최종 적용
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -386,6 +386,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
|
||||
"/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<string, () => Promise<any>> = {
|
||||
"/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"),
|
||||
|
||||
@@ -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<number>([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<number, RemnantItem[]>();
|
||||
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<number>();
|
||||
const ySet = new Set<number>();
|
||||
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<EdgeKey, { from: [number, number]; to: [number, number] }>();
|
||||
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<string, { from: [number, number]; to: [number, number] }[]>();
|
||||
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<string>();
|
||||
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<number>();
|
||||
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<number>();
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user