Compare commits
3 Commits
106de4e945
...
feature/mo
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b0e3b4f6f | |||
| 0e51992cb4 | |||
| ac6a81b3d2 |
25
app/Enums/WarehouseType.php
Normal file
25
app/Enums/WarehouseType.php
Normal 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 => '瑕疵倉 (報廢/檢驗)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -28,4 +28,11 @@ interface CoreServiceInterface
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllUsers(): Collection;
|
||||
|
||||
/**
|
||||
* Get the system user or create one if not exists.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
public function ensureSystemUserExists();
|
||||
}
|
||||
|
||||
@@ -4,18 +4,24 @@ namespace App\Modules\Core\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
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 App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $procurementService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
ProcurementServiceInterface $procurementService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->procurementService = $procurementService;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$centralDomains = config('tenancy.central_domains', []);
|
||||
@@ -25,25 +31,17 @@ class DashboardController extends Controller
|
||||
return redirect()->route('landlord.dashboard');
|
||||
}
|
||||
|
||||
// 計算低庫存數量:各商品在各倉庫的總量 < 安全庫存
|
||||
$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();
|
||||
$invStats = $this->inventoryService->getDashboardStats();
|
||||
$procStats = $this->procurementService->getDashboardStats();
|
||||
|
||||
$stats = [
|
||||
'productsCount' => Product::count(),
|
||||
'vendorsCount' => Vendor::count(),
|
||||
'purchaseOrdersCount' => PurchaseOrder::count(),
|
||||
'warehousesCount' => Warehouse::count(),
|
||||
'totalInventoryValue' => Inventory::join('products', 'inventories.product_id', '=', 'products.id')
|
||||
->sum('inventories.quantity'),
|
||||
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
|
||||
'lowStockCount' => $lowStockCount,
|
||||
'productsCount' => $invStats['productsCount'],
|
||||
'vendorsCount' => $procStats['vendorsCount'],
|
||||
'purchaseOrdersCount' => $procStats['purchaseOrdersCount'],
|
||||
'warehousesCount' => $invStats['warehousesCount'],
|
||||
'totalInventoryValue' => $invStats['totalInventoryQuantity'], // 原本前端命名是 totalInventoryValue 但實作是 Quantity,暫且保留欄位名以不破壞前端
|
||||
'pendingOrdersCount' => $procStats['pendingOrdersCount'],
|
||||
'lowStockCount' => $invStats['lowStockCount'],
|
||||
];
|
||||
|
||||
return Inertia::render('Dashboard', [
|
||||
|
||||
@@ -39,4 +39,17 @@ class CoreService implements CoreServiceInterface
|
||||
{
|
||||
return User::all();
|
||||
}
|
||||
|
||||
public function ensureSystemUserExists()
|
||||
{
|
||||
$user = User::first();
|
||||
if (!$user) {
|
||||
$user = User::create([
|
||||
'name' => '系統管理員',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ interface InventoryServiceInterface
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -97,4 +105,11 @@ interface InventoryServiceInterface
|
||||
* @return void
|
||||
*/
|
||||
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;
|
||||
}
|
||||
@@ -12,10 +12,20 @@ use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
protected $coreService;
|
||||
|
||||
public function __construct(CoreServiceInterface $coreService)
|
||||
{
|
||||
$this->coreService = $coreService;
|
||||
}
|
||||
|
||||
public function index(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
// ... (existing code for index) ...
|
||||
$warehouse->load([
|
||||
'inventories.product.category',
|
||||
'inventories.product.baseUnit',
|
||||
@@ -47,6 +57,8 @@ class InventoryController extends Controller
|
||||
$firstItem = $batchItems->first();
|
||||
$product = $firstItem->product;
|
||||
$totalQuantity = $batchItems->sum('quantity');
|
||||
$totalValue = $batchItems->sum('total_value'); // 計算總價值
|
||||
|
||||
// 從獨立表格讀取安全庫存
|
||||
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
|
||||
|
||||
@@ -64,6 +76,7 @@ class InventoryController extends Controller
|
||||
'productCode' => $product?->code ?? 'N/A',
|
||||
'baseUnit' => $product?->baseUnit?->name ?? '個',
|
||||
'totalQuantity' => (float) $totalQuantity,
|
||||
'totalValue' => (float) $totalValue,
|
||||
'safetyStock' => $safetyStock,
|
||||
'status' => $status,
|
||||
'batches' => $batchItems->map(function ($inv) {
|
||||
@@ -75,6 +88,8 @@ class InventoryController extends Controller
|
||||
'productCode' => $inv->product?->code ?? 'N/A',
|
||||
'unit' => $inv->product?->baseUnit?->name ?? '個',
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'unit_cost' => (float) $inv->unit_cost,
|
||||
'total_value' => (float) $inv->total_value,
|
||||
'safetyStock' => null, // 批號層級不再有安全庫存
|
||||
'status' => '正常',
|
||||
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
||||
@@ -113,7 +128,7 @@ class InventoryController extends Controller
|
||||
|
||||
public function create(Warehouse $warehouse)
|
||||
{
|
||||
// 取得所有商品供前端選單使用
|
||||
// ... (unchanged) ...
|
||||
$products = Product::with(['baseUnit', 'largeUnit'])
|
||||
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
||||
->get()
|
||||
@@ -136,6 +151,7 @@ class InventoryController extends Controller
|
||||
|
||||
public function store(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
// ... (unchanged) ...
|
||||
$validated = $request->validate([
|
||||
'inboundDate' => 'required|date',
|
||||
'reason' => 'required|string',
|
||||
@@ -143,6 +159,7 @@ class InventoryController extends Controller
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
|
||||
'items.*.batchMode' => 'required|in:existing,new',
|
||||
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
|
||||
'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) {
|
||||
foreach ($validated['items'] as $item) {
|
||||
// ... (略,傳遞 unit_cost 交給 Service 處理) ...
|
||||
// 這裡需要修改呼叫 Service 的地方或直接更新邏輯
|
||||
// 為求快速,我將在此更新邏輯
|
||||
|
||||
$inventory = null;
|
||||
|
||||
if ($item['batchMode'] === 'existing') {
|
||||
@@ -159,6 +180,11 @@ class InventoryController extends Controller
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
|
||||
// 更新成本 (若有傳入)
|
||||
if (isset($item['unit_cost'])) {
|
||||
$inventory->unit_cost = $item['unit_cost'];
|
||||
}
|
||||
} else {
|
||||
// 模式 B:建立新批號
|
||||
$originCountry = $item['originCountry'] ?? 'TW';
|
||||
@@ -170,7 +196,7 @@ class InventoryController extends Controller
|
||||
$validated['inboundDate']
|
||||
);
|
||||
|
||||
// 同樣要檢查此批號是否已經存在 (即使模式是 new, 但可能撞到同一天同產地手動建立的)
|
||||
// 檢查是否存在
|
||||
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
|
||||
[
|
||||
'product_id' => $item['productId'],
|
||||
@@ -178,6 +204,8 @@ class InventoryController extends Controller
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $item['unit_cost'] ?? 0, // 新增
|
||||
'total_value' => 0, // 稍後計算
|
||||
'arrival_date' => $validated['inboundDate'],
|
||||
'expiry_date' => $item['expiryDate'] ?? null,
|
||||
'origin_country' => $originCountry,
|
||||
@@ -193,12 +221,15 @@ class InventoryController extends Controller
|
||||
$newQty = $currentQty + $item['quantity'];
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
// 更新總價值
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
// 寫入異動紀錄
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動入庫',
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_cost' => $inventory->unit_cost, // 記錄成本
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
|
||||
@@ -212,9 +243,7 @@ class InventoryController extends Controller
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
|
||||
*/
|
||||
// ... (getBatches unchanged) ...
|
||||
public function getBatches(Warehouse $warehouse, $productId, Request $request)
|
||||
{
|
||||
$originCountry = $request->query('originCountry', 'TW');
|
||||
@@ -230,6 +259,7 @@ class InventoryController extends Controller
|
||||
'originCountry' => $inventory->origin_country,
|
||||
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
|
||||
'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)
|
||||
{
|
||||
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
|
||||
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
|
||||
if (str_starts_with($inventoryId, 'mock-inv-')) {
|
||||
return redirect()->back()->with('error', '無法編輯範例資料');
|
||||
}
|
||||
|
||||
// 移除 'transactions.user' 預載入
|
||||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||
$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 = [
|
||||
@@ -270,6 +304,8 @@ class InventoryController extends Controller
|
||||
'productId' => (string) $inventory->product_id,
|
||||
'productName' => $inventory->product?->name ?? '未知商品',
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'unit_cost' => (float) $inventory->unit_cost,
|
||||
'total_value' => (float) $inventory->total_value,
|
||||
'batchNumber' => $inventory->batch_number ?? '-',
|
||||
'expiryDate' => $inventory->expiry_date ?? null,
|
||||
'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 [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'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'),
|
||||
];
|
||||
});
|
||||
@@ -298,10 +336,7 @@ class InventoryController extends Controller
|
||||
|
||||
public function update(Request $request, Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// 若是 product ID (舊邏輯),先轉為 inventory
|
||||
// 但新路由我們傳的是 inventory ID
|
||||
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
|
||||
|
||||
// ... (unchanged) ...
|
||||
$inventory = Inventory::find($inventoryId);
|
||||
|
||||
// 如果找不到 (可能是舊路由傳 product ID)
|
||||
@@ -320,7 +355,8 @@ class InventoryController extends Controller
|
||||
'operation' => 'nullable|in:add,subtract,set',
|
||||
'reason' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
|
||||
'unit_cost' => 'nullable|numeric|min:0', // 新增成本
|
||||
// ...
|
||||
'batchNumber' => 'nullable|string',
|
||||
'expiryDate' => 'nullable|date',
|
||||
'lastInboundDate' => 'nullable|date',
|
||||
@@ -354,8 +390,16 @@ class InventoryController extends Controller
|
||||
$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');
|
||||
@@ -387,6 +431,7 @@ class InventoryController extends Controller
|
||||
$inventory->transactions()->create([
|
||||
'type' => $chineseType,
|
||||
'quantity' => $changeQty,
|
||||
'unit_cost' => $inventory->unit_cost, // 記錄
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $reason,
|
||||
@@ -402,7 +447,8 @@ class InventoryController extends Controller
|
||||
|
||||
public function destroy(Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
$inventory = Inventory::findOrFail($inventoryId);
|
||||
// ... (unchanged) ...
|
||||
$inventory = Inventory::findOrFail($inventoryId);
|
||||
|
||||
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
||||
if ($inventory->quantity > 0) {
|
||||
@@ -414,6 +460,7 @@ class InventoryController extends Controller
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動編輯',
|
||||
'quantity' => -$inventory->quantity,
|
||||
'unit_cost' => $inventory->unit_cost,
|
||||
'balance_before' => $inventory->quantity,
|
||||
'balance_after' => 0,
|
||||
'reason' => '刪除庫存品項',
|
||||
@@ -430,98 +477,35 @@ class InventoryController extends Controller
|
||||
|
||||
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
{
|
||||
// ... (前端 history 頁面可能也需要 unit_cost,這裡可補上) ...
|
||||
$inventoryId = $request->query('inventoryId');
|
||||
$productId = $request->query('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) {
|
||||
// 單一批號查詢
|
||||
// 移除 'transactions.user'
|
||||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||||
$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 [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'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'),
|
||||
];
|
||||
});
|
||||
@@ -534,6 +518,8 @@ class InventoryController extends Controller
|
||||
'productCode' => $inventory->product?->code ?? 'N/A',
|
||||
'batchNumber' => $inventory->batch_number ?? '-',
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'unit_cost' => (float) $inventory->unit_cost,
|
||||
'total_value' => (float) $inventory->total_value,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
|
||||
@@ -50,6 +50,8 @@ class TransferOrderController extends Controller
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 繼承成本
|
||||
'total_value' => 0,
|
||||
'expiry_date' => $sourceInventory->expiry_date,
|
||||
'quality_status' => $sourceInventory->quality_status,
|
||||
'origin_country' => $sourceInventory->origin_country,
|
||||
@@ -65,12 +67,15 @@ class TransferOrderController extends Controller
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
|
||||
$sourceInventory->update(['quantity' => $newSourceQty]);
|
||||
$sourceInventory->quantity = $newSourceQty;
|
||||
$sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; // 更新總值
|
||||
$sourceInventory->save();
|
||||
|
||||
// 記錄來源異動
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '撥補出庫',
|
||||
'quantity' => -$validated['quantity'],
|
||||
'unit_cost' => $sourceInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
@@ -84,12 +89,19 @@ class TransferOrderController extends Controller
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$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([
|
||||
'type' => '撥補入庫',
|
||||
'quantity' => $validated['quantity'],
|
||||
'unit_cost' => $targetInventory->unit_cost, // 記錄
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
@@ -118,6 +130,8 @@ class TransferOrderController extends Controller
|
||||
'product_name' => $inv->product->name,
|
||||
'batch_number' => $inv->batch_number,
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'unit_cost' => (float) $inv->unit_cost, // 新增
|
||||
'total_value' => (float) $inv->total_value, // 新增
|
||||
'unit_name' => $inv->product->baseUnit?->name ?? '個',
|
||||
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
];
|
||||
|
||||
@@ -74,6 +74,9 @@ class WarehouseController extends Controller
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
'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',
|
||||
'description' => 'nullable|string',
|
||||
'is_sellable' => 'nullable|boolean',
|
||||
'type' => 'required|string',
|
||||
'license_plate' => 'nullable|string|max:20',
|
||||
'driver_name' => 'nullable|string|max:50',
|
||||
]);
|
||||
|
||||
$warehouse->update($validated);
|
||||
|
||||
@@ -18,6 +18,8 @@ class Inventory extends Model
|
||||
'product_id',
|
||||
'quantity',
|
||||
'location',
|
||||
'unit_cost',
|
||||
'total_value',
|
||||
// 批號追溯欄位
|
||||
'batch_number',
|
||||
'box_number',
|
||||
@@ -32,6 +34,8 @@ class Inventory extends Model
|
||||
protected $casts = [
|
||||
'arrival_date' => 'date:Y-m-d',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
'unit_cost' => 'decimal:4',
|
||||
'total_value' => 'decimal:4',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Core\Models\User; // 跨模組核心依賴
|
||||
|
||||
|
||||
class InventoryTransaction extends Model
|
||||
{
|
||||
@@ -15,6 +15,7 @@ class InventoryTransaction extends Model
|
||||
'inventory_id',
|
||||
'type',
|
||||
'quantity',
|
||||
'unit_cost',
|
||||
'balance_before',
|
||||
'balance_after',
|
||||
'reason',
|
||||
@@ -26,6 +27,7 @@ class InventoryTransaction extends Model
|
||||
|
||||
protected $casts = [
|
||||
'actual_time' => 'datetime',
|
||||
'unit_cost' => 'decimal:4',
|
||||
];
|
||||
|
||||
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
@@ -33,11 +35,6 @@ class InventoryTransaction extends Model
|
||||
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
|
||||
{
|
||||
return $this->morphTo();
|
||||
|
||||
@@ -15,13 +15,17 @@ class Warehouse extends Model
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'type',
|
||||
'address',
|
||||
'description',
|
||||
'is_sellable',
|
||||
'license_plate',
|
||||
'driver_name',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_sellable' => 'boolean',
|
||||
'type' => \App\Enums\WarehouseType::class,
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
|
||||
@@ -40,6 +40,11 @@ class InventoryService implements InventoryServiceInterface
|
||||
return Product::whereIn('id', $ids)->get();
|
||||
}
|
||||
|
||||
public function getProductsByName(string $name)
|
||||
{
|
||||
return Product::where('name', 'like', "%{$name}%")->get();
|
||||
}
|
||||
|
||||
public function getWarehouse(int $id)
|
||||
{
|
||||
return Warehouse::find($id);
|
||||
@@ -105,16 +110,28 @@ class InventoryService implements InventoryServiceInterface
|
||||
$inventory = Inventory::lockForUpdate()->find($inventory->id);
|
||||
$balanceBefore = $inventory->quantity;
|
||||
|
||||
// 加權平均成本計算 (可選,這裡先採簡單邏輯:若有新成本則更新,否則沿用)
|
||||
// 若本次入庫有指定成本,則更新該批次單價 (假設同批號成本相同)
|
||||
if (isset($data['unit_cost'])) {
|
||||
$inventory->unit_cost = $data['unit_cost'];
|
||||
}
|
||||
|
||||
$inventory->quantity += $data['quantity'];
|
||||
// 更新總價值
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
|
||||
// 更新其他可能變更的欄位 (如最後入庫日)
|
||||
$inventory->arrival_date = $data['arrival_date'] ?? $inventory->arrival_date;
|
||||
$inventory->save();
|
||||
} else {
|
||||
// 若不存在,則建立新紀錄
|
||||
$unitCost = $data['unit_cost'] ?? 0;
|
||||
$inventory = Inventory::create([
|
||||
'warehouse_id' => $data['warehouse_id'],
|
||||
'product_id' => $data['product_id'],
|
||||
'quantity' => $data['quantity'],
|
||||
'unit_cost' => $unitCost,
|
||||
'total_value' => $data['quantity'] * $unitCost,
|
||||
'batch_number' => $data['batch_number'] ?? null,
|
||||
'box_number' => $data['box_number'] ?? null,
|
||||
'origin_country' => $data['origin_country'] ?? 'TW',
|
||||
@@ -129,6 +146,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
'inventory_id' => $inventory->id,
|
||||
'type' => '入庫',
|
||||
'quantity' => $data['quantity'],
|
||||
'unit_cost' => $inventory->unit_cost, // 記錄當下成本
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => $inventory->quantity,
|
||||
'reason' => $data['reason'] ?? '手動入庫',
|
||||
@@ -148,13 +166,17 @@ class InventoryService implements InventoryServiceInterface
|
||||
$inventory = Inventory::lockForUpdate()->findOrFail($inventoryId);
|
||||
$balanceBefore = $inventory->quantity;
|
||||
|
||||
$inventory->decrement('quantity', $quantity);
|
||||
$inventory->decrement('quantity', $quantity); // decrement 不會自動觸發 total_value 更新
|
||||
// 需要手動更新總價值
|
||||
$inventory->refresh();
|
||||
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
|
||||
$inventory->save();
|
||||
|
||||
\App\Modules\Inventory\Models\InventoryTransaction::create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'type' => '出庫',
|
||||
'quantity' => -$quantity,
|
||||
'unit_cost' => $inventory->unit_cost, // 記錄出庫時的成本
|
||||
'balance_before' => $balanceBefore,
|
||||
'balance_after' => $inventory->quantity,
|
||||
'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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,11 @@ interface ProcurementServiceInterface
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPurchaseOrdersByIds(array $ids, array $with = []): Collection;
|
||||
|
||||
/**
|
||||
* Get statistics for the dashboard.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getDashboardStats(): array;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ class PurchaseOrderController extends Controller
|
||||
'poNumber' => $order->code,
|
||||
'supplierId' => (string) $order->vendor_id,
|
||||
'supplierName' => $order->vendor?->name ?? 'Unknown',
|
||||
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
|
||||
'expectedDate' => $order->expected_delivery_date?->toISOString(),
|
||||
'status' => $order->status,
|
||||
'totalAmount' => (float) $order->total_amount,
|
||||
@@ -169,6 +170,7 @@ class PurchaseOrderController extends Controller
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'required|exists:vendors,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'order_date' => 'required|date', // 新增驗證
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||
@@ -213,15 +215,7 @@ class PurchaseOrderController extends Controller
|
||||
// 確保有一個有效的使用者 ID
|
||||
$userId = auth()->id();
|
||||
if (!$userId) {
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
if (!$user) {
|
||||
$user = \App\Modules\Core\Models\User::create([
|
||||
'name' => '系統管理員',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
$userId = $user->id;
|
||||
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
|
||||
}
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
@@ -230,6 +224,7 @@ class PurchaseOrderController extends Controller
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'user_id' => $userId,
|
||||
'status' => 'draft',
|
||||
'order_date' => $validated['order_date'], // 新增
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
@@ -299,6 +294,7 @@ class PurchaseOrderController extends Controller
|
||||
'poNumber' => $order->code,
|
||||
'supplierId' => (string) $order->vendor_id,
|
||||
'supplierName' => $order->vendor?->name ?? 'Unknown',
|
||||
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
|
||||
'expectedDate' => $order->expected_delivery_date?->toISOString(),
|
||||
'status' => $order->status,
|
||||
'items' => $formattedItems,
|
||||
@@ -395,6 +391,7 @@ class PurchaseOrderController extends Controller
|
||||
'poNumber' => $order->code,
|
||||
'supplierId' => (string) $order->vendor_id,
|
||||
'warehouse_id' => (int) $order->warehouse_id,
|
||||
'orderDate' => $order->order_date?->format('Y-m-d'), // 新增
|
||||
'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'),
|
||||
'status' => $order->status,
|
||||
'items' => $formattedItems,
|
||||
@@ -419,6 +416,7 @@ class PurchaseOrderController extends Controller
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'required|exists:vendors,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'order_date' => 'required|date', // 新增驗證
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
||||
@@ -452,6 +450,7 @@ class PurchaseOrderController extends Controller
|
||||
$order->fill([
|
||||
'vendor_id' => $validated['vendor_id'],
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'order_date' => $validated['order_date'], // 新增
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
|
||||
@@ -78,8 +78,34 @@ class VendorController extends Controller
|
||||
*/
|
||||
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) [
|
||||
'id' => (string) $vendor->id,
|
||||
'code' => $vendor->code,
|
||||
@@ -93,16 +119,7 @@ class VendorController extends Controller
|
||||
'email' => $vendor->email,
|
||||
'address' => $vendor->address,
|
||||
'remark' => $vendor->remark,
|
||||
'supplyProducts' => $vendor->products->map(fn($p) => (object) [
|
||||
'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,
|
||||
]),
|
||||
'supplyProducts' => $supplyProducts,
|
||||
];
|
||||
|
||||
return Inertia::render('Vendor/Show', [
|
||||
|
||||
@@ -17,6 +17,7 @@ class PurchaseOrder extends Model
|
||||
'vendor_id',
|
||||
'warehouse_id',
|
||||
'user_id',
|
||||
'order_date',
|
||||
'expected_delivery_date',
|
||||
'status',
|
||||
'total_amount',
|
||||
@@ -26,6 +27,7 @@ class PurchaseOrder extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order_date' => 'date',
|
||||
'expected_delivery_date' => 'date',
|
||||
'total_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
|
||||
|
||||
class PurchaseOrderItem extends Model
|
||||
{
|
||||
|
||||
@@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
|
||||
|
||||
class Vendor extends Model
|
||||
{
|
||||
|
||||
@@ -20,4 +20,13 @@ class ProcurementService implements ProcurementServiceInterface
|
||||
{
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Modules\Production\Models\ProductionOrder;
|
||||
use App\Modules\Production\Models\ProductionOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Core\Models\User;
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
@@ -16,10 +16,12 @@ use Inertia\Response;
|
||||
class ProductionOrderController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $coreService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->coreService = $coreService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,7 +40,10 @@ class ProductionOrderController extends Controller
|
||||
$q->where('code', 'like', "%{$search}%")
|
||||
->orWhere('output_batch_number', 'like', "%{$search}%");
|
||||
// 若要搜尋產品名稱,現在需先從 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);
|
||||
});
|
||||
}
|
||||
@@ -62,7 +67,7 @@ class ProductionOrderController extends Controller
|
||||
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->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) {
|
||||
$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->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;
|
||||
|
||||
@@ -70,30 +70,6 @@ class ProductionOrder extends Model
|
||||
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
|
||||
{
|
||||
return $this->hasMany(ProductionOrderItem::class);
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace App\Modules\Production\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
|
||||
|
||||
class ProductionOrderItem extends Model
|
||||
{
|
||||
@@ -22,32 +22,8 @@ class ProductionOrderItem extends Model
|
||||
'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
|
||||
{
|
||||
return $this->belongsTo(ProductionOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
|
||||
*/
|
||||
public function product()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -34,6 +34,7 @@ class PermissionSeeder extends Seeder
|
||||
|
||||
// 庫存管理
|
||||
'inventory.view',
|
||||
'inventory.view_cost', // 查看成本與價值
|
||||
'inventory.adjust',
|
||||
'inventory.transfer',
|
||||
|
||||
@@ -96,7 +97,7 @@ class PermissionSeeder extends Seeder
|
||||
'products.view', 'products.create', 'products.edit', 'products.delete',
|
||||
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||
'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',
|
||||
'warehouses.view', 'warehouses.create', 'warehouses.edit', 'warehouses.delete',
|
||||
'users.view', 'users.create', 'users.edit',
|
||||
|
||||
@@ -41,11 +41,11 @@ export function PurchaseOrderItemsTable({
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50 hover:bg-gray-50">
|
||||
<TableHead className="w-[20%] text-left">商品名稱</TableHead>
|
||||
<TableHead className="w-[10%] text-left">數量</TableHead>
|
||||
<TableHead className="w-[10%] text-left">採購數量</TableHead>
|
||||
<TableHead className="w-[12%] text-left">單位</TableHead>
|
||||
<TableHead className="w-[12%] text-left">換算基本單位</TableHead>
|
||||
<TableHead className="w-[15%] text-left">小計</TableHead>
|
||||
<TableHead className="w-[15%] text-left">單價 / 基本單位</TableHead>
|
||||
<TableHead className="w-[15%] text-left">小計</TableHead>
|
||||
{!isReadOnly && <TableHead className="w-[5%]"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -146,7 +146,30 @@ export function PurchaseOrderItemsTable({
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 總金額 (主要輸入欄位) */}
|
||||
{/* 換算採購單價 / 基本單位 (顯示換算結果 - SWAPPED HERE) */}
|
||||
<TableCell className="text-left">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-gray-500 font-medium text-sm">
|
||||
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
|
||||
</div>
|
||||
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
|
||||
<>
|
||||
{convertedUnitPrice > item.previousPrice && (
|
||||
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
|
||||
⚠️ 高於上次: {formatCurrency(item.previousPrice)}
|
||||
</p>
|
||||
)}
|
||||
{convertedUnitPrice < item.previousPrice && (
|
||||
<p className="text-[10px] text-green-600 font-medium">
|
||||
📉 低於上次: {formatCurrency(item.previousPrice)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 總金額 (主要輸入欄位 - SWAPPED HERE) */}
|
||||
<TableCell className="text-left">
|
||||
{isReadOnly ? (
|
||||
<span className="font-bold text-primary">{formatCurrency(item.subtotal)}</span>
|
||||
@@ -178,29 +201,6 @@ export function PurchaseOrderItemsTable({
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 換算採購單價 / 基本單位 (顯示換算結果) */}
|
||||
<TableCell className="text-left">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-gray-500 font-medium text-sm">
|
||||
{formatCurrency(convertedUnitPrice)} / {item.base_unit_name || "個"}
|
||||
</div>
|
||||
{convertedUnitPrice > 0 && item.previousPrice && item.previousPrice > 0 && (
|
||||
<>
|
||||
{convertedUnitPrice > item.previousPrice && (
|
||||
<p className="text-[10px] text-amber-600 font-medium animate-pulse">
|
||||
⚠️ 高於上次: {formatCurrency(item.previousPrice)}
|
||||
</p>
|
||||
)}
|
||||
{convertedUnitPrice < item.previousPrice && (
|
||||
<p className="text-[10px] text-green-600 font-medium">
|
||||
📉 低於上次: {formatCurrency(item.previousPrice)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
{!isReadOnly && onRemoveItem && (
|
||||
<TableCell className="text-center">
|
||||
|
||||
@@ -147,14 +147,21 @@ export default function InventoryTable({
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">
|
||||
總庫存:<span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} 個</span>
|
||||
總庫存:<span className={`font-medium ${isLowStock ? "text-red-600" : "text-gray-900"}`}>{totalQuantity} {group.baseUnit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Can permission="inventory.view_cost">
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">
|
||||
總價值:<span className="font-medium text-gray-900">${group.totalValue?.toLocaleString()}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Can>
|
||||
{group.safetyStock !== null ? (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<span className="text-gray-600">
|
||||
安全庫存:<span className="font-medium text-gray-900">{group.safetyStock} 個</span>
|
||||
安全庫存:<span className="font-medium text-gray-900">{group.safetyStock} {group.baseUnit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -193,11 +200,14 @@ export default function InventoryTable({
|
||||
<TableRow>
|
||||
<TableHead className="w-[5%]">#</TableHead>
|
||||
<TableHead className="w-[12%]">批號</TableHead>
|
||||
<TableHead className="w-[12%]">庫存數量</TableHead>
|
||||
<TableHead className="w-[15%]">進貨編號</TableHead>
|
||||
<TableHead className="w-[14%]">保存期限</TableHead>
|
||||
<TableHead className="w-[14%]">最新入庫</TableHead>
|
||||
<TableHead className="w-[14%]">最新出庫</TableHead>
|
||||
<TableHead className="w-[10%]">庫存數量</TableHead>
|
||||
<Can permission="inventory.view_cost">
|
||||
<TableHead className="w-[10%]">單位成本</TableHead>
|
||||
<TableHead className="w-[10%]">總價值</TableHead>
|
||||
</Can>
|
||||
<TableHead className="w-[12%]">保存期限</TableHead>
|
||||
<TableHead className="w-[12%]">最新入庫</TableHead>
|
||||
<TableHead className="w-[12%]">最新出庫</TableHead>
|
||||
<TableHead className="w-[8%] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -208,9 +218,12 @@ export default function InventoryTable({
|
||||
<TableCell className="text-grey-2">{index + 1}</TableCell>
|
||||
<TableCell>{batch.batchNumber || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<span>{batch.quantity}</span>
|
||||
<span>{batch.quantity} {batch.unit}</span>
|
||||
</TableCell>
|
||||
<TableCell>{batch.batchNumber || "-"}</TableCell>
|
||||
<Can permission="inventory.view_cost">
|
||||
<TableCell>${batch.unit_cost?.toLocaleString()}</TableCell>
|
||||
<TableCell>${batch.total_value?.toLocaleString()}</TableCell>
|
||||
</Can>
|
||||
<TableCell>
|
||||
{batch.expiryDate ? formatDate(batch.expiryDate) : "-"}
|
||||
</TableCell>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Textarea } from "@/Components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { Warehouse, TransferOrder, TransferOrderStatus } from "@/types/warehouse";
|
||||
import { validateTransferOrder, validateTransferQuantity } from "@/utils/validation";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
|
||||
export type { TransferOrder };
|
||||
|
||||
@@ -42,6 +43,8 @@ interface AvailableProduct {
|
||||
availableQty: number;
|
||||
unit: string;
|
||||
expiryDate: string | null;
|
||||
unitCost: number; // 新增
|
||||
totalValue: number; // 新增
|
||||
}
|
||||
|
||||
export default function TransferOrderDialog({
|
||||
@@ -52,6 +55,9 @@ export default function TransferOrderDialog({
|
||||
// inventories,
|
||||
onSave,
|
||||
}: TransferOrderDialogProps) {
|
||||
const { can } = usePermission();
|
||||
const canViewCost = can('inventory.view_cost');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
sourceWarehouseId: "",
|
||||
targetWarehouseId: "",
|
||||
@@ -106,7 +112,9 @@ export default function TransferOrderDialog({
|
||||
batchNumber: item.batch_number,
|
||||
availableQty: item.quantity,
|
||||
unit: item.unit_name,
|
||||
expiryDate: item.expiry_date
|
||||
expiryDate: item.expiry_date,
|
||||
unitCost: item.unit_cost, // 映射
|
||||
totalValue: item.total_value, // 映射
|
||||
}));
|
||||
setAvailableProducts(mappedData);
|
||||
})
|
||||
@@ -249,7 +257,7 @@ export default function TransferOrderDialog({
|
||||
onValueChange={handleProductChange}
|
||||
disabled={!formData.sourceWarehouseId || !!order}
|
||||
options={availableProducts.map((product) => ({
|
||||
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})`,
|
||||
label: `${product.productName} | 批號: ${product.batchNumber || '-'} | 效期: ${product.expiryDate || '-'} (庫存: ${product.availableQty} ${product.unit})${canViewCost ? ` | 成本: $${product.unitCost?.toLocaleString()}` : ''}`,
|
||||
value: `${product.productId}|||${product.batchNumber}`,
|
||||
}))}
|
||||
placeholder="選擇商品與批號"
|
||||
|
||||
@@ -32,6 +32,15 @@ interface WarehouseCardProps {
|
||||
onEdit: (warehouse: Warehouse) => void;
|
||||
}
|
||||
|
||||
const WAREHOUSE_TYPE_LABELS: Record<string, string> = {
|
||||
standard: "標準倉",
|
||||
production: "生產倉",
|
||||
retail: "門市倉",
|
||||
vending: "販賣機",
|
||||
transit: "在途倉",
|
||||
quarantine: "瑕疵倉",
|
||||
};
|
||||
|
||||
export default function WarehouseCard({
|
||||
warehouse,
|
||||
stats,
|
||||
@@ -71,6 +80,16 @@ export default function WarehouseCard({
|
||||
<Info className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Badge variant="outline" className="text-xs font-normal">
|
||||
{WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'}
|
||||
</Badge>
|
||||
{warehouse.type === 'transit' && warehouse.license_plate && (
|
||||
<Badge variant="secondary" className="text-xs font-normal bg-yellow-100 text-yellow-800 border-yellow-200">
|
||||
{warehouse.license_plate}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,6 +126,14 @@ export default function WarehouseCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移動倉司機資訊 */}
|
||||
{warehouse.type === 'transit' && warehouse.driver_name && (
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100">
|
||||
<span className="text-sm text-gray-500">司機</span>
|
||||
<span className="text-sm font-medium text-gray-900">{warehouse.driver_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 倉庫對話框元件
|
||||
* 重構後:加入驗證邏輯
|
||||
* 重構後:加入驗證邏輯與業務類型支援
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -27,9 +27,10 @@ import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Warehouse } from "@/types/warehouse";
|
||||
import { Warehouse, WarehouseType } from "@/types/warehouse";
|
||||
import { validateWarehouse } from "@/utils/validation";
|
||||
import { toast } from "sonner";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
|
||||
interface WarehouseDialogProps {
|
||||
open: boolean;
|
||||
@@ -39,6 +40,15 @@ interface WarehouseDialogProps {
|
||||
onDelete?: (warehouseId: string) => void;
|
||||
}
|
||||
|
||||
const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [
|
||||
{ label: "標準倉 (總倉)", value: "standard" },
|
||||
{ label: "生產倉 (廚房/加工)", value: "production" },
|
||||
{ label: "門市倉 (前台销售)", value: "retail" },
|
||||
{ label: "販賣機 (IoT設備)", value: "vending" },
|
||||
{ label: "在途倉 (物流車)", value: "transit" },
|
||||
{ label: "瑕疵倉 (報廢/檢驗)", value: "quarantine" },
|
||||
];
|
||||
|
||||
export default function WarehouseDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -51,13 +61,19 @@ export default function WarehouseDialog({
|
||||
name: string;
|
||||
address: string;
|
||||
description: string;
|
||||
type: WarehouseType;
|
||||
is_sellable: boolean;
|
||||
license_plate: string;
|
||||
driver_name: string;
|
||||
}>({
|
||||
code: "",
|
||||
name: "",
|
||||
address: "",
|
||||
description: "",
|
||||
type: "standard",
|
||||
is_sellable: true,
|
||||
license_plate: "",
|
||||
driver_name: "",
|
||||
});
|
||||
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
@@ -69,7 +85,10 @@ export default function WarehouseDialog({
|
||||
name: warehouse.name,
|
||||
address: warehouse.address || "",
|
||||
description: warehouse.description || "",
|
||||
type: warehouse.type || "standard",
|
||||
is_sellable: warehouse.is_sellable ?? true,
|
||||
license_plate: warehouse.license_plate || "",
|
||||
driver_name: warehouse.driver_name || "",
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
@@ -77,7 +96,10 @@ export default function WarehouseDialog({
|
||||
name: "",
|
||||
address: "",
|
||||
description: "",
|
||||
type: "standard",
|
||||
is_sellable: true,
|
||||
license_plate: "",
|
||||
driver_name: "",
|
||||
});
|
||||
}
|
||||
}, [warehouse, open]);
|
||||
@@ -136,8 +158,21 @@ export default function WarehouseDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 倉庫名稱 */}
|
||||
{/* 倉庫類型 */}
|
||||
<div className="space-y-2">
|
||||
<Label>倉庫類型 <span className="text-red-500">*</span></Label>
|
||||
<SearchableSelect
|
||||
value={formData.type}
|
||||
onValueChange={(val) => setFormData({ ...formData, type: val as WarehouseType })}
|
||||
options={WAREHOUSE_TYPE_OPTIONS}
|
||||
placeholder="選擇倉庫類型"
|
||||
className="h-9"
|
||||
showSearch={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 倉庫名稱 */}
|
||||
<div className="space-y-2 col-span-2">
|
||||
<Label htmlFor="name">
|
||||
倉庫名稱 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
@@ -147,11 +182,43 @@ export default function WarehouseDialog({
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="例:中央倉庫"
|
||||
required
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移動倉專屬資訊 */}
|
||||
{formData.type === 'transit' && (
|
||||
<div className="space-y-4 bg-yellow-50 p-4 rounded-lg border border-yellow-100">
|
||||
<div className="border-b border-yellow-200 pb-2">
|
||||
<h4 className="text-sm text-yellow-800 font-medium">車輛資訊 (在途倉)</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="license_plate">車牌號碼</Label>
|
||||
<Input
|
||||
id="license_plate"
|
||||
value={formData.license_plate}
|
||||
onChange={(e) => setFormData({ ...formData, license_plate: e.target.value })}
|
||||
placeholder="例:ABC-1234"
|
||||
className="h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="driver_name">司機姓名</Label>
|
||||
<Input
|
||||
id="driver_name"
|
||||
value={formData.driver_name}
|
||||
onChange={(e) => setFormData({ ...formData, driver_name: e.target.value })}
|
||||
placeholder="例:王小明"
|
||||
className="h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 銷售設定 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-2">
|
||||
@@ -167,6 +234,9 @@ export default function WarehouseDialog({
|
||||
/>
|
||||
<Label htmlFor="is_sellable">此倉庫可進行銷售扣庫</Label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 ml-6">
|
||||
啟用後,該倉庫庫存可用於 POS 或訂單銷售扣減。總倉通常不啟用,門市與行動販賣車需啟用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 區塊 B:位置 */}
|
||||
@@ -186,6 +256,7 @@ export default function WarehouseDialog({
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
placeholder="例:台北市信義區信義路五段7號"
|
||||
required
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function CreatePurchaseOrder({
|
||||
const {
|
||||
supplierId,
|
||||
expectedDate,
|
||||
orderDate,
|
||||
items,
|
||||
notes,
|
||||
selectedSupplier,
|
||||
@@ -46,6 +47,7 @@ export default function CreatePurchaseOrder({
|
||||
warehouseId,
|
||||
setSupplierId,
|
||||
setExpectedDate,
|
||||
setOrderDate,
|
||||
setNotes,
|
||||
setWarehouseId,
|
||||
addItem,
|
||||
@@ -87,6 +89,11 @@ export default function CreatePurchaseOrder({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!orderDate) {
|
||||
toast.error("請選擇採購日期");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!expectedDate) {
|
||||
toast.error("請選擇預計到貨日期");
|
||||
return;
|
||||
@@ -120,6 +127,7 @@ export default function CreatePurchaseOrder({
|
||||
const data = {
|
||||
vendor_id: supplierId,
|
||||
warehouse_id: warehouseId,
|
||||
order_date: orderDate,
|
||||
expected_delivery_date: expectedDate,
|
||||
remark: notes,
|
||||
status: status,
|
||||
@@ -235,6 +243,18 @@ export default function CreatePurchaseOrder({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">
|
||||
採購日期 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={orderDate || ""}
|
||||
onChange={(e) => setOrderDate(e.target.value)}
|
||||
className="block w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700">
|
||||
預計到貨日期
|
||||
|
||||
@@ -88,6 +88,10 @@ export default function ViewPurchaseOrderPage({ order }: Props) {
|
||||
<span className="text-sm text-gray-500 block mb-1">建立日期</span>
|
||||
<span className="font-medium text-gray-900">{formatDateTime(order.createdAt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">採購日期</span>
|
||||
<span className="font-medium text-gray-900">{order.orderDate || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 block mb-1">預計到貨日期</span>
|
||||
<span className="font-medium text-gray-900">{order.expectedDate || "-"}</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { PurchaseOrder, PurchaseOrderItem, Supplier, PurchaseOrderStatus } from "@/types/purchase-order";
|
||||
import { calculateSubtotal } from "@/utils/purchase-order";
|
||||
import { calculateSubtotal, getTodayDate } from "@/utils/purchase-order";
|
||||
|
||||
interface UsePurchaseOrderFormProps {
|
||||
order?: PurchaseOrder;
|
||||
@@ -14,6 +14,7 @@ interface UsePurchaseOrderFormProps {
|
||||
export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormProps) {
|
||||
const [supplierId, setSupplierId] = useState(order?.supplierId || "");
|
||||
const [expectedDate, setExpectedDate] = useState(order?.expectedDate || "");
|
||||
const [orderDate, setOrderDate] = useState(order?.orderDate || getTodayDate());
|
||||
const [items, setItems] = useState<PurchaseOrderItem[]>(order?.items || []);
|
||||
const [notes, setNotes] = useState(order?.remark || "");
|
||||
const [status, setStatus] = useState<PurchaseOrderStatus>(order?.status || "draft");
|
||||
@@ -32,6 +33,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
|
||||
if (order) {
|
||||
setSupplierId(order.supplierId);
|
||||
setExpectedDate(order.expectedDate);
|
||||
setOrderDate(order.orderDate || getTodayDate());
|
||||
setItems(order.items || []);
|
||||
setNotes(order.remark || "");
|
||||
setStatus(order.status);
|
||||
@@ -52,6 +54,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
|
||||
const resetForm = () => {
|
||||
setSupplierId("");
|
||||
setExpectedDate("");
|
||||
setOrderDate(getTodayDate());
|
||||
setItems([]);
|
||||
setNotes("");
|
||||
setStatus("draft");
|
||||
@@ -159,6 +162,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
|
||||
// State
|
||||
supplierId,
|
||||
expectedDate,
|
||||
orderDate,
|
||||
items,
|
||||
notes,
|
||||
status,
|
||||
@@ -174,6 +178,7 @@ export function usePurchaseOrderForm({ order, suppliers }: UsePurchaseOrderFormP
|
||||
// Setters
|
||||
setSupplierId,
|
||||
setExpectedDate,
|
||||
setOrderDate,
|
||||
setNotes,
|
||||
setStatus,
|
||||
setWarehouseId,
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface PurchaseOrder {
|
||||
poNumber: string;
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
orderDate?: string; // 採購日期
|
||||
expectedDate: string;
|
||||
status: PurchaseOrderStatus;
|
||||
items: PurchaseOrderItem[];
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 倉庫相關型別定義
|
||||
*/
|
||||
|
||||
export type WarehouseType = "中央倉庫" | "門市";
|
||||
export type WarehouseType = "standard" | "production" | "retail" | "vending" | "transit" | "quarantine";
|
||||
|
||||
/**
|
||||
* 門市資訊
|
||||
@@ -19,17 +19,17 @@ export interface Warehouse {
|
||||
name: string;
|
||||
address?: string;
|
||||
description?: string;
|
||||
createdAt?: string; // 對應 created_at 但前端可能習慣 camelCase,後端傳回 snake_case,Inertia 會保持原樣。
|
||||
// 若後端 Resource 沒轉 camelCase,這裡應該用 snake_case 或在前端轉
|
||||
// 為求簡單,我修改 interface 為 snake_case 以匹配 Laravel 預設 Response
|
||||
createdAt?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
total_quantity?: number;
|
||||
low_stock_count?: number;
|
||||
type?: WarehouseType;
|
||||
is_sellable?: boolean; // 新增欄位
|
||||
book_stock?: number; // 帳面庫存
|
||||
available_stock?: number; // 可用庫存
|
||||
is_sellable?: boolean;
|
||||
license_plate?: string; // 車牌號碼 (移動倉)
|
||||
driver_name?: string; // 司機姓名 (移動倉)
|
||||
book_stock?: number;
|
||||
available_stock?: number;
|
||||
}
|
||||
// 倉庫中的庫存項目
|
||||
export interface WarehouseInventory {
|
||||
@@ -41,6 +41,8 @@ export interface WarehouseInventory {
|
||||
productCode: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
unit_cost?: number; // 單位成本
|
||||
total_value?: number; // 總價值
|
||||
safetyStock: number | null;
|
||||
status?: '正常' | '低於'; // 後端可能回傳的狀態
|
||||
batchNumber: string; // 批號 (Mock for now)
|
||||
@@ -56,6 +58,7 @@ export interface GroupedInventory {
|
||||
productCode: string;
|
||||
baseUnit: string;
|
||||
totalQuantity: number;
|
||||
totalValue?: number; // 總價值總計
|
||||
safetyStock: number | null; // 以商品層級顯示的安全庫存
|
||||
status: '正常' | '低於';
|
||||
batches: WarehouseInventory[]; // 該商品下的所有批號庫存
|
||||
@@ -89,6 +92,7 @@ export interface Product {
|
||||
|
||||
export interface WarehouseStats {
|
||||
totalQuantity: number;
|
||||
totalValue?: number; // 倉庫總值
|
||||
lowStockCount: number;
|
||||
replenishmentNeeded: number;
|
||||
}
|
||||
@@ -145,6 +149,7 @@ export interface InventoryTransaction {
|
||||
productName: string;
|
||||
batchNumber: string;
|
||||
quantity: number; // 正數為入庫,負數為出庫
|
||||
unit_cost?: number; // 異動時的成本
|
||||
transactionType: TransactionType;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
@@ -161,6 +166,7 @@ export interface InboundItem {
|
||||
productId: string;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unit_cost?: number; // 入庫單價
|
||||
unit: string;
|
||||
baseUnit?: string;
|
||||
largeUnit?: string;
|
||||
|
||||
Reference in New Issue
Block a user