star-cloud/app/Http/Controllers/Admin/SalesController.php
twsystem1004 83e1beb7e2 feat(倉庫/銷售): 機台貨道明細匯出 + 出貨失敗補償取貨碼
1. 倉庫→機台庫存概覽:點進機台後,矩陣/表格視圖旁加「匯出」下拉(CSV/Excel),
   內容為該機台每個貨道明細(貨道號/商品/庫存/容量/庫存率/效期/批號等)。
2. 銷售紀錄新增「取貨碼」欄位:對「付款成功但出貨失敗/部分」的單顯示「+」,
   點擊確認「是否產生取貨碼(七天內有效)」後,依缺量產生取貨碼(綁機台+商品、
   usage_limit=缺量、冪等 batch_no=COMP{id}、不設 order_id),顯示碼+「待使用」。
   消費者於機台輸入碼走現成 B660 核銷出貨,app 端不需改。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 08:00:50 +00:00

1953 lines
82 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Transaction\Order;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\DispenseRecord;
use App\Models\Transaction\PaymentType;
use App\Models\Transaction\OrderItem;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCode;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\Machine\Machine;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use App\Models\System\SystemOperationLog;
class SalesController extends Controller
{
// 銷售中心 (銷售&金流紀錄)
public function index(Request $request)
{
$tab = $request->input('tab', 'orders');
$isAjax = $request->ajax();
// 取得篩選參數
$search = $request->input('search');
$machineId = $request->input('machine_id');
$paymentType = $request->input('payment_type');
$status = $request->input('status');
$deliveryStatus = $request->input('delivery_status');
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
// 判斷是否為隱含篩選 (未選日期時預設抓6個月)
$isImplicitFilter = false;
if (!$startDate || !$endDate) {
$isImplicitFilter = true;
$startDate = now()->subMonths(6)->format('Y-m-d H:i');
$endDate = now()->format('Y-m-d H:i');
}
$data = [
'title' => '銷售&金流紀錄',
'description' => '銷售交易與金流明細查詢',
'tab' => $tab,
'filters' => [
'search' => $search,
'machine_id' => $machineId,
'payment_type' => $paymentType,
'status' => $status,
'delivery_status' => $deliveryStatus,
'start_date' => $request->input('start_date'), // 保留原始輸入以顯示於 UI (如果是隱含的則 UI 顯示空)
'end_date' => $request->input('end_date'),
],
'isImplicitFilter' => $isImplicitFilter,
'machines' => Machine::select('id', 'name', 'serial_no')->get(),
'paymentTypes' => Order::getPaymentTypeLabels(),
];
// 1. 建立基本查詢 (套用共用過濾器:機台、日期)
$ordersQuery = Order::with([
'machine:id,name,serial_no',
'invoice:id,order_id,invoice_no,status',
'items',
'staffCardLog:id,order_id,staff_card_id',
'staffCardLog.staffCard:id,employee_id,name,card_uid'
]);
$invoicesQuery = Invoice::with(['machine:id,name,serial_no', 'order:id,order_no,flow_id,payment_type']);
$dispenseQuery = DispenseRecord::with(['order:id,order_no,flow_id', 'machine:id,name,serial_no', 'product:id,name']);
// 共用過濾器:日期
$start = Carbon::parse($startDate);
$end = Carbon::parse($endDate)->endOfMinute();
$ordersQuery->whereBetween('created_at', [$start, $end]);
// 以 created_at 篩(而非 invoice_datepending/failed 發票尚無 invoice_date 也能顯示,才看得到缺漏
$invoicesQuery->whereBetween('created_at', [$start, $end]);
$dispenseQuery->whereBetween('machine_time', [$start, $end]);
// 機台權限隔離:非系統管理員只能看到「自己可存取機台」的交易。沿用 Machine 的 machine_access 全域 scope
// 取得可存取機台集合(與上方機台下拉清單一致),修正訂單/發票/出貨清單先前未依機台授權過濾、
// 導致無該機台權限的帳號仍可見其交易紀錄的資料隔離漏洞。
if (!auth()->user()->isSystemAdmin()) {
$accessibleMachineIds = Machine::pluck('id');
$ordersQuery->whereIn('machine_id', $accessibleMachineIds);
$invoicesQuery->whereIn('machine_id', $accessibleMachineIds);
$dispenseQuery->whereIn('machine_id', $accessibleMachineIds);
}
// 共用過濾器:機台
if ($machineId) {
$ordersQuery->where('machine_id', $machineId);
$invoicesQuery->where('machine_id', $machineId);
$dispenseQuery->where('machine_id', $machineId);
}
// 2. 應用獨立過濾器:搜尋 (僅對當前 Tab 應用)
if ($search) {
if ($tab === 'orders') {
$ordersQuery->where(function($q) use ($search) {
$q->where('order_no', 'like', "%{$search}%")
->orWhere('flow_id', 'like', "%{$search}%")
->orWhere('invoice_info', 'like', "%{$search}%")
->orWhere('member_barcode', 'like', "%{$search}%")
->orWhereHas('staffCardLog.staffCard', function($sq) use ($search) {
$sq->where('name', 'like', "%{$search}%")
->orWhere('employee_id', 'like', "%{$search}%")
->orWhere('card_uid', 'like', "%{$search}%");
});
});
} elseif ($tab === 'invoices') {
$invoicesQuery->where(function($q) use ($search) {
$q->where('invoice_no', 'like', "%{$search}%")
->orWhere('flow_id', 'like', "%{$search}%");
});
} elseif ($tab === 'dispense') {
$dispenseQuery->where(function($q) use ($search) {
$q->where('slot_no', 'like', "%{$search}%")
->orWhereHas('product', function($pq) use ($search) {
$pq->where('name', 'like', "%{$search}%");
})
->orWhereHas('order', function($oq) use ($search) {
$oq->where('order_no', 'like', "%{$search}%");
});
});
}
}
// 訂單專用過濾器
if ($tab === 'orders') {
if ($paymentType) $ordersQuery->where('payment_type', $paymentType);
if ($status) $ordersQuery->where('status', $status);
// 出貨狀態:唯有付款成功(completed/paid)的單才有出貨結果,需與列表顯示邏輯 (hasDeliveryOutcome) 一致
if ($deliveryStatus !== null && $deliveryStatus !== '') {
$ordersQuery->whereIn('status', [Order::STATUS_COMPLETED, 'paid'])
->where('delivery_status', $deliveryStatus);
}
}
// 發票專用過濾器狀態pending/issued/failed/void
if ($tab === 'invoices') {
$invoiceStatus = $request->input('invoice_status');
if ($invoiceStatus) $invoicesQuery->where('status', $invoiceStatus);
}
// 匯出功能攔截
if ($request->filled('export')) {
$exportType = $request->input('export');
return $this->handleExport($tab, $exportType, $ordersQuery, $invoicesQuery, $dispenseQuery);
}
// 3. 執行分頁 (使用獨立的 pageName)
$perPage = $request->input('per_page', 10);
$data['orders'] = $ordersQuery->latest()->paginate($perPage, ['*'], 'orders_page')->withQueryString();
$data['invoices'] = $invoicesQuery->latest()->paginate($perPage, ['*'], 'invoices_page')->withQueryString();
$data['dispenseLogs'] = $dispenseQuery->latest()->paginate($perPage, ['*'], 'dispense_page')->withQueryString();
// 出貨失敗補償取貨碼:僅掛載既有碼供顯示;產碼由 + 按鈕手動觸發 generateOrderPickupCode()
if ($tab === 'orders') {
$this->attachOrderPickupCodes($data['orders']->getCollection());
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.index', $data);
}
/**
* 掛載訂單的補償取貨碼(batch_no='COMP{id}')供銷售頁顯示。只讀不產碼。
*/
private function attachOrderPickupCodes($orders): void
{
if ($orders->isEmpty()) {
return;
}
$batchNos = $orders->map(fn ($o) => 'COMP' . $o->id)->all();
$codesByBatch = PickupCode::whereIn('batch_no', $batchNos)->get()->groupBy('batch_no');
foreach ($orders as $order) {
$order->setRelation('compensationCodes', $codesByBatch->get('COMP' . $order->id, collect()));
}
}
/**
* 手動產生「出貨失敗補償取貨碼」:由銷售頁 + 按鈕、前端確認後 POST 觸發。
* 僅限「付款成功(completed/paid)但出貨失敗/部分(delivery 0/2)」的訂單;依每商品缺量各產一碼
* (綁機台+商品、7 天有效、usage_limit=缺量);冪等(batch_no='COMP{id}')。
* 不設 order_id——取貨碼核銷時 order_id 會被回填為「新出貨單」。
*/
public function generateOrderPickupCode(Request $request, Order $order)
{
if (!Auth::user()->isSystemAdmin() && $order->machine_id && !Machine::whereKey($order->machine_id)->exists()) {
abort(403);
}
$isPaid = in_array($order->status, [Order::STATUS_COMPLETED, 'paid'], true);
$failedDelivery = in_array((int) $order->delivery_status, [Order::DELIVERY_STATUS_FAILED, Order::DELIVERY_STATUS_PARTIAL], true);
if (!$isPaid || !$failedDelivery || !$order->machine_id) {
return response()->json(['success' => false, 'message' => __('This order is not eligible for a pickup code.')], 422);
}
$batchNo = 'COMP' . $order->id;
$existing = PickupCode::where('batch_no', $batchNo)->get();
if ($existing->isNotEmpty()) {
return response()->json(['success' => true, 'message' => __('Pickup code already generated.'), 'codes' => $existing->pluck('code')]);
}
$machine = $order->machine ?: Machine::find($order->machine_id);
if (!$machine) {
return response()->json(['success' => false, 'message' => __('Machine not found.')], 422);
}
$ordered = $order->items->groupBy('product_id')->map(fn ($g) => (int) $g->sum('quantity'));
$dispensedOk = DispenseRecord::where('order_id', $order->id)
->where('dispense_status', 1)
->get()->groupBy('product_id')->map(fn ($g) => (int) $g->sum('amount'));
$created = [];
foreach ($ordered as $productId => $orderedQty) {
if (!$productId) {
continue;
}
$shortfall = $orderedQty - (int) ($dispensedOk[$productId] ?? 0);
if ($shortfall <= 0) {
continue;
}
$code = PickupCode::create([
'company_id' => $order->company_id ?? $machine->company_id,
'machine_id' => $order->machine_id,
'product_id' => $productId,
'code' => PickupCode::generateUniqueCode($order->machine_id),
'slug' => Str::random(16),
'batch_no' => $batchNo,
'status' => 'active',
'usage_limit' => $shortfall,
'usage_count' => 0,
'expires_at' => now()->addDays(7),
'created_by' => Auth::id(),
]);
$created[] = $code->code;
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'create',
'target_id' => $code->id,
'target_type' => PickupCode::class,
'new_values' => $code->toArray() + ['source' => 'failed_delivery_compensation', 'source_order_id' => $order->id],
]);
}
if (empty($created)) {
return response()->json(['success' => false, 'message' => __('No undelivered items to compensate.')], 422);
}
return response()->json(['success' => true, 'message' => __('Pickup code generated.'), 'codes' => $created]);
}
/**
* 取得單筆交易詳情 (用於 Slide-over)
*/
public function show(Order $order)
{
$order->load([
'machine',
'invoice',
'items',
'dispenseRecords.product',
'staffCardLog:id,order_id,staff_card_id',
'staffCardLog.staffCard:id,employee_id,name,card_uid'
]);
return response()->json([
'success' => true,
'html' => view('admin.sales.partials.order-detail-panel', [
'order' => $order,
'paymentTypes' => Order::getPaymentTypeLabels()
])->render()
]);
}
/**
* 手動補單:取得指定機台的貨道(含商品)與電子發票開關,供補單彈窗動態載入。
* 僅限系統管理員(整個補單功能限系統管理員)。
*/
public function manualMachineSlots(Request $request)
{
if (!Auth::user()->isSystemAdmin()) {
abort(403);
}
$machine = Machine::with(['slots.product:id,name,price'])
->findOrFail($request->input('machine_id'));
$slots = $machine->slots
->sortBy(fn ($s) => str_pad(ltrim($s->slot_no, '0') ?: '0', 6, '0', STR_PAD_LEFT))
->values()
->map(fn ($slot) => [
'slot_no' => $slot->slot_no,
'product_id' => $slot->product_id,
'product_name' => $slot->product?->localized_name ?? __('Unknown Product'),
'price' => $slot->product?->price !== null ? (float) $slot->product->price : 0,
'stock' => (int) $slot->stock,
]);
return response()->json([
'success' => true,
'tax_invoice_enabled' => (bool) $machine->tax_invoice_enabled,
'slots' => $slots,
]);
}
/**
* 手動補單:機台斷線/漏報時人工補登銷售紀錄。
*
* 僅限系統管理員。複用 TransactionService::createManualOrder與機台上報共用扣庫存開發票邏輯
* 扣庫存與開立電子發票皆為選配;開發票受機台「電子發票開關」閘門限制(後端再驗一次)。
*/
public function storeManualOrder(Request $request, \App\Services\Transaction\TransactionService $service)
{
// 權限閘門:補單僅限平台系統管理員(前端僅隱藏入口,真正把關在後端,防偽造 POST
if (!Auth::user()->isSystemAdmin()) {
abort(403, __('Only system administrators can create manual orders'));
}
$validated = $request->validate([
'machine_id' => ['required', 'exists:machines,id'],
'payment_type' => ['required', 'integer', 'in:' . implode(',', array_keys(Order::getPaymentTypeLabels()))],
'occurred_at' => ['nullable', 'date'],
'deduct_stock' => ['nullable', 'boolean'],
'remark' => ['nullable', 'string', 'max:1000'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.slot_no' => ['nullable', 'string', 'max:20'],
'items.*.price' => ['required', 'numeric', 'min:0'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:999'],
'issue_invoice' => ['nullable', 'boolean'],
'invoice_type' => ['nullable', 'required_if:issue_invoice,1', 'in:b2c,taxid,mobile,citizen,donation'],
'invoice_value' => ['nullable', 'string', 'max:64'],
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 組裝發票輸入(僅在勾選開立、且機台開關開啟時)。後端閘門:機台關閉電子發票一律不開。
$invoice = null;
if (!empty($validated['issue_invoice'])) {
if (!$machine->tax_invoice_enabled) {
return $this->manualOrderResponse($request, false, __('This machine has e-invoice disabled; cannot issue'));
}
$invoice = $this->buildManualInvoiceInput($validated['invoice_type'] ?? 'b2c', trim((string) ($validated['invoice_value'] ?? '')));
if (isset($invoice['error'])) {
return $this->manualOrderResponse($request, false, $invoice['error']);
}
}
try {
$order = $service->createManualOrder([
'serial_no' => $machine->serial_no,
'created_by' => Auth::id(),
'payment_type' => (int) $validated['payment_type'],
'occurred_at' => $validated['occurred_at'] ?? null,
'deduct_stock' => !empty($validated['deduct_stock']),
'remark' => $validated['remark'] ?? null,
'items' => array_map(fn ($i) => [
'product_id' => (int) $i['product_id'],
'slot_no' => $i['slot_no'] ?? null,
'price' => (float) $i['price'],
'quantity' => (int) $i['quantity'],
], $validated['items']),
'invoice' => $invoice,
]);
} catch (\Throwable $e) {
\Log::error('Manual order creation failed: ' . $e->getMessage(), [
'machine_id' => $machine->id,
'user_id' => Auth::id(),
]);
return $this->manualOrderResponse($request, false, __('Failed to create manual order'));
}
// 審計日誌:留痕「誰/何時/對哪台機台補了什麼、是否開發票/扣庫存」。
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'manual_order',
'action' => 'create',
'target_id' => $order->id,
'target_type' => Order::class,
'new_values' => [
'order_no' => $order->order_no,
'flow_id' => $order->flow_id,
'machine_id' => $machine->id,
'total_amount' => $order->total_amount,
'payment_type' => $order->payment_type,
'deduct_stock' => !empty($validated['deduct_stock']),
'issue_invoice' => $invoice !== null,
'occurred_at' => optional($order->machine_time)->toDateTimeString(),
],
]);
return $this->manualOrderResponse($request, true, __('Manual order created: :no', ['no' => $order->order_no]));
}
/**
* 將補單表單的發票類型/輸入值轉成 recordInvoice 可用的欄位。
* reissue() 依 business_tax_id / carrier_id / love_code 自動判別綠界載具類型與 Print 旗標。
*/
private function buildManualInvoiceInput(string $type, string $value): array
{
switch ($type) {
case 'b2c': // 一般 B2C綠界會員載具Print=0不可列印
return [];
case 'taxid': // 統一編號Print=1可列印
if (!preg_match('/^\d{8}$/', $value)) {
return ['error' => __('Tax ID must be 8 digits')];
}
return ['business_tax_id' => $value];
case 'mobile': // 手機條碼載具8 碼,/ 開頭)
if (!preg_match('#^/[0-9A-Z.\-+]{7}$#', $value)) {
return ['error' => __('Invalid mobile barcode carrier')];
}
return ['carrier_id' => $value];
case 'citizen': // 自然人憑證載具16 碼2 英文 + 14 數字)
if (!preg_match('/^[A-Z]{2}\d{14}$/', $value)) {
return ['error' => __('Invalid citizen digital certificate carrier')];
}
return ['carrier_id' => $value];
case 'donation': // 捐贈(愛心碼 3~7 碼數字)
if (!preg_match('/^\d{3,7}$/', $value)) {
return ['error' => __('Donation love code must be 3 to 7 digits')];
}
return ['love_code' => $value];
default:
return ['error' => __('Invalid invoice type')];
}
}
/** 補單回應AJAX 回 JSON、一般表單回 back()。 */
private function manualOrderResponse(Request $request, bool $success, string $message)
{
if ($request->expectsJson()) {
return response()->json(['success' => $success, 'message' => $message], $success ? 200 : 422);
}
return back()->with($success ? 'success' : 'error', $message);
}
// 取貨碼設定
public function pickupCodes(Request $request)
{
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '取貨碼設定',
'description' => '產生與管理商品取貨驗證碼',
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 取貨碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的取貨碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PickupCode::with(['machine.slots.product', 'product', 'creator', 'order'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$query->where('status', trim($request->status));
}
$per_page = $request->input('per_page', 10);
$data['pickupCodes'] = $query->paginate($per_page, ['*'], 'list_page')->withQueryString();
// 供新增彈窗使用的機台清單
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料machine_id 為 null 的非機台紀錄仍保留)
$logQuery = PickupCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'pickupCode', 'order'])
->where(function ($q) {
$q->whereNull('machine_id')
->orWhereHas('machine');
});
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('pickupCode', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%");
});
});
}
// 新增:類型篩選
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 新增:日期區間篩選 (隱含 6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'create' => __('create'),
'update' => __('update'),
'cancel' => __('cancel'),
'used' => __('used'),
'consume' => __('consume'),
'consume_failed' => __('consume_failed'),
'verify_success' => __('verify_success'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.pickup-codes.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.pickup-codes.index', $data);
}
/**
* 產生取貨碼
*/
public function storePickupCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆。
// 後端強制把關,避免前端被繞過送出大量產碼請求。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
// 取貨碼改綁「商品」:新流程傳 product_id舊的綁「貨道」流程仍可傳 slot_no向後相容
'product_id' => 'required_without:slot_no|nullable|exists:products,id',
'slot_no' => 'required_without:product_id|nullable|string',
'usage_limit' => 'nullable|integer|min:1|max:20',
'expires_hours' => 'nullable|integer|min:1|max:720',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
// 綁商品時驗證該商品屬於此機台所屬公司(不要求機台當下有貨道;機台未上架仍可產碼,
// 由前端提示、刷碼時若無貨道再由 B660 擋下)。
if (!empty($validated['product_id'])) {
$belongsToCompany = \App\Models\Product\Product::where('id', $validated['product_id'])
->where('company_id', $machine->company_id)
->exists();
if (!$belongsToCompany) {
return back()->withErrors(['product_id' => __('The selected product does not belong to this machine\'s company.')]);
}
}
// 處理過期時間:優先使用直接傳入的日期,否則使用時數計算
$expiresAt = $request->filled('expires_at')
? Carbon::parse($validated['expires_at'])
: now()->addHours((int) ($request->expires_hours ?? 24));
$usageLimit = $validated['usage_limit'] ?? 1;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
// 沿用單筆的綁定方式(優先 product_id相容舊的 slot_no
if ($quantity > 1) {
$batchNo = 'PUB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PickupCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'expires_at' => $expiresAt,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PickupCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PickupCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
],
]);
return back()
->with('success', __(':count pickup codes generated', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pickup',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pickup-codes.batch-download', $batchNo),
]);
}
$pickupCode = PickupCode::create([
'machine_id' => $validated['machine_id'],
'product_id' => $validated['product_id'] ?? null,
'slot_no' => $validated['slot_no'] ?? null,
'code' => $request->custom_code ?? PickupCode::generateUniqueCode($validated['machine_id']),
'usage_limit' => $validated['usage_limit'] ?? 1,
'usage_count' => 0,
'expires_at' => $expiresAt,
'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'create',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'new_values' => $pickupCode->toArray(),
]);
return back()->with('success', __('Pickup code generated: :code', ['code' => $pickupCode->code]));
}
/**
* 產碼用 AJAX回傳機台所屬「公司」的商品清單以及機台目前已上架的 product_id。
* 商品下拉以公司商品為準(非僅機台貨道);前端用 machine_product_ids 標示「此機台未上架」提示。
*/
public function pickupCompanyProducts(Machine $machine)
{
$products = \App\Models\Product\Product::where('company_id', $machine->company_id)
->select('id', 'name')
->orderBy('name')
->get();
$machineProductIds = $machine->slots()
->whereNotNull('product_id')
->pluck('product_id')
->unique()
->values();
return response()->json([
'success' => true,
'products' => $products,
'machine_product_ids' => $machineProductIds,
]);
}
/**
* 更新取貨碼 (僅限修改時間)
*/
public function updatePickupCode(Request $request, PickupCode $pickupCode)
{
$validated = $request->validate([
'expires_at' => 'nullable|date|after:now',
'usage_limit' => 'nullable|integer|min:1|max:20',
]);
$oldValues = $pickupCode->toArray();
$pickupCode->update(array_filter([
'expires_at' => $validated['expires_at'] ?? null,
'usage_limit' => $validated['usage_limit'] ?? null,
]));
SystemOperationLog::create([
'company_id' => $pickupCode->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'update',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'old_values' => $oldValues,
'new_values' => $pickupCode->toArray(),
]);
return back()->with('success', __('Pickup code updated.'));
}
/**
* 刪除/取消取貨碼
*/
public function destroyPickupCode(PickupCode $pickupCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該取貨碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($pickupCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $pickupCode->toArray();
$pickupCode->update(['status' => 'cancelled']);
SystemOperationLog::create([
'company_id' => $pickupCode->company_id,
'user_id' => Auth::id(),
'module' => 'pickup_code',
'action' => 'cancel',
'target_id' => $pickupCode->id,
'target_type' => PickupCode::class,
'old_values' => $oldValues,
'new_values' => $pickupCode->toArray(),
]);
return back()->with('success', __('Pickup code cancelled.'));
}
// 購買單
public function orders()
{
return view('admin.placeholder', [
'title' => '購買單',
'description' => '購買訂單管理',
]);
}
// 促銷時段設定
public function promotions()
{
return view('admin.placeholder', [
'title' => '促銷時段設定',
'description' => '促銷活動時間設定',
]);
}
// 通行碼設定
public function passCodes(Request $request)
{
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '通行碼設定',
'description' => '特殊通行權限碼管理 (測試/補貨用)',
'tab' => $tab,
];
// 批次產生數量上限/快捷選項依角色分級:平台管理員 100010/50/100/1000、客戶端 2010/20
$isSystemAdmin = Auth::user()->isSystemAdmin();
$data['maxBatchQuantity'] = $isSystemAdmin ? 1000 : 20;
$data['batchQuickOptions'] = $isSystemAdmin ? [10, 50, 100, 1000] : [10, 20];
// 1. 通行碼列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的通行碼(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = PassCode::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhere('name', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$status = trim($request->status);
if ($status === 'active') {
$query->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
} elseif ($status === 'expired') {
$query->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
} else {
$query->where('status', $status);
}
}
$data['passCodes'] = $query->paginate($request->input('per_page', 10), ['*'], 'list_page')->withQueryString();
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料pass_code_logs.machine_id 為 NOT NULL
$logQuery = PassCodeLog::with(['user:id,name', 'machine:id,name,serial_no', 'passCode'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('passCode', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
});
}
// 新增:類型篩選
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 新增:日期區間篩選 (隱含 6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'create' => __('create'),
'update' => __('update'),
'cancel' => __('cancel'),
'used' => __('used'),
'consume' => __('consume'),
'verify_success' => __('verify_success'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.pass-codes.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.pass-codes.index', $data);
}
/**
* 產生通行碼
*/
public function storePassCode(Request $request)
{
// 批次數量上限依角色分級:平台管理員最多 1000 筆;客戶端(租戶)最多 20 筆(後端強制把關)。
$maxQuantity = Auth::user()->isSystemAdmin() ? 1000 : 20;
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:50',
'expires_days' => 'nullable|integer|min:0',
'custom_code' => 'nullable|string|min:4|max:12',
'quantity' => 'nullable|integer|min:1|max:' . $maxQuantity,
]);
$machine = Machine::findOrFail($validated['machine_id']);
$quantity = (int) ($validated['quantity'] ?? 1);
$expiresAt = $request->expires_days ? now()->addDays((int) $request->expires_days) : null;
// 勾「只能使用一次」→ 使用上限 1未勾 → null(無限次,維持原行為)。
$usageLimit = $request->boolean('single_use') ? 1 : null;
// 批次產生(>1 筆):忽略自訂碼,系統自動產生隨機唯一碼,並以 batch_no 標記同一批。
if ($quantity > 1) {
$batchNo = 'PSB' . now()->format('YmdHis') . strtoupper(Str::random(4));
$codes = $this->generateUniqueCodeBatch(PassCode::class, $machine->id, $quantity);
$nowTs = now();
$rows = [];
foreach ($codes as $i => $code) {
$rows[] = [
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'name' => $validated['name'] . ' #' . ($i + 1),
'code' => $code,
'slug' => Str::random(16),
'batch_no' => $batchNo,
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'created_by' => Auth::id(),
'created_at' => $nowTs,
'updated_at' => $nowTs,
];
}
DB::transaction(function () use ($rows) {
foreach (array_chunk($rows, 500) as $chunk) {
PassCode::insert($chunk);
}
});
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'batch_create',
'target_id' => null,
'target_type' => PassCode::class,
'new_values' => [
'batch_no' => $batchNo,
'count' => count($rows),
'machine_id' => $machine->id,
'name' => $validated['name'],
],
]);
return back()
->with('success', __(':count pass codes created', ['count' => count($rows)]))
->with('code_batch', [
'type' => 'pass',
'batch_no' => $batchNo,
'count' => count($rows),
'download_url' => route('admin.sales.pass-codes.batch-download', $batchNo),
]);
}
$passCode = PassCode::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'] ?? 'Manual Generate',
'code' => $request->custom_code ?: PassCode::generateUniqueCode($validated['machine_id']),
'expires_at' => $expiresAt,
'status' => 'active',
'usage_limit' => $usageLimit,
'usage_count' => 0,
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'create',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code created: :code', ['code' => $passCode->code]));
}
/**
* 批次產生指定數量的唯一 8 位數字碼(避開該機台目前有效的碼,並去除同批重複)
*
* @param class-string<\Illuminate\Database\Eloquent\Model> $modelClass PickupCode::class 或 PassCode::class
* @return string[]
*/
private function generateUniqueCodeBatch(string $modelClass, int $machineId, int $quantity): array
{
// 載入該機台目前仍有效的碼,避免與既有碼衝突
$existing = array_flip(
$modelClass::where('machine_id', $machineId)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->pluck('code')
->all()
);
$codes = [];
$seen = [];
while (count($codes) < $quantity) {
$code = str_pad((string) random_int(0, 99999999), 8, '0', STR_PAD_LEFT);
if (isset($existing[$code]) || isset($seen[$code])) {
continue;
}
$seen[$code] = true;
$codes[] = $code;
}
return $codes;
}
/**
* 下載某一批取貨碼清單 (CSV)
*/
public function downloadPickupCodeBatch(string $batchNo)
{
$codes = PickupCode::with(['machine:id,name,serial_no', 'product:id,name'])
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pickup Code'),
__('Target Machine'),
__('Select Product'),
__('Select Slot'),
__('Usage Limit'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF"); // UTF-8 BOMExcel 開啟才不會亂碼
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->product)->name ?? '',
$c->slot_no ?? '',
$c->usage_limit,
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pickup-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 下載某一批通行碼清單 (CSV)
*/
public function downloadPassCodeBatch(string $batchNo)
{
$codes = PassCode::with('machine:id,name,serial_no')
->where('batch_no', $batchNo)
->orderBy('id')
->get();
abort_if($codes->isEmpty(), 404);
$header = [
__('Pass Code'),
__('Description / Name'),
__('Target Machine'),
__('Expires At'),
__('Link'),
__('Created At'),
];
return response()->streamDownload(function () use ($codes, $header) {
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $header);
foreach ($codes as $c) {
fputcsv($out, [
$c->code,
$c->name,
optional($c->machine)->name . ' (' . optional($c->machine)->serial_no . ')',
optional($c->expires_at)->format('Y-m-d H:i') ?? __('Permanent'),
$c->ticket_url,
optional($c->created_at)->format('Y-m-d H:i'),
]);
}
fclose($out);
}, 'pass-codes-' . $batchNo . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
/**
* 更新通行碼
*/
public function updatePassCode(Request $request, PassCode $passCode)
{
$validated = $request->validate([
'name' => 'nullable|string|max:50',
'expires_at' => 'nullable|date',
'status' => 'nullable|in:active,disabled',
]);
// 確保 expires_at 為空字串時轉為 null
if (isset($validated['expires_at']) && empty($validated['expires_at'])) {
$validated['expires_at'] = null;
}
$oldValues = $passCode->toArray();
$passCode->update(array_filter($validated, function ($value, $key) use ($request) {
return $request->has($key);
}, ARRAY_FILTER_USE_BOTH));
SystemOperationLog::create([
'company_id' => $passCode->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'update',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'old_values' => $oldValues,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code updated.'));
}
/**
* 刪除通行碼 (改為停用)
*/
public function destroyPassCode(PassCode $passCode)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該通行碼所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($passCode->machine_id)->exists()) {
abort(403);
}
$oldValues = $passCode->toArray();
$passCode->update(['status' => 'disabled']);
SystemOperationLog::create([
'company_id' => $passCode->company_id,
'user_id' => Auth::id(),
'module' => 'pass_code',
'action' => 'cancel',
'target_id' => $passCode->id,
'target_type' => PassCode::class,
'old_values' => $oldValues,
'new_values' => $passCode->toArray(),
]);
return back()->with('success', __('Pass code cancelled.'));
}
// 來店禮設定
public function storeGifts(Request $request)
{
$tab = $request->input('tab', 'list');
$isAjax = $request->ajax();
$data = [
'title' => '來店禮設定',
'description' => '來店禮優惠碼管理 (單次與無限制來店優惠折抵)',
'tab' => $tab,
];
// 1. 來店禮列表 (list)
if (!$isAjax || $tab === 'list') {
// 僅顯示目前帳號可存取機台的來店禮(機台授權以 machine_user 為準whereHas 會套用 Machine 的 machine_access 全域 scope
$query = WelcomeGift::with(['machine', 'creator'])
->whereHas('machine')
->latest();
if ($request->search) {
$query->where(function ($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
->orWhere('name', 'like', "%{$request->search}%")
->orWhereHas('machine', function ($mq) use ($request) {
$mq->where('name', 'like', "%{$request->search}%")
->orWhere('serial_no', 'like', "%{$request->search}%");
});
});
}
if ($request->status && trim($request->status) !== '') {
$status = trim($request->status);
if ($status === 'active') {
$query->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
});
} elseif ($status === 'expired') {
$query->where('status', 'active')
->whereNotNull('expires_at')
->where('expires_at', '<=', now());
} else {
$query->where('status', $status);
}
}
$data['welcomeGifts'] = $query->paginate($request->input('per_page', 10), ['*'], 'list_page')->withQueryString();
$data['machines'] = Machine::all();
}
// 2. 操作紀錄 (logs)
if (!$isAjax || $tab === 'logs') {
// 同上操作紀錄只顯示可存取機台的資料welcome_gift_logs.machine_id 為 NOT NULL
$logQuery = WelcomeGiftLog::with(['machine:id,name,serial_no', 'welcomeGift', 'order:id,order_no'])
->whereHas('machine');
if ($request->filled('search_log')) {
$search = $request->input('search_log');
$logQuery->where(function ($q) use ($search) {
$q->where('remark', 'like', "%{$search}%")
->orWhereHas('welcomeGift', function($pq) use ($search) {
$pq->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
});
}
if ($request->filled('action')) {
$logQuery->where('action', $request->action);
}
// 日期區間篩選 (6 個月限制)
$startDate = $request->input('start_date');
$endDate = $request->input('end_date');
if (!$startDate && !$endDate) {
$logQuery->where('created_at', '>=', now()->subMonths(6));
} else {
try {
if ($startDate) {
$logQuery->where('created_at', '>=', \Carbon\Carbon::parse($startDate)->startOfMinute());
}
if ($endDate) {
$logQuery->where('created_at', '<=', \Carbon\Carbon::parse($endDate)->endOfMinute());
}
} catch (\Exception $e) { }
}
$data['logs'] = $logQuery->latest()
->paginate($request->input('per_page', 10), ['*'], 'log_page')
->withQueryString();
// 定義可用動作
$data['actions'] = [
'verify_success' => __('verify_success'),
'consume' => __('consume'),
'cancel' => __('cancel'),
];
}
if ($isAjax) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.sales.welcome-gifts.partials.tab-' . $tab, $data)->render()
]);
}
return view('admin.sales.welcome-gifts.index', $data);
}
/**
* 新增來店禮
*/
public function storeWelcomeGift(Request $request)
{
$validated = $request->validate([
'machine_id' => 'required|exists:machines,id',
'name' => 'required|string|max:100',
'discount_type' => 'required|in:percentage,amount',
'discount_val_input' => 'required|numeric|min:0.1', // 接受折數 (如 8.5) 或 金額 (如 50)
'usage_type' => 'required|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
'expires_at' => 'nullable|date|after:now',
'custom_code' => 'nullable|string|min:4|max:10',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 關鍵自動換算邏輯
$discountValue = 0;
if ($validated['discount_type'] === 'percentage') {
// 使用者輸入折數 (如 8 代表打八折8.5 代表打八五折)
$fold = (float) $validated['discount_val_input'];
// 換算為趴數,公式為 (10 - 折數) * 10 ➜ 例如 8.5折 ➜ (10 - 8.5) * 10 = 15% off
$discountValue = (int) round((10 - $fold) * 10);
} else {
// 金額折抵 (如 50 代表折 50 元)
$discountValue = (int) $validated['discount_val_input'];
}
$welcomeGift = WelcomeGift::create([
'machine_id' => $validated['machine_id'],
'name' => $validated['name'],
'code' => $validated['custom_code'] ?? WelcomeGift::generateUniqueCode($validated['machine_id']),
'discount_type' => $validated['discount_type'],
'discount_value' => $discountValue,
'usage_type' => $validated['usage_type'],
'usage_limit' => $validated['usage_type'] === 'once' ? ($validated['usage_limit'] ?? 1) : null,
'expires_at' => $validated['expires_at'],
'status' => 'active',
'company_id' => $machine->company_id,
'created_by' => Auth::id(),
]);
SystemOperationLog::create([
'company_id' => $machine->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'create',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift created successfully.'));
}
/**
* 更新來店禮
*/
public function updateWelcomeGift(Request $request, WelcomeGift $welcomeGift)
{
$validated = $request->validate([
'name' => 'nullable|string|max:100',
'discount_type' => 'nullable|in:percentage,amount',
'discount_val_input' => 'nullable|numeric|min:0.1',
'expires_at' => 'nullable|date',
'status' => 'nullable|in:active,disabled',
'usage_type' => 'nullable|in:once,unlimited',
'usage_limit' => 'nullable|integer|min:1',
]);
if (isset($validated['expires_at']) && empty($validated['expires_at'])) {
$validated['expires_at'] = null;
}
$oldValues = $welcomeGift->toArray();
// 整理要更新的資料
$updateData = [];
if ($request->has('name')) {
$updateData['name'] = $validated['name'];
}
if ($request->has('status')) {
$updateData['status'] = $validated['status'];
}
if ($request->has('expires_at')) {
$updateData['expires_at'] = $validated['expires_at'];
}
if ($request->has('usage_type')) {
$updateData['usage_type'] = $validated['usage_type'];
if ($validated['usage_type'] === 'unlimited') {
$updateData['usage_limit'] = null;
} elseif ($request->has('usage_limit')) {
$updateData['usage_limit'] = $validated['usage_limit'];
}
} elseif ($request->has('usage_limit') && $welcomeGift->usage_type === 'once') {
$updateData['usage_limit'] = $validated['usage_limit'];
}
// 折扣折數/金額更新
if ($request->has('discount_type') || $request->has('discount_val_input')) {
$discountType = $validated['discount_type'] ?? $welcomeGift->discount_type;
$discountValInput = $validated['discount_val_input'] ?? ($discountType === 'percentage' ? $welcomeGift->input_fold : $welcomeGift->discount_value);
$updateData['discount_type'] = $discountType;
if ($discountType === 'percentage') {
$fold = (float) $discountValInput;
$updateData['discount_value'] = (int) round((10 - $fold) * 10);
} else {
$updateData['discount_value'] = (int) $discountValInput;
}
}
$welcomeGift->update($updateData);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'update',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift updated.'));
}
/**
* 刪除來店禮 (改為停用)
*/
public function destroyWelcomeGift(WelcomeGift $welcomeGift)
{
// 機台授權以 machine_user 為準:非系統管理員若無法存取該來店禮所屬機台則拒絕
if (!Auth::user()->isSystemAdmin() && !Machine::whereKey($welcomeGift->machine_id)->exists()) {
abort(403);
}
$oldValues = $welcomeGift->toArray();
$welcomeGift->update(['status' => 'disabled']);
SystemOperationLog::create([
'company_id' => $welcomeGift->company_id,
'user_id' => Auth::id(),
'module' => 'welcome_gift',
'action' => 'cancel',
'target_id' => $welcomeGift->id,
'target_type' => WelcomeGift::class,
'old_values' => $oldValues,
'new_values' => $welcomeGift->toArray(),
]);
return back()->with('success', __('Welcome Gift cancelled.'));
}
/**
* 處理銷售中心資料匯出 (CSV 串流下載)
*/
private function handleExport($tab, $type, $ordersQuery, $invoicesQuery, $dispenseQuery)
{
$isExcel = ($type === 'excel');
$ext = $isExcel ? 'xls' : 'csv';
$contentType = $isExcel ? 'application/vnd.ms-excel; charset=utf-8' : 'text/csv; charset=utf-8';
$filename = '';
$headers = [];
$callback = null;
if ($tab === 'orders') {
$filename = '交易紀錄_' . now()->format('YmdHis') . '.' . $ext;
$headers = [
__('Order Number'),
__('Transaction Time'),
__('Machine Name'),
__('Machine Serial No'),
__('Product Items'),
__('Quantity'),
__('Original Amount'),
__('Discount Amount'),
__('Payment Amount'),
__('Payment Type'),
__('Payment Status'),
__('Dispense Status'),
__('Invoice Number'),
__('Staff Name'),
__('Staff ID'),
__('Card UID'),
];
// 預先載入關聯以防 N+1
$ordersQuery->with([
'machine:id,name,serial_no',
'invoice:id,order_id,invoice_no',
'items:id,order_id,product_name,quantity',
'staffCardLog:id,order_id,staff_card_id',
'staffCardLog.staffCard:id,employee_id,name,card_uid'
]);
$callback = function () use ($ordersQuery, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF"); // 注入 UTF-8 BOM
fputcsv($file, $headers);
}
// 分批 (Chunk) 查詢,每次 500 筆,確保常數記憶體用量
$ordersQuery->latest()->chunk(500, function ($orders) use ($file, $isExcel) {
foreach ($orders as $order) {
$productSummary = $order->items->map(function ($item) {
return $item->product_name . ' x' . number_format($item->quantity, 0);
})->implode(', ');
$totalQty = $order->items->sum('quantity');
// 轉換訂單狀態標籤,與前端 tab-orders.blade.php / detail 面板一致
$statusLabelMap = [
'pending' => __('Pending'),
'paid' => __('Paid'),
'completed' => __('Completed'),
'failed' => __('Failed'),
'abandoned' => __('Unpaid'),
'cancelled' => __('Cancelled'),
'refunded' => __('Refunded'),
];
$paymentStatusLabel = $statusLabelMap[$order->status] ?? $order->status;
$row = [
$order->order_no,
$order->created_at->format('Y-m-d H:i:s'),
$order->machine->name ?? 'Unknown',
$order->machine->serial_no ?? '---',
$productSummary ?: '---',
$totalQty,
number_format($order->total_amount, 2, '.', ''),
number_format($order->discount_amount, 2, '.', ''),
number_format($order->pay_amount, 2, '.', ''),
$order->payment_type_label,
$paymentStatusLabel,
$order->delivery_status_label,
$order->invoice->invoice_no ?? '---',
$order->staffCardLog->staffCard->name ?? '---',
$order->staffCardLog->staffCard->employee_id ?? '---',
$order->staffCardLog->staffCard->card_uid ?? '---',
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $val) {
fwrite($file, '<td>' . htmlspecialchars($val) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
} elseif ($tab === 'invoices') {
$filename = '電子發票紀錄_' . now()->format('YmdHis') . '.' . $ext;
$headers = [
__('Invoice Number'),
__('Invoice Date'),
__('Flow ID'),
__('Machine Name'),
__('Machine Serial No'),
__('Associated Order'),
__('Amount'),
__('Status'),
];
// 預先載入關聯
$invoicesQuery->with([
'machine:id,name,serial_no',
'order:id,order_no'
]);
$callback = function () use ($invoicesQuery, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, $headers);
}
$invoicesQuery->latest()->chunk(500, function ($invoices) use ($file, $isExcel) {
foreach ($invoices as $inv) {
// 狀態判斷邏輯需與 tab-invoices.blade.php 完全一致
$status = 'valid';
if (empty($inv->invoice_no)) $status = 'failed';
if ($inv->rtn_code === 'void') $status = 'void';
$statusMap = [
'valid' => __('Valid'),
'void' => __('Void'),
'failed' => __('Failed'),
'refunded' => __('Refunded'),
];
$statusLabel = $statusMap[$status] ?? $status;
$row = [
$inv->invoice_no ?: '---',
$inv->invoice_date ?: '---',
$inv->display_flow_id ?: '---',
$inv->machine->name ?? ($inv->order->machine->name ?? 'Unknown'),
$inv->machine->serial_no ?? ($inv->order->machine->serial_no ?? '---'),
$inv->order->order_no ?? '---',
number_format($inv->amount, 2, '.', ''),
$statusLabel,
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $val) {
fwrite($file, '<td>' . htmlspecialchars($val) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
} elseif ($tab === 'dispense') {
$filename = '遠端出貨紀錄_' . now()->format('YmdHis') . '.' . $ext;
$headers = [
__('Machine'),
__('Machine Serial No'),
__('Product Name'),
__('Slot No'),
__('Amount'),
__('Dispense Status'),
__('Dispense Time'),
__('Associated Order'),
];
// 預先載入關聯
$dispenseQuery->with([
'machine:id,name,serial_no',
'product:id,name',
'order:id,order_no'
]);
$callback = function () use ($dispenseQuery, $headers, $isExcel) {
$file = fopen('php://output', 'w');
if ($isExcel) {
fwrite($file, '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns="http://www.w3.org/TR/REC-html40">');
fwrite($file, '<head><meta charset="utf-8"/><style>table { border-collapse: collapse; } th, td { border: 1px solid #cbd5e1; padding: 8px 12px; text-align: left; } th { background-color: #f1f5f9; font-weight: bold; }</style></head><body>');
fwrite($file, '<table><thead><tr>');
foreach ($headers as $header) {
fwrite($file, '<th>' . htmlspecialchars($header) . '</th>');
}
fwrite($file, '</tr></thead><tbody>');
} else {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, $headers);
}
$dispenseQuery->latest()->chunk(500, function ($logs) use ($file, $isExcel) {
foreach ($logs as $log) {
// 出貨狀態對照,需與 tab-dispense.blade.php 完全一致
$statusMap = [
'success' => __('Dispense Success'),
'failed' => __('Dispense Failed'),
'pending' => __('Dispense Pending'),
'1' => __('Dispense Success'),
'0' => __('Dispense Failed'),
];
$statusKey = (string)($log->dispense_status ?? 'pending');
$statusLabel = $statusMap[$statusKey] ?? $statusKey;
$row = [
$log->machine->name ?? 'Unknown',
$log->machine->serial_no ?? '---',
$log->product->localized_name ?? __('Unknown Product'),
$log->slot_no,
number_format($log->amount, 2, '.', ''),
$statusLabel,
$log->machine_time ? $log->machine_time->format('Y-m-d H:i:s') : '---',
$log->order->order_no ?? '---',
];
if ($isExcel) {
fwrite($file, '<tr>');
foreach ($row as $val) {
fwrite($file, '<td>' . htmlspecialchars($val) . '</td>');
}
fwrite($file, '</tr>');
} else {
fputcsv($file, $row);
}
}
});
if ($isExcel) {
fwrite($file, '</tbody></table></body></html>');
}
fclose($file);
};
}
if ($callback) {
return response()->streamDownload($callback, $filename, [
'Content-Type' => $contentType,
'Cache-Control' => 'no-cache, must-revalidate',
'Pragma' => 'no-cache',
'Expires' => '0',
]);
}
return redirect()->back();
}
// ===================== 電子發票對帳/補開/作廢 =====================
/**
* 列印發票向綠界取得列印頁網址InvoicePrint後 302 導向。
* 該網址自綠界回應起 1 小時內有效,前端以 target="_blank" 於新分頁開啟。
*/
public function printInvoice(Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 僅已開立且有發票號者可列印
if ($invoice->status !== Invoice::STATUS_ISSUED || empty($invoice->invoice_no)) {
return back()->with('error', __('Only issued invoices can be printed'));
}
// 僅「統編發票」可列印:本系統開立時 Print=1 僅給帶統編者(見 EcpayInvoiceService::issue 的 Print 規則);
// 捐贈、載具、一般無統編 B2C 皆以 Print=0 開立,綠界 InvoicePrint 會回查無資料而失敗。
if (empty($invoice->metadata['business_tax_id'] ?? '')) {
return back()->with('error', __('Only invoices with a tax ID can be printed'));
}
$machine = $invoice->machine;
if (!$machine || !$ecpay->configForMachine($machine)) {
return back()->with('error', __('This machine has no ECPay invoice settings'));
}
$date = $invoice->invoice_date
? Carbon::parse($invoice->invoice_date)->format('Y-m-d')
: '';
$result = $ecpay->invoicePrint($machine, $invoice->invoice_no, $date);
$url = $result['InvoiceHtml'] ?? '';
if ($result === null || (string) ($result['RtnCode'] ?? '') !== '1' || empty($url)) {
return back()->with('error', __('Failed to get invoice print URL'));
}
return redirect()->away($url);
}
/**
* 手動向綠界查詢單張發票真實狀態GetIssue
* pending 掉包時用此確認:已開→補登 issued查無→標 failed 待補開。
*/
public function reconcileInvoice(Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 伺服器端狀態守衛:僅 pending/failed 可查證(防偽造 POST 對終態發票亂操作)
if (!in_array($invoice->status, [Invoice::STATUS_PENDING, Invoice::STATUS_FAILED], true)) {
return back()->with('error', __('Only pending/failed invoices can be queried'));
}
$machine = $invoice->machine;
if (!$machine || !$ecpay->configForMachine($machine)) {
return back()->with('error', __('This machine has no ECPay invoice settings'));
}
if (empty($invoice->relate_number)) {
return back()->with('error', __('Missing RelateNumber, cannot query'));
}
$result = $ecpay->getIssue($machine, $invoice->relate_number);
if ($result === null) {
$invoice->increment('retry_count');
$invoice->update(['last_checked_at' => now()]);
return back()->with('error', __('ECPay query failed'));
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
$invoiceNo = $result['IIS_Number'] ?? '';
if ($rtnCode === '1' && !empty($invoiceNo)) {
$invoice->update([
'status' => Invoice::STATUS_ISSUED,
'invoice_no' => $invoiceNo,
'invoice_date' => $this->safeDate($result['IIS_Create_Date'] ?? null),
'random_number' => $result['IIS_Random_Number'] ?? $invoice->random_number,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? null,
'last_checked_at' => now(),
]);
return back()->with('success', __('Confirmed issued: ') . $invoiceNo);
}
$invoice->update([
'status' => Invoice::STATUS_FAILED,
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'last_checked_at' => now(),
]);
return back()->with('error', __('ECPay has no such invoice; marked as pending re-issue'));
}
/** 補開Issue沿用同一 RelateNumber 冪等。 */
public function reissueInvoice(Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 伺服器端狀態守衛:僅 pending/failed 可補開(防把 issued/void 重開造成重複開立)
if (!in_array($invoice->status, [Invoice::STATUS_PENDING, Invoice::STATUS_FAILED], true)) {
return back()->with('error', __('Only pending/failed invoices can be re-issued'));
}
$result = $ecpay->reissue($invoice);
if ($result === null) {
return back()->with('error', __('Re-issue failed (no settings or connection error)'));
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
$invoiceNo = $result['InvoiceNo'] ?? '';
if ($rtnCode === '1' && !empty($invoiceNo)) {
$invoice->update([
'status' => Invoice::STATUS_ISSUED,
'invoice_no' => $invoiceNo,
'invoice_date' => $this->safeDate($result['InvoiceDate'] ?? null),
'random_number' => $result['RandomNumber'] ?? $invoice->random_number,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? null,
'last_checked_at' => now(),
]);
return back()->with('success', __('Re-issue success: ') . $invoiceNo);
}
$invoice->update([
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'last_checked_at' => now(),
]);
return back()->with('error', __('Re-issue failed: ') . ($result['RtnMsg'] ?? ''));
}
/** 作廢Invalid用於「已開票未出貨」等情況。 */
public function voidInvoice(Request $request, Invoice $invoice, \App\Services\Invoice\EcpayInvoiceService $ecpay)
{
// 權限守衛:發票作廢僅限「平台系統管理員」,租戶帳號(含其管理員)一律不得作廢。
// 真正的閘門在後端,前端僅隱藏按鈕無法防止偽造 POST。
if (!Auth::user()->isSystemAdmin()) {
abort(403, __('Only system administrators can void invoices'));
}
// 伺服器端狀態守衛:僅 issued 可作廢(防把已作廢/未開立的發票再作廢)
if ($invoice->status !== Invoice::STATUS_ISSUED) {
return back()->with('error', __('Only issued invoices can be voided'));
}
if (empty($invoice->invoice_no)) {
return back()->with('error', __('No invoice issued, cannot void'));
}
// 送綠界的作廢原因維持固定值(法定欄位、上限 20 字);管理者於視窗輸入的備註僅存內部 remark。
$reason = '未出貨作廢';
$remark = trim((string) $request->input('remark', ''));
$date = $invoice->invoice_date
? Carbon::parse($invoice->invoice_date)->format('Y-m-d')
: '';
$result = $ecpay->invalid($invoice->machine, $invoice->invoice_no, $date, $reason);
if ($result === null) {
return back()->with('error', __('Void failed (settings/connection)'));
}
$rtnCode = (string) ($result['RtnCode'] ?? '');
if ($rtnCode === '1') {
$invoice->update([
'status' => Invoice::STATUS_VOID,
'voided_at' => now(),
'void_reason' => $reason,
'remark' => $remark !== '' ? $remark : $invoice->remark,
]);
return back()->with('success', __('Void success'));
}
return back()->with('error', __('Void failed: ') . ($result['RtnMsg'] ?? ''));
}
/** 更新訂單備註(管理者內部註記)。租戶隔離由 TenantScoped + route-model binding 自動處理。 */
public function updateOrderRemark(Request $request, Order $order)
{
$data = $request->validate([
'remark' => ['nullable', 'string', 'max:1000'],
]);
$order->update(['remark' => $data['remark'] ?? null]);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => __('Remark updated')]);
}
return back()->with('success', __('Remark updated'));
}
/** 更新發票備註(管理者內部註記,與送綠界的 void_reason 無關)。 */
public function updateInvoiceRemark(Request $request, Invoice $invoice)
{
$data = $request->validate([
'remark' => ['nullable', 'string', 'max:1000'],
]);
$invoice->update(['remark' => $data['remark'] ?? null]);
if ($request->expectsJson()) {
return response()->json(['success' => true, 'message' => __('Remark updated')]);
}
return back()->with('success', __('Remark updated'));
}
private function safeDate($raw): ?string
{
if (empty($raw)) {
return null;
}
try {
return Carbon::parse($raw)->toDateString();
} catch (\Throwable $e) {
return null;
}
}
}