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 shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||||
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
import shippingOrderRoutes from "./routes/shippingOrderRoutes"; // 출하지시 관리
|
||||||
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
import workInstructionRoutes from "./routes/workInstructionRoutes"; // 작업지시 관리
|
||||||
|
import cuttingPlanRoutes from "./routes/cuttingPlanRoutes"; // 절단계획 관리
|
||||||
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
import salesReportRoutes from "./routes/salesReportRoutes"; // 영업 리포트
|
||||||
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
|
import reportPresetRoutes from "./routes/reportPresetRoutes"; // 리포트 프리셋 저장 (회사별/리포트별)
|
||||||
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
import analyticsReportRoutes from "./routes/analyticsReportRoutes"; // 분석 리포트 (생산/재고/구매/품질/설비/금형)
|
||||||
@@ -379,6 +380,7 @@ app.use("/api/mold", moldRoutes); // 금형 관리
|
|||||||
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리
|
||||||
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
app.use("/api/shipping-order", shippingOrderRoutes); // 출하지시 관리
|
||||||
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
app.use("/api/work-instruction", workInstructionRoutes); // 작업지시 관리
|
||||||
|
app.use("/api/cutting-plan", cuttingPlanRoutes); // 절단계획 관리
|
||||||
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
app.use("/api/sales-report", salesReportRoutes); // 영업 리포트
|
||||||
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
|
app.use("/api/report-presets", reportPresetRoutes); // 리포트 프리셋 (회사별/리포트별 저장)
|
||||||
app.use("/api/system-notice", systemNoticeRoutes); // 시스템 공지
|
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/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/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/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/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/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 }),
|
"/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/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/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/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/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/equipment/plc-settings": () => import("@/app/(main)/COMPANY_30/equipment/plc-settings/page"),
|
||||||
"/COMPANY_30/monitoring/production": () => import("@/app/(main)/COMPANY_30/monitoring/production/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