From 097708aab7c6dcec9a69e14051b177998a429ea6 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Fri, 13 Feb 2026 10:39:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=84=AA=E5=8C=96:=20=E9=96=80=E5=B8=82?= =?UTF-8?q?=E5=8F=AB=E8=B2=A8=E6=A8=A1=E7=B5=84=20UI=20=E8=AA=BF=E6=95=B4?= =?UTF-8?q?=E3=80=81=E6=AC=8A=E9=99=90=E6=A8=99=E7=B1=A4=E4=B8=AD=E6=96=87?= =?UTF-8?q?=E5=8C=96=E5=8F=8A=E8=AA=BF=E6=92=A5=E5=96=AE=E5=8B=95=E6=85=8B?= =?UTF-8?q?=E5=B0=8E=E8=A6=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Controllers/RoleController.php | 2 + .../StoreRequisitionController.php | 352 ++++++++++ .../Controllers/TransferOrderController.php | 6 +- .../Models/InventoryTransferOrder.php | 5 + .../Inventory/Models/StoreRequisition.php | 147 +++++ .../Inventory/Models/StoreRequisitionItem.php | 41 ++ .../StoreRequisitionNotification.php | 54 ++ app/Modules/Inventory/Routes/web.php | 26 + .../Services/StoreRequisitionService.php | 242 +++++++ ...090000_create_store_requisitions_table.php | 40 ++ ...0_create_store_requisition_items_table.php | 31 + database/seeders/PermissionSeeder.php | 12 + resources/js/Layouts/AuthenticatedLayout.tsx | 10 +- .../js/Pages/Inventory/Transfer/Show.tsx | 15 +- .../js/Pages/StoreRequisition/Create.tsx | 374 +++++++++++ resources/js/Pages/StoreRequisition/Index.tsx | 407 ++++++++++++ resources/js/Pages/StoreRequisition/Show.tsx | 601 ++++++++++++++++++ 17 files changed, 2359 insertions(+), 6 deletions(-) create mode 100644 app/Modules/Inventory/Controllers/StoreRequisitionController.php create mode 100644 app/Modules/Inventory/Models/StoreRequisition.php create mode 100644 app/Modules/Inventory/Models/StoreRequisitionItem.php create mode 100644 app/Modules/Inventory/Notifications/StoreRequisitionNotification.php create mode 100644 app/Modules/Inventory/Services/StoreRequisitionService.php create mode 100644 database/migrations/tenant/2026_02_13_090000_create_store_requisitions_table.php create mode 100644 database/migrations/tenant/2026_02_13_090100_create_store_requisition_items_table.php create mode 100644 resources/js/Pages/StoreRequisition/Create.tsx create mode 100644 resources/js/Pages/StoreRequisition/Index.tsx create mode 100644 resources/js/Pages/StoreRequisition/Show.tsx 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..858a5b1 100644 --- a/app/Modules/Inventory/Controllers/TransferOrderController.php +++ b/app/Modules/Inventory/Controllers/TransferOrderController.php @@ -99,7 +99,7 @@ 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', 'createdBy', 'postedBy', 'storeRequisition']); $orderData = [ 'id' => (string) $order->id, @@ -113,6 +113,10 @@ class TransferOrderController extends Controller 'remarks' => $order->remarks, 'created_at' => $order->created_at->format('Y-m-d H:i'), 'created_by' => $order->createdBy?->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) diff --git a/app/Modules/Inventory/Models/InventoryTransferOrder.php b/app/Modules/Inventory/Models/InventoryTransferOrder.php index ca11db3..e43f1d5 100644 --- a/app/Modules/Inventory/Models/InventoryTransferOrder.php +++ b/app/Modules/Inventory/Models/InventoryTransferOrder.php @@ -163,6 +163,11 @@ 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'); 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/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..b129285 --- /dev/null +++ b/app/Modules/Inventory/Services/StoreRequisitionService.php @@ -0,0 +1,242 @@ +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']]); + } + } + + // 產生調撥單(供貨倉庫 → 門市倉庫) + $transferOrder = $this->transferService->createOrder( + fromWarehouseId: $data['supply_warehouse_id'], + toWarehouseId: $requisition->store_warehouse_id, + remarks: "由叫貨單 {$requisition->doc_no} 自動產生", + userId: $userId, + ); + + // 將核准的明細寫入調撥單 + $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/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/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 7ab10d1..2a799b2 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -129,6 +129,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) { @@ -172,6 +180,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 管理庫存與倉庫 @@ -186,6 +196,8 @@ class PermissionSeeder extends Seeder '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/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/Inventory/Transfer/Show.tsx b/resources/js/Pages/Inventory/Transfer/Show.tsx index 12faf29..35f9918 100644 --- a/resources/js/Pages/Inventory/Transfer/Show.tsx +++ b/resources/js/Pages/Inventory/Transfer/Show.tsx @@ -195,21 +195,28 @@ export default function Show({ order }: any) {
- + 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" + /> +

選擇需要補貨的倉庫

+
+
+ +