Compare commits

3 Commits

Author SHA1 Message Date
9b0e3b4f6f refactor(modular): 完成第三與第四階段深層掃描與 Model 清理
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m5s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 09:09:55 +08:00
0e51992cb4 refactor(modular): 完成第二階段儀表板解耦與模型清理
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m1s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
2026-01-27 08:59:45 +08:00
ac6a81b3d2 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 顯示。
2026-01-26 17:27:34 +08:00
36 changed files with 603 additions and 250 deletions

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Enums;
enum WarehouseType: string
{
case STANDARD = 'standard'; // 標準倉/總倉
case PRODUCTION = 'production'; // 生產倉/廚房
case RETAIL = 'retail'; // 門市倉/前台
case VENDING = 'vending'; // 販賣機倉/IoT
case TRANSIT = 'transit'; // 在途倉/移動倉
case QUARANTINE = 'quarantine'; // 瑕疵倉/報廢倉
public function label(): string
{
return match($this) {
self::STANDARD => '標準倉 (總倉)',
self::PRODUCTION => '生產倉 (廚房/加工)',
self::RETAIL => '門市倉 (前台销售)',
self::VENDING => '販賣機 (IoT設備)',
self::TRANSIT => '在途倉 (物流車)',
self::QUARANTINE => '瑕疵倉 (報廢/檢驗)',
};
}
}

View File

@@ -28,4 +28,11 @@ interface CoreServiceInterface
* @return Collection * @return Collection
*/ */
public function getAllUsers(): Collection; public function getAllUsers(): Collection;
/**
* Get the system user or create one if not exists.
*
* @return object
*/
public function ensureSystemUserExists();
} }

View File

@@ -4,18 +4,24 @@ namespace App\Modules\Core\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Product; use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Procurement\Models\Vendor; use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use Inertia\Inertia; use Inertia\Inertia;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller class DashboardController extends Controller
{ {
protected $inventoryService;
protected $procurementService;
public function __construct(
InventoryServiceInterface $inventoryService,
ProcurementServiceInterface $procurementService
) {
$this->inventoryService = $inventoryService;
$this->procurementService = $procurementService;
}
public function index() public function index()
{ {
$centralDomains = config('tenancy.central_domains', []); $centralDomains = config('tenancy.central_domains', []);
@@ -25,25 +31,17 @@ class DashboardController extends Controller
return redirect()->route('landlord.dashboard'); return redirect()->route('landlord.dashboard');
} }
// 計算低庫存數量:各商品在各倉庫的總量 < 安全庫存 $invStats = $this->inventoryService->getDashboardStats();
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss') $procStats = $this->procurementService->getDashboardStats();
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
function ($join) {
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
->on('ss.product_id', '=', 'inv.product_id');
})
->whereRaw('inv.total_qty <= ss.safety_stock')
->count();
$stats = [ $stats = [
'productsCount' => Product::count(), 'productsCount' => $invStats['productsCount'],
'vendorsCount' => Vendor::count(), 'vendorsCount' => $procStats['vendorsCount'],
'purchaseOrdersCount' => PurchaseOrder::count(), 'purchaseOrdersCount' => $procStats['purchaseOrdersCount'],
'warehousesCount' => Warehouse::count(), 'warehousesCount' => $invStats['warehousesCount'],
'totalInventoryValue' => Inventory::join('products', 'inventories.product_id', '=', 'products.id') 'totalInventoryValue' => $invStats['totalInventoryQuantity'], // 原本前端命名是 totalInventoryValue 但實作是 Quantity暫且保留欄位名以不破壞前端
->sum('inventories.quantity'), 'pendingOrdersCount' => $procStats['pendingOrdersCount'],
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(), 'lowStockCount' => $invStats['lowStockCount'],
'lowStockCount' => $lowStockCount,
]; ];
return Inertia::render('Dashboard', [ return Inertia::render('Dashboard', [

View File

@@ -39,4 +39,17 @@ class CoreService implements CoreServiceInterface
{ {
return User::all(); return User::all();
} }
public function ensureSystemUserExists()
{
$user = User::first();
if (!$user) {
$user = User::create([
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
]);
}
return $user;
}
} }

View File

@@ -40,6 +40,14 @@ interface InventoryServiceInterface
*/ */
public function getProductsByIds(array $ids); public function getProductsByIds(array $ids);
/**
* Search products by name.
*
* @param string $name
* @return \Illuminate\Support\Collection
*/
public function getProductsByName(string $name);
/** /**
* Get a specific product by ID. * Get a specific product by ID.
* *
@@ -97,4 +105,11 @@ interface InventoryServiceInterface
* @return void * @return void
*/ */
public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null); public function decreaseInventoryQuantity(int $inventoryId, float $quantity, ?string $reason = null, ?string $referenceType = null, $referenceId = null);
/**
* Get statistics for the dashboard.
*
* @return array
*/
public function getDashboardStats(): array;
} }

View File

