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..2ce49e4 100644
--- a/app/Http/Controllers/Admin/WarehouseController.php
+++ b/app/Http/Controllers/Admin/WarehouseController.php
@@ -722,10 +722,204 @@ class WarehouseController extends Controller
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
- return view('admin.warehouses.machine-inventory', compact('machines', 'companies'));
+ // 供批次匯出 modal 勾選用的完整機台清單(全域 scope 已限可存取機台)
+ $exportMachines = Machine::select('id', 'name', 'serial_no')->orderBy('name')->get();
+
+ return view('admin.warehouses.machine-inventory', compact('machines', 'companies', 'exportMachines'));
}
+ /**
+ * 匯出單一機台的「每個貨道」明細(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, '| ' . htmlspecialchars($header) . ' | ');
+ }
+ 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, '| ' . htmlspecialchars((string) $cell) . ' | ');
+ }
+ fwrite($file, '
');
+ } else {
+ fputcsv($file, $row);
+ }
+ }
+
+ if ($isExcel) {
+ fwrite($file, '
');
+ }
+ fclose($file);
+ };
+
+ return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
+ }
+
+ /**
+ * 批次匯出「機台庫存概覽」——每台機台一個分頁(Excel sheet)/一個檔(CSV),內容為該機台每貨道明細。
+ * Excel = 多分頁 xlsx(OpenSpout);CSV 無分頁概念,故每台一個 .csv 打包成 ZIP。
+ * ids:逗號字串或陣列;空則匯出全部可存取機台。全域 scope 確保只含可存取機台。
+ */
+ public function machineInventoryExportBatch(Request $request)
+ {
+ $isExcel = ($request->input('export', 'csv') === 'excel');
+
+ $ids = $request->input('ids');
+ if (is_string($ids)) {
+ $ids = array_filter(array_map('intval', explode(',', $ids)));
+ } elseif (is_array($ids)) {
+ $ids = array_filter(array_map('intval', $ids));
+ } else {
+ $ids = [];
+ }
+
+ $query = Machine::with(['slots' => fn ($q) => $q->with('product:id,name,barcode')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
+ ->orderBy('name');
+ if (!empty($ids)) {
+ $query->whereIn('id', $ids);
+ }
+ $machines = $query->get();
+
+ $headers = ['貨道號', '商品名稱', '商品條碼', '類型', '目前庫存', '滿庫容量', '庫存率(%)', '效期', '批號', '啟用', '鎖定'];
+ $ts = now()->format('YmdHis');
+
+ // 單台的資料列
+ $rowsOf = function ($machine) {
+ $rows = [];
+ foreach ($machine->slots as $slot) {
+ $stock = (int) $slot->stock;
+ $capacity = (int) $slot->max_stock;
+ $rate = $capacity > 0 ? round($stock / $capacity * 100) : 0;
+ $rows[] = [
+ (string) $slot->slot_no,
+ $slot->product->name ?? '',
+ $slot->product->barcode ?? '',
+ (string) $slot->type,
+ $stock,
+ $capacity,
+ $rate,
+ $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : '',
+ (string) $slot->batch_no,
+ $slot->is_active ? '是' : '否',
+ $slot->is_locked ? '是' : '否',
+ ];
+ }
+ return $rows;
+ };
+
+ // 分頁/檔名(機台名稱_序號,去非法字元、限長、去重;Excel sheet 名上限 31 字)
+ $labelOf = function ($machine, array &$used) {
+ $b = trim(($machine->name ?? '機台') . '_' . ($machine->serial_no ?? $machine->id));
+ $b = preg_replace('/[\\\\\\/\\?\\*\\[\\]:]/u', '_', $b);
+ $b = mb_substr($b, 0, 28);
+ $name = $b;
+ $i = 1;
+ while (in_array($name, $used, true)) {
+ $name = mb_substr($b, 0, 24) . '_' . (++$i);
+ }
+ $used[] = $name;
+ return $name;
+ };
+
+ if ($isExcel) {
+ $tmp = tempnam(sys_get_temp_dir(), 'invxlsx');
+ $writer = new \OpenSpout\Writer\XLSX\Writer();
+ $writer->openToFile($tmp);
+ $used = [];
+ $first = true;
+ foreach ($machines as $machine) {
+ $sheet = $first ? $writer->getCurrentSheet() : $writer->addNewSheetAndMakeItCurrent();
+ $first = false;
+ $sheet->setName($labelOf($machine, $used));
+ $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
+ foreach ($rowsOf($machine) as $row) {
+ $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($row));
+ }
+ }
+ if ($first) {
+ $writer->getCurrentSheet()->setName('機台庫存');
+ $writer->addRow(\OpenSpout\Common\Entity\Row::fromValues($headers));
+ }
+ $writer->close();
+
+ return response()->download($tmp, "機台庫存_{$ts}.xlsx", [
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ ])->deleteFileAfterSend(true);
+ }
+
+ // CSV:每台一個 .csv 打包成 ZIP
+ $tmp = tempnam(sys_get_temp_dir(), 'invzip');
+ $zip = new \ZipArchive();
+ $zip->open($tmp, \ZipArchive::OVERWRITE);
+ $used = [];
+ foreach ($machines as $machine) {
+ $fh = fopen('php://temp', 'r+');
+ fputcsv($fh, $headers);
+ foreach ($rowsOf($machine) as $row) {
+ fputcsv($fh, $row);
+ }
+ rewind($fh);
+ $content = "\xEF\xBB\xBF" . stream_get_contents($fh);
+ fclose($fh);
+ $zip->addFromString($labelOf($machine, $used) . '.csv', $content);
+ }
+ if ($machines->isEmpty()) {
+ $zip->addFromString('機台庫存.csv', "\xEF\xBB\xBF" . implode(',', $headers) . "\n");
+ }
+ $zip->close();
+
+ return response()->download($tmp, "機台庫存_{$ts}.zip")->deleteFileAfterSend(true);
+ }
+
/**
* AJAX:取得單台機台貨道詳情
*/
diff --git a/lang/en.json b/lang/en.json
index 9c4bff1..ebb8007 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -779,6 +779,9 @@
"Export Report": "Export Report",
"Export": "Export",
"Export to CSV": "Export to CSV",
+ "Export Machine Inventory": "Export Machine Inventory",
+ "Please select at least one machine": "Please select at least one machine",
+ "No machines": "No machines",
"Export to Excel": "Export to Excel",
"External URL": "External URL",
"Failed": "Failed",
@@ -1620,6 +1623,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..569580e 100644
--- a/lang/zh_TW.json
+++ b/lang/zh_TW.json
@@ -780,6 +780,9 @@
"Export Report": "匯出報表",
"Export": "匯出",
"Export to CSV": "匯出成 CSV",
+ "Export Machine Inventory": "匯出機台庫存",
+ "Please select at least one machine": "請至少選擇一台機台",
+ "No machines": "沒有機台",
"Export to Excel": "匯出成 Excel",
"External URL": "外連 URL",
"Failed": "失敗",
@@ -1621,6 +1624,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..897b917 100644
--- a/resources/views/admin/warehouses/machine-inventory.blade.php
+++ b/resources/views/admin/warehouses/machine-inventory.blade.php
@@ -85,6 +85,13 @@
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
+
@@ -96,6 +103,60 @@
{{-- Detail Mode --}}
+ {{-- 機台庫存批次匯出 Modal --}}
+
+
+
+
+
+
{{ __('Export Machine Inventory') }}
+
+
+
+
+
+
+ @forelse($exportMachines as $m)
+
+ @empty
+
{{ __('No machines') }}
+ @endforelse
+
+
+
+ {{-- 暫時隱藏 CSV 匯出(保留備用;後端仍支援 csv/zip) --}}
+ {{--
+
+ --}}
+
+
+
+
+
+
@@ -260,6 +321,32 @@
{{ __('Grid View') }}
+
+
+
+
@@ -818,6 +905,35 @@ document.addEventListener('alpine:init', () => {
loading: false,
hasReplenishPermission: {{ auth()->user()->can('menu.warehouses.replenishments') ? 'true' : 'false' }},
+ // 機台庫存批次匯出
+ exportModalOpen: false,
+ exportSelectedIds: [],
+ get exportAllChecked() {
+ const total = {{ $exportMachines->count() }};
+ return total > 0 && this.exportSelectedIds.length === total;
+ },
+ openExportModal() {
+ this.exportSelectedIds = @js($exportMachines->pluck('id'));
+ this.exportModalOpen = true;
+ },
+ toggleExportAll(e) {
+ this.exportSelectedIds = e.target.checked ? @js($exportMachines->pluck('id')) : [];
+ },
+ doExport(format) {
+ if (this.exportSelectedIds.length === 0) {
+ window.dispatchEvent(new CustomEvent('toast', { detail: { message: '{{ __('Please select at least one machine') }}', type: 'error' } }));
+ return;
+ }
+ const url = `/admin/warehouses/machine-inventory/export?export=${format}&ids=${this.exportSelectedIds.join(',')}`;
+ const a = document.createElement('a');
+ a.href = url;
+ a.setAttribute('download', '');
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ this.exportModalOpen = false;
+ },
+
async init() {
const urlParams = new URLSearchParams(window.location.search);
const machineId = urlParams.get('machine_id');
diff --git a/routes/web.php b/routes/web.php
index ed4e10d..11f2c97 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -123,7 +123,9 @@ 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/export', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventoryExportBatch'])->name('machine-inventory.export-batch');
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 +160,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');
// 取貨碼