diff --git a/app/Http/Controllers/Admin/AdvertisementController.php b/app/Http/Controllers/Admin/AdvertisementController.php index 2d51b71..801c84a 100644 --- a/app/Http/Controllers/Admin/AdvertisementController.php +++ b/app/Http/Controllers/Admin/AdvertisementController.php @@ -19,6 +19,9 @@ class AdvertisementController extends AdminController { $user = auth()->user(); $tab = $request->input('tab', 'list'); + $perPage = in_array((int) $request->input('per_page', 10), [10, 25, 50, 100], true) + ? (int) $request->input('per_page', 10) + : 10; // Tab 1: 廣告列表 $query = Advertisement::with('company'); @@ -36,7 +39,7 @@ class AdvertisementController extends AdminController $query->where('company_id', $request->company_id); } - $advertisements = $query->latest()->paginate(10); + $advertisements = $query->latest()->paginate($perPage)->withQueryString(); // Tab 2: 機台廣告設置 (所需資料) - 隱藏已過期的廣告 $allAds = Advertisement::playing()->get(); diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index cc1cba1..391b364 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -13,6 +13,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use App\Services\Product\ProductCatalogService; +use App\Services\ProductImportService; use App\Models\System\SystemOperationLog; class ProductController extends Controller @@ -87,6 +88,7 @@ class ProductController extends Controller 'success' => true, 'html' => view('admin.products.partials.tab-products', [ 'products' => $products, + 'categories' => $categories, 'companySettings' => $companySettings, 'companies' => $companies, 'routeName' => $routeName @@ -540,6 +542,44 @@ class ProductController extends Controller } } + public function downloadTemplate(ProductImportService $importService) + { + return $importService->downloadTemplate(); + } + + public function import(Request $request, ProductImportService $importService) + { + $request->validate([ + 'file' => 'required|file|mimes:xlsx|max:5120', + 'company_id' => 'nullable|exists:companies,id', + ]); + + $companyId = $request->get('company_id') ?: auth()->user()->company_id; + + if (auth()->user()->isSystemAdmin() && !$companyId) { + return response()->json([ + 'success' => false, + 'message' => __('Please select a company'), + ], 422); + } + + $results = $importService->import( + $request->file('file')->getRealPath(), + $companyId, + auth()->id() + ); + + foreach ($results['company_ids'] as $importedCompanyId) { + $this->catalogService->rebuildCache($importedCompanyId); + } + + return response()->json([ + 'success' => true, + 'message' => __(':count products imported successfully', ['count' => $results['success_count']]), + 'results' => $results, + ]); + } + /** * 同步商品目錄至全公司機台 (Phase 2) */ diff --git a/app/Services/ProductImportService.php b/app/Services/ProductImportService.php new file mode 100644 index 0000000..ad80d60 --- /dev/null +++ b/app/Services/ProductImportService.php @@ -0,0 +1,348 @@ +open($filePath); + + $results = [ + 'success_count' => 0, + 'created_count' => 0, + 'updated_count' => 0, + 'error_count' => 0, + 'errors' => [], + 'company_ids' => [], + ]; + + $rowCount = 0; + + foreach ($reader->getSheetIterator() as $sheet) { + foreach ($sheet->getRowIterator() as $row) { + $rowCount++; + + if ($rowCount === 1) { + continue; + } + + $cells = $row->toArray(); + $data = $this->normalizeRow($cells, $companyId); + + if ($this->isEmptyRow($data)) { + continue; + } + + $validator = Validator::make($data, [ + 'company_id' => 'nullable|exists:companies,id', + 'category' => 'nullable|string|max:255', + 'name_zh_TW' => 'required|string|max:255', + 'name_en' => 'nullable|string|max:255', + 'name_ja' => 'nullable|string|max:255', + 'barcode' => 'nullable|string|max:100', + 'spec' => 'nullable|string|max:255', + 'manufacturer' => 'nullable|string|max:255', + 'price' => 'required|numeric|min:0', + 'member_price' => 'nullable|numeric|min:0', + 'cost' => 'required|numeric|min:0', + 'track_limit' => 'nullable|integer|min:0', + 'spring_limit' => 'nullable|integer|min:0', + 'material_code' => 'nullable|string|max:255', + 'points_full' => 'nullable|integer|min:0', + 'points_half' => 'nullable|integer|min:0', + 'points_half_amount' => 'nullable|integer|min:0', + 'is_active' => 'nullable|boolean', + ]); + + if ($validator->fails()) { + $this->appendError($results, $rowCount, $validator->errors()->all()); + continue; + } + + try { + DB::transaction(function () use ($data, $rowCount, $userId, &$results) { + $categoryId = $this->resolveCategoryId($data['category'], $data['company_id']); + $lookup = $this->buildProductLookup($data); + $product = Product::withoutGlobalScopes()->where($lookup)->first(); + $oldValues = $product?->toArray(); + $dictKey = $product?->name_dictionary_key ?: Str::uuid()->toString(); + + $this->syncTranslations($dictKey, $data, $data['company_id']); + + $payload = [ + 'company_id' => $data['company_id'], + 'category_id' => $categoryId, + 'name' => $data['name_zh_TW'], + 'name_dictionary_key' => $dictKey, + 'barcode' => $data['barcode'], + 'spec' => $data['spec'], + 'manufacturer' => $data['manufacturer'], + 'track_limit' => $data['track_limit'], + 'spring_limit' => $data['spring_limit'], + 'price' => $data['price'], + 'cost' => $data['cost'], + 'member_price' => $data['member_price'], + 'metadata' => [ + 'material_code' => $data['material_code'], + 'points_full' => $data['points_full'], + 'points_half' => $data['points_half'], + 'points_half_amount' => $data['points_half_amount'], + ], + 'is_active' => $data['is_active'], + ]; + + if ($product) { + $product->update($payload); + $results['updated_count']++; + $action = 'update'; + $note = 'product_import_updated'; + } else { + $product = Product::withoutGlobalScopes()->create($payload); + $results['created_count']++; + $action = 'create'; + $note = 'product_import_created'; + } + + $results['success_count']++; + if ($product->company_id) { + $results['company_ids'][$product->company_id] = $product->company_id; + } + + SystemOperationLog::create([ + 'company_id' => $product->company_id, + 'user_id' => $userId, + 'module' => 'product', + 'action' => $action, + 'target_id' => $product->id, + 'target_type' => Product::class, + 'note' => $note, + 'old_values' => $oldValues, + 'new_values' => array_merge($product->fresh()->toArray(), ['import_row' => $rowCount]), + ]); + }); + } catch (\Exception $e) { + $this->appendError($results, $rowCount, [$e->getMessage()]); + } + } + } + + $reader->close(); + $results['company_ids'] = array_values($results['company_ids']); + + return $results; + } + + public function downloadTemplate() + { + $writer = new Writer(); + $writer->openToBrowser('products_template.xlsx'); + + $writer->addRow(Row::fromValues([ + '分類 ID 或名稱(選填)', + '商品名稱 zh_TW(必填)', + '商品名稱 en(選填)', + '商品名稱 ja(選填)', + '商品條碼(選填,用於更新)', + '規格(選填)', + '生產公司(選填)', + '售價(必填)', + '會員價(選填)', + '成本(必填)', + '履帶貨道上限(選填,預設 15)', + '彈簧貨道上限(選填,預設 15)', + '物料代碼(選填)', + '全額點數(選填)', + '半額點數(選填)', + '半額點數折抵金額(選填)', + '是否啟用(1/0,選填)', + ])); + + $writer->addRow(Row::fromValues([ + '飲料', + '經典拿鐵', + 'Classic Latte', + 'クラシックラテ', + '471000000001', + '350ml', + 'Star Cloud', + 80, + 75, + 45, + 15, + 15, + 'MAT-LATTE-001', + 800, + 400, + 40, + 1, + ])); + + $writer->close(); + } + + private function normalizeRow(array $cells, ?int $companyId): array + { + return [ + 'company_id' => $companyId, + 'category' => $this->stringValue($cells[0] ?? null), + 'name_zh_TW' => $this->stringValue($cells[1] ?? null), + 'name_en' => $this->stringValue($cells[2] ?? null), + 'name_ja' => $this->stringValue($cells[3] ?? null), + 'barcode' => $this->stringValue($cells[4] ?? null), + 'spec' => $this->stringValue($cells[5] ?? null), + 'manufacturer' => $this->stringValue($cells[6] ?? null), + 'price' => $this->numericValue($cells[7] ?? null, null), + 'member_price' => $this->numericValue($cells[8] ?? null, 0), + 'cost' => $this->numericValue($cells[9] ?? null, null), + 'track_limit' => (int) $this->numericValue($cells[10] ?? null, 15), + 'spring_limit' => (int) $this->numericValue($cells[11] ?? null, 15), + 'material_code' => $this->stringValue($cells[12] ?? null), + 'points_full' => (int) $this->numericValue($cells[13] ?? null, 0), + 'points_half' => (int) $this->numericValue($cells[14] ?? null, 0), + 'points_half_amount' => (int) $this->numericValue($cells[15] ?? null, 0), + 'is_active' => $this->booleanValue($cells[16] ?? null), + ]; + } + + private function isEmptyRow(array $data): bool + { + return empty($data['category']) + && empty($data['name_zh_TW']) + && empty($data['barcode']) + && $data['price'] === null + && $data['cost'] === null; + } + + private function resolveCategoryId(?string $category, ?int $companyId): ?int + { + if (!$category) { + return null; + } + + $query = ProductCategory::withoutGlobalScopes() + ->where(function ($q) use ($companyId) { + $q->where('company_id', $companyId)->orWhereNull('company_id'); + }); + + if (ctype_digit($category)) { + $found = (clone $query)->where('id', (int) $category)->first(); + if ($found) { + return $found->id; + } + } + + $found = (clone $query) + ->where(function ($q) use ($category) { + $q->where('name', $category) + ->orWhereHas('translations', function ($translationQuery) use ($category) { + $translationQuery->withoutGlobalScopes()->where('value', $category); + }); + }) + ->first(); + + if (!$found) { + throw new \InvalidArgumentException(__('Category not found: :category', ['category' => $category])); + } + + return $found->id; + } + + private function buildProductLookup(array $data): array + { + if (!empty($data['barcode'])) { + return [ + 'company_id' => $data['company_id'], + 'barcode' => $data['barcode'], + ]; + } + + return [ + 'company_id' => $data['company_id'], + 'name' => $data['name_zh_TW'], + ]; + } + + private function syncTranslations(string $dictKey, array $data, ?int $companyId): void + { + $names = [ + 'zh_TW' => $data['name_zh_TW'], + 'en' => $data['name_en'], + 'ja' => $data['name_ja'], + ]; + + foreach ($names as $locale => $name) { + if (empty($name)) { + Translation::withoutGlobalScopes()->where([ + 'group' => 'product', + 'key' => $dictKey, + 'locale' => $locale, + ])->delete(); + continue; + } + + Translation::withoutGlobalScopes()->updateOrCreate( + [ + 'group' => 'product', + 'key' => $dictKey, + 'locale' => $locale, + ], + [ + 'value' => $name, + 'company_id' => $companyId, + ] + ); + } + } + + private function appendError(array &$results, int $rowCount, array $messages): void + { + $results['error_count']++; + $results['errors'][] = [ + 'row' => $rowCount, + 'messages' => $messages, + ]; + } + + private function stringValue(mixed $value): ?string + { + $value = trim((string) $value); + return $value === '' ? null : $value; + } + + private function numericValue(mixed $value, mixed $default): mixed + { + if ($value === null || $value === '') { + return $default; + } + + return is_numeric($value) ? $value : $value; + } + + private function booleanValue(mixed $value): bool + { + if ($value === null || $value === '') { + return true; + } + + if (is_bool($value)) { + return $value; + } + + $normalized = strtolower(trim((string) $value)); + + return in_array($normalized, ['1', 'true', 'yes', 'y', 'active', 'enabled', '啟用', '是'], true); + } +} diff --git a/lang/en.json b/lang/en.json index b8194f2..6dfb8d3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -6,6 +6,7 @@ "60 Seconds": "60 Seconds", "60s": "60s", ":count staff cards imported successfully": ":count staff cards imported successfully", + ":count products imported successfully": ":count products imported successfully", "A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.", "A sync command was recently sent. Please wait 1 minute.": "A sync command was recently sent. Please wait 1 minute.", "AI Prediction": "AI Prediction", @@ -256,6 +257,7 @@ "Cash Module Function": "Cash Module Function", "Category": "Category", "Category Management": "Category Management", + "Category not found: :category": "Category not found: :category", "Category Name": "Category Name", "Category Name (en)": "Category Name (en)", "Category Name (ja)": "Category Name (ja)", @@ -809,8 +811,10 @@ "Image Downloaded": "Image Downloaded", "Immediate": "Immediate", "Import Excel": "Import Excel", + "Import Products": "Import Products", "Import Staff Cards": "Import Staff Cards", "Import failed": "Import failed", + "Imported with errors. Please check the console.": "Imported with errors. Please check the console.", "Important Notice": "Important Notice", "In Stock": "In Stock", "Inactive": "Inactive", @@ -1225,6 +1229,7 @@ "Online Machines": "Online Machines", "Online Only": "Online Only", "Online Status": "Online Status", + "Only .xlsx files are supported (Max 5MB)": "Only .xlsx files are supported (Max 5MB)", "Only .xlsx, .xls, .csv files are supported (Max 5MB)": "Only .xlsx, .xls, .csv files are supported (Max 5MB)", "Only machines under the same company can be cloned for security.": "Only machines under the same company can be cloned for security.", "Only system roles can be assigned to platform administrative accounts.": "Only system roles can be assigned to platform administrative accounts.", @@ -1745,6 +1750,7 @@ "Standby Ad": "Standby Ad", "Start Date": "Start Date", "Start Delivery": "Start Delivery", + "Processing...": "Processing...", "Start Import": "Start Import", "Start Time": "Start Time", "Statistics": "Statistics", diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 4fd8677..7805b7f 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -6,6 +6,7 @@ "60 Seconds": "60 秒", "60s": "60秒", ":count staff cards imported successfully": "成功匯入 :count 張員工識別卡", + ":count products imported successfully": "成功匯入 :count 筆商品", "A new verification link has been sent to your email address.": "已將新的驗證連結發送至您的電子郵件地址。", "A sync command was recently sent. Please wait 1 minute.": "近期已發送過同步指令,請等待 1 分鐘後再試", "AI Prediction": "AI智能預測", @@ -256,6 +257,7 @@ "Cash Module Function": "現金模組功能", "Category": "類別", "Category Management": "分類管理", + "Category not found: :category": "找不到商品分類::category", "Category Name": "分類名稱", "Category Name (en)": "分類名稱 (英文)", "Category Name (ja)": "分類名稱 (日文)", @@ -809,8 +811,10 @@ "Image Downloaded": "圖片已下載", "Immediate": "立即", "Import Excel": "Excel 匯入", + "Import Products": "匯入商品", "Import Staff Cards": "匯入員工識別卡", "Import failed": "匯入失敗", + "Imported with errors. Please check the console.": "部分資料匯入失敗,請查看瀏覽器主控台。", "Important Notice": "重要通知", "In Stock": "當前庫存", "Inactive": "已停用", @@ -1225,6 +1229,7 @@ "Online Machines": "在線機台", "Online Only": "僅限線上機台", "Online Status": "在線狀態", + "Only .xlsx files are supported (Max 5MB)": "僅支援 .xlsx 檔案 (最大 5MB)", "Only .xlsx, .xls, .csv files are supported (Max 5MB)": "僅支援 .xlsx, .xls, .csv 檔案 (最大 5MB)", "Only machines under the same company can be cloned for security.": "基於安全性,僅限複製同公司旗下的機台設定。", "Only system roles can be assigned to platform administrative accounts.": "僅系統角色可指派給平台管理帳號。", @@ -1745,6 +1750,7 @@ "Standby Ad": "待機廣告", "Start Date": "起始日", "Start Delivery": "開始配送", + "Processing...": "處理中...", "Start Import": "開始匯入", "Start Time": "開始時間", "Statistics": "數據統計", diff --git a/resources/views/admin/products/index.blade.php b/resources/views/admin/products/index.blade.php index fff5ba1..b022608 100644 --- a/resources/views/admin/products/index.blade.php +++ b/resources/views/admin/products/index.blade.php @@ -23,23 +23,34 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be {{-- Page Header --}} + :subtitle="__('Manage your catalog, prices, and multilingual details.')" + container-class="flex flex-wrap md:flex-nowrap items-center justify-between gap-4" + actions-class="contents md:flex md:items-center md:gap-3 md:shrink-0">