@@ -12,10 +12,20 @@ use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory; use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\WarehouseProductSafetyStock; use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
use App\Modules\Core\Contracts\CoreServiceInterface;
class InventoryController extends Controller class InventoryController extends Controller
{ {
protected $coreService;
public function __construct(CoreServiceInterface $coreService)
{
$this->coreService = $coreService;
}
public function index(Request $request, Warehouse $warehouse) public function index(Request $request, Warehouse $warehouse)
{ {
// ... (existing code for index) ...
$warehouse->load([ $warehouse->load([
'inventories.product.category', 'inventories.product.category',
'inventories.product.baseUnit', 'inventories.product.baseUnit',
@@ -47,6 +57,8 @@ class InventoryController extends Controller
$firstItem = $batchItems->first(); $firstItem = $batchItems->first();
$product = $firstItem->product; $product = $firstItem->product;
$totalQuantity = $batchItems->sum('quantity'); $totalQuantity = $batchItems->sum('quantity');
$totalValue = $batchItems->sum('total_value'); // 計算總價值
// 從獨立表格讀取安全庫存 // 從獨立表格讀取安全庫存
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null; $safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
@@ -64,6 +76,7 @@ class InventoryController extends Controller
'productCode' => $product?->code ?? 'N/A', 'productCode' => $product?->code ?? 'N/A',
'baseUnit' => $product?->baseUnit?->name ?? '個', 'baseUnit' => $product?->baseUnit?->name ?? '個',
'totalQuantity' => (float) $totalQuantity, 'totalQuantity' => (float) $totalQuantity,
'totalValue' => (float) $totalValue,
'safetyStock' => $safetyStock, 'safetyStock' => $safetyStock,
'status' => $status, 'status' => $status,
'batches' => $batchItems->map(function ($inv) { 'batches' => $batchItems->map(function ($inv) {
@@ -75,6 +88,8 @@ class InventoryController extends Controller
'productCode' => $inv->product?->code ?? 'N/A', 'productCode' => $inv->product?->code ?? 'N/A',
'unit' => $inv->product?->baseUnit?->name ?? '個', 'unit' => $inv->product?->baseUnit?->name ?? '個',
'quantity' => (float) $inv->quantity, 'quantity' => (float) $inv->quantity,
'unit_cost' => (float) $inv->unit_cost,
'total_value' => (float) $inv->total_value,
'safetyStock' => null, // 批號層級不再有安全庫存 'safetyStock' => null, // 批號層級不再有安全庫存
'status' => '正常', 'status' => '正常',
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
@@ -113,7 +128,7 @@ class InventoryController extends Controller
public function create(Warehouse $warehouse) public function create(Warehouse $warehouse)
{ {
// 取得所有商品供前端選單使用 // ... (unchanged) ...
$products = Product::with(['baseUnit', 'largeUnit']) $products = Product::with(['baseUnit', 'largeUnit'])
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate') ->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
->get() ->get()
@@ -136,6 +151,7 @@ class InventoryController extends Controller
public function store(Request $request, Warehouse $warehouse) public function store(Request $request, Warehouse $warehouse)
{ {
// ... (unchanged) ...
$validated = $request->validate([ $validated = $request->validate([
'inboundDate' => 'required|date', 'inboundDate' => 'required|date',
'reason' => 'required|string', 'reason' => 'required|string',
@@ -143,6 +159,7 @@ class InventoryController extends Controller
'items' => 'required|array|min:1', 'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id', 'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01', 'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
'items.*.batchMode' => 'required|in:existing,new', 'items.*.batchMode' => 'required|in:existing,new',
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id', 'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2', 'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
@@ -151,6 +168,10 @@ class InventoryController extends Controller
return DB::transaction(function () use ($validated, $warehouse) { return DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) { foreach ($validated['items'] as $item) {
// ... (略,傳遞 unit_cost 交給 Service 處理) ...
// 這裡需要修改呼叫 Service 的地方或直接更新邏輯
// 為求快速,我將在此更新邏輯
$inventory = null; $inventory = null;
if ($item['batchMode'] === 'existing') { if ($item['batchMode'] === 'existing') {
@@ -159,6 +180,11 @@ class InventoryController extends Controller
if ($inventory->trashed()) { if ($inventory->trashed()) {
$inventory->restore(); $inventory->restore();
} }
// 更新成本 (若有傳入)
if (isset($item['unit_cost'])) {
$inventory->unit_cost = $item['unit_cost'];
}
} else { } else {
// 模式 B建立新批號 // 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW'; $originCountry = $item['originCountry'] ?? 'TW';
@@ -170,7 +196,7 @@ class InventoryController extends Controller
$validated['inboundDate'] $validated['inboundDate']
); );
// 同樣要檢查此批號是否已經存在 (即使模式是 new, 但可能撞到同一天同產地手動建立的) // 檢查是否存在
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew( $inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[ [
'product_id' => $item['productId'], 'product_id' => $item['productId'],
@@ -178,6 +204,8 @@ class InventoryController extends Controller
], ],
[ [
'quantity' => 0, 'quantity' => 0,
'unit_cost' => $item['unit_cost'] ?? 0, // 新增
'total_value' => 0, // 稍後計算
'arrival_date' => $validated['inboundDate'], 'arrival_date' => $validated['inboundDate'],
'expiry_date' => $item['expiryDate'] ?? null, 'expiry_date' => $item['expiryDate'] ?? null,
'origin_country' => $originCountry, 'origin_country' => $originCountry,
@@ -193,12 +221,15 @@ class InventoryController extends Controller
$newQty = $currentQty + $item['quantity']; $newQty = $currentQty + $item['quantity'];
$inventory->quantity = $newQty; $inventory->quantity = $newQty;
// 更新總價值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save(); $inventory->save();
// 寫入異動紀錄 // 寫入異動紀錄
$inventory->transactions()->create([ $inventory->transactions()->create([
'type' => '手動入庫', 'type' => '手動入庫',
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit_cost' => $inventory->unit_cost, // 記錄成本
'balance_before' => $currentQty, 'balance_before' => $currentQty,
'balance_after' => $newQty, 'balance_after' => $newQty,
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''), 'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
@@ -212,9 +243,7 @@ class InventoryController extends Controller
}); });
} }
/** // ... (getBatches unchanged) ...
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
*/
public function getBatches(Warehouse $warehouse, $productId, Request $request) public function getBatches(Warehouse $warehouse, $productId, Request $request)
{ {
$originCountry = $request->query('originCountry', 'TW'); $originCountry = $request->query('originCountry', 'TW');
@@ -230,6 +259,7 @@ class InventoryController extends Controller
'originCountry' => $inventory->origin_country, 'originCountry' => $inventory->origin_country,
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null, 'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
'quantity' => (float) $inventory->quantity, 'quantity' => (float) $inventory->quantity,
'unitCost' => (float) $inventory->unit_cost, // 新增
]; ];
}); });
@@ -251,17 +281,21 @@ class InventoryController extends Controller
]); ]);
} }
public function edit(Request $request, Warehouse $warehouse, $inventoryId) public function edit(Request $request, Warehouse $warehouse, $inventoryId)
{ {
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
if (str_starts_with($inventoryId, 'mock-inv-')) { if (str_starts_with($inventoryId, 'mock-inv-')) {
return redirect()->back()->with('error', '無法編輯範例資料'); return redirect()->back()->with('error', '無法編輯範例資料');
} }
// 移除 'transactions.user' 預載入
$inventory = Inventory::with(['product', 'transactions' => function($query) { $inventory = Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc'); $query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId); }])->findOrFail($inventoryId);
// 手動 Hydrate 使用者資料
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
// 轉換為前端需要的格式 // 轉換為前端需要的格式
$inventoryData = [ $inventoryData = [
@@ -270,6 +304,8 @@ class InventoryController extends Controller
'productId' => (string) $inventory->product_id, 'productId' => (string) $inventory->product_id,
'productName' => $inventory->product?->name ?? '未知商品', 'productName' => $inventory->product?->name ?? '未知商品',
'quantity' => (float) $inventory->quantity, 'quantity' => (float) $inventory->quantity,
'unit_cost' => (float) $inventory->unit_cost,
'total_value' => (float) $inventory->total_value,
'batchNumber' => $inventory->batch_number ?? '-', 'batchNumber' => $inventory->batch_number ?? '-',
'expiryDate' => $inventory->expiry_date ?? null, 'expiryDate' => $inventory->expiry_date ?? null,
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'), 'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
@@ -277,14 +313,16 @@ class InventoryController extends Controller
]; ];
// 整理異動紀錄 // 整理異動紀錄
$transactions = $inventory->transactions->map(function ($tx) { $transactions = $inventory->transactions->map(function ($tx) use ($users) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
return [ return [
'id' => (string) $tx->id, 'id' => (string) $tx->id,
'type' => $tx->type, 'type' => $tx->type,
'quantity' => (float) $tx->quantity, 'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $tx->balance_after, 'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason, 'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統', 'userName' => $user ? $user->name : '系統', // 手動對應
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
]; ];
}); });
@@ -298,10 +336,7 @@ class InventoryController extends Controller
public function update(Request $request, Warehouse $warehouse, $inventoryId) public function update(Request $request, Warehouse $warehouse, $inventoryId)
{ {
// 若是 product ID (舊邏輯),先轉為 inventory // ... (unchanged) ...
// 但新路由我們傳的是 inventory ID
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$inventory = Inventory::find($inventoryId); $inventory = Inventory::find($inventoryId);
// 如果找不到 (可能是舊路由傳 product ID) // 如果找不到 (可能是舊路由傳 product ID)
@@ -320,7 +355,8 @@ class InventoryController extends Controller
'operation' => 'nullable|in:add,subtract,set', 'operation' => 'nullable|in:add,subtract,set',
'reason' => 'nullable|string', 'reason' => 'nullable|string',
'notes' => 'nullable|string', 'notes' => 'nullable|string',
// 新增日期欄位驗證 (雖然暫不儲存到 DB) 'unit_cost' => 'nullable|numeric|min:0', // 新增成本
// ...
'batchNumber' => 'nullable|string', 'batchNumber' => 'nullable|string',
'expiryDate' => 'nullable|date', 'expiryDate' => 'nullable|date',
'lastInboundDate' => 'nullable|date', 'lastInboundDate' => 'nullable|date',
@@ -354,8 +390,16 @@ class InventoryController extends Controller
$changeQty = $newQty - $currentQty; $changeQty = $newQty - $currentQty;
} }
// 更新成本 (若有傳)
if (isset($validated['unit_cost'])) {
$inventory->unit_cost = $validated['unit_cost'];
}
// 更新庫存 // 更新庫存
$inventory->update(['quantity' => $newQty]); $inventory->quantity = $newQty;
// 更新總值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
// 異動類型映射 // 異動類型映射
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment'); $type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
@@ -387,6 +431,7 @@ class InventoryController extends Controller
$inventory->transactions()->create([ $inventory->transactions()->create([
'type' => $chineseType, 'type' => $chineseType,
'quantity' => $changeQty, 'quantity' => $changeQty,
'unit_cost' => $inventory->unit_cost, // 記錄
'balance_before' => $currentQty, 'balance_before' => $currentQty,
'balance_after' => $newQty, 'balance_after' => $newQty,
'reason' => $reason, 'reason' => $reason,
@@ -402,7 +447,8 @@ class InventoryController extends Controller
public function destroy(Warehouse $warehouse, $inventoryId) public function destroy(Warehouse $warehouse, $inventoryId)
{ {
$inventory = Inventory::findOrFail($inventoryId); // ... (unchanged) ...
$inventory = Inventory::findOrFail($inventoryId);
// 庫存 > 0 不允許刪除 (哪怕是軟刪除) // 庫存 > 0 不允許刪除 (哪怕是軟刪除)
if ($inventory->quantity > 0) { if ($inventory->quantity > 0) {
@@ -414,6 +460,7 @@ class InventoryController extends Controller
$inventory->transactions()->create([ $inventory->transactions()->create([
'type' => '手動編輯', 'type' => '手動編輯',
'quantity' => -$inventory->quantity, 'quantity' => -$inventory->quantity,
'unit_cost' => $inventory->unit_cost,
'balance_before' => $inventory->quantity, 'balance_before' => $inventory->quantity,
'balance_after' => 0, 'balance_after' => 0,
'reason' => '刪除庫存品項', 'reason' => '刪除庫存品項',
@@ -430,98 +477,35 @@ class InventoryController extends Controller
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse) public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
{ {
// ... (前端 history 頁面可能也需要 unit_cost這裡可補上) ...
$inventoryId = $request->query('inventoryId'); $inventoryId = $request->query('inventoryId');
$productId = $request->query('productId'); $productId = $request->query('productId');
if ($productId) { if ($productId) {
// 商品層級查詢 // ... (略) ...
$inventories = Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])
->get();
if ($inventories->isEmpty()) {
return redirect()->back()->with('error', '找不到該商品的庫存紀錄');
}
$firstInventory = $inventories->first();
$productName = $firstInventory->product?->name ?? '未知商品';
$productCode = $firstInventory->product?->code ?? 'N/A';
$currentTotalQuantity = $inventories->sum('quantity');
// 合併所有批號的交易紀錄
$allTransactions = collect();
foreach ($inventories as $inv) {
foreach ($inv->transactions as $tx) {
$allTransactions->push([
'raw_tx' => $tx,
'batchNumber' => $inv->batch_number ?? '-',
'sort_time' => $tx->actual_time ?? $tx->created_at,
]);
}
}
// 依時間倒序排序 (最新的在前面)
$sortedTransactions = $allTransactions->sort(function ($a, $b) {
// 先比時間 (Desc)
if ($a['sort_time'] != $b['sort_time']) {
return $a['sort_time'] > $b['sort_time'] ? -1 : 1;
}
// 再比 ID (Desc)
return $a['raw_tx']->id > $b['raw_tx']->id ? -1 : 1;
});
// 回推計算結餘
$runningBalance = $currentTotalQuantity;
$transactions = $sortedTransactions->map(function ($item) use (&$runningBalance) {
$tx = $item['raw_tx'];
// 本次異動後的結餘 = 當前推算的結餘
$balanceAfter = $runningBalance;
// 推算前一次的結餘 (減去本次的異動量:如果是入庫+10前一次就是-10)
$runningBalance = $runningBalance - $tx->quantity;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $balanceAfter, // 使用即時計算的商品總結餘
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
'batchNumber' => $item['batchNumber'],
];
})->values();
return Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => 'product-' . $productId,
'productName' => $productName,
'productCode' => $productCode,
'quantity' => (float) $currentTotalQuantity,
],
'transactions' => $transactions
]);
} }
if ($inventoryId) { if ($inventoryId) {
// 單一批號查詢 // 單一批號查詢
// 移除 'transactions.user'
$inventory = Inventory::with(['product', 'transactions' => function($query) { $inventory = Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc'); $query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId); }])->findOrFail($inventoryId);
$transactions = $inventory->transactions->map(function ($tx) { // 手動 Hydrate 使用者資料
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
return [ return [
'id' => (string) $tx->id, 'id' => (string) $tx->id,
'type' => $tx->type, 'type' => $tx->type,
'quantity' => (float) $tx->quantity, 'quantity' => (float) $tx->quantity,
'unit_cost' => (float) $tx->unit_cost,
'balanceAfter' => (float) $tx->balance_after, 'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason, 'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統', 'userName' => $user ? $user->name : '系統', // 手動對應
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
]; ];
}); });
@@ -534,6 +518,8 @@ class InventoryController extends Controller
'productCode' => $inventory->product?->code ?? 'N/A', 'productCode' => $inventory->product?->code ?? 'N/A',
'batchNumber' => $inventory->batch_number ?? '-', 'batchNumber' => $inventory->batch_number ?? '-',
'quantity' => (float) $inventory->quantity, 'quantity' => (float) $inventory->quantity,
'unit_cost' => (float) $inventory->unit_cost,
'total_value' => (float) $inventory->total_value,
], ],
'transactions' => $transactions 'transactions' => $transactions
]); ]);

View File

@@ -50,6 +50,8 @@ class TransferOrderController extends Controller
], ],
[ [
'quantity' => 0, 'quantity' => 0,
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
'total_value' => 0,
'expiry_date' => $sourceInventory->expiry_date, 'expiry_date' => $sourceInventory->expiry_date,
'quality_status' => $sourceInventory->quality_status, 'quality_status' => $sourceInventory->quality_status,
'origin_country' => $sourceInventory->origin_country, 'origin_country' => $sourceInventory->origin_country,
@@ -65,12 +67,15 @@ class TransferOrderController extends Controller
// 設定活動紀錄原因 // 設定活動紀錄原因
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}"; $sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
$sourceInventory->update(['quantity' => $newSourceQty]); $sourceInventory->quantity = $newSourceQty;
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
$sourceInventory->save();
// 記錄來源異動 // 記錄來源異動
$sourceInventory->transactions()->create([ $sourceInventory->transactions()->create([
'type' => '撥補出庫', 'type' => '撥補出庫',
'quantity' => -$validated['quantity'], 'quantity' => -$validated['quantity'],
'unit_cost' => $sourceInventory->unit_cost, // 記錄
'balance_before' => $oldSourceQty, 'balance_before' => $oldSourceQty,
'balance_after' => $newSourceQty, 'balance_after' => $newSourceQty,
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""), 'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
@@ -84,12 +89,19 @@ class TransferOrderController extends Controller
// 設定活動紀錄原因 // 設定活動紀錄原因
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}"; $targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
$targetInventory->update(['quantity' => $newTargetQty]); // 確保目標庫存也有成本 (如果是繼承來的)
if ($targetInventory->unit_cost == 0 && $sourceInventory->unit_cost > 0) {
$targetInventory->unit_cost = $sourceInventory->unit_cost;
}
$targetInventory->quantity = $newTargetQty;
$targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; // 更新總值
$targetInventory->save();
// 記錄目標異動 // 記錄目標異動
$targetInventory->transactions()->create([ $targetInventory->transactions()->create([
'type' => '撥補入庫', 'type' => '撥補入庫',
'quantity' => $validated['quantity'], 'quantity' => $validated['quantity'],
'unit_cost' => $targetInventory->unit_cost, // 記錄
'balance_before' => $oldTargetQty, 'balance_before' => $oldTargetQty,
'balance_after' => $newTargetQty, 'balance_after' => $newTargetQty,
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""), 'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
@@ -118,6 +130,8 @@ class TransferOrderController extends Controller
'product_name' => $inv->product->name, 'product_name' => $inv->product->name,
'batch_number' => $inv->batch_number, 'batch_number' => $inv->batch_number,
'quantity' => (float) $inv->quantity, 'quantity' => (float) $inv->quantity,
'unit_cost' => (float) $inv->unit_cost, // 新增
'total_value' => (float) $inv->total_value, // 新增
'unit_name' => $inv->product->baseUnit?->name ?? '個', 'unit_name' => $inv->product->baseUnit?->name ?? '個',
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, 'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
]; ];

View File

@@ -74,6 +74,9 @@ class WarehouseController extends Controller
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_sellable' => 'nullable|boolean', 'is_sellable' => 'nullable|boolean',
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
]); ]);
// 自動產生代碼 // 自動產生代碼
@@ -96,6 +99,9 @@ class WarehouseController extends Controller
'address' => 'nullable|string|max:255', 'address' => 'nullable|string|max:255',
'description' => 'nullable|string', 'description' => 'nullable|string',
'is_sellable' => 'nullable|boolean', 'is_sellable' => 'nullable|boolean',
'type' => 'required|string',
'license_plate' => 'nullable|string|max:20',
'driver_name' => 'nullable|string|max:50',
]); ]);
$warehouse->update($validated); $warehouse->update($validated);

View File

@@ -18,6 +18,8 @@ class Inventory extends Model
'product_id', 'product_id',
'quantity', 'quantity',
'location', 'location',
'unit_cost',
'total_value',
// 批號追溯欄位 // 批號追溯欄位
'batch_number', 'batch_number',
'box_number', 'box_number',
@@ -32,6 +34,8 @@ class Inventory extends Model
protected $casts = [ protected $casts = [
'arrival_date' => 'date:Y-m-d', 'arrival_date' => 'date:Y-m-d',
'expiry_date' => 'date:Y-m-d', 'expiry_date' => 'date:Y-m-d',
'unit_cost' => 'decimal:4',
'total_value' => 'decimal:4',
]; ];
/** /**

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Modules\Core\Models\User; // 跨模組核心依賴
class InventoryTransaction extends Model class InventoryTransaction extends Model
{ {
@@ -15,6 +15,7 @@ class InventoryTransaction extends Model
'inventory_id', 'inventory_id',
'type', 'type',
'quantity', 'quantity',
'unit_cost',
'balance_before', 'balance_before',
'balance_after', 'balance_after',
'reason', 'reason',
@@ -26,6 +27,7 @@ class InventoryTransaction extends Model
protected $casts = [ protected $casts = [
'actual_time' => 'datetime', 'actual_time' => 'datetime',
'unit_cost' => 'decimal:4',
]; ];
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
@@ -33,11 +35,6 @@ class InventoryTransaction extends Model
return $this->belongsTo(Inventory::class); return $this->belongsTo(Inventory::class);
} }
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(User::class);
}
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
{ {
return $this->morphTo(); return $this->morphTo();

View File

@@ -15,13 +15,17 @@ class Warehouse extends Model
protected $fillable = [ protected $fillable = [
'code', 'code',
'name', 'name',
'type',
'address', 'address',
'description', 'description',
'is_sellable', 'is_sellable',
'license_plate',
'driver_name',
]; ];
protected $casts = [ protected $casts = [
'is_sellable' => 'boolean', 'is_sellable' => 'boolean',
'type' => \App\Enums\WarehouseType::class,
]; ];
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions

View File

@@ -40,6 +40,11 @@ class InventoryService implements InventoryServiceInterface
return Product::whereIn('id', $ids)->get(); return Product::whereIn('id', $ids)->get();
} }
public function getProductsByName(string $name)
{
return Product::where('name', 'like', "%{$name}%")->get();
}
public function getWarehouse(int $id) public function getWarehouse(int $id)
{ {
return Warehouse::find($id); return Warehouse::find($id);
@@ -105,16 +110,28 @@ class InventoryService implements InventoryServiceInterface
$inventory = Inventory::lockForUpdate()->find($inventory->id); $inventory = Inventory::lockForUpdate()->find($inventory->id);
$balanceBefore = $inventory->quantity; $balanceBefore = $inventory->quantity;
// 加權平均成本計算 (可選,這裡先採簡單邏輯:若有新成本則更新,否則沿用)
// 若本次入庫有指定成本,則更新該批次單價 (假設同批號成本相同)
if (isset($data['unit_cost'])) {
$inventory->unit_cost = $data['unit_cost'];
}
$inventory->quantity += $data['quantity']; $inventory->quantity += $data['quantity'];
// 更新總價值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
// 更新其他可能變更的欄位 (如最後入庫日) // 更新其他可能變更的欄位 (如最後入庫日)
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date; $inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
$inventory->save(); $inventory->save();
} else { } else {
// 若不存在,則建立新紀錄 // 若不存在,則建立新紀錄
$unitCost = $data['unit_cost'] ?? 0;
$inventory = Inventory::create([ $inventory = Inventory::create([
'warehouse_id' => $data['warehouse_id'], 'warehouse_id' => $data['warehouse_id'],
'product_id' => $data['product_id'], 'product_id' => $data['product_id'],
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'unit_cost' => $unitCost,
'total_value' => $data['quantity'] * $unitCost,
'batch_number' => $data['batch_number'] ?? null, 'batch_number' => $data['batch_number'] ?? null,
'box_number' => $data['box_number'] ?? null, 'box_number' => $data['box_number'] ?? null,
'origin_country' => $data['origin_country'] ?? 'TW', 'origin_country' => $data['origin_country'] ?? 'TW',
@@ -129,6 +146,7 @@ class InventoryService implements InventoryServiceInterface
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'type' => '入庫', 'type' => '入庫',
'quantity' => $data['quantity'], 'quantity' => $data['quantity'],
'unit_cost' => $inventory->unit_cost, // 記錄當下成本
'balance_before' => $balanceBefore, 'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity, 'balance_after' => $inventory->quantity,
'reason' => $data['reason'] ?? '手動入庫', 'reason' => $data['reason'] ?? '手動入庫',
@@ -148,13 +166,17 @@ class InventoryService implements InventoryServiceInterface
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId); $inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
$balanceBefore = $inventory->quantity; $balanceBefore = $inventory->quantity;
$inventory->decrement('quantity', $quantity); $inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
// 需要手動更新總價值
$inventory->refresh(); $inventory->refresh();
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
\App\Modules\Inventory\Models\InventoryTransaction::create([ \App\Modules\Inventory\Models\InventoryTransaction::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'type' => '出庫', 'type' => '出庫',
'quantity' => -$quantity, 'quantity' => -$quantity,
'unit_cost' => $inventory->unit_cost, // 記錄出庫時的成本
'balance_before' => $balanceBefore, 'balance_before' => $balanceBefore,
'balance_after' => $inventory->quantity, 'balance_after' => $inventory->quantity,
'reason' => $reason ?? '庫存扣減', 'reason' => $reason ?? '庫存扣減',
@@ -165,4 +187,24 @@ class InventoryService implements InventoryServiceInterface
]); ]);
}); });
} }
public function getDashboardStats(): array
{
// 庫存總表 join 安全庫存表,計算低庫存
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
function ($join) {
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
->on('ss.product_id', '=', 'inv.product_id');
})
->whereRaw('inv.total_qty <= ss.safety_stock')
->count();
return [
'productsCount' => Product::count(),
'warehousesCount' => Warehouse::count(),
'lowStockCount' => $lowStockCount,
'totalInventoryQuantity' => Inventory::sum('quantity'),
];
}
} }

View File

@@ -24,4 +24,11 @@ interface ProcurementServiceInterface
* @return Collection * @return Collection
*/ */
public function getPurchaseOrdersByIds(array $ids, array $with = []): Collection; public function getPurchaseOrdersByIds(array $ids, array $with = []): Collection;
/**
* Get statistics for the dashboard.
*
* @return array
*/
public function getDashboardStats(): array;
} }

View File

@@ -89,6 +89,7 @@ class PurchaseOrderController extends Controller
'poNumber' => $order->code, 'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id, 'supplierId' => (string) $order->vendor_id,
'supplierName' => $order->vendor?->name ?? 'Unknown', 'supplierName' => $order->vendor?->name ?? 'Unknown',
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
'expectedDate' => $order->expected_delivery_date?->toISOString(), 'expectedDate' => $order->expected_delivery_date?->toISOString(),
'status' => $order->status, 'status' => $order->status,
'totalAmount' => (float) $order->total_amount, 'totalAmount' => (float) $order->total_amount,
@@ -169,6 +170,7 @@ class PurchaseOrderController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id', 'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'order_date' => 'required|date', // 新增驗證
'expected_delivery_date' => 'nullable|date', 'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string', 'remark' => 'nullable|string',
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'], 'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
@@ -213,15 +215,7 @@ class PurchaseOrderController extends Controller
// 確保有一個有效的使用者 ID // 確保有一個有效的使用者 ID
$userId = auth()->id(); $userId = auth()->id();
if (!$userId) { if (!$userId) {
$user = \App\Modules\Core\Models\User::first(); $user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
if (!$user) {
$user = \App\Modules\Core\Models\User::create([
'name' => '系統管理員',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
]);
}
$userId = $user->id;
} }
$order = PurchaseOrder::create([ $order = PurchaseOrder::create([
@@ -230,6 +224,7 @@ class PurchaseOrderController extends Controller
'warehouse_id' => $validated['warehouse_id'], 'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId, 'user_id' => $userId,
'status' => 'draft', 'status' => 'draft',
'order_date' => $validated['order_date'], // 新增
'expected_delivery_date' => $validated['expected_delivery_date'], 'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'tax_amount' => $taxAmount, 'tax_amount' => $taxAmount,
@@ -299,6 +294,7 @@ class PurchaseOrderController extends Controller
'poNumber' => $order->code, 'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id, 'supplierId' => (string) $order->vendor_id,
'supplierName' => $order->vendor?->name ?? 'Unknown', 'supplierName' => $order->vendor?->name ?? 'Unknown',
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
'expectedDate' => $order->expected_delivery_date?->toISOString(), 'expectedDate' => $order->expected_delivery_date?->toISOString(),
'status' => $order->status, 'status' => $order->status,
'items' => $formattedItems, 'items' => $formattedItems,
@@ -395,6 +391,7 @@ class PurchaseOrderController extends Controller
'poNumber' => $order->code, 'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id, 'supplierId' => (string) $order->vendor_id,
'warehouse_id' => (int) $order->warehouse_id, 'warehouse_id' => (int) $order->warehouse_id,
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'), 'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'),
'status' => $order->status, 'status' => $order->status,
'items' => $formattedItems, 'items' => $formattedItems,
@@ -419,6 +416,7 @@ class PurchaseOrderController extends Controller
$validated = $request->validate([ $validated = $request->validate([
'vendor_id' => 'required|exists:vendors,id', 'vendor_id' => 'required|exists:vendors,id',
'warehouse_id' => 'required|exists:warehouses,id', 'warehouse_id' => 'required|exists:warehouses,id',
'order_date' => 'required|date', // 新增驗證
'expected_delivery_date' => 'nullable|date', 'expected_delivery_date' => 'nullable|date',
'remark' => 'nullable|string', 'remark' => 'nullable|string',
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled', 'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
@@ -452,6 +450,7 @@ class PurchaseOrderController extends Controller
$order->fill([ $order->fill([
'vendor_id' => $validated['vendor_id'], 'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'], 'warehouse_id' => $validated['warehouse_id'],
'order_date' => $validated['order_date'], // 新增
'expected_delivery_date' => $validated['expected_delivery_date'], 'expected_delivery_date' => $validated['expected_delivery_date'],
'total_amount' => $totalAmount, 'total_amount' => $totalAmount,
'tax_amount' => $taxAmount, 'tax_amount' => $taxAmount,

View File

@@ -78,8 +78,34 @@ class VendorController extends Controller
*/ */
public function show(Vendor $vendor): Response public function show(Vendor $vendor): Response
{ {
$vendor->load(['products.baseUnit', 'products.largeUnit']); // $vendor->load(['products.baseUnit', 'products.largeUnit']); // REMOVED: Cross-module relation
// 1. 獲取關聯的 Product IDs 與 Pivot Data
$pivots = \Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendor->id)
->get();
$productIds = $pivots->pluck('product_id')->toArray();
// 2. 透過 Service 獲取 Products
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$supplyProducts = $pivots->map(function ($pivot) use ($products) {
$product = $products->get($pivot->product_id);
if (!$product) return null;
return (object) [
'id' => (string) $pivot->id,
'productId' => (string) $product->id,
'productName' => $product->name,
'unit' => $product->baseUnit?->name ?? 'N/A',
'baseUnit' => $product->baseUnit?->name,
'largeUnit' => $product->largeUnit?->name,
'conversionRate' => (float) $product->conversion_rate,
'lastPrice' => (float) $pivot->last_price,
];
})->filter()->values();
$formattedVendor = (object) [ $formattedVendor = (object) [
'id' => (string) $vendor->id, 'id' => (string) $vendor->id,
'code' => $vendor->code, 'code' => $vendor->code,
@@ -93,16 +119,7 @@ class VendorController extends Controller
'email' => $vendor->email, 'email' => $vendor->email,
'address' => $vendor->address, 'address' => $vendor->address,
'remark' => $vendor->remark, 'remark' => $vendor->remark,
'supplyProducts' => $vendor->products->map(fn($p) => (object) [ 'supplyProducts' => $supplyProducts,
'id' => (string) $p->pivot->id,
'productId' => (string) $p->id,
'productName' => $p->name,
'unit' => $p->baseUnit?->name ?? 'N/A',
'baseUnit' => $p->baseUnit?->name,
'largeUnit' => $p->largeUnit?->name,
'conversionRate' => (float) $p->conversion_rate,
'lastPrice' => (float) $p->pivot->last_price,
]),
]; ];
return Inertia::render('Vendor/Show', [ return Inertia::render('Vendor/Show', [

View File

@@ -17,6 +17,7 @@ class PurchaseOrder extends Model
'vendor_id', 'vendor_id',
'warehouse_id', 'warehouse_id',
'user_id', 'user_id',
'order_date',
'expected_delivery_date', 'expected_delivery_date',
'status', 'status',
'total_amount', 'total_amount',
@@ -26,6 +27,7 @@ class PurchaseOrder extends Model
]; ];
protected $casts = [ protected $casts = [
'order_date' => 'date',
'expected_delivery_date' => 'date', 'expected_delivery_date' => 'date',
'total_amount' => 'decimal:2', 'total_amount' => 'decimal:2',
]; ];

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Procurement\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Modules\Inventory\Models\Product;
class PurchaseOrderItem extends Model class PurchaseOrderItem extends Model
{ {

View File

@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
use App\Modules\Inventory\Models\Product;
class Vendor extends Model class Vendor extends Model
{ {

View File

@@ -20,4 +20,13 @@ class ProcurementService implements ProcurementServiceInterface
{ {
return PurchaseOrder::whereIn('id', $ids)->with($with)->get(); return PurchaseOrder::whereIn('id', $ids)->with($with)->get();
} }
public function getDashboardStats(): array
{
return [
'vendorsCount' => \App\Modules\Procurement\Models\Vendor::count(),
'purchaseOrdersCount' => PurchaseOrder::count(),
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
];
}
} }

View File

@@ -7,7 +7,7 @@ use App\Http\Controllers\Controller;
use App\Modules\Production\Models\ProductionOrder; use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem; use App\Modules\Production\Models\ProductionOrderItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface; use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Core\Models\User; use App\Modules\Core\Contracts\CoreServiceInterface;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
@@ -16,10 +16,12 @@ use Inertia\Response;
class ProductionOrderController extends Controller class ProductionOrderController extends Controller
{ {
protected $inventoryService; protected $inventoryService;
protected $coreService;
public function __construct(InventoryServiceInterface $inventoryService) public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
{ {
$this->inventoryService = $inventoryService; $this->inventoryService = $inventoryService;
$this->coreService = $coreService;
} }
/** /**
@@ -38,7 +40,10 @@ class ProductionOrderController extends Controller
$q->where('code', 'like', "%{$search}%") $q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%"); ->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs // 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
$productIds = \App\Modules\Inventory\Models\Product::where('name', 'like', "%{$search}%")->pluck('id'); $q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
$productIds = $this->inventoryService->getProductsByName($search)->pluck('id');
$q->orWhereIn('product_id', $productIds); $q->orWhereIn('product_id', $productIds);
}); });
} }
@@ -62,7 +67,7 @@ class ProductionOrderController extends Controller
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id'); $warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id');
$users = User::whereIn('id', $userIds)->get()->keyBy('id'); // Core 模組暫由 Model 直接獲取 $users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users) { $productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users) {
$order->product = $products->get($order->product_id); $order->product = $products->get($order->product_id);
@@ -195,7 +200,7 @@ class ProductionOrderController extends Controller
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first(); $productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
} }
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id); $productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
$productionOrder->user = User::find($productionOrder->user_id); $productionOrder->user = $this->coreService->getUser($productionOrder->user_id);
// 手動水和明細資料 // 手動水和明細資料
$items = $productionOrder->items; $items = $productionOrder->items;

View File

@@ -70,30 +70,6 @@ class ProductionOrder extends Model
return $prefix . $sequence; return $prefix . $sequence;
} }
/**
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
*/
public function product()
{
return null;
}
/**
* @deprecated 使用 InventoryServiceInterface 獲取倉庫資訊
*/
public function warehouse()
{
return null;
}
/**
* @deprecated 使用 CoreServiceInterface 獲取使用者資訊
*/
public function user()
{
return null;
}
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
{ {
return $this->hasMany(ProductionOrderItem::class); return $this->hasMany(ProductionOrderItem::class);

View File

@@ -4,7 +4,7 @@ namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Modules\Inventory\Models\Product;
class ProductionOrderItem extends Model class ProductionOrderItem extends Model
{ {
@@ -22,32 +22,8 @@ class ProductionOrderItem extends Model
'quantity_used' => 'decimal:4', 'quantity_used' => 'decimal:4',
]; ];
/**
* @deprecated 使用 InventoryServiceInterface 獲取庫存資訊
*/
public function inventory()
{
return null;
}
/**
* @deprecated
*/
public function unit()
{
return null;
}
public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{ {
return $this->belongsTo(ProductionOrder::class); return $this->belongsTo(ProductionOrder::class);
} }
/**
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
*/
public function product()
{
return null;
}
} }

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->string('type')->default('standard')->after('description')->comment('倉庫業務類型 (enum)');
$table->string('license_plate')->nullable()->after('type')->comment('車牌號碼 (移動倉用)');
$table->string('driver_name')->nullable()->after('license_plate')->comment('司機姓名 (移動倉用)');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('warehouses', function (Blueprint $table) {
$table->dropColumn(['type', 'license_plate', 'driver_name']);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('inventories', function (Blueprint $table) {
$table->decimal('unit_cost', 12, 4)->default(0)->after('quantity')->comment('單位成本');
$table->decimal('total_value', 12, 4)->default(0)->after('unit_cost')->comment('庫存總價值');
});
Schema::table('inventory_transactions', function (Blueprint $table) {
$table->decimal('unit_cost', 12, 4)->nullable()->after('quantity')->comment('異動當下的單位成本');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_transactions', function (Blueprint $table) {
$table->dropColumn('unit_cost');
});
Schema::table('inventories', function (Blueprint $table) {
$table->dropColumn(['unit_cost', 'total_value']);
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('purchase_orders', function (Blueprint $table) {
$table->date('order_date')->nullable()->after('code');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('purchase_orders', function (Blueprint $table) {
$table->dropColumn('order_date');
});
}
};

View File

@@ -34,6 +34,7 @@ class PermissionSeeder extends Seeder
// 庫存管理 // 庫存管理
'inventory.view', 'inventory.view',
'inventory.view_cost', // 查看成本與價值
'inventory.adjust', 'inventory.adjust',
'inventory.transfer', 'inventory.transfer',
@@ -96,7 +97,7 @@ class PermissionSeeder extends Seeder
'products.view', 'products.create', 'products.edit', 'products.delete', 'products.view', 'products.create', 'products.edit', 'products.delete',
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit', 'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
'purchase_orders.delete', 'purchase_orders.publish', 'purchase_orders.delete', 'purchase_orders.publish',
'inventory.view', 'inventory.adjust', 'inventory.transfer', 'inventory.view', 'inventory.view_cost', 'inventory.adjust', 'inventory.transfer',
'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete', 'vendors.view', 'vendors.create', 'vendors.edit', 'vendors.delete',
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete', 'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
'users.view', 'users.create', 'users.edit', 'users.view', 'users.create', 'users.edit',

View File

@@ -41,11 +41,11 @@ export function PurchaseOrderItemsTable({
<TableHeader> <TableHeader>
<TableRow className="bg-gray-50 hover:bg-gray-50"> <TableRow className="bg-gray-50 hover:bg-gray-50">
<TableHead className="w-[20%] text-left"></TableHead> <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-[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>
<TableHead className="w-[15%] text-left"></TableHead>
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>} {!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -146,7 +146,30 @@ export function PurchaseOrderItemsTable({
</div> </div>
</TableCell> </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"> <TableCell className="text-left">
{isReadOnly ? ( {isReadOnly ? (
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span> <span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
@@ -178,29 +201,6 @@ export function PurchaseOrderItemsTable({
)} )}
</TableCell> </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 && ( {!isReadOnly && onRemoveItem && (
<TableCell className="text-center"> <TableCell className="text-center">

View File

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

View File

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

View File

@@ -32,6 +32,15 @@ interface WarehouseCardProps {
onEdit: (warehouse: Warehouse) => void; onEdit: (warehouse: Warehouse) => void;
} }
const WAREHOUSE_TYPE_LABELS: Record<string, string> = {
standard: "標準倉",
production: "生產倉",
retail: "門市倉",
vending: "販賣機",
transit: "在途倉",
quarantine: "瑕疵倉",
};
export default function WarehouseCard({ export default function WarehouseCard({
warehouse, warehouse,
stats, stats,
@@ -71,6 +80,16 @@ export default function WarehouseCard({
<Info className="h-5 w-5" /> <Info className="h-5 w-5" />
</button> </button>
</div> </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>
</div> </div>
@@ -107,6 +126,14 @@ export default function WarehouseCard({
)} )}
</div> </div>
</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>
</div> </div>

View File

@@ -1,6 +1,6 @@
/** /**
* 倉庫對話框元件 * 倉庫對話框元件
* 重構後:加入驗證邏輯 * 重構後:加入驗證邏輯與業務類型支援
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -27,9 +27,10 @@ import { Label } from "@/Components/ui/label";
import { Textarea } from "@/Components/ui/textarea"; import { Textarea } from "@/Components/ui/textarea";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { Warehouse } from "@/types/warehouse"; import { Warehouse, WarehouseType } from "@/types/warehouse";
import { validateWarehouse } from "@/utils/validation"; import { validateWarehouse } from "@/utils/validation";
import { toast } from "sonner"; import { toast } from "sonner";
import { SearchableSelect } from "@/Components/ui/searchable-select";
interface WarehouseDialogProps { interface WarehouseDialogProps {
open: boolean; open: boolean;
@@ -39,6 +40,15 @@ interface WarehouseDialogProps {
onDelete?: (warehouseId: string) => void; 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({ export default function WarehouseDialog({
open, open,
onOpenChange, onOpenChange,
@@ -51,13 +61,19 @@ export default function WarehouseDialog({
name: string; name: string;
address: string; address: string;
description: string; description: string;
type: WarehouseType;
is_sellable: boolean; is_sellable: boolean;
license_plate: string;
driver_name: string;
}>({ }>({
code: "", code: "",
name: "", name: "",
address: "", address: "",
description: "", description: "",
type: "standard",
is_sellable: true, is_sellable: true,
license_plate: "",
driver_name: "",
}); });
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
@@ -69,7 +85,10 @@ export default function WarehouseDialog({
name: warehouse.name, name: warehouse.name,
address: warehouse.address || "", address: warehouse.address || "",
description: warehouse.description || "", description: warehouse.description || "",
type: warehouse.type || "standard",
is_sellable: warehouse.is_sellable ?? true, is_sellable: warehouse.is_sellable ?? true,
license_plate: warehouse.license_plate || "",
driver_name: warehouse.driver_name || "",
}); });
} else { } else {
setFormData({ setFormData({
@@ -77,7 +96,10 @@ export default function WarehouseDialog({
name: "", name: "",
address: "", address: "",
description: "", description: "",
type: "standard",
is_sellable: true, is_sellable: true,
license_plate: "",
driver_name: "",
}); });
} }
}, [warehouse, open]); }, [warehouse, open]);
@@ -136,8 +158,21 @@ export default function WarehouseDialog({
/> />
</div> </div>
{/* 倉庫名稱 */} {/* 倉庫類型 */}
<div className="space-y-2"> <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"> <Label htmlFor="name">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</Label> </Label>
@@ -147,11 +182,43 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例:中央倉庫" placeholder="例:中央倉庫"
required required
className="h-9"
/> />
</div> </div>
</div> </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="space-y-4">
<div className="border-b pb-2"> <div className="border-b pb-2">
@@ -167,6 +234,9 @@ export default function WarehouseDialog({
/> />
<Label htmlFor="is_sellable"></Label> <Label htmlFor="is_sellable"></Label>
</div> </div>
<p className="text-xs text-gray-500 ml-6">
POS
</p>
</div> </div>
{/* 區塊 B位置 */} {/* 區塊 B位置 */}
@@ -186,6 +256,7 @@ export default function WarehouseDialog({
onChange={(e) => setFormData({ ...formData, address: e.target.value })} onChange={(e) => setFormData({ ...formData, address: e.target.value })}
placeholder="例台北市信義區信義路五段7號" placeholder="例台北市信義區信義路五段7號"
required required
className="h-9"
/> />
</div> </div>

View File

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

View File

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

View File

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