優化: 門市叫貨模組 UI 調整、權限標籤中文化及調撥單動態導覽
This commit is contained in:
@@ -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' => '系統管理',
|
||||
|
||||
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal file
352
app/Modules/Inventory/Controllers/StoreRequisitionController.php
Normal 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', '叫貨單已刪除');
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal file
147
app/Modules/Inventory/Models/StoreRequisition.php
Normal 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');
|
||||
}
|
||||
}
|
||||
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal file
41
app/Modules/Inventory/Models/StoreRequisitionItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
242
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
242
app/Modules/Inventory/Services/StoreRequisitionService.php
Normal file
@@ -0,0 +1,242 @@
|
||||
<?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']]);
|
||||
}
|
||||
}
|
||||
|
||||
// 產生調撥單(供貨倉庫 → 門市倉庫)
|
||||
$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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user