1. Machine 新增 show_card_terminal 取值器:僅 shopping_mode=basic 且 card_terminal_enabled 的機台才呈現刷卡機燈號與日誌分頁(購物車模式或未啟用刷卡機者隱藏)。 2. 機台列表頁:刷卡機狀態燈號與「刷卡機日誌」分頁改以 show_card_terminal 控制顯示;openLogPanel 多帶 cardTerminalEnabled 參數,分頁不可見時自動退回機台狀態分頁。 3. 列表狀態欄文案調整:欄位標頭由「Sub-machine Status」改為「Hardware Status」,下位機燈號標籤改顯示「Sub-machine」,與新增的刷卡機燈號並列更清楚。 4. 新增 migration:machine_logs 加複合索引 (machine_id, type, is_resolved, level),加速列表頁逐台的狀態未解決日誌查詢(正式環境資料量大,建議離峰執行)。 5. CardTerminalStatusTest 新增 show_card_terminal 顯示條件測試(基礎版啟用才顯示、未啟用或非基礎版皆不顯示)。 6. 新增三語系 key(Hardware Status、Sub-machine),zh_TW/en/ja 對齊並 ksort 排序。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
210 lines
8.6 KiB
PHP
210 lines
8.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_show_card_terminal_only_when_basic_and_enabled(): void
|
||
{
|
||
// 基礎版 + 刷卡機啟用 → 顯示
|
||
$m1 = Machine::factory()->create();
|
||
$m1->shopping_mode = 'basic';
|
||
$m1->card_terminal_enabled = true;
|
||
$m1->save();
|
||
$this->assertTrue($m1->fresh()->show_card_terminal);
|
||
|
||
// 基礎版 + 刷卡機未啟用 → 不顯示
|
||
$m2 = Machine::factory()->create();
|
||
$m2->shopping_mode = 'basic';
|
||
$m2->card_terminal_enabled = false;
|
||
$m2->save();
|
||
$this->assertFalse($m2->fresh()->show_card_terminal);
|
||
|
||
// 非基礎版(員工卡=晟崴 / 取物單=中國醫) → 無支付設備,不顯示
|
||
foreach (['employee_card', 'pickup_sheet'] as $mode) {
|
||
$m = Machine::factory()->create();
|
||
$m->shopping_mode = $mode;
|
||
$m->card_terminal_enabled = true; // 即便旗標誤開,非 basic 也不顯示
|
||
$m->save();
|
||
$this->assertFalse($m->fresh()->show_card_terminal, "shopping_mode={$mode} 不應顯示刷卡機");
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|