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>
114 lines
4.2 KiB
PHP
114 lines
4.2 KiB
PHP
<?php
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Models\Transaction\Invoice;
|
||
use App\Services\Invoice\EcpayInvoiceService;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* 電子發票對帳:掃 pending 發票,去綠界 GetIssue 查真實狀態。
|
||
*
|
||
* 機台開票回應掉包時 status 會停在 pending(不能當失敗,否則重開會重複)。
|
||
* 本命令以 RelateNumber 向綠界查詢確認:已開→補登 issued;確認未開→標 failed(待補開)。
|
||
* 僅處理機台有綠界發票設定者;未設定者一律略過,未開發票的機台零接觸。
|
||
*/
|
||
class ReconcileInvoicesCommand extends Command
|
||
{
|
||
protected $signature = 'invoices:reconcile {--limit=100 : 單次處理筆數上限} {--max-retry=12 : 單張最多查詢次數}';
|
||
|
||
protected $description = '對 pending 電子發票向綠界 GetIssue 查詢並補登/標記狀態';
|
||
|
||
public function handle(EcpayInvoiceService $ecpay): int
|
||
{
|
||
$limit = (int) $this->option('limit');
|
||
$maxRetry = (int) $this->option('max-retry');
|
||
|
||
$invoices = Invoice::with(['machine.paymentConfig', 'order'])
|
||
->where('status', Invoice::STATUS_PENDING)
|
||
->whereNotNull('relate_number')
|
||
->where('retry_count', '<', $maxRetry)
|
||
->orderBy('id')
|
||
->limit($limit)
|
||
->get();
|
||
|
||
if ($invoices->isEmpty()) {
|
||
$this->info('No pending invoices to reconcile.');
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
$issued = 0;
|
||
$failed = 0;
|
||
$skipped = 0;
|
||
|
||
foreach ($invoices as $invoice) {
|
||
$machine = $invoice->machine;
|
||
if (!$machine || !$ecpay->configForMachine($machine)) {
|
||
// 機台無綠界設定 → 略過,不動狀態(不可能誤觸未開發票機台)
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$result = $ecpay->getIssue($machine, $invoice->relate_number);
|
||
|
||
// 連線/解密失敗 → 本輪略過,僅累加重試次數待下輪
|
||
if ($result === null) {
|
||
$invoice->update([
|
||
'retry_count' => $invoice->retry_count + 1,
|
||
'last_checked_at' => now(),
|
||
]);
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
|
||
$rtnCode = (string) ($result['RtnCode'] ?? '');
|
||
$invoiceNo = $result['IIS_Number'] ?? ($result['InvoiceNumber'] ?? '');
|
||
|
||
if ($rtnCode === '1' && !empty($invoiceNo)) {
|
||
// 綠界確認已開立 → 補登
|
||
$invoice->update([
|
||
'status' => Invoice::STATUS_ISSUED,
|
||
'invoice_no' => $invoiceNo,
|
||
'invoice_date' => $this->parseDate($result['IIS_Create_Date'] ?? null),
|
||
'random_number' => $result['IIS_Random_Number'] ?? $invoice->random_number,
|
||
'rtn_code' => $rtnCode,
|
||
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
|
||
'retry_count' => $invoice->retry_count + 1,
|
||
'last_checked_at' => now(),
|
||
]);
|
||
$issued++;
|
||
Log::info('Invoice reconciled as issued', [
|
||
'invoice_id' => $invoice->id,
|
||
'invoice_no' => $invoiceNo,
|
||
]);
|
||
} else {
|
||
// 綠界查無此發票 → 確認未開立,標 failed 待補開
|
||
$invoice->update([
|
||
'status' => Invoice::STATUS_FAILED,
|
||
'rtn_code' => $rtnCode ?: $invoice->rtn_code,
|
||
'rtn_msg' => $result['RtnMsg'] ?? $invoice->rtn_msg,
|
||
'retry_count' => $invoice->retry_count + 1,
|
||
'last_checked_at' => now(),
|
||
]);
|
||
$failed++;
|
||
}
|
||
}
|
||
|
||
$this->info("Reconcile done. issued={$issued} failed={$failed} skipped={$skipped}");
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
private function parseDate(?string $raw): ?string
|
||
{
|
||
if (empty($raw)) {
|
||
return null;
|
||
}
|
||
try {
|
||
return \Carbon\Carbon::parse($raw)->toDateString();
|
||
} catch (\Throwable $e) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|