feat: 整合門市領料日誌、API 文件存取、修改庫存與併發編號問題、供應商商品內聯編輯及日誌 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 1m0s

This commit is contained in:
2026-03-02 16:42:12 +08:00
parent 7dac2d1f77
commit 0a955fb993
33 changed files with 1424 additions and 853 deletions

View File

@@ -104,4 +104,12 @@ interface ProcurementServiceInterface
* 移除供貨商品關聯
*/
public function detachProductFromVendor(int $vendorId, int $productId): void;
/**
* 整批同步供貨商品
*
* @param int $vendorId
* @param array $productsData Format: [['product_id' => 1, 'last_price' => 100], ...]
*/
public function syncVendorProducts(int $vendorId, array $productsData): void;
}

View File

@@ -189,71 +189,81 @@ class PurchaseOrderController extends Controller
]);
try {
DB::beginTransaction();
// 使用 Cache Lock 防止併發時產生重複單號
$lock = \Illuminate\Support\Facades\Cache::lock('po_code_generation', 10);
// 生成單號PO-YYYYMMDD-01
$today = now()->format('Ymd');
$prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->lockForUpdate() // 鎖定以避免並發衝突
->orderBy('code', 'desc')
->first();
if ($lastOrder) {
// 取得最後 2 碼序號並加 1
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
$sequence = '01';
}
$code = $prefix . $sequence;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
if (!$lock->get()) {
return back()->withErrors(['error' => '系統忙碌中,請稍後再試']);
}
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
try {
DB::beginTransaction();
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = $this->coreService->ensureSystemUserExists(); $userId = $user->id;
}
// 生成單號PO-YYYYMMDD-01
$today = now()->format('Ymd');
$prefix = 'PO-' . $today . '-';
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
->orderBy('code', 'desc')
->first();
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'order_date' => $validated['order_date'], // 新增
'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,
]);
if ($lastOrder) {
$lastSequence = intval(substr($lastOrder->code, -2));
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
} else {
$sequence = '01';
}
$code = $prefix . $sequence;
foreach ($validated['items'] as $item) {
// 反算單價
$unitPrice = $item['quantity'] > 0 ? $item['subtotal'] / $item['quantity'] : 0;
$totalAmount = 0;
foreach ($validated['items'] as $item) {
$totalAmount += $item['subtotal'];
}
$order->items()->create([
'product_id' => $item['productId'],
'quantity' => $item['quantity'],
'unit_id' => $item['unitId'] ?? null,
'unit_price' => $unitPrice,
'subtotal' => $item['subtotal'],
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 確保有一個有效的使用者 ID
$userId = auth()->id();
if (!$userId) {
$user = $this->coreService->ensureSystemUserExists();
$userId = $user->id;
}
$order = PurchaseOrder::create([
'code' => $code,
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
'user_id' => $userId,
'status' => 'draft',
'order_date' => $validated['order_date'],
'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,
]);
}
DB::commit();
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();
} finally {
$lock->release();
}
return redirect()->route('purchase-orders.index')->with('success', '採購單已成功建立');

View File

@@ -133,4 +133,35 @@ class VendorProductController extends Controller
return redirect()->back()->with('success', '供貨商品已移除');
}
/**
* 整批同步供貨商品
*/
public function sync(Request $request, Vendor $vendor)
{
$validated = $request->validate([
'products' => 'present|array',
'products.*.product_id' => 'required|exists:products,id',
'products.*.last_price' => 'nullable|numeric|min:0',
]);
$this->procurementService->syncVendorProducts($vendor->id, $validated['products']);
activity()
->performedOn($vendor)
->withProperties([
'attributes' => [
'products_count' => count($validated['products']),
],
'sub_subject' => '供貨商品',
'snapshot' => [
'name' => "{$vendor->name} 的供貨清單",
'vendor_name' => $vendor->name,
]
])
->event('updated')
->log('整批更新供貨商品');
return redirect()->back()->with('success', '供貨商品已更新');
}
}

View File

@@ -24,6 +24,9 @@ class PurchaseOrder extends Model
'tax_amount',
'grand_total',
'remark',
'invoice_number',
'invoice_date',
'invoice_amount',
];
protected $casts = [

View File

@@ -16,6 +16,7 @@ Route::middleware('auth')->group(function () {
// 供貨商品相關路由
Route::post('/vendors/{vendor}/products', [VendorProductController::class, 'store'])->middleware('permission:vendors.edit')->name('vendors.products.store');
Route::put('/vendors/{vendor}/products/sync', [VendorProductController::class, 'sync'])->middleware('permission:vendors.edit')->name('vendors.products.sync');
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');
});

View File

@@ -147,4 +147,48 @@ class ProcurementService implements ProcurementServiceInterface
->where('product_id', $productId)
->delete();
}
public function syncVendorProducts(int $vendorId, array $productsData): void
{
\Illuminate\Support\Facades\DB::transaction(function () use ($vendorId, $productsData) {
$existingPivots = \Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->get();
$existingProductIds = $existingPivots->pluck('product_id')->toArray();
$newProductIds = array_column($productsData, 'product_id');
$toDelete = array_diff($existingProductIds, $newProductIds);
if (!empty($toDelete)) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->whereIn('product_id', $toDelete)
->delete();
}
foreach ($productsData as $data) {
$exists = in_array($data['product_id'], $existingProductIds);
if ($exists) {
\Illuminate\Support\Facades\DB::table('product_vendor')
->where('vendor_id', $vendorId)
->where('product_id', $data['product_id'])
->update([
'last_price' => $data['last_price'] ?? null,
'updated_at' => now(),
]);
} else {
\Illuminate\Support\Facades\DB::table('product_vendor')
->insert([
'vendor_id' => $vendorId,
'product_id' => $data['product_id'],
'last_price' => $data['last_price'] ?? null,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
});
}
}