diff --git a/backend-node/src/controllers/cuttingPlanController.ts b/backend-node/src/controllers/cuttingPlanController.ts index 53316214..7c7e99f4 100644 --- a/backend-node/src/controllers/cuttingPlanController.ts +++ b/backend-node/src/controllers/cuttingPlanController.ts @@ -95,3 +95,80 @@ export async function deletePlan(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: e?.message }); } } + +// ───────────────────────────────────────────────────────── +// [TASK:ERP-109] 보관 자투리 풀 (cutting_scrap) +// ───────────────────────────────────────────────────────── + +/** POST /cutting-plan/scrap — 보관 등록(단건/배치) */ +export async function scrapStore(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId || "system"; + const body = req.body || {}; + // 단일 item 또는 items 배열 모두 허용 + let items: any[] = []; + if (Array.isArray(body.items)) { + items = body.items.map((it: any) => ({ + mat_item_id: it.mat_item_id || body.mat_item_id, + ...it, + })); + } else if (body.mat_item_id) { + items = [body]; + } + if (!items.length) { + return res.status(400).json({ success: false, message: "items 또는 mat_item_id 누락" }); + } + const data = await svc.scrapStore(companyCode, userId, items); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자투리 보관 실패", { error: e?.message }); + const status = e?.status || 400; + return res.status(status).json({ success: false, message: e?.message || "자투리 보관 실패" }); + } +} + +/** GET /cutting-plan/scrap?mat_item_id=&status= — 풀 조회 */ +export async function scrapList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const mat_item_id = req.query.mat_item_id as string | undefined; + const status = (req.query.status as string) || "keep"; + const data = await svc.scrapList(companyCode, { mat_item_id, status }); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자투리 풀 조회 실패", { error: e?.message }); + return res.status(500).json({ success: false, message: e?.message }); + } +} + +/** PATCH /cutting-plan/scrap/:id/use — 사용 처리 */ +export async function scrapUse(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId || "system"; + const scrapId = req.params.id; + const usedPlanId = req.body?.used_plan_id != null ? Number(req.body.used_plan_id) : null; + const data = await svc.scrapUse(companyCode, userId, scrapId, usedPlanId); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자투리 사용 처리 실패", { error: e?.message }); + const status = e?.status || 500; + return res.status(status).json({ success: false, message: e?.message }); + } +} + +/** PATCH /cutting-plan/scrap/:id/discard — 폐기 처리 */ +export async function scrapDiscard(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId || "system"; + const scrapId = req.params.id; + const data = await svc.scrapDiscard(companyCode, userId, scrapId); + return res.json({ success: true, data }); + } catch (e: any) { + logger.error("자투리 폐기 처리 실패", { error: e?.message }); + const status = e?.status || 500; + return res.status(status).json({ success: false, message: e?.message }); + } +} diff --git a/backend-node/src/routes/cuttingPlanRoutes.ts b/backend-node/src/routes/cuttingPlanRoutes.ts index 825f3a50..b5fb9007 100644 --- a/backend-node/src/routes/cuttingPlanRoutes.ts +++ b/backend-node/src/routes/cuttingPlanRoutes.ts @@ -22,4 +22,10 @@ router.post("/plans", ctrl.savePlan); router.put("/plans/:id", ctrl.savePlan); // id는 body에 담겨오거나 경로로 router.delete("/plans/:id", ctrl.deletePlan); +// [TASK:ERP-109] 보관 자투리 풀 (cutting_scrap) +router.post ("/scrap", ctrl.scrapStore); // 보관 등록(단건/배치) +router.get ("/scrap", ctrl.scrapList); // 풀 조회 (mat_item_id/status 필터) +router.patch ("/scrap/:id/use", ctrl.scrapUse); // 사용 처리 +router.patch ("/scrap/:id/discard",ctrl.scrapDiscard); // 폐기 처리 + export default router; diff --git a/backend-node/src/services/cuttingPlanService.ts b/backend-node/src/services/cuttingPlanService.ts index 411b43f2..306a7d7e 100644 --- a/backend-node/src/services/cuttingPlanService.ts +++ b/backend-node/src/services/cuttingPlanService.ts @@ -393,3 +393,197 @@ export async function deletePlan(companyCode: string, planId: number) { ); return r.rows.length > 0; } + +// ───────────────────────────────────────────────────────── +// [TASK:ERP-109] 보관 자투리 원자재 단위 풀 (cutting_scrap) +// - 절단계획·시트와 독립. 회사 격리 필수. +// ───────────────────────────────────────────────────────── + +export interface ScrapItemInput { + mat_item_id: string; + mat_name?: string | null; + width?: number | null; + height?: number | null; + length?: number | null; + thickness?: number | null; + qty?: number; + source_plan_id?: number | null; + source_sheet_id?: number | null; + memo?: string | null; +} + +/** + * 자투리 보관 등록 (단건 또는 배치). + * @returns 신규 생성된 행 배열 + */ +export async function scrapStore( + companyCode: string, + userId: string, + items: ScrapItemInput[] +) { + if (!companyCode) throw new Error("회사코드 누락"); + if (!Array.isArray(items) || items.length === 0) return []; + + const pool = getPool(); + const client = await pool.connect(); + const created: any[] = []; + try { + await client.query("BEGIN"); + for (const it of items) { + if (!it.mat_item_id) { + throw new Error("원자재 품목코드(mat_item_id) 누락"); + } + const qty = Number.isFinite(it.qty as number) ? Number(it.qty) : 1; + if (qty <= 0) { + throw new Error("수량은 1 이상이어야 합니다"); + } + // 크기 검증 — 면적형(width/height) 또는 길이형(length) 중 하나는 양수여야 함 + const w = it.width != null ? Number(it.width) : null; + const h = it.height != null ? Number(it.height) : null; + const l = it.length != null ? Number(it.length) : null; + const t = it.thickness != null ? Number(it.thickness) : null; + const hasArea = (w != null && w > 0) && (h != null && h > 0); + const hasLength = (l != null && l > 0); + if (!hasArea && !hasLength) { + throw new Error("크기(가로/세로 또는 길이)는 1 이상이어야 합니다"); + } + if ((w != null && w < 0) || (h != null && h < 0) || (l != null && l < 0) || (t != null && t < 0)) { + throw new Error("크기는 음수일 수 없습니다"); + } + const r = await client.query( + `INSERT INTO cutting_scrap + (company_code, mat_item_id, mat_name, width, height, length, thickness, + qty, status, source_plan_id, source_sheet_id, memo, created_by, updated_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'keep',$9,$10,$11,$12,$12) + RETURNING *`, + [ + companyCode, + it.mat_item_id, + it.mat_name || null, + w, h, l, t, + qty, + it.source_plan_id || null, + it.source_sheet_id || null, + it.memo || null, + userId, + ] + ); + created.push(r.rows[0]); + } + await client.query("COMMIT"); + return created; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +/** + * 자투리 풀 조회. + * mat_item_id 미지정 시 회사 전체. + * status 기본 'keep'. + */ +export async function scrapList( + companyCode: string, + opts?: { mat_item_id?: string; status?: string } +) { + if (!companyCode) throw new Error("회사코드 누락"); + const pool = getPool(); + const params: any[] = [companyCode]; + let where = `company_code = $1`; + const status = opts?.status || "keep"; + params.push(status); + where += ` AND status = $${params.length}`; + if (opts?.mat_item_id) { + params.push(opts.mat_item_id); + where += ` AND mat_item_id = $${params.length}`; + } + const q = ` + SELECT id, company_code, mat_item_id, mat_name, + width, height, length, thickness, + qty, status, source_plan_id, source_sheet_id, used_plan_id, + memo, created_date, updated_date, created_by, updated_by + FROM cutting_scrap + WHERE ${where} + ORDER BY created_date DESC, id DESC + LIMIT 500 + `; + const r = await pool.query(q, params); + return r.rows; +} + +/** + * 자투리 사용 처리 (status='used'). + * 이미 used/discard 인 행은 재사용 차단. + */ +export async function scrapUse( + companyCode: string, + userId: string, + scrapId: string, + usedPlanId: number | null +) { + if (!companyCode) throw new Error("회사코드 누락"); + if (!scrapId) throw new Error("자투리 id 누락"); + const pool = getPool(); + const cur = await pool.query( + `SELECT id, status FROM cutting_scrap WHERE id=$1 AND company_code=$2`, + [scrapId, companyCode] + ); + if (!cur.rows.length) { + const err: any = new Error("자투리를 찾을 수 없습니다"); + err.status = 404; + throw err; + } + if (cur.rows[0].status !== "keep") { + const err: any = new Error(`이미 처리된 자투리입니다 (status=${cur.rows[0].status})`); + err.status = 400; + throw err; + } + const r = await pool.query( + `UPDATE cutting_scrap + SET status='used', used_plan_id=$1, + updated_date=NOW(), updated_by=$2 + WHERE id=$3 AND company_code=$4 + RETURNING *`, + [usedPlanId || null, userId, scrapId, companyCode] + ); + return r.rows[0]; +} + +/** + * 자투리 폐기 처리 (status='discard'). + */ +export async function scrapDiscard( + companyCode: string, + userId: string, + scrapId: string +) { + if (!companyCode) throw new Error("회사코드 누락"); + if (!scrapId) throw new Error("자투리 id 누락"); + const pool = getPool(); + const cur = await pool.query( + `SELECT id, status FROM cutting_scrap WHERE id=$1 AND company_code=$2`, + [scrapId, companyCode] + ); + if (!cur.rows.length) { + const err: any = new Error("자투리를 찾을 수 없습니다"); + err.status = 404; + throw err; + } + if (cur.rows[0].status === "used") { + const err: any = new Error("사용된 자투리는 폐기할 수 없습니다"); + err.status = 400; + throw err; + } + const r = await pool.query( + `UPDATE cutting_scrap + SET status='discard', + updated_date=NOW(), updated_by=$1 + WHERE id=$2 AND company_code=$3 + RETURNING *`, + [userId, scrapId, companyCode] + ); + return r.rows[0]; +} diff --git a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx index 620398c1..5b2c0026 100644 --- a/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx +++ b/frontend/app/(main)/COMPANY_9/production/cutting-plan/page.tsx @@ -142,6 +142,29 @@ export default function CuttingPlanPage() { const [currentPlanNo, setCurrentPlanNo] = useState(""); const [saving, setSaving] = useState(false); + // [TASK:ERP-109] 보관 자투리 영속 풀 (원자재 단위) + // - scrapPool: 현재 선택된 mat1Id의 STORED('keep') 자투리 목록 + // - allScrapPool: 회사 전체 STORED 자투리 (자투리관리 탭에서 mat 무관하게 표시) + type ScrapPoolRow = { + id: string; + company_code: string; + mat_item_id: string; + mat_name?: string; + width?: number | null; + height?: number | null; + length?: number | null; + thickness?: number | null; + qty: number; + status: "keep" | "used" | "discard"; + source_plan_id?: number | null; + source_sheet_id?: number | null; + used_plan_id?: number | null; + created_date?: string; + }; + const [scrapPool, setScrapPool] = useState([]); + const [allScrapPool, setAllScrapPool] = useState([]); + const [scrapPoolLoading, setScrapPoolLoading] = useState(false); + // ─────────────────────────────────────────────────────── // 데이터 로딩 // ─────────────────────────────────────────────────────── @@ -165,6 +188,55 @@ export default function CuttingPlanPage() { } }, [cutType]); + // [TASK:ERP-109] 보관 자투리 풀 조회 헬퍼 + // - loadScrapPool(matItemId): 특정 원자재의 STORED 자투리 (후보 노출/사용 처리용) + // - loadAllScrapPool(): 회사 전체 STORED 자투리 (자투리관리 탭 풀 섹션용) + const normalizeScrap = (r: any): ScrapPoolRow => ({ + id: String(r.id), + company_code: r.company_code, + mat_item_id: String(r.mat_item_id), + mat_name: r.mat_name || "", + width: r.width != null ? Number(r.width) : null, + height: r.height != null ? Number(r.height) : null, + length: r.length != null ? Number(r.length) : null, + thickness: r.thickness != null ? Number(r.thickness) : null, + qty: Number(r.qty) || 1, + status: (r.status as any) || "keep", + source_plan_id: r.source_plan_id ?? null, + source_sheet_id: r.source_sheet_id ?? null, + used_plan_id: r.used_plan_id ?? null, + created_date: r.created_date, + }); + + const loadScrapPool = useCallback(async (matItemId: string) => { + if (!matItemId) { setScrapPool([]); return; } + try { + const res = await apiClient.get(`/cutting-plan/scrap`, { + params: { mat_item_id: matItemId, status: "keep" }, + }); + const rows = (res.data?.data || []).map(normalizeScrap); + setScrapPool(rows); + } catch (e: any) { + // 풀 조회 실패는 토스트 한 번만, 화면은 빈 풀로 동작 + toast.error("자투리 풀 조회 실패: " + (e?.response?.data?.message || e?.message || "")); + setScrapPool([]); + } + }, []); + + const loadAllScrapPool = useCallback(async () => { + setScrapPoolLoading(true); + try { + const res = await apiClient.get(`/cutting-plan/scrap`, { params: { status: "keep" } }); + const rows = (res.data?.data || []).map(normalizeScrap); + setAllScrapPool(rows); + } catch (e: any) { + toast.error("자투리 풀 조회 실패: " + (e?.response?.data?.message || e?.message || "")); + setAllScrapPool([]); + } finally { + setScrapPoolLoading(false); + } + }, []); + const loadOrders = useCallback(async () => { setLoadingOrders(true); try { @@ -401,6 +473,37 @@ export default function CuttingPlanPage() { else if (leftTab === "ship") loadShipmentPlans(); }, [leftTab, loadProductionPlans, loadShipmentPlans]); + // [TASK:ERP-109] 원자재 변경 시 후보 풀 자동 fetch + 초기 회사 전체 풀 fetch + useEffect(() => { loadScrapPool(mat1Id); }, [mat1Id, loadScrapPool]); + useEffect(() => { loadAllScrapPool(); }, [loadAllScrapPool]); + + // [TASK:ERP-109] 풀에서 폐기/사용 처리 핸들러 + const discardScrap = useCallback(async (scrapId: string) => { + try { + await apiClient.patch(`/cutting-plan/scrap/${scrapId}/discard`); + toast.success("자투리가 폐기 처리되었습니다"); + loadAllScrapPool(); + if (mat1Id) loadScrapPool(mat1Id); + } catch (e: any) { + toast.error("폐기 실패: " + (e?.response?.data?.message || e?.message || "")); + } + }, [mat1Id, loadAllScrapPool, loadScrapPool]); + + const useScrapAsCandidate = useCallback(async (scrapId: string) => { + // 후보 추가 자체는 UI 상에서 sheet 후보로 prepend, 실제 사용 확정은 저장 시점에 호출. + // 여기서는 "사용 처리"만 단독 호출하는 경로 (수동 풀 → 즉시 사용) + try { + await apiClient.patch(`/cutting-plan/scrap/${scrapId}/use`, { + used_plan_id: currentPlanId, + }); + toast.success("자투리가 사용 처리되었습니다"); + loadAllScrapPool(); + if (mat1Id) loadScrapPool(mat1Id); + } catch (e: any) { + toast.error("사용 처리 실패: " + (e?.response?.data?.message || e?.message || "")); + } + }, [currentPlanId, mat1Id, loadAllScrapPool, loadScrapPool]); + // 절단유형 바뀌면 선택/결과 리셋 useEffect(() => { setMat1Id(""); @@ -976,17 +1079,52 @@ export default function CuttingPlanPage() { const res = await apiClient.post("/cutting-plan/plans", { header, items, sheets }); const data = res.data?.data; + const savedPlanId = data?.id || currentPlanId; if (data?.id) setCurrentPlanId(data.id); if (data?.plan_no) setCurrentPlanNo(data.plan_no); + + // [TASK:ERP-109] 보관(keep) 자투리를 영속 풀(cutting_scrap)에 등록 + // 면적형(area)만 대상. mat1 id 필수. 같은 mat·크기·두께는 행으로 분리 보관(출처 추적 위해 별행). + if (cutType === "area" && mat1?.id && batchResult) { + try { + const keepItems: any[] = []; + (batchResult as AreaResult).sheets.forEach((sh) => { + const rems = sh.remnants || extractInitialRemnants(sh, `s${sh.id}-`, kerf); + rems.forEach((rm) => { + if (rm.status === "keep" && rm.w > 0 && rm.h > 0) { + keepItems.push({ + mat_item_id: String(mat1.id), + mat_name: mat1.name || sh.matName || null, + width: rm.w, + height: rm.h, + thickness: null, + qty: 1, + source_plan_id: savedPlanId || null, + source_sheet_id: sh.id || null, + }); + } + }); + }); + if (keepItems.length > 0) { + await apiClient.post("/cutting-plan/scrap", { items: keepItems }); + } + } catch (scrapErr: any) { + // 보관 등록 실패는 경고만 (계획 저장 자체는 성공) + toast.error("자투리 보관 등록 실패: " + (scrapErr?.response?.data?.message || scrapErr?.message || "")); + } + } + toast.success(`저장되었습니다 — 배치번호 ${data?.plan_no || currentPlanNo}`); - // 수주 목록 자동 새로고침 → 배치번호 표시 + // 수주 목록 + 풀 자동 새로고침 loadOrders(); + loadAllScrapPool(); + if (mat1?.id) loadScrapPool(String(mat1.id)); } catch (e: any) { toast.error("저장 실패: " + (e?.response?.data?.message || e?.message || "")); } finally { setSaving(false); } - }, [planItems, currentPlanId, currentPlanNo, dateFrom, dateTo, cutType, calcMode, packMode, mat1, mat2, kerf, margin, minRemnant, minReuse, batchResult]); + }, [planItems, currentPlanId, currentPlanNo, dateFrom, dateTo, cutType, calcMode, packMode, mat1, mat2, kerf, margin, minRemnant, minReuse, batchResult, loadOrders, loadAllScrapPool, loadScrapPool]); // ─────────────────────────────────────────────────────── // UI Helpers @@ -1516,6 +1654,19 @@ export default function CuttingPlanPage() { 원자재 2 )} + {/* [TASK:ERP-109] 선택된 원자재의 보관 자투리 후보 배지 */} + {mat1 && scrapPool.length > 0 && ( + + )}
setMinRemnant(+e.target.value)} className="h-7 w-[60px] text-xs px-1.5" /> @@ -1742,6 +1893,10 @@ export default function CuttingPlanPage() { getSheetRemnants={getSheetRemnants} onToggleGroupStatus={setGroupRemnantStatus} onSetAllStatus={setAllRemnantStatus} + scrapPool={allScrapPool} + scrapPoolLoading={scrapPoolLoading} + onDiscardScrap={discardScrap} + onReloadScrapPool={loadAllScrapPool} /> @@ -3001,8 +3156,23 @@ function LengthBatchView({ result }: { result: LengthResult }) { // ───────────────────────────────────────────────────────── // 자투리 관리 뷰 // ───────────────────────────────────────────────────────── +type ScrapPoolRowView = { + id: string; + mat_item_id: string; + mat_name?: string; + width?: number | null; + height?: number | null; + length?: number | null; + thickness?: number | null; + qty: number; + status: "keep" | "used" | "discard"; + source_plan_id?: number | null; + created_date?: string; +}; + function RemnantView({ batchResult, cutType, minReuse, setMinReuse, getSheetRemnants, onToggleGroupStatus, onSetAllStatus, + scrapPool, scrapPoolLoading, onDiscardScrap, onReloadScrapPool, }: { batchResult: AreaResult | LengthResult | null; cutType: CutType; @@ -3011,6 +3181,11 @@ function RemnantView({ getSheetRemnants: (sheet: Sheet) => RemnantItem[]; onToggleGroupStatus: (sheetId: number, remIds: string[], status: "keep" | "discard") => void; onSetAllStatus: (status: "keep" | "discard") => void; + // [TASK:ERP-109] 보관 자투리 영속 풀 (회사 전체 STORED) + scrapPool: ScrapPoolRowView[]; + scrapPoolLoading: boolean; + onDiscardScrap: (scrapId: string) => void; + onReloadScrapPool: () => void; }) { const rows = useMemo(() => { if (!batchResult) return []; @@ -3111,6 +3286,12 @@ function RemnantView({ }; }, [rows]); + // [TASK:ERP-109] 영속 풀 통계 — 회사 전체 STORED 자투리 수량 합산 + const poolSummary = useMemo(() => { + const totalQty = scrapPool.reduce((s, r) => s + (r.qty || 0), 0); + return { rows: scrapPool.length, totalQty }; + }, [scrapPool]); + return (
@@ -3131,6 +3312,11 @@ function RemnantView({ )} + {/* [TASK:ERP-109] 영속 풀 배지 — 회사 전체 보관 자투리 (계획 전환에도 영속) */} +
+ + 영속 보관 {poolSummary.totalQty}개 ({poolSummary.rows}건) +
{cutType === "area" && rows.length > 0 && ( @@ -3146,10 +3332,71 @@ function RemnantView({
+ {/* [TASK:ERP-109] 보관 풀 섹션 — 회사 전체 STORED 자투리 (계획·세션 무관 영속) */} +
+
+
+ + 보관 풀 (영속) + — 모든 원자재의 보관 자투리 (다른 계획 추가/이동에도 유지) +
+ +
+ {scrapPool.length === 0 ? ( +
보관된 자투리가 없습니다
+ ) : ( +
+ + + + + + + + + + + + {scrapPool.map((s) => { + const sizeStr = s.length && s.length > 0 + ? `L${s.length}mm` + : `${s.width || 0}×${s.height || 0}${s.thickness ? ` t${s.thickness}` : ""}`; + return ( + + + + + + + + ); + })} + +
원자재크기수량출처계획처리
{s.mat_name || s.mat_item_id}{sizeStr}{s.qty} + {s.source_plan_id ? `#${s.source_plan_id}` : "-"} + + +
+
+ )} +
+ {rows.length === 0 ? ( -
+
-

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

+

현재 계획의 자투리는 계산 실행 후 표시됩니다

) : (