star-cloud/app/Services/Transaction/TransactionService.php
sky121113 cbc4ae3749 [FIX] 開立電子發票前檢查機台電子發票開關
1. finalizeTransaction() 開立發票條件改為「機台 tax_invoice_enabled 開啟 AND 上報帶 invoice」,兩者皆成立才開立。
2. 機台關閉電子發票時:不建發票、不送綠界 IssueInvoiceJob,僅記 warning log(machine_id / flow_id / order_id)供追查。
3. 修正原本只判斷上報是否帶 invoice、完全忽略機台開關而可能誤開發票的問題。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:30:17 +08:00

549 lines
25 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\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;
// 交易生命週期狀態payload 沒帶就預設 completed。
// → main(晟崴/中國醫)的成功 finalize 不帶 status仍得到 'completed',行為逐字不變。
$incomingStatus = $data['status'] ?? Order::STATUS_COMPLETED;
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()
->where('flow_id', $flowId)
->first();
if ($existingOrder) {
// 鐵律二:終態(completed/failed/abandoned)一律冪等回傳,不可覆蓋。
// main 的單永遠是 completed → 等同原本「flow_id 已存在直接 return」的行為
// 重送/redelivery 也不會重複建立、不會重複扣庫存。
if (in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
return $existingOrder;
}
// 鐵律三:只有「既有單為 pending」才允許狀態轉移pending → completed/failed/abandoned
// 唯有「先送過 pending」的機台(本分支、flag on)會走到這main 從不產生 pending → 不可達。
return $this->transitionOrder($existingOrder, $data, $incomingStatus);
}
}
return DB::transaction(function () use ($data, $incomingStatus) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$paymentStatus = (int) ($data['payment_status'] ?? Order::PAYMENT_STATUS_SUCCESS);
// 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' => $paymentStatus,
'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_atpending/failed 無實際付款時間。
// main = completed + payment_status=1 → now(),與原本一致。
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS ? now() : null,
'status' => $incomingStatus,
'metadata' => $data['metadata'] ?? null,
'delivery_status' => (int) ($data['delivery_status'] ?? Order::DELIVERY_STATUS_SUCCESS),
]);
$this->createOrderItems($order, $data['items'] ?? []);
return $order;
});
}
/**
* pending 單的狀態轉移pending → completed/failed/abandoned
* 只更新付款結果相關欄位;商品明細在 pending 階段已建立,不重複建立(避免品項翻倍)。
*/
protected function transitionOrder(Order $order, array $data, string $newStatus): Order
{
return DB::transaction(function () use ($order, $data, $newStatus) {
$paymentStatus = isset($data['payment_status'])
? (int) $data['payment_status']
: (int) $order->payment_status;
$order->update([
'status' => $newStatus,
'payment_status' => $paymentStatus,
// payment_type 以本次上報為準:例如手機支付 pending 階段為 4(路由鍵)
// 成功 finalize 覆寫為 10(手機支付),轉移時需一併更新。
'payment_type' => isset($data['payment_type'])
? (int) $data['payment_type']
: $order->payment_type,
'payment_request' => $data['payment_request'] ?? $order->payment_request,
'payment_response' => $data['payment_response'] ?? $order->payment_response,
'pay_amount' => $data['pay_amount'] ?? $order->pay_amount,
'change_amount' => $data['change_amount'] ?? $order->change_amount,
'points_used' => $data['points_used'] ?? $order->points_used,
'invoice_info' => $data['invoice_info'] ?? $order->invoice_info,
'delivery_status' => isset($data['delivery_status'])
? (int) $data['delivery_status']
: $order->delivery_status,
'payment_at' => $paymentStatus === Order::PAYMENT_STATUS_SUCCESS
? ($order->payment_at ?? now())
: $order->payment_at,
]);
// pending 階段若未帶明細,補建一次(防禦)。
if ($order->items()->count() === 0 && !empty($data['items'])) {
$this->createOrderItems($order, $data['items']);
}
return $order->fresh();
});
}
/** 建立訂單品項(沒帶名稱時回資料庫補抓)。 */
protected function createOrderItems(Order $order, array $items): void
{
foreach ($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'],
]);
}
}
/**
* 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::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();
}
// 處理額外的發票資訊 (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'];
$invoiceNo = $data['invoice_no'] ?? null;
$rtnCode = $data['rtn_code'] ?? null;
// 狀態:優先採用 App 已判定的 status新 finalize 流程一定帶 status=pending否則才推導。
// 此推導分支目前僅 MQTT 'invoice' 指令(ProcessInvoice)會走App 暫未使用,屬防禦性。
// 重點:有發票號就代表已開立,除非「明確」收到失敗碼才算 failed
//(部分上報格式只帶 invoice_no、不帶 rtn_code不可因缺 rtn_code 就誤判失敗)。
// 綠界 RtnCode == "1" 為成功;其餘非空值才視為明確失敗。
$status = $data['status'] ?? null;
if (empty($status)) {
$explicitFailure = !empty($rtnCode) && (string) $rtnCode !== '1';
if (!empty($invoiceNo)) {
$status = $explicitFailure ? 'failed' : 'issued';
} else {
// 無發票號明確失敗→failed否則待開立/待查證→pending
$status = $explicitFailure ? 'failed' : 'pending';
}
}
// RelateNumber 冪等鍵App 未帶時由後台以 serial + flow_id 推導(綠界上限 30 字)
$relateNumber = $data['relate_number'] ?? null;
if (empty($relateNumber) && !empty($data['flow_id'])) {
$relateNumber = substr($machine->serial_no . $data['flow_id'], 0, 30);
}
$attributes = [
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'machine_id' => $machine->id,
'invoice_no' => $invoiceNo,
'status' => $status,
'relate_number' => $relateNumber,
'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' => $rtnCode,
'rtn_msg' => $data['rtn_msg'] ?? null,
'metadata' => $metadata,
];
// 去重:同一 flow_id一張訂單一張發票以 updateOrCreate 收斂,
// 避免 finalize 重送 / 補開回寫產生重複發票列。
if (!empty($data['flow_id'])) {
// 終態守衛(防禦縱深):已開立/已作廢的發票為終態,不得被後續寫入覆蓋,
// 避免任何重送(含 ProcessInvoice/MQTT invoice 指令)把 issued 打回 pending、清掉發票號。
$existing = Invoice::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
if ($existing && in_array($existing->status, [Invoice::STATUS_ISSUED, Invoice::STATUS_VOID], true)) {
return $existing;
}
return Invoice::withoutGlobalScopes()->updateOrCreate(
['flow_id' => $data['flow_id']],
$attributes
);
}
return Invoice::create(array_merge($attributes, ['flow_id' => null]));
});
}
/**
* 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);
// 冪等性檢查:只有「終態(completed/failed/abandoned)」才直接回傳,避免重複出貨/扣庫存。
// main(晟崴/中國醫)的單永遠是 completed → 等同原本的 early-return行為不變。
// 既有單為 pending本分支先送過 pending時則「放行」往下交給 processTransaction
// 做 pending → paid/failed 的狀態轉移(含本次的 dispense/invoice
if ($flowId) {
$existingOrder = Order::withoutGlobalScopes()->where('flow_id', $flowId)->first();
if ($existingOrder && in_array($existingOrder->status, Order::TERMINAL_STATUSES, true)) {
Log::info("Transaction already finalized (terminal), returning existing order", [
'flow_id' => $flowId,
'status' => $existingOrder->status,
]);
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['delivery_status']) && !empty($data['dispense'])) {
$orderData['delivery_status'] = $this->resolveDeliveryStatusFromDispense($data['dispense']);
}
if (!isset($orderData['payment_type']) && isset($data['payment_type'])) {
$orderData['payment_type'] = $data['payment_type'];
}
$order = $this->processTransaction($orderData);
// 2. Record Invoice — 後台開立版finalize 帶 invoice 輸入(status=pending)時建 pending 發票,
// commit 後派 IssueInvoiceJob 去綠界開立(不在交易內等 ECPay避免長交易
// 開立條件:機台「電子發票開關」開啟 AND 上報帶 invoice兩者皆成立才開立。
// 機台關閉電子發票時略過開立(不建發票、不送綠界),僅留 warning log 供日後追查。
if (!empty($data['invoice']) && $order->machine?->tax_invoice_enabled) {
$invoiceData = $data['invoice'];
$invoiceData['serial_no'] = $serialNo;
$invoiceData['flow_id'] = $order->flow_id;
$invoiceData['order_id'] = $order->id;
$invoice = $this->recordInvoice($invoiceData);
// 待開立 → commit 後非同步開立(已開/失敗則不重複觸發)
if ($invoice->status === Invoice::STATUS_PENDING) {
DB::afterCommit(function () use ($invoice) {
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
});
}
} elseif (!empty($data['invoice'])) {
// 上報帶了 invoice但機台「電子發票開關」為關閉 → 略過開立,留軌跡供追查。
Log::warning('機台已關閉電子發票,略過開立', [
'machine_id' => $order->machine_id,
'flow_id' => $order->flow_id,
'order_id' => $order->id,
]);
}
// 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;
});
}
/**
* Resolve order-level delivery status from item-level dispense results.
*/
protected function resolveDeliveryStatusFromDispense(array $dispense): int
{
$dispenseList = isset($dispense[0]) ? $dispense : [$dispense];
$statuses = collect($dispenseList)
->filter(fn ($item) => is_array($item) && array_key_exists('dispense_status', $item))
->map(fn ($item) => (int) $item['dispense_status']);
if ($statuses->isEmpty()) {
return Order::DELIVERY_STATUS_SUCCESS;
}
$successCount = $statuses
->filter(fn ($status) => $status === Order::DELIVERY_STATUS_SUCCESS)
->count();
if ($successCount === $statuses->count()) {
return Order::DELIVERY_STATUS_SUCCESS;
}
if ($successCount === 0) {
return Order::DELIVERY_STATUS_FAILED;
}
return Order::DELIVERY_STATUS_PARTIAL;
}
}