1. 移除非必要之 Log 備註:從來店禮使用記錄 (Logs) 的電腦版與行動版畫面中,徹底移除了無業務參考價值的技術備註 (Remark) 欄位,優化排版空間以符合極簡奢華風 UI 設計。 2. 補齊與對齊三語系檔案:補齊來店禮、通行碼與商品領取所缺少的多語系 Key,三個語系 JSON 檔 (zh_TW, en, ja) 的 Key 與行數已 100% 完美對齊 (共 2,255 行) 且完成升冪字母排序 (ksort)。 3. 簡化 Guest 票券大標題:簡化 Guest 端三種票券頁面 (通行碼、取貨碼、來店禮) 的大標題多語系拼接結構,改為單一乾淨的翻譯 Key 呼叫。 4. 口語化翻譯微調:將 Pickup Code 在語系檔中的中文翻譯由「領取碼」微調為更口語、直覺的「取貨碼」。 5. 優化 Guest 頁面欄位:將取貨碼頁面的「機台序號」替換為更具溫度的「機台名稱」;通行碼與來店禮頁面則徹底隱藏機台序號,優化用戶體驗。
389 lines
16 KiB
PHP
389 lines
16 KiB
PHP
<?php
|
||
|
||
namespace App\Services\Transaction;
|
||
|
||
use App\Models\Transaction\Order;
|
||
use App\Models\Transaction\OrderItem;
|
||
use App\Models\Transaction\Invoice;
|
||
use App\Models\Transaction\DispenseRecord;
|
||
use Illuminate\Support\Facades\DB;
|
||
use App\Models\Machine\Machine;
|
||
use App\Models\StaffCardLog;
|
||
use App\Models\Transaction\PickupCode;
|
||
use App\Models\Transaction\PickupCodeLog;
|
||
use App\Models\Transaction\PassCodeLog;
|
||
use App\Models\Transaction\WelcomeGift;
|
||
use App\Models\Transaction\WelcomeGiftLog;
|
||
use Illuminate\Support\Facades\Log;
|
||
use App\Services\Machine\MachineService;
|
||
|
||
|
||
class TransactionService
|
||
{
|
||
protected $machineService;
|
||
|
||
public function __construct(MachineService $machineService)
|
||
{
|
||
$this->machineService = $machineService;
|
||
}
|
||
|
||
/**
|
||
* Process a new transaction (B600).
|
||
*/
|
||
public function processTransaction(array $data): Order
|
||
{
|
||
$flowId = $data['flow_id'] ?? null;
|
||
|
||
if ($flowId) {
|
||
$existingOrder = Order::withoutGlobalScopes()
|
||
->where('flow_id', $flowId)
|
||
->first();
|
||
if ($existingOrder) {
|
||
return $existingOrder;
|
||
}
|
||
}
|
||
|
||
return DB::transaction(function () use ($data) {
|
||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||
|
||
// Create Order
|
||
$order = Order::create([
|
||
'company_id' => $machine->company_id,
|
||
'flow_id' => $data['flow_id'] ?? null,
|
||
'order_no' => $data['order_no'] ?? $this->generateOrderNo(),
|
||
'machine_id' => $machine->id,
|
||
'member_id' => $data['member_id'] ?? null,
|
||
'total_amount' => $data['total_amount'],
|
||
'discount_amount' => $data['discount_amount'] ?? 0,
|
||
'pay_amount' => $data['pay_amount'],
|
||
'change_amount' => $data['change_amount'] ?? 0,
|
||
'points_used' => $data['points_used'] ?? 0,
|
||
'original_amount' => $data['original_amount'] ?? $data['total_amount'],
|
||
'payment_type' => (int) ($data['payment_type'] ?? 0),
|
||
'payment_status' => $data['payment_status'] ?? 1,
|
||
'payment_request' => $data['payment_request'] ?? null,
|
||
'payment_response' => $data['payment_response'] ?? null,
|
||
'member_barcode' => $data['member_barcode'] ?? null,
|
||
'code_id' => $data['code_id'] ?? null,
|
||
'welcome_gift_id' => $data['welcome_gift_id'] ?? null,
|
||
'invoice_info' => $data['invoice_info'] ?? null,
|
||
'machine_time' => $data['machine_time'] ?? now(),
|
||
'payment_at' => now(),
|
||
'status' => 'completed',
|
||
'metadata' => $data['metadata'] ?? null,
|
||
]);
|
||
|
||
// Create Order Items
|
||
if (!empty($data['items'])) {
|
||
foreach ($data['items'] as $item) {
|
||
$productName = $item['product_name'] ?? null;
|
||
|
||
// 如果沒傳名稱,嘗試從資料庫抓取
|
||
if (empty($productName)) {
|
||
$product = \App\Models\Product\Product::find($item['product_id']);
|
||
$productName = $product?->name ?? 'Unknown';
|
||
}
|
||
|
||
$order->items()->create([
|
||
'product_id' => $item['product_id'],
|
||
'product_name' => $productName,
|
||
'barcode' => $item['barcode'] ?? null,
|
||
'price' => $item['price'],
|
||
'quantity' => $item['quantity'],
|
||
'subtotal' => $item['price'] * $item['quantity'],
|
||
]);
|
||
}
|
||
}
|
||
|
||
return $order;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Generate a unique order number.
|
||
*/
|
||
protected function generateOrderNo(): string
|
||
{
|
||
return 'ORD-' . now()->format('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
|
||
}
|
||
|
||
/**
|
||
* Record Invoice (B601).
|
||
*/
|
||
public function recordInvoice(array $data): Invoice
|
||
{
|
||
return DB::transaction(function () use ($data) {
|
||
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
|
||
|
||
$order = null;
|
||
if (!empty($data['flow_id'])) {
|
||
$order = Order::where('flow_id', $data['flow_id'])->first();
|
||
}
|
||
|
||
// 處理額外的發票資訊 (business_tax_id, carrier_type)
|
||
$metadata = $data['metadata'] ?? [];
|
||
if (isset($data['business_tax_id']))
|
||
$metadata['business_tax_id'] = $data['business_tax_id'];
|
||
if (isset($data['carrier_type']))
|
||
$metadata['carrier_type'] = $data['carrier_type'];
|
||
|
||
return Invoice::create([
|
||
'company_id' => $machine->company_id,
|
||
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
|
||
'machine_id' => $machine->id,
|
||
'flow_id' => $data['flow_id'] ?? null,
|
||
'invoice_no' => $data['invoice_no'] ?? null,
|
||
'amount' => $data['amount'] ?? 0,
|
||
'carrier_id' => $data['carrier_id'] ?? null,
|
||
'invoice_date' => $data['invoice_date'] ?? null,
|
||
'machine_time' => $data['machine_time'] ?? null,
|
||
'random_number' => $data['random_number'] ?? ($data['random_no'] ?? null),
|
||
'love_code' => $data['love_code'] ?? null,
|
||
'rtn_code' => $data['rtn_code'] ?? null,
|
||
'rtn_msg' => $data['rtn_msg'] ?? null,
|
||
'metadata' => $metadata,
|
||
]);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Record dispense result (B602).
|
||
*/
|
||
public function recordDispense(array $data): DispenseRecord
|
||
{
|
||
return DB::transaction(function () use ($data) {
|
||
// Use withoutGlobalScopes for background job safety
|
||
$machine = Machine::withoutGlobalScopes()->where('serial_no', $data['serial_no'])->firstOrFail();
|
||
|
||
$order = null;
|
||
if (!empty($data['flow_id'])) {
|
||
$order = Order::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
|
||
}
|
||
|
||
// Find slot with robust matching (handle "1" vs "01")
|
||
$slotNo = $data['slot_no'] ?? null;
|
||
$slot = null;
|
||
if ($slotNo) {
|
||
$slot = $machine->slots()->where('slot_no', (string) $slotNo)->first();
|
||
if (!$slot) {
|
||
$normalized = ltrim((string) $slotNo, '0');
|
||
if ($normalized === '')
|
||
$normalized = '0';
|
||
$slot = $machine->slots()->get()->first(function ($s) use ($normalized) {
|
||
$sn = ltrim($s->slot_no, '0');
|
||
if ($sn === '')
|
||
$sn = '0';
|
||
return $sn === $normalized;
|
||
});
|
||
}
|
||
}
|
||
|
||
$record = DispenseRecord::create([
|
||
'company_id' => $machine->company_id,
|
||
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
|
||
'flow_id' => $data['flow_id'] ?? null,
|
||
'machine_id' => $machine->id,
|
||
'slot_no' => $slotNo ?? 'unknown',
|
||
'product_id' => ($data['product_id'] ?? 0) > 0 ? $data['product_id'] : ($slot ? $slot->product_id : null),
|
||
'amount' => $data['amount'] ?? 0,
|
||
'remaining_stock' => $data['remaining_stock'] ?? null,
|
||
'dispense_status' => $data['dispense_status'] ?? 0,
|
||
'member_barcode' => $data['member_barcode'] ?? ($order?->member_barcode ?? null),
|
||
'machine_time' => $data['machine_time'] ?? now(),
|
||
'points_used' => $data['points_used'] ?? 0,
|
||
]);
|
||
|
||
// 執行實體庫存扣除 (真實交易化)
|
||
if ((int) ($data['dispense_status'] ?? 0) === 1) {
|
||
if ($slot) {
|
||
$slot->decrement('stock');
|
||
|
||
$type = \App\Models\Machine\MachineStockMovement::TYPE_SALE;
|
||
|
||
$this->machineService->recordStockMovement(
|
||
$slot,
|
||
-1,
|
||
$type,
|
||
$order ?? $record,
|
||
$order ? "Sale Order: #{$order->order_no}" : "Dispense Record: #{$record->id}",
|
||
['order_id' => $order?->id, 'dispense_id' => $record->id]
|
||
);
|
||
} else {
|
||
// Log warning if slot matching fails to help diagnostic
|
||
\Log::warning("Dispense record created but slot not found for decrement", [
|
||
'machine_id' => $machine->id,
|
||
'slot_no' => $slotNo
|
||
]);
|
||
}
|
||
}
|
||
|
||
return $record;
|
||
});
|
||
}
|
||
|
||
|
||
/**
|
||
* Finalize a complete transaction (Unified B600/B601/B602).
|
||
*/
|
||
public function finalizeTransaction(array $data): Order
|
||
{
|
||
// 優先從 Root 讀取,再從 order 內讀取
|
||
$flowId = $data['flow_id'] ?? ($data['order']['flow_id'] ?? null);
|
||
|
||
// 冪等性檢查:如果 flow_id 已經存在,直接回傳既有訂單
|
||
if ($flowId) {
|
||
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
|
||
if ($existingOrder) {
|
||
Log::info("Transaction already finalized, returning existing order", ['flow_id' => $flowId]);
|
||
return $existingOrder;
|
||
}
|
||
} else {
|
||
throw new \Exception("Flow ID is required for finalization");
|
||
}
|
||
|
||
return DB::transaction(function () use ($data, $flowId) {
|
||
$serialNo = $data['serial_no'];
|
||
|
||
// 1. Process Order (B600)
|
||
$orderData = $data['order'];
|
||
$orderData['serial_no'] = $serialNo;
|
||
|
||
// 確保提取 payment_type 與 flow_id (可能在 root 或 order 內)
|
||
$orderData['flow_id'] = $flowId;
|
||
if (!isset($orderData['payment_type']) && isset($data['payment_type'])) {
|
||
$orderData['payment_type'] = $data['payment_type'];
|
||
}
|
||
$order = $this->processTransaction($orderData);
|
||
|
||
// 2. Record Invoice (B601) - Optional
|
||
if (!empty($data['invoice'])) {
|
||
$invoiceData = $data['invoice'];
|
||
$invoiceData['serial_no'] = $serialNo;
|
||
$invoiceData['flow_id'] = $order->flow_id;
|
||
$invoiceData['order_id'] = $order->id;
|
||
$this->recordInvoice($invoiceData);
|
||
}
|
||
|
||
// 3. Record Dispense Results (B602) - Optional/Multiple
|
||
if (!empty($data['dispense'])) {
|
||
$dispenseList = isset($data['dispense'][0]) ? $data['dispense'] : [$data['dispense']];
|
||
foreach ($dispenseList as $dispenseItem) {
|
||
$dispenseItem['serial_no'] = $serialNo;
|
||
$dispenseItem['flow_id'] = $order->flow_id;
|
||
$dispenseItem['order_id'] = $order->id;
|
||
|
||
// 自動繼承:如果出貨紀錄沒帶 Barcode,就用訂單的
|
||
if (empty($dispenseItem['member_barcode']) && !empty($order->member_barcode)) {
|
||
$dispenseItem['member_barcode'] = $order->member_barcode;
|
||
}
|
||
|
||
$this->recordDispense($dispenseItem);
|
||
}
|
||
}
|
||
|
||
// 4. 閉環勾稽 (Closed Loop Check)
|
||
$codeId = $data['order']['code_id'] ?? ($data['code_id'] ?? null);
|
||
$paymentType = (int) $order->payment_type;
|
||
|
||
if ($codeId) {
|
||
switch ($paymentType) {
|
||
case 41: // 員工卡 (Staff Card)
|
||
// 建立員工卡核銷日誌
|
||
StaffCardLog::create([
|
||
'company_id' => $order->company_id,
|
||
'machine_id' => $order->machine_id,
|
||
'staff_card_id' => $codeId,
|
||
'payment_type' => 41,
|
||
'code_id' => $codeId,
|
||
'order_id' => $order->id,
|
||
'action' => 'consume',
|
||
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}"
|
||
]);
|
||
break;
|
||
|
||
case 6: // 取貨碼 (Pickup Code)
|
||
$pickupCode = PickupCode::where('machine_id', $order->machine_id)
|
||
->where('id', $codeId)
|
||
->where('status', 'active')
|
||
->first();
|
||
if ($pickupCode) {
|
||
$newCount = $pickupCode->usage_count + 1;
|
||
$isUsedUp = $newCount >= $pickupCode->usage_limit;
|
||
|
||
$pickupCode->update([
|
||
'order_id' => $order->id,
|
||
'usage_count' => $newCount,
|
||
'status' => $isUsedUp ? 'used' : 'active',
|
||
'used_at' => $isUsedUp ? now() : $pickupCode->used_at
|
||
]);
|
||
|
||
// 建立核銷日誌 (閉環最後一哩路)
|
||
PickupCodeLog::create([
|
||
'company_id' => $order->company_id,
|
||
'machine_id' => $order->machine_id,
|
||
'pickup_code_id' => $pickupCode->id,
|
||
'order_id' => $order->id,
|
||
'action' => $isUsedUp ? 'used' : 'consume',
|
||
'remark' => "MQTT 交易完成自動核銷 (" . ($newCount) . "/" . $pickupCode->usage_limit . "),訂單: #{$order->id}",
|
||
'raw_data' => [
|
||
'slot' => $pickupCode->slot_no,
|
||
'code' => $pickupCode->code,
|
||
'count' => $newCount,
|
||
'limit' => $pickupCode->usage_limit
|
||
]
|
||
]);
|
||
}
|
||
break;
|
||
|
||
case 5: // 通行碼 (Pass Code)
|
||
// 建立通行碼核銷日誌
|
||
PassCodeLog::create([
|
||
'company_id' => $order->company_id,
|
||
'machine_id' => $order->machine_id,
|
||
'pass_code_id' => $codeId,
|
||
'order_id' => $order->id,
|
||
'action' => 'completed',
|
||
'remark' => "MQTT 交易完成自動紀錄取貨,訂單: #{$order->id}"
|
||
]);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 處理來店禮 (Welcome Gift) 自動核銷
|
||
$welcomeGiftId = $data['order']['welcome_gift_id'] ?? ($data['welcome_gift_id'] ?? null);
|
||
if ($welcomeGiftId) {
|
||
$welcomeGift = WelcomeGift::where('machine_id', $order->machine_id)
|
||
->where('id', $welcomeGiftId)
|
||
->where('status', 'active')
|
||
->first();
|
||
if ($welcomeGift) {
|
||
$newCount = $welcomeGift->usage_count + 1;
|
||
$isUsedUp = ($welcomeGift->usage_type === 'once' && $newCount >= ($welcomeGift->usage_limit ?? 1));
|
||
|
||
$welcomeGift->update([
|
||
'usage_count' => $newCount,
|
||
'status' => $isUsedUp ? 'used' : 'active',
|
||
]);
|
||
|
||
WelcomeGiftLog::create([
|
||
'company_id' => $order->company_id,
|
||
'machine_id' => $order->machine_id,
|
||
'welcome_gift_id' => $welcomeGift->id,
|
||
'order_id' => $order->id,
|
||
'action' => 'consume',
|
||
'remark' => "MQTT 交易完成自動核銷,訂單: #{$order->id}",
|
||
'raw_data' => [
|
||
'code' => $welcomeGift->code,
|
||
'count' => $newCount,
|
||
'type' => $welcomeGift->usage_type
|
||
]
|
||
]);
|
||
}
|
||
}
|
||
|
||
|
||
return $order;
|
||
});
|
||
}
|
||
}
|