feat: 新增商品 Excel 匯入功能與修復 HTTPS 混合內容問題
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Has been skipped
Koori-ERP-Deploy-System / deploy-production (push) Successful in 1m4s

1. 新增商品 Excel 匯入功能 (ProductImport, Export Template)
2. 調整商品代號驗證規則為 1-5 碼 (Controller & Import)
3. 修正 HTTPS Mixed Content 問題 (AppServiceProvider)
This commit is contained in:
2026-02-02 14:39:13 +08:00
parent df3db38dd4
commit 6204f0d915
13 changed files with 1352 additions and 10 deletions

View File

@@ -10,6 +10,9 @@ use App\Modules\Inventory\Models\Category;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Maatwebsite\Excel\Facades\Excel;
use App\Modules\Inventory\Exports\ProductTemplateExport;
use App\Modules\Inventory\Imports\ProductImport;
class ProductController extends Controller
{
@@ -111,7 +114,7 @@ class ProductController extends Controller
public function store(Request $request)
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code',
'code' => 'required|string|min:1|max:5|unique:products,code',
'barcode' => 'required|string|unique:products,barcode',
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
@@ -124,7 +127,8 @@ class ProductController extends Controller
'purchase_unit_id' => 'nullable|exists:units,id',
], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.max' => '商品代號最多 5 碼',
'code.min' => '商品代號最少 1 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
@@ -149,7 +153,7 @@ class ProductController extends Controller
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
'code' => 'required|string|min:1|max:5|unique:products,code,' . $product->id,
'barcode' => 'required|string|unique:products,barcode,' . $product->id,
'name' => 'required|string|max:255',
'category_id' => 'required|exists:categories,id',
@@ -161,7 +165,8 @@ class ProductController extends Controller
'purchase_unit_id' => 'nullable|exists:units,id',
], [
'code.required' => '商品代號為必填',
'code.max' => '商品代號最多 2 碼',
'code.max' => '商品代號最多 5 碼',
'code.min' => '商品代號最少 1 碼',
'code.unique' => '商品代號已存在',
'barcode.required' => '條碼編號為必填',
'barcode.unique' => '條碼編號已存在',
@@ -189,4 +194,36 @@ class ProductController extends Controller
return redirect()->back()->with('success', '商品已刪除');
}
/**
* 下載匯入範本
*/
public function template()
{
return Excel::download(new ProductTemplateExport, 'products_template.xlsx');
}
/**
* 匯入商品
*/
public function import(Request $request)
{
$request->validate([
'file' => 'required|file|mimes:xlsx,xls',
]);
try {
Excel::import(new ProductImport, $request->file('file'));
return redirect()->back()->with('success', '商品匯入成功');
} catch (\Maatwebsite\Excel\Validators\ValidationException $e) {
$failures = $e->failures();
$messages = [];
foreach ($failures as $failure) {
$messages[] = '第 ' . $failure->row() . ' 行: ' . implode(', ', $failure->errors());
}
return redirect()->back()->withErrors(['file' => implode("\n", $messages)]);
} catch (\Exception $e) {
return redirect()->back()->withErrors(['file' => '匯入失敗: ' . $e->getMessage()]);
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Modules\Inventory\Exports;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
use Maatwebsite\Excel\Concerns\WithHeadings;
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
class ProductTemplateExport implements WithHeadings, WithColumnFormatting
{
public function headings(): array
{
return [
'商品代號',
'條碼',
'商品名稱',
'類別名稱',
'品牌',
'規格',
'基本單位',
'大單位',
'換算率',
];
}
public function columnFormats(): array
{
return [
'A' => NumberFormat::FORMAT_TEXT, // 商品代號
'B' => NumberFormat::FORMAT_TEXT, // 條碼
];
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Modules\Inventory\Imports;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Validation\Rule;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithMapping;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Imports\HeadingRowFormatter;
class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapping
{
private $categories;
private $units;
public function __construct()
{
// 禁用標題格式化,保留中文標題
HeadingRowFormatter::default('none');
// 快取所有類別與單位,避免 N+1 查詢
$this->categories = Category::pluck('id', 'name');
$this->units = Unit::pluck('id', 'name');
}
/**
* @param mixed $row
*
* @return array
*/
public function map($row): array
{
// 強制將代號與條碼轉為字串,避免純數字被當作整數處理導致 max:5 驗證錯誤
if (isset($row['商品代號'])) {
$row['商品代號'] = (string) $row['商品代號'];
}
if (isset($row['條碼'])) {
$row['條碼'] = (string) $row['條碼'];
}
return $row;
}
/**
* @param array $row
*
* @return \Illuminate\Database\Eloquent\Model|null
*/
public function model(array $row)
{
// 查找關聯 ID
$categoryId = $this->categories[$row['類別名稱']] ?? null;
$baseUnitId = $this->units[$row['基本單位']] ?? null;
$largeUnitId = isset($row['大單位']) ? ($this->units[$row['大單位']] ?? null) : null;
// 若必要關聯找不到,理論上 Validation 會攔截,但此處做防禦性編程
if (!$categoryId || !$baseUnitId) {
return null;
}
return new Product([
'code' => $row['商品代號'],
'barcode' => $row['條碼'],
'name' => $row['商品名稱'],
'category_id' => $categoryId,
'brand' => $row['品牌'] ?? null,
'specification' => $row['規格'] ?? null,
'base_unit_id' => $baseUnitId,
'large_unit_id' => $largeUnitId,
'conversion_rate' => $row['換算率'] ?? null,
'purchase_unit_id' => null,
]);
}
public function rules(): array
{
return [
'商品代號' => ['required', 'string', 'min:1', 'max:5', 'unique:products,code'],
'條碼' => ['required', 'string', 'unique:products,barcode'],
'商品名稱' => ['required', 'string'],
'類別名稱' => ['required', function($attribute, $value, $fail) {
if (!isset($this->categories[$value])) {
$fail("找不到類別: " . $value);
}
}],
'基本單位' => ['required', function($attribute, $value, $fail) {
if (!isset($this->units[$value])) {
$fail("找不到單位: " . $value);
}
}],
'大單位' => ['nullable', function($attribute, $value, $fail) {
if ($value && !isset($this->units[$value])) {
$fail("找不到單位: " . $value);
}
}],
'換算率' => ['nullable', 'numeric', 'min:0.0001', 'required_with:大單位'],
];
}
}

View File

@@ -30,6 +30,8 @@ Route::middleware('auth')->group(function () {
// 商品管理
Route::middleware('permission:products.view')->group(function () {
Route::get('/products/template', [ProductController::class, 'template'])->name('products.template');
Route::post('/products/import', [ProductController::class, 'import'])->middleware('permission:products.create')->name('products.import');
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');