[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>
This commit is contained in:
sky121113 2026-06-15 16:18:33 +08:00
parent 3d35218d6b
commit d9edeb0d5d
12 changed files with 837 additions and 41 deletions

View File

@ -0,0 +1,113 @@
<?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;
}
}
}

View File

@ -14,6 +14,8 @@ class Kernel extends ConsoleKernel
{
// $schedule->command('inspire')->hourly();
$schedule->command('ota:process-schedules')->everyMinute();
// 電子發票對帳:對 pending 發票去綠界查證(補登 issued / 標 failed 待補開)
$schedule->command('invoices:reconcile')->everyFiveMinutes()->withoutOverlapping();
}
/**

View File

@ -79,7 +79,8 @@ class SalesController extends Controller
$end = Carbon::parse($endDate)->endOfMinute();
$ordersQuery->whereBetween('created_at', [$start, $end]);
$invoicesQuery->whereBetween('invoice_date', [$start, $end]);
// 以 created_at 篩(而非 invoice_datepending/failed 發票尚無 invoice_date 也能顯示,才看得到缺漏
$invoicesQuery->whereBetween('created_at', [$start, $end]);
$dispenseQuery->whereBetween('machine_time', [$start, $end]);
// 共用過濾器:機台
@ -127,6 +128,12 @@ class SalesController extends Controller
if ($status) $ordersQuery->where('status', $status);
}
// 發票專用過濾器狀態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');
@ -1135,4 +1142,131 @@ class SalesController extends Controller
return redirect()->back();
}
// ===================== 電子發票對帳/補開/作廢 =====================
/**
* 手動向綠界查詢單張發票真實狀態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)
{
// 伺服器端狀態守衛:僅 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'));
}
$reason = $request->input('reason', '未出貨作廢');
$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,
]);
return back()->with('success', __('Void success'));
}
return back()->with('error', __('Void failed: ') . ($result['RtnMsg'] ?? ''));
}
private function safeDate($raw): ?string
{
if (empty($raw)) {
return null;
}
try {
return Carbon::parse($raw)->toDateString();
} catch (\Throwable $e) {
return null;
}
}
}

View File

@ -0,0 +1,103 @@
<?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;
}
}
}

View File

@ -5,10 +5,14 @@ namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
class Invoice extends Model
{
use HasFactory, SoftDeletes;
// TenantScoped自動以登入者 company_id 過濾(含 route-model binding 與列表查詢),
// 防止跨公司讀取/操作他人發票。console佇列 Job / reconcile 命令)會自動跳過 scope
// 系統身分仍可跨公司開立/對帳。
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
@ -16,10 +20,16 @@ class Invoice extends Model
'machine_id',
'flow_id',
'invoice_no',
'status',
'relate_number',
'amount',
'carrier_id',
'invoice_date',
'machine_time',
'last_checked_at',
'retry_count',
'voided_at',
'void_reason',
'random_number',
'love_code',
'rtn_code',
@ -28,12 +38,21 @@ class Invoice extends Model
];
protected $casts = [
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'amount' => 'decimal:2',
'invoice_date' => 'date',
'machine_time' => 'datetime',
'last_checked_at' => 'datetime',
'voided_at' => 'datetime',
'retry_count' => 'integer',
'metadata' => 'array',
];
// 發票狀態常數
public const STATUS_PENDING = 'pending'; // 已送出綠界、尚未確認
public const STATUS_ISSUED = 'issued'; // 已開立成功
public const STATUS_FAILED = 'failed'; // 綠界回失敗,待補開
public const STATUS_VOID = 'void'; // 已作廢
public function order()
{
return $this->belongsTo(Order::class);

View File

@ -0,0 +1,230 @@
<?php
namespace App\Services\Invoice;
use App\Models\Machine\Machine;
use App\Models\Transaction\Invoice;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* 綠界電子發票 B2C API 服務(後台對帳/補開/作廢用)。
*
* 機台端在出貨時已「直連綠界」開立並把結果隨 finalize 上報;本服務負責後台側:
* - GetIssue pending 發票以 RelateNumber 去綠界查詢真實狀態(處理掉包/未知,避免重複開立)
* - Issue :對確認未開立者補開(沿用同一 RelateNumber冪等
* - Invalid :對「已開票未出貨」等情況作廢
*
* 金鑰來源:機台關聯的金流配置 payment_configs.settings.ecpay_invoicestore_id/hash_key/hash_iv/email
* 與機台 B014 下發同源。未設定者一律略過(回 null),確保未開發票的機台零接觸。
*/
class EcpayInvoiceService
{
private const REVISION = '3.2.9';
private function baseUrl(): string
{
// fail-safe一律預設「測試站」只有明確設定 ECPAY_INVOICE_BASE_URL 才會打正式站。
// 不可用 APP_ENV 判斷——demo 站的 APP_ENV 也是 production靠它會誤打正式綠界開出真發票。
// 正式環境必須在 .env 設 ECPAY_INVOICE_BASE_URL=https://einvoice.ecpay.com.tw
// 若漏設,最壞情況是正式環境開到測試站(無真實發票、可被察覺),不會誤開真發票。
$configured = config('services.ecpay_invoice.base_url');
if (!empty($configured)) {
return rtrim($configured, '/');
}
return 'https://einvoice-stage.ecpay.com.tw';
}
/** 取得機台綠界發票設定;未設定回 null。 */
public function configForMachine(Machine $machine): ?array
{
$settings = $machine->paymentConfig->settings ?? [];
$merchantId = data_get($settings, 'ecpay_invoice.store_id');
$hashKey = data_get($settings, 'ecpay_invoice.hash_key');
$hashIv = data_get($settings, 'ecpay_invoice.hash_iv');
if (empty($merchantId) || empty($hashKey) || empty($hashIv)) {
return null;
}
return [
'merchant_id' => $merchantId,
'hash_key' => $hashKey,
'hash_iv' => $hashIv,
'email' => data_get($settings, 'ecpay_invoice.email', ''),
];
}
private function encrypt(array $data, string $key, string $iv): string
{
$json = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return base64_encode(openssl_encrypt(urlencode($json), 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv));
}
private function decrypt(?string $data, string $key, string $iv): array
{
if (empty($data)) {
return [];
}
$decrypted = openssl_decrypt(base64_decode($data), 'AES-128-CBC', $key, OPENSSL_RAW_DATA, $iv);
if ($decrypted === false) {
return [];
}
return json_decode(urldecode($decrypted), true) ?: [];
}
/** 共用 POST包 RqHeader + AES 加密 Data回傳 decode 後的 Data 陣列(失敗回 null。 */
private function post(string $endpoint, array $cfg, array $payloadData): ?array
{
$body = [
'MerchantID' => $cfg['merchant_id'],
'RqHeader' => ['Timestamp' => time(), 'Revision' => self::REVISION],
'Data' => $this->encrypt($payloadData, $cfg['hash_key'], $cfg['hash_iv']),
];
try {
$resp = Http::timeout(15)->asJson()->post($this->baseUrl() . $endpoint, $body);
$json = $resp->json();
if (!is_array($json) || !isset($json['Data'])) {
Log::warning('ECPay invoice response missing Data', ['endpoint' => $endpoint, 'resp' => $json]);
return null;
}
return $this->decrypt($json['Data'], $cfg['hash_key'], $cfg['hash_iv']);
} catch (\Throwable $e) {
Log::error('ECPay invoice request failed: ' . $e->getMessage(), ['endpoint' => $endpoint]);
return null;
}
}
/**
* 查詢發票GetIssue RelateNumber 查。
* 回傳 decode 後陣列(含 RtnCode/IIS_Number 等)或 null(金鑰缺/連線失敗)。
*/
public function getIssue(Machine $machine, string $relateNumber): ?array
{
$cfg = $this->configForMachine($machine);
if (!$cfg) {
return null;
}
return $this->post('/B2CInvoice/GetIssue', $cfg, [
'MerchantID' => $cfg['merchant_id'],
'RelateNumber' => $relateNumber,
]);
}
/** 補開:以完整開立內容呼叫 Issue。回傳 decode 後陣列或 null。 */
public function issue(Machine $machine, array $invoicePayload): ?array
{
$cfg = $this->configForMachine($machine);
if (!$cfg) {
return null;
}
$invoicePayload['MerchantID'] = $cfg['merchant_id'];
return $this->post('/B2CInvoice/Issue', $cfg, $invoicePayload);
}
/** 作廢Invalid。 */
public function invalid(Machine $machine, string $invoiceNo, string $invoiceDate, string $reason = '作廢'): ?array
{
$cfg = $this->configForMachine($machine);
if (!$cfg) {
return null;
}
return $this->post('/B2CInvoice/Invalid', $cfg, [
'MerchantID' => $cfg['merchant_id'],
'InvoiceNo' => $invoiceNo,
'InvoiceDate' => $invoiceDate,
'Reason' => mb_substr($reason ?: '作廢', 0, 20),
]);
}
/**
* 由既有 Invoice+ 關聯 Order/items重建開立內容並補開。
* 沿用 invoice.relate_number RelateNumber冪等不會重複開立
*/
public function reissue(Invoice $invoice): ?array
{
$machine = $invoice->machine;
$cfg = $this->configForMachine($machine);
if (!$cfg || empty($invoice->relate_number)) {
return null;
}
$order = $invoice->order;
$metadata = $invoice->metadata ?? [];
$businessTaxId = $metadata['business_tax_id'] ?? '';
$carrier = $invoice->carrier_id ?? '';
$loveCode = $invoice->love_code ?? '';
// 載具類型判定(對齊機台端邏輯)
$carrierType = '';
$donation = '0';
if (strlen($loveCode) > 2) {
$donation = '1';
$carrierType = '';
} elseif (strlen($carrier) === 8) {
$carrierType = '3'; // 手機條碼
} elseif (strlen($carrier) === 16) {
$carrierType = '2'; // 自然人憑證
} elseif (empty($businessTaxId)) {
$carrierType = '1'; // 綠界會員載具
}
// 品項
$items = [];
$seq = 1;
$orderItems = $order ? $order->items : collect();
foreach ($orderItems as $oi) {
$items[] = [
'ItemSeq' => $seq++,
'ItemName' => $oi->product_name ?: ('商品' . $oi->product_id),
'ItemCount' => (int) $oi->quantity,
'ItemWord' => '個',
'ItemPrice' => (int) round($oi->price),
'ItemTaxType' => '1',
'ItemAmount' => (int) round($oi->subtotal ?: ($oi->price * $oi->quantity)),
'ItemRemark' => '',
];
}
if (empty($items)) {
// 無明細時以單列總額補上,確保發票可開立
$items[] = [
'ItemSeq' => 1,
'ItemName' => '商品',
'ItemCount' => 1,
'ItemWord' => '式',
'ItemPrice' => (int) round($invoice->amount),
'ItemTaxType' => '1',
'ItemAmount' => (int) round($invoice->amount),
'ItemRemark' => '',
];
}
$payload = [
'RelateNumber' => $invoice->relate_number,
'CustomerID' => $machine->serial_no,
'Print' => empty($businessTaxId) ? '0' : '1',
'CustomerIdentifier' => $businessTaxId,
'CustomerName' => $businessTaxId,
'CustomerAddr' => $businessTaxId,
'CustomerPhone' => '',
'CustomerEmail' => $donation === '1'
? ($invoice->relate_number . substr($cfg['email'], strpos($cfg['email'], '@') ?: 0))
: ($cfg['email'] ?: ''),
'ClearanceMark' => '',
'Donation' => $donation,
'LoveCode' => $donation === '1' ? $loveCode : '',
'CarrierType' => $carrierType,
'CarrierNum' => ($carrierType === '2' || $carrierType === '3') ? str_replace('+', ' ', $carrier) : '',
'TaxType' => '1',
'SpecialTaxType' => 0,
'SalesAmount' => (int) round($invoice->amount),
'InvoiceRemark' => '發票備註:無',
'InvType' => '07',
'vat' => '1',
'Items' => $items,
];
return $this->issue($machine, $payload);
}
}

