[PROMOTE] dev -> demo: 機台庫存概覽批次匯出 Excel(多分頁)

1. 機台庫存概覽列表頁「匯出」鈕→全選/逐台勾選→匯出 xlsx,每台一分頁、內容為該機台每貨道明細(CSV 前端先隱藏、後端保留)
This commit is contained in:
twsystem1004 2026-07-14 02:46:30 +00:00
commit 3e2069e099
5 changed files with 217 additions and 1 deletions

View File

@ -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取得單台機台貨道詳情
*/

View File

@ -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",

View File

@ -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": "失敗",

View File

@ -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" />
</svg>
</button>
<button type="button" @click="openExportModal()"
class="p-2.5 rounded-xl bg-emerald-500 text-white hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 group transition-all active:scale-95"
title="{{ __('Export Machine Inventory') }}">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
</button>
</div>
</form>
</div>
@ -96,6 +103,60 @@
</div>
{{-- Detail Mode --}}
{{-- 機台庫存批次匯出 Modal --}}
<div x-show="exportModalOpen" class="fixed inset-0 z-[100] overflow-y-auto" x-cloak
x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0">
<div class="flex items-center justify-center min-h-screen px-4 py-8">
<div class="fixed inset-0 bg-slate-900/60 backdrop-blur-sm" @click="exportModalOpen = false"></div>
<div class="relative bg-white dark:bg-slate-900 rounded-3xl shadow-2xl w-full max-w-lg border border-slate-100 dark:border-slate-800 max-h-[85vh] flex flex-col">
<div class="px-8 pt-8 pb-4 border-b border-slate-50 dark:border-slate-800/50 flex justify-between items-center">
<h3 class="text-xl font-black text-slate-800 dark:text-white tracking-tight">{{ __('Export Machine Inventory') }}</h3>
<button @click="exportModalOpen = false" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="px-8 py-4 border-b border-slate-50 dark:border-slate-800/50">
<label class="flex items-center gap-3 cursor-pointer">
<input type="checkbox" :checked="exportAllChecked" @change="toggleExportAll($event)"
class="w-4 h-4 text-emerald-500 rounded border-slate-300 focus:ring-emerald-500 dark:bg-slate-800 dark:border-slate-700">
<span class="text-sm font-black text-slate-700 dark:text-slate-200">{{ __('Select All') }}
<span class="text-slate-400 font-bold">(<span x-text="exportSelectedIds.length"></span>/{{ $exportMachines->count() }})</span>
</span>
</label>
</div>
<div class="px-6 py-3 overflow-y-auto flex-1 space-y-1">
@forelse($exportMachines as $m)
<label class="flex items-center gap-3 py-2 px-3 rounded-xl hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer transition-colors">
<input type="checkbox" value="{{ $m->id }}" x-model.number="exportSelectedIds"
class="w-4 h-4 text-emerald-500 rounded border-slate-300 focus:ring-emerald-500 dark:bg-slate-800 dark:border-slate-700">
<span class="flex flex-col">
<span class="text-sm font-bold text-slate-700 dark:text-slate-200">{{ $m->name }}</span>
<span class="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-widest">{{ $m->serial_no }}</span>
</span>
</label>
@empty
<p class="text-sm text-slate-400 text-center py-8">{{ __('No machines') }}</p>
@endforelse
</div>
<div class="px-8 py-6 bg-slate-50 dark:bg-slate-900/50 flex flex-wrap justify-end gap-3 rounded-b-3xl border-t border-slate-100 dark:border-slate-800">
<button @click="exportModalOpen = false" class="btn-luxury-ghost">{{ __('Cancel') }}</button>
{{-- 暫時隱藏 CSV 匯出(保留備用;後端仍支援 csv/zip --}}
{{--
<button @click="doExport('csv')"
class="px-5 py-2.5 rounded-xl bg-emerald-500 text-white font-black text-sm uppercase tracking-widest hover:bg-emerald-600 transition-all active:scale-95 flex items-center gap-2">
<span>📄</span> {{ __('Export to CSV') }}
</button>
--}}
<button @click="doExport('excel')"
class="px-5 py-2.5 rounded-xl bg-emerald-600 text-white font-black text-sm uppercase tracking-widest hover:bg-emerald-700 transition-all active:scale-95 flex items-center gap-2">
<span>📊</span> {{ __('Export to Excel') }}
</button>
</div>
</div>
</div>
</div>
<div x-show="viewMode === 'detail'" class="space-y-8 animate-luxury-in" x-cloak>
<!-- Statistics Dashboard -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
@ -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');

View File

@ -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');