feat: [商品管理] 優化商品匯入邏輯,支援 13 碼條碼自動生成、Upsert 更新機制與 Excel 說明工作表
This commit is contained in:
@@ -197,6 +197,10 @@ class ProductController extends Controller
|
|||||||
$validated['code'] = $this->generateRandomCode();
|
$validated['code'] = $this->generateRandomCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($validated['barcode'])) {
|
||||||
|
$validated['barcode'] = $this->generateRandomBarcode();
|
||||||
|
}
|
||||||
|
|
||||||
$product = Product::create($validated);
|
$product = Product::create($validated);
|
||||||
|
|
||||||
return redirect()->route('products.index')->with('success', '商品已建立');
|
return redirect()->route('products.index')->with('success', '商品已建立');
|
||||||
@@ -260,6 +264,10 @@ class ProductController extends Controller
|
|||||||
$validated['code'] = $this->generateRandomCode();
|
$validated['code'] = $this->generateRandomCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (empty($validated['barcode'])) {
|
||||||
|
$validated['barcode'] = $this->generateRandomBarcode();
|
||||||
|
}
|
||||||
|
|
||||||
$product->update($validated);
|
$product->update($validated);
|
||||||
|
|
||||||
if ($request->input('from') === 'show') {
|
if ($request->input('from') === 'show') {
|
||||||
@@ -328,4 +336,21 @@ class ProductController extends Controller
|
|||||||
|
|
||||||
return $code;
|
return $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成隨機 13 碼條碼 (純數字)
|
||||||
|
*/
|
||||||
|
private function generateRandomBarcode(): string
|
||||||
|
{
|
||||||
|
$barcode = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
$barcode = '';
|
||||||
|
for ($i = 0; $i < 13; $i++) {
|
||||||
|
$barcode .= rand(0, 9);
|
||||||
|
}
|
||||||
|
} while (Product::where('barcode', $barcode)->exists());
|
||||||
|
|
||||||
|
return $barcode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
app/Modules/Inventory/Exports/InstructionSheet.php
Normal file
54
app/Modules/Inventory/Exports/InstructionSheet.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Exports;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||||
|
|
||||||
|
class InstructionSheet implements FromCollection, WithHeadings, WithTitle, WithStyles
|
||||||
|
{
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return '填寫說明';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'欄位名稱',
|
||||||
|
'是否必填',
|
||||||
|
'填寫說明',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function collection()
|
||||||
|
{
|
||||||
|
return collect([
|
||||||
|
['商品代號', '選填', '2-8 碼,若未填寫系統將自動生成。若代號已存在,將更新該商品資料。'],
|
||||||
|
['條碼', '選填', '13 碼數字,若未填寫系統將自動生成。若條碼已存在(優先比對),將更新該商品資料。'],
|
||||||
|
['商品名稱', '必填', '請填寫完整商品名稱。'],
|
||||||
|
['類別名稱', '必填', '必須為系統中已存在的類別名稱(如:飲品)。'],
|
||||||
|
['品牌', '選填', '商品品牌名稱。'],
|
||||||
|
['規格', '選填', '商品規格描述(如:25kg/袋)。'],
|
||||||
|
['基本單位', '必填', '必須為系統中已存在的單位名稱(如:瓶、個)。'],
|
||||||
|
['大單位', '選填', '若有大單位換算請填寫(如:箱)。'],
|
||||||
|
['換算率', '若有大單位則必填', '1 個大單位等於多少個基本單位。'],
|
||||||
|
['成本價', '選填', '數字,預設為 0。'],
|
||||||
|
['售價', '選填', '數字,預設為 0。'],
|
||||||
|
['會員價', '選填', '數字,預設為 0。'],
|
||||||
|
['批發價', '選填', '數字,預設為 0。'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function styles(Worksheet $sheet)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// 第一行標題粗體
|
||||||
|
1 => ['font' => ['bold' => true]],
|
||||||
|
// 欄位寬度自動
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Modules/Inventory/Exports/ProductImportSheet.php
Normal file
43
app/Modules/Inventory/Exports/ProductImportSheet.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Modules\Inventory\Exports;
|
||||||
|
|
||||||
|
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
||||||
|
use Maatwebsite\Excel\Concerns\WithTitle;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||||
|
|
||||||
|
class ProductImportSheet implements WithHeadings, WithColumnFormatting, WithTitle
|
||||||
|
{
|
||||||
|
public function title(): string
|
||||||
|
{
|
||||||
|
return '商品匯入';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function headings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'商品代號(選填)',
|
||||||
|
'條碼(選填)',
|
||||||
|
'商品名稱',
|
||||||
|
'類別名稱',
|
||||||
|
'品牌',
|
||||||
|
'規格',
|
||||||
|
'基本單位',
|
||||||
|
'大單位',
|
||||||
|
'換算率',
|
||||||
|
'成本價',
|
||||||
|
'售價',
|
||||||
|
'會員價',
|
||||||
|
'批發價',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function columnFormats(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'A' => NumberFormat::FORMAT_TEXT, // 商品代號
|
||||||
|
'B' => NumberFormat::FORMAT_TEXT, // 條碼
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,39 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Modules\Inventory\Exports;
|
namespace App\Modules\Inventory\Exports;
|
||||||
|
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
use Maatwebsite\Excel\Concerns\WithMultipleSheets;
|
||||||
|
|
||||||
use Maatwebsite\Excel\Concerns\WithColumnFormatting;
|
class ProductTemplateExport implements WithMultipleSheets
|
||||||
// use Maatwebsite\Excel\Concerns\WithHeadings;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
|
||||||
|
|
||||||
class ProductTemplateExport implements WithHeadings, WithColumnFormatting
|
|
||||||
{
|
{
|
||||||
public function headings(): array
|
public function sheets(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'商品代號',
|
new ProductImportSheet(),
|
||||||
'條碼',
|
new InstructionSheet(),
|
||||||
'商品名稱',
|
|
||||||
'類別名稱',
|
|
||||||
'品牌',
|
|
||||||
'規格',
|
|
||||||
'基本單位',
|
|
||||||
'大單位',
|
|
||||||
'換算率',
|
|
||||||
'成本價',
|
|
||||||
'售價',
|
|
||||||
'會員價',
|
|
||||||
'批發價',
|
|
||||||
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function columnFormats(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'A' => NumberFormat::FORMAT_TEXT, // 商品代號
|
|
||||||
'B' => NumberFormat::FORMAT_TEXT, // 條碼
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,21 +57,25 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
$baseUnitId = $this->units[$row['基本單位']] ?? null;
|
$baseUnitId = $this->units[$row['基本單位']] ?? null;
|
||||||
$largeUnitId = isset($row['大單位']) ? ($this->units[$row['大單位']] ?? null) : null;
|
$largeUnitId = isset($row['大單位']) ? ($this->units[$row['大單位']] ?? null) : null;
|
||||||
|
|
||||||
|
|
||||||
// 若必要關聯找不到,理論上 Validation 會攔截,但此處做防禦性編程
|
// 若必要關聯找不到,理論上 Validation 會攔截,但此處做防禦性編程
|
||||||
if (!$categoryId || !$baseUnitId) {
|
if (!$categoryId || !$baseUnitId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 處理商品代號:若為空則自動生成
|
|
||||||
$code = $row['商品代號'] ?? null;
|
$code = $row['商品代號'] ?? null;
|
||||||
if (empty($code)) {
|
$barcode = $row['條碼'] ?? null;
|
||||||
$code = $this->generateRandomCode();
|
|
||||||
|
// Upsert 邏輯:優先以條碼查找,次之以商品代號查找
|
||||||
|
$product = null;
|
||||||
|
if (!empty($barcode)) {
|
||||||
|
$product = Product::where('barcode', $barcode)->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Product([
|
if (!$product && !empty($code)) {
|
||||||
'code' => $code,
|
$product = Product::where('code', $code)->first();
|
||||||
'barcode' => $row['條碼'],
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
'name' => $row['商品名稱'],
|
'name' => $row['商品名稱'],
|
||||||
'category_id' => $categoryId,
|
'category_id' => $categoryId,
|
||||||
'brand' => $row['品牌'] ?? null,
|
'brand' => $row['品牌'] ?? null,
|
||||||
@@ -84,7 +88,26 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
'price' => $row['售價'] ?? null,
|
'price' => $row['售價'] ?? null,
|
||||||
'member_price' => $row['會員價'] ?? null,
|
'member_price' => $row['會員價'] ?? null,
|
||||||
'wholesale_price' => $row['批發價'] ?? null,
|
'wholesale_price' => $row['批發價'] ?? null,
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($product) {
|
||||||
|
// 更新現有商品
|
||||||
|
$product->update($data);
|
||||||
|
return null; // 返回 null 以避免 Maatwebsite/Excel 嘗試再次 insert
|
||||||
|
}
|
||||||
|
|
||||||
|
// 建立新商品:處理代碼與條碼自動生成
|
||||||
|
if (empty($code)) {
|
||||||
|
$code = $this->generateRandomCode();
|
||||||
|
}
|
||||||
|
if (empty($barcode)) {
|
||||||
|
$barcode = $this->generateRandomBarcode();
|
||||||
|
}
|
||||||
|
|
||||||
|
$data['code'] = $code;
|
||||||
|
$data['barcode'] = $barcode;
|
||||||
|
|
||||||
|
return new Product($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -105,11 +128,28 @@ class ProductImport implements ToModel, WithHeadingRow, WithValidation, WithMapp
|
|||||||
return $code;
|
return $code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成隨機 13 碼條碼 (純數字)
|
||||||
|
*/
|
||||||
|
private function generateRandomBarcode(): string
|
||||||
|
{
|
||||||
|
$barcode = '';
|
||||||
|
|
||||||
|
do {
|
||||||
|
$barcode = '';
|
||||||
|
for ($i = 0; $i < 13; $i++) {
|
||||||
|
$barcode .= rand(0, 9);
|
||||||
|
}
|
||||||
|
} while (Product::where('barcode', $barcode)->exists());
|
||||||
|
|
||||||
|
return $barcode;
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'商品代號' => ['nullable', 'string', 'min:2', 'max:8', 'unique:products,code'],
|
'商品代號' => ['nullable', 'string', 'min:2', 'max:8'],
|
||||||
'條碼' => ['required', 'string', 'unique:products,barcode'],
|
'條碼' => ['nullable', 'string'],
|
||||||
'商品名稱' => ['required', 'string'],
|
'商品名稱' => ['required', 'string'],
|
||||||
'類別名稱' => ['required', function($attribute, $value, $fail) {
|
'類別名稱' => ['required', function($attribute, $value, $fail) {
|
||||||
if (!isset($this->categories[$value])) {
|
if (!isset($this->categories[$value])) {
|
||||||
|
|||||||
@@ -62,8 +62,11 @@ export default function ProductForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateRandomBarcode = () => {
|
const generateRandomBarcode = () => {
|
||||||
const randomDigits = Math.floor(Math.random() * 9000000000) + 1000000000;
|
let result = "";
|
||||||
setData("barcode", randomDigits.toString());
|
for (let i = 0; i < 13; i++) {
|
||||||
|
result += Math.floor(Math.random() * 10).toString();
|
||||||
|
}
|
||||||
|
setData("barcode", result);
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateRandomCode = () => {
|
const generateRandomCode = () => {
|
||||||
@@ -150,7 +153,7 @@ export default function ProductForm({
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="barcode">
|
<Label htmlFor="barcode">
|
||||||
條碼編號 <span className="text-red-500">*</span>
|
條碼編號 <span className="text-gray-400 font-normal">(選填,未填將自動生成)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -111,9 +111,11 @@ export default function ProductImportDialog({ open, onOpenChange }: ProductImpor
|
|||||||
<AccordionContent>
|
<AccordionContent>
|
||||||
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
|
<div className="text-sm text-gray-600 space-y-2 pb-2 pl-6">
|
||||||
<ul className="list-disc space-y-1">
|
<ul className="list-disc space-y-1">
|
||||||
<li><span className="font-medium text-gray-700">必填欄位</span>:商品名稱、類別名稱、基本單位、條碼。</li>
|
<li><span className="font-medium text-gray-700">必填欄位</span>:商品名稱、類別名稱、基本單位。</li>
|
||||||
<li><span className="font-medium text-gray-700">商品代號</span>:2-8 碼,非必填(未填將自動生成,大寫英文+數字 8 碼)。</li>
|
<li><span className="font-medium text-gray-700">商品代號</span>:2-8 碼,非必填(未填將自動生成,大寫英文+數字 8 碼)。</li>
|
||||||
<li><span className="font-medium text-gray-700">唯一性</span>:商品代號(若有填寫)與條碼不可與現有資料重複。</li>
|
<li><span className="font-medium text-gray-700">條碼</span>:13 碼數字,非必填(未填將自動生成)。</li>
|
||||||
|
<li><span className="font-medium text-gray-700">補充說明</span>:詳細規則亦可於 Excel 範本的<strong>「填寫說明」</strong>工作表中查看。</li>
|
||||||
|
<li><span className="font-medium text-gray-700">更新機制</span>:若系統中已有相同<strong>「條碼」</strong>或<strong>「商品代號」</strong>,系統將自動更新該筆資料,不會重複建立。</li>
|
||||||
<li><span className="font-medium text-gray-700">自動關聯</span>:類別與單位請填寫系統當前存在的「名稱」(如:飲品、瓶)。</li>
|
<li><span className="font-medium text-gray-700">自動關聯</span>:類別與單位請填寫系統當前存在的「名稱」(如:飲品、瓶)。</li>
|
||||||
<li><span className="font-medium text-gray-700">大單位</span>:若填寫大單位,則「換算率」為必填(需大於 0)。</li>
|
<li><span className="font-medium text-gray-700">大單位</span>:若填寫大單位,則「換算率」為必填(需大於 0)。</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user