star-cloud/tests/Feature/CardTerminalStatusTest.php
sky121113 df3c72e927 [FEAT] 機台刷卡機 (Nexsys 終端機) 健康狀態燈號與失敗日誌
1. 刷卡機共用判定:信用卡(1)、電子票證(2)、手機支付(10) 走同一台實體刷卡機,以交易結果維護燈號(刷卡機無斷線心跳)。
2. ProcessTransactionFinalized 新增 syncCardTerminalStatus:成功交易自動消除該機台未解決的 card_terminal 警告;失敗交易解析 payment_response 的 "| code=XXXX" 回應碼,寫一筆 type='card_terminal' 的 warning 日誌(warning 級不觸發 Discord)。
3. MachineService 新增 CARD_TERMINAL_CODE_MAP(NCCC/聯卡中心回應碼對照,值為英文翻譯鍵);MachineLog 依 card_code 重組多語可讀失敗原因。
4. Machine 模型新增 card_terminal_status 與 latest_card_terminal_log_time 取值器;MachineController 列表以 withCount + 相關子查詢預載彙總,避免 N+1。
5. 機台列表頁新增「刷卡機」狀態燈號(綠/琥珀/紅 + 最近異常時間)與「刷卡機日誌」分頁。
6. 文件更新:config/api-docs.php 與 api-technical-specs SKILL.md 補充 payment_type 說明(2/10 與 1 共用同一台刷卡機、補列 42 取物單與 100 遠端出貨)。
7. 既有 migration change_products_spec_to_text 加 sqlite 守衛,讓測試環境可跑(VARCHAR/TEXT 在 sqlite 等價且不支援 MODIFY)。
8. 新增 CardTerminalStatusTest 功能測試(18 斷言通過,涵蓋成功消警、失敗寫日誌、掃碼排除、多語訊息、N+1 預載路徑)。
9. 新增 25 組三語系 key(刷卡機狀態與回應碼),zh_TW/en/ja 對齊並 ksort 排序。

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

