feat: 實作出貨單模組並暫時導向通用製作中頁面,同步優化盤點與調撥功能的活動日誌顯示
This commit is contained in:
133
app/Modules/Procurement/Controllers/ShippingOrderController.php
Normal file
133
app/Modules/Procurement/Controllers/ShippingOrderController.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Procurement\Models\ShippingOrder;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShippingOrderController extends Controller
|
||||
{
|
||||
protected $inventoryService;
|
||||
protected $coreService;
|
||||
protected $shippingService;
|
||||
|
||||
public function __construct(
|
||||
InventoryServiceInterface $inventoryService,
|
||||
CoreServiceInterface $coreService,
|
||||
\App\Modules\Procurement\Services\ShippingService $shippingService
|
||||
) {
|
||||
$this->inventoryService = $inventoryService;
|
||||
$this->coreService = $coreService;
|
||||
$this->shippingService = $shippingService;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單管理'
|
||||
]);
|
||||
|
||||
/* 原有邏輯暫存
|
||||
$query = ShippingOrder::query();
|
||||
|
||||
// 搜尋
|
||||
if ($request->search) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('doc_no', 'like', "%{$request->search}%")
|
||||
->orWhere('customer_name', 'like', "%{$request->search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->status && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$orders = $query->orderBy('id', 'desc')->paginate($perPage)->withQueryString();
|
||||
|
||||
// 水和倉庫與使用者
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
$userIds = $orders->getCollection()->pluck('created_by')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
$orders->getCollection()->transform(function ($order) use ($warehouses, $users) {
|
||||
$order->warehouse_name = $warehouses->firstWhere('id', $order->warehouse_id)?->name ?? 'Unknown';
|
||||
$order->creator_name = $users->get($order->created_by)?->name ?? 'System';
|
||||
return $order;
|
||||
});
|
||||
|
||||
return Inertia::render('ShippingOrder/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'status', 'per_page']),
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
]);
|
||||
*/
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單建立'
|
||||
]);
|
||||
|
||||
/* 原有邏輯暫存
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
$products = $this->inventoryService->getAllProducts();
|
||||
|
||||
return Inertia::render('ShippingOrder/Create', [
|
||||
'warehouses' => $warehouses->map(fn($w) => ['id' => $w->id, 'name' => $w->name]),
|
||||
'products' => $products->map(fn($p) => [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'code' => $p->code,
|
||||
'unit_name' => $p->baseUnit?->name,
|
||||
]),
|
||||
]);
|
||||
*/
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
return back()->with('error', '出貨單管理功能正在製作中');
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單詳情'
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
return Inertia::render('Common/UnderConstruction', [
|
||||
'featureName' => '出貨單編輯'
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
return back()->with('error', '出貨單管理功能正在製作中');
|
||||
}
|
||||
|
||||
public function post($id)
|
||||
{
|
||||
return back()->with('error', '出貨單管理功能正在製作中');
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
$order = ShippingOrder::findOrFail($id);
|
||||
if ($order->status !== 'draft') {
|
||||
return back()->withErrors(['error' => '僅能刪除草稿狀態的單據']);
|
||||
}
|
||||
$order->delete();
|
||||
return redirect()->route('delivery-notes.index')->with('success', '出貨單已刪除');
|
||||
}
|
||||
}
|
||||
89
app/Modules/Procurement/Models/ShippingOrder.php
Normal file
89
app/Modules/Procurement/Models/ShippingOrder.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Contracts\Activity;
|
||||
|
||||
class ShippingOrder extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'doc_no',
|
||||
'customer_name',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'shipping_date',
|
||||
'total_amount',
|
||||
'tax_amount',
|
||||
'grand_total',
|
||||
'remarks',
|
||||
'created_by',
|
||||
'posted_by',
|
||||
'posted_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'shipping_date' => 'date',
|
||||
'posted_at' => 'datetime',
|
||||
'total_amount' => 'decimal:2',
|
||||
'tax_amount' => 'decimal:2',
|
||||
'grand_total' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(Activity $activity, string $eventName)
|
||||
{
|
||||
$snapshot = $activity->properties['snapshot'] ?? [];
|
||||
$snapshot['doc_no'] = $this->doc_no;
|
||||
$snapshot['customer_name'] = $this->customer_name;
|
||||
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'snapshot' => $snapshot
|
||||
]);
|
||||
}
|
||||
|
||||
public function items()
|
||||
{
|
||||
return $this->hasMany(ShippingOrderItem::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自動產生單號
|
||||
*/
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
if (empty($model->doc_no)) {
|
||||
$today = date('Ymd');
|
||||
$prefix = 'SHP-' . $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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
35
app/Modules/Procurement/Models/ShippingOrderItem.php
Normal file
35
app/Modules/Procurement/Models/ShippingOrderItem.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ShippingOrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'shipping_order_id',
|
||||
'product_id',
|
||||
'batch_number',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:4',
|
||||
'unit_price' => 'decimal:4',
|
||||
'subtotal' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function shippingOrder()
|
||||
{
|
||||
return $this->belongsTo(ShippingOrder::class);
|
||||
}
|
||||
|
||||
// 注意:在模組化架構下,跨模組關聯應謹慎使用或是直接在 Controller 水和 (Hydration)
|
||||
// 但為了開發便利,暫時保留對 Product 的關聯(如果 Product 在不同模組,可能無法直接 lazy load)
|
||||
}
|
||||
@@ -35,4 +35,24 @@ Route::middleware('auth')->group(function () {
|
||||
Route::put('/purchase-orders/{id}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.update');
|
||||
Route::delete('/purchase-orders/{id}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchase_orders.delete')->name('purchase-orders.destroy');
|
||||
});
|
||||
|
||||
// 出貨單管理 (Delivery Notes)
|
||||
Route::middleware('permission:delivery_notes.view')->group(function () {
|
||||
Route::get('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'index'])->name('delivery-notes.index');
|
||||
|
||||
Route::middleware('permission:delivery_notes.create')->group(function () {
|
||||
Route::get('/delivery-notes/create', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'create'])->name('delivery-notes.create');
|
||||
Route::post('/delivery-notes', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'store'])->name('delivery-notes.store');
|
||||
});
|
||||
|
||||
Route::get('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'show'])->name('delivery-notes.show');
|
||||
|
||||
Route::middleware('permission:delivery_notes.edit')->group(function () {
|
||||
Route::get('/delivery-notes/{id}/edit', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'edit'])->name('delivery-notes.edit');
|
||||
Route::put('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'update'])->name('delivery-notes.update');
|
||||
Route::post('/delivery-notes/{id}/post', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'post'])->name('delivery-notes.post');
|
||||
});
|
||||
|
||||
Route::delete('/delivery-notes/{id}', [\App\Modules\Procurement\Controllers\ShippingOrderController::class, 'destroy'])->middleware('permission:delivery_notes.delete')->name('delivery-notes.destroy');
|
||||
});
|
||||
});
|
||||
|
||||
118
app/Modules/Procurement/Services/ShippingService.php
Normal file
118
app/Modules/Procurement/Services/ShippingService.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Services;
|
||||
|
||||
use App\Modules\Procurement\Models\ShippingOrder;
|
||||
use App\Modules\Procurement\Models\ShippingOrderItem;
|
||||
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ShippingService
|
||||
{
|
||||
protected $inventoryService;
|
||||
|
||||
public function __construct(InventoryServiceInterface $inventoryService)
|
||||
{
|
||||
$this->inventoryService = $inventoryService;
|
||||
}
|
||||
|
||||
public function createShippingOrder(array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$order = ShippingOrder::create([
|
||||
'warehouse_id' => $data['warehouse_id'],
|
||||
'customer_name' => $data['customer_name'] ?? null,
|
||||
'shipping_date' => $data['shipping_date'],
|
||||
'status' => 'draft',
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'created_by' => auth()->id(),
|
||||
'total_amount' => $data['total_amount'] ?? 0,
|
||||
'tax_amount' => $data['tax_amount'] ?? 0,
|
||||
'grand_total' => $data['grand_total'] ?? 0,
|
||||
]);
|
||||
|
||||
foreach ($data['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'batch_number' => $item['batch_number'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'] ?? 0,
|
||||
'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)),
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
public function updateShippingOrder(ShippingOrder $order, array $data)
|
||||
{
|
||||
return DB::transaction(function () use ($order, $data) {
|
||||
$order->update([
|
||||
'warehouse_id' => $data['warehouse_id'],
|
||||
'customer_name' => $data['customer_name'] ?? null,
|
||||
'shipping_date' => $data['shipping_date'],
|
||||
'remarks' => $data['remarks'] ?? null,
|
||||
'total_amount' => $data['total_amount'] ?? 0,
|
||||
'tax_amount' => $data['tax_amount'] ?? 0,
|
||||
'grand_total' => $data['grand_total'] ?? 0,
|
||||
]);
|
||||
|
||||
// 簡單處理:刪除舊項目並新增
|
||||
$order->items()->delete();
|
||||
foreach ($data['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'product_id' => $item['product_id'],
|
||||
'batch_number' => $item['batch_number'] ?? null,
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'] ?? 0,
|
||||
'subtotal' => $item['subtotal'] ?? ($item['quantity'] * ($item['unit_price'] ?? 0)),
|
||||
'remark' => $item['remark'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
|
||||
public function post(ShippingOrder $order)
|
||||
{
|
||||
if ($order->status !== 'draft') {
|
||||
throw new \Exception('該單據已過帳或已取消。');
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($order) {
|
||||
foreach ($order->items as $item) {
|
||||
// 尋找對應的庫存紀錄
|
||||
$inventory = $this->inventoryService->findInventoryByBatch(
|
||||
$order->warehouse_id,
|
||||
$item->product_id,
|
||||
$item->batch_number
|
||||
);
|
||||
|
||||
if (!$inventory || $inventory->quantity < $item->quantity) {
|
||||
$productName = $this->inventoryService->getProduct($item->product_id)?->name ?? 'Unknown';
|
||||
throw new \Exception("商品 [{$productName}] (批號: {$item->batch_number}) 庫存不足。");
|
||||
}
|
||||
|
||||
// 扣除庫存
|
||||
$this->inventoryService->decreaseInventoryQuantity(
|
||||
$inventory->id,
|
||||
$item->quantity,
|
||||
"出貨扣款: 單號 [{$order->doc_no}]",
|
||||
'ShippingOrder',
|
||||
$order->id
|
||||
);
|
||||
}
|
||||
|
||||
$order->update([
|
||||
'status' => 'completed',
|
||||
'posted_by' => auth()->id(),
|
||||
'posted_at' => now(),
|
||||
]);
|
||||
|
||||
return $order;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user