[PROMOTE] dev → demo:商品匯入圖片功能與銷售發票調整
1. [FEAT] 商品匯入支援圖片:Excel 新增「圖片檔名」欄,搭配圖片壓縮檔(上限 60MB)一併上傳,背景 Job 轉 WebP 入庫;含安全解壓防護與 retry_after 調整。 2. [FIX] 銷售掃碼支付方式細分為玉山/TayPay,並調整訂單詳情發票操作按鈕與相關分頁顯示。
This commit is contained in:
commit
db0099f8c9
@ -88,13 +88,14 @@ class AnalysisController extends Controller
|
||||
$availablePaymentTypes = [
|
||||
1 => __('Credit Card'),
|
||||
2 => __('E-Ticket (EasyCard/iPass)'),
|
||||
3 => __('QR Code Payment'),
|
||||
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
|
||||
4 => __('Bill Acceptor'),
|
||||
5 => __('Pass Code'),
|
||||
6 => __('Pickup Code'),
|
||||
7 => __('Welcome Gift'),
|
||||
8 => __('Questionnaire'),
|
||||
9 => __('Coin Acceptor'),
|
||||
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
|
||||
41 => __('Staff Card'),
|
||||
42 => __('Pickup Voucher'),
|
||||
];
|
||||
|
||||
@ -594,6 +594,7 @@ class ProductController extends Controller
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:xlsx|max:5120',
|
||||
'company_id' => 'nullable|exists:companies,id',
|
||||
'images' => 'nullable|file|mimes:zip|max:61440', // 60MB;落在 php 預設 100M 與 Cloudflare 100M 之內,免改機器設定
|
||||
]);
|
||||
|
||||
$companyId = $request->get('company_id') ?: auth()->user()->company_id;
|
||||
@ -605,23 +606,128 @@ class ProductController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$results = $importService->import(
|
||||
$request->file('file')->getRealPath(),
|
||||
$companyId,
|
||||
auth()->id()
|
||||
);
|
||||
// 有圖片壓縮檔:解壓到暫存目錄,圖片轉檔交由背景 Job 處理 (避免大量同步轉檔逾時)
|
||||
$imageDir = $request->hasFile('images') ? $this->extractImageZip($request->file('images')) : null;
|
||||
|
||||
try {
|
||||
$results = $importService->import(
|
||||
$request->file('file')->getRealPath(),
|
||||
$companyId,
|
||||
auth()->id(),
|
||||
$imageDir,
|
||||
deferImages: $imageDir !== null
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($imageDir && is_dir($imageDir)) {
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($imageDir);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
|
||||
foreach ($results['company_ids'] as $importedCompanyId) {
|
||||
$this->catalogService->rebuildCache($importedCompanyId);
|
||||
}
|
||||
|
||||
// 派發背景圖片處理 Job (Job 負責轉檔 + 重建快取 + 刪暫存目錄)
|
||||
$queuedImages = 0;
|
||||
if ($imageDir) {
|
||||
if (!empty($results['pending_images'])) {
|
||||
$queuedImages = count($results['pending_images']);
|
||||
\App\Jobs\Product\AttachProductImagesJob::dispatch(
|
||||
$results['pending_images'],
|
||||
$imageDir,
|
||||
$companyId,
|
||||
auth()->id()
|
||||
);
|
||||
} else {
|
||||
// 沒有任何圖片需處理,立即清掉暫存目錄
|
||||
\Illuminate\Support\Facades\File::deleteDirectory($imageDir);
|
||||
}
|
||||
}
|
||||
|
||||
$message = __(':count products imported successfully', ['count' => $results['success_count']]);
|
||||
if ($queuedImages > 0) {
|
||||
$message .= ' · ' . __(':count images are being processed in the background', ['count' => $queuedImages]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => __(':count products imported successfully', ['count' => $results['success_count']]),
|
||||
'message' => $message,
|
||||
'results' => $results,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將上傳的圖片壓縮檔安全解壓到暫存目錄,回傳目錄路徑。
|
||||
* 防護:副檔名白名單、basename 防穿越、項目數/單檔/總量上限 (防 zip bomb)、內容驗證為圖片。
|
||||
*/
|
||||
private function extractImageZip(\Illuminate\Http\UploadedFile $zipFile): string
|
||||
{
|
||||
$allowedExt = ['jpg', 'jpeg', 'png', 'webp'];
|
||||
$maxEntries = 2000;
|
||||
$maxFileBytes = 5 * 1024 * 1024; // 單檔解壓上限 5MB
|
||||
$maxTotalBytes = 200 * 1024 * 1024; // 總解壓上限 200MB
|
||||
|
||||
$dir = storage_path('app/tmp/product_import_' . Str::uuid()->toString());
|
||||
\Illuminate\Support\Facades\File::ensureDirectoryExists($dir);
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFile->getRealPath()) !== true) {
|
||||
throw new \RuntimeException(__('Unable to read the image archive'));
|
||||
}
|
||||
|
||||
if ($zip->numFiles > $maxEntries) {
|
||||
$zip->close();
|
||||
throw new \RuntimeException(__('The image archive contains too many files'));
|
||||
}
|
||||
|
||||
$totalBytes = 0;
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
$entryName = $stat['name'];
|
||||
|
||||
// 跳過目錄與含路徑穿越的項目,只取檔名
|
||||
if (str_ends_with($entryName, '/') || str_contains($entryName, '..')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$base = basename($entryName);
|
||||
$ext = strtolower(pathinfo($base, PATHINFO_EXTENSION));
|
||||
if (!in_array($ext, $allowedExt, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($stat['size'] > $maxFileBytes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalBytes += $stat['size'];
|
||||
if ($totalBytes > $maxTotalBytes) {
|
||||
$zip->close();
|
||||
throw new \RuntimeException(__('The image archive is too large when extracted'));
|
||||
}
|
||||
|
||||
$stream = $zip->getStream($entryName);
|
||||
if ($stream === false) {
|
||||
continue;
|
||||
}
|
||||
$contents = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
|
||||
// 內容驗證:確認真為圖片,擋掉偽裝檔
|
||||
if ($contents === false || getimagesizefromstring($contents) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
file_put_contents($dir . '/' . $base, $contents);
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步商品目錄至全公司機台 (Phase 2)
|
||||
*/
|
||||
|
||||
99
app/Jobs/Product/AttachProductImagesJob.php
Normal file
99
app/Jobs/Product/AttachProductImagesJob.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Product;
|
||||
|
||||
use App\Models\Product\Product;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Services\Product\ProductCatalogService;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* 背景處理產品匯入的圖片:將已解壓資料夾中的圖片逐筆轉 WebP 入庫並寫回 image_url。
|
||||
* 與商品文字匯入解耦,避免大量圖片同步轉檔導致請求逾時。
|
||||
* 完成後重建目錄快取並刪除暫存圖片資料夾。
|
||||
*/
|
||||
class AttachProductImagesJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels, ImageHandler;
|
||||
|
||||
public int $timeout = 1800; // 30 分鐘,容納大量圖片轉檔
|
||||
public int $tries = 1; // 圖片步驟不重試整批(避免重複轉檔),失敗以 log 呈現
|
||||
|
||||
/**
|
||||
* @param array<int, array{product_id:int, filename:string, row:int}> $pendingImages
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $pendingImages,
|
||||
protected string $imageDir,
|
||||
protected ?int $companyId = null,
|
||||
protected ?int $userId = null
|
||||
) {}
|
||||
|
||||
public function handle(ProductCatalogService $catalogService): void
|
||||
{
|
||||
$success = 0;
|
||||
$missing = [];
|
||||
|
||||
try {
|
||||
foreach ($this->pendingImages as $item) {
|
||||
$product = Product::withoutGlobalScopes()->find($item['product_id']);
|
||||
if (!$product) {
|
||||
$missing[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = rtrim($this->imageDir, '/') . '/' . basename($item['filename']);
|
||||
if (!is_file($path) || getimagesize($path) === false) {
|
||||
$missing[] = $item;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$oldImage = $product->image_url;
|
||||
$file = new UploadedFile($path, basename($path), null, null, true);
|
||||
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
|
||||
$product->update(['image_url' => Storage::url($storedPath)]);
|
||||
|
||||
if ($oldImage) {
|
||||
Storage::disk('public')->delete(str_replace('/storage/', '', $oldImage));
|
||||
}
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$missing[] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
// 圖片已更新,重建目錄快取讓機台拿到新圖
|
||||
if ($this->companyId) {
|
||||
$catalogService->rebuildCache($this->companyId);
|
||||
}
|
||||
|
||||
SystemOperationLog::create([
|
||||
'company_id' => $this->companyId,
|
||||
'user_id' => $this->userId,
|
||||
'module' => 'product',
|
||||
'action' => 'update',
|
||||
'target_type' => Product::class,
|
||||
'note' => 'product_import_images_processed',
|
||||
'new_values' => [
|
||||
'image_success' => $success,
|
||||
'image_missing' => count($missing),
|
||||
'missing_detail' => $missing,
|
||||
],
|
||||
]);
|
||||
} finally {
|
||||
// 無論成敗,清掉暫存圖片資料夾
|
||||
if (is_dir($this->imageDir)) {
|
||||
File::deleteDirectory($this->imageDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -86,7 +86,7 @@ class Order extends Model
|
||||
return [
|
||||
1 => __('Credit Card'),
|
||||
2 => __('E-Ticket (EasyCard/iPass)'),
|
||||
3 => __('QR Code Payment'),
|
||||
3 => __('QR Code Payment - E.SUN'), // 掃碼支付-玉山
|
||||
4 => __('Bill Acceptor'),
|
||||
5 => __('Pass Code'), // 使用者要求由「通關密碼」改為「通行碼」
|
||||
6 => __('Pickup Code'),
|
||||
@ -94,6 +94,7 @@ class Order extends Model
|
||||
8 => __('Questionnaire'),
|
||||
9 => __('Coin Acceptor'),
|
||||
10 => __('Mobile Pay'), // NFC 手機感應(行動支付,信用卡軌道,依使用者按鈕分類)
|
||||
11 => __('QR Code Payment - TayPay'), // 掃碼支付-TayPay
|
||||
21 => __('Offline + 1'),
|
||||
22 => __('Offline + 2'),
|
||||
23 => __('Offline + 3'),
|
||||
|
||||
@ -6,7 +6,10 @@ use App\Models\Product\Product;
|
||||
use App\Models\Product\ProductCategory;
|
||||
use App\Models\System\SystemOperationLog;
|
||||
use App\Models\System\Translation;
|
||||
use App\Traits\ImageHandler;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use OpenSpout\Reader\XLSX\Reader;
|
||||
@ -15,7 +18,14 @@ use OpenSpout\Common\Entity\Row;
|
||||
|
||||
class ProductImportService
|
||||
{
|
||||
public function import(string $filePath, ?int $companyId = null, ?int $userId = null): array
|
||||
use ImageHandler;
|
||||
|
||||
/**
|
||||
* @param string|null $imageDir 已解壓的圖片資料夾路徑 (Excel「圖片檔名」欄對應此資料夾內檔案)
|
||||
* @param bool $deferImages true 時不在此同步轉檔,改將 (product_id, filename) 收集到 results['pending_images']
|
||||
* 供呼叫端丟背景 Job 處理,避免大量圖片同步轉檔逾時。
|
||||
*/
|
||||
public function import(string $filePath, ?int $companyId = null, ?int $userId = null, ?string $imageDir = null, bool $deferImages = false): array
|
||||
{
|
||||
$reader = new Reader();
|
||||
$reader->open($filePath);
|
||||
@ -27,6 +37,9 @@ class ProductImportService
|
||||
'error_count' => 0,
|
||||
'errors' => [],
|
||||
'company_ids' => [],
|
||||
'image_success' => 0,
|
||||
'image_missing' => [],
|
||||
'pending_images' => [],
|
||||
];
|
||||
|
||||
$rowCount = 0;
|
||||
@ -65,6 +78,7 @@ class ProductImportService
|
||||
'points_half' => 'nullable|integer|min:0',
|
||||
'points_half_amount' => 'nullable|integer|min:0',
|
||||
'is_active' => 'nullable|boolean',
|
||||
'image_filename' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -73,7 +87,7 @@ class ProductImportService
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($data, $rowCount, $userId, &$results) {
|
||||
DB::transaction(function () use ($data, $rowCount, $userId, $imageDir, $deferImages, &$results) {
|
||||
$categoryId = $this->resolveCategoryId($data['category'], $data['company_id']);
|
||||
$lookup = $this->buildProductLookup($data);
|
||||
$product = Product::withoutGlobalScopes()->where($lookup)->first();
|
||||
@ -116,6 +130,18 @@ class ProductImportService
|
||||
$note = 'product_import_created';
|
||||
}
|
||||
|
||||
if ($deferImages) {
|
||||
if ($imageDir && $data['image_filename']) {
|
||||
$results['pending_images'][] = [
|
||||
'product_id' => $product->id,
|
||||
'filename' => $data['image_filename'],
|
||||
'row' => $rowCount,
|
||||
];
|
||||
}
|
||||
} else {
|
||||
$this->attachImage($product, $data['image_filename'], $imageDir, $rowCount, $results);
|
||||
}
|
||||
|
||||
$results['success_count']++;
|
||||
if ($product->company_id) {
|
||||
$results['company_ids'][$product->company_id] = $product->company_id;
|
||||
@ -168,6 +194,7 @@ class ProductImportService
|
||||
'半額點數(選填)',
|
||||
'半額點數折抵金額(選填)',
|
||||
'是否啟用(1/0,選填)',
|
||||
'圖片檔名(選填,需一併上傳圖片壓縮檔)',
|
||||
]));
|
||||
|
||||
$writer->addRow(Row::fromValues([
|
||||
@ -188,6 +215,7 @@ class ProductImportService
|
||||
400,
|
||||
40,
|
||||
1,
|
||||
'latte.png',
|
||||
]));
|
||||
|
||||
$writer->close();
|
||||
@ -214,9 +242,46 @@ class ProductImportService
|
||||
'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),
|
||||
'image_filename' => $this->stringValue($cells[17] ?? null),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 依「圖片檔名」欄將已解壓資料夾中的圖片轉 WebP 入庫並寫回 image_url。
|
||||
* 失敗 (找不到檔/非圖) 只記錄至 image_missing,不影響商品本身寫入。
|
||||
*/
|
||||
private function attachImage(Product $product, ?string $filename, ?string $imageDir, int $rowCount, array &$results): void
|
||||
{
|
||||
if (!$filename || !$imageDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$path = rtrim($imageDir, '/') . '/' . basename($filename);
|
||||
|
||||
if (!is_file($path) || getimagesize($path) === false) {
|
||||
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
|
||||
return;
|
||||
}
|
||||
|
||||
$oldImage = $product->image_url;
|
||||
|
||||
$file = new UploadedFile($path, basename($path), null, null, true);
|
||||
$storedPath = $this->storeAsWebp($file, 'products', 80, 320, 320);
|
||||
|
||||
$product->update(['image_url' => Storage::url($storedPath)]);
|
||||
|
||||
if ($oldImage) {
|
||||
$oldPath = str_replace('/storage/', '', $oldImage);
|
||||
Storage::disk('public')->delete($oldPath);
|
||||
}
|
||||
|
||||
$results['image_success']++;
|
||||
} catch (\Throwable $e) {
|
||||
$results['image_missing'][] = ['row' => $rowCount, 'filename' => $filename];
|
||||
}
|
||||
}
|
||||
|
||||
private function isEmptyRow(array $data): bool
|
||||
{
|
||||
return empty($data['category'])
|
||||
|
||||
@ -66,7 +66,8 @@ return [
|
||||
'driver' => 'redis',
|
||||
'connection' => 'default',
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => 90,
|
||||
// 須大於最長 Job 的 timeout(AttachProductImagesJob 1800s),否則長任務未跑完即被重派造成重複處理
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 1830),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
10
lang/en.json
10
lang/en.json
@ -6,6 +6,9 @@
|
||||
"30s": "30s",
|
||||
"60 Seconds": "60 Seconds",
|
||||
"60s": "60s",
|
||||
":count image files not found": ":count image files not found",
|
||||
":count images are being processed in the background": ":count images are being processed in the background",
|
||||
":count images matched": ":count images matched",
|
||||
":count products imported successfully": ":count products imported successfully",
|
||||
":count staff cards imported successfully": ":count staff cards imported successfully",
|
||||
"A new verification link has been sent to your email address.": "A new verification link has been sent to your email address.",
|
||||
@ -1407,6 +1410,7 @@
|
||||
"Optional remarks": "Optional remarks",
|
||||
"Optional remarks...": "Optional remarks...",
|
||||
"Optional. If you fill in any field, all fields below become required.": "Optional. If you fill in any field, all fields below become required.",
|
||||
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)",
|
||||
"Or Choose Custom Date": "Or Choose Custom Date",
|
||||
"Order Details": "Order Details",
|
||||
"Order Info": "Order Info",
|
||||
@ -1594,6 +1598,7 @@
|
||||
"Product Details": "Product Details",
|
||||
"Product ID": "Product ID",
|
||||
"Product Image": "Product Image",
|
||||
"Product Images (ZIP)": "Product Images (ZIP)",
|
||||
"Product Info": "Product Info",
|
||||
"Product Items": "Product Items",
|
||||
"Product List": "Product List",
|
||||
@ -1638,6 +1643,8 @@
|
||||
"QR CODE": "QR CODE",
|
||||
"QR Code": "QR Code",
|
||||
"QR Code Payment": "QR Code Payment",
|
||||
"QR Code Payment - E.SUN": "QR Code Payment - E.SUN",
|
||||
"QR Code Payment - TayPay": "QR Code Payment - TayPay",
|
||||
"QR Scan Payment": "QR Scan Payment",
|
||||
"Qty": "Qty",
|
||||
"Qty Change": "Qty Change",
|
||||
@ -2092,6 +2099,8 @@
|
||||
"The Super Admin role cannot be deleted.": "The Super Admin role cannot be deleted.",
|
||||
"The Super Admin role is immutable.": "The Super Admin role is immutable.",
|
||||
"The Super Admin role name cannot be modified.": "The Super Admin role name cannot be modified.",
|
||||
"The image archive contains too many files": "The image archive contains too many files",
|
||||
"The image archive is too large when extracted": "The image archive is too large when extracted",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "The image is too large. Please upload an image smaller than 1MB.",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "The image is too large. Please upload an image smaller than 5MB.",
|
||||
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "The machine will be notified to fetch the latest system settings. It may take a moment to apply.",
|
||||
@ -2182,6 +2191,7 @@
|
||||
"Type": "Type",
|
||||
"Type to search or leave blank for system defaults.": "Type to search or leave blank for system defaults.",
|
||||
"UI Elements": "UI Elements",
|
||||
"Unable to read the image archive": "Unable to read the image archive",
|
||||
"Unassigned": "Unassigned",
|
||||
"Unassigned / No Change": "Unassigned / No Change",
|
||||
"Unauthorized": "Unauthorized",
|
||||
|
||||
10
lang/ja.json
10
lang/ja.json
@ -6,6 +6,9 @@
|
||||
"30s": "30秒",
|
||||
"60 Seconds": "60秒",
|
||||
"60s": "60秒",
|
||||
":count image files not found": ":count 個の画像ファイルが見つかりません",
|
||||
":count images are being processed in the background": ":count 枚の画像をバックグラウンドで処理しています",
|
||||
":count images matched": ":count 枚の画像を紐付けました",
|
||||
":count products imported successfully": ":count products imported successfully",
|
||||
":count staff cards imported successfully": ":count 枚のスタッフカードが正常にインポートされました",
|
||||
"A new verification link has been sent to your email address.": "新しい確認リンクがメールアドレスに送信されました。",
|
||||
@ -1407,6 +1410,7 @@
|
||||
"Optional remarks": "任意の備考",
|
||||
"Optional remarks...": "任意の備考...",
|
||||
"Optional. If you fill in any field, all fields below become required.": "任意。いずれかの項目を入力すると、以下のすべての項目が必須になります。",
|
||||
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "任意。画像のファイル名を「画像ファイル名」列に記入し、画像を .zip にまとめてアップロードしてください (最大 60MB)",
|
||||
"Or Choose Custom Date": "または日付を選択",
|
||||
"Order Details": "注文詳細",
|
||||
"Order Info": "注文情報",
|
||||
@ -1594,6 +1598,7 @@
|
||||
"Product Details": "商品詳細",
|
||||
"Product ID": "商品ID",
|
||||
"Product Image": "商品画像",
|
||||
"Product Images (ZIP)": "商品画像 (ZIP)",
|
||||
"Product Info": "商品情報",
|
||||
"Product Items": "Product Items",
|
||||
"Product List": "商品リスト",
|
||||
@ -1638,6 +1643,8 @@
|
||||
"QR CODE": "QRコード",
|
||||
"QR Code": "QRコード",
|
||||
"QR Code Payment": "QRコード決済",
|
||||
"QR Code Payment - E.SUN": "QRコード決済-玉山銀行",
|
||||
"QR Code Payment - TayPay": "QRコード決済-TayPay",
|
||||
"QR Scan Payment": "QRスキャン決済",
|
||||
"Qty": "数量",
|
||||
"Qty Change": "変動数",
|
||||
@ -2092,6 +2099,8 @@
|
||||
"The Super Admin role cannot be deleted.": "特権管理者ロールは削除できません。",
|
||||
"The Super Admin role is immutable.": "特権管理者ロールは変更できません。",
|
||||
"The Super Admin role name cannot be modified.": "特権管理者ロールの名前は変更できません。",
|
||||
"The image archive contains too many files": "画像アーカイブのファイル数が多すぎます",
|
||||
"The image archive is too large when extracted": "画像アーカイブの展開後のサイズが大きすぎます",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "画像サイズが大きすぎます。1MB未満の画像をアップロードしてください。",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "画像サイズが大きすぎます。5MB未満の画像をアップロードしてください。",
|
||||
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "最新のシステム設定を取得するよう機台に通知しました。反映まで少々時間がかかる場合があります。",
|
||||
@ -2182,6 +2191,7 @@
|
||||
"Type": "タイプ",
|
||||
"Type to search or leave blank for system defaults.": "入力して検索、または空白のままでシステムデフォルトを使用します。",
|
||||
"UI Elements": "UI要素",
|
||||
"Unable to read the image archive": "画像アーカイブを読み込めません",
|
||||
"Unassigned": "未割り当て",
|
||||
"Unassigned / No Change": "未割り当て / 変更なし",
|
||||
"Unauthorized": "未授権",
|
||||
|
||||
@ -6,6 +6,9 @@
|
||||
"30s": "30秒",
|
||||
"60 Seconds": "60 秒",
|
||||
"60s": "60秒",
|
||||
":count image files not found": ":count 個圖片檔找不到",
|
||||
":count images are being processed in the background": ":count 張圖片背景處理中",
|
||||
":count images matched": "已配對 :count 張圖片",
|
||||
":count products imported successfully": "成功匯入 :count 筆商品",
|
||||
":count staff cards imported successfully": "成功匯入 :count 張員工識別卡",
|
||||
"A new verification link has been sent to your email address.": "已將新的驗證連結發送至您的電子郵件地址。",
|
||||
@ -1407,6 +1410,7 @@
|
||||
"Optional remarks": "選填備註",
|
||||
"Optional remarks...": "選填備註...",
|
||||
"Optional. If you fill in any field, all fields below become required.": "選填。只要填寫任一欄位,以下欄位即全部必填。",
|
||||
"Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)": "選填。在「圖片檔名」欄填寫檔名,再將圖片打包成 .zip 一併上傳 (上限 60MB)",
|
||||
"Or Choose Custom Date": "或選擇自訂日期",
|
||||
"Order Details": "訂單詳情",
|
||||
"Order Info": "單據資訊",
|
||||
@ -1594,6 +1598,7 @@
|
||||
"Product Details": "商品明細",
|
||||
"Product ID": "商品 ID",
|
||||
"Product Image": "商品圖片",
|
||||
"Product Images (ZIP)": "商品圖片壓縮檔 (ZIP)",
|
||||
"Product Info": "商品資訊",
|
||||
"Product Items": "商品品項",
|
||||
"Product List": "商品清單",
|
||||
@ -1638,6 +1643,8 @@
|
||||
"QR CODE": "二維碼",
|
||||
"QR Code": "QR Code",
|
||||
"QR Code Payment": "掃碼支付",
|
||||
"QR Code Payment - E.SUN": "掃碼支付-玉山",
|
||||
"QR Code Payment - TayPay": "掃碼支付-TayPay",
|
||||
"QR Scan Payment": "掃碼支付功能",
|
||||
"Qty": "數量",
|
||||
"Qty Change": "異動數量",
|
||||
@ -2092,6 +2099,8 @@
|
||||
"The Super Admin role cannot be deleted.": "超級管理員角色不可刪除。",
|
||||
"The Super Admin role is immutable.": "超級管理員角色不可修改。",
|
||||
"The Super Admin role name cannot be modified.": "超級管理員角色的名稱無法修改。",
|
||||
"The image archive contains too many files": "圖片壓縮檔內的檔案數量過多",
|
||||
"The image archive is too large when extracted": "圖片壓縮檔解壓後的體積過大",
|
||||
"The image is too large. Please upload an image smaller than 1MB.": "圖片檔案太大,請上傳小於 1MB 的圖片。",
|
||||
"The image is too large. Please upload an image smaller than 5MB.": "圖片檔案太大,請上傳小於 5MB 的圖片。",
|
||||
"The machine will be notified to fetch the latest system settings. It may take a moment to apply.": "已通知機台回抓最新系統設定,套用可能需要一點時間。",
|
||||
@ -2182,6 +2191,7 @@
|
||||
"Type": "類型",
|
||||
"Type to search or leave blank for system defaults.": "輸入關鍵字搜尋,或留空以使用系統預設。",
|
||||
"UI Elements": "UI元素",
|
||||
"Unable to read the image archive": "無法讀取圖片壓縮檔",
|
||||
"Unassigned": "未指派",
|
||||
"Unassigned / No Change": "未指派 / 不變動",
|
||||
"Unauthorized": "未授權",
|
||||
|
||||
@ -188,6 +188,27 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-[10px] font-black text-slate-400 uppercase tracking-[0.2em]">
|
||||
{{ __('Product Images (ZIP)') }}
|
||||
</label>
|
||||
<div class="relative group">
|
||||
<input type="file" id="product-import-images" @change="importFormFields.images = $event.target.files[0]"
|
||||
accept=".zip" class="luxury-input w-full pr-12 cursor-pointer">
|
||||
<div class="absolute right-4 top-1/2 -translate-y-1/2 text-slate-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-2 flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ __('Optional. Reference each filename in the image column, then upload them here as a .zip (Max 60MB)') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-white/5 rounded-2xl p-4 border border-slate-100 dark:border-white/5">
|
||||
<h4 class="text-xs font-black text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||
{{ __('Download Template') }}
|
||||
@ -827,7 +848,7 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
names: { zh_TW: '', en: '', ja: '' },
|
||||
company_id: ''
|
||||
},
|
||||
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null },
|
||||
importFormFields: { company_id: '{{ auth()->user()->company_id ?? "" }}', file: null, images: null },
|
||||
|
||||
init() {
|
||||
this.categories = JSON.parse(this.$el.dataset.categories || '[]');
|
||||
@ -1265,6 +1286,9 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
if (this.importFormFields.company_id) {
|
||||
formData.append('company_id', this.importFormFields.company_id);
|
||||
}
|
||||
if (this.importFormFields.images) {
|
||||
formData.append('images', this.importFormFields.images);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ route($baseRoute . '.import') }}`, {
|
||||
@ -1280,8 +1304,15 @@ hover:bg-slate-100 dark:hover:bg-white/5 rounded-lg flex items-center justify-be
|
||||
if (data.success) {
|
||||
this.isImportModalOpen = false;
|
||||
this.importFormFields.file = null;
|
||||
this.importFormFields.images = null;
|
||||
const input = document.getElementById('product-import-file');
|
||||
if (input) input.value = '';
|
||||
const imagesInput = document.getElementById('product-import-images');
|
||||
if (imagesInput) imagesInput.value = '';
|
||||
|
||||
if (data.results.image_missing && data.results.image_missing.length > 0) {
|
||||
console.warn('Image files not found for some rows:', data.results.image_missing);
|
||||
}
|
||||
sessionStorage.setItem('productImportToast', JSON.stringify({
|
||||
message: data.message,
|
||||
type: 'success'
|
||||
|
||||
@ -92,6 +92,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- 發票作廢 / 補開 確認框(自製 UI,取代瀏覽器原生 confirm) --}}
|
||||
<x-confirm-modal alpineVar="showVoidModal" confirmAction="submitInvoiceAction()" :title="__('Void Invoice')"
|
||||
:message="__('Void this invoice?')" :confirmText="__('Void Invoice')" :cancelText="__('Cancel')"
|
||||
iconType="danger" confirmColor="rose" />
|
||||
<x-confirm-modal alpineVar="showReissueModal" confirmAction="submitInvoiceAction()" :title="__('Re-issue')"
|
||||
:message="__('Re-issue this invoice?')" :confirmText="__('Re-issue')" :cancelText="__('Cancel')"
|
||||
iconType="info" confirmColor="sky" />
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@ -102,7 +110,29 @@ function salesCenter() {
|
||||
showPanel: false,
|
||||
activeTab: '{{ $tab }}',
|
||||
tabLoading: null,
|
||||
|
||||
showVoidModal: false,
|
||||
showReissueModal: false,
|
||||
pendingInvoiceForm: '',
|
||||
|
||||
confirmVoidInvoice(formId) {
|
||||
this.pendingInvoiceForm = formId;
|
||||
this.showVoidModal = true;
|
||||
},
|
||||
|
||||
confirmReissueInvoice(formId) {
|
||||
this.pendingInvoiceForm = formId;
|
||||
this.showReissueModal = true;
|
||||
},
|
||||
|
||||
submitInvoiceAction() {
|
||||
if (this.pendingInvoiceForm) {
|
||||
const form = document.getElementById(this.pendingInvoiceForm);
|
||||
if (form) form.submit();
|
||||
}
|
||||
this.showVoidModal = false;
|
||||
this.showReissueModal = false;
|
||||
},
|
||||
|
||||
init() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlTab = params.get('tab');
|
||||
|
||||
@ -309,27 +309,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if($invStatus === 'issued')
|
||||
<div class="flex items-center gap-3">
|
||||
@if($invPrintable)
|
||||
<form method="POST" action="{{ route('admin.sales.invoices.print', $order->invoice) }}" target="_blank">
|
||||
@csrf
|
||||
<button type="submit" class="flex-1 sm:flex-none px-6 py-3 rounded-xl bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold text-xs border border-slate-200 dark:border-slate-700 hover:bg-indigo-500 hover:text-white hover:border-indigo-500 transition-all duration-300 shadow-sm">
|
||||
{{ __('Print Invoice') }}
|
||||
<div class="flex items-center gap-2">
|
||||
@if($invStatus === 'issued')
|
||||
{{-- 列印(捐贈發票無紙本,僅非捐贈可列印) --}}
|
||||
@if($invPrintable)
|
||||
<form method="POST" action="{{ route('admin.sales.invoices.print', $order->invoice) }}" target="_blank">
|
||||
@csrf
|
||||
<button type="submit" title="{{ __('Print Invoice') }}"
|
||||
class="p-3 rounded-xl bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 hover:bg-indigo-500 hover:text-white hover:border-indigo-500 transition-all duration-300 shadow-sm">
|
||||
<svg class="w-4 h-4 stroke-[2.2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.72 13.829c-.24.03-.48.062-.72.096m.72-.096a42.415 42.415 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.171c.24.03.48.062.72.096m-.72-.096L17.66 18m0 0 .229 2.523a1.125 1.125 0 0 1-1.12 1.227H7.231c-.662 0-1.18-.568-1.12-1.227L6.34 18m11.318 0h1.091A2.25 2.25 0 0 0 21 15.75V9.456c0-1.081-.768-2.015-1.837-2.175a48.055 48.055 0 0 0-1.913-.247M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.456c0-1.081.768-2.015 1.837-2.175a48.041 48.041 0 0 1 1.913-.247m10.5 0a48.536 48.536 0 0 0-10.5 0m10.5 0V3.375c0-.621-.504-1.125-1.125-1.125h-8.25c-.621 0-1.125.504-1.125 1.125v3.659M18 10.5h.008v.008H18V10.5Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-2">{{ __('Donation invoices cannot be printed') }}</span>
|
||||
@endif
|
||||
{{-- 作廢:使用自製確認框 UI(不用瀏覽器原生 confirm) --}}
|
||||
<form id="invoice-void-form-{{ $order->invoice->id }}" method="POST" action="{{ route('admin.sales.invoices.void', $order->invoice) }}" class="hidden">
|
||||
@csrf
|
||||
</form>
|
||||
<button type="button" title="{{ __('Void Invoice') }}"
|
||||
@click="confirmVoidInvoice('invoice-void-form-{{ $order->invoice->id }}')"
|
||||
class="p-3 rounded-xl bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-all duration-300 shadow-sm">
|
||||
<svg class="w-4 h-4 stroke-[2.2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
@elseif(in_array($invStatus, ['pending', 'failed']))
|
||||
{{-- 查詢(對帳) --}}
|
||||
<form method="POST" action="{{ route('admin.sales.invoices.reconcile', $order->invoice) }}">
|
||||
@csrf
|
||||
<button type="submit" title="{{ __('Query') }}"
|
||||
class="p-3 rounded-xl bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 hover:bg-amber-500 hover:text-white hover:border-amber-500 transition-all duration-300 shadow-sm">
|
||||
<svg class="w-4 h-4 stroke-[2.2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
{{-- 補開 --}}
|
||||
<form id="invoice-reissue-form-{{ $order->invoice->id }}" method="POST" action="{{ route('admin.sales.invoices.reissue', $order->invoice) }}" class="hidden">
|
||||
@csrf
|
||||
</form>
|
||||
<button type="button" title="{{ __('Re-issue') }}"
|
||||
@click="confirmReissueInvoice('invoice-reissue-form-{{ $order->invoice->id }}')"
|
||||
class="p-3 rounded-xl bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 border border-slate-200 dark:border-slate-700 hover:bg-cyan-500 hover:text-white hover:border-cyan-500 transition-all duration-300 shadow-sm">
|
||||
<svg class="w-4 h-4 stroke-[2.2]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="text-[11px] font-bold text-slate-400 dark:text-slate-500 px-2">{{ __('Donation invoices cannot be printed') }}</span>
|
||||
@endif
|
||||
<form method="POST" action="{{ route('admin.sales.invoices.void', $order->invoice) }}"
|
||||
onsubmit="return confirm('{{ __('Void this invoice?') }}')">
|
||||
@csrf
|
||||
<button type="submit" class="flex-1 sm:flex-none px-6 py-3 rounded-xl bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-bold text-xs border border-slate-200 dark:border-slate-700 hover:bg-rose-500 hover:text-white hover:border-rose-500 transition-all duration-300 shadow-sm">
|
||||
{{ __('Void Invoice') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -254,16 +254,7 @@
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
@if($st === 'issued')
|
||||
<form method="POST" action="{{ route('admin.sales.invoices.void', $invoice) }}"
|
||||
onsubmit="return confirm('{{ __('Void this invoice?') }}')">
|
||||
@csrf
|
||||
<button type="submit" title="{{ __('Void') }}"
|
||||
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold text-rose-600 dark:text-rose-400 bg-rose-500/5 hover:bg-rose-500/10 border border-rose-500/20 transition-all">
|
||||
{{ __('Void') }}
|
||||
</button>
|
||||
</form>
|
||||
@endif
|
||||
{{-- 作廢按鈕僅在「訂單詳情」提供,列表操作欄不再顯示 --}}
|
||||
<button @click="openDetail({{ $invoice->order_id }})"
|
||||
class="p-2 rounded-xl text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all">
|
||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user