[FEAT] 重構機台日誌 UI 與增加多語系支援,並整合 IoT API 核心架構

- 機台日誌:對齊 Luxury UI 規範,實作整合式佈局與分頁組件。
- 多語系:完成機台日誌繁、英、日三語系翻譯與動態處理。
- UI 規範:更新 SKILL.md 定義「標準列表 Bible」。
- 後端:完善 TenantScoped 隔離邏輯,修復儀表板死循環與 User Model 缺失。
- IoT:擴展機台、會員 Model 並建立交易、商品、狀態等核心表結構。
- 基礎設施:設置台北時區與 Docker 環境變數同步。
This commit is contained in:
2026-03-16 17:29:15 +08:00
parent 1851e91c86
commit 3ce88ed342
54 changed files with 2015 additions and 227 deletions

View File

@@ -42,7 +42,7 @@ class MachineController extends AdminController
*/
public function logs(Request $request): View
{
$per_page = $request->input('per_page', 20);
$per_page = $request->input('per_page', 10);
$logs = \App\Models\Machine\MachineLog::with('machine')
->when($request->level, function ($query, $level) {
return $query->where('level', $level);

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use App\Jobs\Machine\ProcessHeartbeat;
use App\Jobs\Machine\ProcessTimerStatus;
use App\Jobs\Machine\ProcessCoinInventory;
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->all();
// 異步處理狀態更新
ProcessHeartbeat::dispatch($machine->serial_no, $data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'OK',
'status' => '49' // 某些硬體可能需要的成功碼
], 202); // 202 Accepted
}
/**
* B017: Get Slot Info & Stock (Synchronous)
*/
public function getSlots(Request $request)
{
$machine = $request->get('machine');
$slots = $machine->slots()->with('product')->get();
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,
];
})
]);
}
/**
* B710: Sync Timer status (Asynchronous)
*/
public function syncTimer(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
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->all();
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,
]
]);
}
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api\V1\App;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Jobs\Transaction\ProcessTransaction;
use App\Jobs\Transaction\ProcessInvoice;
use App\Jobs\Transaction\ProcessDispenseRecord;
class TransactionController extends Controller
{
/**
* B600: Record Transaction (Asynchronous)
*/
public function store(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data['serial_no'] = $machine->serial_no;
ProcessTransaction::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Accepted'
], 202);
}
/**
* B601: Record Invoice (Asynchronous)
*/
public function recordInvoice(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data['serial_no'] = $machine->serial_no;
ProcessInvoice::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Accepted'
], 202);
}
/**
* B602: Record Dispense Result (Asynchronous)
*/
public function recordDispense(Request $request)
{
$machine = $request->get('machine');
$data = $request->all();
$data['serial_no'] = $machine->serial_no;
ProcessDispenseRecord::dispatch($data);
return response()->json([
'success' => true,
'code' => 200,
'message' => 'Accepted'
], 202);
}
}

View File

