From 6204f0d9157076eae64cc17d192d42ca3038190b Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 2 Feb 2026 14:39:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=95=86=E5=93=81=20?= =?UTF-8?q?Excel=20=E5=8C=AF=E5=85=A5=E5=8A=9F=E8=83=BD=E8=88=87=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9=20HTTPS=20=E6=B7=B7=E5=90=88=E5=85=A7=E5=AE=B9?= =?UTF-8?q?=E5=95=8F=E9=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增商品 Excel 匯入功能 (ProductImport, Export Template) 2. 調整商品代號驗證規則為 1-5 碼 (Controller & Import) 3. 修正 HTTPS Mixed Content 問題 (AppServiceProvider) --- .../Controllers/ProductController.php | 45 +- .../Exports/ProductTemplateExport.php | 36 ++ .../Inventory/Imports/ProductImport.php | 105 ++++ app/Modules/Inventory/Routes/web.php | 2 + app/Providers/AppServiceProvider.php | 4 +- composer.json | 3 +- composer.lock | 591 +++++++++++++++++- config/excel.php | 380 +++++++++++ package-lock.json | 32 + package.json | 1 + .../Product/ProductImportDialog.tsx | 142 +++++ resources/js/Pages/Product/Index.tsx | 19 +- resources/js/ziggy.js | 2 +- 13 files changed, 1352 insertions(+), 10 deletions(-) create mode 100644 app/Modules/Inventory/Exports/ProductTemplateExport.php create mode 100644 app/Modules/Inventory/Imports/ProductImport.php create mode 100644 config/excel.php create mode 100644 resources/js/Components/Product/ProductImportDialog.tsx diff --git a/app/Modules/Inventory/Controllers/ProductController.php b/app/Modules/Inventory/Controllers/ProductController.php index 23f4912..71650be 100644 --- a/app/Modules/Inventory/Controllers/ProductController.php +++ b/app/Modules/Inventory/Controllers/ProductController.php @@ -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()]); + } + } } diff --git a/app/Modules/Inventory/Exports/ProductTemplateExport.php b/app/Modules/Inventory/Exports/ProductTemplateExport.php new file mode 100644 index 0000000..bd10690 --- /dev/null +++ b/app/Modules/Inventory/Exports/ProductTemplateExport.php @@ -0,0 +1,36 @@ + NumberFormat::FORMAT_TEXT, // 商品代號 + 'B' => NumberFormat::FORMAT_TEXT, // 條碼 + ]; + } +} diff --git a/app/Modules/Inventory/Imports/ProductImport.php b/app/Modules/Inventory/Imports/ProductImport.php new file mode 100644 index 0000000..cfc62a8 --- /dev/null +++ b/app/Modules/Inventory/Imports/ProductImport.php @@ -0,0 +1,105 @@ +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:大單位'], + ]; + } +} diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 284c671..ec5b579 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -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'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 641a050..4ef0c87 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,8 +18,8 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { - // 如果是在正式環境,強制轉為 https - if (config('app.env') === 'production') { + // 如果是在正式環境或 APP_URL 是 https,強制轉為 https + if ($this->app->environment('production') || str_contains(config('app.url'), 'https')) { URL::forceScheme('https'); } diff --git a/composer.json b/composer.json index 460cb70..c95b4cb 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/tinker": "^2.10.1", + "maatwebsite/excel": "^3.1", "spatie/laravel-activitylog": "^4.10", "spatie/laravel-permission": "^6.24", "stancl/jobpipeline": "^1.8", @@ -93,4 +94,4 @@ }, "minimum-stability": "stable", "prefer-stable": true -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index a4f9810..aa484d6 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "46092572c41c587bf3e7fc53465e5b56", + "content-hash": "b3cbace7e72a7a68b5aefdd82bea205a", "packages": [ { "name": "brick/math", @@ -135,6 +135,162 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -508,6 +664,67 @@ ], "time": "2025-03-06T22:45:56+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.19.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf", + "reference": "b287d2a16aceffbf6e0295559b39662612b77fcf", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0" + }, + "time": "2025-10-17T16:34:55+00:00" + }, { "name": "facade/ignition-contracts", "version": "1.0.2", @@ -2142,6 +2359,272 @@ ], "time": "2025-12-07T16:03:21+00:00" }, + { + "name": "maatwebsite/excel", + "version": "3.1.67", + "source": { + "type": "git", + "url": "https://github.com/SpartnerNL/Laravel-Excel.git", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d", + "reference": "e508e34a502a3acc3329b464dad257378a7edb4d", + "shasum": "" + }, + "require": { + "composer/semver": "^3.3", + "ext-json": "*", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0", + "php": "^7.0||^8.0", + "phpoffice/phpspreadsheet": "^1.30.0", + "psr/simple-cache": "^1.0||^2.0||^3.0" + }, + "require-dev": { + "laravel/scout": "^7.0||^8.0||^9.0||^10.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0", + "predis/predis": "^1.1" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Excel": "Maatwebsite\\Excel\\Facades\\Excel" + }, + "providers": [ + "Maatwebsite\\Excel\\ExcelServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Maatwebsite\\Excel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Patrick Brouwers", + "email": "patrick@spartner.nl" + } + ], + "description": "Supercharged Excel exports and imports in Laravel", + "keywords": [ + "PHPExcel", + "batch", + "csv", + "excel", + "export", + "import", + "laravel", + "php", + "phpspreadsheet" + ], + "support": { + "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67" + }, + "funding": [ + { + "url": "https://laravel-excel.com/commercial-support", + "type": "custom" + }, + { + "url": "https://github.com/patrickbrouwers", + "type": "github" + } + ], + "time": "2025-08-26T09:13:16+00:00" + }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5", + "reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.86", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-12-10T09:58:31+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "monolog/monolog", "version": "3.9.0", @@ -2649,6 +3132,112 @@ ], "time": "2025-11-20T02:34:59+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "1.30.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "2f39286e0136673778b7a142b3f0d141e43d1714" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/2f39286e0136673778b7a142b3f0d141e43d1714", + "reference": "2f39286e0136673778b7a142b3f0d141e43d1714", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "ezyang/htmlpurifier": "^4.15", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^7.4 || ^8.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^1.0 || ^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.3", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^8.5 || ^9.0", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.0" + }, + "time": "2025-08-10T06:28:02+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.4", diff --git a/config/excel.php b/config/excel.php new file mode 100644 index 0000000..c1fd34a --- /dev/null +++ b/config/excel.php @@ -0,0 +1,380 @@ + [ + + /* + |-------------------------------------------------------------------------- + | Chunk size + |-------------------------------------------------------------------------- + | + | When using FromQuery, the query is automatically chunked. + | Here you can specify how big the chunk should be. + | + */ + 'chunk_size' => 1000, + + /* + |-------------------------------------------------------------------------- + | Pre-calculate formulas during export + |-------------------------------------------------------------------------- + */ + 'pre_calculate_formulas' => false, + + /* + |-------------------------------------------------------------------------- + | Enable strict null comparison + |-------------------------------------------------------------------------- + | + | When enabling strict null comparison empty cells ('') will + | be added to the sheet. + */ + 'strict_null_comparison' => false, + + /* + |-------------------------------------------------------------------------- + | CSV Settings + |-------------------------------------------------------------------------- + | + | Configure e.g. delimiter, enclosure and line ending for CSV exports. + | + */ + 'csv' => [ + 'delimiter' => ',', + 'enclosure' => '"', + 'line_ending' => PHP_EOL, + 'use_bom' => false, + 'include_separator_line' => false, + 'excel_compatibility' => false, + 'output_encoding' => '', + 'test_auto_detect' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Worksheet properties + |-------------------------------------------------------------------------- + | + | Configure e.g. default title, creator, subject,... + | + */ + 'properties' => [ + 'creator' => '', + 'lastModifiedBy' => '', + 'title' => '', + 'description' => '', + 'subject' => '', + 'keywords' => '', + 'category' => '', + 'manager' => '', + 'company' => '', + ], + ], + + 'imports' => [ + + /* + |-------------------------------------------------------------------------- + | Read Only + |-------------------------------------------------------------------------- + | + | When dealing with imports, you might only be interested in the + | data that the sheet exists. By default we ignore all styles, + | however if you want to do some logic based on style data + | you can enable it by setting read_only to false. + | + */ + 'read_only' => true, + + /* + |-------------------------------------------------------------------------- + | Ignore Empty + |-------------------------------------------------------------------------- + | + | When dealing with imports, you might be interested in ignoring + | rows that have null values or empty strings. By default rows + | containing empty strings or empty values are not ignored but can be + | ignored by enabling the setting ignore_empty to true. + | + */ + 'ignore_empty' => false, + + /* + |-------------------------------------------------------------------------- + | Heading Row Formatter + |-------------------------------------------------------------------------- + | + | Configure the heading row formatter. + | Available options: none|slug|custom + | + */ + 'heading_row' => [ + 'formatter' => 'slug', + ], + + /* + |-------------------------------------------------------------------------- + | CSV Settings + |-------------------------------------------------------------------------- + | + | Configure e.g. delimiter, enclosure and line ending for CSV imports. + | + */ + 'csv' => [ + 'delimiter' => null, + 'enclosure' => '"', + 'escape_character' => '\\', + 'contiguous' => false, + 'input_encoding' => Csv::GUESS_ENCODING, + ], + + /* + |-------------------------------------------------------------------------- + | Worksheet properties + |-------------------------------------------------------------------------- + | + | Configure e.g. default title, creator, subject,... + | + */ + 'properties' => [ + 'creator' => '', + 'lastModifiedBy' => '', + 'title' => '', + 'description' => '', + 'subject' => '', + 'keywords' => '', + 'category' => '', + 'manager' => '', + 'company' => '', + ], + + /* + |-------------------------------------------------------------------------- + | Cell Middleware + |-------------------------------------------------------------------------- + | + | Configure middleware that is executed on getting a cell value + | + */ + 'cells' => [ + 'middleware' => [ + //\Maatwebsite\Excel\Middleware\TrimCellValue::class, + //\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class, + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Extension detector + |-------------------------------------------------------------------------- + | + | Configure here which writer/reader type should be used when the package + | needs to guess the correct type based on the extension alone. + | + */ + 'extension_detector' => [ + 'xlsx' => Excel::XLSX, + 'xlsm' => Excel::XLSX, + 'xltx' => Excel::XLSX, + 'xltm' => Excel::XLSX, + 'xls' => Excel::XLS, + 'xlt' => Excel::XLS, + 'ods' => Excel::ODS, + 'ots' => Excel::ODS, + 'slk' => Excel::SLK, + 'xml' => Excel::XML, + 'gnumeric' => Excel::GNUMERIC, + 'htm' => Excel::HTML, + 'html' => Excel::HTML, + 'csv' => Excel::CSV, + 'tsv' => Excel::TSV, + + /* + |-------------------------------------------------------------------------- + | PDF Extension + |-------------------------------------------------------------------------- + | + | Configure here which Pdf driver should be used by default. + | Available options: Excel::MPDF | Excel::TCPDF | Excel::DOMPDF + | + */ + 'pdf' => Excel::DOMPDF, + ], + + /* + |-------------------------------------------------------------------------- + | Value Binder + |-------------------------------------------------------------------------- + | + | PhpSpreadsheet offers a way to hook into the process of a value being + | written to a cell. In there some assumptions are made on how the + | value should be formatted. If you want to change those defaults, + | you can implement your own default value binder. + | + | Possible value binders: + | + | [x] Maatwebsite\Excel\DefaultValueBinder::class + | [x] PhpOffice\PhpSpreadsheet\Cell\StringValueBinder::class + | [x] PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder::class + | + */ + 'value_binder' => [ + 'default' => Maatwebsite\Excel\DefaultValueBinder::class, + ], + + 'cache' => [ + /* + |-------------------------------------------------------------------------- + | Default cell caching driver + |-------------------------------------------------------------------------- + | + | By default PhpSpreadsheet keeps all cell values in memory, however when + | dealing with large files, this might result into memory issues. If you + | want to mitigate that, you can configure a cell caching driver here. + | When using the illuminate driver, it will store each value in the + | cache store. This can slow down the process, because it needs to + | store each value. You can use the "batch" store if you want to + | only persist to the store when the memory limit is reached. + | + | Drivers: memory|illuminate|batch + | + */ + 'driver' => 'memory', + + /* + |-------------------------------------------------------------------------- + | Batch memory caching + |-------------------------------------------------------------------------- + | + | When dealing with the "batch" caching driver, it will only + | persist to the store when the memory limit is reached. + | Here you can tweak the memory limit to your liking. + | + */ + 'batch' => [ + 'memory_limit' => 60000, + ], + + /* + |-------------------------------------------------------------------------- + | Illuminate cache + |-------------------------------------------------------------------------- + | + | When using the "illuminate" caching driver, it will automatically use + | your default cache store. However if you prefer to have the cell + | cache on a separate store, you can configure the store name here. + | You can use any store defined in your cache config. When leaving + | at "null" it will use the default store. + | + */ + 'illuminate' => [ + 'store' => null, + ], + + /* + |-------------------------------------------------------------------------- + | Cache Time-to-live (TTL) + |-------------------------------------------------------------------------- + | + | The TTL of items written to cache. If you want to keep the items cached + | indefinitely, set this to null. Otherwise, set a number of seconds, + | a \DateInterval, or a callable. + | + | Allowable types: callable|\DateInterval|int|null + | + */ + 'default_ttl' => 10800, + ], + + /* + |-------------------------------------------------------------------------- + | Transaction Handler + |-------------------------------------------------------------------------- + | + | By default the import is wrapped in a transaction. This is useful + | for when an import may fail and you want to retry it. With the + | transactions, the previous import gets rolled-back. + | + | You can disable the transaction handler by setting this to null. + | Or you can choose a custom made transaction handler here. + | + | Supported handlers: null|db + | + */ + 'transactions' => [ + 'handler' => 'db', + 'db' => [ + 'connection' => null, + ], + ], + + 'temporary_files' => [ + + /* + |-------------------------------------------------------------------------- + | Local Temporary Path + |-------------------------------------------------------------------------- + | + | When exporting and importing files, we use a temporary file, before + | storing reading or downloading. Here you can customize that path. + | permissions is an array with the permission flags for the directory (dir) + | and the create file (file). + | + */ + 'local_path' => storage_path('framework/cache/laravel-excel'), + + /* + |-------------------------------------------------------------------------- + | Local Temporary Path Permissions + |-------------------------------------------------------------------------- + | + | Permissions is an array with the permission flags for the directory (dir) + | and the create file (file). + | If omitted the default permissions of the filesystem will be used. + | + */ + 'local_permissions' => [ + // 'dir' => 0755, + // 'file' => 0644, + ], + + /* + |-------------------------------------------------------------------------- + | Remote Temporary Disk + |-------------------------------------------------------------------------- + | + | When dealing with a multi server setup with queues in which you + | cannot rely on having a shared local temporary path, you might + | want to store the temporary file on a shared disk. During the + | queue executing, we'll retrieve the temporary file from that + | location instead. When left to null, it will always use + | the local path. This setting only has effect when using + | in conjunction with queued imports and exports. + | + */ + 'remote_disk' => null, + 'remote_prefix' => null, + + /* + |-------------------------------------------------------------------------- + | Force Resync + |-------------------------------------------------------------------------- + | + | When dealing with a multi server setup as above, it's possible + | for the clean up that occurs after entire queue has been run to only + | cleanup the server that the last AfterImportJob runs on. The rest of the server + | would still have the local temporary file stored on it. In this case your + | local storage limits can be exceeded and future imports won't be processed. + | To mitigate this you can set this config value to be true, so that after every + | queued chunk is processed the local temporary file is deleted on the server that + | processed it. + | + */ + 'force_resync_remote' => null, + ], +]; diff --git a/package-lock.json b/package-lock.json index 045eeca..625d796 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "star-erp", "dependencies": { "@inertiajs/react": "^2.3.4", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", @@ -851,6 +852,37 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-alert-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", diff --git a/package.json b/package.json index 3b7d43f..0c7d346 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@inertiajs/react": "^2.3.4", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", diff --git a/resources/js/Components/Product/ProductImportDialog.tsx b/resources/js/Components/Product/ProductImportDialog.tsx new file mode 100644 index 0000000..eab852b --- /dev/null +++ b/resources/js/Components/Product/ProductImportDialog.tsx @@ -0,0 +1,142 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/Components/ui/dialog"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { Upload, Download, FileSpreadsheet, AlertCircle, Info } from "lucide-react"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/Components/ui/accordion"; +import { useForm } from "@inertiajs/react"; +import { Alert, AlertDescription } from "@/Components/ui/alert"; + +interface ProductImportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function ProductImportDialog({ open, onOpenChange }: ProductImportDialogProps) { + const { data, setData, post, processing, errors, reset, clearErrors } = useForm<{ + file: File | null; + }>({ + file: null, + }); + + + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files[0]) { + setData("file", e.target.files[0]); + clearErrors("file"); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + post(route("products.import"), { + onSuccess: () => { + reset(); + onOpenChange(false); + }, + }); + }; + + const handleDownloadTemplate = () => { + window.location.href = route('products.template'); + }; + + return ( + + + + 匯入商品資料 + + 請先下載範本,填寫後上傳。系統將自動建立商品資料。 + + + +
+ {/* 步驟 1: 下載範本 */} +
+ +
+ 下載標準範本以確保資料格式正確。請勿修改欄位名稱。 +
+ +
+ + {/* 步驟 2: 上傳檔案 */} +
+ + +
+ +
+ {errors.file && ( + + + + {errors.file} + + + )} +
+ + {/* 欄位說明 */} + + + +
+ + 欄位填寫規則 +
+
+ +
+
    +
  • 必填欄位:商品代號 (1-5 碼)、條碼、商品名稱、類別名稱、基本單位。
  • +
  • 唯一性:商品代號與條碼不可與現有資料重複。
  • +
  • 自動關聯:類別與單位請填寫系統當前存在的「名稱」(如:飲品、瓶)。
  • +
  • 大單位:若填寫大單位,則「換算率」為必填(需大於 0)。
  • +
