UI優化: 全系統狀態標籤 (StatusBadge) 統一化重構完成 (Phase 3 & 4)
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m8s

This commit is contained in:
2026-02-13 13:16:05 +08:00
56 changed files with 3343 additions and 429 deletions

View File

@@ -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' => '系統管理',

View File

@@ -0,0 +1,352 @@
<?php
namespace App\Modules\Inventory\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\StoreRequisition;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Services\StoreRequisitionService;
use App\Modules\Core\Contracts\CoreServiceInterface;
use Illuminate\Http\Request;
use Inertia\Inertia;
class StoreRequisitionController extends Controller
{
protected StoreRequisitionService $service;
protected CoreServiceInterface $coreService;
public function __construct(
StoreRequisitionService $service,
CoreServiceInterface $coreService
) {
$this->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', '叫貨單已刪除');
}
}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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');
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
use App\Modules\Core\Models\User;
class StoreRequisition extends Model
{
use HasFactory, LogsActivity;
protected $fillable = [
'doc_no',
'store_warehouse_id',
'supply_warehouse_id',
'status',
'remark',
'reject_reason',
'created_by',
'approved_by',
'submitted_at',
'approved_at',
'transfer_order_id',
];
protected $casts = [
'submitted_at' => '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');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Modules\Inventory\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoreRequisitionItem extends Model
{
use HasFactory;
protected $fillable = [
'store_requisition_id',
'product_id',
'requested_qty',
'approved_qty',
'remark',
];
protected $casts = [
'requested_qty' => '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);
}
}

View File

@@ -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
{

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Modules\Inventory\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Modules\Inventory\Models\StoreRequisition;
class StoreRequisitionNotification extends Notification
{
use Queueable;
protected StoreRequisition $requisition;
protected string $action;
protected string $actorName;
/**
* 建立通知實例
*
* @param StoreRequisition $requisition 叫貨單
* @param string $action 操作類型submitted / approved / rejected
* @param string $actorName 操作者名稱
*/
public function __construct(StoreRequisition $requisition, string $action, string $actorName)
{
$this->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),
];
}
}

View File

@@ -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');

View File

@@ -0,0 +1,247 @@
<?php
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Models\StoreRequisition;
use App\Modules\Inventory\Models\StoreRequisitionItem;
use App\Modules\Inventory\Models\InventoryTransferOrder;
use App\Modules\Inventory\Models\InventoryTransferItem;
use App\Modules\Inventory\Notifications\StoreRequisitionNotification;
use App\Modules\Core\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class StoreRequisitionService
{
protected TransferService $transferService;
public function __construct(TransferService $transferService)
{
$this->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));
}
}
}

View File

@@ -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') {