[FEAT] 重構機台日誌 UI 與增加多語系支援,並整合 IoT API 核心架構
- 機台日誌:對齊 Luxury UI 規範,實作整合式佈局與分頁組件。 - 多語系:完成機台日誌繁、英、日三語系翻譯與動態處理。 - UI 規範:更新 SKILL.md 定義「標準列表 Bible」。 - 後端:完善 TenantScoped 隔離邏輯,修復儀表板死循環與 User Model 缺失。 - IoT:擴展機台、會員 Model 並建立交易、商品、狀態等核心表結構。 - 基礎設施:設置台北時區與 Docker 環境變數同步。
This commit is contained in:
@@ -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);
|
||||
|
||||
123
app/Http/Controllers/Api/V1/App/MachineController.php
Normal file
123
app/Http/Controllers/Api/V1/App/MachineController.php
Normal 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,
|
||||
]
|
||||
]);
|
||||
}
|
||||
}
|
||||
66
app/Http/Controllers/Api/V1/App/TransactionController.php
Normal file
66
app/Http/Controllers/Api/V1/App/TransactionController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
39
app/Http/Middleware/IotAuth.php
Normal file
39
app/Http/Middleware/IotAuth.php
Normal 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);
|
||||
}
|
||||
}
|
||||
57
app/Jobs/Machine/ProcessCoinInventory.php
Normal file
57
app/Jobs/Machine/ProcessCoinInventory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Jobs/Machine/ProcessHeartbeat.php
Normal file
41
app/Jobs/Machine/ProcessHeartbeat.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Jobs/Machine/ProcessTimerStatus.php
Normal file
51
app/Jobs/Machine/ProcessTimerStatus.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/Jobs/Transaction/ProcessDispenseRecord.php
Normal file
39
app/Jobs/Transaction/ProcessDispenseRecord.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
42
app/Jobs/Transaction/ProcessInvoice.php
Normal file
42
app/Jobs/Transaction/ProcessInvoice.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/Jobs/Transaction/ProcessTransaction.php
Normal file
39
app/Jobs/Transaction/ProcessTransaction.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
app/Models/Machine/CoinInventory.php
Normal file
23
app/Models/Machine/CoinInventory.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
39
app/Models/Machine/MachineSlot.php
Normal file
39
app/Models/Machine/MachineSlot.php
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/Models/Machine/RemoteCommand.php
Normal file
31
app/Models/Machine/RemoteCommand.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
app/Models/Machine/TimerStatus.php
Normal file
28
app/Models/Machine/TimerStatus.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
40
app/Models/Product/Product.php
Normal file
40
app/Models/Product/Product.php
Normal 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');
|
||||
}
|
||||
}
|
||||
24
app/Models/Product/ProductCategory.php
Normal file
24
app/Models/Product/ProductCategory.php
Normal 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');
|
||||
}
|
||||
}
|
||||
18
app/Models/System/Translation.php
Normal file
18
app/Models/System/Translation.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
50
app/Models/Transaction/DispenseRecord.php
Normal file
50
app/Models/Transaction/DispenseRecord.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
app/Models/Transaction/Invoice.php
Normal file
39
app/Models/Transaction/Invoice.php
Normal 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);
|
||||
}
|
||||
}
|
||||
64
app/Models/Transaction/Order.php
Normal file
64
app/Models/Transaction/Order.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
app/Models/Transaction/OrderItem.php
Normal file
39
app/Models/Transaction/OrderItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
23
app/Models/Transaction/PaymentType.php
Normal file
23
app/Models/Transaction/PaymentType.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
120
app/Services/Transaction/TransactionService.php
Normal file
120
app/Services/Transaction/TransactionService.php
Normal 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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
// 如果使用者已登入且有綁定公司,則自動注入過濾條件
|
||||
|
||||
Reference in New Issue
Block a user