feat: 倉庫業務屬性、庫存成本追蹤與採購單功能更新
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 58s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 倉庫管理:新增業務類型 (Owned/External/Customer) 與車牌資訊與司機欄位。
2. 庫存管理:實作成本追蹤 (unit_cost, total_value),更新列表與撥補單顯示。
3. 採購單:新增採購日期 (order_date),調整欄位名稱與順序。
4. 前端優化:更新相關 TS Type 定義與 UI 顯示。
This commit is contained in:
2026-01-26 17:27:34 +08:00
parent 106de4e945
commit ac6a81b3d2
24 changed files with 429 additions and 130 deletions

View File

@@ -41,11 +41,11 @@ export function PurchaseOrderItemsTable({
<TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[20%] text-left"></TableHead>
<TableHead className="w-[10%] text-left"></TableHead>
<TableHead className="w-[10%] text-left"></TableHead>
<TableHead className="w-[12%] text-left"></TableHead>
<TableHead className="w-[12%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
<TableHead className="w-[15%] text-left"> / </TableHead>
<TableHead className="w-[15%] text-left"></TableHead>
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
</TableRow>
</TableHeader>
@@ -146,7 +146,30 @@ export function PurchaseOrderItemsTable({
</div>
</TableCell>
{/* 總金額 (主要輸入欄位) */}
{/* 換算採購單價 / 基本單位 (顯示換算結果 - SWAPPED HERE) */}
<TableCell className="text-left">
<div className="flex flex-col">
<div className="text-gray-500 font-medium text-sm">
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
</div>
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
<>
{convertedUnitPrice > item.previousPrice && (
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
: {formatCurrency(item.previousPrice)}
</p>
)}
{convertedUnitPrice < item.previousPrice && (
<p className="text-[10px] text-green-600 font-medium">
📉 : {formatCurrency(item.previousPrice)}
</p>
)}
</>
)}
</div>
</TableCell>
{/* 總金額 (主要輸入欄位 - SWAPPED HERE) */}
<TableCell className="text-left">
{isReadOnly ? (
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
@@ -178,29 +201,6 @@ export function PurchaseOrderItemsTable({
)}
</TableCell>
{/* 換算採購單價 / 基本單位 (顯示換算結果) */}
<TableCell className="text-left">
<div className="flex flex-col">
<div className="text-gray-500 font-medium text-sm">
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
</div>
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
<>
{convertedUnitPrice > item.previousPrice && (
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
: {formatCurrency(item.previousPrice)}
</p>
)}
{convertedUnitPrice < item.previousPrice && (
<p className="text-[10px] text-green-600 font-medium">
📉 : {formatCurrency(item.previousPrice)}
</p>
)}
</>
)}
</div>
</TableCell>
{/* 刪除按鈕 */}
{!isReadOnly && onRemoveItem && (
<TableCell className="text-center">

View File

@@ -147,14 +147,21 @@ export default function InventoryTable({
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-gray-600">
<span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} </span>
<span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} {group.baseUnit}</span>
</span>
</div>
<Can permission="inventory.view_cost">
<div className="text-sm">
<span className="text-gray-600">
<span className="font-medium text-gray-900">${group.totalValue?.toLocaleString()}</span>
</span>
</div>
</Can>
{group.safetyStock !== null ? (
<>
<div className="text-sm">
<span className="text-gray-600">
<span className="font-medium text-gray-900">{group.safetyStock} </span>
<span className="font-medium text-gray-900">{group.safetyStock} {group.baseUnit}</span>
</span>
</div>
<div>
@@ -193,11 +200,14 @@ export default function InventoryTable({
<TableRow>
<TableHead className="w-[5%]">#</TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[15%]"></TableHead>
<TableHead className="w-[14%]"></TableHead>
<TableHead className="w-[14%]"></TableHead>
<TableHead className="w-[14%]"></TableHead>
<TableHead className="w-[10%]"></TableHead>
<Can permission="inventory.view_cost">
<TableHead className="w-[10%]"></TableHead>
<TableHead className="w-[10%]"></TableHead>
</Can>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[12%]"></TableHead>
<TableHead className="w-[8%] text-right"></TableHead>
</TableRow>
</TableHeader>
@@ -208,9 +218,12 @@ export default function InventoryTable({
<TableCell className="text-grey-2">{index + 1}</TableCell>
<TableCell>{batch.batchNumber || "-"}</TableCell>
<TableCell>
<span>{batch.quantity}</span>
<span>{batch.quantity} {batch.unit}</span>
</TableCell>
<TableCell>{batch.batchNumber || "-"}</TableCell>
<Can permission="inventory.view_cost">
<TableCell>${batch.unit_cost?.toLocaleString()}</TableCell>
<TableCell>${batch.total_value?.toLocaleString()}</TableCell>
</Can>
<TableCell>
{batch.expiryDate ? formatDate(batch.expiryDate) : "-"}
</TableCell>

View File

@@ -23,6 +23,7 @@ import { Textarea } from "@/Components/ui/textarea";
import { toast } from "sonner";
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation";
import { usePermission } from "@/hooks/usePermission";
export type { TransferOrder };
@@ -42,6 +43,8 @@ interface AvailableProduct {
availableQty: number;
unit: string;
expiryDate: string | null;
unitCost: number; // 新增
totalValue: number; // 新增
}
export default function TransferOrderDialog({
@@ -52,6 +55,9 @@ export default function TransferOrderDialog({
// inventories,
onSave,
}: TransferOrderDialogProps) {
const { can } = usePermission();
const canViewCost = can('inventory.view_cost');
const [formData, setFormData] = useState({
sourceWarehouseId: "",
targetWarehouseId: "",
@@ -106,7 +112,9 @@ export default function TransferOrderDialog({
batchNumber: item.batch_number,
availableQty: item.quantity,
unit: item.unit_name,
expiryDate: item.expiry_date
expiryDate: item.expiry_date,
unitCost: item.unit_cost, // 映射
totalValue: item.total_value, // 映射
}));
setAvailableProducts(mappedData);
})
@@ -249,7 +257,7 @@ export default function TransferOrderDialog({
onValueChange={handleProductChange}
disabled={!formData.sourceWarehouseId || !!order}
options={availableProducts.map((product) => ({
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})`,
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})${canViewCost ? ` | 成本: $${product.unitCost?.toLocaleString()}` : ''}`,
value: `${product.productId}|||${product.batchNumber}`,
}))}
placeholder="選擇商品與批號"

View File

@@ -32,6 +32,15 @@ interface WarehouseCardProps {
onEdit: (warehouse: Warehouse) => void;
}
const WAREHOUSE_TYPE_LABELS: Record<string, string> = {
standard: "標準倉",
production: "生產倉",
retail: "門市倉",
vending: "販賣機",
transit: "在途倉",
quarantine: "瑕疵倉",
};
export default function WarehouseCard({
warehouse,
stats,
@@ -71,6 +80,16 @@ export default function WarehouseCard({
<Info className="h-5 w-5" />
</button>
</div>
<div className="flex gap-2 mt-1">
<Badge variant="outline" className="text-xs font-normal">
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
</Badge>
{warehouse.type === 'transit' && warehouse.license_plate && (
<Badge variant="secondary" className="text-xs font-normal bg-yellow-100 text-yellow-800 border-yellow-200">
{warehouse.license_plate}
</Badge>
)}
</div>
</div>
</div>
@@ -107,6 +126,14 @@ export default function WarehouseCard({
)}
</div>
</div>
{/* 移動倉司機資訊 */}
{warehouse.type === 'transit' && warehouse.driver_name && (
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium text-gray-900">{warehouse.driver_name}</span>
</div>
)}
</div>
</div>

View File

@@ -1,6 +1,6 @@
/**
* 倉庫對話框元件
* 重構後:加入驗證邏輯
* 重構後:加入驗證邏輯與業務類型支援
*/
import { useEffect, useState } from "react";
@@ -27,9 +27,10 @@ import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea";
import { Button } from "@/Components/ui/button";
import { Trash2 } from "lucide-react";
import { Warehouse } from "@/types/warehouse";
import { Warehouse, WarehouseType } from "@/types/warehouse";
import { validateWarehouse } from "@/utils/validation";
import { toast } from "sonner";
import { SearchableSelect } from "@/Components/ui/searchable-select";
interface WarehouseDialogProps {
open: boolean;
@@ -39,6 +40,15 @@ interface WarehouseDialogProps {
onDelete?: (warehouseId: string) => void;
}
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
{ label: "標準倉 (總倉)", value: "standard" },
{ label: "生產倉 (廚房/加工)", value: "production" },
{ label: "門市倉 (前台销售)", value: "retail" },
{ label: "販賣機 (IoT設備)", value: "vending" },
{ label: "在途倉 (物流車)", value: "transit" },
{ label: "瑕疵倉 (報廢/檢驗)", value: "quarantine" },
];
export default function WarehouseDialog({
open,
onOpenChange,
@@ -51,13 +61,19 @@ export default function WarehouseDialog({
name: string;
address: string;
description: string;
type: WarehouseType;
is_sellable: boolean;
license_plate: string;
driver_name: string;
}>({
code: "",
name: "",
address: "",
description: "",
type: "standard",
is_sellable: true,
license_plate: "",
driver_name: "",
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -69,7 +85,10 @@ export default function WarehouseDialog({
name: warehouse.name,
address: warehouse.address || "",
description: warehouse.description || "",
type: warehouse.type || "standard",
is_sellable: warehouse.is_sellable ?? true,
license_plate: warehouse.license_plate || "",
driver_name: warehouse.driver_name || "",
});
} else {
setFormData({
@@ -77,7 +96,10 @@ export default function WarehouseDialog({
name: "",
address: "",
description: "",
type: "standard",
is_sellable: true,
license_plate: "",
driver_name: "",
});
}
}, [warehouse, open]);
@@ -136,8 +158,21 @@ export default function WarehouseDialog({
/>
</div>
{/* 倉庫名稱 */}
{/* 倉庫類型 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<SearchableSelect
value={formData.type}
onValueChange={(val) => setFormData({ ...formData, type: val as WarehouseType })}
options={WAREHOUSE_TYPE_OPTIONS}
placeholder="選擇倉庫類型"
className="h-9"
showSearch={false}
/>
</div>
{/* 倉庫名稱 */}
<div className="space-y-2 col-span-2">
<Label htmlFor="name">
<span className="text-red-500">*</span>
</Label>
@@ -147,11 +182,43 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例:中央倉庫"
required
className="h-9"
/>
</div>
</div>
</div>
{/* 移動倉專屬資訊 */}
{formData.type === 'transit' && (
<div className="space-y-4 bg-yellow-50 p-4 rounded-lg border border-yellow-100">
<div className="border-b border-yellow-200 pb-2">
<h4 className="text-sm text-yellow-800 font-medium"> ()</h4>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="license_plate"></Label>
<Input
id="license_plate"
value={formData.license_plate}
onChange={(e) => setFormData({ ...formData, license_plate: e.target.value })}
placeholder="例ABC-1234"
className="h-9 bg-white"
/>
</div>
<div className="space-y-2">
<Label htmlFor="driver_name"></Label>
<Input
id="driver_name"
value={formData.driver_name}
onChange={(e) => setFormData({ ...formData, driver_name: e.target.value })}
placeholder="例:王小明"
className="h-9 bg-white"
/>
</div>
</div>
</div>
)}
{/* 銷售設定 */}
<div className="space-y-4">
<div className="border-b pb-2">
@@ -167,6 +234,9 @@ export default function WarehouseDialog({
/>
<Label htmlFor="is_sellable"></Label>
</div>
<p className="text-xs text-gray-500 ml-6">
POS
</p>
</div>
{/* 區塊 B位置 */}
@@ -186,6 +256,7 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="例台北市信義區信義路五段7號"
required
className="h-9"
/>
</div>

View File

@@ -39,6 +39,7 @@ export default function CreatePurchaseOrder({
const {
supplierId,
expectedDate,
orderDate,
items,
notes,
selectedSupplier,
@@ -46,6 +47,7 @@ export default function CreatePurchaseOrder({
warehouseId,
setSupplierId,
setExpectedDate,
setOrderDate,
setNotes,
setWarehouseId,
addItem,
@@ -87,6 +89,11 @@ export default function CreatePurchaseOrder({
return;
}
if (!orderDate) {
toast.error("請選擇採購日期");
return;
}
if (!expectedDate) {
toast.error("請選擇預計到貨日期");
return;
@@ -120,6 +127,7 @@ export default function CreatePurchaseOrder({
const data = {
vendor_id: supplierId,
warehouse_id: warehouseId,
order_date: orderDate,
expected_delivery_date: expectedDate,
remark: notes,
status: status,
@@ -235,6 +243,18 @@ export default function CreatePurchaseOrder({
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">
<span className="text-red-500">*</span>
</label>
<Input
type="date"
value={orderDate || ""}
onChange={(e) => setOrderDate(e.target.value)}
className="block w-full"
/>
</div>
<div className="space-y-3">
<label className="text-sm font-bold text-gray-700">

View File

@@ -88,6 +88,10 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{order.orderDate || "-"}</span>
</div>
<div>
<span className="text-sm text-gray-500 block mb-1"></span>
<span className="font-medium text-gray-900">{order.expectedDate || "-"}</span>

View File

@@ -4,7 +4,7 @@
import { useState, useEffect } from "react";
import type { PurchaseOrder, PurchaseOrderItem, Supplier, PurchaseOrderStatus } from "@/types/purchase-order";
import { calculateSubtotal } from "@/utils/purchase-order";
import { calculateSubtotal, getTodayDate } from "@/utils/purchase-order";
interface UsePurchaseOrderFormProps {
order?: PurchaseOrder;
@@ -14,6 +14,7 @@ interface UsePurchaseOrderFormProps {
export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormProps) {
const [supplierId, setSupplierId] = useState(order?.supplierId || "");
const [expectedDate, setExpectedDate] = useState(order?.expectedDate || "");
const [orderDate, setOrderDate] = useState(order?.orderDate || getTodayDate());
const [items, setItems] = useState<PurchaseOrderItem[]>(order?.items || []);
const [notes, setNotes] = useState(order?.remark || "");
const [status, setStatus] = useState<PurchaseOrderStatus>(order?.status || "draft");
@@ -32,6 +33,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
if (order) {
setSupplierId(order.supplierId);
setExpectedDate(order.expectedDate);
setOrderDate(order.orderDate || getTodayDate());
setItems(order.items || []);
setNotes(order.remark || "");
setStatus(order.status);
@@ -52,6 +54,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
const resetForm = () => {
setSupplierId("");
setExpectedDate("");
setOrderDate(getTodayDate());
setItems([]);
setNotes("");
setStatus("draft");
@@ -159,6 +162,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
// State
supplierId,
expectedDate,
orderDate,
items,
notes,
status,
@@ -174,6 +178,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
// Setters
setSupplierId,
setExpectedDate,
setOrderDate,
setNotes,
setStatus,
setWarehouseId,

View File

@@ -67,6 +67,7 @@ export interface PurchaseOrder {
poNumber: string;
supplierId: string;
supplierName: string;
orderDate?: string; // 採購日期
expectedDate: string;
status: PurchaseOrderStatus;
items: PurchaseOrderItem[];

View File

@@ -2,7 +2,7 @@
* 倉庫相關型別定義
*/
export type WarehouseType = "中央倉庫" | "門市";
export type WarehouseType = "standard" | "production" | "retail" | "vending" | "transit" | "quarantine";
/**
* 門市資訊
@@ -19,17 +19,17 @@ export interface Warehouse {
name: string;
address?: string;
description?: string;
createdAt?: string; // 對應 created_at 但前端可能習慣 camelCase後端傳回 snake_caseInertia 會保持原樣。
// 若後端 Resource 沒轉 camelCase這裡應該用 snake_case 或在前端轉
// 為求簡單,我修改 interface 為 snake_case 以匹配 Laravel 預設 Response
createdAt?: string;
created_at?: string;
updated_at?: string;
total_quantity?: number;
low_stock_count?: number;
type?: WarehouseType;
is_sellable?: boolean; // 新增欄位
book_stock?: number; // 帳面庫存
available_stock?: number; // 可用庫存
is_sellable?: boolean;
license_plate?: string; // 車牌號碼 (移動倉)
driver_name?: string; // 司機姓名 (移動倉)
book_stock?: number;
available_stock?: number;
}
// 倉庫中的庫存項目
export interface WarehouseInventory {
@@ -41,6 +41,8 @@ export interface WarehouseInventory {
productCode: string;
unit: string;
quantity: number;
unit_cost?: number; // 單位成本
total_value?: number; // 總價值
safetyStock: number | null;
status?: '正常' | '低於'; // 後端可能回傳的狀態
batchNumber: string; // 批號 (Mock for now)
@@ -56,6 +58,7 @@ export interface GroupedInventory {
productCode: string;
baseUnit: string;
totalQuantity: number;
totalValue?: number; // 總價值總計
safetyStock: number | null; // 以商品層級顯示的安全庫存
status: '正常' | '低於';
batches: WarehouseInventory[]; // 該商品下的所有批號庫存
@@ -89,6 +92,7 @@ export interface Product {
export interface WarehouseStats {
totalQuantity: number;
totalValue?: number; // 倉庫總值
lowStockCount: number;
replenishmentNeeded: number;
}
@@ -145,6 +149,7 @@ export interface InventoryTransaction {
productName: string;
batchNumber: string;
quantity: number; // 正數為入庫,負數為出庫
unit_cost?: number; // 異動時的成本
transactionType: TransactionType;
reason?: string;
notes?: string;
@@ -161,6 +166,7 @@ export interface InboundItem {
productId: string;
productName: string;
quantity: number;
unit_cost?: number; // 入庫單價
unit: string;
baseUnit?: string;
largeUnit?: string;