View File

@ -114,11 +114,11 @@ class TransactionService
public function recordInvoice(array $data): Invoice
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$machine = Machine::withoutGlobalScopes()->where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::where('flow_id', $data['flow_id'])->first();
$order = Order::withoutGlobalScopes()->where('flow_id', $data['flow_id'])->first();
}
// 處理額外的發票資訊 (business_tax_id, carrier_type)
@ -128,22 +128,65 @@ class TransactionService
if (isset($data['carrier_type']))
$metadata['carrier_type'] = $data['carrier_type'];
return Invoice::create([
$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,
'flow_id' => $data['flow_id'] ?? null,
'invoice_no' => $data['invoice_no'] ?? null,
'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' => $data['rtn_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]));
});
}
@ -259,13 +302,21 @@ class TransactionService
}
$order = $this->processTransaction($orderData);
// 2. Record Invoice (B601) - Optional
// 2. Record Invoice — 後台開立版finalize 帶 invoice 輸入(status=pending)時建 pending 發票,
// commit 後派 IssueInvoiceJob 去綠界開立(不在交易內等 ECPay避免長交易
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);
$invoice = $this->recordInvoice($invoiceData);
// 待開立 → commit 後非同步開立(已開/失敗則不重複觸發)
if ($invoice->status === Invoice::STATUS_PENDING) {
DB::afterCommit(function () use ($invoice) {
\App\Jobs\Transaction\IssueInvoiceJob::dispatch($invoice->id);
});
}
}
// 3. Record Dispense Results (B602) - Optional/Multiple

