star-cloud/app/Jobs/Transaction/IssueInvoiceJob.php
sky121113 d9edeb0d5d [FEAT] 電子發票後台開立、對帳、補開與作廢流程
1. 新增發票狀態機(pending/issued/failed/void)與冪等鍵 relate_number,並補上 last_checked_at、retry_count、voided_at、void_reason 欄位(migration 全欄位 nullable/有預設,對既有資料零影響並回填既有狀態)。
2. finalize 帶入發票輸入時改為先建 pending 發票,commit 後派發 IssueInvoiceJob 非同步向綠界開立,避免在交易內等待 ECPay 造成長交易。
3. 新增 EcpayInvoiceService 封裝綠界 B2C API(GetIssue 查詢、Issue 補開、Invalid 作廢),金鑰仍取自各機台 payment_configs。
4. 新增 invoices:reconcile 排程指令,每 5 分鐘對 pending 發票以 RelateNumber 向綠界 GetIssue 查證,補登 issued 或標記 failed 待補開。
5. SalesController 新增 reconcileInvoice/reissueInvoice/voidInvoice 三個後台操作端點與對應路由。
6. 發票列表改以 created_at 篩選(pending/failed 尚無 invoice_date 也能顯示),新增狀態過濾器與查詢/補開/作廢操作按鈕,狀態徽章改以狀態機顯示。
7. recordInvoice 改以 flow_id updateOrCreate 收斂,避免重送產生重複發票列;查詢機台/訂單時加上 withoutGlobalScopes。
8. 同步新增相關三語系字串並加入 ecpay_invoice base_url 設定。

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

104 lines
3.6 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\Jobs\Transaction;
use App\Models\Transaction\Invoice;
use App\Services\Invoice\EcpayInvoiceService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* 後台開立電子發票(取代機台直連綠界)。
*
* finalize 建立 pending 發票後派發;以 invoice.relate_number 為冪等鍵向綠界開立Issue
* 成功→issued、失敗→failed待人工/排程補開)。連線失敗則 retry由佇列重試機制接手
* 仍有殘留 pending 者由 invoices:reconcileGetIssue做最終查證避免重複開立。
*/
class IssueInvoiceJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 30;
public function __construct(public int $invoiceId)
{
}
public function handle(EcpayInvoiceService $ecpay): void
{
$invoice = Invoice::with(['machine.paymentConfig', 'order.items'])->find($this->invoiceId);
if (!$invoice) {
return;
}
// 僅處理待開立;已開/失敗/作廢不重複觸發(冪等)
if ($invoice->status !== Invoice::STATUS_PENDING) {
return;
}
// 機台無綠界設定 → 留 pending理論上不會發生無設定機台不會帶 invoice
if (!$invoice->machine || !$ecpay->configForMachine($invoice->machine)) {
Log::warning('IssueInvoiceJob: machine has no ECPay config, leave pending', [
'invoice_id' => $invoice->id,
]);
return;
}
$result = $ecpay->reissue($invoice);
// 連線/解密失敗 → 丟例外讓佇列重試(不改狀態)
if ($result === null) {
$invoice->increment('retry_count');
$invoice->update(['last_checked_at' => now()]);
throw new \RuntimeException('ECPay issue returned null for invoice ' . $invoice->id);
}
$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(),
]);
Log::info('Invoice issued by backend', ['invoice_id' => $invoice->id, 'invoice_no' => $invoiceNo]);
return;
}
// 綠界明確回失敗 → 標 failed待補開不重試
$invoice->update([
'status' => Invoice::STATUS_FAILED,
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
'last_checked_at' => now(),
]);
Log::warning('Invoice issue failed', [
'invoice_id' => $invoice->id,
'rtn_code' => $rtnCode,
'rtn_msg' => $result['RtnMsg'] ?? '',
]);
}
private function safeDate($raw): ?string
{
if (empty($raw)) {
return null;
}
try {
return \Carbon\Carbon::parse($raw)->toDateString();
} catch (\Throwable $e) {
return null;
}
}
}