feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
2026-01-26 14:59:24 +08:00
parent b0848a6bb8
commit 106de4e945
81 changed files with 4118 additions and 1023 deletions

View File

@@ -41,7 +41,7 @@ interface Props {
activity: Activity | null;
}
// Field translation map
// 欄位翻譯對照表
const fieldLabels: Record<string, string> = {
name: '名稱',
code: '商品代號',
@@ -66,19 +66,19 @@ const fieldLabels: Record<string, string> = {
role_id: '角色',
email_verified_at: '電子郵件驗證時間',
remember_token: '登入權杖',
// Snapshot fields
// 快照欄位
category_name: '分類名稱',
base_unit_name: '基本單位名稱',
large_unit_name: '大單位名稱',
purchase_unit_name: '採購單位名稱',
// Vendor fields
// 廠商欄位
short_name: '簡稱',
tax_id: '統編',
owner: '負責人',
contact_name: '聯絡人',
tel: '電話',
remark: '備註',
// Warehouse & Inventory fields
// 倉庫與庫存欄位
warehouse_name: '倉庫名稱',
product_name: '商品名稱',
warehouse_id: '倉庫',
@@ -86,7 +86,7 @@ const fieldLabels: Record<string, string> = {
quantity: '數量',
safety_stock: '安全庫存',
location: '儲位',
// Inventory fields
// 庫存欄位
batch_number: '批號',
box_number: '箱號',
origin_country: '來源國家',
@@ -95,7 +95,7 @@ const fieldLabels: Record<string, string> = {
source_purchase_order_id: '來源採購單',
quality_status: '品質狀態',
quality_remark: '品質備註',
// Purchase Order fields
// 採購單欄位
po_number: '採購單號',
vendor_id: '廠商',
vendor_name: '廠商名稱',
@@ -110,13 +110,13 @@ const fieldLabels: Record<string, string> = {
invoice_date: '發票日期',
invoice_amount: '發票金額',
last_price: '供貨價格',
// Utility Fee fields
// 公共事業費欄位
transaction_date: '費用日期',
category: '費用類別',
amount: '金額',
};
// Purchase Order Status Map
// 採購單狀態對照表
const statusMap: Record<string, string> = {
draft: '草稿',
pending: '待審核',
@@ -127,7 +127,7 @@ const statusMap: Record<string, string> = {
completed: '已完成',
};
// Inventory Quality Status Map
// 庫存品質狀態對照表
const qualityStatusMap: Record<string, string> = {
normal: '正常',
frozen: '凍結',
@@ -141,17 +141,17 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
const old = activity.properties?.old || {};
const snapshot = activity.properties?.snapshot || {};
// Get all keys from both attributes and old to ensure we show all changes
// 取得屬性和舊值的所有鍵,以確保顯示所有變更
const allKeys = Array.from(new Set([...Object.keys(attributes), ...Object.keys(old)]));
// Custom sort order for fields
// 自訂欄位排序順序
const sortOrder = [
'po_number', 'vendor_name', 'warehouse_name', 'expected_delivery_date', 'status', 'remark',
'invoice_number', 'invoice_date', 'invoice_amount',
'total_amount', 'tax_amount', 'grand_total' // Ensure specific order for amounts
'total_amount', 'tax_amount', 'grand_total' // 確保金額的特定順序
];
// Filter out internal keys often logged but not useful for users
// 過濾掉通常會記錄但對使用者無用的內部鍵
const filteredKeys = allKeys
.filter(key =>
!['created_at', 'updated_at', 'deleted_at', 'id', 'remember_token'].includes(key)
@@ -160,16 +160,16 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
const indexA = sortOrder.indexOf(a);
const indexB = sortOrder.indexOf(b);
// If both are in sortOrder, compare indices
// 如果兩者都在排序順序中,比較索引
if (indexA !== -1 && indexB !== -1) return indexA - indexB;
// If only A is in sortOrder, it comes first (or wherever logic dictates, usually put known fields first)
// 如果只有 A 在排序順序中,它排在前面(或根據邏輯,通常將已知欄位排在前面)
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// Otherwise alphabetical or default
// 否則按字母順序或預設
return a.localeCompare(b);
});
// Helper to check if a key is a snapshot name field
// 檢查鍵是否為快照名稱欄位的輔助函式
const isSnapshotField = (key: string) => {
return [
'category_name', 'base_unit_name', 'large_unit_name', 'purchase_unit_name',
@@ -197,26 +197,26 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
};
const formatValue = (key: string, value: any) => {
// Mask password
// 遮蔽密碼
if (key === 'password') return '******';
if (value === null || value === undefined) return '-';
if (typeof value === 'boolean') return value ? '是' : '否';
if (key === 'is_active') return value ? '啟用' : '停用';
// Handle Purchase Order Status
// 處理採購單狀態
if (key === 'status' && typeof value === 'string' && statusMap[value]) {
return statusMap[value];
}
// Handle Inventory Quality Status
// 處理庫存品質狀態
if (key === 'quality_status' && typeof value === 'string' && qualityStatusMap[value]) {
return qualityStatusMap[value];
}
// Handle Date Fields (YYYY-MM-DD)
// 處理日期欄位 (YYYY-MM-DD)
if ((key === 'expected_delivery_date' || key === 'invoice_date' || key === 'arrival_date' || key === 'expiry_date') && typeof value === 'string') {
// Take only the date part (YYYY-MM-DD)
// 僅取日期部分 (YYYY-MM-DD)
return value.split('T')[0].split(' ')[0];
}
@@ -224,10 +224,10 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
};
const getFormattedValue = (key: string, value: any) => {
// If it's an ID field, try to find a corresponding name in snapshot or attributes
// 如果是 ID 欄位,嘗試在快照或屬性中尋找對應名稱
if (key.endsWith('_id')) {
const nameKey = key.replace('_id', '_name');
// Check snapshot first, then attributes
// 先檢查快照,然後檢查屬性
const nameValue = snapshot[nameKey] || attributes[nameKey];
if (nameValue) {
return `${nameValue}`;
@@ -236,14 +236,14 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
return formatValue(key, value);
};
// Helper to get translated field label
// 取得翻譯欄位標籤的輔助函式
const getFieldLabel = (key: string) => {
return fieldLabels[key] || key;
};
// Get subject name for header
// 取得標題的主題名稱
const getSubjectName = () => {
// Special handling for Inventory: show "Warehouse - Product"
// 庫存的特殊處理:顯示 "倉庫 - 商品"
if ((snapshot.warehouse_name || attributes.warehouse_name) && (snapshot.product_name || attributes.product_name)) {
const wName = snapshot.warehouse_name || attributes.warehouse_name;
const pName = snapshot.product_name || attributes.product_name;
@@ -276,7 +276,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</Badge>
</div>
{/* Modern Metadata Strip */}
{/* 現代化元數據條 */}
<div className="flex flex-wrap items-center gap-6 pt-2 text-sm text-gray-500">
<div className="flex items-center gap-2">
<User className="w-4 h-4 text-gray-400" />
@@ -293,7 +293,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
{activity.properties?.sub_subject || activity.subject_type}
</span>
</div>
{/* Only show 'description' if it differs from event name (unlikely but safe) */}
{/* 僅在描述與事件名稱不同時顯示(不太可能發生但為了安全起見) */}
{activity.description !== getEventLabel(activity.event) &&
activity.description !== 'created' && activity.description !== 'updated' && (
<div className="flex items-center gap-2">
@@ -367,7 +367,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
const newValue = attributes[key];
const isChanged = JSON.stringify(oldValue) !== JSON.stringify(newValue);
// For deleted events, we want to show the current attributes in the "Before" column
// 對於刪除事件,我們希望在 "變更前" 欄位顯示當前屬性
const displayBefore = activity.event === 'deleted'
? getFormattedValue(key, newValue || oldValue)
: getFormattedValue(key, oldValue);
@@ -399,7 +399,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</Table>
</div>
)}
{/* Items Diff Section (Special for Purchase Orders) */}
{/* 項目差異區塊(採購單專用) */}
{activity.properties?.items_diff && (
<div className="mt-6 space-y-4">
<h3 className="text-sm font-bold text-gray-900 flex items-center gap-2 px-1">
@@ -417,7 +417,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
</TableHeader>
<TableBody>
{/* Updated Items */}
{/* 更新項目 */}
{activity.properties.items_diff.updated.map((item: any, idx: number) => (
<TableRow key={`upd-${idx}`} className="bg-blue-50/10 hover:bg-blue-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
@@ -440,7 +440,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
))}
{/* Added Items */}
{/* 新增項目 */}
{activity.properties.items_diff.added.map((item: any, idx: number) => (
<TableRow key={`add-${idx}`} className="bg-green-50/10 hover:bg-green-50/20">
<TableCell className="font-medium">{item.product_name}</TableCell>
@@ -453,7 +453,7 @@ export default function ActivityDetailDialog({ open, onOpenChange, activity }: P
</TableRow>
))}
{/* Removed Items */}
{/* 移除項目 */}
{activity.properties.items_diff.removed.map((item: any, idx: number) => (
<TableRow key={`rem-${idx}`} className="bg-red-50/10 hover:bg-red-50/20">
<TableCell className="font-medium text-gray-400 line-through">{item.product_name}</TableCell>

View File

@@ -26,7 +26,7 @@ interface LogTableProps {
sortOrder?: 'asc' | 'desc';
onSort?: (field: string) => void;
onViewDetail: (activity: Activity) => void;
from?: number; // Starting index number (paginator.from)
from?: number; // 起始索引編號 (paginator.from)
}
export default function LogTable({
@@ -61,12 +61,12 @@ export default function LogTable({
const old = props.old || {};
const snapshot = props.snapshot || {};
// Try to find a name in snapshot, attributes or old values
// Priority: snapshot > specific name fields > generic name > code > ID
// 嘗試在快照、屬性或舊值中尋找名稱
// 優先順序:快照 > 特定名稱欄位 > 通用名稱 > 代碼 > ID
const nameParams = ['po_number', 'name', 'code', 'product_name', 'warehouse_name', 'category_name', 'base_unit_name', 'title', 'category'];
let subjectName = '';
// Special handling for Inventory: show "Warehouse - Product"
// 庫存的特殊處理:顯示 "倉庫 - 商品"
if ((snapshot.warehouse_name || attrs.warehouse_name) && (snapshot.product_name || attrs.product_name)) {
const wName = snapshot.warehouse_name || attrs.warehouse_name;
const pName = snapshot.product_name || attrs.product_name;
@@ -74,7 +74,7 @@ export default function LogTable({
} else if (old.warehouse_name && old.product_name) {
subjectName = `${old.warehouse_name} - ${old.product_name}`;
} else {
// Default fallback
// 預設備案
for (const param of nameParams) {
if (snapshot[param]) {
subjectName = snapshot[param];
@@ -91,12 +91,12 @@ export default function LogTable({
}
}
// If no name found, try ID but format it nicely if possible, or just don't show it if it's redundant with subject_type
// 如果找不到名稱,嘗試使用 ID如果可能則格式化顯示或者如果與主題類型重複則不顯示
if (!subjectName && (attrs.id || old.id)) {
subjectName = `#${attrs.id || old.id}`;
}
// Combine parts: [Causer] [Action] [Name] [Subject]
// 組合部分:[操作者] [動作] [名稱] [主題]
// Example: Admin 新增 可樂 商品
// Example: Admin 更新 台北倉 - 可樂 庫存
return (
@@ -114,7 +114,7 @@ export default function LogTable({
<span className="text-gray-700">{activity.subject_type}</span>
)}
{/* Display reason/source if available (e.g., from Replenishment) */}
{/* 如果有原因/來源則顯示(例如:來自補貨) */}
{(attrs._reason || old._reason) && (
<span className="text-gray-500 text-xs">
( {attrs._reason || old._reason})

View File

@@ -53,13 +53,13 @@ export default function ProductDialog({
setData({
code: product.code,
name: product.name,
category_id: product.category_id.toString(),
category_id: product.categoryId.toString(),
brand: product.brand || "",
specification: product.specification || "",
base_unit_id: product.base_unit_id?.toString() || "",
large_unit_id: product.large_unit_id?.toString() || "",
conversion_rate: product.conversion_rate ? product.conversion_rate.toString() : "",
purchase_unit_id: product.purchase_unit_id?.toString() || "",
base_unit_id: product.baseUnitId?.toString() || "",
large_unit_id: product.largeUnitId?.toString() || "",
conversion_rate: product.conversionRate ? product.conversionRate.toString() : "",
purchase_unit_id: product.purchaseUnitId?.toString() || "",
});
} else {
reset();

View File

@@ -26,7 +26,7 @@ import type { Product } from "@/Pages/Product/Index";
interface ProductTableProps {
products: Product[];
onEdit: (product: Product) => void;
onDelete: (id: number) => void;
onDelete: (id: string) => void;
startIndex: number;
sortField: string | null;
@@ -125,11 +125,11 @@ export default function ProductTable({
{product.category?.name || '-'}
</Badge>
</TableCell>
<TableCell>{product.base_unit?.name || '-'}</TableCell>
<TableCell>{product.baseUnit?.name || '-'}</TableCell>
<TableCell>
{product.large_unit ? (
{product.largeUnit ? (
<span className="text-sm text-gray-500">
1 {product.large_unit?.name} = {Number(product.conversion_rate)} {product.base_unit?.name}
1 {product.largeUnit?.name} = {Number(product.conversionRate)} {product.baseUnit?.name}
</span>
) : (
'-'

View File

@@ -62,7 +62,7 @@ export function PurchaseOrderItemsTable({
) : (
items.map((item, index) => {
// 計算換算後的單價 (基本單位單價)
// unitPrice is derived from subtotal / quantity
// 單價由 小計 / 數量 推導得出
const currentUnitPrice = item.unitPrice;
const convertedUnitPrice = item.selectedUnit === 'large' && item.conversion_rate

View File

@@ -26,7 +26,7 @@ import { toast } from "sonner";
import { Trash2, Edit2, Check, X, Plus, Loader2 } from "lucide-react";
export interface Unit {
id: number;
id: string;
name: string;
code: string | null;
}
@@ -42,7 +42,7 @@ export default function UnitManagerDialog({
onOpenChange,
units,
}: UnitManagerDialogProps) {
const [editingId, setEditingId] = useState<number | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editCode, setEditCode] = useState("");
@@ -85,7 +85,7 @@ export default function UnitManagerDialog({
setEditCode("");
};
const saveEdit = (id: number) => {
const saveEdit = (id: string) => {
if (!editName.trim()) return;
router.put(route("units.update", id), { name: editName, code: editCode }, {
@@ -98,7 +98,7 @@ export default function UnitManagerDialog({
});
};
const handleDelete = (id: number) => {
const handleDelete = (id: string) => {
router.delete(route("units.destroy", id), {
onSuccess: () => {
// 由全域 flash 處理

View File

@@ -45,10 +45,10 @@ export default function VendorDialog({
if (vendor) {
setData({
name: vendor.name,
short_name: vendor.short_name || "",
tax_id: vendor.tax_id || "",
short_name: vendor.shortName || "",
tax_id: vendor.taxId || "",
owner: vendor.owner || "",
contact_name: vendor.contact_name || "",
contact_name: vendor.contactName || "",
tel: vendor.tel || "",
phone: vendor.phone || "",
email: vendor.email || "",

View File

@@ -26,7 +26,7 @@ interface VendorTableProps {
vendors: Vendor[];
onView: (vendor: Vendor) => void;
onEdit: (vendor: Vendor) => void;
onDelete: (id: number) => void;
onDelete: (id: string) => void;
sortField: string | null;
sortDirection: "asc" | "desc" | null;
onSort: (field: string) => void;
@@ -107,11 +107,11 @@ export default function VendorTable({
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{vendor.name}</span>
{vendor.short_name && <span className="text-xs text-gray-400">{vendor.short_name}</span>}
{vendor.shortName && <span className="text-xs text-gray-400">{vendor.shortName}</span>}
</div>
</TableCell>
<TableCell>{vendor.owner || '-'}</TableCell>
<TableCell>{vendor.contact_name || '-'}</TableCell>
<TableCell>{vendor.contactName || '-'}</TableCell>
<TableCell>{vendor.phone || vendor.tel || '-'}</TableCell>
<TableCell className="text-center">
<div className="flex justify-center gap-2">

View File

@@ -95,7 +95,7 @@ export default function AddSafetyStockDialog({
// 更新商品安全庫存量
const updateQuantity = (productId: string, value: number) => {
const newQuantities = new Map(productQuantities);
newQuantities.set(productId, value); // Allow 0
newQuantities.set(productId, value); // 允許為 0
setProductQuantities(newQuantities);
};

View File

@@ -31,7 +31,7 @@ interface TransferOrderDialogProps {
onOpenChange: (open: boolean) => void;
order: TransferOrder | null;
warehouses: Warehouse[];
// inventories: WarehouseInventory[]; // Removed as we fetch from API
// inventories: WarehouseInventory[]; // 因從 API 獲取而移除
onSave: (order: Omit<TransferOrder, "id" | "createdAt" | "orderNumber">) => void;
}
@@ -41,6 +41,7 @@ interface AvailableProduct {
batchNumber: string;
availableQty: number;
unit: string;
expiryDate: string | null;
}
export default function TransferOrderDialog({
@@ -99,7 +100,15 @@ export default function TransferOrderDialog({
if (formData.sourceWarehouseId) {
axios.get(route('api.warehouses.inventories', formData.sourceWarehouseId))
.then(response => {
setAvailableProducts(response.data);
const mappedData = response.data.map((item: any) => ({
productId: item.product_id,
productName: item.product_name,
batchNumber: item.batch_number,
availableQty: item.quantity,
unit: item.unit_name,
expiryDate: item.expiry_date
}));
setAvailableProducts(mappedData);
})
.catch(error => {
console.error("Failed to fetch inventories:", error);
@@ -240,7 +249,7 @@ export default function TransferOrderDialog({
onValueChange={handleProductChange}
disabled={!formData.sourceWarehouseId || !!order}
options={availableProducts.map((product) => ({
label: `${product.productName} (庫存: ${product.availableQty} ${product.unit})`,
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})`,
value: `${product.productId}|||${product.batchNumber}`,
}))}
placeholder="選擇商品與批號"

View File

@@ -78,8 +78,17 @@ export default function WarehouseCard({
{warehouse.description || "無描述"}
</div>
{/* 統計區塊 - 庫存警告 */}
{/* 統計區塊 - 狀態標籤 */}
<div className="space-y-3">
{/* 銷售狀態 */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500"></span>
<Badge variant={warehouse.is_sellable ? "default" : "secondary"} className={warehouse.is_sellable ? "bg-green-600" : "bg-gray-400"}>
{warehouse.is_sellable ? "可銷售" : "暫停銷售"}
</Badge>
</div>
{/* 低庫存警告狀態 */}
<div className="flex items-center justify-between p-3 rounded-lg bg-gray-50">
<div className="flex items-center gap-2 text-gray-600">

View File

@@ -51,11 +51,13 @@ export default function WarehouseDialog({
name: string;
address: string;
description: string;
is_sellable: boolean;
}>({
code: "",
name: "",
address: "",
description: "",
is_sellable: true,
});
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -67,6 +69,7 @@ export default function WarehouseDialog({
name: warehouse.name,
address: warehouse.address || "",
description: warehouse.description || "",
is_sellable: warehouse.is_sellable ?? true,
});
} else {
setFormData({
@@ -74,6 +77,7 @@ export default function WarehouseDialog({
name: "",
address: "",
description: "",
is_sellable: true,
});
}
}, [warehouse, open]);
@@ -148,6 +152,23 @@ export default function WarehouseDialog({
</div>
</div>
{/* 銷售設定 */}
<div className="space-y-4">
<div className="border-b pb-2">
<h4 className="text-sm text-gray-700"></h4>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="is_sellable"
className="h-4 w-4 rounded border-gray-300 text-primary-main focus:ring-primary-main"
checked={formData.is_sellable}
onChange={(e) => setFormData({ ...formData, is_sellable: e.target.checked })}
/>
<Label htmlFor="is_sellable"></Label>
</div>
</div>
{/* 區塊 B位置 */}
<div className="space-y-4">
<div className="border-b pb-2">
@@ -210,10 +231,10 @@ export default function WarehouseDialog({
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</Dialog >
{/* 刪除確認對話框 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
< AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog} >
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
@@ -231,7 +252,7 @@ export default function WarehouseDialog({
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AlertDialog >
</>
);
}