From 83e1beb7e24700ce8108e484a15769ba0abca1e5 Mon Sep 17 00:00:00 2001 From: twsystem1004 Date: Mon, 13 Jul 2026 08:00:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E5=80=89=E5=BA=AB/=E9=8A=B7=E5=94=AE):=20?= =?UTF-8?q?=E6=A9=9F=E5=8F=B0=E8=B2=A8=E9=81=93=E6=98=8E=E7=B4=B0=E5=8C=AF?= =?UTF-8?q?=E5=87=BA=20+=20=E5=87=BA=E8=B2=A8=E5=A4=B1=E6=95=97=E8=A3=9C?= =?UTF-8?q?=E5=84=9F=E5=8F=96=E8=B2=A8=E7=A2=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 倉庫→機台庫存概覽:點進機台後,矩陣/表格視圖旁加「匯出」下拉(CSV/Excel), 內容為該機台每個貨道明細(貨道號/商品/庫存/容量/庫存率/效期/批號等)。 2. 銷售紀錄新增「取貨碼」欄位:對「付款成功但出貨失敗/部分」的單顯示「+」, 點擊確認「是否產生取貨碼(七天內有效)」後,依缺量產生取貨碼(綁機台+商品、 usage_limit=缺量、冪等 batch_no=COMP{id}、不設 order_id),顯示碼+「待使用」。 消費者於機台輸入碼走現成 B660 核銷出貨,app 端不需改。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Admin/SalesController.php | 97 +++++++++++++++++++ .../Controllers/Admin/WarehouseController.php | 75 ++++++++++++++ lang/en.json | 7 ++ lang/zh_TW.json | 7 ++ resources/views/admin/sales/index.blade.php | 18 ++++ .../admin/sales/partials/tab-orders.blade.php | 69 ++++++++++++- .../warehouses/machine-inventory.blade.php | 26 +++++ routes/web.php | 2 + 8 files changed, 299 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php index 7bc0d1d..2688188 100644 --- a/app/Http/Controllers/Admin/SalesController.php +++ b/app/Http/Controllers/Admin/SalesController.php @@ -164,6 +164,11 @@ class SalesController extends Controller $data['invoices'] = $invoicesQuery->latest()->paginate($perPage, ['*'], 'invoices_page')->withQueryString(); $data['dispenseLogs'] = $dispenseQuery->latest()->paginate($perPage, ['*'], 'dispense_page')->withQueryString(); + // 出貨失敗補償取貨碼:僅掛載既有碼供顯示;產碼由 + 按鈕手動觸發 generateOrderPickupCode() + if ($tab === 'orders') { + $this->attachOrderPickupCodes($data['orders']->getCollection()); + } + if ($isAjax) { return response()->json([ 'success' => true, @@ -175,6 +180,98 @@ class SalesController extends Controller return view('admin.sales.index', $data); } + /** + * 掛載訂單的補償取貨碼(batch_no='COMP{id}')供銷售頁顯示。只讀不產碼。 + */ + private function attachOrderPickupCodes($orders): void + { + if ($orders->isEmpty()) { + return; + } + $batchNos = $orders->map(fn ($o) => 'COMP' . $o->id)->all(); + $codesByBatch = PickupCode::whereIn('batch_no', $batchNos)->get()->groupBy('batch_no'); + foreach ($orders as $order) { + $order->setRelation('compensationCodes', $codesByBatch->get('COMP' . $order->id, collect())); + } + } + + /** + * 手動產生「出貨失敗補償取貨碼」:由銷售頁 + 按鈕、前端確認後 POST 觸發。 + * 僅限「付款成功(completed/paid)但出貨失敗/部分(delivery 0/2)」的訂單;依每商品缺量各產一碼 + * (綁機台+商品、7 天有效、usage_limit=缺量);冪等(batch_no='COMP{id}')。 + * 不設 order_id——取貨碼核銷時 order_id 會被回填為「新出貨單」。 + */ + public function generateOrderPickupCode(Request $request, Order $order) + { + if (!Auth::user()->isSystemAdmin() && $order->machine_id && !Machine::whereKey($order->machine_id)->exists()) { + abort(403); + } + + $isPaid = in_array($order->status, [Order::STATUS_COMPLETED, 'paid'], true); + $failedDelivery = in_array((int) $order->delivery_status, [Order::DELIVERY_STATUS_FAILED, Order::DELIVERY_STATUS_PARTIAL], true); + if (!$isPaid || !$failedDelivery || !$order->machine_id) { + return response()->json(['success' => false, 'message' => __('This order is not eligible for a pickup code.')], 422); + } + + $batchNo = 'COMP' . $order->id; + $existing = PickupCode::where('batch_no', $batchNo)->get(); + if ($existing->isNotEmpty()) { + return response()->json(['success' => true, 'message' => __('Pickup code already generated.'), 'codes' => $existing->pluck('code')]); + } + + $machine = $order->machine ?: Machine::find($order->machine_id); + if (!$machine) { + return response()->json(['success' => false, 'message' => __('Machine not found.')], 422); + } + + $ordered = $order->items->groupBy('product_id')->map(fn ($g) => (int) $g->sum('quantity')); + $dispensedOk = DispenseRecord::where('order_id', $order->id) + ->where('dispense_status', 1) + ->get()->groupBy('product_id')->map(fn ($g) => (int) $g->sum('amount')); + + $created = []; + foreach ($ordered as $productId => $orderedQty) { + if (!$productId) { + continue; + } + $shortfall = $orderedQty - (int) ($dispensedOk[$productId] ?? 0); + if ($shortfall <= 0) { + continue; + } + + $code = PickupCode::create([ + 'company_id' => $order->company_id ?? $machine->company_id, + 'machine_id' => $order->machine_id, + 'product_id' => $productId, + 'code' => PickupCode::generateUniqueCode($order->machine_id), + 'slug' => Str::random(16), + 'batch_no' => $batchNo, + 'status' => 'active', + 'usage_limit' => $shortfall, + 'usage_count' => 0, + 'expires_at' => now()->addDays(7), + 'created_by' => Auth::id(), + ]); + $created[] = $code->code; + + SystemOperationLog::create([ + 'company_id' => $machine->company_id, + 'user_id' => Auth::id(), + 'module' => 'pickup_code', + 'action' => 'create', + 'target_id' => $code->id, + 'target_type' => PickupCode::class, + 'new_values' => $code->toArray() + ['source' => 'failed_delivery_compensation', 'source_order_id' => $order->id], + ]); + } + + if (empty($created)) { + return response()->json(['success' => false, 'message' => __('No undelivered items to compensate.')], 422); + } + + return response()->json(['success' => true, 'message' => __('Pickup code generated.'), 'codes' => $created]); + } + /** * 取得單筆交易詳情 (用於 Slide-over) */ diff --git a/app/Http/Controllers/Admin/WarehouseController.php b/app/Http/Controllers/Admin/WarehouseController.php index d467530..4df4cf4 100644 --- a/app/Http/Controllers/Admin/WarehouseController.php +++ b/app/Http/Controllers/Admin/WarehouseController.php @@ -726,6 +726,81 @@ class WarehouseController extends Controller } + /** + * 匯出單一機台的「每個貨道」明細(CSV / Excel)——供機台詳細視圖(矩陣/表格)旁的匯出鈕使用。 + * 參考 ProductController::export / SalesController::handleExport 的串流下載模式。 + */ + public function machineInventoryExport(Request $request, Machine $machine) + { + $type = $request->input('export', 'csv'); + $isExcel = ($type === 'excel'); + $ext = $isExcel ? 'xls' : 'csv'; + $contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8'; + + $slots = $machine->slots() + ->with('product:id,name,barcode') + ->orderByRaw('CAST(slot_no AS UNSIGNED) ASC') + ->get(); + + $headers = ['貨道號', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定']; + + $safeSerial = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $machine->serial_no); + $filename = '貨道明細_' . ($safeSerial ?: $machine->id) . '_' . now()->format('YmdHis') . '.' . $ext; + + $callback = function () use ($slots, $headers, $isExcel) { + $file = fopen('php://output', 'w'); + if ($isExcel) { + fwrite($file, ''); + fwrite($file, ''); + fwrite($file, ''); + foreach ($headers as $header) { + fwrite($file, ''); + } + fwrite($file, ''); + } else { + fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM + fputcsv($file, $headers); + } + + foreach ($slots as $slot) { + $stock = (int) $slot->stock; + $capacity = (int) $slot->max_stock; + $rate = $capacity > 0 ? round($stock / $capacity * 100) : 0; + + $row = [ + $slot->slot_no, + $slot->product->name ?? '', + $slot->product->barcode ?? '', + $slot->type, + $stock, + $capacity, + $rate, + $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '', + $slot->batch_no, + $slot->is_active ? '是' : '否', + $slot->is_locked ? '是' : '否', + ]; + + if ($isExcel) { + fwrite($file, ''); + foreach ($row as $cell) { + fwrite($file, ''); + } + fwrite($file, ''); + } else { + fputcsv($file, $row); + } + } + + if ($isExcel) { + fwrite($file, '
' . htmlspecialchars($header) . '
' . htmlspecialchars((string) $cell) . '
'); + } + fclose($file); + }; + + return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]); + } + /** * AJAX:取得單台機台貨道詳情 */ diff --git a/lang/en.json b/lang/en.json index 9c4bff1..c172046 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1620,6 +1620,13 @@ "Picked up": "Picked up", "Picked up Time": "Picked up Time", "Pickup Code": "Pickup Code", + "Pending Use": "Pending Use", + "Generate pickup code? (Valid for pickup within 7 days)": "Generate pickup code? (Valid for pickup within 7 days)", + "Pickup code generated.": "Pickup code generated.", + "Pickup code already generated.": "Pickup code already generated.", + "This order is not eligible for a pickup code.": "This order is not eligible for a pickup code.", + "No undelivered items to compensate.": "No undelivered items to compensate.", + "Machine not found.": "Machine not found.", "Pickup Code (8 Digits)": "Pickup Code (8 Digits)", "Pickup Codes": "Pickup Codes", "Pickup Module": "Pickup Module", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 5512d47..4b307d6 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -1621,6 +1621,13 @@ "Picked up": "領取", "Picked up Time": "領取時間", "Pickup Code": "取貨碼", + "Pending Use": "待使用", + "Generate pickup code? (Valid for pickup within 7 days)": "是否產生取貨碼?(取貨 7 天內有效)", + "Pickup code generated.": "取貨碼已產生。", + "Pickup code already generated.": "此訂單已產生過取貨碼。", + "This order is not eligible for a pickup code.": "此訂單不符合產生取貨碼的條件。", + "No undelivered items to compensate.": "沒有需要補償的未出貨商品。", + "Machine not found.": "找不到機台。", "Pickup Code (8 Digits)": "取貨碼 (8 位數)", "Pickup Codes": "取貨碼", "Pickup Module": "取貨模組", diff --git a/resources/views/admin/sales/index.blade.php b/resources/views/admin/sales/index.blade.php index feb71a7..b06f19a 100644 --- a/resources/views/admin/sales/index.blade.php +++ b/resources/views/admin/sales/index.blade.php @@ -233,6 +233,24 @@ function salesCenter() { } }, + async generatePickupCode(orderId) { + if (!confirm('{{ __('Generate pickup code? (Valid for pickup within 7 days)') }}')) return; + try { + const res = await fetch(`/admin/sales/orders/${orderId}/pickup-code`, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json', + } + }); + const data = await res.json(); + window.dispatchEvent(new CustomEvent('toast', { detail: { message: data.message || '', type: data.success ? 'success' : 'error' } })); + if (data.success) this.fetchTabData('orders'); + } catch (e) { + window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Operation failed') }}', type: 'error' } })); + } + }, openDetail(orderId) { this.showPanel = true; document.getElementById('order-detail-content').innerHTML = `
`; diff --git a/resources/views/admin/sales/partials/tab-orders.blade.php b/resources/views/admin/sales/partials/tab-orders.blade.php index 34fe611..ae790e3 100644 --- a/resources/views/admin/sales/partials/tab-orders.blade.php +++ b/resources/views/admin/sales/partials/tab-orders.blade.php @@ -212,6 +212,10 @@ class="px-6 py-4 text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-[0.15em] border-b border-slate-100 dark:border-slate-800"> {{ __('Dispense Status') }} + + {{ __('Pickup Code') }} + {{ __('Invoice Number') }} @@ -346,6 +350,36 @@ @endif + + @php + $qualifiesForPickup = in_array($order->status, ['completed','paid'], true) && in_array((int) $order->delivery_status, [0, 2], true); + $orderPickupCodes = $order->relationLoaded('compensationCodes') ? $order->compensationCodes : collect(); + @endphp + @if($orderPickupCodes->count()) +
+ @foreach($orderPickupCodes as $cc) + + {{ $cc->code }} + @if($cc->status === 'active' && $cc->usage_count < $cc->usage_limit) + {{ __('Pending Use') }} + @else + {{ __('Used') }} + @endif + + @endforeach +
+ @elseif($qualifiesForPickup) + + @else + -- + @endif + @php $invoiceSearchValue = $order->invoice ? $order->invoice->invoice_no : $order->order_no; @@ -390,8 +424,8 @@ @empty - - + + @endforelse @@ -533,6 +567,37 @@ @endif +
+

{{ __('Pickup Code') }}

+ @php + $qualifiesForPickup = in_array($order->status, ['completed','paid'], true) && in_array((int) $order->delivery_status, [0, 2], true); + $orderPickupCodes = $order->relationLoaded('compensationCodes') ? $order->compensationCodes : collect(); + @endphp + @if($orderPickupCodes->count()) +
+ @foreach($orderPickupCodes as $cc) + + {{ $cc->code }} + @if($cc->status === 'active' && $cc->usage_count < $cc->usage_limit) + {{ __('Pending Use') }} + @else + {{ __('Used') }} + @endif + + @endforeach +
+ @elseif($qualifiesForPickup) + + @else + -- + @endif +
{{-- Remark --}} diff --git a/resources/views/admin/warehouses/machine-inventory.blade.php b/resources/views/admin/warehouses/machine-inventory.blade.php index f95b20d..6ec0fdf 100644 --- a/resources/views/admin/warehouses/machine-inventory.blade.php +++ b/resources/views/admin/warehouses/machine-inventory.blade.php @@ -260,6 +260,32 @@ {{ __('Grid View') }} +
diff --git a/routes/web.php b/routes/web.php index ed4e10d..cffdcdb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -124,6 +124,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix // 模組 4:機台庫存總覽 (Force Route Refresh) Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory'); Route::get('/machine-inventory/{machine}/slots', [App\Http\Controllers\Admin\WarehouseController::class, 'machineSlots'])->name('machine-inventory.slots'); + Route::get('/machine-inventory/{machine}/export', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventoryExport'])->name('machine-inventory.export'); Route::get('/machine-inventory/{machine}/movements', [App\Http\Controllers\Admin\WarehouseController::class, 'machineStockMovements'])->name('machine-inventory.movements'); // 模組 5:機台補貨 @@ -158,6 +159,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix // 管理者備註(訂單/發票各自獨立) Route::patch('/orders/{order}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateOrderRemark'])->name('orders.remark'); + Route::post('/orders/{order}/pickup-code', [App\Http\Controllers\Admin\SalesController::class, 'generateOrderPickupCode'])->name('orders.pickup-code'); Route::patch('/invoices/{invoice}/remark', [App\Http\Controllers\Admin\SalesController::class, 'updateInvoiceRemark'])->name('invoices.remark'); // 取貨碼