chore: 完善模組化架構遷移與修復前端顯示錯誤
- 修正所有模組 Controller 的 Model 引用路徑 (App\Modules\...) - 更新 ProductionOrder 與 ProductionOrderItem 模型結構以符合新版邏輯 - 修復 resources/js/utils/format.ts 在處理空值時導致 toLocaleString 崩潰的問題 - 清除全域路徑與 Controller 遷移殘留檔案
This commit is contained in:
556
app/Modules/Procurement/Controllers/PurchaseOrderController.php
Normal file
556
app/Modules/Procurement/Controllers/PurchaseOrderController.php
Normal file
@@ -0,0 +1,556 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Procurement\Models\PurchaseOrder;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PurchaseOrderController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
|
||||
|
||||
// Search
|
||||
if ($request->search) {
|
||||
$query->where(function($q) use ($request) {
|
||||
$q->where('code', 'like', "%{$request->search}%")
|
||||
->orWhereHas('vendor', function($vq) use ($request) {
|
||||
$vq->where('name', 'like', "%{$request->search}%");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filters
|
||||
if ($request->status && $request->status !== 'all') {
|
||||
$query->where('status', $request->status);
|
||||
}
|
||||
|
||||
if ($request->warehouse_id && $request->warehouse_id !== 'all') {
|
||||
$query->where('warehouse_id', $request->warehouse_id);
|
||||
}
|
||||
|
||||
// Date Range
|
||||
if ($request->date_start) {
|
||||
$query->whereDate('created_at', '>=', $request->date_start);
|
||||
}
|
||||
|
||||
if ($request->date_end) {
|
||||
$query->whereDate('created_at', '<=', $request->date_end);
|
||||
}
|
||||
|
||||
// Sorting
|
||||
$sortField = $request->sort_field ?? 'id';
|
||||
$sortDirection = $request->sort_direction ?? 'desc';
|
||||
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
|
||||
|
||||
if (in_array($sortField, $allowedSortFields)) {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$orders = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
return Inertia::render('PurchaseOrder/Index', [
|
||||
'orders' => $orders,
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
|
||||
'warehouses' => Warehouse::all(['id', 'name']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $vendor->products->map(function ($product) {
|
||||
return [
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'base_unit_id' => $product->base_unit_id,
|
||||
'base_unit_name' => $product->baseUnit?->name,
|
||||
'large_unit_id' => $product->large_unit_id,
|
||||
'large_unit_name' => $product->largeUnit?->name,
|
||||
'purchase_unit_id' => $product->purchase_unit_id,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||
];
|
||||
})
|
||||
];
|
||||
});
|
||||
|
||||
$warehouses = Warehouse::all()->map(function ($w) {
|
||||
return [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('PurchaseOrder/Create', [
|
||||
'suppliers' => $vendors,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'required|exists:vendors,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||
'invoice_date' => 'nullable|date',
|
||||
'invoice_amount' => 'nullable|numeric|min:0',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
|
||||
'items.*.unitId' => 'nullable|exists:units,id',
|
||||
'tax_amount' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
// 生成單號:YYYYMMDD001
|
||||
$today = now()->format('Ymd');
|
||||
$lastOrder = PurchaseOrder::where('code', 'like', $today . '%')
|
||||
->lockForUpdate() // 鎖定以避免並發衝突
|
||||
->orderBy('code', 'desc')
|
||||
->first();
|
||||
|
||||
if ($lastOrder) {
|
||||
// 取得最後 3 碼序號並加 1
|
||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$sequence = '001';
|
||||
}
|
||||
$code = $today . $sequence;
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
$totalAmount += $item['subtotal'];
|
||||
}
|
||||
|
||||
// Tax calculation
|
||||
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
|
||||
$grandTotal = $totalAmount + $taxAmount;
|
||||
|
||||
// 確保有一個有效的使用者 ID
|
||||
$userId = auth()->id();
|
||||
if (!$userId) {
|
||||
$user = \App\Modules\Core\Models\User::first();
|
||||
if (!$user) {
|
||||
$user = \App\Modules\Core\Models\User::create([
|
||||
'name' => '系統管理員',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
]);
|
||||
}
|
||||
$userId = $user->id;
|
||||
}
|
||||
|
||||
$order = PurchaseOrder::create([
|
||||
'code' => $code,
|
||||
'vendor_id' => $validated['vendor_id'],
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'user_id' => $userId,
|
||||
'status' => 'draft',
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
'grand_total' => $grandTotal,
|
||||
'remark' => $validated['remark'],
|
||||
'invoice_number' => $validated['invoice_number'] ?? null,
|
||||
'invoice_date' => $validated['invoice_date'] ?? null,
|
||||
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
// 反算單價
|
||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||
|
||||
$order->items()->create([
|
||||
'product_id' => $item['productId'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_id' => $item['unitId'] ?? null,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal' => $item['subtotal'],
|
||||
]);
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->withErrors(['error' => '建立失敗:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product.baseUnit', 'items.product.largeUnit'])->findOrFail($id);
|
||||
|
||||
$order->items->transform(function ($item) use ($order) {
|
||||
$product = $item->product;
|
||||
if ($product) {
|
||||
// 手動附加所有必要的屬性
|
||||
$item->productId = (string) $product->id;
|
||||
$item->productName = $product->name;
|
||||
$item->base_unit_id = $product->base_unit_id;
|
||||
$item->base_unit_name = $product->baseUnit?->name;
|
||||
$item->large_unit_id = $product->large_unit_id;
|
||||
$item->large_unit_name = $product->largeUnit?->name;
|
||||
$item->purchase_unit_id = $product->purchase_unit_id;
|
||||
|
||||
$item->conversion_rate = (float) $product->conversion_rate;
|
||||
|
||||
// Fetch last price
|
||||
$lastPrice = DB::table('product_vendor')
|
||||
->where('vendor_id', $order->vendor_id)
|
||||
->where('product_id', $product->id)
|
||||
->value('last_price');
|
||||
$item->previousPrice = (float) ($lastPrice ?? 0);
|
||||
|
||||
// 設定當前選中的單位 ID (from saved item)
|
||||
$item->unitId = $item->unit_id;
|
||||
|
||||
// 決定 selectedUnit (用於 UI 顯示)
|
||||
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
|
||||
$item->selectedUnit = 'large';
|
||||
} else {
|
||||
$item->selectedUnit = 'base';
|
||||
}
|
||||
|
||||
$item->unitPrice = (float) $item->unit_price;
|
||||
}
|
||||
return $item;
|
||||
});
|
||||
|
||||
return Inertia::render('PurchaseOrder/Show', [
|
||||
'order' => $order
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
|
||||
|
||||
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
|
||||
return [
|
||||
'id' => (string) $vendor->id,
|
||||
'name' => $vendor->name,
|
||||
'commonProducts' => $vendor->products->map(function ($product) {
|
||||
return [
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'base_unit_id' => $product->base_unit_id,
|
||||
'base_unit_name' => $product->baseUnit?->name,
|
||||
'large_unit_id' => $product->large_unit_id,
|
||||
'large_unit_name' => $product->largeUnit?->name,
|
||||
'purchase_unit_id' => $product->purchase_unit_id,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
|
||||
];
|
||||
})
|
||||
];
|
||||
});
|
||||
|
||||
$warehouses = Warehouse::all()->map(function ($w) {
|
||||
return [
|
||||
'id' => (string) $w->id,
|
||||
'name' => $w->name,
|
||||
];
|
||||
});
|
||||
|
||||
// Transform items for frontend form
|
||||
// Transform items for frontend form
|
||||
$vendorId = $order->vendor_id;
|
||||
$order->items->transform(function ($item) use ($vendorId) {
|
||||
$product = $item->product;
|
||||
if ($product) {
|
||||
// 手動附加所有必要的屬性
|
||||
$item->productId = (string) $product->id;
|
||||
$item->productName = $product->name;
|
||||
$item->base_unit_id = $product->base_unit_id;
|
||||
$item->base_unit_name = $product->baseUnit?->name;
|
||||
$item->large_unit_id = $product->large_unit_id;
|
||||
$item->large_unit_name = $product->largeUnit?->name;
|
||||
|
||||
$item->conversion_rate = (float) $product->conversion_rate;
|
||||
|
||||
// Fetch last price
|
||||
$lastPrice = DB::table('product_vendor')
|
||||
->where('vendor_id', $vendorId)
|
||||
->where('product_id', $product->id)
|
||||
->value('last_price');
|
||||
$item->previousPrice = (float) ($lastPrice ?? 0);
|
||||
|
||||
// 設定當前選中的單位 ID
|
||||
$item->unitId = $item->unit_id; // 資料庫中的 unit_id
|
||||
|
||||
// 決定 selectedUnit (用於 UI 狀態)
|
||||
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
|
||||
$item->selectedUnit = 'large';
|
||||
} else {
|
||||
$item->selectedUnit = 'base';
|
||||
}
|
||||
|
||||
$item->unitPrice = (float) $item->unit_price;
|
||||
}
|
||||
return $item;
|
||||
});
|
||||
|
||||
return Inertia::render('PurchaseOrder/Create', [
|
||||
'order' => $order,
|
||||
'suppliers' => $vendors,
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, $id)
|
||||
{
|
||||
$order = PurchaseOrder::findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'vendor_id' => 'required|exists:vendors,id',
|
||||
'warehouse_id' => 'required|exists:warehouses,id',
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled',
|
||||
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||
'invoice_date' => 'nullable|date',
|
||||
'invoice_amount' => 'nullable|numeric|min:0',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
|
||||
'items.*.unitId' => 'nullable|exists:units,id',
|
||||
// Allow both tax_amount and taxAmount for compatibility
|
||||
'tax_amount' => 'nullable|numeric|min:0',
|
||||
'taxAmount' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$totalAmount = 0;
|
||||
foreach ($validated['items'] as $item) {
|
||||
$totalAmount += $item['subtotal'];
|
||||
}
|
||||
|
||||
// Tax calculation (handle both keys)
|
||||
$inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null;
|
||||
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
|
||||
$grandTotal = $totalAmount + $taxAmount;
|
||||
|
||||
// 1. Fill attributes but don't save yet to capture changes
|
||||
$order->fill([
|
||||
'vendor_id' => $validated['vendor_id'],
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'expected_delivery_date' => $validated['expected_delivery_date'],
|
||||
'total_amount' => $totalAmount,
|
||||
'tax_amount' => $taxAmount,
|
||||
'grand_total' => $grandTotal,
|
||||
'remark' => $validated['remark'],
|
||||
'status' => $validated['status'],
|
||||
'invoice_number' => $validated['invoice_number'] ?? null,
|
||||
'invoice_date' => $validated['invoice_date'] ?? null,
|
||||
'invoice_amount' => $validated['invoice_amount'] ?? null,
|
||||
]);
|
||||
|
||||
// Capture attribute changes for manual logging
|
||||
$dirty = $order->getDirty();
|
||||
$oldAttributes = [];
|
||||
$newAttributes = [];
|
||||
|
||||
foreach ($dirty as $key => $value) {
|
||||
$oldAttributes[$key] = $order->getOriginal($key);
|
||||
$newAttributes[$key] = $value;
|
||||
}
|
||||
|
||||
// Save without triggering events (prevents duplicate log)
|
||||
$order->saveQuietly();
|
||||
|
||||
// 2. Capture old items with product names for diffing
|
||||
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
return [
|
||||
'id' => $item->id,
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product?->name,
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
// Sync items (Original logic)
|
||||
$order->items()->delete();
|
||||
|
||||
$newItemsData = [];
|
||||
foreach ($validated['items'] as $item) {
|
||||
// 反算單價
|
||||
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
|
||||
|
||||
$newItem = $order->items()->create([
|
||||
'product_id' => $item['productId'],
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_id' => $item['unitId'] ?? null,
|
||||
'unit_price' => $unitPrice,
|
||||
'subtotal' => $item['subtotal'],
|
||||
]);
|
||||
$newItemsData[] = $newItem;
|
||||
}
|
||||
|
||||
// 3. Calculate Item Diffs
|
||||
$itemDiffs = [
|
||||
'added' => [],
|
||||
'removed' => [],
|
||||
'updated' => [],
|
||||
];
|
||||
|
||||
// Re-fetch new items to ensure we have fresh relations
|
||||
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
|
||||
return [
|
||||
'product_id' => $item->product_id,
|
||||
'product_name' => $item->product?->name,
|
||||
'quantity' => (float) $item->quantity,
|
||||
'unit_id' => $item->unit_id,
|
||||
'unit_name' => $item->unit?->name,
|
||||
'subtotal' => (float) $item->subtotal,
|
||||
];
|
||||
})->keyBy('product_id');
|
||||
|
||||
// Find removed
|
||||
foreach ($oldItems as $productId => $oldItem) {
|
||||
if (!$newItemsFormatted->has($productId)) {
|
||||
$itemDiffs['removed'][] = $oldItem;
|
||||
}
|
||||
}
|
||||
|
||||
// Find added and updated
|
||||
foreach ($newItemsFormatted as $productId => $newItem) {
|
||||
if (!$oldItems->has($productId)) {
|
||||
$itemDiffs['added'][] = $newItem;
|
||||
} else {
|
||||
$oldItem = $oldItems[$productId];
|
||||
// Compare fields
|
||||
if (
|
||||
$oldItem['quantity'] != $newItem['quantity'] ||
|
||||
$oldItem['unit_id'] != $newItem['unit_id'] ||
|
||||
$oldItem['subtotal'] != $newItem['subtotal']
|
||||
) {
|
||||
$itemDiffs['updated'][] = [
|
||||
'product_name' => $newItem['product_name'],
|
||||
'old' => [
|
||||
'quantity' => $oldItem['quantity'],
|
||||
'unit_name' => $oldItem['unit_name'],
|
||||
'subtotal' => $oldItem['subtotal'],
|
||||
],
|
||||
'new' => [
|
||||
'quantity' => $newItem['quantity'],
|
||||
'unit_name' => $newItem['unit_name'],
|
||||
'subtotal' => $newItem['subtotal'],
|
||||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Manually Log activity (Single Consolidated Log)
|
||||
// Log if there are attribute changes OR item changes
|
||||
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->user())
|
||||
->event('updated')
|
||||
->withProperties([
|
||||
'attributes' => $newAttributes,
|
||||
'old' => $oldAttributes,
|
||||
'items_diff' => $itemDiffs,
|
||||
'snapshot' => [
|
||||
'po_number' => $order->code,
|
||||
'vendor_name' => $order->vendor?->name,
|
||||
'warehouse_name' => $order->warehouse?->name,
|
||||
'user_name' => $order->user?->name,
|
||||
]
|
||||
])
|
||||
->log('updated');
|
||||
}
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已更新');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->withErrors(['error' => '更新失敗:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function destroy($id)
|
||||
{
|
||||
try {
|
||||
DB::beginTransaction();
|
||||
|
||||
$order = PurchaseOrder::with(['items.product', 'items.unit'])->findOrFail($id);
|
||||
|
||||
// Capture items for logging
|
||||
$items = $order->items->map(function ($item) {
|
||||
return [
|
||||
'product_name' => $item->product_name,
|
||||
'quantity' => floatval($item->quantity),
|
||||
'unit_name' => $item->unit_name,
|
||||
'subtotal' => floatval($item->subtotal),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// Manually log the deletion with items
|
||||
activity()
|
||||
->performedOn($order)
|
||||
->causedBy(auth()->user())
|
||||
->event('deleted')
|
||||
->withProperties([
|
||||
'attributes' => $order->getAttributes(),
|
||||
'items_diff' => [
|
||||
'added' => [],
|
||||
'removed' => $items,
|
||||
'updated' => [],
|
||||
],
|
||||
'snapshot' => [
|
||||
'po_number' => $order->code,
|
||||
'vendor_name' => $order->vendor?->name,
|
||||
'warehouse_name' => $order->warehouse?->name,
|
||||
'user_name' => $order->user?->name,
|
||||
]
|
||||
])
|
||||
->log('deleted');
|
||||
|
||||
// Disable automatic logging for this operation
|
||||
$order->disableLogging();
|
||||
|
||||
// Delete associated items first
|
||||
$order->items()->delete();
|
||||
$order->delete();
|
||||
|
||||
DB::commit();
|
||||
|
||||
return redirect()->route('purchase-orders.index')->with('success', '採購單已刪除');
|
||||
} catch (\Exception $e) {
|
||||
DB::rollBack();
|
||||
return back()->withErrors(['error' => '刪除失敗:' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
128
app/Modules/Procurement/Controllers/VendorController.php
Normal file
128
app/Modules/Procurement/Controllers/VendorController.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class VendorController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(\Illuminate\Http\Request $request): \Inertia\Response
|
||||
{
|
||||
$query = Vendor::query();
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('tax_id', 'like', "%{$search}%")
|
||||
->orWhere('owner', 'like', "%{$search}%")
|
||||
->orWhere('contact_name', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
|
||||
$allowedSorts = ['id', 'code', 'name', 'owner', 'contact_name', 'phone'];
|
||||
if (!in_array($sortField, $allowedSorts)) {
|
||||
$sortField = 'id';
|
||||
}
|
||||
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
||||
$sortDirection = 'desc';
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
|
||||
$vendors = $query->orderBy($sortField, $sortDirection)
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
return \Inertia\Inertia::render('Vendor/Index', [
|
||||
'vendors' => $vendors,
|
||||
'filters' => $request->only(['search', 'sort_field', 'sort_direction', 'per_page']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Vendor $vendor): \Inertia\Response
|
||||
{
|
||||
$vendor->load(['products.baseUnit', 'products.largeUnit']);
|
||||
return \Inertia\Inertia::render('Vendor/Show', [
|
||||
'vendor' => $vendor,
|
||||
'products' => \App\Modules\Inventory\Models\Product::with('baseUnit')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(\Illuminate\Http\Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'short_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:8',
|
||||
'owner' => 'nullable|string|max:255',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'tel' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'address' => 'nullable|string',
|
||||
'remark' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Auto-generate code
|
||||
$prefix = 'V';
|
||||
$lastVendor = Vendor::latest('id')->first();
|
||||
$nextId = $lastVendor ? $lastVendor->id + 1 : 1;
|
||||
$code = $prefix . str_pad($nextId, 5, '0', STR_PAD_LEFT);
|
||||
|
||||
$validated['code'] = $code;
|
||||
|
||||
Vendor::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '廠商已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(\Illuminate\Http\Request $request, Vendor $vendor)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'short_name' => 'nullable|string|max:255',
|
||||
'tax_id' => 'nullable|string|max:8',
|
||||
'owner' => 'nullable|string|max:255',
|
||||
'contact_name' => 'nullable|string|max:255',
|
||||
'tel' => 'nullable|string|max:50',
|
||||
'phone' => 'nullable|string|max:50',
|
||||
'email' => 'nullable|email|max:255',
|
||||
'address' => 'nullable|string',
|
||||
'remark' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$vendor->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '廠商資料已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Vendor $vendor)
|
||||
{
|
||||
$vendor->delete();
|
||||
|
||||
return redirect()->back()->with('success', '廠商已刪除');
|
||||
}
|
||||
}
|
||||
126
app/Modules/Procurement/Controllers/VendorProductController.php
Normal file
126
app/Modules/Procurement/Controllers/VendorProductController.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class VendorProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* 新增供貨商品 (Attach)
|
||||
*/
|
||||
public function store(Request $request, Vendor $vendor)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'last_price' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// 檢查是否已存在
|
||||
if ($vendor->products()->where('product_id', $validated['product_id'])->exists()) {
|
||||
return redirect()->back()->with('error', '該商品已在供貨清單中');
|
||||
}
|
||||
|
||||
$vendor->products()->attach($validated['product_id'], [
|
||||
'last_price' => $validated['last_price'] ?? null
|
||||
]);
|
||||
|
||||
// 記錄操作
|
||||
$product = \App\Modules\Inventory\Models\Product::find($validated['product_id']);
|
||||
activity()
|
||||
->performedOn($vendor)
|
||||
->withProperties([
|
||||
'attributes' => [
|
||||
'product_name' => $product->name,
|
||||
'last_price' => $validated['last_price'] ?? null,
|
||||
],
|
||||
'sub_subject' => '供貨商品',
|
||||
'snapshot' => [
|
||||
'name' => "{$vendor->name}-{$product->name}", // 顯示例如:台積電-紅糖
|
||||
'vendor_name' => $vendor->name,
|
||||
'product_name' => $product->name,
|
||||
]
|
||||
])
|
||||
->event('created')
|
||||
->log('新增供貨商品');
|
||||
|
||||
return redirect()->back()->with('success', '供貨商品已新增');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新供貨商品資訊 (Update Pivot)
|
||||
*/
|
||||
public function update(Request $request, Vendor $vendor, $productId)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'last_price' => 'nullable|numeric|min:0',
|
||||
]);
|
||||
|
||||
// 獲取舊價格
|
||||
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
|
||||
|
||||
$vendor->products()->updateExistingPivot($productId, [
|
||||
'last_price' => $validated['last_price'] ?? null
|
||||
]);
|
||||
|
||||
// 記錄操作
|
||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
||||
activity()
|
||||
->performedOn($vendor)
|
||||
->withProperties([
|
||||
'old' => [
|
||||
'last_price' => $old_price,
|
||||
],
|
||||
'attributes' => [
|
||||
'last_price' => $validated['last_price'] ?? null,
|
||||
],
|
||||
'sub_subject' => '供貨商品',
|
||||
'snapshot' => [
|
||||
'name' => "{$vendor->name}-{$product->name}",
|
||||
'vendor_name' => $vendor->name,
|
||||
'product_name' => $product->name,
|
||||
]
|
||||
])
|
||||
->event('updated')
|
||||
->log('更新供貨商品價格');
|
||||
|
||||
return redirect()->back()->with('success', '供貨資訊已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除供貨商品 (Detach)
|
||||
*/
|
||||
public function destroy(Vendor $vendor, $productId)
|
||||
{
|
||||
// 記錄操作 (需在 detach 前獲取資訊)
|
||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
||||
$old_price = $vendor->products()->where('product_id', $productId)->first()?->pivot?->last_price;
|
||||
|
||||
$vendor->products()->detach($productId);
|
||||
|
||||
if ($product) {
|
||||
activity()
|
||||
->performedOn($vendor)
|
||||
->withProperties([
|
||||
'old' => [
|
||||
'product_name' => $product->name,
|
||||
'last_price' => $old_price,
|
||||
],
|
||||
'sub_subject' => '供貨商品',
|
||||
'snapshot' => [
|
||||
'name' => "{$vendor->name}-{$product->name}",
|
||||
'vendor_name' => $vendor->name,
|
||||
'product_name' => $product->name,
|
||||
]
|
||||
])
|
||||
->event('deleted')
|
||||
->log('移除供貨商品');
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', '供貨商品已移除');
|
||||
}
|
||||
}
|
||||
79
app/Modules/Procurement/Models/PurchaseOrder.php
Normal file
79
app/Modules/Procurement/Models/PurchaseOrder.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Core\Models\User;
|
||||
|
||||
class PurchaseOrder extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\PurchaseOrderFactory> */
|
||||
use HasFactory;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'po_number',
|
||||
'vendor_id',
|
||||
'warehouse_id',
|
||||
'user_id',
|
||||
'order_date',
|
||||
'expected_delivery_date',
|
||||
'status',
|
||||
'total_amount',
|
||||
'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'order_date' => 'date',
|
||||
'expected_delivery_date' => 'date',
|
||||
'total_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$snapshot = $activity->properties['snapshot'] ?? [];
|
||||
|
||||
$snapshot['po_number'] = $this->po_number;
|
||||
|
||||
if ($this->vendor) {
|
||||
$snapshot['vendor_name'] = $this->vendor->name;
|
||||
}
|
||||
if ($this->warehouse) {
|
||||
$snapshot['warehouse_name'] = $this->warehouse->name;
|
||||
}
|
||||
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'snapshot' => $snapshot
|
||||
]);
|
||||
}
|
||||
|
||||
public function vendor(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Vendor::class);
|
||||
}
|
||||
|
||||
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrderItem::class);
|
||||
}
|
||||
}
|
||||
44
app/Modules/Procurement/Models/PurchaseOrderItem.php
Normal file
44
app/Modules/Procurement/Models/PurchaseOrderItem.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Procurement\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
|
||||
class PurchaseOrderItem extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\PurchaseOrderItemFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'purchase_order_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'unit_price',
|
||||
'subtotal',
|
||||
// 驗收欄位
|
||||
'received_quantity',
|
||||
// 批號與效期 (驗收時填寫)
|
||||
'batch_number',
|
||||
'expiry_date',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity' => 'decimal:2',
|
||||
'unit_price' => 'decimal:4',
|
||||
'subtotal' => 'decimal:2',
|
||||
'received_quantity' => 'decimal:2',
|
||||
'expiry_date' => 'date',
|
||||
];
|
||||
|
||||
public function purchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
55
app/Modules/Procurement/Models/Vendor.php
Normal file
55
app/Modules/Procurement/Models/Vendor.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?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 App\Modules\Inventory\Models\Product;
|
||||
|
||||
class Vendor extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\VendorFactory> */
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'contact_person',
|
||||
'email',
|
||||
'phone',
|
||||
'address',
|
||||
'tax_id',
|
||||
'payment_terms',
|
||||
];
|
||||
|
||||
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class)->withPivot('last_price')->withTimestamps();
|
||||
}
|
||||
|
||||
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['name'] = $this->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
38
app/Modules/Procurement/Routes/web.php
Normal file
38
app/Modules/Procurement/Routes/web.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Procurement\Controllers\VendorController;
|
||||
use App\Modules\Procurement\Controllers\VendorProductController;
|
||||
use App\Modules\Procurement\Controllers\PurchaseOrderController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
// 廠商管理
|
||||
Route::middleware('permission:vendors.view')->group(function () {
|
||||
Route::get('/vendors', [VendorController::class, 'index'])->name('vendors.index');
|
||||
Route::get('/vendors/{vendor}', [VendorController::class, 'show'])->name('vendors.show');
|
||||
Route::post('/vendors', [VendorController::class, 'store'])->middleware('permission:vendors.create')->name('vendors.store');
|
||||
Route::put('/vendors/{vendor}', [VendorController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.update');
|
||||
Route::delete('/vendors/{vendor}', [VendorController::class, 'destroy'])->middleware('permission:vendors.delete')->name('vendors.destroy');
|
||||
|
||||
// 供貨商品相關路由
|
||||
Route::post('/vendors/{vendor}/products', [VendorProductController::class, 'store'])->middleware('permission:vendors.edit')->name('vendors.products.store');
|
||||
Route::put('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'update'])->middleware('permission:vendors.edit')->name('vendors.products.update');
|
||||
Route::delete('/vendors/{vendor}/products/{product}', [VendorProductController::class, 'destroy'])->middleware('permission:vendors.edit')->name('vendors.products.destroy');
|
||||
});
|
||||
|
||||
// 採購單管理
|
||||
Route::middleware('permission:purchase_orders.view')->group(function () {
|
||||
Route::get('/purchase-orders', [PurchaseOrderController::class, 'index'])->name('purchase-orders.index');
|
||||
|
||||
Route::middleware('permission:purchase_orders.create')->group(function () {
|
||||
Route::get('/purchase-orders/create', [PurchaseOrderController::class, 'create'])->name('purchase-orders.create');
|
||||
Route::post('/purchase-orders', [PurchaseOrderController::class, 'store'])->name('purchase-orders.store');
|
||||
});
|
||||
|
||||
Route::get('/purchase-orders/{id}', [PurchaseOrderController::class, 'show'])->name('purchase-orders.show');
|
||||
|
||||
Route::get('/purchase-orders/{id}/edit', [PurchaseOrderController::class, 'edit'])->middleware('permission:purchase_orders.edit')->name('purchase-orders.edit');
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user