[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:
parent
3d35218d6b
commit
d9edeb0d5d
113
app/Console/Commands/ReconcileInvoicesCommand.php
Normal file
113
app/Console/Commands/ReconcileInvoicesCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ class Kernel extends ConsoleKernel
|
|||||||
{
|
{
|
||||||
// $schedule->command('inspire')->hourly();
|
// $schedule->command('inspire')->hourly();
|
||||||
$schedule->command('ota:process-schedules')->everyMinute();
|
$schedule->command('ota:process-schedules')->everyMinute();
|
||||||
|
// 電子發票對帳:對 pending 發票去綠界查證(補登 issued / 標 failed 待補開)
|
||||||
|
$schedule->command('invoices:reconcile')->everyFiveMinutes()->withoutOverlapping();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -79,7 +79,8 @@ class SalesController extends Controller
|
|||||||
$end = Carbon::parse($endDate)->endOfMinute();
|
$end = Carbon::parse($endDate)->endOfMinute();
|
||||||
|
|
||||||
$ordersQuery->whereBetween('created_at', [$start, $end]);
|
$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]);
|
$dispenseQuery->whereBetween('machine_time', [$start, $end]);
|
||||||
|
|
||||||
// 共用過濾器:機台
|
// 共用過濾器:機台
|
||||||
@ -127,6 +128,12 @@ class SalesController extends Controller
|
|||||||
if ($status) $ordersQuery->where('status', $status);
|
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')) {
|
if ($request->filled('export')) {
|
||||||
$exportType = $request->input('export');
|
$exportType = $request->input('export');
|
||||||
@ -1135,4 +1142,131 @@ class SalesController extends Controller
|
|||||||
|
|
||||||
return redirect()->back();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
app/Jobs/Transaction/IssueInvoiceJob.php
Normal file
103
app/Jobs/Transaction/IssueInvoiceJob.php
Normal 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:reconcile(GetIssue)做最終查證,避免重複開立。
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,10 +5,14 @@ namespace App\Models\Transaction;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use App\Traits\TenantScoped;
|
||||||
|
|
||||||
class Invoice extends Model
|
class Invoice extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
// TenantScoped:自動以登入者 company_id 過濾(含 route-model binding 與列表查詢),
|
||||||
|
// 防止跨公司讀取/操作他人發票。console(佇列 Job / reconcile 命令)會自動跳過 scope,
|
||||||
|
// 系統身分仍可跨公司開立/對帳。
|
||||||
|
use HasFactory, SoftDeletes, TenantScoped;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'company_id',
|
'company_id',
|
||||||
@ -16,10 +20,16 @@ class Invoice extends Model
|
|||||||
'machine_id',
|
'machine_id',
|
||||||
'flow_id',
|
'flow_id',
|
||||||
'invoice_no',
|
'invoice_no',
|
||||||
|
'status',
|
||||||
|
'relate_number',
|
||||||
'amount',
|
'amount',
|
||||||
'carrier_id',
|
'carrier_id',
|
||||||
'invoice_date',
|
'invoice_date',
|
||||||
'machine_time',
|
'machine_time',
|
||||||
|
'last_checked_at',
|
||||||
|
'retry_count',
|
||||||
|
'voided_at',
|
||||||
|
'void_reason',
|
||||||
'random_number',
|
'random_number',
|
||||||
'love_code',
|
'love_code',
|
||||||
'rtn_code',
|
'rtn_code',
|
||||||
@ -28,12 +38,21 @@ class Invoice extends Model
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'total_amount' => 'decimal:2',
|
'amount' => 'decimal:2',
|
||||||
'tax_amount' => 'decimal:2',
|
'invoice_date' => 'date',
|
||||||
'machine_time' => 'datetime',
|
'machine_time' => 'datetime',
|
||||||
|
'last_checked_at' => 'datetime',
|
||||||
|
'voided_at' => 'datetime',
|
||||||
|
'retry_count' => 'integer',
|
||||||
'metadata' => 'array',
|
'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()
|
public function order()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Order::class);
|
return $this->belongsTo(Order::class);
|
||||||
|
|||||||
230
app/Services/Invoice/EcpayInvoiceService.php
Normal file
230
app/Services/Invoice/EcpayInvoiceService.php
Normal 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_invoice(store_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -114,11 +114,11 @@ class TransactionService
|
|||||||
public function recordInvoice(array $data): Invoice
|
public function recordInvoice(array $data): Invoice
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($data) {
|
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;
|
$order = null;
|
||||||
if (!empty($data['flow_id'])) {
|
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)
|
// 處理額外的發票資訊 (business_tax_id, carrier_type)
|
||||||
@ -128,22 +128,65 @@ class TransactionService
|
|||||||
if (isset($data['carrier_type']))
|
if (isset($data['carrier_type']))
|
||||||
$metadata['carrier_type'] = $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,
|
'company_id' => $machine->company_id,
|
||||||
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
|
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
|
||||||
'machine_id' => $machine->id,
|
'machine_id' => $machine->id,
|
||||||
'flow_id' => $data['flow_id'] ?? null,
|
'invoice_no' => $invoiceNo,
|
||||||
'invoice_no' => $data['invoice_no'] ?? null,
|
'status' => $status,
|
||||||
|
'relate_number' => $relateNumber,
|
||||||
'amount' => $data['amount'] ?? 0,
|
'amount' => $data['amount'] ?? 0,
|
||||||
'carrier_id' => $data['carrier_id'] ?? null,
|
'carrier_id' => $data['carrier_id'] ?? null,
|
||||||
'invoice_date' => $data['invoice_date'] ?? null,
|
'invoice_date' => $data['invoice_date'] ?? null,
|
||||||
'machine_time' => $data['machine_time'] ?? null,
|
'machine_time' => $data['machine_time'] ?? null,
|
||||||
'random_number' => $data['random_number'] ?? ($data['random_no'] ?? null),
|
'random_number' => $data['random_number'] ?? ($data['random_no'] ?? null),
|
||||||
'love_code' => $data['love_code'] ?? null,
|
'love_code' => $data['love_code'] ?? null,
|
||||||
'rtn_code' => $data['rtn_code'] ?? null,
|
'rtn_code' => $rtnCode,
|
||||||
'rtn_msg' => $data['rtn_msg'] ?? null,
|
'rtn_msg' => $data['rtn_msg'] ?? null,
|
||||||
'metadata' => $metadata,
|
'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);
|
$order = $this->processTransaction($orderData);
|
||||||
|
|
||||||
// 2. Record Invoice (B601) - Optional
|
// 2. Record Invoice — 後台開立版:finalize 帶 invoice 輸入(status=pending)時建 pending 發票,
|
||||||
|
// commit 後派 IssueInvoiceJob 去綠界開立(不在交易內等 ECPay,避免長交易)。
|
||||||
if (!empty($data['invoice'])) {
|
if (!empty($data['invoice'])) {
|
||||||
$invoiceData = $data['invoice'];
|
$invoiceData = $data['invoice'];
|
||||||
$invoiceData['serial_no'] = $serialNo;
|
$invoiceData['serial_no'] = $serialNo;
|
||||||
$invoiceData['flow_id'] = $order->flow_id;
|
$invoiceData['flow_id'] = $order->flow_id;
|
||||||
$invoiceData['order_id'] = $order->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
|
// 3. Record Dispense Results (B602) - Optional/Multiple
|
||||||
|
|||||||
@ -31,4 +31,11 @@ return [
|
|||||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
'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'),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -961,6 +961,28 @@
|
|||||||
"LINE Official Pay": "Line官方支付",
|
"LINE Official Pay": "Line官方支付",
|
||||||
"LINE Pay": "LINE Pay",
|
"LINE Pay": "LINE Pay",
|
||||||
"LINE Pay (Official)": "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": "LINE Pay 官方直連",
|
||||||
"LINE Pay Direct Settings Description": "LINE Pay 官方直連設定",
|
"LINE Pay Direct Settings Description": "LINE Pay 官方直連設定",
|
||||||
"LINE-ChannelId": "LINE-ChannelId",
|
"LINE-ChannelId": "LINE-ChannelId",
|
||||||
|
|||||||
@ -217,18 +217,15 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-6 whitespace-nowrap">
|
<td class="px-6 py-6 whitespace-nowrap">
|
||||||
@php
|
@php
|
||||||
// 簡單判斷:如果有發票號碼且 rtn_code 為空或 0/1 則視為有效
|
// 以發票狀態機顯示(pending/issued/failed/void);舊資料無 status 時以發票號回推
|
||||||
$status = 'valid';
|
$st = $invoice->status ?: (empty($invoice->invoice_no) ? 'failed' : 'issued');
|
||||||
if (empty($invoice->invoice_no)) $status = 'failed';
|
|
||||||
if ($invoice->rtn_code === 'void') $status = 'void';
|
|
||||||
|
|
||||||
$statusMap = [
|
$statusMap = [
|
||||||
'valid' => ['label' => __('Valid'), 'color' => 'emerald'],
|
'pending' => ['label' => __('Pending'), 'color' => 'amber'],
|
||||||
'void' => ['label' => __('Void'), 'color' => 'rose'],
|
'issued' => ['label' => __('Issued'), 'color' => 'emerald'],
|
||||||
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
|
'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
|
@endphp
|
||||||
<x-status-badge :color="$s['color']" :label="$s['label']" size="xs" />
|
<x-status-badge :color="$s['color']" :label="$s['label']" size="xs" />
|
||||||
</td>
|
</td>
|
||||||
@ -238,16 +235,45 @@
|
|||||||
{{ $invoice->order->order_no ?? '---' }}
|
{{ $invoice->order->order_no ?? '---' }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-6 text-right">
|
<td class="px-6 py-6 text-right" @click.stop>
|
||||||
<button
|
<div class="flex items-center justify-end gap-1.5">
|
||||||
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">
|
@if(in_array($st, ['pending', 'failed']))
|
||||||
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<form method="POST" action="{{ route('admin.sales.invoices.reconcile', $invoice) }}">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
@csrf
|
||||||
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" />
|
<button type="submit" title="{{ __('Query ECPay') }}"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round"
|
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">
|
||||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
{{ __('Query') }}
|
||||||
</svg>
|
</button>
|
||||||
</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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@empty
|
@empty
|
||||||
@ -267,16 +293,14 @@
|
|||||||
<div
|
<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">
|
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
|
@php
|
||||||
$status = 'valid';
|
$st = $invoice->status ?: (empty($invoice->invoice_no) ? 'failed' : 'issued');
|
||||||
if (empty($invoice->invoice_no)) $status = 'failed';
|
|
||||||
if ($invoice->rtn_code === 'void') $status = 'void';
|
|
||||||
$statusMap = [
|
$statusMap = [
|
||||||
'valid' => ['label' => __('Valid'), 'color' => 'emerald'],
|
'pending' => ['label' => __('Pending'), 'color' => 'amber'],
|
||||||
'void' => ['label' => __('Void'), 'color' => 'rose'],
|
'issued' => ['label' => __('Issued'), 'color' => 'emerald'],
|
||||||
'failed' => ['label' => __('Failed'), 'color' => 'rose'],
|
'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
|
@endphp
|
||||||
|
|
||||||
{{-- Card Header --}}
|
{{-- Card Header --}}
|
||||||
@ -344,7 +368,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Action Buttons --}}
|
{{-- Action Buttons --}}
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<button @click="openDetail({{ $invoice->order_id }})"
|
<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">
|
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">
|
<svg class="w-4 h-4 stroke-[2.5]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@ -354,6 +378,35 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ __('View Details') }}
|
{{ __('View Details') }}
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
@empty
|
@empty
|
||||||
|
|||||||
@ -141,7 +141,12 @@ Route::middleware(['auth', 'auth.session', 'verified', 'tenant.access'])->prefix
|
|||||||
// 6. 銷售管理
|
// 6. 銷售管理
|
||||||
Route::prefix('sales')->name('sales.')->group(function () {
|
Route::prefix('sales')->name('sales.')->group(function () {
|
||||||
Route::get('/', [App\Http\Controllers\Admin\SalesController::class, 'index'])->name('index');
|
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::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');
|
Route::post('/pickup-codes', [App\Http\Controllers\Admin\SalesController::class, 'storePickupCode'])->name('pickup-codes.store');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user