@@ -69,5 +69,6 @@ class Kernel extends HttpKernel
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'iot.auth' => \App\Http\Middleware\IotAuth::class,
];
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Machine\Machine;
use Symfony\Component\HttpFoundation\Response;
class IotAuth
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
// Phase 1: 暫時也接受 Request Body 中的 key 欄位 (相容模式)
if (!$token) {
$token = $request->input('key');
}
if (!$token) {
return response()->json(['success' => false, 'message' => 'Unauthorized: Missing Token'], 401);
}
$machine = Machine::where('api_token', $token)->first();
if (!$machine) {
return response()->json(['success' => false, 'message' => 'Unauthorized: Invalid Token'], 401);
}
// 將機台物件注入 Request 供後端使用
$request->merge(['machine' => $machine]);
return $next($request);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\CoinInventory;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessCoinInventory implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
// Sync inventory: typically the IoT device sends the full state
// If it sends partial, logic would differ. For now, we assume simple updateOrCreate per denomination.
if (isset($this->data['inventories']) && is_array($this->data['inventories'])) {
foreach ($this->data['inventories'] as $inv) {
CoinInventory::updateOrCreate(
[
'machine_id' => $machine->id,
'denomination' => $inv['denomination'],
'type' => $inv['type'] ?? 'coin'
],
['count' => $inv['count']]
);
}
}
} catch (\Exception $e) {
Log::error("Failed to process coin inventory for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Jobs\Machine;
use App\Services\Machine\MachineService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessHeartbeat implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(MachineService $machineService): void
{
try {
$machineService->updateHeartbeat($this->serialNo, $this->data);
} catch (\Exception $e) {
Log::error("Failed to process heartbeat for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Jobs\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\TimerStatus;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessTimerStatus implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $serialNo;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(string $serialNo, array $data)
{
$this->serialNo = $serialNo;
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
$machine = Machine::where('serial_no', $this->serialNo)->firstOrFail();
TimerStatus::updateOrCreate(
['machine_id' => $machine->id, 'slot_no' => $this->data['slot_no']],
[
'status' => $this->data['status'],
'remaining_seconds' => $this->data['remaining_seconds'],
'end_at' => isset($this->data['end_at']) ? \Carbon\Carbon::parse($this->data['end_at']) : null,
]
);
} catch (\Exception $e) {
Log::error("Failed to process timer status for machine {$this->serialNo}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Transaction;
use App\Services\Transaction\TransactionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessDispenseRecord implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
{
try {
$transactionService->recordDispense($this->data);
} catch (\Exception $e) {
Log::error("Failed to record dispense for machine {$this->data['serial_no']}: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Jobs\Transaction;
use App\Services\Transaction\TransactionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessInvoice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
{
try {
$transactionService->recordInvoice($this->data);
} catch (\Exception $e) {
Log::error('Failed to process invoice: ' . $e->getMessage(), [
'data' => $this->data,
'exception' => $e
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Jobs\Transaction;
use App\Services\Transaction\TransactionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessTransaction implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $data;
/**
* Create a new job instance.
*/
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Execute the job.
*/
public function handle(TransactionService $transactionService): void
{
try {
$transactionService->processTransaction($this->data);
} catch (\Exception $e) {
Log::error("Failed to process transaction: " . $e->getMessage());
throw $e;
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CoinInventory extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'denomination',
'count',
'type',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

@@ -10,14 +10,20 @@ use App\Traits\TenantScoped;
class Machine extends Model
{
use HasFactory, TenantScoped;
use \Illuminate\Database\Eloquent\SoftDeletes;
protected $fillable = [
'company_id',
'name',
'serial_no',
'model',
'location',
'status',
'current_page',
'door_status',
'temperature',
'firmware_version',
'api_token',
'last_heartbeat_at',
];

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Product\Product;
class MachineSlot extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'product_id',
'slot_no',
'slot_name',
'capacity',
'stock',
'price',
'status',
'last_restocked_at',
];
protected $casts = [
'price' => 'decimal:2',
'last_restocked_at' => 'datetime',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RemoteCommand extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'command',
'payload',
'status',
'response_payload',
'executed_at',
];
protected $casts = [
'payload' => 'array',
'response_payload' => 'array',
'executed_at' => 'datetime',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models\Machine;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TimerStatus extends Model
{
use HasFactory;
protected $fillable = [
'machine_id',
'slot_no',
'status',
'remaining_seconds',
'end_at',
];
protected $casts = [
'end_at' => 'datetime',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
}

View File

@@ -31,6 +31,10 @@ class Member extends Authenticatable
'avatar',
'is_active',
'email_verified_at',
'company_id',
'barcode',
'points',
'wallet_balance',
];
/**
@@ -49,6 +53,8 @@ class Member extends Authenticatable
'birthday' => 'date',
'is_active' => 'boolean',
'password' => 'hashed',
'points' => 'integer',
'wallet_balance' => 'decimal:2',
];
/**

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
class Product extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
'category_id',
'name',
'sku',
'barcode',
'description',
'price',
'cost',
'type',
'image_url',
'status',
'name_dictionary_key',
'metadata',
];
protected $casts = [
'price' => 'decimal:2',
'cost' => 'decimal:2',
'metadata' => 'array',
];
public function category()
{
return $this->belongsTo(ProductCategory::class, 'category_id');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models\Product;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
class ProductCategory extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
'name',
'name_dictionary_key',
];
public function products()
{
return $this->hasMany(Product::class, 'category_id');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Translation extends Model
{
use HasFactory;
protected $fillable = [
'group',
'key',
'locale',
'value',
];
}

View File

@@ -8,11 +8,13 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use App\Traits\TenantScoped;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable, HasRoles;
use HasApiTokens, HasFactory, Notifiable, HasRoles, TenantScoped, SoftDeletes;
/**
* The attributes that are mass assignable.

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Models\Machine\Machine;
use App\Models\Product\Product;
class DispenseRecord extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'company_id',
'order_id',
'flow_id',
'machine_id',
'product_id',
'slot_no',
'amount',
'remaining_stock',
'dispense_status',
'member_barcode',
'machine_time',
'points_used',
];
protected $casts = [
'amount' => 'decimal:2',
'machine_time' => 'datetime',
'dispense_status' => 'integer',
];
public function order()
{
return $this->belongsTo(Order::class);
}
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Invoice extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'company_id',
'order_id',
'machine_id',
'flow_id',
'invoice_no',
'amount',
'carrier_id',
'invoice_date',
'random_number',
'love_code',
'rtn_code',
'rtn_msg',
'metadata',
];
protected $casts = [
'total_amount' => 'decimal:2',
'tax_amount' => 'decimal:2',
'metadata' => 'array',
];
public function order()
{
return $this->belongsTo(Order::class);
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\TenantScoped;
use App\Models\Machine\Machine;
use App\Models\Member\Member;
class Order extends Model
{
use HasFactory, SoftDeletes, TenantScoped;
protected $fillable = [
'company_id',
'flow_id',
'order_no',
'machine_id',
'member_id',
'total_amount',
'discount_amount',
'pay_amount',
'payment_type',
'payment_status',
'payment_at',
'status',
'metadata',
];
protected $casts = [
'total_amount' => 'decimal:2',
'discount_amount' => 'decimal:2',
'pay_amount' => 'decimal:2',
'payment_at' => 'datetime',
'metadata' => 'array',
];
public function machine()
{
return $this->belongsTo(Machine::class);
}
public function member()
{
return $this->belongsTo(Member::class);
}
public function items()
{
return $this->hasMany(OrderItem::class);
}
public function invoice()
{
return $this->hasOne(Invoice::class);
}
public function dispenseRecords()
{
return $this->hasMany(DispenseRecord::class);
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Models\Product\Product;
class OrderItem extends Model
{
use HasFactory;
protected $fillable = [
'order_id',
'product_id',
'product_name',
'sku',
'price',
'quantity',
'subtotal',
'metadata',
];
protected $casts = [
'price' => 'decimal:2',
'subtotal' => 'decimal:2',
'metadata' => 'array',
];
public function order()
{
return $this->belongsTo(Order::class);
}
public function product()
{
return $this->belongsTo(Product::class);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Models\Transaction;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PaymentType extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'name',
'code',
'config',
'status',
];
protected $casts = [
'config' => 'array',
];
}

View File

@@ -4,42 +4,69 @@ namespace App\Services\Machine;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineLog;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Carbon\Carbon;
class MachineService
{
/**
* 處理機台日誌寫入與狀態更新
* Update machine heartbeat and status.
*
* @param string $serialNo
* @param array $data
* @return Machine
*/
public function updateHeartbeat(string $serialNo, array $data): Machine
{
return DB::transaction(function () use ($serialNo, $data) {
$machine = Machine::where('serial_no', $serialNo)->firstOrFail();
$updateData = [
'status' => 'online',
'temperature' => $data['temperature'] ?? $machine->temperature,
'current_page' => $data['current_page'] ?? $machine->current_page,
'door_status' => $data['door_status'] ?? $machine->door_status,
'firmware_version' => $data['firmware_version'] ?? $machine->firmware_version,
'last_heartbeat_at' => now(),
];
$machine->update($updateData);
// Record log if provided
if (!empty($data['log'])) {
$machine->logs()->create([
'level' => $data['log_level'] ?? 'info',
'message' => $data['log'],
'payload' => $data['log_payload'] ?? null,
]);
}
return $machine;
});
}
/**
* Update machine slot stock.
*/
public function updateSlotStock(Machine $machine, int $slotNo, int $stock): void
{
$machine->slots()->where('slot_no', $slotNo)->update([
'stock' => $stock,
'last_restocked_at' => now(),
]);
}
/**
* Legacy support for recordLog (Existing code).
*/
public function recordLog(int $machineId, array $data): MachineLog
{
$machine = Machine::findOrFail($machineId);
// 建立日誌紀錄
$log = $machine->logs()->create([
return $machine->logs()->create([
'level' => $data['level'] ?? 'info',
'message' => $data['message'],
'context' => $data['context'] ?? null,
'payload' => $data['context'] ?? null,
]);
// 同步更新機台最後活耀時間與狀態
$machine->update([
'last_heartbeat_at' => now(),
'status' => $this->resolveStatus($data),
]);
return $log;
}
/**
* 根據日誌內容判斷機台是否應標記成錯誤
*/
protected function resolveStatus(array $data): string
{
if (isset($data['level']) && $data['level'] === 'error') {
return 'error';
}
return 'online';
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Services\Transaction;
use App\Models\Transaction\Order;
use App\Models\Transaction\OrderItem;
use App\Models\Transaction\Invoice;
use App\Models\Transaction\DispenseRecord;
use Illuminate\Support\Facades\DB;
use App\Models\Machine\Machine;
class TransactionService
{
/**
* Process a new transaction (B600).
*/
public function processTransaction(array $data): Order
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
// Create Order
$order = Order::create([
'company_id' => $machine->company_id,
'flow_id' => $data['flow_id'] ?? null,
'order_no' => $data['order_no'] ?? $this->generateOrderNo(),
'machine_id' => $machine->id,
'member_id' => $data['member_id'] ?? null,
'total_amount' => $data['total_amount'],
'discount_amount' => $data['discount_amount'] ?? 0,
'pay_amount' => $data['pay_amount'],
'payment_type' => $data['payment_type'] ?? 0,
'payment_status' => $data['payment_status'] ?? 1,
'payment_at' => now(),
'status' => 'completed',
'metadata' => $data['metadata'] ?? null,
]);
// Create Order Items
if (!empty($data['items'])) {
foreach ($data['items'] as $item) {
$order->items()->create([
'product_id' => $item['product_id'],
'product_name' => $item['product_name'] ?? 'Unknown',
'sku' => $item['sku'] ?? null,
'price' => $item['price'],
'quantity' => $item['quantity'],
'subtotal' => $item['price'] * $item['quantity'],
]);
}
}
return $order;
});
}
/**
* Generate a unique order number.
*/
protected function generateOrderNo(): string
{
return 'ORD-' . now()->format('YmdHis') . '-' . strtoupper(bin2hex(random_bytes(3)));
}
/**
* Record Invoice (B601).
*/
public function recordInvoice(array $data): Invoice
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::where('flow_id', $data['flow_id'])->first();
}
return Invoice::create([
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'machine_id' => $machine->id,
'flow_id' => $data['flow_id'] ?? null,
'invoice_no' => $data['invoice_no'] ?? null,
'amount' => $data['amount'] ?? 0,
'carrier_id' => $data['carrier_id'] ?? null,
'invoice_date' => $data['invoice_date'] ?? null,
'random_number' => $data['random_no'] ?? null,
'love_code' => $data['love_code'] ?? null,
'metadata' => $data['metadata'] ?? null,
]);
});
}
/**
* Record dispense result (B602).
*/
public function recordDispense(array $data): DispenseRecord
{
return DB::transaction(function () use ($data) {
$machine = Machine::where('serial_no', $data['serial_no'])->firstOrFail();
$order = null;
if (!empty($data['flow_id'])) {
$order = Order::where('flow_id', $data['flow_id'])->first();
}
return DispenseRecord::create([
'company_id' => $machine->company_id,
'order_id' => $order?->id ?? ($data['order_id'] ?? null),
'flow_id' => $data['flow_id'] ?? null,
'machine_id' => $machine->id,
'slot_no' => $data['slot_no'] ?? 'unknown',
'product_id' => $data['product_id'] ?? null,
'amount' => $data['amount'] ?? 0,
'dispense_status' => $data['dispense_status'] ?? 0,
'machine_time' => $data['machine_time'] ?? now(),
]);
});
}
}

View File

@@ -12,6 +12,16 @@ trait TenantScoped
public static function bootTenantScoped(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
// 避免在 User Model 本身套用此 Scope否則在 auth()->user() 讀取 User 時會產生循環引用
if (static::class === \App\Models\System\User::class) {
return;
}
// check if running in console/migration
if (app()->runningInConsole()) {
return;
}
$user = auth()->user();
// 如果使用者已登入且有綁定公司,則自動注入過濾條件