refactor: 重構 VendorProduct API 與新增進貨單重複檢查前端邏輯
All checks were successful
ERP-Deploy-Production / deploy-production (push) Successful in 1m9s
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m10s

1. 將 VendorProductController 中的 Eloquent 關聯操作改為透過 ProcurementService 使用 DB 操作,解除跨模組 Model 直接依賴。
2. ProcurementService 加入 vendor product 的資料存取方法。
3. 進貨單建立前端 (GoodsReceipt/Create.tsx) 新增重複進貨檢查與警告對話框邏輯。
This commit is contained in:
2026-02-25 11:11:28 +08:00
parent e406ecd63d
commit ad91b08dbc
11 changed files with 689 additions and 23 deletions

View File

@@ -35,6 +35,7 @@ import {
import axios from 'axios';
import { PurchaseOrderStatus } from '@/types/purchase-order';
import { STATUS_CONFIG } from '@/constants/purchase-order';
import { DuplicateWarningDialog } from './components/DuplicateWarningDialog';
@@ -89,6 +90,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
const [productSearch, setProductSearch] = useState('');
const [foundProducts, setFoundProducts] = useState<any[]>([]);
// Duplicate Check States
const [warningOpen, setWarningOpen] = useState(false);
const [warnings, setWarnings] = useState<any[]>([]);
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
const { data, setData, post, processing, errors } = useForm({
type: 'standard', // 'standard', 'miscellaneous', 'other'
warehouse_id: '',
@@ -280,9 +286,30 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
});
}, [nextSequences, JSON.stringify(data.items.map(i => ({ m: i.batchMode, c: i.originCountry, s: i.product_code, p: i.product_id }))), data.received_date]);
const submit = (e: React.FormEvent) => {
e.preventDefault();
post(route('goods-receipts.store'));
const submit = async (e: React.FormEvent, force: boolean = false) => {
if (e) e.preventDefault();
// 如果不是強制提交,先檢查重複
if (!force) {
setIsCheckingDuplicate(true);
try {
const response = await axios.post(route('goods-receipts.check-duplicate'), data);
if (response.data.has_warnings) {
setWarnings(response.data.warnings);
setWarningOpen(true);
return; // 停止並顯示警告
}
} catch (error) {
console.error("Duplicate check failed", error);
// 檢查失敗則繼續,或視為阻擋?這裡選擇繼續
} finally {
setIsCheckingDuplicate(false);
}
}
post(route('goods-receipts.store'), {
onSuccess: () => setWarningOpen(false),
});
};
@@ -738,13 +765,21 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
size="lg"
className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]"
onClick={submit}
disabled={processing || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
disabled={processing || isCheckingDuplicate || (data.type === 'standard' ? !selectedPO : !selectedVendor)}
>
<Save className="mr-2 h-5 w-5" />
{processing ? '處理中...' : '確認進貨'}
{processing || isCheckingDuplicate ? '處理中...' : '確認進貨'}
</Button>
</div>
</div>
<DuplicateWarningDialog
open={warningOpen}
onClose={() => setWarningOpen(false)}
onConfirm={() => submit(null as any, true)}
warnings={warnings}
processing={processing}
/>
</AuthenticatedLayout >
);
}

View File

@@ -0,0 +1,176 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { AlertTriangle, ArrowRight } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/Components/ui/table";
import { StatusBadge } from "@/Components/shared/StatusBadge";
interface DuplicateWarningDialogProps {
open: boolean;
onClose: () => void;
onConfirm: () => void;
warnings: any[];
processing?: boolean;
}
export function DuplicateWarningDialog({ open, onClose, onConfirm, warnings, processing }: DuplicateWarningDialogProps) {
return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2 text-amber-600 mb-2">
<AlertTriangle className="h-6 w-6" />
<DialogTitle className="text-xl font-bold"></DialogTitle>
</div>
<p className="text-gray-500">
</p>
</DialogHeader>
<div className="py-4 space-y-6">
{warnings.map((warning, idx) => (
<div key={idx} className={`p-4 rounded-lg border ${warning.level === 'high' ? 'bg-red-50 border-red-100' : 'bg-amber-50 border-amber-100'}`}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2 h-2 rounded-full ${warning.level === 'high' ? 'bg-red-500' : 'bg-amber-500'}`} />
<h4 className={`font-bold ${warning.level === 'high' ? 'text-red-900' : 'text-amber-900'}`}>
{warning.title}
</h4>
</div>
<p className="text-sm text-gray-700 mb-4">{warning.message}</p>
{/* Same PO Warning Details */}
{warning.type === 'same_po' && warning.related_receipts && (
<div className="bg-white rounded border overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{warning.related_receipts.map((r: any) => (
<TableRow key={r.id}>
<TableCell className="font-medium text-blue-600">{r.code}</TableCell>
<TableCell>{r.received_date}</TableCell>
<TableCell>
<StatusBadge variant="neutral">{r.status}</StatusBadge>
</TableCell>
<TableCell className="text-center">{r.item_count} </TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Recent Products Warning Details */}
{warning.type === 'recent_duplicate_product' && warning.duplicated_items && (
<div className="bg-white rounded border overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs"> / </TableHead>
<TableHead className="text-xs text-right"></TableHead>
<TableHead className="text-xs text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{warning.duplicated_items.map((item: any, i: number) => (
<TableRow key={i}>
<TableCell>
<div>{item.product_name}</div>
<div className="text-[10px] text-gray-400">{item.product_id}</div>
</TableCell>
<TableCell>
<div>{item.last_receipt_date}</div>
<div className="text-[10px] text-gray-400">{item.last_receipt_code}</div>
</TableCell>
<TableCell className="text-right">{item.last_quantity}</TableCell>
<TableCell className="text-right font-bold flex items-center justify-end gap-1">
<ArrowRight className="h-3 w-3 text-gray-300" />
{item.current_quantity}
{item.is_high_risk && <span className="text-[10px] bg-red-100 text-red-600 px-1 rounded"></span>}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* Stale Price Warning Details */}
{warning.type === 'stale_price' && warning.stale_items && (
<div className="bg-white rounded border overflow-hidden">
<Table>
<TableHeader className="bg-gray-50">
<TableRow>
<TableHead className="text-xs"></TableHead>
<TableHead className="text-xs text-right"></TableHead>
<TableHead className="text-xs text-center"></TableHead>
<TableHead className="text-xs"></TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-xs">
{warning.stale_items.map((item: any, i: number) => (
<TableRow key={i}>
<TableCell>
<div>{item.product_name}</div>
<div className="text-[10px] text-gray-400">{item.product_id}</div>
</TableCell>
<TableCell className="text-right font-mono font-bold text-amber-700">
${item.unit_price.toLocaleString()}
</TableCell>
<TableCell className="text-center">
{item.record_count}
</TableCell>
<TableCell>
<div>{item.earliest_date} ~ {item.latest_date}</div>
<div className="text-[10px] text-gray-400">{item.latest_code}</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
))}
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
onClick={onClose}
disabled={processing}
className="button-outlined-primary"
>
</Button>
<Button
onClick={onConfirm}
className="button-filled-primary"
disabled={processing}
>
{processing ? '處理中...' : '確認無重複,繼續建立'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}