diff --git a/app/Console/Commands/ReconcileInvoicesCommand.php b/app/Console/Commands/ReconcileInvoicesCommand.php new file mode 100644 index 0000000..10d0be5 --- /dev/null +++ b/app/Console/Commands/ReconcileInvoicesCommand.php @@ -0,0 +1,113 @@ +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; + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 3c78da9..4f0822f 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -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(); } /** diff --git a/app/Http/Controllers/Admin/SalesController.php b/app/Http/Controllers/Admin/SalesController.php index bd59d09..a157214 100644 --- a/app/Http/Controllers/Admin/SalesController.php +++ b/app/Http/Controllers/Admin/SalesController.php @@ -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_date),pending/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; + } + } } diff --git a/app/Jobs/Transaction/IssueInvoiceJob.php b/app/Jobs/Transaction/IssueInvoiceJob.php new file mode 100644 index 0000000..3ebac51 --- /dev/null +++ b/app/Jobs/Transaction/IssueInvoiceJob.php @@ -0,0 +1,103 @@ +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; + } + } +} diff --git a/app/Models/Transaction/Invoice.php b/app/Models/Transaction/Invoice.php index 2426f9f..e027e9a 100644 --- a/app/Models/Transaction/Invoice.php +++ b/app/Models/Transaction/Invoice.php @@ -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); diff --git a/app/Services/Invoice/EcpayInvoiceService.php b/app/Services/Invoice/EcpayInvoiceService.php new file mode 100644 index 0000000..369bb0e --- /dev/null +++ b/app/Services/Invoice/EcpayInvoiceService.php @@ -0,0 +1,230 @@ +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); + } +} diff --git a/app/Services/Transaction/TransactionService.php b/app/Services/Transaction/TransactionService.php index 637fd57..cb7d0e3 100644 --- a/app/Services/Transaction/TransactionService.php +++ b/app/Services/Transaction/TransactionService.php @@ -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 diff --git a/config/services.php b/config/services.php index 0ace530..c7c05f7 100644 --- a/config/services.php +++ b/config/services.php @@ -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'), + ], + ]; diff --git a/database/migrations/2026_06_15_120000_add_status_and_reconciliation_to_invoices_table.php b/database/migrations/2026_06_15_120000_add_status_and_reconciliation_to_invoices_table.php new file mode 100644 index 0000000..0082d69 --- /dev/null +++ b/database/migrations/2026_06_15_120000_add_status_and_reconciliation_to_invoices_table.php @@ -0,0 +1,57 @@ +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', + ]); + }); + } +}; diff --git a/lang/zh_TW.json b/lang/zh_TW.json index 10c32a2..58a4776 100644 --- a/lang/zh_TW.json +++ b/lang/zh_TW.json @@ -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", diff --git a/resources/views/admin/sales/partials/tab-invoices.blade.php b/resources/views/admin/sales/partials/tab-invoices.blade.php index 4fb7281..f84cdd5 100644 --- a/resources/views/admin/sales/partials/tab-invoices.blade.php +++ b/resources/views/admin/sales/partials/tab-invoices.blade.php @@ -217,18 +217,15 @@ @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 @@ -238,16 +235,45 @@ {{ $invoice->order->order_no ?? '---' }} - - + +
+ @if(in_array($st, ['pending', 'failed'])) +
+ @csrf + +
+
+ @csrf + +
+ @endif + @if($st === 'issued') +
+ @csrf + +
+ @endif + +
@empty @@ -267,16 +293,14 @@
@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 @@
{{-- Action Buttons --}} -
+
+ @if(in_array($st, ['pending', 'failed'])) +
+
+ @csrf + +
+
+ @csrf + +
+
+ @endif + @if($st === 'issued') +
+ @csrf + +
+ @endif
@empty diff --git a/routes/web.php b/routes/web.php index e152a28..8b2cd2b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');