[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>
This commit is contained in:
sky121113 2026-06-18 13:09:05 +08:00
parent 840052dd5a
commit df3c72e927
13 changed files with 447 additions and 12 deletions

View File

@ -414,7 +414,7 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| 代碼 | 支付方式 | 備註 / 說明 |
| :--- | :--- | :--- |
| `1` | 信用卡 | 一般實體信用卡刷卡 |
| `2` | 電子票證 | 包含悠遊卡、一卡通|
| `2` | 電子票證 | 包含悠遊卡、一卡通、iCash 等(與信用卡、手機支付共用同一台實體刷卡機)|
| `3` | 掃碼支付 | 一般掃碼支付 |
| `4` | 投幣機支付 | 實體紙鈔機 |
| `5` | 通行碼 | 現場維護人員測試使用 |
@ -431,8 +431,10 @@ description: 本技能規範定義了 Star Cloud 系統中所有機台 (IoT) 與
| `34` | TapPay - 全盈+PAY | |
| `40` | 會員驗證取貨商品 | |
| `41` | 員工卡 | RFID / NFC 刷卡 |
| `42` | 取物單 | |
| `50` ~ `54` | 線下付款 + TapPay | `50`=線下+30, `51`=線下+31... `54`=線下+34 |
| `70` | LINE Pay官方直連 | LINE Pay 官方直連,非 TapPay 底下的 `30` |
| `60` | 點數 / 優惠卷全額折抵 | 點數全額折抵出貨 或 優惠卷全額折抵出貨 |
| `61` ~ `69` | 會員 + X | `61`=會員+1, `62`=會員+2... `69`=會員+9 |
| `90` ~ `94` | 會員 + TapPay | `90`=會員+30, `91`=會員+31... `94`=會員+34 |
| `100` | 遠端出貨 | 後台遠端觸發出貨 |

View File

@ -34,6 +34,21 @@ class MachineController extends AdminController
$query->where('company_id', $companyId);
}
// 刷卡機狀態:以彙總/相關子查詢預載,避免列表逐列 N+1
$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' => \App\Models\Machine\MachineLog::select('created_at')
->whereColumn('machine_id', 'machines.id')
->where('type', 'card_terminal')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->latest()
->limit(1)
]);
// 預加載統計資料
$machines = $query->orderBy("last_heartbeat_at", "desc")
->orderBy("id", "desc")

View File

@ -11,11 +11,16 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use App\Jobs\Machine\ProcessStateLog;
use App\Models\Transaction\Order;
use App\Services\Machine\MachineService;
class ProcessTransactionFinalized implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/** 走實體刷卡機(Nexsys)的支付類型1 信用卡、2 電子票證、10 手機感應支付,共用同一台機器。 */
private const CARD_TERMINAL_PAYMENT_TYPES = [1, 2, 10];
protected $serialNo;
protected $payload;
@ -60,6 +65,9 @@ class ProcessTransactionFinalized implements ShouldQueue
'transaction'
);
// 刷卡機健康狀態同步:成功消警 / 失敗寫 card_terminal 日誌
$this->syncCardTerminalStatus($machine, $order);
Log::info("MQTT Transaction [finalize] processed successfully", [
'serial_no' => $this->serialNo,
'order_no' => $order->order_no
@ -89,4 +97,45 @@ class ProcessTransactionFinalized implements ShouldQueue
throw $e;
}
}
/**
* 刷卡機 (信用卡/電子票證/手機支付payment_type 1/2/10) 狀態同步。
* 刷卡機沒有斷線心跳,所以靠交易結果維護燈號:
* - 成功交易 自動消除該機台先前未解決的刷卡機警告,恢復正常。
* - 失敗交易(payment_response NFC 回應碼) 寫一筆 type='card_terminal' warning 日誌
* (warning 級依 MachineLog 既有規則不會觸發 Discord)
*/
private function syncCardTerminalStatus(Machine $machine, Order $order): void
{
if (!in_array((int) $order->payment_type, self::CARD_TERMINAL_PAYMENT_TYPES, true)) {
return;
}
// 刷卡成功 → 消警,恢復刷卡機燈號
if ((int) $order->payment_status === Order::PAYMENT_STATUS_SUCCESS) {
$machine->logs()
->where('type', 'card_terminal')
->where('is_resolved', false)
->update(['is_resolved' => true]);
return;
}
// 刷卡失敗 → 解析 App 端格式 "[電文] | code=XXXX" 取回應碼
if (!preg_match('/\|\s*code=([A-Za-z0-9_]+)/', (string) $order->payment_response, $m)) {
return; // 無刷卡機回應碼(例如顧客未感應就放棄),不視為硬體訊號問題
}
$code = $m[1];
if ($code === '0000') {
return; // 0000 為成功碼,理論上不會走到失敗分支,保險過濾
}
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
'Card payment failed',
'warning',
['card_code' => $code, 'order_no' => $order->order_no],
'card_terminal'
);
}
}

