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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user