Implement scrap management functionality in cutting plan

- Added new endpoints for managing scrap items, including storing, listing, using, and discarding scraps.
- Implemented company code filtering to ensure data integrity based on user permissions.
- Enhanced error handling for missing fields and data not found scenarios.
- Updated the cutting plan routes to include new scrap management operations.

(TASK: ERP-109)
This commit is contained in:
kjs
2026-05-26 17:20:03 +09:00
parent 08ff796ff1
commit f2d5cd668d
4 changed files with 528 additions and 4 deletions

View File

@@ -142,6 +142,29 @@ export default function CuttingPlanPage() {
const [currentPlanNo, setCurrentPlanNo] = useState<string>("");
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<ScrapPoolRow[]>([]);
const [allScrapPool, setAllScrapPool] = useState<ScrapPoolRow[]>([]);
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() {
<Plus className="h-3 w-3 mr-0.5" /> 2
</Button>
)}
{/* [TASK:ERP-109] 선택된 원자재의 보관 자투리 후보 배지 */}
{mat1 && scrapPool.length > 0 && (
<button
type="button"
onClick={() => setRightTab("remnant")}
className="inline-flex items-center gap-1 rounded bg-emerald-50 border border-emerald-300 px-2 py-0.5 text-[11px] text-emerald-800 hover:bg-emerald-100"
title="보관 풀 보기 (자투리 관리 탭으로 이동)"
>
<Package className="h-3 w-3" />
<span className="font-semibold"> {scrapPool.reduce((s, r) => s + (r.qty || 0), 0)}</span>
<span className="text-emerald-600"> </span>
</button>
)}
<div className="ml-auto flex items-center gap-1">
<Label className="text-[11px] text-muted-foreground whitespace-nowrap"></Label>
<Input type="number" value={minRemnant} onChange={(e) => 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}
/>
</TabsContent>
</Tabs>
@@ -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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between border-b px-3 py-2 bg-muted/20 flex-wrap gap-2">
@@ -3131,6 +3312,11 @@ function RemnantView({
</Badge>
</>
)}
{/* [TASK:ERP-109] 영속 풀 배지 — 회사 전체 보관 자투리 (계획 전환에도 영속) */}
<div className="h-4 w-px bg-border" />
<Badge variant="outline" className="bg-emerald-500/10 text-emerald-700 border-emerald-500/30 text-[10px]" title="원자재 단위 영속 풀">
{poolSummary.totalQty} ({poolSummary.rows})
</Badge>
</div>
<div className="flex items-center gap-2">
{cutType === "area" && rows.length > 0 && (
@@ -3146,10 +3332,71 @@ function RemnantView({
</div>
</div>
<div className="flex-1 overflow-auto">
{/* [TASK:ERP-109] 보관 풀 섹션 — 회사 전체 STORED 자투리 (계획·세션 무관 영속) */}
<div className="border-b bg-emerald-50/30">
<div className="px-3 py-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs">
<Package className="h-3.5 w-3.5 text-emerald-700" />
<span className="font-semibold text-emerald-800"> ()</span>
<span className="text-muted-foreground"> ( / )</span>
</div>
<Button size="sm" variant="ghost" className="h-6 text-[10px]" onClick={onReloadScrapPool}
disabled={scrapPoolLoading} title="풀 새로고침">
<RefreshCw className={cn("h-3 w-3 mr-1", scrapPoolLoading && "animate-spin")} />
</Button>
</div>
{scrapPool.length === 0 ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground"> </div>
) : (
<div className="max-h-[180px] overflow-auto px-3 pb-2">
<table className="w-full text-[11px]">
<thead className="text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1 pr-2 font-normal"></th>
<th className="text-left py-1 pr-2 font-normal"></th>
<th className="text-right py-1 pr-2 font-normal"></th>
<th className="text-left py-1 pr-2 font-normal"></th>
<th className="text-center py-1 font-normal"></th>
</tr>
</thead>
<tbody>
{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 (
<tr key={s.id} className="border-b last:border-b-0 hover:bg-emerald-50/40">
<td className="py-1 pr-2 truncate">{s.mat_name || s.mat_item_id}</td>
<td className="py-1 pr-2 font-medium">{sizeStr}</td>
<td className="py-1 pr-2 text-right">{s.qty}</td>
<td className="py-1 pr-2 text-[10px] text-muted-foreground">
{s.source_plan_id ? `#${s.source_plan_id}` : "-"}
</td>
<td className="py-1 text-center">
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[10px] text-amber-700 border-amber-300 hover:bg-amber-50"
onClick={() => onDiscardScrap(s.id)}
title="풀에서 폐기 처리"
>
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{rows.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<div className="flex h-40 flex-col items-center justify-center text-muted-foreground">
<Package className="h-10 w-10 opacity-30 mb-2" />
<p className="text-xs"> </p>
<p className="text-xs"> </p>
</div>
) : (
<Table>