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>
184 lines
7.6 KiB
PHP
184 lines
7.6 KiB
PHP
<?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);
|
||
}
|
||
}
|