diff --git a/app/Modules/Core/Controllers/RoleController.php b/app/Modules/Core/Controllers/RoleController.php index fe55716..30735a6 100644 --- a/app/Modules/Core/Controllers/RoleController.php +++ b/app/Modules/Core/Controllers/RoleController.php @@ -188,11 +188,13 @@ class RoleController extends Controller 'vendors' => '廠商資料管理', 'purchase_orders' => '採購單管理', 'goods_receipts' => '進貨單管理', + 'delivery_notes' => '出貨單管理', 'recipes' => '配方管理', 'production_orders' => '生產工單管理', 'utility_fees' => '公共事業費管理', 'accounting' => '會計報表', 'sales_imports' => '銷售單匯入管理', + 'store_requisitions' => '門市叫貨申請', 'users' => '使用者管理', 'roles' => '角色與權限', 'system' => '系統管理', diff --git a/app/Modules/Inventory/Controllers/StoreRequisitionController.php b/app/Modules/Inventory/Controllers/StoreRequisitionController.php new file mode 100644 index 0000000..4a61972 --- /dev/null +++ b/app/Modules/Inventory/Controllers/StoreRequisitionController.php @@ -0,0 +1,352 @@ +service = $service; + $this->coreService = $coreService; + } + + /** + * 叫貨單列表 + */ + public function index(Request $request) + { + $query = StoreRequisition::query(); + + // 搜尋(單號) + if ($request->search) { + $query->where('doc_no', 'like', "%{$request->search}%"); + } + + // 狀態篩選 + if ($request->status && $request->status !== 'all') { + $query->where('status', $request->status); + } + + // 倉庫篩選 + if ($request->warehouse_id) { + $query->where('store_warehouse_id', $request->warehouse_id); + } + + // 日期範圍 + if ($request->date_start) { + $query->whereDate('created_at', '>=', $request->date_start); + } + if ($request->date_end) { + $query->whereDate('created_at', '<=', $request->date_end); + } + + // 排序 + $sortField = $request->input('sort_by', 'id'); + $sortOrder = $request->input('sort_order', 'desc'); + $allowedSorts = ['id', 'doc_no', 'status', 'created_at', 'submitted_at']; + if (in_array($sortField, $allowedSorts)) { + $query->orderBy($sortField, $sortOrder); + } else { + $query->orderBy('id', 'desc'); + } + + $perPage = $request->input('per_page', 10); + $requisitions = $query->paginate($perPage)->withQueryString(); + + // 水和倉庫名稱與使用者名稱 + $warehouses = Warehouse::select('id', 'name', 'type')->get(); + $warehouseMap = $warehouses->keyBy('id'); + + $userIds = $requisitions->getCollection() + ->pluck('created_by') + ->merge($requisitions->getCollection()->pluck('approved_by')) + ->filter() + ->unique() + ->toArray(); + $users = $this->coreService->getUsersByIds($userIds)->keyBy('id'); + + $requisitions->getCollection()->transform(function ($req) use ($warehouseMap, $users) { + $req->store_warehouse_name = $warehouseMap->get($req->store_warehouse_id)?->name ?? '-'; + $req->supply_warehouse_name = $warehouseMap->get($req->supply_warehouse_id)?->name ?? '-'; + $req->creator_name = $users->get($req->created_by)?->name ?? '-'; + $req->approver_name = $users->get($req->approved_by)?->name ?? '-'; + return $req; + }); + + return Inertia::render('StoreRequisition/Index', [ + 'requisitions' => $requisitions, + 'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_by', 'sort_order', 'per_page']), + 'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]), + ]); + } + + /** + * 新增頁面 + */ + public function create() + { + $warehouses = Warehouse::select('id', 'name', 'type')->get(); + $products = Product::select('id', 'name', 'code', 'base_unit_id') + ->with('baseUnit:id,name') + ->where('is_active', true) + ->get(); + + return Inertia::render('StoreRequisition/Create', [ + 'warehouses' => $warehouses->map(fn($w) => [ + 'id' => $w->id, + 'name' => $w->name, + 'type' => $w->type?->value, + ]), + 'products' => $products->map(fn($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'code' => $p->code, + 'unit_name' => $p->baseUnit?->name, + ]), + ]); + } + + /** + * 儲存叫貨單 + */ + public function store(Request $request) + { + $request->validate([ + 'store_warehouse_id' => 'required|exists:warehouses,id', + 'remark' => 'nullable|string|max:500', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.requested_qty' => 'required|numeric|min:0.01', + 'items.*.remark' => 'nullable|string|max:200', + ], [ + 'items.required' => '至少需要一項商品', + 'items.min' => '至少需要一項商品', + 'items.*.requested_qty.min' => '需求數量必須大於 0', + ]); + + $requisition = $this->service->create( + $request->only(['store_warehouse_id', 'remark']), + $request->items, + auth()->id() + ); + + // 如果需要直接提交 + if ($request->boolean('submit_immediately')) { + $this->service->submit($requisition, auth()->id()); + return redirect()->route('store-requisitions.index') + ->with('success', '叫貨單已提交審核'); + } + + return redirect()->route('store-requisitions.show', $requisition->id) + ->with('success', '叫貨單已儲存為草稿'); + } + + /** + * 叫貨單詳情 + */ + public function show($id) + { + $requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id); + + // 水和倉庫 + $warehouses = Warehouse::select('id', 'name', 'type')->get(); + $warehouseMap = $warehouses->keyBy('id'); + + $requisition->store_warehouse_name = $warehouseMap->get($requisition->store_warehouse_id)?->name ?? '-'; + $requisition->supply_warehouse_name = $warehouseMap->get($requisition->supply_warehouse_id)?->name ?? '-'; + + // 水和使用者 + $userIds = collect([$requisition->created_by, $requisition->approved_by])->filter()->unique()->toArray(); + $users = $this->coreService->getUsersByIds($userIds)->keyBy('id'); + $requisition->creator_name = $users->get($requisition->created_by)?->name ?? '-'; + $requisition->approver_name = $users->get($requisition->approved_by)?->name ?? '-'; + + // 水和明細商品資訊 + $requisition->items->transform(function ($item) { + $item->product_name = $item->product?->name ?? '-'; + $item->product_code = $item->product?->code ?? '-'; + $item->unit_name = $item->product?->baseUnit?->name ?? '-'; + return $item; + }); + + // 取得庫存資訊(顯示該商品在申請倉庫的現有庫存量) + $productIds = $requisition->items->pluck('product_id')->toArray(); + $inventories = Inventory::where('warehouse_id', $requisition->store_warehouse_id) + ->whereIn('product_id', $productIds) + ->select('product_id') + ->selectRaw('SUM(quantity) as total_qty') + ->groupBy('product_id') + ->get() + ->keyBy('product_id'); + + $requisition->items->transform(function ($item) use ($inventories) { + $item->current_stock = $inventories->get($item->product_id)?->total_qty ?? 0; + return $item; + }); + + // 操作紀錄 + $activities = \Spatie\Activitylog\Models\Activity::where('subject_type', StoreRequisition::class) + ->where('subject_id', $requisition->id) + ->orderBy('created_at', 'desc') + ->get(); + + return Inertia::render('StoreRequisition/Show', [ + 'requisition' => $requisition, + 'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]), + 'activities' => $activities, + ]); + } + + /** + * 編輯頁面 + */ + public function edit($id) + { + $requisition = StoreRequisition::with(['items.product.baseUnit'])->findOrFail($id); + + if (!in_array($requisition->status, ['draft', 'rejected'])) { + return redirect()->route('store-requisitions.show', $id) + ->with('error', '僅能編輯草稿或被駁回的叫貨單'); + } + + $warehouses = Warehouse::select('id', 'name', 'type')->get(); + $products = Product::select('id', 'name', 'code', 'base_unit_id') + ->with('baseUnit:id,name') + ->where('is_active', true) + ->get(); + + return Inertia::render('StoreRequisition/Create', [ + 'requisition' => $requisition, + 'warehouses' => $warehouses->map(fn($w) => [ + 'id' => $w->id, + 'name' => $w->name, + 'type' => $w->type?->value, + ]), + 'products' => $products->map(fn($p) => [ + 'id' => $p->id, + 'name' => $p->name, + 'code' => $p->code, + 'unit_name' => $p->baseUnit?->name, + ]), + ]); + } + + /** + * 更新叫貨單 + */ + public function update(Request $request, $id) + { + $requisition = StoreRequisition::findOrFail($id); + + $request->validate([ + 'store_warehouse_id' => 'required|exists:warehouses,id', + 'remark' => 'nullable|string|max:500', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.requested_qty' => 'required|numeric|min:0.01', + 'items.*.remark' => 'nullable|string|max:200', + ]); + + $requisition = $this->service->update( + $requisition, + $request->only(['store_warehouse_id', 'remark']), + $request->items + ); + + // 如果需要直接提交 + if ($request->boolean('submit_immediately')) { + $this->service->submit($requisition, auth()->id()); + return redirect()->route('store-requisitions.index') + ->with('success', '叫貨單已重新提交審核'); + } + + return redirect()->route('store-requisitions.show', $requisition->id) + ->with('success', '叫貨單已更新'); + } + + /** + * 提交審核 + */ + public function submit($id) + { + $requisition = StoreRequisition::findOrFail($id); + $this->service->submit($requisition, auth()->id()); + + return redirect()->route('store-requisitions.show', $id) + ->with('success', '叫貨單已提交審核'); + } + + /** + * 核准叫貨單 + */ + public function approve(Request $request, $id) + { + $requisition = StoreRequisition::findOrFail($id); + + $request->validate([ + 'supply_warehouse_id' => 'required|exists:warehouses,id', + 'items' => 'required|array', + 'items.*.id' => 'required|exists:store_requisition_items,id', + 'items.*.approved_qty' => 'required|numeric|min:0', + ], [ + 'supply_warehouse_id.required' => '請選擇供貨倉庫', + ]); + + $this->service->approve($requisition, $request->only(['supply_warehouse_id', 'items']), auth()->id()); + + return redirect()->route('store-requisitions.show', $id) + ->with('success', '叫貨單已核准,調撥單已自動產生'); + } + + /** + * 駁回叫貨單 + */ + public function reject(Request $request, $id) + { + $requisition = StoreRequisition::findOrFail($id); + + $request->validate([ + 'reject_reason' => 'required|string|max:500', + ], [ + 'reject_reason.required' => '請填寫駁回原因', + ]); + + $this->service->reject($requisition, $request->reject_reason, auth()->id()); + + return redirect()->route('store-requisitions.show', $id) + ->with('success', '叫貨單已駁回'); + } + + /** + * 刪除叫貨單(僅限草稿) + */ + public function destroy($id) + { + $requisition = StoreRequisition::findOrFail($id); + + if ($requisition->status !== 'draft') { + return back()->withErrors(['error' => '僅能刪除草稿狀態的叫貨單']); + } + + $requisition->items()->delete(); + $requisition->delete(); + + return redirect()->route('store-requisitions.index') + ->with('success', '叫貨單已刪除'); + } +} diff --git a/app/Modules/Inventory/Controllers/TransferOrderController.php b/app/Modules/Inventory/Controllers/TransferOrderController.php index f4532f3..d40edcc 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -3,11 +3,13 @@ namespace App\Modules\Inventory\Controllers; use App\Http\Controllers\Controller; +use App\Enums\WarehouseType; use App\Modules\Inventory\Models\InventoryTransferOrder; use App\Modules\Inventory\Models\Warehouse; use App\Modules\Inventory\Models\Inventory; use App\Modules\Inventory\Services\TransferService; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Inertia\Inertia; class TransferOrderController extends Controller @@ -65,6 +67,7 @@ class TransferOrderController extends Controller $validated = $request->validate([ 'from_warehouse_id' => 'required_without:sourceWarehouseId|exists:warehouses,id', 'to_warehouse_id' => 'required_without:targetWarehouseId|exists:warehouses,id|different:from_warehouse_id', + 'transit_warehouse_id' => 'nullable|exists:warehouses,id', 'remarks' => 'nullable|string', 'notes' => 'nullable|string', 'instant_post' => 'boolean', @@ -75,20 +78,22 @@ class TransferOrderController extends Controller ]); $remarks = $validated['remarks'] ?? $validated['notes'] ?? null; + $transitWarehouseId = $validated['transit_warehouse_id'] ?? null; + $order = $this->transferService->createOrder( $fromId, $toId, $remarks, - auth()->id() + auth()->id(), + $transitWarehouseId ); if ($request->input('instant_post') === true) { try { - $this->transferService->post($order, auth()->id()); + $this->transferService->dispatch($order, auth()->id()); return redirect()->back()->with('success', '撥補成功,庫存已更新'); } catch (\Exception $e) { - // 如果過帳失敗,雖然單據已建立,但應回報錯誤 return redirect()->back()->withErrors(['items' => $e->getMessage()]); } } @@ -99,22 +104,37 @@ class TransferOrderController extends Controller public function show(InventoryTransferOrder $order) { - $order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'createdBy', 'postedBy']); + $order->load(['items.product.baseUnit', 'fromWarehouse', 'toWarehouse', 'transitWarehouse', 'createdBy', 'postedBy', 'dispatchedBy', 'receivedBy', 'storeRequisition']); $orderData = [ 'id' => (string) $order->id, 'doc_no' => $order->doc_no, 'from_warehouse_id' => (string) $order->from_warehouse_id, 'from_warehouse_name' => $order->fromWarehouse->name, + 'from_warehouse_default_transit' => $order->fromWarehouse->default_transit_warehouse_id ? (string)$order->fromWarehouse->default_transit_warehouse_id : null, 'to_warehouse_id' => (string) $order->to_warehouse_id, 'to_warehouse_name' => $order->toWarehouse->name, - 'to_warehouse_type' => $order->toWarehouse->type->value, // 用於判斷是否為販賣機 + 'to_warehouse_type' => $order->toWarehouse->type->value, + // 在途倉資訊 + 'transit_warehouse_id' => $order->transit_warehouse_id ? (string) $order->transit_warehouse_id : null, + 'transit_warehouse_name' => $order->transitWarehouse?->name, + 'transit_warehouse_plate' => $order->transitWarehouse?->license_plate, + 'transit_warehouse_driver' => $order->transitWarehouse?->driver_name, 'status' => $order->status, 'remarks' => $order->remarks, 'created_at' => $order->created_at->format('Y-m-d H:i'), 'created_by' => $order->createdBy?->name, + 'posted_at' => $order->posted_at?->format('Y-m-d H:i'), + 'posted_by' => $order->postedBy?->name, + 'dispatched_at' => $order->dispatched_at?->format('Y-m-d H:i'), + 'dispatched_by' => $order->dispatchedBy?->name, + 'received_at' => $order->received_at?->format('Y-m-d H:i'), + 'received_by' => $order->receivedBy?->name, + 'requisition' => $order->storeRequisition ? [ + 'id' => (string) $order->storeRequisition->id, + 'doc_no' => $order->storeRequisition->doc_no, + ] : null, 'items' => $order->items->map(function ($item) use ($order) { - // 獲取來源倉庫的當前庫存 $stock = Inventory::where('warehouse_id', $order->from_warehouse_id) ->where('product_id', $item->product_id) ->where('batch_number', $item->batch_number) @@ -136,18 +156,51 @@ class TransferOrderController extends Controller }), ]; + // 取得在途倉庫列表供前端選擇 + $transitWarehouses = Warehouse::where('type', WarehouseType::TRANSIT) + ->get() + ->map(fn($w) => [ + 'id' => (string) $w->id, + 'name' => $w->name, + 'license_plate' => $w->license_plate, + 'driver_name' => $w->driver_name, + ]); + return Inertia::render('Inventory/Transfer/Show', [ 'order' => $orderData, + 'transitWarehouses' => $transitWarehouses, ]); } public function update(Request $request, InventoryTransferOrder $order) { + // 收貨動作:僅限 dispatched 狀態 + if ($request->input('action') === 'receive') { + if ($order->status !== 'dispatched') { + return redirect()->back()->with('error', '僅能對已出貨的調撥單進行收貨確認'); + } + try { + $this->transferService->receive($order, auth()->id()); + return redirect()->route('inventory.transfer.index') + ->with('success', '調撥單已收貨完成'); + } catch (ValidationException $e) { + return redirect()->back()->withErrors($e->errors()); + } catch (\Exception $e) { + return redirect()->back()->withErrors(['items' => $e->getMessage()]); + } + } + + // 以下操作僅限草稿 if ($order->status !== 'draft') { return redirect()->back()->with('error', '只能修改草稿狀態的單據'); } - // 1. 先更新資料 (如果請求中包含 items,則先執行儲存) + // 1. 更新在途倉庫(如果前端有傳) + if ($request->has('transit_warehouse_id')) { + $order->transit_warehouse_id = $request->input('transit_warehouse_id') ?: null; + } + + // 2. 先更新資料 (如果請求中包含 items,則先執行儲存) $itemsChanged = false; if ($request->has('items')) { $validated = $request->validate([ @@ -167,20 +220,21 @@ class TransferOrderController extends Controller $order->remarks = $request->input('remarks'); } - if ($itemsChanged || $remarksChanged) { - // [IMPORTANT] 使用 touch() 確保即便只有品項異動,也會因為 updated_at 變更而觸發自動日誌 + if ($itemsChanged || $remarksChanged || $order->isDirty()) { $order->touch(); $message = '儲存成功'; } else { $message = '資料未變更'; } - // 2. 判斷是否需要過帳 + // 3. 判斷是否需要出貨/過帳 if ($request->input('action') === 'post') { try { - $this->transferService->post($order, auth()->id()); + $this->transferService->dispatch($order, auth()->id()); + $hasTransit = !empty($order->transit_warehouse_id); + $successMsg = $hasTransit ? '調撥單已出貨,庫存已轉入在途倉' : '調撥單已過帳完成'; return redirect()->route('inventory.transfer.index') - ->with('success', '調撥單已過帳完成'); + ->with('success', $successMsg); } catch (ValidationException $e) { return redirect()->back()->withErrors($e->errors()); } catch (\Exception $e) { diff --git a/app/Modules/Inventory/Controllers/WarehouseController.php b/app/Modules/Inventory/Controllers/WarehouseController.php index 715807f..6b65297 100644 --- a/app/Modules/Inventory/Controllers/WarehouseController.php +++ b/app/Modules/Inventory/Controllers/WarehouseController.php @@ -113,9 +113,22 @@ class WarehouseController extends Controller 'book_amount' => \App\Modules\Inventory\Models\Inventory::sum('total_value'), ]; + // 取得在途倉列表供前端選擇「預設在途倉」 + $transitWarehouses = Warehouse::where('type', \App\Enums\WarehouseType::TRANSIT) + ->select('id', 'name', 'license_plate', 'driver_name') + ->orderBy('name') + ->get() + ->map(fn ($w) => [ + 'id' => (string) $w->id, + 'name' => $w->name, + 'license_plate' => $w->license_plate, + 'driver_name' => $w->driver_name, + ]); + return Inertia::render('Warehouse/Index', [ 'warehouses' => $warehouses, 'totals' => $totals, + 'transitWarehouses' => $transitWarehouses, 'filters' => $request->only(['search', 'per_page']), ]); } @@ -130,6 +143,7 @@ class WarehouseController extends Controller 'type' => 'required|string', 'license_plate' => 'nullable|string|max:20', 'driver_name' => 'nullable|string|max:50', + 'default_transit_warehouse_id' => 'nullable|exists:warehouses,id', ]); Warehouse::create($validated); @@ -147,6 +161,7 @@ class WarehouseController extends Controller 'type' => 'required|string', 'license_plate' => 'nullable|string|max:20', 'driver_name' => 'nullable|string|max:50', + 'default_transit_warehouse_id' => 'nullable|exists:warehouses,id', ]); $warehouse->update($validated); diff --git a/app/Modules/Inventory/Models/InventoryTransferOrder.php b/app/Modules/Inventory/Models/InventoryTransferOrder.php index ca11db3..82769b1 100644 --- a/app/Modules/Inventory/Models/InventoryTransferOrder.php +++ b/app/Modules/Inventory/Models/InventoryTransferOrder.php @@ -106,16 +106,23 @@ class InventoryTransferOrder extends Model 'doc_no', 'from_warehouse_id', 'to_warehouse_id', + 'transit_warehouse_id', 'status', 'remarks', 'posted_at', 'created_by', 'updated_by', 'posted_by', + 'dispatched_at', + 'dispatched_by', + 'received_at', + 'received_by', ]; protected $casts = [ 'posted_at' => 'datetime', + 'dispatched_at' => 'datetime', + 'received_at' => 'datetime', ]; protected static function boot() @@ -163,8 +170,28 @@ class InventoryTransferOrder extends Model return $this->belongsTo(User::class, 'created_by'); } + public function storeRequisition(): \Illuminate\Database\Eloquent\Relations\HasOne + { + return $this->hasOne(StoreRequisition::class, 'transfer_order_id'); + } + public function postedBy(): BelongsTo { return $this->belongsTo(User::class, 'posted_by'); } + + public function transitWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'transit_warehouse_id'); + } + + public function dispatchedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'dispatched_by'); + } + + public function receivedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'received_by'); + } } diff --git a/app/Modules/Inventory/Models/StoreRequisition.php b/app/Modules/Inventory/Models/StoreRequisition.php new file mode 100644 index 0000000..314ead4 --- /dev/null +++ b/app/Modules/Inventory/Models/StoreRequisition.php @@ -0,0 +1,147 @@ + 'datetime', + 'approved_at' => 'datetime', + ]; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + + /** + * 自定義日誌屬性,解析 ID 為名稱 + */ + public function tapActivity(\Spatie\Activitylog\Models\Activity $activity, string $eventName) + { + $properties = $activity->properties->toArray(); + + // 基本單據資訊快照 + $properties['snapshot'] = [ + 'doc_no' => $this->doc_no, + 'store_warehouse_name' => $this->storeWarehouse?->name, + 'supply_warehouse_name' => $this->supplyWarehouse?->name, + 'status' => $this->status, + ]; + + // 移除雜訊欄位 + if (isset($properties['attributes'])) { + unset($properties['attributes']['updated_at']); + } + if (isset($properties['old'])) { + unset($properties['old']['updated_at']); + } + + $activity->properties = collect($properties); + } + + /** + * 自動產生單號 SR-YYYYMMDD-XX + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($model) { + if (empty($model->doc_no)) { + $today = date('Ymd'); + $prefix = 'SR-' . $today . '-'; + + $lastDoc = static::where('doc_no', 'like', $prefix . '%') + ->orderBy('doc_no', 'desc') + ->first(); + + if ($lastDoc) { + $lastNumber = substr($lastDoc->doc_no, -2); + $nextNumber = str_pad((int)$lastNumber + 1, 2, '0', STR_PAD_LEFT); + } else { + $nextNumber = '01'; + } + + $model->doc_no = $prefix . $nextNumber; + } + }); + } + + // ===== 關聯 ===== + + /** + * 申請倉庫 + */ + public function storeWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'store_warehouse_id'); + } + + /** + * 供貨倉庫(審核時填入) + */ + public function supplyWarehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class, 'supply_warehouse_id'); + } + + /** + * 叫貨明細 + */ + public function items(): HasMany + { + return $this->hasMany(StoreRequisitionItem::class); + } + + /** + * 申請人 + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * 審核人 + */ + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * 關聯調撥單 + */ + public function transferOrder(): BelongsTo + { + return $this->belongsTo(InventoryTransferOrder::class, 'transfer_order_id'); + } +} diff --git a/app/Modules/Inventory/Models/StoreRequisitionItem.php b/app/Modules/Inventory/Models/StoreRequisitionItem.php new file mode 100644 index 0000000..3c96199 --- /dev/null +++ b/app/Modules/Inventory/Models/StoreRequisitionItem.php @@ -0,0 +1,41 @@ + 'decimal:2', + 'approved_qty' => 'decimal:2', + ]; + + /** + * 所屬叫貨單 + */ + public function requisition(): BelongsTo + { + return $this->belongsTo(StoreRequisition::class, 'store_requisition_id'); + } + + /** + * 關聯商品(同模組) + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Modules/Inventory/Models/Warehouse.php b/app/Modules/Inventory/Models/Warehouse.php index 4e6b2fc..b70f749 100644 --- a/app/Modules/Inventory/Models/Warehouse.php +++ b/app/Modules/Inventory/Models/Warehouse.php @@ -20,6 +20,7 @@ class Warehouse extends Model 'description', 'license_plate', 'driver_name', + 'default_transit_warehouse_id', ]; protected $casts = [ @@ -50,7 +51,13 @@ class Warehouse extends Model return $this->hasMany(Inventory::class); } - + /** + * 預設在途倉庫 + */ + public function defaultTransitWarehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(self::class, 'default_transit_warehouse_id'); + } public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany { diff --git a/app/Modules/Inventory/Notifications/StoreRequisitionNotification.php b/app/Modules/Inventory/Notifications/StoreRequisitionNotification.php new file mode 100644 index 0000000..8e425aa --- /dev/null +++ b/app/Modules/Inventory/Notifications/StoreRequisitionNotification.php @@ -0,0 +1,54 @@ +requisition = $requisition; + $this->action = $action; + $this->actorName = $actorName; + } + + public function via(object $notifiable): array + { + return ['database']; + } + + public function toArray(object $notifiable): array + { + $messages = [ + 'submitted' => "{$this->actorName} 提交了叫貨申請:{$this->requisition->doc_no}", + 'approved' => "{$this->actorName} 核准了叫貨申請:{$this->requisition->doc_no}", + 'rejected' => "{$this->actorName} 駁回了叫貨申請:{$this->requisition->doc_no}", + ]; + + return [ + 'type' => 'store_requisition', + 'action' => $this->action, + 'store_requisition_id' => $this->requisition->id, + 'doc_no' => $this->requisition->doc_no, + 'actor_name' => $this->actorName, + 'message' => $messages[$this->action] ?? "{$this->actorName} 操作了叫貨申請:{$this->requisition->doc_no}", + 'link' => route('store-requisitions.show', $this->requisition->id), + ]; + } +} diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 32f7d23..2c2674a 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -141,6 +141,32 @@ Route::middleware('auth')->group(function () { ->middleware('permission:inventory_transfer.view') ->name('inventory.transfer.template'); + // 門市叫貨申請 (Store Requisitions) + Route::middleware('permission:store_requisitions.view')->group(function () { + Route::get('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'index'])->name('store-requisitions.index'); + + Route::middleware('permission:store_requisitions.create')->group(function () { + Route::get('/store-requisitions/create', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'create'])->name('store-requisitions.create'); + Route::post('/store-requisitions', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'store'])->name('store-requisitions.store'); + }); + + Route::get('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'show'])->name('store-requisitions.show'); + + Route::middleware('permission:store_requisitions.edit')->group(function () { + Route::get('/store-requisitions/{id}/edit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'edit'])->name('store-requisitions.edit'); + Route::put('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'update'])->name('store-requisitions.update'); + }); + + Route::post('/store-requisitions/{id}/submit', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'submit'])->name('store-requisitions.submit'); + + Route::middleware('permission:store_requisitions.approve')->group(function () { + Route::post('/store-requisitions/{id}/approve', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'approve'])->name('store-requisitions.approve'); + Route::post('/store-requisitions/{id}/reject', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'reject'])->name('store-requisitions.reject'); + }); + + Route::delete('/store-requisitions/{id}', [\App\Modules\Inventory\Controllers\StoreRequisitionController::class, 'destroy'])->middleware('permission:store_requisitions.delete')->name('store-requisitions.destroy'); + }); + // 進貨單 (Goods Receipts) Route::middleware('permission:goods_receipts.view')->group(function () { Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index'); diff --git a/app/Modules/Inventory/Services/StoreRequisitionService.php b/app/Modules/Inventory/Services/StoreRequisitionService.php new file mode 100644 index 0000000..d290aff --- /dev/null +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -0,0 +1,247 @@ +transferService = $transferService; + } + + /** + * 建立叫貨單(含明細) + */ + public function create(array $data, array $items, int $userId): StoreRequisition + { + return DB::transaction(function () use ($data, $items, $userId) { + $requisition = StoreRequisition::create([ + 'store_warehouse_id' => $data['store_warehouse_id'], + 'status' => 'draft', + 'remark' => $data['remark'] ?? null, + 'created_by' => $userId, + ]); + + foreach ($items as $item) { + $requisition->items()->create([ + 'product_id' => $item['product_id'], + 'requested_qty' => $item['requested_qty'], + 'remark' => $item['remark'] ?? null, + ]); + } + + return $requisition->load('items'); + }); + } + + /** + * 更新叫貨單(僅限 draft / rejected 狀態) + */ + public function update(StoreRequisition $requisition, array $data, array $items): StoreRequisition + { + if (!in_array($requisition->status, ['draft', 'rejected'])) { + throw ValidationException::withMessages([ + 'status' => '僅能編輯草稿或被駁回的叫貨單', + ]); + } + + return DB::transaction(function () use ($requisition, $data, $items) { + $requisition->update([ + 'store_warehouse_id' => $data['store_warehouse_id'], + 'remark' => $data['remark'] ?? null, + 'reject_reason' => null, // 清除駁回原因 + ]); + + // 重建明細 + $requisition->items()->delete(); + foreach ($items as $item) { + $requisition->items()->create([ + 'product_id' => $item['product_id'], + 'requested_qty' => $item['requested_qty'], + 'remark' => $item['remark'] ?? null, + ]); + } + + return $requisition->load('items'); + }); + } + + /** + * 提交審核(draft → pending) + */ + public function submit(StoreRequisition $requisition, int $userId): StoreRequisition + { + if ($requisition->status !== 'draft' && $requisition->status !== 'rejected') { + throw ValidationException::withMessages([ + 'status' => '僅能提交草稿或被駁回的叫貨單', + ]); + } + + if ($requisition->items()->count() === 0) { + throw ValidationException::withMessages([ + 'items' => '叫貨單必須至少有一項商品', + ]); + } + + $requisition->update([ + 'status' => 'pending', + 'submitted_at' => now(), + 'reject_reason' => null, + ]); + + // 通知有審核權限的使用者 + $this->notifyApprovers($requisition, 'submitted', $userId); + + return $requisition; + } + + /** + * 核准叫貨單(pending → approved),選擇供貨倉庫並自動產生調撥單 + */ + public function approve(StoreRequisition $requisition, array $data, int $userId): StoreRequisition + { + if ($requisition->status !== 'pending') { + throw ValidationException::withMessages([ + 'status' => '僅能核准待審核的叫貨單', + ]); + } + + return DB::transaction(function () use ($requisition, $data, $userId) { + // 更新核准數量 + if (isset($data['items'])) { + foreach ($data['items'] as $itemData) { + StoreRequisitionItem::where('id', $itemData['id']) + ->where('store_requisition_id', $requisition->id) + ->update(['approved_qty' => $itemData['approved_qty']]); + } + } + + // 查詢供貨倉庫是否有預設在途倉 + $supplyWarehouse = \App\Modules\Inventory\Models\Warehouse::find($data['supply_warehouse_id']); + $defaultTransitId = $supplyWarehouse?->default_transit_warehouse_id; + + // 產生調撥單(供貨倉庫 → 門市倉庫) + $transferOrder = $this->transferService->createOrder( + fromWarehouseId: $data['supply_warehouse_id'], + toWarehouseId: $requisition->store_warehouse_id, + remarks: "由叫貨單 {$requisition->doc_no} 自動產生", + userId: $userId, + transitWarehouseId: $defaultTransitId, + ); + + // 將核准的明細寫入調撥單 + $requisition->load('items'); + $transferItems = []; + foreach ($requisition->items as $item) { + $qty = $item->approved_qty ?? $item->requested_qty; + if ($qty > 0) { + $transferItems[] = [ + 'product_id' => $item->product_id, + 'quantity' => $qty, + ]; + } + } + + if (!empty($transferItems)) { + $this->transferService->updateItems($transferOrder, $transferItems); + } + + // 更新叫貨單狀態 + $requisition->update([ + 'status' => 'approved', + 'supply_warehouse_id' => $data['supply_warehouse_id'], + 'approved_by' => $userId, + 'approved_at' => now(), + 'transfer_order_id' => $transferOrder->id, + ]); + + // 通知申請人 + $this->notifyCreator($requisition, 'approved', $userId); + + return $requisition->load(['items', 'transferOrder']); + }); + } + + /** + * 駁回叫貨單(pending → rejected) + */ + public function reject(StoreRequisition $requisition, string $reason, int $userId): StoreRequisition + { + if ($requisition->status !== 'pending') { + throw ValidationException::withMessages([ + 'status' => '僅能駁回待審核的叫貨單', + ]); + } + + $requisition->update([ + 'status' => 'rejected', + 'reject_reason' => $reason, + 'approved_by' => $userId, + 'approved_at' => now(), + ]); + + // 通知申請人 + $this->notifyCreator($requisition, 'rejected', $userId); + + return $requisition; + } + + /** + * 取消叫貨單 + */ + public function cancel(StoreRequisition $requisition): StoreRequisition + { + if (!in_array($requisition->status, ['draft', 'pending'])) { + throw ValidationException::withMessages([ + 'status' => '僅能取消草稿或待審核的叫貨單', + ]); + } + + $requisition->update(['status' => 'cancelled']); + + return $requisition; + } + + /** + * 通知有審核權限的使用者 + */ + protected function notifyApprovers(StoreRequisition $requisition, string $action, int $actorId): void + { + $actor = User::find($actorId); + $actorName = $actor?->name ?? 'System'; + + // 找出有 store_requisitions.approve 權限的使用者 + $approvers = User::permission('store_requisitions.approve')->get(); + + foreach ($approvers as $approver) { + if ($approver->id !== $actorId) { + $approver->notify(new StoreRequisitionNotification($requisition, $action, $actorName)); + } + } + } + + /** + * 通知叫貨單申請人 + */ + protected function notifyCreator(StoreRequisition $requisition, string $action, int $actorId): void + { + $actor = User::find($actorId); + $actorName = $actor?->name ?? 'System'; + + $creator = User::find($requisition->created_by); + if ($creator && $creator->id !== $actorId) { + $creator->notify(new StoreRequisitionNotification($requisition, $action, $actorName)); + } + } +} diff --git a/app/Modules/Inventory/Services/TransferService.php b/app/Modules/Inventory/Services/TransferService.php index 5f00819..c1631b2 100644 --- a/app/Modules/Inventory/Services/TransferService.php +++ b/app/Modules/Inventory/Services/TransferService.php @@ -14,27 +14,32 @@ class TransferService /** * 建立調撥單草稿 */ - public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId): InventoryTransferOrder + public function createOrder(int $fromWarehouseId, int $toWarehouseId, ?string $remarks, int $userId, ?int $transitWarehouseId = null): InventoryTransferOrder { + // 若未指定在途倉,嘗試使用來源倉庫的預設在途倉 (一次性設定) + if (is_null($transitWarehouseId)) { + $fromWarehouse = Warehouse::find($fromWarehouseId); + if ($fromWarehouse && $fromWarehouse->default_transit_warehouse_id) { + $transitWarehouseId = $fromWarehouse->default_transit_warehouse_id; + } + } + return InventoryTransferOrder::create([ 'from_warehouse_id' => $fromWarehouseId, 'to_warehouse_id' => $toWarehouseId, + 'transit_warehouse_id' => $transitWarehouseId, 'status' => 'draft', 'remarks' => $remarks, 'created_by' => $userId, ]); } - /** - * 更新調撥單明細 - */ /** * 更新調撥單明細 (支援精確 Diff 與自動日誌整合) */ public function updateItems(InventoryTransferOrder $order, array $itemsData): bool { return DB::transaction(function () use ($order, $itemsData) { - // 1. 準備舊資料索引 (Key: product_id . '_' . batch_number) $oldItemsMap = $order->items->mapWithKeys(function ($item) { $key = $item->product_id . '_' . ($item->batch_number ?? ''); return [$key => $item]; @@ -46,13 +51,7 @@ class TransferService 'updated' => [], ]; - // 2. 處理新資料 (Deleted and Re-inserted currently for simplicity, but logic simulates update) - // 為了保持 ID 當作外鍵的穩定性,最佳做法是 update 存在的,create 新的,delete 舊的。 - // 但考量現有邏輯是 delete all -> create all,我們維持原策略但優化 Diff 計算。 - - // 由於採用全刪重建,我們必須手動計算 Diff $order->items()->delete(); - $newItemsKeys = []; foreach ($itemsData as $data) { @@ -66,13 +65,10 @@ class TransferService 'position' => $data['position'] ?? null, 'notes' => $data['notes'] ?? null, ]); - // Eager load product for name $item->load('product'); - // 比對邏輯 if ($oldItemsMap->has($key)) { $oldItem = $oldItemsMap->get($key); - // 檢查數值是否有變動 if ((float)$oldItem->quantity !== (float)$data['quantity'] || $oldItem->notes !== ($data['notes'] ?? null) || $oldItem->position !== ($data['position'] ?? null)) { @@ -92,7 +88,6 @@ class TransferService ]; } } else { - // 新增 (使用者需求:顯示為更新,從 0 -> X) $diff['updated'][] = [ 'product_name' => $item->product->name, 'old' => [ @@ -107,7 +102,6 @@ class TransferService } } - // 3. 處理被移除的項目 foreach ($oldItemsMap as $key => $oldItem) { if (!in_array($key, $newItemsKeys)) { $diff['removed'][] = [ @@ -120,7 +114,6 @@ class TransferService } } - // 4. 將 Diff 注入到 Model 的暫存屬性中 $hasChanged = !empty($diff['added']) || !empty($diff['removed']) || !empty($diff['updated']); if ($hasChanged) { $order->activityProperties['items_diff'] = $diff; @@ -131,16 +124,24 @@ class TransferService } /** - * 過帳 (Post) - 執行調撥 (直接扣除來源,增加目的) + * 出貨 (Dispatch) - 根據是否有在途倉決定流程 + * + * 有在途倉:來源倉扣除 → 在途倉增加,狀態改為 dispatched + * 無在途倉:來源倉扣除 → 目的倉增加,狀態改為 completed(維持原有邏輯) */ - public function post(InventoryTransferOrder $order, int $userId): void + public function dispatch(InventoryTransferOrder $order, int $userId): void { - // [IMPORTANT] 強制重新載入品項,因為在 Controller 中可能剛執行過 updateItems,導致記憶體中快取的 items 是舊的或空的 $order->load('items.product'); DB::transaction(function () use ($order, $userId) { $fromWarehouse = $order->fromWarehouse; - $toWarehouse = $order->toWarehouse; + $hasTransit = !empty($order->transit_warehouse_id); + + $targetWarehouseId = $hasTransit ? $order->transit_warehouse_id : $order->to_warehouse_id; + $targetWarehouse = $hasTransit ? $order->transitWarehouse : $order->toWarehouse; + + $outType = '調撥出庫'; + $inType = $hasTransit ? '在途入庫' : '調撥入庫'; foreach ($order->items as $item) { if ($item->quantity <= 0) continue; @@ -162,46 +163,41 @@ class TransferService $oldSourceQty = $sourceInventory->quantity; $newSourceQty = $oldSourceQty - $item->quantity; - // 儲存庫存快照 $item->update(['snapshot_quantity' => $oldSourceQty]); $sourceInventory->quantity = $newSourceQty; - // 更新總值 (假設成本不變) $sourceInventory->total_value = $sourceInventory->quantity * $sourceInventory->unit_cost; $sourceInventory->save(); - // 記錄來源交易 $sourceInventory->transactions()->create([ - 'type' => '調撥出庫', + 'type' => $outType, 'quantity' => -$item->quantity, 'unit_cost' => $sourceInventory->unit_cost, 'balance_before' => $oldSourceQty, 'balance_after' => $newSourceQty, - 'reason' => "調撥單 {$order->doc_no} 至 {$toWarehouse->name}", + 'reason' => "調撥單 {$order->doc_no} 至 {$targetWarehouse->name}", 'actual_time' => now(), 'user_id' => $userId, ]); - // 2. 處理目的倉 (增加) + // 2. 處理目的倉/在途倉 (增加) $targetInventory = Inventory::firstOrCreate( [ - 'warehouse_id' => $order->to_warehouse_id, + 'warehouse_id' => $targetWarehouseId, 'product_id' => $item->product_id, 'batch_number' => $item->batch_number, - 'location' => $item->position, // 同步貨道至庫存位置 + 'location' => $hasTransit ? null : ($item->position ?? null), ], [ 'quantity' => 0, - 'unit_cost' => $sourceInventory->unit_cost, // 繼承成本 + 'unit_cost' => $sourceInventory->unit_cost, 'total_value' => 0, - // 繼承其他屬性 'expiry_date' => $sourceInventory->expiry_date, 'quality_status' => $sourceInventory->quality_status, 'origin_country' => $sourceInventory->origin_country, ] ); - // 若是新建立的,且成本為0,確保繼承成本 if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) { $targetInventory->unit_cost = $sourceInventory->unit_cost; } @@ -213,9 +209,8 @@ class TransferService $targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; $targetInventory->save(); - // 記錄目的交易 $targetInventory->transactions()->create([ - 'type' => '調撥入庫', + 'type' => $inType, 'quantity' => $item->quantity, 'unit_cost' => $targetInventory->unit_cost, 'balance_before' => $oldTargetQty, @@ -226,28 +221,126 @@ class TransferService ]); } - // 準備品項快照供日誌使用 - $itemsSnapshot = $order->items->map(function($item) { - return [ - 'product_name' => $item->product->name, - 'old' => [ - 'quantity' => (float)$item->quantity, - 'notes' => $item->notes, + if ($hasTransit) { + $order->status = 'dispatched'; + $order->dispatched_at = now(); + $order->dispatched_by = $userId; + } else { + $order->status = 'completed'; + $order->posted_at = now(); + $order->posted_by = $userId; + } + $order->save(); + }); + } + + /** + * 收貨確認 (Receive) - 在途倉扣除 → 目的倉增加 + * 僅適用於有在途倉且狀態為 dispatched 的調撥單 + */ + public function receive(InventoryTransferOrder $order, int $userId): void + { + if ($order->status !== 'dispatched') { + throw new \Exception('僅能對已出貨的調撥單進行收貨確認'); + } + + if (empty($order->transit_warehouse_id)) { + throw new \Exception('此調撥單未設定在途倉庫'); + } + + $order->load('items.product'); + + DB::transaction(function () use ($order, $userId) { + $transitWarehouse = $order->transitWarehouse; + $toWarehouse = $order->toWarehouse; + + foreach ($order->items as $item) { + if ($item->quantity <= 0) continue; + + // 1. 在途倉扣除 + $transitInventory = Inventory::where('warehouse_id', $order->transit_warehouse_id) + ->where('product_id', $item->product_id) + ->where('batch_number', $item->batch_number) + ->first(); + + if (!$transitInventory || $transitInventory->quantity < $item->quantity) { + $availableQty = $transitInventory->quantity ?? 0; + throw ValidationException::withMessages([ + 'items' => ["商品 {$item->product->name} 在途倉庫存不足。現有:{$availableQty},需要:{$item->quantity}。"], + ]); + } + + $oldTransitQty = $transitInventory->quantity; + $newTransitQty = $oldTransitQty - $item->quantity; + + $transitInventory->quantity = $newTransitQty; + $transitInventory->total_value = $transitInventory->quantity * $transitInventory->unit_cost; + $transitInventory->save(); + + $transitInventory->transactions()->create([ + 'type' => '在途出庫', + 'quantity' => -$item->quantity, + 'unit_cost' => $transitInventory->unit_cost, + 'balance_before' => $oldTransitQty, + 'balance_after' => $newTransitQty, + 'reason' => "調撥單 {$order->doc_no} 配送至 {$toWarehouse->name}", + 'actual_time' => now(), + 'user_id' => $userId, + ]); + + // 2. 目的倉增加 + $targetInventory = Inventory::firstOrCreate( + [ + 'warehouse_id' => $order->to_warehouse_id, + 'product_id' => $item->product_id, + 'batch_number' => $item->batch_number, + 'location' => $item->position, ], - 'new' => [ - 'quantity' => (float)$item->quantity, - 'notes' => $item->notes, + [ + 'quantity' => 0, + 'unit_cost' => $transitInventory->unit_cost, + 'total_value' => 0, + 'expiry_date' => $transitInventory->expiry_date, + 'quality_status' => $transitInventory->quality_status, + 'origin_country' => $transitInventory->origin_country, ] - ]; - })->toArray(); + ); + + if ($targetInventory->wasRecentlyCreated && $targetInventory->unit_cost == 0) { + $targetInventory->unit_cost = $transitInventory->unit_cost; + } + + $oldTargetQty = $targetInventory->quantity; + $newTargetQty = $oldTargetQty + $item->quantity; + + $targetInventory->quantity = $newTargetQty; + $targetInventory->total_value = $targetInventory->quantity * $targetInventory->unit_cost; + $targetInventory->save(); + + $targetInventory->transactions()->create([ + 'type' => '調撥入庫', + 'quantity' => $item->quantity, + 'unit_cost' => $targetInventory->unit_cost, + 'balance_before' => $oldTargetQty, + 'balance_after' => $newTargetQty, + 'reason' => "調撥單 {$order->doc_no} 來自 {$transitWarehouse->name}", + 'actual_time' => now(), + 'user_id' => $userId, + ]); + } $order->status = 'completed'; $order->posted_at = now(); $order->posted_by = $userId; - $order->save(); // 觸發自動日誌 + $order->received_at = now(); + $order->received_by = $userId; + $order->save(); }); } + /** + * 作廢 (Void) - 僅限草稿狀態 + */ public function void(InventoryTransferOrder $order, int $userId): void { if ($order->status !== 'draft') { diff --git a/database/migrations/tenant/2026_02_13_090000_create_store_requisitions_table.php b/database/migrations/tenant/2026_02_13_090000_create_store_requisitions_table.php new file mode 100644 index 0000000..1800cf9 --- /dev/null +++ b/database/migrations/tenant/2026_02_13_090000_create_store_requisitions_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('doc_no')->unique()->comment('單號 SR-YYYYMMDD-XX'); + $table->unsignedBigInteger('store_warehouse_id')->comment('申請倉庫(任意類型)'); + $table->unsignedBigInteger('supply_warehouse_id')->nullable()->comment('供貨倉庫(審核時填入)'); + $table->enum('status', ['draft', 'pending', 'approved', 'rejected', 'completed', 'cancelled']) + ->default('draft'); + $table->text('remark')->nullable()->comment('申請備註'); + $table->text('reject_reason')->nullable()->comment('駁回原因'); + $table->unsignedBigInteger('created_by')->comment('申請人'); + $table->unsignedBigInteger('approved_by')->nullable()->comment('審核人'); + $table->timestamp('submitted_at')->nullable()->comment('提交時間'); + $table->timestamp('approved_at')->nullable()->comment('審核時間'); + $table->unsignedBigInteger('transfer_order_id')->nullable()->comment('關聯調撥單'); + $table->timestamps(); + + $table->index('status'); + $table->index('store_warehouse_id'); + $table->index('created_by'); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_requisitions'); + } +}; diff --git a/database/migrations/tenant/2026_02_13_090100_create_store_requisition_items_table.php b/database/migrations/tenant/2026_02_13_090100_create_store_requisition_items_table.php new file mode 100644 index 0000000..99c0d19 --- /dev/null +++ b/database/migrations/tenant/2026_02_13_090100_create_store_requisition_items_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_requisition_id')->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('product_id'); + $table->decimal('requested_qty', 12, 2)->comment('需求數量'); + $table->decimal('approved_qty', 12, 2)->nullable()->comment('核准數量(審核時填入)'); + $table->text('remark')->nullable(); + $table->timestamps(); + + $table->index('product_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_requisition_items'); + } +}; diff --git a/database/migrations/tenant/2026_02_13_110000_add_transit_to_transfer_orders.php b/database/migrations/tenant/2026_02_13_110000_add_transit_to_transfer_orders.php new file mode 100644 index 0000000..4f53199 --- /dev/null +++ b/database/migrations/tenant/2026_02_13_110000_add_transit_to_transfer_orders.php @@ -0,0 +1,50 @@ +foreignId('transit_warehouse_id') + ->nullable() + ->after('to_warehouse_id') + ->constrained('warehouses') + ->nullOnDelete(); + + // 出貨資訊 + $table->timestamp('dispatched_at')->nullable()->after('posted_at'); + $table->foreignId('dispatched_by')->nullable()->after('dispatched_at')->constrained('users')->nullOnDelete(); + + // 收貨確認資訊 + $table->timestamp('received_at')->nullable()->after('dispatched_by'); + $table->foreignId('received_by')->nullable()->after('received_at')->constrained('users')->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_transfer_orders', function (Blueprint $table) { + $table->dropForeign(['transit_warehouse_id']); + $table->dropForeign(['dispatched_by']); + $table->dropForeign(['received_by']); + $table->dropColumn([ + 'transit_warehouse_id', + 'dispatched_at', + 'dispatched_by', + 'received_at', + 'received_by', + ]); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_13_110100_add_default_transit_warehouse_to_warehouses.php b/database/migrations/tenant/2026_02_13_110100_add_default_transit_warehouse_to_warehouses.php new file mode 100644 index 0000000..b77647a --- /dev/null +++ b/database/migrations/tenant/2026_02_13_110100_add_default_transit_warehouse_to_warehouses.php @@ -0,0 +1,33 @@ +foreignId('default_transit_warehouse_id') + ->nullable() + ->after('driver_name') + ->constrained('warehouses') + ->nullOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('warehouses', function (Blueprint $table) { + $table->dropForeign(['default_transit_warehouse_id']); + $table->dropColumn('default_transit_warehouse_id'); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_13_120000_add_default_transit_to_warehouses.php b/database/migrations/tenant/2026_02_13_120000_add_default_transit_to_warehouses.php new file mode 100644 index 0000000..b06e7fe --- /dev/null +++ b/database/migrations/tenant/2026_02_13_120000_add_default_transit_to_warehouses.php @@ -0,0 +1,36 @@ +foreignId('default_transit_warehouse_id') + ->nullable() + ->after('driver_name') + ->comment('預設使用的在途倉(物流車)') + ->constrained('warehouses') + ->nullOnDelete(); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('warehouses', function (Blueprint $table) { + $table->dropForeign(['default_transit_warehouse_id']); + $table->dropColumn('default_transit_warehouse_id'); + }); + } +}; diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 7ab10d1..5ecfb78 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -55,6 +55,8 @@ class PermissionSeeder extends Seeder 'inventory_transfer.create' => '建立', 'inventory_transfer.edit' => '編輯', 'inventory_transfer.delete' => '刪除', + 'inventory_transfer.dispatch' => '確認出貨', + 'inventory_transfer.receive' => '確認收貨', // 庫存報表 'inventory_report.view' => '檢視', @@ -129,6 +131,14 @@ class PermissionSeeder extends Seeder 'sales_imports.create' => '建立', 'sales_imports.confirm' => '確認', 'sales_imports.delete' => '刪除', + + // 門市叫貨申請 + 'store_requisitions.view' => '檢視', + 'store_requisitions.create' => '建立', + 'store_requisitions.edit' => '編輯', + 'store_requisitions.delete' => '刪除', + 'store_requisitions.approve' => '核準', + 'store_requisitions.cancel' => '取消', ]; foreach ($permissions as $name => $displayName) { @@ -158,7 +168,7 @@ class PermissionSeeder extends Seeder 'inventory.view', 'inventory.view_cost', 'inventory.delete', 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', - 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', + 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive', 'inventory_report.view', 'inventory_report.export', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete', @@ -172,6 +182,8 @@ class PermissionSeeder extends Seeder 'utility_fees.view', 'utility_fees.create', 'utility_fees.edit', 'utility_fees.delete', 'accounting.view', 'accounting.export', 'sales_imports.view', 'sales_imports.create', 'sales_imports.confirm', 'sales_imports.delete', + 'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit', + 'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel', ]); // warehouse-manager 管理庫存與倉庫 @@ -180,12 +192,14 @@ class PermissionSeeder extends Seeder 'inventory.view', 'inventory.delete', 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', - 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', + 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', 'inventory_transfer.dispatch', 'inventory_transfer.receive', 'inventory_report.view', 'inventory_report.export', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'production_orders.view', 'production_orders.create', 'production_orders.edit', 'warehouses.view', 'warehouses.create', 'warehouses.edit', + 'store_requisitions.view', 'store_requisitions.create', 'store_requisitions.edit', + 'store_requisitions.delete', 'store_requisitions.approve', 'store_requisitions.cancel', ]); // purchaser 管理採購與供應商 diff --git a/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx b/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx index 866cf21..c1af734 100644 --- a/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx +++ b/resources/js/Components/Inventory/GoodsReceiptStatusBadge.tsx @@ -1,9 +1,9 @@ -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; export type GoodsReceiptStatus = 'processing' | 'completed' | 'cancelled'; -export const GOODS_RECEIPT_STATUS_CONFIG: Record = { - processing: { label: "處理中", variant: "warning" }, +export const GOODS_RECEIPT_STATUS_CONFIG: Record = { + processing: { label: "處理中", variant: "info" }, completed: { label: "已完成", variant: "success" }, cancelled: { label: "已取消", variant: "destructive" }, }; @@ -19,28 +19,9 @@ export default function GoodsReceiptStatusBadge({ }: GoodsReceiptStatusBadgeProps) { const config = GOODS_RECEIPT_STATUS_CONFIG[status] || { label: "未知", variant: "outline" }; - // Apply custom styling based on variant mapping if not using standard badge variants - let badgeClass = ""; - switch (config.variant) { - case "success": - badgeClass = "bg-green-100 text-green-800 hover:bg-green-200 border-green-200"; - break; - case "warning": - badgeClass = "bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border-yellow-200"; - break; - case "destructive": - badgeClass = "bg-red-100 text-red-800 hover:bg-red-200 border-red-200"; - break; - default: - badgeClass = "bg-gray-100 text-gray-800 hover:bg-gray-200 border-gray-200"; - } - return ( - + {config.label} - + ); } diff --git a/resources/js/Components/Inventory/InventoryTable.tsx b/resources/js/Components/Inventory/InventoryTable.tsx index 7af4212..c18b71f 100644 --- a/resources/js/Components/Inventory/InventoryTable.tsx +++ b/resources/js/Components/Inventory/InventoryTable.tsx @@ -4,7 +4,7 @@ */ import { useState } from "react"; -import { AlertTriangle, Edit, ChevronDown, ChevronRight, CheckCircle, Package } from "lucide-react"; +import { Edit, ChevronDown, ChevronRight, Package } from "lucide-react"; import { Table, TableBody, @@ -14,14 +14,14 @@ import { TableRow, } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/Components/ui/collapsible"; import { WarehouseInventory, SafetyStockSetting } from "@/types/warehouse"; -import { calculateProductTotalStock, getSafetyStockStatus } from "@/utils/inventory"; +import { getSafetyStockStatus } from "@/utils/inventory"; import { formatDate } from "@/utils/format"; export type InventoryItemWithId = WarehouseInventory & { inventoryId: string }; @@ -74,31 +74,28 @@ export default function InventoryTable({ // 獲取狀態徽章 const getStatusBadge = (status: string) => { - switch (status) { - case "正常": - return ( - - - 正常 - - ); - case "接近": - return ( - - - 接近 - - ); - case "低於": - return ( - - - 低於 - - ); - default: - return null; + if (status === '正常') { + return ( + + 庫存充足 + + ); } + if (status === '接近') { + return ( + + 低於安全存量 + + ); + } + if (status === '低於') { + return ( + + 嚴重短缺 + + ); + } + return null; }; return ( @@ -108,12 +105,12 @@ export default function InventoryTable({ (sum, item) => sum + item.quantity, 0 ); - + // 計算安全庫存狀態 const status = group.safetySetting ? getSafetyStockStatus(totalQuantity, group.safetySetting.safetyStock) : null; - + const isLowStock = status === "低於"; const isExpanded = expandedProducts.has(group.productId); const hasInventory = group.items.length > 0; @@ -127,10 +124,9 @@ export default function InventoryTable({
{/* 商品標題 - 可點擊折疊 */} -
@@ -164,9 +160,9 @@ export default function InventoryTable({ )} {!group.safetySetting && ( - + 未設定 - + )}
diff --git a/resources/js/Components/Product/ProductTable.tsx b/resources/js/Components/Product/ProductTable.tsx index 013ad0c..63a028b 100644 --- a/resources/js/Components/Product/ProductTable.tsx +++ b/resources/js/Components/Product/ProductTable.tsx @@ -7,7 +7,7 @@ import { TableRow, } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Pencil, Trash2, ArrowUpDown, ArrowUp, ArrowDown, Eye } from "lucide-react"; import { Tooltip, @@ -122,15 +122,15 @@ export default function ProductTable({
{product.name} - {product.brand && {product.brand}} + {product.brand && {product.brand}}
代號: {product.code}
- + {product.category?.name || '-'} - + {product.baseUnit?.name || '-'} @@ -163,9 +163,9 @@ export default function ProductTable({ {product.is_active ? ( - 啟用 + 啟用 ) : ( - 停用 + 停用 )} diff --git a/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx b/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx index 54414bb..0822c89 100644 --- a/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx +++ b/resources/js/Components/ProductionOrder/ProductionOrderStatusBadge.tsx @@ -1,8 +1,4 @@ -/** - * 生產工單狀態標籤組件 - */ - -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; import { ProductionOrderStatus, STATUS_CONFIG } from "@/constants/production-order"; interface ProductionOrderStatusBadgeProps { @@ -16,31 +12,31 @@ export default function ProductionOrderStatusBadge({ }: ProductionOrderStatusBadgeProps) { const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" }; - const getStatusStyles = (status: string) => { + const getVariant = (status: string): StatusVariant => { switch (status) { case 'draft': - return 'bg-gray-100 text-gray-600 border-gray-200'; + return 'neutral'; case 'pending': - return 'bg-blue-50 text-blue-600 border-blue-200'; + return 'warning'; case 'approved': - return 'bg-primary text-primary-foreground border-transparent'; + return 'success'; case 'in_progress': - return 'bg-amber-50 text-amber-600 border-amber-200'; + return 'info'; case 'completed': - return 'bg-primary text-primary-foreground border-transparent transition-all shadow-sm'; + return 'success'; case 'cancelled': - return 'bg-destructive text-destructive-foreground border-transparent'; + return 'destructive'; default: - return 'bg-gray-50 text-gray-500 border-gray-200'; + return 'neutral'; } }; return ( - {config.label} - + ); } diff --git a/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx b/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx index 7a017a0..ebd4551 100644 --- a/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx +++ b/resources/js/Components/PurchaseOrder/PurchaseOrderStatusBadge.tsx @@ -2,7 +2,7 @@ * 採購單狀態標籤組件 */ -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { PurchaseOrderStatus } from "@/types/purchase-order"; import { STATUS_CONFIG } from "@/constants/purchase-order"; @@ -15,14 +15,11 @@ export default function PurchaseOrderStatusBadge({ status, className, }: PurchaseOrderStatusBadgeProps) { - const config = STATUS_CONFIG[status] || { label: "未知", variant: "outline" }; + const config = STATUS_CONFIG[status] || { label: "未知", variant: "neutral" }; return ( - + {config.label} - + ); } diff --git a/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx b/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx index fccd479..c990454 100644 --- a/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx +++ b/resources/js/Components/SafetyStock/EditSafetyStockDialog.tsx @@ -16,7 +16,7 @@ import { Input } from "@/Components/ui/input"; import { Label } from "@/Components/ui/label"; import { SafetyStockSetting } from "@/types/warehouse"; import { toast } from "sonner"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; interface EditSafetyStockDialogProps { open: boolean; @@ -66,7 +66,7 @@ export default function EditSafetyStockDialog({
{setting.productName} - {setting.productType} + {setting.productType}
diff --git a/resources/js/Components/SafetyStock/SafetyStockList.tsx b/resources/js/Components/SafetyStock/SafetyStockList.tsx index 3b7a5e7..06d04df 100644 --- a/resources/js/Components/SafetyStock/SafetyStockList.tsx +++ b/resources/js/Components/SafetyStock/SafetyStockList.tsx @@ -2,7 +2,7 @@ * 安全庫存列表組件 */ -import { Edit, Trash2, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react"; +import { Trash2, Pencil } from "lucide-react"; import { Table, TableBody, @@ -13,7 +13,7 @@ import { } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; import { SafetyStockSetting, WarehouseInventory, SafetyStockStatus } from "@/types/warehouse"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; interface SafetyStockListProps { settings: SafetyStockSetting[]; @@ -35,29 +35,28 @@ function getSafetyStockStatus( // 獲取狀態徽章 function getStatusBadge(status: SafetyStockStatus) { - switch (status) { - case "正常": - return ( - - - 正常 - - ); - case "接近": - return ( - - - 接近 - - ); - case "低於": - return ( - - - 低於 - - ); + if (status === '正常') { + return ( + + 正常 + + ); } + if (status === '接近') { + return ( + + 接近 + + ); + } + if (status === '低於') { + return ( + + 低於 + + ); + } + return null; // Should not happen if SafetyStockStatus is exhaustive } export default function SafetyStockList({ @@ -108,7 +107,7 @@ export default function SafetyStockList({ {index + 1} {setting.productName} - {setting.productType} + {setting.productType} @@ -126,7 +125,7 @@ export default function SafetyStockList({ onClick={() => onEdit(setting)} className="hover:bg-primary/10 hover:text-primary" > - + 編輯
@@ -199,9 +195,9 @@ export default function InventoryTable({
) : ( - + 未設定 - + )} {onViewProduct && (
- {WAREHOUSE_TYPE_LABELS[warehouse.type || 'standard'] || '標準倉'} {warehouse.type === 'quarantine' ? ' (不計入可用)' : ' (計入可用)'} - +
diff --git a/resources/js/Components/Warehouse/WarehouseDialog.tsx b/resources/js/Components/Warehouse/WarehouseDialog.tsx index 3e860fa..fab0c2a 100644 --- a/resources/js/Components/Warehouse/WarehouseDialog.tsx +++ b/resources/js/Components/Warehouse/WarehouseDialog.tsx @@ -32,12 +32,20 @@ import { validateWarehouse } from "@/utils/validation"; import { toast } from "sonner"; import { SearchableSelect } from "@/Components/ui/searchable-select"; +interface TransitWarehouseOption { + id: string; + name: string; + license_plate?: string; + driver_name?: string; +} + interface WarehouseDialogProps { open: boolean; onOpenChange: (open: boolean) => void; warehouse: Warehouse | null; onSave: (warehouse: Omit) => void; onDelete?: (warehouseId: string) => void; + transitWarehouses?: TransitWarehouseOption[]; } const WAREHOUSE_TYPE_OPTIONS: { label: string; value: WarehouseType }[] = [ @@ -55,6 +63,7 @@ export default function WarehouseDialog({ warehouse, onSave, onDelete, + transitWarehouses = [], }: WarehouseDialogProps) { const [formData, setFormData] = useState<{ code: string; @@ -64,6 +73,7 @@ export default function WarehouseDialog({ type: WarehouseType; license_plate: string; driver_name: string; + default_transit_warehouse_id: string | null; }>({ code: "", name: "", @@ -72,6 +82,7 @@ export default function WarehouseDialog({ type: "standard", license_plate: "", driver_name: "", + default_transit_warehouse_id: null, }); const [showDeleteDialog, setShowDeleteDialog] = useState(false); @@ -86,6 +97,7 @@ export default function WarehouseDialog({ type: warehouse.type || "standard", license_plate: warehouse.license_plate || "", driver_name: warehouse.driver_name || "", + default_transit_warehouse_id: warehouse.default_transit_warehouse_id ? String(warehouse.default_transit_warehouse_id) : null, }); } else { setFormData({ @@ -96,6 +108,7 @@ export default function WarehouseDialog({ type: "standard", license_plate: "", driver_name: "", + default_transit_warehouse_id: null, }); } }, [warehouse, open]); @@ -216,6 +229,32 @@ export default function WarehouseDialog({ )} + {/* 預設在途倉設定(僅非 transit 類型顯示) */} + {formData.type !== 'transit' && transitWarehouses.length > 0 && ( +
+
+

調撥配送設定

+
+
+ +

從此倉庫建立調撥單時,系統將自動帶入此在途倉作為配送中繼倉

+ setFormData({ ...formData, default_transit_warehouse_id: val || null })} + options={[ + { label: "不指定", value: "" }, + ...transitWarehouses.map((tw) => ({ + label: `${tw.name}${tw.license_plate ? ` (${tw.license_plate})` : ''}`, + value: tw.id, + })), + ]} + placeholder="選擇預設在途倉" + className="h-9 bg-white" + /> +
+
+ )} + {/* 區塊 B:位置 */} diff --git a/resources/js/Components/shared/StatusBadge.tsx b/resources/js/Components/shared/StatusBadge.tsx new file mode 100644 index 0000000..0655096 --- /dev/null +++ b/resources/js/Components/shared/StatusBadge.tsx @@ -0,0 +1,34 @@ +import { Badge } from "@/Components/ui/badge"; +import { cn } from "@/lib/utils"; + +export type StatusVariant = + | "neutral" + | "info" + | "warning" + | "success" + | "destructive"; + +interface StatusBadgeProps { + variant: StatusVariant; + children: React.ReactNode; + className?: string; +} + +const variantStyles: Record = { + neutral: "bg-gray-100 text-gray-800 border-gray-200 hover:bg-gray-100", // Draft, Cancelled(sometimes), Closed + info: "bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100", // Processing, Active + warning: "bg-amber-100 text-amber-800 border-amber-200 hover:bg-amber-100", // Pending, Review + success: "bg-green-100 text-green-800 border-green-200 hover:bg-green-100", // Completed, Approved + destructive: "bg-red-100 text-red-800 border-red-200 hover:bg-red-100", // Voided, Rejected, High Risk +}; + +export function StatusBadge({ variant, children, className }: StatusBadgeProps) { + return ( + + {children} + + ); +} diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index b2c5eac..348be2d 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -25,7 +25,8 @@ import { ClipboardCheck, ArrowLeftRight, TrendingUp, - FileUp + FileUp, + Store } from "lucide-react"; import { toast, Toaster } from "sonner"; import { useState, useEffect, useMemo, useRef } from "react"; @@ -131,6 +132,13 @@ export default function AuthenticatedLayout({ route: "/inventory/transfer-orders", permission: "inventory_transfer.view", }, + { + id: "store-requisition", + label: "門市叫貨", + icon: , + route: "/store-requisitions", + permission: "store_requisitions.view", + }, ], }, { diff --git a/resources/js/Pages/Dashboard.tsx b/resources/js/Pages/Dashboard.tsx index 8a6c16f..8c5f37d 100644 --- a/resources/js/Pages/Dashboard.tsx +++ b/resources/js/Pages/Dashboard.tsx @@ -16,7 +16,7 @@ import { TableHeader, TableRow, } from "@/Components/ui/table"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; import { Button } from "@/Components/ui/button"; interface AbnormalItem { @@ -103,6 +103,21 @@ export default function Dashboard({ stats, abnormalItems }: Props) { }, ]; + const getStatusVariant = (status: string): StatusVariant => { + switch (status) { + case 'negative': return 'destructive'; + case 'low_stock': return 'warning'; + case 'expiring': return 'warning'; + case 'expired': return 'destructive'; + default: return 'neutral'; + } + }; + + const getStatusLabel = (status: string): string => { + const config = statusConfig[status]; + return config ? config.label : status; + }; + return (
{item.statuses.map( - (status) => { - const config = - statusConfig[ - status - ]; - if (!config) - return null; - return ( - - {config.label} - - ); - } + (status) => ( + + {getStatusLabel(status)} + + ) )}
diff --git a/resources/js/Pages/Inventory/Adjust/Index.tsx b/resources/js/Pages/Inventory/Adjust/Index.tsx index d7ba412..ba4ff81 100644 --- a/resources/js/Pages/Inventory/Adjust/Index.tsx +++ b/resources/js/Pages/Inventory/Adjust/Index.tsx @@ -1,6 +1,8 @@ + import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; import { Head, useForm, router, Link } from '@inertiajs/react'; import { usePermission } from '@/hooks/usePermission'; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Table, TableBody, @@ -11,7 +13,6 @@ import { } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; -import { Badge } from "@/Components/ui/badge"; import { Dialog, DialogContent, @@ -167,13 +168,13 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat const getStatusBadge = (status: string) => { switch (status) { case 'draft': - return 草稿; + return 草稿; case 'posted': - return 已過帳; + return 已過帳; case 'voided': - return 已作廢; + return 已作廢; default: - return {status}; + return {status}; } }; @@ -257,10 +258,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat 單號 倉庫 調整原因 - 狀態 建立者 建立時間 過帳時間 + 狀態 操作 @@ -286,10 +287,10 @@ export default function Index({ docs, warehouses, filters }: { docs: DocsPaginat {doc.warehouse_name} {doc.reason} - {getStatusBadge(doc.status)} {doc.created_by} {doc.created_at} {doc.posted_at || '-'} + {getStatusBadge(doc.status)}
e.stopPropagation()}> {(() => { diff --git a/resources/js/Pages/Inventory/Adjust/Show.tsx b/resources/js/Pages/Inventory/Adjust/Show.tsx index fa7e7e8..4fd9163 100644 --- a/resources/js/Pages/Inventory/Adjust/Show.tsx +++ b/resources/js/Pages/Inventory/Adjust/Show.tsx @@ -11,7 +11,7 @@ import { } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Checkbox } from "@/Components/ui/checkbox"; import { AlertDialog, @@ -243,9 +243,9 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { 盤調單: {doc.doc_no} {isDraft ? ( - 草稿 + 草稿 ) : ( - 已過帳 + 已過帳 )}

@@ -604,6 +604,6 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) { - + ); } diff --git a/resources/js/Pages/Inventory/Count/Index.tsx b/resources/js/Pages/Inventory/Count/Index.tsx index 888666d..45c97e0 100644 --- a/resources/js/Pages/Inventory/Count/Index.tsx +++ b/resources/js/Pages/Inventory/Count/Index.tsx @@ -1,7 +1,9 @@ + import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head, Link, useForm, router } from '@inertiajs/react'; +import { Head, Link, router, useForm } from '@inertiajs/react'; import { useState, useCallback, useEffect } from 'react'; import { usePermission } from '@/hooks/usePermission'; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { debounce } from "lodash"; import { SearchableSelect } from "@/Components/ui/searchable-select"; import { @@ -14,7 +16,6 @@ import { } from '@/Components/ui/table'; import { Button } from '@/Components/ui/button'; import { Input } from '@/Components/ui/input'; -import { Badge } from '@/Components/ui/badge'; import { Dialog, DialogContent, @@ -138,19 +139,19 @@ export default function Index({ docs, warehouses, filters }: any) { const getStatusBadge = (status: string) => { switch (status) { case 'draft': - return 草稿; + return 草稿; case 'counting': - return 盤點中; + return 盤點中; case 'completed': - return 盤點完成; + return 盤點完成; case 'no_adjust': - return 盤點完成 (無需盤調); + return 盤點完成 (無需盤調); case 'adjusted': - return 已盤調庫存; + return 已盤調庫存; // Decided on info/blue for adjusted to match "active/done" but distinctive from pure success if needed, or stick to success? Plan said Info/Blue. case 'cancelled': - return 已取消; + return 已取消; default: - return {status}; + return {status}; } }; @@ -273,11 +274,11 @@ export default function Index({ docs, warehouses, filters }: any) { # 單號 倉庫 - 狀態 快照時間 盤點進度 完成時間 建立人員 + 狀態 操作 @@ -296,7 +297,6 @@ export default function Index({ docs, warehouses, filters }: any) { {doc.doc_no} {doc.warehouse_name} - {getStatusBadge(doc.status)} {doc.snapshot_date} {doc.counted_items} @@ -305,6 +305,7 @@ export default function Index({ docs, warehouses, filters }: any) { {doc.completed_at || '-'} {doc.created_by} + {getStatusBadge(doc.status)}

{/* Action Button Logic: Prefer Edit if allowed and status is active, otherwise fallback to View if allowed */} diff --git a/resources/js/Pages/Inventory/Count/Show.tsx b/resources/js/Pages/Inventory/Count/Show.tsx index 899dcbb..f777e4b 100644 --- a/resources/js/Pages/Inventory/Count/Show.tsx +++ b/resources/js/Pages/Inventory/Count/Show.tsx @@ -11,7 +11,7 @@ import { } from '@/Components/ui/table'; import { Button } from '@/Components/ui/button'; import { Input } from '@/Components/ui/input'; -import { Badge } from '@/Components/ui/badge'; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Save, Printer, Trash2, ClipboardCheck, ArrowLeft, RotateCcw } from 'lucide-react'; // Added ArrowLeft import { AlertDialog, @@ -121,16 +121,16 @@ export default function Show({ doc }: any) { 盤點單: {doc.doc_no} {doc.status === 'completed' && ( - 盤點完成 + 盤點完成 )} {doc.status === 'no_adjust' && ( - 盤點完成 (無需盤調) + 盤點完成 (無需盤調) )} {doc.status === 'adjusted' && ( - 已盤調庫存 + 已盤調庫存 )} {doc.status === 'draft' && ( - 盤點中 + 盤點中 )}

diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx index 5fdd967..7af8384 100644 --- a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx +++ b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx @@ -21,7 +21,7 @@ import { TableRow, } from '@/Components/ui/table'; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { @@ -395,9 +395,9 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, {po.code} {po.vendor_name} - + {STATUS_CONFIG[po.status]?.label || po.status} - + {po.items.length} 項 diff --git a/resources/js/Pages/Inventory/Report/Show.tsx b/resources/js/Pages/Inventory/Report/Show.tsx index a471625..0a4e3a5 100644 --- a/resources/js/Pages/Inventory/Report/Show.tsx +++ b/resources/js/Pages/Inventory/Report/Show.tsx @@ -10,7 +10,7 @@ import { TableRow, } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { ArrowLeft, FileText, Package } from "lucide-react"; import Pagination from "@/Components/shared/Pagination"; import { formatDate } from "@/utils/format"; @@ -69,17 +69,18 @@ interface ShowProps extends PageProps { export default function InventoryReportShow({ product, transactions, filters, reportFilters, warehouses }: ShowProps) { // 類型 Badge 顏色映射 - const getTypeBadgeVariant = (type: string) => { + // 類型 Badge 顏色映射 + const getTypeBadgeVariant = (type: string): "success" | "destructive" | "neutral" => { switch (type) { case '入庫': case '手動入庫': case '調撥入庫': - return "default"; + return "success"; case '出庫': case '調撥出庫': return "destructive"; default: - return "secondary"; + return "neutral"; } }; @@ -128,9 +129,9 @@ export default function InventoryReportShow({ product, transactions, filters, re

{product.name}

- + {product.code} - +
@@ -212,9 +213,9 @@ export default function InventoryReportShow({ product, transactions, filters, re {formatDate(tx.actual_time)} - + {tx.type} - + {tx.warehouse_name} 0 ? 'text-emerald-600' : diff --git a/resources/js/Pages/Inventory/StockQuery/Index.tsx b/resources/js/Pages/Inventory/StockQuery/Index.tsx index 79e4892..e77c4ef 100644 --- a/resources/js/Pages/Inventory/StockQuery/Index.tsx +++ b/resources/js/Pages/Inventory/StockQuery/Index.tsx @@ -20,7 +20,7 @@ import { TableHeader, TableRow, } from "@/Components/ui/table"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; import { SearchableSelect } from "@/Components/ui/searchable-select"; @@ -77,31 +77,31 @@ interface Props { categories: { id: number; name: string }[]; } -// 狀態 Badge -const statusConfig: Record< - string, - { label: string; className: string } -> = { - normal: { - label: "正常", - className: "bg-green-100 text-green-800 border-green-200", - }, - negative: { - label: "負庫存", - className: "bg-red-100 text-red-800 border-red-200", - }, - low_stock: { - label: "低庫存", - className: "bg-amber-100 text-amber-800 border-amber-200", - }, - expiring: { - label: "即將過期", - className: "bg-yellow-100 text-yellow-800 border-yellow-200", - }, - expired: { - label: "已過期", - className: "bg-red-100 text-red-800 border-red-200", - }, +// 狀態與樣式映射 +const getStatusVariant = (status: string): StatusVariant => { + switch (status) { + case 'negative': + case 'expired': + return 'destructive'; + case 'low_stock': + case 'expiring': + return 'warning'; + case 'normal': + return 'success'; + default: + return 'neutral'; + } +}; + +const getStatusLabel = (status: string): string => { + switch (status) { + case 'normal': return "正常"; + case 'negative': return "負庫存"; + case 'low_stock': return "低庫存"; + case 'expiring': return "即將過期"; + case 'expired': return "已過期"; + default: return status; + } }; // 狀態篩選選項 @@ -512,25 +512,14 @@ export default function StockQueryIndex({
{item.statuses.map( - (status) => { - const config = - statusConfig[ - status - ]; - if (!config) - return null; - return ( - - {config.label} - - ); - } + (status) => ( + + {getStatusLabel(status)} + + ) )}
diff --git a/resources/js/Pages/Inventory/Transfer/Index.tsx b/resources/js/Pages/Inventory/Transfer/Index.tsx index 6fa5ee3..98a70ae 100644 --- a/resources/js/Pages/Inventory/Transfer/Index.tsx +++ b/resources/js/Pages/Inventory/Transfer/Index.tsx @@ -4,6 +4,7 @@ import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, Link, router } from "@inertiajs/react"; import { debounce } from "lodash"; import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Table, TableBody, @@ -14,7 +15,6 @@ import { } from "@/Components/ui/table"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; -import { Badge } from "@/Components/ui/badge"; import { Dialog, DialogContent, @@ -160,13 +160,15 @@ export default function Index({ warehouses, orders, filters }: any) { const getStatusBadge = (status: string) => { switch (status) { case 'draft': - return 草稿; + return 草稿; + case 'dispatched': + return 配送中; case 'completed': - return 已完成; + return 已完成; case 'voided': - return 已作廢; + return 已作廢; default: - return {status}; + return {status}; } }; @@ -287,12 +289,12 @@ export default function Index({ warehouses, orders, filters }: any) { # 單號 - 狀態 來源倉庫 目的倉庫 建立日期 過帳日期 建立人員 + 狀態 操作 @@ -314,12 +316,12 @@ export default function Index({ warehouses, orders, filters }: any) { {(orders.current_page - 1) * orders.per_page + index + 1}
{order.doc_no} - {getStatusBadge(order.status)} {order.from_warehouse_name} {order.to_warehouse_name} {order.created_at} {order.posted_at || '-'} {order.created_by} + {getStatusBadge(order.status)}
e.stopPropagation()}> {(() => { diff --git a/resources/js/Pages/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 12faf29..3a476c7 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; -import { Head, router, Link } from "@inertiajs/react"; +import { Head, router, Link, usePage } from "@inertiajs/react"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; import { Label } from "@/Components/ui/label"; @@ -12,7 +12,7 @@ import { TableHeader, TableRow, } from "@/Components/ui/table"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Checkbox } from "@/Components/ui/checkbox"; import { Dialog, @@ -32,27 +32,86 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/Components/ui/alert-dialog"; -import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/Components/ui/select"; +import { Plus, Save, Trash2, ArrowLeft, CheckCircle, Package, ArrowLeftRight, Printer, Search, Truck, PackageCheck } from "lucide-react"; import { toast } from "sonner"; import axios from "axios"; import { Can } from '@/Components/Permission/Can'; import { usePermission } from '@/hooks/usePermission'; import TransferImportDialog from '@/Components/Transfer/TransferImportDialog'; -export default function Show({ order }: any) { +interface TransitWarehouse { + id: string; + name: string; + license_plate: string | null; + driver_name: string | null; +} + +export default function Show({ order, transitWarehouses = [] }: { order: any; transitWarehouses?: TransitWarehouse[] }) { const { can } = usePermission(); + const { url } = usePage(); + + // 解析 URL query 參數,判斷使用者從哪裡來 + const backNav = useMemo(() => { + const params = new URLSearchParams(url.split('?')[1] || ''); + const from = params.get('from'); + if (from === 'requisition') { + const fromId = params.get('from_id'); + const fromDoc = params.get('from_doc') || ''; + return { + href: route('store-requisitions.show', [fromId!]), + label: `返回叫貨單: ${decodeURIComponent(fromDoc)}`, + breadcrumbs: [ + { label: '商品與庫存管理', href: '#' }, + { label: '門市叫貨申請', href: route('store-requisitions.index') }, + { label: `叫貨單: ${decodeURIComponent(fromDoc)}`, href: route('store-requisitions.show', [fromId!]) }, + { label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true }, + ], + }; + } + return { + href: route('inventory.transfer.index'), + label: '返回調撥單列表', + breadcrumbs: [ + { label: '商品與庫存管理', href: '#' }, + { label: '庫存調撥', href: route('inventory.transfer.index') }, + { label: `調撥單: ${order.doc_no}`, href: route('inventory.transfer.show', [order.id]), isPage: true }, + ], + }; + }, [url, order]); + const [items, setItems] = useState(order.items || []); const [remarks, setRemarks] = useState(order.remarks || ""); + + // 狀態初始化 + const [transitWarehouseId, setTransitWarehouseId] = useState(order.transit_warehouse_id || null); + + + const [isSaving, setIsSaving] = useState(false); const [deleteId, setDeleteId] = useState(null); const [isPostDialogOpen, setIsPostDialogOpen] = useState(false); + const [isReceiveDialogOpen, setIsReceiveDialogOpen] = useState(false); const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); + // 判斷是否有在途倉流程 (包含前端暫選的) + const hasTransit = !!transitWarehouseId; + + // 取得選中的在途倉資訊 + const selectedTransitWarehouse = transitWarehouses.find(w => w.id === transitWarehouseId); + // 當 order prop 變動時 (例如匯入後 router.reload),同步更新內部狀態 useEffect(() => { if (order) { setItems(order.items || []); setRemarks(order.remarks || ""); + setTransitWarehouseId(order.transit_warehouse_id || null); } }, [order]); @@ -74,7 +133,6 @@ export default function Show({ order }: any) { const loadInventory = async () => { setLoadingInventory(true); try { - // Fetch inventory from SOURCE warehouse const response = await axios.get(route('api.warehouses.inventories', order.from_warehouse_id)); setAvailableInventory(response.data); } catch (error) { @@ -122,8 +180,8 @@ export default function Show({ order }: any) { batch_number: inv.batch_number, expiry_date: inv.expiry_date, unit: inv.unit_name, - quantity: 1, // Default 1 - max_quantity: inv.quantity, // Max available + quantity: 1, + max_quantity: inv.quantity, notes: "", }); addedCount++; @@ -155,6 +213,7 @@ export default function Show({ order }: any) { await router.put(route('inventory.transfer.update', [order.id]), { items: items, remarks: remarks, + transit_warehouse_id: transitWarehouseId || '', }, { onSuccess: () => { }, onError: () => toast.error("儲存失敗,請檢查輸入"), @@ -164,21 +223,42 @@ export default function Show({ order }: any) { } }; + // 確認出貨 / 確認過帳(無在途倉) + // 確認出貨 / 確認過帳(無在途倉) const handlePost = () => { router.put(route('inventory.transfer.update', [order.id]), { - action: 'post' + action: 'post', + transit_warehouse_id: transitWarehouseId || '', + items: items, + remarks: remarks, }, { onSuccess: () => { setIsPostDialogOpen(false); }, onError: (errors) => { - const message = Object.values(errors).join('\n') || "過帳失敗,請檢查輸入或庫存狀態"; + const message = Object.values(errors).join('\n') || "操作失敗,請檢查輸入或庫存狀態"; toast.error(message); setIsPostDialogOpen(false); } }); }; + // 確認收貨 + const handleReceive = () => { + router.put(route('inventory.transfer.update', [order.id]), { + action: 'receive' + }, { + onSuccess: () => { + setIsReceiveDialogOpen(false); + }, + onError: (errors) => { + const message = Object.values(errors).join('\n') || "收貨失敗"; + toast.error(message); + setIsReceiveDialogOpen(false); + } + }); + }; + const handleDelete = () => { router.delete(route('inventory.transfer.destroy', [order.id]), { onSuccess: () => { @@ -188,28 +268,44 @@ export default function Show({ order }: any) { }; const canEdit = can('inventory_transfer.edit'); - const isReadOnly = order.status !== 'draft' || !canEdit; + const isReadOnly = (order.status !== 'draft' || !canEdit); const isVending = order.to_warehouse_type === 'vending'; + // 狀態 Badge 渲染 + const renderStatusBadge = () => { + const statusConfig: Record = { + completed: { variant: 'success', label: '已完成' }, + dispatched: { variant: 'warning', label: '配送中' }, + draft: { variant: 'neutral', label: '草稿' }, + voided: { variant: 'destructive', label: '已作廢' }, + }; + + const config = statusConfig[order.status] || { variant: 'neutral', label: order.status }; + + return {config.label}; + }; + + // 過帳時庫存欄標題 + const stockColumnTitle = () => { + if (order.status === 'completed' || order.status === 'dispatched') return '出貨時庫存'; + return '可用庫存'; + }; + return (
- + @@ -220,9 +316,7 @@ export default function Show({ order }: any) { 調撥單: {order.doc_no} - {order.status === 'completed' && 已完成} - {order.status === 'draft' && 草稿} - {order.status === 'voided' && 已作廢} + {renderStatusBadge()}

來源: {order.from_warehouse_name} 目的: {order.to_warehouse_name} | 建立人: {order.created_by} @@ -240,6 +334,7 @@ export default function Show({ order }: any) { 列印 + {/* 草稿狀態:儲存 + 出貨/過帳 + 刪除 */} {!isReadOnly && (

@@ -284,30 +379,168 @@ export default function Show({ order }: any) { className="button-filled-primary" disabled={items.length === 0 || isSaving} > - - 確認過帳 + {hasTransit ? ( + <>確認出貨 + ) : ( + <>確認過帳 + )} - 確定要過帳嗎? + + {hasTransit ? '確定要出貨嗎?' : '確定要過帳嗎?'} + - 過帳後庫存將立即從「{order.from_warehouse_name}」轉移至「{order.to_warehouse_name}」,且無法再進行修改。 + {hasTransit ? ( + <>庫存將從「{order.from_warehouse_name}」移至在途倉「{selectedTransitWarehouse?.name || order.transit_warehouse_name}」,等待目的倉庫確認收貨後才完成調撥。 + ) : ( + <>過帳後庫存將立即從「{order.from_warehouse_name}」轉移至「{order.to_warehouse_name}」,且無法再進行修改。 + )} 取消 - 確認過帳 + + {hasTransit ? '確認出貨' : '確認過帳'} +
)} + + {/* 已出貨狀態:確認收貨按鈕 */} + {order.status === 'dispatched' && ( + + + + + + + + 確定要確認收貨嗎? + + 庫存將從在途倉「{order.transit_warehouse_name}」移至目的倉庫「{order.to_warehouse_name}」,完成此次調撥流程。 + + + + 取消 + 確認收貨 + + + + + )}
+ {/* 在途倉資訊卡片 */} + {(hasTransit || transitWarehouses.length > 0) && ( +
+
+ + +
+ + {order.status === 'draft' && canEdit ? ( + /* 草稿狀態:可選擇在途倉 */ +
+
+ + +
+ {selectedTransitWarehouse && ( + <> +
+ +
+ {selectedTransitWarehouse.license_plate || '-'} +
+
+
+ +
+ {selectedTransitWarehouse.driver_name || '-'} +
+
+ + )} +
+ ) : hasTransit ? ( + /* 非草稿狀態:唯讀顯示在途倉資訊 */ +
+
+ +
{order.transit_warehouse_name}
+
+
+ +
{order.transit_warehouse_plate || '-'}
+
+
+ +
{order.transit_warehouse_driver || '-'}
+
+
+ +
+ {order.status === 'dispatched' && ( + 配送中({order.dispatched_at}) + )} + {order.status === 'completed' && ( + 已收貨({order.received_at}) + )} +
+
+
+ ) : null} + + {/* 顯示時間軸(已出貨或已完成時) */} + {(order.dispatched_at || order.received_at) && ( +
+ {order.dispatched_at && ( +
+ + 出貨:{order.dispatched_at} + ({order.dispatched_by}) +
+ )} + {order.received_at && ( +
+ + 收貨:{order.received_at} + ({order.received_by}) +
+ )} +
+ )} +
+ )} +
@@ -497,7 +730,7 @@ export default function Show({ order }: any) { 商品名稱 / 代號 批號 - {order.status === 'completed' ? '過帳時庫存' : '可用庫存'} + {stockColumnTitle()} 調撥數量 單位 diff --git a/resources/js/Pages/Product/Show.tsx b/resources/js/Pages/Product/Show.tsx index f7cd1bf..c30281f 100644 --- a/resources/js/Pages/Product/Show.tsx +++ b/resources/js/Pages/Product/Show.tsx @@ -6,7 +6,7 @@ import { Head, Link } from "@inertiajs/react"; import { ArrowLeft, Package, Tag, Layers, MapPin, DollarSign } from "lucide-react"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Button } from "@/Components/ui/button"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Label } from "@/Components/ui/label"; import { getShowBreadcrumbs } from "@/utils/breadcrumb"; import { Can } from "@/Components/Permission/Can"; @@ -105,15 +105,15 @@ export default function ProductShow({ product }: Props) {
- {product.category?.name || "未分類"} + {product.category?.name || "未分類"}
- + {product.is_active ? "啟用中" : "已停用"} - +
diff --git a/resources/js/Pages/Production/Recipe/Components/RecipeDetailModal.tsx b/resources/js/Pages/Production/Recipe/Components/RecipeDetailModal.tsx index 765cf52..1c3025a 100644 --- a/resources/js/Pages/Production/Recipe/Components/RecipeDetailModal.tsx +++ b/resources/js/Pages/Production/Recipe/Components/RecipeDetailModal.tsx @@ -12,7 +12,7 @@ import { TableHeader, TableRow, } from "@/Components/ui/table"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Loader2, Package, Calendar, Clock, BookOpen } from "lucide-react"; interface RecipeDetailModalProps { @@ -34,9 +34,9 @@ export function RecipeDetailModal({ isOpen, onClose, recipe, isLoading }: Recipe 配方明細 {recipe && ( - + {recipe.is_active ? "啟用中" : "已停用"} - + )}
diff --git a/resources/js/Pages/Production/Recipe/Index.tsx b/resources/js/Pages/Production/Recipe/Index.tsx index 52e7025..cc4a3ef 100644 --- a/resources/js/Pages/Production/Recipe/Index.tsx +++ b/resources/js/Pages/Production/Recipe/Index.tsx @@ -13,7 +13,7 @@ import { SearchableSelect } from "@/Components/ui/searchable-select"; import { Input } from "@/Components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Can } from "@/Components/Permission/Can"; import { RecipeDetailModal } from "./Components/RecipeDetailModal"; import axios from 'axios'; @@ -231,9 +231,11 @@ export default function RecipeIndex({ recipes, filters }: Props) { {recipe.yield_quantity} - - {recipe.is_active ? "啟用" : "停用"} - + {recipe.is_active ? ( + 啟用 + ) : ( + 停用 + )} {new Date(recipe.updated_at).toLocaleDateString()} diff --git a/resources/js/Pages/Production/Show.tsx b/resources/js/Pages/Production/Show.tsx index 089ca34..0f9f8de 100644 --- a/resources/js/Pages/Production/Show.tsx +++ b/resources/js/Pages/Production/Show.tsx @@ -10,7 +10,7 @@ import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, Link, useForm, router } from "@inertiajs/react"; import { getBreadcrumbs } from "@/utils/breadcrumb"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import ProductionOrderStatusBadge from '@/Components/ProductionOrder/ProductionOrderStatusBadge'; import { ProductionStatusProgressBar } from '@/Components/ProductionOrder/ProductionStatusProgressBar'; import { PRODUCTION_ORDER_STATUS, ProductionOrderStatus } from '@/constants/production-order'; @@ -348,9 +348,9 @@ export default function ProductionShow({ productionOrder, warehouses, auth }: Pr 原料耗用與追溯 - + 共 {productionOrder.items.length} 項物料 - +
{productionOrder.items.length === 0 ? ( diff --git a/resources/js/Pages/Sales/Import/Index.tsx b/resources/js/Pages/Sales/Import/Index.tsx index 41e383c..4b86cc4 100644 --- a/resources/js/Pages/Sales/Import/Index.tsx +++ b/resources/js/Pages/Sales/Import/Index.tsx @@ -20,7 +20,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/Components/ui/alert-dialog"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { Plus, FileUp, Eye, Trash2, Search, X } from 'lucide-react'; import { useState, useEffect } from "react"; import { format } from 'date-fns'; @@ -201,9 +201,11 @@ export default function SalesImportIndex({ batches, filters = {} }: Props) { NT$ {Number(batch.total_amount || 0).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 })}
- - {batch.status === 'confirmed' ? '已確認' : '待確認'} - + {batch.status === 'confirmed' ? ( + 已確認 + ) : ( + 待確認 + )}
diff --git a/resources/js/Pages/Sales/Import/Show.tsx b/resources/js/Pages/Sales/Import/Show.tsx index f16c65e..bd36323 100644 --- a/resources/js/Pages/Sales/Import/Show.tsx +++ b/resources/js/Pages/Sales/Import/Show.tsx @@ -22,7 +22,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/Components/ui/alert-dialog"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { ArrowLeft, CheckCircle, Trash2, Printer } from 'lucide-react'; import { format } from 'date-fns'; import Pagination from "@/Components/shared/Pagination"; @@ -137,9 +137,9 @@ export default function SalesImportShow({ import: batch, items, filters = {} }:

批次編號:#{batch.id} | 匯入時間:{format(new Date(batch.created_at), 'yyyy/MM/dd HH:mm')}

- + {batch.status === 'confirmed' ? '已確認' : '待確認'} - + {batch.status === 'pending' && (
{can('sales_imports.delete') && ( @@ -304,9 +304,9 @@ export default function SalesImportShow({ import: batch, items, filters = {} }: {item.slot || '--'} - + {item.original_status} - + {Math.floor(item.quantity)} diff --git a/resources/js/Pages/ShippingOrder/Index.tsx b/resources/js/Pages/ShippingOrder/Index.tsx index bc32192..ff7136a 100644 --- a/resources/js/Pages/ShippingOrder/Index.tsx +++ b/resources/js/Pages/ShippingOrder/Index.tsx @@ -16,7 +16,7 @@ import { TableHeader, TableRow, } from "@/Components/ui/table"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; interface Props { orders: { @@ -54,13 +54,13 @@ export default function ShippingOrderIndex({ orders, filters, warehouses }: Prop const getStatusBadge = (status: string) => { switch (status) { case 'draft': - return 草稿; + return 草稿; case 'completed': - return 已過帳; + return 已過帳; case 'cancelled': - return 已取消; + return 已取消; default: - return {status}; + return {status}; } }; diff --git a/resources/js/Pages/ShippingOrder/Show.tsx b/resources/js/Pages/ShippingOrder/Show.tsx index 47c87ee..64b65ae 100644 --- a/resources/js/Pages/ShippingOrder/Show.tsx +++ b/resources/js/Pages/ShippingOrder/Show.tsx @@ -2,7 +2,7 @@ import { ArrowLeft, Package, Info, CheckCircle2, AlertCircle, Trash2, Edit } fro import { Button } from "@/Components/ui/button"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Head, Link, router } from "@inertiajs/react"; -import { Badge } from "@/Components/ui/badge"; +import { StatusBadge } from "@/Components/shared/StatusBadge"; import { toast } from "sonner"; import ActivityLog from "@/Components/ActivityLog/ActivityLog"; @@ -31,16 +31,16 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) { }; const getStatusBadge = (status: string) => { - switch (status) { - case 'draft': - return 草稿; - case 'completed': - return 已完成; - case 'cancelled': - return 已取消; - default: - return {status}; - } + const statusConfig: Record = { + draft: { variant: 'neutral', label: '草稿' }, + completed: { variant: 'success', label: '已完成' }, + cancelled: { variant: 'destructive', label: '已取消' }, + }; + + const config = statusConfig[status]; + if (!config) return {status}; + + return {config.label}; }; return ( @@ -130,7 +130,7 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {

出貨品項清單

- {order.items.length} 個品項 + {order.items.length} 個品項
@@ -151,7 +151,7 @@ export default function ShippingOrderShow({ order, activities = [] }: Props) {
{item.product_code}
- {item.batch_number || 'N/A'} + {item.batch_number || 'N/A'} {parseFloat(item.quantity).toLocaleString()} diff --git a/resources/js/Pages/StoreRequisition/Create.tsx b/resources/js/Pages/StoreRequisition/Create.tsx new file mode 100644 index 0000000..6ac7d7e --- /dev/null +++ b/resources/js/Pages/StoreRequisition/Create.tsx @@ -0,0 +1,374 @@ +import { useState } from "react"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, Link, router } from "@inertiajs/react"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Textarea } from "@/Components/ui/textarea"; +import { Label } from "@/Components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { toast } from "sonner"; +import { + Store, + Plus, + Trash2, + Loader2, + Save, + SendHorizontal, + ArrowLeft, +} from "lucide-react"; + +interface Product { + id: number; + name: string; + code: string; + unit_name: string; +} + +interface Warehouse { + id: number; + name: string; + type: string; +} + +interface RequisitionItem { + product_id: string; + requested_qty: string; + remark: string; +} + +interface Props { + requisition?: { + id: number; + store_warehouse_id: number; + remark: string | null; + status: string; + items: { + id: number; + product_id: number; + requested_qty: number; + remark: string | null; + }[]; + }; + warehouses: Warehouse[]; + products: Product[]; +} + +export default function Create({ requisition, warehouses, products }: Props) { + const isEditing = !!requisition; + + const [storeWarehouseId, setStoreWarehouseId] = useState( + requisition?.store_warehouse_id?.toString() || "" + ); + const [remark, setRemark] = useState(requisition?.remark || ""); + const [items, setItems] = useState( + requisition?.items?.map((item) => ({ + product_id: item.product_id.toString(), + requested_qty: item.requested_qty.toString(), + remark: item.remark || "", + })) || [{ product_id: "", requested_qty: "", remark: "" }] + ); + const [saving, setSaving] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const addItem = () => { + setItems([...items, { product_id: "", requested_qty: "", remark: "" }]); + }; + + const removeItem = (index: number) => { + if (items.length <= 1) { + toast.error("至少需要一項商品"); + return; + } + setItems(items.filter((_, i) => i !== index)); + }; + + const updateItem = (index: number, field: keyof RequisitionItem, value: string) => { + const newItems = [...items]; + newItems[index] = { ...newItems[index], [field]: value }; + setItems(newItems); + }; + + const validate = (): boolean => { + if (!storeWarehouseId) { + toast.error("請選擇申請倉庫"); + return false; + } + if (items.length === 0) { + toast.error("至少需要一項商品"); + return false; + } + for (let i = 0; i < items.length; i++) { + if (!items[i].product_id) { + toast.error(`第 ${i + 1} 行請選擇商品`); + return false; + } + const qty = parseInt(items[i].requested_qty); + if (!qty || qty < 1) { + toast.error(`第 ${i + 1} 行需求數量必須大於等於 1`); + return false; + } + } + // 檢查是否有重複商品 + const productIds = items.map((item) => item.product_id); + if (new Set(productIds).size !== productIds.length) { + toast.error("不可重複選擇商品"); + return false; + } + return true; + }; + + const handleSave = (submitImmediately: boolean = false) => { + if (!validate()) return; + + const setter = submitImmediately ? setSubmitting : setSaving; + setter(true); + + const payload = { + store_warehouse_id: storeWarehouseId, + remark: remark || null, + items: items.map((item) => ({ + product_id: parseInt(item.product_id), + requested_qty: parseFloat(item.requested_qty), + remark: item.remark || null, + })), + submit_immediately: submitImmediately, + }; + + if (isEditing) { + router.put(route("store-requisitions.update", [requisition!.id]), payload, { + onFinish: () => setter(false), + }); + } else { + router.post(route("store-requisitions.store"), payload, { + onFinish: () => setter(false), + }); + } + }; + + // 已選商品列表(用於過濾下拉選項) + const selectedProductIds = items.map((item) => item.product_id).filter(Boolean); + + return ( + + + +
+ {/* 返回按鈕 */} +
+ + + +
+ + {/* 頁面標題 */} +
+

+ + {isEditing ? `編輯叫貨單 ${requisition?.status === "rejected" ? "(重新提交)" : ""}` : "新增叫貨單"} +

+

+ 選擇需要補貨的倉庫,並填入所需商品與數量。 +

+
+ + {/* 基本資訊 */} +
+

基本資訊

+
+
+ + ({ + label: w.name, + value: w.id.toString(), + }))} + placeholder="請選擇倉庫" + className="h-9" + /> +

選擇需要補貨的倉庫

+
+
+ +