View File

@ -221,6 +221,52 @@ class Machine extends Model
->first()?->created_at;
}
/**
* 刷卡機健康狀態 (基於 type = 'card_terminal' 未解決日誌)
* 信用卡/電子票證/手機支付共用同一台實體刷卡機,刷卡失敗會寫此類日誌,
* 下一次刷卡成功時由 finalize 自動消警恢復正常。
* 列表頁優先使用 MachineController 預載的彙總欄位 (避免 N+1),其餘情境退回即時查詢。
*/
public function getCardTerminalStatusAttribute(): string
{
if (array_key_exists('card_terminal_error_count', $this->attributes)
|| array_key_exists('card_terminal_warning_count', $this->attributes)) {
if (($this->attributes['card_terminal_error_count'] ?? 0) > 0) {
return 'error';
}
if (($this->attributes['card_terminal_warning_count'] ?? 0) > 0) {
return 'warning';
}
return 'normal';
}
if ($this->logs()->where('type', 'card_terminal')->where('level', 'error')->where('is_resolved', false)->exists()) {
return 'error';
}
if ($this->logs()->where('type', 'card_terminal')->where('level', 'warning')->where('is_resolved', false)->exists()) {
return 'warning';
}
return 'normal';
}
/**
* 取得最新一筆未解決的刷卡機日誌時間 (列表頁優先用預載 subquery 欄位,避免 N+1)
*/
public function getLatestCardTerminalLogTimeAttribute()
{
if (array_key_exists('latest_card_terminal_log_at', $this->attributes)) {
$value = $this->attributes['latest_card_terminal_log_at'];
return $value ? \Illuminate\Support\Carbon::parse($value) : null;
}
return $this->logs()
->where('type', 'card_terminal')
->whereIn('level', ['error', 'warning'])
->where('is_resolved', false)
->latest()
->first()?->created_at;
}
/**
* Scope: 判定在線 (讀取 status 欄位)
*/

View File

