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:
+ 選擇需要補貨的倉庫,並填入所需商品與數量。 +
+選擇需要補貨的倉庫
++ 門市人員依庫存與銷售需求,向總倉提出商品補貨申請。 +
++ {requisition.store_warehouse_name} +
++ {requisition.supply_warehouse_name || "-"} +
++ {requisition.creator_name} +
++ {formatDate(requisition.submitted_at)} +
++ {requisition.approver_name} +
++ {formatDate(requisition.approved_at)} +
++ {requisition.remark} +
++ {requisition.reject_reason} +
++ + 查看調撥單 → + +
+