184 lines
7.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Tests\Feature;
use App\Jobs\Transaction\ProcessTransactionFinalized;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use App\Services\Machine\MqttService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 刷卡機 (Nexsys 終端機) 交易訊號 → 後台機台日誌/燈號 (Plan A) 測試。
* 涵蓋 payment_type {1 信用卡, 2 電子票證, 10 手機支付} 共用同一台實體刷卡機。
*/
class CardTerminalStatusTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Job handle 內注入 MqttService 發 ack測試環境以 mock 取代避免真的連 MQTT
$this->mock(MqttService::class, function ($m) {
$m->shouldReceive('pushCommand')->andReturnTrue();
});
}
/** 組裝一筆 finalize payload */
private function finalizePayload(string $serialNo, string $flowId, int $paymentType, int $paymentStatus, ?string $paymentResponse, string $status): array
{
return [
'action' => 'finalize',
'flow_id' => $flowId,
'payment_type' => $paymentType,
'serial_no' => $serialNo,
'order' => [
'flow_id' => $flowId,
'order_no' => $flowId,
'total_amount' => 59,
'pay_amount' => 59,
'payment_type' => $paymentType,
'payment_status' => $paymentStatus,
'payment_response' => $paymentResponse,
'status' => $status,
'items' => [],
],
];
}
public function test_failed_credit_card_writes_card_terminal_warning_log(): void
{
$machine = Machine::factory()->create(['last_heartbeat_at' => now()]);
ProcessTransactionFinalized::dispatchSync(
$machine->serial_no,
$this->finalizePayload($machine->serial_no, 'FLOW-CC-FAIL', 1, 0, 'RAWMSG... | code=0003', 'failed')
);
$log = MachineLog::where('machine_id', $machine->id)->where('type', 'card_terminal')->first();
$this->assertNotNull($log, '應寫入一筆 card_terminal 日誌');
$this->assertEquals('warning', $log->level, 'card_terminal 應為 warning 級 (不觸發 Discord)');
$this->assertEquals('0003', $log->context['card_code']);
// 燈號 (fallback 即時查詢路徑)
$this->assertEquals('warning', $machine->fresh()->card_terminal_status);
}
public function test_failed_eticket_and_mobile_pay_also_write_log(): void
{
foreach ([2 => 'FLOW-ET', 10 => 'FLOW-MO'] as $type => $flow) {
$machine = Machine::factory()->create(['last_heartbeat_at' => now()]);
ProcessTransactionFinalized::dispatchSync(
$machine->serial_no,
$this->finalizePayload($machine->serial_no, $flow, $type, 0, ' | code=0005', 'failed')
);
$this->assertEquals('warning', $machine->fresh()->card_terminal_status, "payment_type={$type} 應納入刷卡機燈號");
}
}
public function test_scan_payment_failure_is_excluded(): void
{
$machine = Machine::factory()->create(['last_heartbeat_at' => now()]);
// payment_type=3 掃碼支付 (走網路,非實體刷卡機) → 不應寫 card_terminal 日誌
ProcessTransactionFinalized::dispatchSync(
$machine->serial_no,
$this->finalizePayload($machine->serial_no, 'FLOW-SCAN', 3, 0, ' | code=scan_failed', 'failed')
);
$this->assertEquals(0, MachineLog::where('machine_id', $machine->id)->where('type', 'card_terminal')->count());
$this->assertEquals('normal', $machine->fresh()->card_terminal_status);
}
public function test_successful_transaction_auto_resolves_prior_warnings(): void
{
$machine = Machine::factory()->create(['last_heartbeat_at' => now()]);
// 先製造一筆未解決的刷卡機警告
ProcessTransactionFinalized::dispatchSync(
$machine->serial_no,
$this->finalizePayload($machine->serial_no, 'FLOW-FAIL', 1, 0, ' | code=0003', 'failed')
);
$this->assertEquals('warning', $machine->fresh()->card_terminal_status);
// 下一筆刷卡成功 → 應自動消警,燈號恢復 normal
ProcessTransactionFinalized::dispatchSync(
$machine->serial_no,
$this->finalizePayload($machine->serial_no, 'FLOW-OK', 1, 1, 'RAWMSG-SUCCESS', 'completed')
);
$this->assertEquals('normal', $machine->fresh()->card_terminal_status, '刷卡成功後應自動消警');
$unresolved = MachineLog::where('machine_id', $machine->id)
->where('type', 'card_terminal')->where('is_resolved', false)->count();
$this->assertEquals(0, $unresolved);
}
public function test_failure_without_response_code_does_not_log(): void
{
$machine = Machine::factory()->create(['last_heartbeat_at' => now()]);
// 失敗但沒有刷卡機回應碼 (例如顧客未感應就放棄) → 不視為硬體訊號問題
ProcessTransactionFinalized::dispatchSync(
$machine->serial_no,
$this->finalizePayload($machine->serial_no, 'FLOW-NOCODE', 1, 0, 'JUSTRAWNOCODE', 'abandoned')
);
$this->assertEquals(0, MachineLog::where('machine_id', $machine->id)->where('type', 'card_terminal')->count());
}
public function test_translated_message_renders_official_chinese(): void
{
app()->setLocale('zh_TW');
$machine = Machine::factory()->create();
$log = MachineLog::create([
'machine_id' => $machine->id,
'type' => 'card_terminal',
'level' => 'warning',
'message' => 'Card payment failed',
'context' => ['card_code' => '0003', 'order_no' => 'X'],
]);
$this->assertStringContainsString('刷卡失敗', $log->translated_message);
$this->assertStringContainsString('交易逾時', $log->translated_message);
$this->assertStringContainsString('0003', $log->translated_message);
}
public function test_preloaded_aggregate_path_avoids_n_plus_one(): void
{
$machine = Machine::factory()->create();
MachineLog::create([
'machine_id' => $machine->id,
'type' => 'card_terminal',
'level' => 'warning',
'message' => 'Card payment failed',
'context' => ['card_code' => '0003'],
]);
// 模擬 MachineController index 的預載 (withCount + 相關子查詢)
$loaded = Machine::query()
->select('machines.*')
->withCount([
'logs as card_terminal_error_count' => fn($q) => $q->where('type', 'card_terminal')->where('level', 'error')->where('is_resolved', false),
'logs as card_terminal_warning_count' => fn($q) => $q->where('type', 'card_terminal')->where('level', 'warning')->where('is_resolved', false),
])
->addSelect(['latest_card_terminal_log_at' => MachineLog::select('created_at')
->whereColumn('machine_id', 'machines.id')
->where('type', 'card_terminal')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->latest()
->limit(1)
])
->find($machine->id);
$this->assertArrayHasKey('card_terminal_warning_count', $loaded->getAttributes());
$this->assertEquals('warning', $loaded->card_terminal_status);
$this->assertNotNull($loaded->latest_card_terminal_log_time);
}
}