feat(商品管理): 新增商品匯出(CSV/Excel)
資料設定→商品管理新增「匯出」下拉(CSV/Excel),套用當前篩選(搜尋/分類/公司), 欄位順序對齊匯入範本方便回匯入。匯入鈕與匯出下拉移至查詢/重設右邊。 匯出連結加 download 屬性,避免觸發全域導覽遮罩導致下載後畫面卡霧面。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d8f549d390
commit
1d745c5722
@ -584,6 +584,136 @@ class ProductController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匯出商品資料(CSV / Excel),套用與 index 相同的篩選(搜尋/分類/公司)。
|
||||
* 參考 SalesController::handleExport 的串流下載模式;欄位順序對齊匯入範本,方便編輯後回匯入。
|
||||
*/
|
||||
public function export(Request $request)
|
||||
{
|
||||
$user = auth()->user();
|
||||
$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';
|
||||
|
||||
// 與 index 相同的商品查詢與篩選(租戶隔離沿用 Product 全域 scope)
|
||||
$query = Product::with(['category.translations', 'translations', 'company']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('barcode', 'like', "%{$search}%")
|
||||
->orWhere('spec', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
if ($user->isSystemAdmin() && $request->filled('product_company_id')) {
|
||||
$query->where('company_id', $request->product_company_id);
|
||||
}
|
||||
|
||||
// 欄位順序對齊匯入範本(ProductImportService::downloadTemplate)
|
||||
$headers = [
|
||||
'分類',
|
||||
'商品名稱(zh_TW)',
|
||||
'商品名稱(en)',
|
||||
'商品名稱(ja)',
|
||||
'條碼',
|
||||
'規格',
|
||||
'生產公司',
|
||||
'售價',
|
||||
'會員價',
|
||||
'成本',
|
||||
'履帶貨道上限',
|
||||
'彈簧貨道上限',
|
||||
'物料代碼',
|
||||
'全額點數',
|
||||
'半額點數',
|
||||
'半額折抵金額',
|
||||
'是否啟用',
|
||||
'圖片檔名',
|
||||
];
|
||||
|
||||
$filename = '商品資料_' . now()->format('YmdHis') . '.' . $ext;
|
||||
|
||||
$callback = function () use ($query, $headers, $isExcel) {
|
||||
$file = fopen('php://output', 'w');
|
||||
if ($isExcel) {
|
||||
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
|
||||
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
|
||||
fwrite($file, '<table><thead><tr>');
|
||||
foreach ($headers as $header) {
|
||||
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
|
||||
}
|
||||
fwrite($file, '</tr></thead><tbody>');
|
||||
} else {
|
||||
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM,Excel 開啟不亂碼
|
||||
fputcsv($file, $headers);
|
||||
}
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$query->orderBy('id')->chunk(500, function ($products) use ($file, $isExcel, $locale) {
|
||||
foreach ($products as $p) {
|
||||
$meta = is_array($p->metadata) ? $p->metadata : [];
|
||||
$nameEn = optional($p->translations->firstWhere('locale', 'en'))->value;
|
||||
$nameJa = optional($p->translations->firstWhere('locale', 'ja'))->value;
|
||||
|
||||
$categoryName = '';
|
||||
if ($p->category) {
|
||||
$catTrans = $p->category->translations->firstWhere('locale', $locale)
|
||||
?? $p->category->translations->firstWhere('locale', 'zh_TW');
|
||||
$categoryName = $catTrans->value ?? ($p->category->name ?? '');
|
||||
}
|
||||
|
||||
$imageFilename = $p->image_url ? basename($p->image_url) : '';
|
||||
|
||||
$row = [
|
||||
$categoryName,
|
||||
$p->name,
|
||||
$nameEn,
|
||||
$nameJa,
|
||||
$p->barcode,
|
||||
$p->spec,
|
||||
$p->manufacturer,
|
||||
$p->price,
|
||||
$p->member_price,
|
||||
$p->cost,
|
||||
$p->track_limit,
|
||||
$p->spring_limit,
|
||||
$meta['material_code'] ?? '',
|
||||
$meta['points_full'] ?? 0,
|
||||
$meta['points_half'] ?? 0,
|
||||
$meta['points_half_amount'] ?? 0,
|
||||
$p->is_active ? 1 : 0,
|
||||
$imageFilename,
|
||||
];
|
||||
|
||||
if ($isExcel) {
|
||||
fwrite($file, '<tr>');
|
||||
foreach ($row as $cell) {
|
||||
fwrite($file, '<td>' . htmlspecialchars((string) $cell) . '</td>');
|
||||
}
|
||||
fwrite($file, '</tr>');
|
||||
} else {
|
||||
fputcsv($file, $row);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($isExcel) {
|
||||
fwrite($file, '</tbody></table></body></html>');
|
||||
}
|
||||
fclose($file);
|
||||
};
|
||||
|
||||
return response()->streamDownload($callback, $filename, ['Content-Type' => $contentType]);
|
||||
}
|
||||
|
||||
public function downloadTemplate(ProductImportService $importService)
|
||||
{
|
||||
return $importService->downloadTemplate();
|
||||
|
||||
@ -777,6 +777,7 @@
|
||||
"Expiry Time": "Expiry Time",
|
||||
"Expiry date tracking and warnings": "Expiry date tracking and warnings",
|
||||
"Export Report": "Export Report",
|
||||
"Export": "Export",
|
||||
"Export to CSV": "Export to CSV",
|
||||
"Export to Excel": "Export to Excel",
|
||||
"External URL": "External URL",
|
||||
@ -965,6 +966,8 @@
|
||||
"Image Downloaded": "Image Downloaded",
|
||||
"Immediate": "Immediate",
|
||||
"Import Excel": "Import Excel",
|
||||
"Export CSV": "Export CSV",
|
||||
"Export Excel": "Export Excel",
|
||||
"Import Products": "Import Products",
|
||||
"Import Staff Cards": "Import Staff Cards",
|
||||
"Import failed": "Import failed",
|
||||
|
||||
@ -778,6 +778,7 @@
|
||||
"Expiry Time": "到期時間",
|
||||
"Expiry date tracking and warnings": "有效期限追蹤與預警",
|
||||
"Export Report": "匯出報表",
|
||||
"Export": "匯出",
|
||||
"Export to CSV": "匯出成 CSV",
|
||||
"Export to Excel": "匯出成 Excel",
|
||||
"External URL": "外連 URL",
|
||||
@ -966,6 +967,8 @@
|
||||
"Image Downloaded": "圖片已下載",
|
||||
"Immediate": "立即",
|
||||
"Import Excel": "Excel 匯入",
|
||||
"Export CSV": "匯出 CSV",
|
||||
"Export Excel": "匯出 Excel",
|
||||
"Import Products": "匯入商品",
|
||||
"Import Staff Cards": "匯入員工識別卡",
|
||||
"Import failed": "匯入失敗",
|
||||
|
||||
@ -36,13 +36,6 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Add Product') }}</span>
|
||||
</a>
|
||||
<div class="order-2 w-full flex items-center gap-2 md:contents">
|
||||
<button @click="isImportModalOpen = true" type="button"
|
||||
class="btn-luxury-secondary whitespace-nowrap flex items-center gap-2 transition-all duration-300 flex-1 md:flex-none justify-center min-w-[126px] md:order-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span class="text-sm sm:text-base font-black tracking-tight uppercase">{{ __('Import Excel') }}</span>
|
||||
</button>
|
||||
<button @click="syncToAllMachines()"
|
||||
class="btn-luxury-ghost group whitespace-nowrap flex items-center justify-center gap-2 transition-all duration-300 py-2 sm:py-2.5 px-4 sm:px-6 rounded-2xl border-slate-200 dark:border-white/10 hover:border-cyan-500/50 hover:bg-cyan-500/5 flex-1 md:flex-none min-w-[150px] md:order-2">
|
||||
<svg class="w-4 h-4 text-cyan-500 group-hover:rotate-180 transition-transform duration-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
|
||||
@ -62,6 +62,41 @@ $baseRoute = 'admin.data-config.products';
|
||||
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 @click="isImportModalOpen = true" type="button"
|
||||
class="px-4 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 border border-slate-200 dark:border-slate-700 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
|
||||
title="{{ __('Import Excel') }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ __('Import Excel') }}</span>
|
||||
</button>
|
||||
<div class="relative" x-data="{ exportOpen: false }">
|
||||
<button type="button" @click="exportOpen = !exportOpen" @click.away="exportOpen = false"
|
||||
class="px-4 py-2.5 rounded-xl bg-emerald-500 text-white hover:bg-emerald-600 shadow-lg shadow-emerald-500/25 transition-all active:scale-95 flex items-center gap-2 text-xs font-bold whitespace-nowrap"
|
||||
title="{{ __('Export') }}">
|
||||
<svg class="w-4 h-4" 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>
|
||||
<span class="hidden sm:inline">{{ __('Export') }}</span>
|
||||
<svg class="w-3 h-3 transition-transform duration-200" :class="exportOpen ? 'rotate-180' : ''" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="exportOpen" x-transition
|
||||
class="absolute right-0 top-full mt-2 bg-white dark:bg-slate-900 border border-slate-100 dark:border-slate-800 shadow-xl rounded-2xl p-2 z-30 min-w-[160px]"
|
||||
x-cloak>
|
||||
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'csv'])) }}"
|
||||
download @click="exportOpen = false"
|
||||
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
|
||||
📄 {{ __('Export to CSV') }}
|
||||
</a>
|
||||
<a href="{{ route($baseRoute . '.export', array_merge(request()->only(['search','category_id','product_company_id']), ['export' => 'excel'])) }}"
|
||||
download @click="exportOpen = false"
|
||||
class="w-full text-left py-2 px-3 hover:bg-slate-50 dark:hover:bg-slate-800 text-xs font-bold text-slate-700 dark:text-slate-300 rounded-xl transition-all flex items-center gap-2">
|
||||
📊 {{ __('Export to Excel') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="tab" value="products">
|
||||
|
||||
@ -213,6 +213,7 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
|
||||
Route::prefix('data-config')->name('data-config.')->group(function () {
|
||||
Route::middleware('can:menu.data-config.products')->group(function () {
|
||||
Route::get('/products/template', [App\Http\Controllers\Admin\ProductController::class, 'downloadTemplate'])->name('products.template');
|
||||
Route::get('/products/export', [App\Http\Controllers\Admin\ProductController::class, 'export'])->name('products.export');
|
||||
Route::post('/products/import', [App\Http\Controllers\Admin\ProductController::class, 'import'])->name('products.import');
|
||||
Route::resource('products', App\Http\Controllers\Admin\ProductController::class)->except(['show']);
|
||||
Route::patch('/products/{id}/toggle-status', [App\Http\Controllers\Admin\ProductController::class, 'toggleStatus'])->name('products.status.toggle');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user