View File

@ -31,4 +31,11 @@ return [
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
],
// 綠界電子發票 B2C API後台對帳/補開/作廢)。金鑰仍取自各機台 payment_configs.ecpay_invoice
// 此處僅控制 API 網域。留空(未設 env時由 EcpayInvoiceService 依環境 fail-safe
// 正式環境才用 https://einvoice.ecpay.com.tw其餘走 https://einvoice-stage.ecpay.com.tw。
'ecpay_invoice' => [
'base_url' => env('ECPAY_INVOICE_BASE_URL'),
],
];

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
/**
* 電子發票對帳/補開所需欄位。
*
* 背景:機台改走 MQTT finalize 後,發票結果(含 pending隨交易一起上報
* 後台需要狀態機與冪等鍵,才能對 pending 去綠界查詢(GetIssue)、對 failed 補開(Issue)
* 對「已開票未出貨」作廢(Invalid)。全部欄位 nullable / 有預設,對既有資料與未開發票的機台零影響。
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('invoices', function (Blueprint $table) {
// 開立狀態pending已送出未回應/ issued已開/ failed綠界回失敗/ void已作廢
$table->string('status')->default('issued')->index()->after('invoice_no')
->comment('發票狀態: pending/issued/failed/void');
// 綠界 RelateNumber冪等鍵, = machineID + flow_id查詢/補開用
$table->string('relate_number')->nullable()->index()->after('status')
->comment('綠界 RelateNumber 冪等鍵');
// 對帳排程:上次去綠界查詢的時間與已重試次數
$table->timestamp('last_checked_at')->nullable()->after('machine_time')
->comment('上次向綠界查詢/補開的時間');
$table->unsignedInteger('retry_count')->default(0)->after('last_checked_at')
->comment('查詢/補開重試次數');
// 作廢稽核
$table->timestamp('voided_at')->nullable()->after('retry_count')
->comment('作廢時間');
$table->string('void_reason')->nullable()->after('voided_at')
->comment('作廢原因');
});
// 既有資料回填有發票號→issued否則→failed未開發票的機台沒有任何列等同 no-op
DB::table('invoices')
->whereNull('invoice_no')->orWhere('invoice_no', '')
->update(['status' => 'failed']);
}
public function down(): void
{
Schema::table('invoices', function (Blueprint $table) {
$table->dropColumn([
'status',
'relate_number',
'last_checked_at',
'retry_count',
'voided_at',
'void_reason',
]);
});
}
};

View File

@ -961,6 +961,28 @@
"LINE Official Pay": "Line官方支付",
"LINE Pay": "LINE Pay",
"LINE Pay (Official)": "LINE Pay(官方)",
"Pending": "待開立",
"Issued": "已開立",
"Query": "查詢",
"Query ECPay": "向綠界查詢",
"Re-issue": "補開",
"Re-issue this invoice?": "確定要補開這張發票嗎?",
"Void this invoice?": "確定要作廢這張發票嗎?",
"This machine has no ECPay invoice settings": "此機台未設定綠界電子發票",
"Missing RelateNumber, cannot query": "缺少 RelateNumber無法查詢",
"ECPay query failed": "綠界查詢失敗",
"Confirmed issued: ": "已確認開立:",
"ECPay has no such invoice; marked as pending re-issue": "綠界查無此發票,已標記為待補開",
"Re-issue failed (no settings or connection error)": "補開失敗(未設定或連線錯誤)",
"Re-issue success: ": "補開成功:",
"Re-issue failed: ": "補開失敗:",
"No invoice issued, cannot void": "尚未開立發票,無法作廢",
"Void failed (settings/connection)": "作廢失敗(設定或連線)",
"Void success": "作廢成功",
"Void failed: ": "作廢失敗:",
"Only pending/failed invoices can be queried": "僅待開立/失敗的發票可查詢",
"Only pending/failed invoices can be re-issued": "僅待開立/失敗的發票可補開",
"Only issued invoices can be voided": "僅已開立的發票可作廢",
"LINE Pay Direct": "LINE Pay 官方直連",
"LINE Pay Direct Settings Description": "LINE Pay 官方直連設定",
"LINE-ChannelId": "LINE-ChannelId",

View File

@ -217,18 +217,15 @@
</td>
<td class="px-6 py-6 whitespace-nowrap">
@php
// 簡單判斷:如果有發票號碼且 rtn_code 為空或 0/1 則視為有效
$status = 'valid';
if (empty($invoice->invoice_no)) $status = 'failed';
if ($invoice->rtn_code === 'void') $status = 'void';
// 以發票狀態機顯示pending/issued/failed/void舊資料無 status 時以發票號回推
$st = $invoice->status ?: (empty($invoice->invoice_no) ? 'failed' : 'issued');
$statusMap = [
'valid' => ['label' => __('Valid'), 'color' => 'emerald'],
'void' => ['label' => __('Void'), 'color' => 'rose'],
'pending' => ['label' => __('Pending'), 'color' => 'amber'],
'issued' => ['label' => __('Issued'), 'color' => 'emerald'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'refunded' => ['label' => __('Refunded'), 'color' => 'slate'],
'void' => ['label' => __('Void'), 'color' => 'slate'],
];
$s = $statusMap[$status] ?? ['label' => $status, 'color' => 'slate'];
$s = $statusMap[$st] ?? ['label' => $st, 'color' => 'slate'];
@endphp
<x-status-badge :color="$s['color']" :label="$s['label']" size="xs" />
</td>
@ -238,16 +235,45 @@
{{ $invoice->order->order_no ?? '---' }}
</span>
</td>
<td class="px-6 py-6 text-right">
<button
class="p-2.5 rounded-xl text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
<td class="px-6 py-6 text-right" @click.stop>
<div class="flex items-center justify-end gap-1.5">
@if(in_array($st, ['pending', 'failed']))
<form method="POST" action="{{ route('admin.sales.invoices.reconcile', $invoice) }}">
@csrf
<button type="submit" title="{{ __('Query ECPay') }}"
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold text-amber-600 dark:text-amber-400 bg-amber-500/5 hover:bg-amber-500/10 border border-amber-500/20 transition-all">
{{ __('Query') }}
</button>
</form>
<form method="POST" action="{{ route('admin.sales.invoices.reissue', $invoice) }}"
onsubmit="return confirm('{{ __('Re-issue this invoice?') }}')">
@csrf
<button type="submit" title="{{ __('Re-issue') }}"
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold text-cyan-600 dark:text-cyan-400 bg-cyan-500/5 hover:bg-cyan-500/10 border border-cyan-500/20 transition-all">
{{ __('Re-issue') }}
</button>
</form>
@endif
@if($st === 'issued')
<form method="POST" action="{{ route('admin.sales.invoices.void', $invoice) }}"
onsubmit="return confirm('{{ __('Void this invoice?') }}')">
@csrf
<button type="submit" title="{{ __('Void') }}"
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold text-rose-600 dark:text-rose-400 bg-rose-500/5 hover:bg-rose-500/10 border border-rose-500/20 transition-all">
{{ __('Void') }}
</button>
</form>
@endif
<button @click="openDetail({{ $invoice->order_id }})"
class="p-2 rounded-xl text-slate-400 hover:text-cyan-500 hover:bg-cyan-500/5 dark:hover:bg-cyan-500/10 border border-transparent hover:border-cyan-500/20 transition-all">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
</div>
</td>
</tr>
@empty
@ -267,16 +293,14 @@
<div
class="luxury-card p-6 rounded-[2rem] border border-slate-100 dark:border-slate-800 bg-white/50 dark:bg-slate-900/50 transition-all duration-300 group">
@php
$status = 'valid';
if (empty($invoice->invoice_no)) $status = 'failed';
if ($invoice->rtn_code === 'void') $status = 'void';
$st = $invoice->status ?: (empty($invoice->invoice_no) ? 'failed' : 'issued');
$statusMap = [
'valid' => ['label' => __('Valid'), 'color' => 'emerald'],
'void' => ['label' => __('Void'), 'color' => 'rose'],
'pending' => ['label' => __('Pending'), 'color' => 'amber'],
'issued' => ['label' => __('Issued'), 'color' => 'emerald'],
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
'refunded' => ['label' => __('Refunded'), 'color' => 'slate'],
'void' => ['label' => __('Void'), 'color' => 'slate'],
];
$s = $statusMap[$status] ?? ['label' => $status, 'color' => 'slate'];
$s = $statusMap[$st] ?? ['label' => $st, 'color' => 'slate'];
@endphp
{{-- Card Header --}}
@ -344,7 +368,7 @@
</div>
{{-- Action Buttons --}}
<div class="flex items-center gap-3">
<div class="flex flex-col gap-3">
<button @click="openDetail({{ $invoice->order_id }})"
class="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 font-black text-xs uppercase tracking-widest border border-slate-100 dark:border-slate-800 hover:text-cyan-500 hover:bg-cyan-500/5 transition-all duration-300">
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -354,6 +378,35 @@
</svg>
{{ __('View Details') }}
</button>
@if(in_array($st, ['pending', 'failed']))
<div class="flex items-center gap-2">
<form method="POST" action="{{ route('admin.sales.invoices.reconcile', $invoice) }}" class="flex-1">
@csrf
<button type="submit"
class="w-full py-2.5 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400 font-black text-xs uppercase tracking-widest border border-amber-500/20">
{{ __('Query') }}
</button>
</form>
<form method="POST" action="{{ route('admin.sales.invoices.reissue', $invoice) }}" class="flex-1"
onsubmit="return confirm('{{ __('Re-issue this invoice?') }}')">
@csrf
<button type="submit"
class="w-full py-2.5 rounded-xl bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 font-black text-xs uppercase tracking-widest border border-cyan-500/20">
{{ __('Re-issue') }}
</button>
</form>
</div>
@endif
@if($st === 'issued')
<form method="POST" action="{{ route('admin.sales.invoices.void', $invoice) }}"
onsubmit="return confirm('{{ __('Void this invoice?') }}')">
@csrf
<button type="submit"
class="w-full py-2.5 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400 font-black text-xs uppercase tracking-widest border border-rose-500/20">
{{ __('Void') }}
</button>
</form>
@endif
</div>
</div>
@empty

View File

@ -141,7 +141,12 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
// 6. 銷售管理
Route::prefix('sales')->name('sales.')->group(function () {
Route::get('/', [App\Http\Controllers\Admin\SalesController::class, 'index'])->name('index');
// 電子發票對帳/補開/作廢
Route::post('/invoices/{invoice}/reconcile', [App\Http\Controllers\Admin\SalesController::class, 'reconcileInvoice'])->name('invoices.reconcile');
Route::post('/invoices/{invoice}/reissue', [App\Http\Controllers\Admin\SalesController::class, 'reissueInvoice'])->name('invoices.reissue');
Route::post('/invoices/{invoice}/void', [App\Http\Controllers\Admin\SalesController::class, 'voidInvoice'])->name('invoices.void');
// 取貨碼
Route::get('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'pickupCodes'])->name('pickup-codes');
Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store');