feat(倉庫): 機台庫存概覽批次匯出 Excel(多分頁)
機台庫存概覽列表頁新增「匯出」鈕,跳出可全選帳號下全部機台或逐台勾選的視窗, 匯出成 Excel(xlsx)——每台機台一個分頁(sheet),內容為該機台每貨道明細 (貨道號/商品/條碼/類型/庫存/容量/庫存率/效期/批號/啟用/鎖定)。 用 OpenSpout 寫多分頁、動態 <a download> 觸發(不卡霧面)。 CSV 後端保留(每台一檔打包 zip)但前端按鈕先隱藏。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
83e1beb7e2
commit
34b0a7faf6
@ -722,7 +722,10 @@ class WarehouseController extends Controller
|
|||||||
? \App\Models\System\Company::active()->orderBy('name')->get()
|
? \App\Models\System\Company::active()->orderBy('name')->get()
|
||||||
: collect();
|
: 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]);
|
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:取得單台機台貨道詳情
|
* AJAX:取得單台機台貨道詳情
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -779,6 +779,9 @@
|
|||||||
"Export Report": "Export Report",
|
"Export Report": "Export Report",
|
||||||
"Export": "Export",
|
"Export": "Export",
|
||||||
"Export to CSV": "Export to CSV",
|
"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",
|
"Export to Excel": "Export to Excel",
|
||||||
"External URL": "External URL",
|
"External URL": "External URL",
|
||||||
"Failed": "Failed",
|
"Failed": "Failed",
|
||||||
|
|||||||
@ -780,6 +780,9 @@
|
|||||||
"Export Report": "匯出報表",
|
"Export Report": "匯出報表",
|
||||||
"Export": "匯出",
|
"Export": "匯出",
|
||||||
"Export to CSV": "匯出成 CSV",
|
"Export to CSV": "匯出成 CSV",
|
||||||
|
"Export Machine Inventory": "匯出機台庫存",
|
||||||
|
"Please select at least one machine": "請至少選擇一台機台",
|
||||||
|
"No machines": "沒有機台",
|
||||||
"Export to Excel": "匯出成 Excel",
|
"Export to Excel": "匯出成 Excel",
|
||||||
"External URL": "外連 URL",
|
"External URL": "外連 URL",
|
||||||
"Failed": "失敗",
|
"Failed": "失敗",
|
||||||
|
|||||||
@ -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" />
|
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>
|
</svg>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -96,6 +103,60 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Detail Mode --}}
|
{{-- 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>
|
<div x-show="viewMode === 'detail'" class="space-y-8 animate-luxury-in" x-cloak>
|
||||||
<!-- Statistics Dashboard -->
|
<!-- Statistics Dashboard -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch">
|
||||||
@ -844,6 +905,35 @@ document.addEventListener('alpine:init', () => {
|
|||||||
loading: false,
|
loading: false,
|
||||||
hasReplenishPermission: {{ auth()->user()->can('menu.warehouses.replenishments') ? 'true' : '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() {
|
async init() {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const machineId = urlParams.get('machine_id');
|
const machineId = urlParams.get('machine_id');
|
||||||
|
|||||||
@ -123,6 +123,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
|
|||||||
|
|
||||||
// 模組 4:機台庫存總覽 (Force Route Refresh)
|
// 模組 4:機台庫存總覽 (Force Route Refresh)
|
||||||
Route::get('/machine-inventory', [App\Http\Controllers\Admin\WarehouseController::class, 'machineInventory'])->name('machine-inventory');
|
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}/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}/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');
|
Route::get('/machine-inventory/{machine}/movements', [App\Http\Controllers\Admin\WarehouseController::class, 'machineStockMovements'])->name('machine-inventory.movements');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user