star-cloud/app/Http/Controllers/Api/V1/App/MachineController.php
sky121113 f59efc6dc7 [FIX] 補齊與優化來店禮、通行碼、取貨碼之多語系與介面欄位呈現
1. 移除非必要之 Log 備註:從來店禮使用記錄 (Logs) 的電腦版與行動版畫面中,徹底移除了無業務參考價值的技術備註 (Remark) 欄位,優化排版空間以符合極簡奢華風 UI 設計。
2. 補齊與對齊三語系檔案:補齊來店禮、通行碼與商品領取所缺少的多語系 Key,三個語系 JSON 檔 (zh_TW, en, ja) 的 Key 與行數已 100% 完美對齊 (共 2,255 行) 且完成升冪字母排序 (ksort)。
3. 簡化 Guest 票券大標題:簡化 Guest 端三種票券頁面 (通行碼、取貨碼、來店禮) 的大標題多語系拼接結構,改為單一乾淨的翻譯 Key 呼叫。
4. 口語化翻譯微調:將 Pickup Code 在語系檔中的中文翻譯由「領取碼」微調為更口語、直覺的「取貨碼」。
5. 優化 Guest 頁面欄位:將取貨碼頁面的「機台序號」替換為更具溫度的「機台名稱」;通行碼與來店禮頁面則徹底隱藏機台序號,優化用戶體驗。
2026-05-22 14:46:26 +08:00

