From 1aa5aed72e4d8e07e1413358e904d25bcadb75b3 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 19 May 2026 12:33:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(sales):=20=E5=8D=87=E7=B4=9A=E9=8A=B7?= =?UTF-8?q?=E5=94=AE=E7=B4=80=E9=8C=84=E9=AB=98=E5=A5=A2=E7=BF=A1=E7=BF=A0?= =?UTF-8?q?=E7=B6=A0=E5=B0=8E=E5=87=BA=E4=B8=8B=E6=8B=89=E9=81=B8=E5=96=AE?= =?UTF-8?q?=E8=88=87=E4=BF=AE=E5=BE=A9=E9=80=B2=E5=BA=A6=E6=A2=9D=E5=8D=A1?= =?UTF-8?q?=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Admin/SalesController.php | 274 ++++++++++++++++++ lang/zh_TW.json | 6 +- .../data-config/staff-cards/index.blade.php | 3 +- resources/views/admin/sales/index.blade.php | 125 ++++++++ .../sales/partials/tab-dispense.blade.php | 31 ++ .../sales/partials/tab-invoices.blade.php | 31 ++ .../admin/sales/partials/tab-orders.blade.php | 31 ++ 7 files changed, 499 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php index 142d376..6e13561 100644 --- a/app/Http/Controllers/Admin/SalesController.php +++ b/app/Http/Controllers/Admin/SalesController.php @@ -125,6 +125,12 @@ class SalesController extends Controller if ($status) $ordersQuery->where('status', $status); } + // 匯出功能攔截 + if ($request->filled('export')) { + $exportType = $request->input('export'); + return $this->handleExport($tab, $exportType, $ordersQuery, $invoicesQuery, $dispenseQuery); + } + // 3. 執行分頁 (使用獨立的 pageName) $perPage = $request->input('per_page', 10); $data['orders'] = $ordersQuery->latest()->paginate($perPage, ['*'], 'orders_page')->withQueryString(); @@ -597,4 +603,272 @@ class SalesController extends Controller 'description' => '來店優惠活動設定', ]); } + + /** + * 處理銷售中心資料匯出 (CSV 串流下載) + */ + private function handleExport($tab, $type, $ordersQuery, $invoicesQuery, $dispenseQuery) + { + $isExcel = ($type === 'excel'); + $ext = $isExcel ? 'xls' : 'csv'; + $contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8'; + + $filename = ''; + $headers = []; + $callback = null; + + if ($tab === 'orders') { + $filename = '交易紀錄_' . now()->format('YmdHis') . '.' . $ext; + $headers = [ + __('Order Number'), + __('Transaction Time'), + __('Machine Name'), + __('Machine Serial No'), + __('Product Items'), + __('Quantity'), + __('Original Amount'), + __('Discount Amount'), + __('Payment Amount'), + __('Payment Type'), + __('Status'), + __('Invoice Number'), + __('Staff Name'), + __('Staff ID'), + __('Card UID'), + ]; + + // 預先載入關聯以防 N+1 + $ordersQuery->with([ + 'machine:id,name,serial_no', + 'invoice:id,order_id,invoice_no', + 'items:id,order_id,product_name,quantity', + 'staffCardLog:id,order_id,staff_card_id', + 'staffCardLog.staffCard:id,employee_id,name,card_uid' + ]); + + $callback = function () use ($ordersQuery, $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); + } + + // 分批 (Chunk) 查詢,每次 500 筆,確保常數記憶體用量 + $ordersQuery->latest()->chunk(500, function ($orders) use ($file, $isExcel) { + foreach ($orders as $order) { + $productSummary = $order->items->map(function ($item) { + return $item->product_name . ' x' . number_format($item->quantity, 0); + })->implode(', '); + + $totalQty = $order->items->sum('quantity'); + + $row = [ + $order->order_no, + $order->created_at->format('Y-m-d H:i:s'), + $order->machine->name ?? 'Unknown', + $order->machine->serial_no ?? '---', + $productSummary ?: '---', + $totalQty, + number_format($order->total_amount, 2, '.', ''), + number_format($order->discount_amount, 2, '.', ''), + number_format($order->pay_amount, 2, '.', ''), + $order->payment_type_label, + __($order->status), + $order->invoice->invoice_no ?? '---', + $order->staffCardLog->staffCard->name ?? '---', + $order->staffCardLog->staffCard->employee_id ?? '---', + $order->staffCardLog->staffCard->card_uid ?? '---', + ]; + + if ($isExcel) { + fwrite($file, ''); + foreach ($row as $val) { + fwrite($file, ''); + } + fwrite($file, ''); + } else { + fputcsv($file, $row); + } + } + }); + + if ($isExcel) { + fwrite($file, '
' . htmlspecialchars($header) . '
' . htmlspecialchars($val) . '
'); + } + fclose($file); + }; + } elseif ($tab === 'invoices') { + $filename = '電子發票紀錄_' . now()->format('YmdHis') . '.' . $ext; + $headers = [ + __('Invoice Number'), + __('Invoice Date'), + __('Flow ID'), + __('Machine Name'), + __('Machine Serial No'), + __('Associated Order'), + __('Amount'), + __('Status'), + ]; + + // 預先載入關聯 + $invoicesQuery->with([ + 'machine:id,name,serial_no', + 'order:id,order_no' + ]); + + $callback = function () use ($invoicesQuery, $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"); + fputcsv($file, $headers); + } + + $invoicesQuery->latest()->chunk(500, function ($invoices) use ($file, $isExcel) { + foreach ($invoices as $inv) { + // 狀態判斷邏輯需與 tab-invoices.blade.php 完全一致 + $status = 'valid'; + if (empty($inv->invoice_no)) $status = 'failed'; + if ($inv->rtn_code === 'void') $status = 'void'; + + $statusMap = [ + 'valid' => __('Valid'), + 'void' => __('Void'), + 'failed' => __('Failed'), + 'refunded' => __('Refunded'), + ]; + $statusLabel = $statusMap[$status] ?? $status; + + $row = [ + $inv->invoice_no ?: '---', + $inv->invoice_date ?: '---', + $inv->flow_id ?: '---', + $inv->machine->name ?? ($inv->order->machine->name ?? 'Unknown'), + $inv->machine->serial_no ?? ($inv->order->machine->serial_no ?? '---'), + $inv->order->order_no ?? '---', + number_format($inv->amount, 2, '.', ''), + $statusLabel, + ]; + + if ($isExcel) { + fwrite($file, ''); + foreach ($row as $val) { + fwrite($file, ''); + } + fwrite($file, ''); + } else { + fputcsv($file, $row); + } + } + }); + + if ($isExcel) { + fwrite($file, '
' . htmlspecialchars($header) . '
' . htmlspecialchars($val) . '
'); + } + fclose($file); + }; + } elseif ($tab === 'dispense') { + $filename = '遠端出貨紀錄_' . now()->format('YmdHis') . '.' . $ext; + $headers = [ + __('Machine'), + __('Machine Serial No'), + __('Product Name'), + __('Slot No'), + __('Amount'), + __('Dispense Status'), + __('Dispense Time'), + __('Associated Order'), + ]; + + // 預先載入關聯 + $dispenseQuery->with([ + 'machine:id,name,serial_no', + 'product:id,name', + 'order:id,order_no' + ]); + + $callback = function () use ($dispenseQuery, $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"); + fputcsv($file, $headers); + } + + $dispenseQuery->latest()->chunk(500, function ($logs) use ($file, $isExcel) { + foreach ($logs as $log) { + // 出貨狀態對照,需與 tab-dispense.blade.php 完全一致 + $statusMap = [ + 'success' => __('Dispense Success'), + 'failed' => __('Dispense Failed'), + 'pending' => __('Dispense Pending'), + '1' => __('Dispense Success'), + '0' => __('Dispense Failed'), + ]; + $statusKey = (string)($log->dispense_status ?? 'pending'); + $statusLabel = $statusMap[$statusKey] ?? $statusKey; + + $row = [ + $log->machine->name ?? 'Unknown', + $log->machine->serial_no ?? '---', + $log->product->name ?? 'Unknown Product', + $log->slot_no, + number_format($log->amount, 2, '.', ''), + $statusLabel, + $log->machine_time ? $log->machine_time->format('Y-m-d H:i:s') : '---', + $log->order->order_no ?? '---', + ]; + + if ($isExcel) { + fwrite($file, ''); + foreach ($row as $val) { + fwrite($file, ''); + } + fwrite($file, ''); + } else { + fputcsv($file, $row); + } + } + }); + + if ($isExcel) { + fwrite($file, '
' . htmlspecialchars($header) . '
' . htmlspecialchars($val) . '
'); + } + fclose($file); + }; + } + + if ($callback) { + return response()->streamDownload($callback, $filename, [ + 'Content-Type' => $contentType, + 'Cache-Control' => 'no-cache, must-revalidate', + 'Pragma' => 'no-cache', + 'Expires' => '0', + ]); + } + + return redirect()->back(); + } } diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 37bd298..33a8035 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -2085,5 +2085,9 @@ "Total Net Profit": "總淨利潤", "Gross Margin": "毛利率", "No product data matching search criteria": "沒有符合搜尋條件的商品資料", - "Analyze Solely": "單獨分析" + "Analyze Solely": "單獨分析", + "Order Number": "訂單編號", + "Product Items": "商品品項", + "Original Amount": "應付金額", + "Discount Amount": "折扣金額" } \ No newline at end of file diff --git a/resources/views/admin/data-config/staff-cards/index.blade.php b/resources/views/admin/data-config/staff-cards/index.blade.php index 77ac1ee..9f858c0 100644 --- a/resources/views/admin/data-config/staff-cards/index.blade.php +++ b/resources/views/admin/data-config/staff-cards/index.blade.php @@ -140,7 +140,8 @@