@ -99,6 +99,13 @@ class MachineLog extends Model
{
$context = $this->context;
// 刷卡機回應碼:用目前代碼表重組失敗原因,支援多語系顯示。
if (isset($context['card_code'])) {
$code = $context['card_code'];
$reason = \App\Services\Machine\MachineService::CARD_TERMINAL_CODE_MAP[$code] ?? 'Transaction failed';
return __('Card payment failed') . '' . __($reason) . " (Code: {$code})";
}
// B013 封裝:優先用目前代碼表重組,讓舊日誌也能吃到最新文案。
if (isset($context['translated_label'])) {
$code = $context['raw_code'] ?? $context['error_code'] ?? '0000';

View File

@ -149,6 +149,37 @@ class MachineService
*/
public const DISPENSE_SUCCESS_CODES = ['0402', '0424'];
/**
* 刷卡機 (Nexsys 終端機 / NCCC 標準) 交易回應碼對照表。
* 來源:聯合信用卡處理中心終端機回應碼規格。
* 用於把 order.payment_response 內的 "| code=XXXX" 解析成可讀(可多語)的失敗原因,
* 寫入 type='card_terminal' 機台日誌。信用卡(1)/電子票證(2)/手機支付(10) 共用同一台實體刷卡機。
* 值為英文翻譯鍵,實際顯示由 lang/*.json 多語系翻譯。
*/
public const CARD_TERMINAL_CODE_MAP = [
'0000' => 'Transaction approved',
'0001' => 'Transaction failed (issuer/card declined)',
'0002' => 'Please contact bank / e-wallet provider',
'0003' => 'Transaction timeout',
'0004' => 'Operation error',
'0005' => 'Communication failure',
'0006' => 'User terminated transaction',
'0009' => 'Non-participating institution card',
'0010' => 'Message format error',
'0011' => 'Not a co-branded card',
'0012' => 'Stored-value amount over limit',
'0013' => 'Card locked',
'0014' => 'Cannot match original stored-value transaction',
'0015' => 'Card auto-load not enabled',
'0016' => 'Insufficient stored-value / e-wallet balance',
'0017' => 'Multiple cards detected',
'0018' => 'Please re-tap card / re-scan / retry',
'0019' => 'Auto-load succeeded but purchase failed',
'1001' => 'Credit card and stored-value settlement both failed',
'1002' => 'Credit card settled, stored-value settlement failed',
'1003' => 'Credit card settlement failed, stored-value settled',
];
/**
* Update machine heartbeat and status.
*

View File

@ -857,7 +857,7 @@ return [
'order.original_amount' => ['type' => 'numeric', 'description' => '(選填) 原始標價金額', 'required' => false],
'order.discount_amount' => ['type' => 'numeric', 'description' => '(選填) 折扣金額,預設 0', 'required' => false],
'order.points_used' => ['type' => 'integer', 'description' => '(選填) 使用點數,預設 0', 'required' => false],
'order.payment_type' => ['type' => 'integer', 'description' => "(必填) 支付類型代碼:\n1:信用卡, 2:悠遊卡/一卡通, 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼, 7:來店禮, 8:問卷, 9:零錢, 10:手機支付(行動支付/NFC手機), 41:員工卡, 42:取物單。\n21~25:線下付款+X (21:線下+1, 22:線下+2, 23:線下+3, 24:線下+4, 25:線下+9)。\n30:LINE Pay, 31:街口, 32:悠遊付, 33:Pi, 34:全盈+\n70:LINE Pay(官方直連,非 TapPay 底下的 30)。\n40:會員驗證取貨商品。\n50~54:線下付款+TapPay (50:線下+30, 51:線下+31, 52:線下+32, 53:線下+33, 54:線下+34)。\n60:點數/優惠卷全額折抵。61~69:會員+X (61:會員+1...69:會員+9)。\n90~94:會員+TapPay (90:會員+30...94:會員+34)。\n100:遠端出貨。"],
'order.payment_type' => ['type' => 'integer', 'description' => "(必填) 支付類型代碼:\n1:信用卡, 2:電子票證(悠遊卡/一卡通/iCash 等), 3:掃碼支付, 4:紙鈔機, 5:通行碼, 6:取貨碼, 7:來店禮, 8:問卷, 9:零錢(硬幣), 10:手機支付(行動支付/NFC 手機感應), 41:員工卡, 42:取物單。\n※ 1(信用卡)、2(電子票證)、10(手機支付) 走同一台實體刷卡機(Nexsys 終端機)。\n21~25:線下付款+基本類型 (21:線下+1 信用卡, 22:線下+2 電子票證, 23:線下+3 掃碼, 24:線下+4 紙鈔, 25:線下+9 零錢)。\n30:LINE Pay(TapPay), 31:街口, 32:悠遊付, 33:Pi 拍錢包, 34:全盈+PAY\n70:LINE Pay(官方直連,非 TapPay 底下的 30)。\n40:會員驗證取貨商品。\n50~54:線下付款+TapPay (50:線下+30, 51:線下+31, 52:線下+32, 53:線下+33, 54:線下+34)。\n60:點數/優惠券全額折抵。61~69:會員+基本類型 (61:會員+1...69:會員+9)。\n90~94:會員+TapPay (90:會員+30...94:會員+34)。\n100:遠端出貨。"],
'order.payment_status' => ['type' => 'integer', 'description' => '(必填) 支付狀態 (1:成功)'],
'order.code_id' => ['type' => 'string', 'description' => '(選填) 閉環勾稽用的代碼 ID (payment_type 為 5, 6, 41 時必填)', 'required' => false],
'order.payment_request' => ['type' => 'string', 'description' => '(選填) 金流請求原始字串', 'required' => false],

View File

@ -12,11 +12,16 @@ return new class extends Migration
*/
public function up(): void
{
// sqlite 為動態型別VARCHAR/TEXT 等價且不支援 MODIFY 語法,僅在 MySQL 執行。
if (DB::getDriverName() !== 'sqlite') {
DB::statement('ALTER TABLE products MODIFY spec TEXT NULL COMMENT \'詳情\'');
}
}
public function down(): void
{
if (DB::getDriverName() !== 'sqlite') {
DB::statement('ALTER TABLE products MODIFY spec VARCHAR(255) NULL COMMENT \'規格\'');
}
}
};

View File

@ -214,6 +214,7 @@
"Authorized Status": "Authorized Status",
"Auto Refresh": "Auto Refresh",
"Auto Replenishment": "Auto Replenishment",
"Auto-load succeeded but purchase failed": "Auto-load succeeded but purchase failed",
"Automatically calculate replenishment needs based on machine capacity": "Automatically calculate replenishment needs based on machine capacity",
"Availability": "Availability",
"Available Machines": "Available Machines",
@ -274,6 +275,7 @@
"Cannot delete model that is currently in use by machines.": "Cannot delete model that is currently in use by machines.",
"Cannot delete role with active users.": "Cannot delete role with active users.",
"Cannot delete warehouse with existing stock": "Cannot delete warehouse with existing stock",
"Cannot match original stored-value transaction": "Cannot match original stored-value transaction",
"Card Machine System": "Card Machine System",
"Card Payment": "Card Payment",
"Card Reader": "Card Reader",
@ -284,8 +286,13 @@
"Card Side Background": "Card Side Background",
"Card System": "Card System",
"Card Terminal": "Card Terminal",
"Card Terminal Logs": "Card Terminal Logs",
"Card Terminal Status": "Card Terminal Status",
"Card Terminal System": "Card Terminal System",
"Card UID": "Card UID",
"Card auto-load not enabled": "Card auto-load not enabled",
"Card locked": "Card locked",
"Card payment failed": "Card payment failed",
"Cash Module": "Cash Module",
"Cash Module Feature": "Cash Module Feature",
"Cash Module Function": "Cash Module Function",
@ -342,6 +349,7 @@
"Command has been queued successfully.": "Command has been queued successfully.",
"Command not found": "Command not found",
"Command queued successfully.": "Command queued successfully.",
"Communication failure": "Communication failure",
"Company": "Company",
"Company Code": "Company Code",
"Company Discord notification settings updated successfully.": "Company Discord notification settings updated successfully.",
@ -440,6 +448,9 @@
"Credit Card": "Credit Card",
"Credit Card / Contactless": "Credit Card / Contactless",
"Credit Card Payment": "Credit Card Payment",
"Credit card and stored-value settlement both failed": "Credit card and stored-value settlement both failed",
"Credit card settled, stored-value settlement failed": "Credit card settled, stored-value settlement failed",
"Credit card settlement failed, stored-value settled": "Credit card settlement failed, stored-value settled",
"Critical": "Critical",
"Critical failures requiring immediate hands-on attention.": "Critical failures requiring immediate hands-on attention.",
"Cross-Company / Headquarter Global Stats": "Cross-Company / Headquarter Global Stats",
@ -941,6 +952,7 @@
"Installation": "Installation",
"Installation Location": "Installation Location",
"Insufficient stock for transfer": "Insufficient stock for transfer",
"Insufficient stored-value / e-wallet balance": "Insufficient stored-value / e-wallet balance",
"Invalid personnel selection": "Invalid personnel selection",
"Invalid status transition": "Invalid status transition",
"Inventory": "Inventory",
@ -1202,6 +1214,7 @@
"Message": "Message",
"Message Content": "Message Content",
"Message Display": "Message Display",
"Message format error": "Message format error",
"Microwave delivery door close error": "Microwave delivery door close error",
"Microwave delivery door close error (0211)": "Microwave delivery door close error (0211)",
"Microwave delivery door closing": "Microwave delivery door closing",
@ -1245,6 +1258,7 @@
"Movement History": "Movement History",
"Movement Logs": "Movement Logs",
"Multilingual Names": "Multilingual Names",
"Multiple cards detected": "Multiple cards detected",
"Must be strictly greater than previous version": "Must be strictly greater than previous version",
"N/A": "N/A",
"Name": "Name",
@ -1344,11 +1358,13 @@
"No versions found": "No versions found",
"No warehouses found": "No warehouses found",
"No welcome gifts found": "No welcome gifts found",
"Non-participating institution card": "Non-participating institution card",
"None": "None",
"Normal": "Normal",
"Not Configured": "Not Configured",
"Not Used": "Not Used",
"Not Used Description": "Not Used Description",
"Not a co-branded card": "Not a co-branded card",
"Note": "Note",
"Note (optional)": "Note (optional)",
"Notes": "Notes",
@ -1403,6 +1419,7 @@
"Operation Logs": "Operation Logs",
"Operation Note": "Operation Note",
"Operation Records": "Operation Records",
"Operation error": "Operation error",
"Operation failed": "Operation failed",
"Operational Parameters": "Operational Parameters",
"Operations": "Operations",
@ -1548,6 +1565,7 @@
"Please check the following errors:": "Please check the following errors:",
"Please check the form for errors.": "Please check the form for errors.",
"Please confirm the details below": "Please confirm the details below",
"Please contact bank / e-wallet provider": "Please contact bank / e-wallet provider",
"Please enter Webhook URL first.": "Please enter Webhook URL first.",
"Please enter a valid ECPay e-invoice notification email.": "Please enter a valid ECPay e-invoice notification email.",
"Please enter address first": "Please enter address first",
@ -1556,6 +1574,7 @@
"Please enter version name": "Please enter version name",
"Please make sure to enable \"Background graphics\" in your browser print settings to print product images and styled components correctly.": "Please make sure to enable \"Background graphics\" in your browser print settings to print product images and styled components correctly.",
"Please provide a download URL": "Please provide a download URL",
"Please re-tap card / re-scan / retry": "Please re-tap card / re-scan / retry",
"Please remove bento from microwave": "Please remove bento from microwave",
"Please select a company": "Please select a company",
"Please select a company.": "Please select a company.",
@ -2017,6 +2036,7 @@
"Store ID": "Store ID",
"Store Management": "Store Management",
"StoreID": "StoreID",
"Stored-value amount over limit": "Stored-value amount over limit",
"Sub / Card / Scan": "Sub / Card / Scan",
"Sub Account Management": "Sub Account Management",
"Sub Account Roles": "Sub Account Roles",
@ -2168,7 +2188,11 @@
"Transaction Info": "Transaction Info",
"Transaction Orders": "Transaction Orders",
"Transaction Time": "Transaction Time",
"Transaction approved": "Transaction approved",
"Transaction failed": "Transaction failed",
"Transaction failed (issuer/card declined)": "Transaction failed (issuer/card declined)",
"Transaction processed: :id": "Transaction processed: :id",
"Transaction timeout": "Transaction timeout",
"Transfer Audit": "Transfer Audit",
"Transfer Details": "Transfer Details",
"Transfer In": "Transfer In",
@ -2258,6 +2282,7 @@
"User": "User",
"User Info": "User Info",
"User logged in: :name": "User logged in: :name",
"User terminated transaction": "User terminated transaction",
"Username": "Username",
"Users": "Users",
"Utilization Rate": "Utilization Rate",

View File

@ -214,6 +214,7 @@
"Authorized Status": "承認ステータス",
"Auto Refresh": "自動更新",
"Auto Replenishment": "一括補充",
"Auto-load succeeded but purchase failed": "オートチャージ成功、但し購買取引失敗",
"Automatically calculate replenishment needs based on machine capacity": "機器容量に基づいて補充必要量を自動計算",
"Availability": "可用性 (Availability)",
"Available Machines": "割り当て可能な機器",
@ -274,6 +275,7 @@
"Cannot delete model that is currently in use by machines.": "機器で使用中のモデルは削除できません。",
"Cannot delete role with active users.": "ユーザーが紐付けられているロールは削除できません。",
"Cannot delete warehouse with existing stock": "在庫がある倉庫は削除できません",
"Cannot match original stored-value transaction": "元の電子マネー取引と照合できません",
"Card Machine System": "カードリーダーシステム",
"Card Payment": "カード決済",
"Card Reader": "カードリーダー",
@ -284,8 +286,13 @@
"Card Side Background": "カード側背景画像",
"Card System": "カードシステム",
"Card Terminal": "カード端末",
"Card Terminal Logs": "カード端末ログ",
"Card Terminal Status": "カード端末ステータス",
"Card Terminal System": "カードリーダーシステム",
"Card UID": "カードUID",
"Card auto-load not enabled": "カードのオートチャージが未設定です",
"Card locked": "カードがロックされています",
"Card payment failed": "カード決済失敗",
"Cash Module": "現金モジュール",
"Cash Module Feature": "現金モジュール機能",
"Cash Module Function": "現金モジュール機能",
@ -342,6 +349,7 @@
"Command has been queued successfully.": "コマンドがキューに追加されました。",
"Command not found": "コマンドが見つかりません",
"Command queued successfully.": "コマンドが正常にキューされました。",
"Communication failure": "通信エラー",
"Company": "顧客企業",
"Company Code": "会社コード",
"Company Discord notification settings updated successfully.": "会社の Discord 通知設定が正常に更新されました。",
@ -440,6 +448,9 @@
"Credit Card": "クレジットカード",
"Credit Card / Contactless": "クレジットカード/非接触決済対応",
"Credit Card Payment": "クレジットカード決済",
"Credit card and stored-value settlement both failed": "クレジットカード・電子マネーともに精算失敗",
"Credit card settled, stored-value settlement failed": "クレジットカード精算成功、電子マネー精算失敗",
"Credit card settlement failed, stored-value settled": "クレジットカード精算失敗、電子マネー精算成功",
"Critical": "深刻",
"Critical failures requiring immediate hands-on attention.": "即座に対処が必要な重大な障害。",
"Cross-Company / Headquarter Global Stats": "複数会社間 / 本部グローバル統計",
@ -941,6 +952,7 @@
"Installation": "設置",
"Installation Location": "設置場所",
"Insufficient stock for transfer": "在庫不足のため移動できません",
"Insufficient stored-value / e-wallet balance": "電子マネー/電子ウォレット残高不足",
"Invalid personnel selection": "無効な担当者選択",
"Invalid status transition": "無効なステータス遷移",
"Inventory": "在庫概要",
@ -1202,6 +1214,7 @@
"Message": "メッセージ",
"Message Content": "メッセージ内容",
"Message Display": "メッセージ表示",
"Message format error": "電文/フォーマットエラー",
"Microwave delivery door close error": "電子レンジ搬入口扉閉鎖エラー",
"Microwave delivery door close error (0211)": "電子レンジ搬入口扉閉エラー (0211)",
"Microwave delivery door closing": "電子レンジ搬入口扉閉鎖中",
@ -1245,6 +1258,7 @@
"Movement History": "変動履歴",
"Movement Logs": "変動ログ",
"Multilingual Names": "多言語名",
"Multiple cards detected": "複数カードの読み取り失敗",
"Must be strictly greater than previous version": "Must be strictly greater than previous version",
"N/A": "なし",
"Name": "名前",
@ -1344,11 +1358,13 @@
"No versions found": "No versions found",
"No warehouses found": "倉庫が見つかりません",
"No welcome gifts found": "ウェルカムギフトが見つかりません",
"Non-participating institution card": "非参加機関のカード",
"None": "なし",
"Normal": "正常",
"Not Configured": "未設定",
"Not Used": "使用しない",
"Not Used Description": "サードパーティ決済を使用しない",
"Not a co-branded card": "提携カードではありません",
"Note": "備考",
"Note (optional)": "備考 (任意)",
"Notes": "備考",
@ -1403,6 +1419,7 @@
"Operation Logs": "Operation Logs",
"Operation Note": "操作メモ",
"Operation Records": "操作記録",
"Operation error": "操作エラー",
"Operation failed": "操作失敗",
"Operational Parameters": "運用パラメータ",
"Operations": "運用設定",
@ -1548,6 +1565,7 @@
"Please check the following errors:": "以下のエラーを確認してください:",
"Please check the form for errors.": "フォームのエラーを確認してください。",
"Please confirm the details below": "以下の詳細を確認してください",
"Please contact bank / e-wallet provider": "銀行/電子マネー事業者へお問い合わせください",
"Please enter Webhook URL first.": "最初にWebhook URLを入力してください。",
"Please enter a valid ECPay e-invoice notification email.": "有効な ECPay 電子インボイス通知用メールアドレスを入力してください。",
"Please enter address first": "先に住所を入力してください",
@ -1556,6 +1574,7 @@
"Please enter version name": "Please enter version name",
"Please make sure to enable \"Background graphics\" in your browser print settings to print product images and styled components correctly.": "商品の画像やスタイル付きコンポーネントを正しく印刷するために、ブラウザの印刷設定で『背景のグラフィック』を有効にしてください。",
"Please provide a download URL": "Please provide a download URL",
"Please re-tap card / re-scan / retry": "カードを再タッチ/再スキャン/再試行してください",
"Please remove bento from microwave": "電子レンジ内の弁当を取り出してください",
"Please select a company": "会社を選択してください",
"Please select a company.": "会社を選択してください。",
@ -2017,6 +2036,7 @@
"Store ID": "店舗ID (Store ID)",
"Store Management": "店舗管理",
"StoreID": "店舗ID",
"Stored-value amount over limit": "電子マネー金額が上限超過",
"Sub / Card / Scan": "下位機 / カードリーダー / スキャナー",
"Sub Account Management": "サブアカウント管理",
"Sub Account Roles": "サブアカウントロール",
@ -2168,7 +2188,11 @@
"Transaction Info": "取引情報",
"Transaction Orders": "取引注文",
"Transaction Time": "取引時間",
"Transaction approved": "取引成功",
"Transaction failed": "取引失敗",
"Transaction failed (issuer/card declined)": "取引失敗(ホストまたはカード拒否)",
"Transaction processed: :id": "取引処理完了: :id",
"Transaction timeout": "取引タイムアウト",
"Transfer Audit": "移動伝票監査",
"Transfer Details": "移動伝票詳細",
"Transfer In": "転入",
@ -2258,6 +2282,7 @@
"User": "一般ユーザー",
"User Info": "ユーザー情報",
"User logged in: :name": "ユーザーがログインしました: :name",
"User terminated transaction": "ユーザーが取引を中止しました",
"Username": "ユーザー名",
"Users": "アカウント数",
"Utilization Rate": "機器稼働率",

View File

@ -214,6 +214,7 @@
"Authorized Status": "已授權",
"Auto Refresh": "自動更新",
"Auto Replenishment": "自動補貨單",
"Auto-load succeeded but purchase failed": "自動加值成功,但購貨交易失敗",
"Automatically calculate replenishment needs based on machine capacity": "根據機台容量自動計算補貨需求",
"Availability": "可用性 (Availability)",
"Available Machines": "可供分配的機台",
@ -274,6 +275,7 @@
"Cannot delete model that is currently in use by machines.": "無法刪除目前正在被機台使用的型號。",
"Cannot delete role with active users.": "無法刪除已有綁定帳號的角色。",
"Cannot delete warehouse with existing stock": "無法刪除仍有庫存的倉庫",
"Cannot match original stored-value transaction": "無法比對原始電票交易",
"Card Machine System": "刷卡機系統",
"Card Payment": "卡片支付",
"Card Reader": "刷卡機",
@ -284,8 +286,13 @@
"Card Side Background": "左側卡片背景圖",
"Card System": "刷卡機系統",
"Card Terminal": "刷卡機端",
"Card Terminal Logs": "刷卡機日誌",
"Card Terminal Status": "刷卡機狀態",
"Card Terminal System": "刷卡機系統",
"Card UID": "卡片 UID",
"Card auto-load not enabled": "票卡未開啟自動加值",
"Card locked": "票卡已鎖",
"Card payment failed": "刷卡失敗",
"Cash Module": "現金模組",
"Cash Module Feature": "現金模組功能",
"Cash Module Function": "現金模組功能",
@ -342,6 +349,7 @@
"Command has been queued successfully.": "指令已成功排入佇列。",
"Command not found": "找不到指令",
"Command queued successfully.": "指令已加入佇列。",
"Communication failure": "通訊失敗",
"Company": "客戶公司",
"Company Code": "公司代碼",
"Company Discord notification settings updated successfully.": "公司 Discord 通知設定已成功更新。",
@ -440,6 +448,9 @@
"Credit Card": "信用卡",
"Credit Card / Contactless": "支援信用卡 / 感應支付",
"Credit Card Payment": "信用卡支付",
"Credit card and stored-value settlement both failed": "信用卡及電票皆結帳失敗",
"Credit card settled, stored-value settlement failed": "信用卡結帳成功,電票結帳失敗",
"Credit card settlement failed, stored-value settled": "信用卡結帳失敗,電票結帳成功",
"Critical": "嚴重",
"Critical failures requiring immediate hands-on attention.": "需要立即處理的關鍵故障。",
"Cross-Company / Headquarter Global Stats": "跨公司 / 總部全域統計",
@ -941,6 +952,7 @@
"Installation": "裝機",
"Installation Location": "安裝位置",
"Insufficient stock for transfer": "庫存不足,無法調撥",
"Insufficient stored-value / e-wallet balance": "電票/電子錢包餘額不足",
"Invalid personnel selection": "無效的人員選擇",
"Invalid status transition": "無效的狀態轉換",
"Inventory": "庫存概覽",
@ -1202,6 +1214,7 @@
"Message": "訊息",
"Message Content": "日誌內容",
"Message Display": "訊息顯示",
"Message format error": "電文/格式錯誤",
"Microwave delivery door close error": "微波爐進貨口門關閉錯誤",
"Microwave delivery door close error (0211)": "微波爐進貨口門關閉錯誤",
"Microwave delivery door closing": "正在關閉微波爐進貨口門",
@ -1245,6 +1258,7 @@
"Movement History": "異動紀錄",
"Movement Logs": "異動紀錄",
"Multilingual Names": "多語系名稱",
"Multiple cards detected": "多張票卡讀取失敗",
"Must be strictly greater than previous version": "必須嚴格大於上一個版本代碼",
"N/A": "不適用",
"Name": "名稱",
@ -1344,11 +1358,13 @@
"No versions found": "未找到任何版本",
"No warehouses found": "未找到倉庫",
"No welcome gifts found": "找不到來店禮資料",
"Non-participating institution card": "非參加機構卡片",
"None": "無",
"Normal": "正常",
"Not Configured": "尚未設定",
"Not Used": "不使用",
"Not Used Description": "不使用第三方支付介接",
"Not a co-branded card": "非聯名卡",
"Note": "備註",
"Note (optional)": "備註 (選填)",
"Notes": "備註",
@ -1403,6 +1419,7 @@
"Operation Logs": "操作紀錄",
"Operation Note": "操作備註",
"Operation Records": "操作紀錄",
"Operation error": "操作錯誤",
"Operation failed": "操作失敗",
"Operational Parameters": "運作參數",
"Operations": "運作設定",
@ -1548,6 +1565,7 @@
"Please check the following errors:": "請檢查以下錯誤:",
"Please check the form for errors.": "請檢查欄位內容是否正確。",
"Please confirm the details below": "請確認以下詳細資訊",
"Please contact bank / e-wallet provider": "請連絡銀行/電子錢包業者",
"Please enter Webhook URL first.": "請先輸入 Webhook 網址。",
"Please enter a valid ECPay e-invoice notification email.": "請輸入有效的綠界電子發票通知 Email。",
"Please enter address first": "請先輸入地址",
@ -1556,6 +1574,7 @@
"Please enter version name": "請輸入版本名稱",
"Please make sure to enable \"Background graphics\" in your browser print settings to print product images and styled components correctly.": "請確保在瀏覽器列印設定中啟用「背景圖形」,以便正確列印商品圖片和樣式元件。",
"Please provide a download URL": "請提供下載連結",
"Please re-tap card / re-scan / retry": "請重新感應卡片/重新掃碼/重試交易",
"Please remove bento from microwave": "請取出微波爐內盒飯",
"Please select a company": "請選擇公司",
"Please select a company.": "請選擇公司",
@ -2017,6 +2036,7 @@
"Store ID": "商店代號",
"Store Management": "店家管理",
"StoreID": "商店代號 (StoreID)",
"Stored-value amount over limit": "電票金額超過上限",
"Sub / Card / Scan": "下位機 / 刷卡機 / 掃碼機",
"Sub Account Management": "子帳號管理",
"Sub Account Roles": "子帳號角色",
@ -2168,7 +2188,11 @@
"Transaction Info": "交易資訊",
"Transaction Orders": "交易訂單",
"Transaction Time": "交易時間",
"Transaction approved": "交易成功",
"Transaction failed": "交易失敗",
"Transaction failed (issuer/card declined)": "交易失敗(主機或卡片拒絕)",
"Transaction processed: :id": "交易處理完成::id",
"Transaction timeout": "交易逾時",
"Transfer Audit": "調撥單",
"Transfer Details": "調撥單詳情",
"Transfer In": "轉入",
@ -2258,6 +2282,7 @@
"User": "一般用戶",
"User Info": "用戶資訊",
"User logged in: :name": "使用者登入::name",
"User terminated transaction": "使用者終止交易",
"Username": "使用者帳號",
"Users": "帳號數",
"Utilization Rate": "機台稼動率",

View File

@ -641,8 +641,9 @@
<td class="px-6 py-6 text-center">
@php
$hStatus = $machine->hardware_status;
$ctStatus = $machine->card_terminal_status;
@endphp
<div class="flex items-center justify-center">
<div class="flex items-center justify-center gap-3">
{{-- 下位機 --}}
<div class="flex items-center gap-1.5 tooltip" title="{{ __('Sub-machine Status') }}">
<span class="w-1.5 h-1.5 rounded-full {{
@ -659,6 +660,22 @@
@endif
</span>
</div>
{{-- 刷卡機 (信用卡/電子票證/手機支付共用同一台) --}}
<div class="flex items-center gap-1.5 tooltip" title="{{ __('Card Terminal Status') }}">
<span class="w-1.5 h-1.5 rounded-full {{
$ctStatus === 'error' ? 'bg-rose-500 animate-pulse' :
($ctStatus === 'warning' ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500/50')
}}"></span>
<span class="text-xs font-black {{
$ctStatus === 'error' ? 'text-rose-500' :
($ctStatus === 'warning' ? 'text-amber-500' : 'text-slate-400')
}} uppercase tracking-widest">
{{ __('Card Terminal') }}
@if($machine->latest_card_terminal_log_time && $ctStatus !== 'normal')
<span class="text-[9px] lowercase opacity-70 block -mt-0.5 ml-3 font-bold">{{ $machine->latest_card_terminal_log_time->diffForHumans() }}</span>
@endif
</span>
</div>
</div>
</td>
<td
@ -1011,6 +1028,11 @@
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
{{ __('Sub-machine Status Request') }}
</button>
<button @click="activeTab = 'card_terminal'"
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'card_terminal', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'card_terminal'}"
class="whitespace-nowrap py-4 px-1 border-b-2 font-bold text-[13px] sm:text-sm transition duration-300">
{{ __('Card Terminal Logs') }}
</button>
<button @click="activeTab = 'ambient_temp'"
x-show="currentMachineAmbientTempEnabled"
:class="{'border-cyan-500 text-cyan-600 dark:text-cyan-400': activeTab === 'ambient_temp', 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 dark:hover:text-slate-300': activeTab !== 'ambient_temp'}"

View File

@ -0,0 +1,183 @@
<?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);
}
}