716 lines
25 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 App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Models\System\User;
use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessCoinInventory;
use App\Jobs\Machine\ProcessMachineError;
use App\Jobs\Machine\ProcessStateLog;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Cache;
use App\Models\Machine\MachineStockMovement;
use App\Models\Transaction\PickupCode;
use App\Models\Transaction\PickupCodeLog;
use App\Models\Transaction\PassCode;
use App\Models\StaffCard;
use App\Models\StaffCardLog;
use App\Models\Transaction\PassCodeLog;
use App\Models\Transaction\WelcomeGift;
use App\Models\Transaction\WelcomeGiftLog;
use App\Models\System\SystemOperationLog;
use App\Services\Machine\MachineService;
use App\Services\Product\ProductCatalogService;
class MachineController extends Controller
{
/**
* B055: Get Remote Dispense Queue (POST/GET)
* 用於獲取雲端下發的遠端出貨指令詳情。
*/
public function getDispenseQueue(Request $request)
{
$machine = $request->get('machine');
// 查找該機台狀態為「已發送 (sent)」且類型為「出貨 (dispense)」的最新指令
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'dispense')
->where('status', 'sent')
->latest()
->first();
if (!$command) {
return response()->json([
'success' => true,
'code' => 200,
'data' => []
]);
}
// 映射 Java APP 預期格式: res1 = ID, res2 = SlotNo
return response()->json([
'success' => true,
'code' => 200,
'data' => [
[
'res1' => (string) $command->id,
'res2' => (string) ($command->payload['slot_no'] ?? ''),
]
]
]);
}
/**
* B055: Report Remote Dispense Result (PUT)
* 用於機台回報遠端出貨執行的最終結果。
*/
public function reportDispenseResult(Request $request, \App\Services\Machine\MachineService $machineService)
{
$commandId = $request->input('id');
$type = $request->input('type'); // 通常為 0
$stockReported = $request->input('stock'); // 機台回報的剩餘庫存
$command = \App\Models\Machine\RemoteCommand::find($commandId);
if (!$command) {
return response()->json([
'success' => false,
'code' => 404,
'message' => __('Command not found')
], 404);
}
// 更新指令狀態 (0 代表成功)
$status = ($type == '0') ? 'success' : 'failed';
$payloadUpdates = [
'reported_stock' => $stockReported,
'report_type' => $type
];
// 若成功,連動更新貨道庫存
if ($status === 'success' && isset($command->payload['slot_no'])) {
$machine = $command->machine;
$slotNo = $command->payload['slot_no'];
$slot = $machine->slots()->where('slot_no', $slotNo)->first();
if ($slot) {
$oldStock = $slot->stock;
// 若 APP 回傳的庫存大於 0 則使用回傳值,否則執行扣減
$newStock = (int) ($stockReported ?? 0);
if ($newStock <= 0 && $oldStock > 0) {
$newStock = $oldStock - 1;
}
$finalStock = max(0, $newStock);
$slot->update(['stock' => $finalStock]);
// 紀錄庫存變化供前端顯示
$payloadUpdates['old_stock'] = $oldStock;
$payloadUpdates['new_stock'] = $finalStock;
ProcessStateLog::dispatch(
$machine->id,
$machine->company_id,
"Remote dispense successful for slot :slot",
'info',
['slot' => $slotNo]
);
}
}
$command->update([
'status' => $status,
'executed_at' => now(),
'payload' => array_merge($command->payload, $payloadUpdates)
]);
return response()->json([
'success' => true,
'code' => 200,
'message' => __('Result reported')
]);
}
/**
* B018: Record Machine Restock/Setup Report (Asynchronous)
*/
public function recordRestock(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
$data['serial_no'] = $machine->serial_no;
\App\Jobs\Machine\ProcessRestockReport::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => __('Restock report accepted'),
'status' => '49'
], 202);
}
/**
* B017: Get Slot Info & Stock (Synchronous)
*/
/**
* B017: Get Slot Info & Stock (Synchronous - Full Sync)
*/
public function getSlots(Request $request)
{
$machine = $request->get('machine');
// 依貨道編號排序 (Sorted by slot_no as requested)
$slots = $machine->slots()->with('product')->orderBy('slot_no')->get();
// 自動轉 Success: 若機台來撈 B017代表之前的 reload_stock 指令已成功被機台響應
// 同時處理 sent 與 pending 狀態,確保狀態機正確關閉
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
->where('command_type', 'reload_stock')
->whereIn('status', ['pending', 'sent'])
->update([
'status' => 'success',
'executed_at' => now(),
'note' => __('Inventory synced with machine')
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => $slots->map(function ($slot) {
return [
'tid' => $slot->slot_no,
'num' => (int) $slot->stock,
'expiry_date' => $slot->expiry_date ? $slot->expiry_date->format('Y-m-d') : null,
'batch_no' => $slot->batch_no,
// 保留原始欄位以供除錯或未來擴充
'product_id' => $slot->product_id,
'capacity' => $slot->max_stock,
'status' => $slot->is_active ? '1' : '0',
];
})
]);
}
/**
* B220: Sync Coin Inventory (Asynchronous)
*/
public function syncCoinInventory(Request $request)
{
$machine = $request->get('machine');
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
ProcessCoinInventory::dispatch($machine->serial_no, $data);
return response()->json(['success' => true], 202);
}
/**
* B650: Verify Member Code/Barcode (Synchronous)
*/
public function verifyMember(Request $request)
{
$validator = Validator::make($request->all(), [
'code' => 'required|string',
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid code'], 400);
}
$code = $request->input('code');
// 搜尋會員 (barcode 或特定驗證碼)
$member = \App\Models\Member\Member::where('barcode', $code)
->orWhere('id', $code) // 暫時支援 ID
->first();
if (!$member) {
return response()->json([
'success' => false,
'code' => 404,
'message' => 'Member not found'
], 404);
}
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'member_id' => $member->id,
'name' => $member->name,
'points' => $member->points,
'wallet_balance' => $member->wallet_balance ?? 0,
]
]);
}
/**
* B005: Download Machine Advertisements (Synchronous)
*/
public function getAdvertisements(Request $request)
{
$machine = $request->get('machine');
$advertisements = \App\Models\Machine\MachineAdvertisement::where('machine_id', $machine->id)
->with([
'advertisement' => function ($query) {
$query->playing();
}
])
->get()
->filter(fn($ma) => $ma->advertisement !== null)
->map(function ($ma) {
// 定義顯示順序權重 (待機 > 購物 > 成功禮)
$posWeight = [
'standby' => 1,
'vending' => 2,
'visit_gift' => 3
];
// 為了相容現有機台 App 邏輯:
// App 讀取 t070v03 作為位置標籤 (flag)
// 1. HomeActivity (待機) 讀取 "3"
// 2. FontendActivity (販賣頁) 讀取 "1"
$posIdMap = [
'standby' => '3',
'vending' => '1',
'visit_gift' => '2'
];
return [
't070v01' => $ma->advertisement->name,
't070v02' => (int) ($ma->advertisement->duration ?? 15),
't070v03' => (int) ($posIdMap[$ma->position] ?? 1),
't070v04' => $ma->advertisement->url ? (str_starts_with($ma->advertisement->url, 'http') ? $ma->advertisement->url : asset($ma->advertisement->url)) : '',
't070v05' => (int) $ma->sort_order,
'raw_pos_weight' => $posWeight[$ma->position] ?? 99,
'raw_sort' => (int) $ma->sort_order
];
})
->sortBy([
['raw_pos_weight', 'asc'],
['raw_sort', 'asc']
])
->values()
->map(function ($item) {
unset($item['raw_pos_weight'], $item['raw_sort']);
return $item;
});
return response()->json([
'success' => true,
'code' => 200,
'data' => $advertisements->values()
]);
}
/**
* B009: Report Machine Slot List / Supplementary (Synchronous)
*/
public function reportSlotList(Request $request, \App\Services\Machine\MachineService $machineService)
{
$machine = $request->get('machine');
$payload = $request->all();
$account = $payload['account'] ?? null;
// 1. 驗證帳號是否存在 (驗證執行補貨的人員身分)
$user = User::where('username', $account)
->orWhere('email', $account)
->first();
if (!$user) {
return response()->json([
'success' => false,
'code' => 403,
'message' => __('Unauthorized: Account not found'),
'status' => ''
], 403);
}
// 2. 階層式權限驗證 (遵循 RBAC 多租戶規範)
$isAuthorized = false;
if ($user->isSystemAdmin()) {
// [系統層]:系統管理員可異動所有機台
$isAuthorized = true;
} elseif ($user->is_admin) {
// [公司層]:公司管理員需驗證此機台是否隸屬於該公司
$isAuthorized = ($machine->company_id === $user->company_id);
} else {
// [人員層]:一般人員需檢查 machine_user 授權表
$isAuthorized = $user->machines()->where('machine_id', $machine->id)->exists();
}
if (!$isAuthorized) {
return response()->json([
'success' => false,
'code' => 403,
'message' => __('Unauthorized: Account not authorized for this machine'),
'status' => ''
], 403);
}
// 3. 映射舊版機台回傳格式 (Map legacy machine format)
// 支持單個物件 data: {} 或 陣列 data: [{}] (Handle both single object and array)
$legacyData = $payload['data'] ?? [];
if (Arr::isAssoc($legacyData)) {
$legacyData = [$legacyData];
}
$mappedSlots = array_map(function ($item) {
return [
'slot_no' => isset($item['tid']) ? (string) $item['tid'] : null,
'product_id' => $item['t060v00'] ?? null,
'stock' => isset($item['num']) ? (int) $item['num'] : 0,
'type' => isset($item['type']) ? (int) $item['type'] : null,
];
}, $legacyData);
// 過濾無效資料 (Filter invalid entries)
$mappedSlots = array_filter($mappedSlots, fn($s) => $s['slot_no'] !== null);
// 同步處理更新庫存 (直接更新不進隊列)
$machineService->syncSlots($machine, $mappedSlots);
return response()->json([
'success' => true,
'code' => 200,
'message' => __('Slot report synchronized success'),
]);
}
/**
* B012_new: Download Product Catalog (Synchronous)
*/
public function getProducts(Request $request, ProductCatalogService $catalogService)
{
$machine = $request->get('machine');
// Cache First: Get from cache or rebuild if missing
$payload = $catalogService->getPayload($machine->company_id);
return response()->json($payload);
}
/**
* B014: Download Machine Settings & Config (Synchronous)
* 用於機台引導階段,同步金流、發票與機台專屬 API Token。
*/
public function getSettings(Request $request)
{
$serialNo = $request->input('machine');
// 1. 查找機台 (忽略全局範圍以進行認領)
$machine = Machine::withoutGlobalScopes()
->with(['paymentConfig', 'company'])
->where('serial_no', $serialNo)
->first();
if (!$machine) {
return response()->json([
'success' => false,
'code' => 404,
'message' => __('Machine not found')
], 404);
}
// 2. 獲取關聯設定 (依據使用者要求,跳過權限校驗,只要有序號即可獲取)
$paymentSettings = $machine->paymentConfig->settings ?? [];
$companySettings = $machine->company->settings ?? [];
// 4. 映射 App 預期欄位 (嚴格遵守 HttpAPI.java 結構)
$data = [
't050v01' => $machine->serial_no,
'api_token' => $machine->api_token, // 向 App 核發正式通訊 Token
// 玉山支付
't050v41' => $paymentSettings['esun_store_id'] ?? '',
't050v42' => $paymentSettings['esun_term_id'] ?? '',
't050v43' => $paymentSettings['esun_hash'] ?? '',
// 電子發票 (綠界)
't050v34' => $companySettings['invoice_merchant_id'] ?? '',
't050v35' => $companySettings['invoice_hash_key'] ?? '',
't050v36' => $companySettings['invoice_hash_iv'] ?? '',
't050v38' => $companySettings['invoice_email'] ?? '',
// 趨勢支付 (TrendPay/Greenpay)
'TP_APP_ID' => $paymentSettings['tp_app_id'] ?? '',
'TP_APP_KEY' => $paymentSettings['tp_app_key'] ?? '',
'TP_PARTNER_KEY' => $paymentSettings['tp_partner_key'] ?? '',
// 各類行動支付特店 ID
'TP_LINE_MERCHANT_ID' => $paymentSettings['tp_line_merchant_id'] ?? '',
'TP_PS_MERCHANT_ID' => $paymentSettings['tp_ps_merchant_id'] ?? '',
'TP_EASY_MERCHANT_ID' => $paymentSettings['tp_easy_merchant_id'] ?? '',
'TP_PI_MERCHANT_ID' => $paymentSettings['tp_pi_merchant_id'] ?? '',
'TP_JKO_MERCHANT_ID' => $paymentSettings['tp_jko_merchant_id'] ?? '',
];
return response()->json([
'success' => true,
'code' => 200,
'data' => $data
]);
}
/**
* B660: Verify/Consume Pickup Code
* POST: 驗證 8 位數取貨碼 (與原邏輯一致,但加入日誌)
* PUT: 消耗取貨碼 (扣庫存)
*/
public function verifyPickupCode(Request $request)
{
return $this->handlePickupVerify($request, $request->get('machine'));
}
private function handlePickupVerify(Request $request, $machine)
{
$lockoutKey = "pickup_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many failed attempts.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$pickupCode = PickupCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$pickupCode) {
$fails = Cache::increment("pickup_fails:{$machine->id}");
if ($fails === 1)
Cache::put("pickup_fails:{$machine->id}", 1, 600);
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("pickup_fails:{$machine->id}");
}
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[PickupCode] Verification failed: :code", 'info', ['code' => $code]);
return response()->json(['success' => false, 'message' => 'Invalid code', 'code' => 404, 'remaining_attempts' => 5 - $fails], 404);
}
Cache::forget("pickup_fails:{$machine->id}");
// 建立取貨碼專屬日誌 (驗證成功)
PickupCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pickup_code_id' => $pickupCode->id,
'action' => 'verify_success',
'remark' => "log.pickup.verify_success",
'raw_data' => [
'machine_sn' => $machine->serial_no,
'code' => $pickupCode->code,
'slot' => $pickupCode->slot_no
]
]);
// 移除 StaffCardLog 重複紀錄,因為取貨碼本身有 status 和 order_id 可以追蹤
// 憑證的「核銷」將由 MQTT finalizeTransaction 流程處理,不再需要 B660 PUT
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'slot_no' => $pickupCode->slot_no,
'pickup_code_id' => $pickupCode->id,
'code_id' => $pickupCode->id, // 統一回傳 code_id
'status' => 'active'
]
]);
}
/**
* B670: Verify Pass Code (For Technicians)
*/
public function verifyPassCode(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "passcode_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Locked', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails())
return response()->json(['success' => false, 'code' => 400], 400);
$code = $request->input('code');
$passCode = PassCode::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$passCode) {
$fails = Cache::increment("passcode_fails:{$machine->id}");
if ($fails >= 5)
Cache::put($lockoutKey, true, 60);
return response()->json(['success' => false, 'code' => 404], 404);
}
// 建立通行碼專屬日誌 (驗證成功)
PassCodeLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'pass_code_id' => $passCode->id,
'status' => 'verified',
'action' => 'verify_success',
'raw_data' => ['machine_sn' => $machine->serial_no, 'code' => $passCode->code]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $passCode->name,
'code_id' => $passCode->id, // 統一回傳 code_id
'status' => 'active'
]
]);
}
/**
* B680: Verify Staff Card (New)
*/
public function verifyStaffCard(Request $request)
{
$machine = $request->get('machine');
$validator = Validator::make($request->all(), [
'code' => 'required|string', // 卡片 UID
]);
if ($validator->fails()) {
return response()->json(['success' => false, 'code' => 400], 400);
}
$uid = $request->input('code');
// 搜尋該公司的有效員工卡 (遵循租戶隔離)
$staffCard = StaffCard::where('company_id', $machine->company_id)
->where('card_uid', $uid)
->where('status', 'active')
->first();
if (!$staffCard) {
ProcessStateLog::dispatch($machine->id, $machine->company_id, "[StaffCard] Verification failed: :uid", 'info', ['uid' => $uid]);
return response()->json(['success' => false, 'code' => 404, 'message' => 'Card not found or inactive'], 404);
}
// 建立刷卡日誌 (作為閉環起點)
StaffCardLog::create([
'company_id' => $machine->company_id,
'staff_card_id' => $staffCard->id,
'machine_id' => $machine->id,
'payment_type' => 41, // Staff Card
'code_id' => $staffCard->id,
'action' => 'verify_success',
'raw_data' => ['machine_sn' => $machine->serial_no, 'uid' => $uid]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $staffCard->name,
'code_id' => $staffCard->id, // 統一回傳 code_id
'status' => 'active'
]
]);
}
/**
* B690: Verify Welcome Gift (New)
*/
public function verifyWelcomeGift(Request $request)
{
$machine = $request->get('machine');
$lockoutKey = "welcome_gift_lockout:{$machine->id}";
if (Cache::has($lockoutKey)) {
return response()->json(['success' => false, 'message' => 'Too many attempts. Locked.', 'code' => 429], 429);
}
$validator = Validator::make($request->all(), ['code' => 'required|string|size:8']);
if ($validator->fails()) {
return response()->json(['success' => false, 'message' => 'Invalid format', 'code' => 400], 400);
}
$code = $request->input('code');
$gift = WelcomeGift::where('machine_id', $machine->id)
->where('code', $code)
->where('status', 'active')
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->first();
if (!$gift || !$gift->isValid()) {
$fails = Cache::increment("welcome_gift_fails:{$machine->id}");
if ($fails === 1) {
Cache::put("welcome_gift_fails:{$machine->id}", 1, 600);
}
if ($fails >= 5) {
Cache::put($lockoutKey, true, 60);
Cache::forget("welcome_gift_fails:{$machine->id}");
}
return response()->json(['success' => false, 'message' => 'Invalid or expired code', 'code' => 404, 'remaining_attempts' => max(0, 5 - $fails)], 404);
}
Cache::forget("welcome_gift_fails:{$machine->id}");
// 建立來店禮驗證日誌 (驗證成功)
WelcomeGiftLog::create([
'company_id' => $machine->company_id,
'machine_id' => $machine->id,
'welcome_gift_id' => $gift->id,
'action' => 'verify_success',
'remark' => 'log.welcome_gift.verify_success',
'raw_data' => [
'code' => $gift->code,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value
]
]);
return response()->json([
'success' => true,
'code' => 200,
'data' => [
'name' => $gift->name,
'code_id' => $gift->id,
'discount_type' => $gift->discount_type,
'discount_value' => $gift->discount_value,
'discount_label' => $gift->discount_label, // 回傳自動算好的中文折數 (如「八折」、「折50元」)
'usage_type' => $gift->usage_type,
'status' => 'active'
]
]);
}
}