{{ __('Please use our standard template to ensure data compatibility.') }}

- {{ __('Download Template') }} diff --git a/resources/views/admin/sales/index.blade.php b/resources/views/admin/sales/index.blade.php index 9dc5b06..878583b 100644 --- a/resources/views/admin/sales/index.blade.php +++ b/resources/views/admin/sales/index.blade.php @@ -196,6 +196,131 @@ function salesCenter() { console.error('Failed to load order detail:', err); this.showPanel = false; }); + }, + + exportData(type) { + const container = document.getElementById('tab-' + this.activeTab + '-container'); + let params = new URLSearchParams(); + params.set('tab', this.activeTab); + params.set('export', type); + + if (container) { + const form = container.querySelector('form'); + if (form) { + const formData = new FormData(form); + formData.forEach((value, key) => { + if (value && value.trim() !== '') { + if (key !== 'tab' && key !== 'export') { + params.append(key, value); + } + } + }); + } + } + + // 使用隱藏的 iframe 進行下載,避免觸發主視窗的 beforeunload 事件, + // 從而防止全域的頂部進度條 (top-loading-bar) 被觸發並卡在 95% 的位置。 + let iframe = document.getElementById('export-download-iframe'); + if (!iframe) { + iframe = document.createElement('iframe'); + iframe.id = 'export-download-iframe'; + iframe.style.display = 'none'; + document.body.appendChild(iframe); + } + iframe.src = `${window.location.pathname}?${params.toString()}`; + + // 顯示成功提示訊息,與商品報表保持一致 + if (type === 'csv') { + window.showToast?.('CSV 報表導出成功', 'success'); + } else if (type === 'excel') { + window.showToast?.('Excel 報表導出成功', 'success'); + } + }, + + copyTableData() { + const container = document.getElementById('tab-' + this.activeTab + '-container'); + if (!container) return; + const table = container.querySelector('table'); + if (!table) return; + + let headers = []; + table.querySelectorAll('thead th').forEach((th, idx, arr) => { + if (idx < arr.length - 1) { + headers.push(th.innerText.trim().replace(/\s+/g, ' ')); + } + }); + + let rows = [headers.join('\t')]; + table.querySelectorAll('tbody tr').forEach(tr => { + if (tr.querySelector('td.colspan') || tr.querySelector('td[colspan]')) return; + let cells = []; + tr.querySelectorAll('td').forEach((td, idx, arr) => { + if (idx < arr.length - 1) { + let text = td.innerText.trim().replace(/\r?\n/g, ' ').replace(/\s+/g, ' '); + cells.push(text); + } + }); + if (cells.length > 0) { + rows.push(cells.join('\t')); + } + }); + + navigator.clipboard.writeText(rows.join('\n')).then(() => { + window.showToast?.('已成功將明細複製至剪貼簿', 'success'); + }).catch(err => { + console.error('Clipboard copy failed:', err); + window.showToast?.('複製失敗,瀏覽器權限可能不足', 'error'); + }); + }, + + printTable() { + const container = document.getElementById('tab-' + this.activeTab + '-container'); + if (!container) return; + const table = container.querySelector('table'); + if (!table) return; + + const clonedTable = table.cloneNode(true); + + // 移除所有 SVG 圖標,防止列印或 PDF 輸出時因為沒有載入 Tailwind 樣式而顯示巨大黑色區塊 + clonedTable.querySelectorAll('svg').forEach(svg => svg.remove()); + + clonedTable.querySelectorAll('tr').forEach(tr => { + const cells = tr.querySelectorAll('th, td'); + if (cells.length > 0) { + cells[cells.length - 1].remove(); + } + }); + + let tabTitle = ''; + if (this.activeTab === 'orders') tabTitle = '交易紀錄'; + else if (this.activeTab === 'invoices') tabTitle = '電子發票'; + else if (this.activeTab === 'dispense') tabTitle = '遠端出貨紀錄'; + + const printWindow = window.open('', '_blank'); + let html = `${tabTitle}`; + html += ``; + html += `

${tabTitle}

`; + html += `
列印時間:${new Date().toLocaleString()}
`; + html += clonedTable.outerHTML; + html += ``; + + printWindow.document.write(html); + printWindow.document.close(); + printWindow.focus(); + setTimeout(() => { + printWindow.print(); + printWindow.close(); + }, 250); } } } diff --git a/resources/views/admin/sales/partials/tab-dispense.blade.php b/resources/views/admin/sales/partials/tab-dispense.blade.php index 14fb16f..199d2ba 100644 --- a/resources/views/admin/sales/partials/tab-dispense.blade.php +++ b/resources/views/admin/sales/partials/tab-dispense.blade.php @@ -100,6 +100,37 @@ 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" /> + +
+ + + +
+ + + + +
+
diff --git a/resources/views/admin/sales/partials/tab-invoices.blade.php b/resources/views/admin/sales/partials/tab-invoices.blade.php index a208b03..153ae88 100644 --- a/resources/views/admin/sales/partials/tab-invoices.blade.php +++ b/resources/views/admin/sales/partials/tab-invoices.blade.php @@ -101,6 +101,37 @@ 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" /> + +
+ + + +
+ + + + +
+
diff --git a/resources/views/admin/sales/partials/tab-orders.blade.php b/resources/views/admin/sales/partials/tab-orders.blade.php index 15e72ca..28ec407 100644 --- a/resources/views/admin/sales/partials/tab-orders.blade.php +++ b/resources/views/admin/sales/partials/tab-orders.blade.php @@ -101,6 +101,37 @@ 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" /> + +
+ + + +
+ + + + +
+