From 34b0a7faf63d47fa66ff15947492f07a402b2341 Mon Sep 17 00:00:00 2001 From: twsystem1004 Date: Tue, 14 Jul 2026 02:46:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(=E5=80=89=E5=BA=AB):=20=E6=A9=9F=E5=8F=B0?= =?UTF-8?q?=E5=BA=AB=E5=AD=98=E6=A6=82=E8=A6=BD=E6=89=B9=E6=AC=A1=E5=8C=AF?= =?UTF-8?q?=E5=87=BA=20Excel(=E5=A4=9A=E5=88=86=E9=A0=81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 機台庫存概覽列表頁新增「匯出」鈕,跳出可全選帳號下全部機台或逐台勾選的視窗, 匯出成 Excel(xlsx)——每台機台一個分頁(sheet),內容為該機台每貨道明細 (貨道號/商品/條碼/類型/庫存/容量/庫存率/效期/批號/啟用/鎖定)。 用 OpenSpout 寫多分頁、動態 觸發(不卡霧面)。 CSV 後端保留(每台一檔打包 zip)但前端按鈕先隱藏。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Admin/WarehouseController.php | 121 +++++++++++++++++- lang/en.json | 3 + lang/zh_TW.json | 3 + .../warehouses/machine-inventory.blade.php | 90 +++++++++++++ routes/web.php | 1 + 5 files changed, 217 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Admin/WarehouseController.php b/app/Http/Controllers/Admin/WarehouseController.php index 4df4cf4..2ce49e4 100644 --- a/app/Http/Controllers/Admin/WarehouseController.php +++ b/app/Http/Controllers/Admin/WarehouseController.php @@ -722,7 +722,10 @@ 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')); } @@ -801,6 +804,122 @@ class WarehouseController extends Controller 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 c172046..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", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 4b307d6..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": "失敗", diff --git a/resources/views/admin/warehouses/machine-inventory.blade.php b/resources/views/admin/warehouses/machine-inventory.blade.php index 6ec0fdf..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) --}} + {{-- + + --}} + +
+
+
+
+
@@ -844,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 cffdcdb..11f2c97 100644 --- a/routes/web.php +++ b/routes/web.php @@ -123,6 +123,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/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');