All checks were successful
star-cloud-deploy-demo / deploy-demo (push) Successful in 1m4s
1. 將 B005 (廣告同步) 從 POST 改為 GET,符合 RESTful 規範。
2. 完善 B009 (庫存回報) 回應規格,加入業務代碼 (200 OK)。
3. API 文件 UI 優化:新增 Method Badge (方法標籤),並修正 JSON 中文/斜線轉義問題。
4. 機台管理介面優化:實作「唯讀庫存與效期」面板,並將日誌圖示改為「👁️」。
5. 標準化 ID 識別邏輯:資料表全面移除對 sku 的依賴,改以 id 為主、barcode 為輔。
6. 新增 Migration:正式移除 sku 欄位並同步 barcode 指向。
7. 更新多語系支援 (zh_TW, en, ja)。
334 lines
11 KiB
PHP
334 lines
11 KiB
PHP
<?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\ProcessTimerStatus;
|
||
use App\Jobs\Machine\ProcessCoinInventory;
|
||
use Illuminate\Support\Arr;
|
||
use Illuminate\Support\Facades\Validator;
|
||
|
||
class MachineController extends Controller
|
||
{
|
||
/**
|
||
* B010: Machine Heartbeat & Status Update (Asynchronous)
|
||
*/
|
||
public function heartbeat(Request $request)
|
||
{
|
||
$machine = $request->get('machine');
|
||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入的 Model 物件與認證 key
|
||
|
||
// 異步處理狀態更新
|
||
ProcessHeartbeat::dispatch($machine->serial_no, $data);
|
||
|
||
// 取出待處理指令
|
||
$command = \App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||
->pending()
|
||
->first();
|
||
|
||
$status = '49'; // 預設 49 (OK / No Command)
|
||
$message = 'OK';
|
||
|
||
if ($command) {
|
||
switch ($command->command_type) {
|
||
case 'reboot':
|
||
$status = '51';
|
||
$message = 'reboot';
|
||
break;
|
||
case 'reboot_card':
|
||
$status = '60';
|
||
$message = 'reboot card machine';
|
||
break;
|
||
case 'checkout':
|
||
$status = '61';
|
||
$message = 'checkout';
|
||
break;
|
||
case 'lock':
|
||
$status = '71';
|
||
$message = 'lock';
|
||
break;
|
||
case 'unlock':
|
||
$status = '70';
|
||
$message = 'unlock';
|
||
break;
|
||
case 'change':
|
||
$status = '82';
|
||
$message = $command->payload['amount'] ?? '0';
|
||
break;
|
||
case 'dispense':
|
||
$status = '85';
|
||
$message = $command->payload['slot_no'] ?? '';
|
||
break;
|
||
case 'reload_stock':
|
||
$status = '49';
|
||
$message = 'reload B017';
|
||
break;
|
||
}
|
||
// 標記為已發送 (sent)
|
||
$command->update(['status' => 'sent', 'executed_at' => now()]);
|
||
}
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'code' => 200,
|
||
'message' => $message,
|
||
'status' => $status
|
||
], 202); // 202 Accepted
|
||
}
|
||
|
||
/**
|
||
* 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)
|
||
*/
|
||
public function getSlots(Request $request)
|
||
{
|
||
$machine = $request->get('machine');
|
||
$slots = $machine->slots()->with('product')->get();
|
||
|
||
// 自動轉 Success: 若機台來撈 B017,代表之前的 reload_stock 指令已成功被機台響應
|
||
\App\Models\Machine\RemoteCommand::where('machine_id', $machine->id)
|
||
->where('command_type', 'reload_stock')
|
||
->where('status', 'sent')
|
||
->update(['status' => 'success', 'executed_at' => now()]);
|
||
|
||
return response()->json([
|
||
'success' => true,
|
||
'code' => 200,
|
||
'data' => $slots->map(function ($slot) {
|
||
return [
|
||
'slot_no' => $slot->slot_no,
|
||
'product_id' => $slot->product_id,
|
||
'stock' => $slot->stock,
|
||
'capacity' => $slot->capacity,
|
||
'price' => $slot->price,
|
||
'status' => $slot->status,
|
||
'expiry_date' => $slot->expiry_date,
|
||
'batch_no' => $slot->batch_no,
|
||
];
|
||
})
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* B710: Sync Timer status (Asynchronous)
|
||
*/
|
||
public function syncTimer(Request $request)
|
||
{
|
||
$machine = $request->get('machine');
|
||
$data = $request->except(['machine', 'key']); // 排除 Middleware 注入物件
|
||
|
||
ProcessTimerStatus::dispatch($machine->serial_no, $data);
|
||
|
||
return response()->json(['success' => true], 202);
|
||
}
|
||
|
||
/**
|
||
* 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->active();
|
||
}
|
||
])
|
||
->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' => (string) ($ma->advertisement->duration ?? 15), // 秒數改放這裡
|
||
't070v03' => (string) ($posIdMap[$ma->position] ?? '1'), // 位置數字改放這裡 (App 會讀這欄當 Flag)
|
||
't070v04' => $ma->advertisement->url,
|
||
't070v05' => (string) $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' => $item['tid'] ?? null,
|
||
'product_id' => $item['t060v00'] ?? null,
|
||
'stock' => $item['num'] ?? 0,
|
||
];
|
||
}, $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',
|
||
'status' => '49'
|
||
]);
|
||
}
|
||
}
|