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); } }