+
+
+
+
+ + + + + +
+
+
+ ); +} diff --git a/resources/js/Pages/Product/Index.tsx b/resources/js/Pages/Product/Index.tsx index 662673d..7e74e4b 100644 --- a/resources/js/Pages/Product/Index.tsx +++ b/resources/js/Pages/Product/Index.tsx @@ -2,9 +2,10 @@ import { useState, useEffect, useCallback } from "react"; import { Button } from "@/Components/ui/button"; import { Input } from "@/Components/ui/input"; import { SearchableSelect } from "@/Components/ui/searchable-select"; -import { Plus, Search, Package, X } from 'lucide-react'; +import { Plus, Search, Package, X, Upload } from 'lucide-react'; import ProductTable from "@/Components/Product/ProductTable"; import ProductDialog from "@/Components/Product/ProductDialog"; +import ProductImportDialog from "@/Components/Product/ProductImportDialog"; import CategoryManagerDialog from "@/Components/Category/CategoryManagerDialog"; import UnitManagerDialog, { Unit } from "@/Components/Unit/UnitManagerDialog"; import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; @@ -67,6 +68,7 @@ export default function ProductManagement({ products, categories, units, filters const [isDialogOpen, setIsDialogOpen] = useState(false); const [isCategoryDialogOpen, setIsCategoryDialogOpen] = useState(false); const [isUnitDialogOpen, setIsUnitDialogOpen] = useState(false); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [editingProduct, setEditingProduct] = useState(null); // Sync state with props when they change (e.g. navigation) @@ -241,6 +243,16 @@ export default function ProductManagement({ products, categories, units, filters 管理單位 + + +