star-cloud/app/Services/ProductImportService.php
sky121113 c3e60b8746 [FEAT] 新增商品 Excel 批量匯入與模板下載功能
1. 新增 ProductImportService 服務,實作 Excel 商品檔案解析、欄位驗證、多租戶 company_id 歸屬處理及資料批量寫入/更新邏輯。
2. 在 ProductController 新增 import 與 downloadTemplate 動作,處理檔案接收驗證與匯出,並在匯入後自動重建該公司商品目錄快取。
3. routes/web.php 新增商品下載模板與 Excel 匯入的專用路由。
4. 重構 products/index.blade.php,新增極簡奢華風商品匯入 Modal、檔案拖放/選擇區,並採用 Alpine.js 進行彈窗開啟與非同步上傳 API 交互。
5. 擴充 components/page-header.blade.php,使頁面主標頭支援渲染多個操作按鈕 (actions 插槽),以配合新增的「匯入商品」按鈕。
6. 同步更新 lang/zh_TW.json 與 lang/en.json,對齊「商品匯入成功」、「請選擇公司」等相關語系翻譯。
7. 微調 AdvertisementController,維持全站 Controller 進度對齊。
2026-05-28 11:19:00 +08:00

349 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\Product\Product;
use App\Models\Product\ProductCategory;
use App\Models\System\SystemOperationLog;
use App\Models\System\Translation;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use OpenSpout\Reader\XLSX\Reader;
use OpenSpout\Writer\XLSX\Writer;
use OpenSpout\Common\Entity\Row;
class ProductImportService
{
public function import(string $filePath, ?int $companyId = null, ?int $userId = null): array
{
$reader = new Reader();
$reader->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);
}
}