diff --git a/app/Http/Controllers/Landlord/TenantController.php b/app/Http/Controllers/Landlord/TenantController.php index 6137b77..d33069f 100644 --- a/app/Http/Controllers/Landlord/TenantController.php +++ b/app/Http/Controllers/Landlord/TenantController.php @@ -84,6 +84,24 @@ class TenantController extends Controller { $tenant = Tenant::with('domains')->findOrFail($id); + $tokens = []; + try { + tenancy()->initialize($tenant); + $user = \App\Modules\Core\Models\User::first(); + if ($user) { + $tokens = $user->tokens()->orderBy('created_at', 'desc')->get(['id', 'name', 'last_used_at', 'created_at'])->map(function($token) { + return [ + 'id' => $token->id, + 'name' => $token->name, + 'last_used_at' => $token->last_used_at ? $token->last_used_at->format('Y-m-d H:i') : '未使用', + 'created_at' => $token->created_at->format('Y-m-d H:i'), + ]; + }); + } + } catch (\Exception $e) { + \Log::warning("Failed to fetch tokens for tenant {$id}: " . $e->getMessage()); + } + return Inertia::render('Landlord/Tenant/Show', [ 'tenant' => [ 'id' => $tenant->id, @@ -98,6 +116,7 @@ class TenantController extends Controller 'domain' => $d->domain, ])->toArray(), ], + 'tokens' => $tokens, ]); } @@ -242,4 +261,58 @@ class TenantController extends Controller return redirect()->back()->with('success', '樣式設定已更新'); } + + /** + * 建立 API Token (用於 POS) + */ + public function createToken(Request $request, Tenant $tenant) + { + $request->validate([ + 'name' => 'required|string|max:50', + ]); + + try { + // 切換至租戶環境 + tenancy()->initialize($tenant); + + // 尋找超級管理員 (假設 ID 1, 或者根據 Role) + // 這裡簡單取第一個使用者,通常是 Admin + $user = \App\Modules\Core\Models\User::first(); + + if (!$user) { + return back()->with('error', '該租戶尚無使用者,無法建立 Token。'); + } + + // 建立 Token + $token = $user->createToken($request->name); + + return back()->with('success', 'Token 建立成功')->with('new_token', $token->plainTextToken); + } catch (\Exception $e) { + \Log::error("Token creation failed: " . $e->getMessage()); + return back()->with('error', 'Token 建立失敗'); + } finally { + // tenancy()->end(); // Laravel Tenancy 自動處理 scope 結束? 通常 Controller request life-cycle? + // Landlord controller is Central. Tenancy initialization persists for request. + // We should explicit end if we want to be safe, but redirect ends request anyway. + } + } + + /** + * 撤銷 API Token + */ + public function revokeToken(Request $request, Tenant $tenant, string $tokenId) + { + try { + tenancy()->initialize($tenant); + $user = \App\Modules\Core\Models\User::first(); + + if ($user) { + $user->tokens()->where('id', $tokenId)->delete(); + } + + return back()->with('success', 'Token 已撤銷'); + } catch (\Exception $e) { + return back()->with('error', 'Token 撤銷失敗'); + } + } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index ca4217e..679266f 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -61,6 +61,7 @@ class HandleInertiaRequests extends Middleware 'flash' => [ 'success' => $request->session()->get('success'), 'error' => $request->session()->get('error'), + 'new_token' => $request->session()->get('new_token'), ], 'branding' => function () { $tenant = tenancy()->tenant; diff --git a/app/Modules/Core/Models/User.php b/app/Modules/Core/Models/User.php index 7ac0e11..f91aec7 100644 --- a/app/Modules/Core/Models/User.php +++ b/app/Modules/Core/Models/User.php @@ -10,10 +10,12 @@ use Spatie\Permission\Traits\HasRoles; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; +use Laravel\Sanctum\HasApiTokens; + class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, HasRoles, LogsActivity; + use HasFactory, Notifiable, HasRoles, LogsActivity, HasApiTokens; /** * 可批量賦值的屬性。 diff --git a/app/Modules/Integration/Controllers/OrderSyncController.php b/app/Modules/Integration/Controllers/OrderSyncController.php new file mode 100644 index 0000000..dfa4f89 --- /dev/null +++ b/app/Modules/Integration/Controllers/OrderSyncController.php @@ -0,0 +1,109 @@ +inventoryService = $inventoryService; + } + + public function store(Request $request) + { + $request->validate([ + 'external_order_id' => 'required|string|unique:sales_orders,external_order_id', + 'warehouse' => 'nullable|string', + 'warehouse_id' => 'nullable|exists:warehouses,id', + 'items' => 'required|array', + 'items.*.pos_product_id' => 'required|string', + 'items.*.qty' => 'required|numeric|min:0.0001', + 'items.*.price' => 'required|numeric', + ]); + + try { + return DB::transaction(function () use ($request) { + // 1. Create Order + $order = SalesOrder::create([ + 'external_order_id' => $request->external_order_id, + 'status' => 'completed', + 'payment_method' => $request->payment_method ?? 'cash', + 'total_amount' => 0, // Will calculate + 'sold_at' => $request->sold_at ?? now(), + 'raw_payload' => $request->all(), + ]); + + // Find Warehouse (Default to "銷售倉庫") + $warehouseId = $request->warehouse_id; + + if (empty($warehouseId)) { + $warehouseName = $request->warehouse ?: '銷售倉庫'; + $warehouse = Warehouse::firstOrCreate(['name' => $warehouseName], [ + 'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))), + 'type' => 'system_sales', + 'is_active' => true, + ]); + $warehouseId = $warehouse->id; + } + + $totalAmount = 0; + + foreach ($request->items as $itemData) { + // Find product by external ID (Strict Check) + $product = Product::where('external_pos_id', $itemData['pos_product_id'])->first(); + + if (!$product) { + throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first."); + } + + $qty = $itemData['qty']; + $price = $itemData['price']; + $lineTotal = $qty * $price; + $totalAmount += $lineTotal; + + // 2. Create Order Item + SalesOrderItem::create([ + 'sales_order_id' => $order->id, + 'product_id' => $product->id, + 'product_name' => $product->name, // Snapshot name + 'quantity' => $qty, + 'price' => $price, + 'total' => $lineTotal, + ]); + + // 3. Deduct Stock (Force negative allowed for POS orders) + $this->inventoryService->decreaseStock( + $product->id, + $warehouseId, + $qty, + "POS Order: " . $order->external_order_id, + true // Force = true + ); + } + + $order->update(['total_amount' => $totalAmount]); + + return response()->json([ + 'message' => 'Order synced and stock deducted successfully', + 'order_id' => $order->id, + ], 201); + }); + + } catch (\Exception $e) { + Log::error('Order Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]); + return response()->json(['message' => 'Sync failed: ' . $e->getMessage()], 400); + } + } +} diff --git a/app/Modules/Integration/Controllers/ProductSyncController.php b/app/Modules/Integration/Controllers/ProductSyncController.php new file mode 100644 index 0000000..af939ff --- /dev/null +++ b/app/Modules/Integration/Controllers/ProductSyncController.php @@ -0,0 +1,47 @@ +productService = $productService; + } + + public function upsert(Request $request) + { + $request->validate([ + 'external_pos_id' => 'required|string', + 'name' => 'required|string', + 'price' => 'nullable|numeric', + 'sku' => 'nullable|string', + 'barcode' => 'nullable|string', + 'category' => 'nullable|string', + 'unit' => 'nullable|string', + 'updated_at' => 'nullable|date', + ]); + + try { + $product = $this->productService->upsertFromPos($request->all()); + + return response()->json([ + 'message' => 'Product synced successfully', + 'data' => [ + 'id' => $product->id, + 'external_pos_id' => $product->external_pos_id, + ] + ]); + } catch (\Exception $e) { + Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]); + return response()->json(['message' => 'Sync failed'], 500); + } + } +} diff --git a/app/Modules/Integration/IntegrationServiceProvider.php b/app/Modules/Integration/IntegrationServiceProvider.php new file mode 100644 index 0000000..1ea7da9 --- /dev/null +++ b/app/Modules/Integration/IntegrationServiceProvider.php @@ -0,0 +1,24 @@ +loadRoutesFrom(__DIR__ . '/Routes/api.php'); + $this->loadMigrationsFrom(__DIR__ . '/Database/Migrations'); + + // Register Middleware Alias + Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class); + } + + public function register() + { + // + } +} diff --git a/app/Modules/Integration/Middleware/TenantIdentificationMiddleware.php b/app/Modules/Integration/Middleware/TenantIdentificationMiddleware.php new file mode 100644 index 0000000..eb705f6 --- /dev/null +++ b/app/Modules/Integration/Middleware/TenantIdentificationMiddleware.php @@ -0,0 +1,58 @@ +header('X-Tenant-Domain'); + + if (! $domain) { + return response()->json([ + 'message' => 'Missing X-Tenant-Domain header.', + ], 400); + } + + // 2. Find Tenant by domain + // Assuming domains are stored in 'domains' table and linked to tenants + // Or using Stancl's tenant finder. + // Stancl Tenancy usually finds by domain automatically for web routes, but for API + // we are doing manual identification because we might not be using subdomains for API calls (or maybe we are). + // If the API endpoint is centrally hosted (e.g. api.star-erp.com/v1/...), we need this header. + + // Let's try to initialize tenancy manually. + // We need to find the tenant model that has this domain. + try { + $tenant = \App\Modules\Core\Models\Tenant::whereHas('domains', function ($query) use ($domain) { + $query->where('domain', $domain); + })->first(); + + if (! $tenant) { + return response()->json([ + 'message' => 'Tenant not found.', + ], 404); + } + + Tenancy::initialize($tenant); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'Tenant initialization failed: ' . $e->getMessage(), + ], 500); + } + + return $next($request); + } +} diff --git a/app/Modules/Integration/Models/SalesOrder.php b/app/Modules/Integration/Models/SalesOrder.php new file mode 100644 index 0000000..450e560 --- /dev/null +++ b/app/Modules/Integration/Models/SalesOrder.php @@ -0,0 +1,31 @@ + 'datetime', + 'raw_payload' => 'array', + 'total_amount' => 'decimal:4', + ]; + + public function items(): HasMany + { + return $this->hasMany(SalesOrderItem::class); + } +} diff --git a/app/Modules/Integration/Models/SalesOrderItem.php b/app/Modules/Integration/Models/SalesOrderItem.php new file mode 100644 index 0000000..dd7c048 --- /dev/null +++ b/app/Modules/Integration/Models/SalesOrderItem.php @@ -0,0 +1,31 @@ + 'decimal:4', + 'price' => 'decimal:4', + 'total' => 'decimal:4', + ]; + + public function order(): BelongsTo + { + return $this->belongsTo(SalesOrder::class, 'sales_order_id'); + } +} diff --git a/app/Modules/Integration/Routes/api.php b/app/Modules/Integration/Routes/api.php new file mode 100644 index 0000000..571c93c --- /dev/null +++ b/app/Modules/Integration/Routes/api.php @@ -0,0 +1,12 @@ +middleware(['api', 'integration.tenant', 'auth:sanctum']) // integration.tenant middleware to identify tenant + ->group(function () { + Route::post('products/upsert', [ProductSyncController::class, 'upsert']); + Route::post('orders', [OrderSyncController::class, 'store']); + }); diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index f77cd98..2bf631a 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -21,9 +21,10 @@ interface InventoryServiceInterface * @param int $warehouseId * @param float $quantity * @param string|null $reason + * @param bool $force * @return void */ - public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void; + public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false): void; /** * Get all active warehouses. diff --git a/app/Modules/Inventory/Models/Product.php b/app/Modules/Inventory/Models/Product.php index e243aba..771eba5 100644 --- a/app/Modules/Inventory/Models/Product.php +++ b/app/Modules/Inventory/Models/Product.php @@ -18,7 +18,9 @@ class Product extends Model protected $fillable = [ 'code', 'barcode', + 'sku', 'name', + 'external_pos_id', 'category_id', 'brand', 'specification', diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 314371d..ece5d17 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -59,9 +59,9 @@ class InventoryService implements InventoryServiceInterface return $stock >= $quantity; } - public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null): void + public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false): void { - DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason) { + DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force) { $inventories = Inventory::where('product_id', $productId) ->where('warehouse_id', $warehouseId) ->where('quantity', '>', 0) @@ -79,8 +79,30 @@ class InventoryService implements InventoryServiceInterface } if ($remainingToDecrease > 0) { - // 這裡可以選擇報錯或允許負庫存,目前為了嚴謹拋出異常 - throw new \Exception("庫存不足,無法扣除所有請求的數量。"); + if ($force) { + // Find any existing inventory record in this warehouse to subtract from, or create one + $inventory = Inventory::where('product_id', $productId) + ->where('warehouse_id', $warehouseId) + ->first(); + + if (!$inventory) { + $inventory = Inventory::create([ + 'warehouse_id' => $warehouseId, + 'product_id' => $productId, + 'quantity' => 0, + 'unit_cost' => 0, + 'total_value' => 0, + 'batch_number' => 'POS-AUTO-' . time(), + 'arrival_date' => now(), + 'origin_country' => 'TW', + 'quality_status' => 'normal', + ]); + } + + $this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason); + } else { + throw new \Exception("庫存不足,無法扣除所有請求的數量。"); + } } }); } diff --git a/app/Modules/Inventory/Services/ProductService.php b/app/Modules/Inventory/Services/ProductService.php new file mode 100644 index 0000000..26b4bac --- /dev/null +++ b/app/Modules/Inventory/Services/ProductService.php @@ -0,0 +1,73 @@ +first(); + + if (!$product) { + // If not found, create new + // Optional: Check SKU conflict if needed, but for now trust POS ID + $product = new Product(); + $product->external_pos_id = $externalId; + } + + // Map allowed fields + $product->name = $data['name']; + $product->barcode = $data['barcode'] ?? $product->barcode; + $product->sku = $data['sku'] ?? $product->sku; // Maybe allow SKU update? + $product->price = $data['price'] ?? 0; + + // Generate Code if missing (use sku or external_id) + if (empty($product->code)) { + $product->code = $data['code'] ?? ($product->sku ?? $product->external_pos_id); + } + + // Handle Category (Default: 未分類) + if (empty($product->category_id)) { + $categoryName = $data['category'] ?? '未分類'; + $category = Category::firstOrCreate( + ['name' => $categoryName], + ['code' => 'CAT-' . strtoupper(bin2hex(random_bytes(4)))] + ); + $product->category_id = $category->id; + } + + // Handle Base Unit (Default: 個) + if (empty($product->base_unit_id)) { + $unitName = $data['unit'] ?? '個'; + $unit = Unit::firstOrCreate(['name' => $unitName]); + $product->base_unit_id = $unit->id; + } + + $product->is_active = $data['is_active'] ?? true; + + $product->save(); + + return $product; + }); + } +} diff --git a/composer.json b/composer.json index c95b4cb..65025cd 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "php": "^8.2", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", "maatwebsite/excel": "^3.1", "spatie/laravel-activitylog": "^4.10", diff --git a/composer.lock b/composer.lock index aa484d6..a19c483 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b3cbace7e72a7a68b5aefdd82bea205a", + "content-hash": "0efc099e328144f00fc558c52ff945c4", "packages": [ { "name": "brick/math", @@ -1673,6 +1673,69 @@ }, "time": "2025-11-21T20:52:52+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "c978c82b2b8ab685468a7ca35224497d541b775a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/c978c82b2b8ab685468a7ca35224497d541b775a", + "reference": "c978c82b2b8ab685468a7ca35224497d541b775a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0", + "illuminate/contracts": "^11.0|^12.0", + "illuminate/database": "^11.0|^12.0", + "illuminate/support": "^11.0|^12.0", + "php": "^8.2", + "symfony/console": "^7.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-01-22T22:27:01+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.7", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..44527d6 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/migrations/2026_01_16_152857_create_activity_log_table.php b/database/migrations/2026_01_16_152857_create_activity_log_table.php index 7c05bc8..945796d 100644 --- a/database/migrations/2026_01_16_152857_create_activity_log_table.php +++ b/database/migrations/2026_01_16_152857_create_activity_log_table.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class CreateActivityLogTable extends Migration +return new class extends Migration { public function up() { @@ -24,4 +24,4 @@ class CreateActivityLogTable extends Migration { Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); } -} +}; diff --git a/database/migrations/2026_01_16_152858_add_event_column_to_activity_log_table.php b/database/migrations/2026_01_16_152858_add_event_column_to_activity_log_table.php index 7b797fd..16b7964 100644 --- a/database/migrations/2026_01_16_152858_add_event_column_to_activity_log_table.php +++ b/database/migrations/2026_01_16_152858_add_event_column_to_activity_log_table.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddEventColumnToActivityLogTable extends Migration +return new class extends Migration { public function up() { @@ -19,4 +19,4 @@ class AddEventColumnToActivityLogTable extends Migration $table->dropColumn('event'); }); } -} +}; diff --git a/database/migrations/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php b/database/migrations/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php index 8f7db66..85d4025 100644 --- a/database/migrations/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php +++ b/database/migrations/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddBatchUuidColumnToActivityLogTable extends Migration +return new class extends Migration { public function up() { @@ -19,4 +19,4 @@ class AddBatchUuidColumnToActivityLogTable extends Migration $table->dropColumn('batch_uuid'); }); } -} +}; diff --git a/database/migrations/tenant/2026_01_16_152857_create_activity_log_table.php b/database/migrations/tenant/2026_01_16_152857_create_activity_log_table.php index 7c05bc8..945796d 100644 --- a/database/migrations/tenant/2026_01_16_152857_create_activity_log_table.php +++ b/database/migrations/tenant/2026_01_16_152857_create_activity_log_table.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class CreateActivityLogTable extends Migration +return new class extends Migration { public function up() { @@ -24,4 +24,4 @@ class CreateActivityLogTable extends Migration { Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); } -} +}; diff --git a/database/migrations/tenant/2026_01_16_152858_add_event_column_to_activity_log_table.php b/database/migrations/tenant/2026_01_16_152858_add_event_column_to_activity_log_table.php index 7b797fd..16b7964 100644 --- a/database/migrations/tenant/2026_01_16_152858_add_event_column_to_activity_log_table.php +++ b/database/migrations/tenant/2026_01_16_152858_add_event_column_to_activity_log_table.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddEventColumnToActivityLogTable extends Migration +return new class extends Migration { public function up() { @@ -19,4 +19,4 @@ class AddEventColumnToActivityLogTable extends Migration $table->dropColumn('event'); }); } -} +}; diff --git a/database/migrations/tenant/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php b/database/migrations/tenant/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php index 8f7db66..85d4025 100644 --- a/database/migrations/tenant/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php +++ b/database/migrations/tenant/2026_01_16_152859_add_batch_uuid_column_to_activity_log_table.php @@ -4,7 +4,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Database\Schema\Blueprint; use Illuminate\Database\Migrations\Migration; -class AddBatchUuidColumnToActivityLogTable extends Migration +return new class extends Migration { public function up() { @@ -19,4 +19,4 @@ class AddBatchUuidColumnToActivityLogTable extends Migration $table->dropColumn('batch_uuid'); }); } -} +}; diff --git a/database/migrations/tenant/2026_02_06_101512_create_personal_access_tokens_table.php b/database/migrations/tenant/2026_02_06_101512_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/tenant/2026_02_06_101512_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/tenant/2026_02_06_102000_add_external_pos_id_to_products_table.php b/database/migrations/tenant/2026_02_06_102000_add_external_pos_id_to_products_table.php new file mode 100644 index 0000000..7d9315f --- /dev/null +++ b/database/migrations/tenant/2026_02_06_102000_add_external_pos_id_to_products_table.php @@ -0,0 +1,28 @@ +string('external_pos_id')->nullable()->after('id')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('external_pos_id'); + }); + } +}; diff --git a/database/migrations/tenant/2026_02_06_102100_create_sales_orders_tables.php b/database/migrations/tenant/2026_02_06_102100_create_sales_orders_tables.php new file mode 100644 index 0000000..709e592 --- /dev/null +++ b/database/migrations/tenant/2026_02_06_102100_create_sales_orders_tables.php @@ -0,0 +1,45 @@ +id(); + $table->string('external_order_id')->nullable()->unique(); + $table->string('status')->default('completed'); + $table->string('payment_method')->nullable(); + $table->decimal('total_amount', 12, 4)->default(0); + $table->timestamp('sold_at')->useCurrent(); + $table->json('raw_payload')->nullable(); + $table->timestamps(); + }); + + Schema::create('sales_order_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('sales_order_id')->constrained('sales_orders')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->string('product_name'); + $table->decimal('quantity', 12, 4); + $table->decimal('price', 12, 4); + $table->decimal('total', 12, 4); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sales_order_items'); + Schema::dropIfExists('sales_orders'); + } +}; diff --git a/database/migrations/tenant/2026_02_06_103718_add_sku_to_products_table.php b/database/migrations/tenant/2026_02_06_103718_add_sku_to_products_table.php new file mode 100644 index 0000000..2412a2a --- /dev/null +++ b/database/migrations/tenant/2026_02_06_103718_add_sku_to_products_table.php @@ -0,0 +1,28 @@ +string('sku')->nullable()->after('barcode')->comment('SKU'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn(['sku']); + }); + } +}; diff --git a/resources/js/Pages/Landlord/Tenant/Show.tsx b/resources/js/Pages/Landlord/Tenant/Show.tsx index c4ceaad..8f8ef00 100644 --- a/resources/js/Pages/Landlord/Tenant/Show.tsx +++ b/resources/js/Pages/Landlord/Tenant/Show.tsx @@ -19,16 +19,44 @@ interface Tenant { domains: Domain[]; } -interface Props { - tenant: Tenant; +interface Token { + id: number; + name: string; + last_used_at: string; + created_at: string; } -export default function TenantShow({ tenant }: Props) { +interface Flash { + success: string | null; + error: string | null; + new_token: string | null; +} + +interface Props { + tenant: Tenant; + tokens: Token[]; + flash: Flash; +} + +export default function TenantShow({ tenant, tokens = [], flash }: Props) { const [showAddDomain, setShowAddDomain] = useState(false); + const [showAddToken, setShowAddToken] = useState(false); + const { data, setData, post, processing, errors, reset } = useForm({ domain: "", }); + // Token Form + const { + data: tokenData, + setData: setTokenData, + post: postToken, + processing: processingToken, + reset: resetToken + } = useForm({ + name: "", + }); + const handleAddDomain = (e: FormEvent) => { e.preventDefault(); post(route("landlord.tenants.domains.store", tenant.id), { @@ -39,6 +67,24 @@ export default function TenantShow({ tenant }: Props) { }); }; + const handleAddToken = (e: FormEvent) => { + e.preventDefault(); + postToken(route("landlord.tenants.tokens.store", tenant.id), { + onSuccess: () => { + resetToken(); + // Don't close immediately if we want to show flash message? + // Flash message usually appears on redirect back. + setShowAddToken(false); + }, + }); + }; + + const handleRevokeToken = (tokenId: number) => { + if (confirm("確定要撤銷此金鑰嗎?撤銷後無法復原,POS 連線將中斷。")) { + router.delete(route("landlord.tenants.tokens.destroy", [tenant.id, tokenId])); + } + }; + const handleRemoveDomain = (domainId: number) => { if (confirm("確定要移除這個域名嗎?")) { router.delete(route("landlord.tenants.domains.destroy", [tenant.id, domainId])); @@ -174,6 +220,84 @@ export default function TenantShow({ tenant }: Props) { )} + + {/* API Tokens Card */} +
+
+

API 金鑰 (POS 整合)

+ +
+ + {flash?.new_token && ( +
+

金鑰建立成功!請立即複製,離開後將無法再次查看。

+
+ + {flash.new_token} + +
+
+ )} + + {showAddToken && ( +
+ setTokenData("name", e.target.value)} + placeholder="金鑰名稱 (例如: POS-01)" + className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-main focus:border-primary-main" + /> + +
+ )} + + {tokens.length === 0 ? ( +

尚未建立任何 API 金鑰

+ ) : ( +
+ + + + + + + + + + + {tokens.map((token) => ( + + + + + + + ))} + +
名稱建立時間最後使用操作
{token.name}{token.created_at}{token.last_used_at} + +
+
+ )} +
); diff --git a/routes/landlord.php b/routes/landlord.php index 67ebd71..2d421b9 100644 --- a/routes/landlord.php +++ b/routes/landlord.php @@ -34,4 +34,8 @@ Route::prefix('landlord')->name('landlord.')->middleware(['web', 'auth', \App\Ht // 租戶樣式管理 Route::get('tenants/{tenant}/branding', [TenantController::class, 'showBranding'])->name('tenants.branding'); Route::post('tenants/{tenant}/branding', [TenantController::class, 'updateBranding'])->name('tenants.branding.update'); + + // 租戶 API Token 管理 + Route::post('tenants/{tenant}/tokens', [TenantController::class, 'createToken'])->name('tenants.tokens.store'); + Route::delete('tenants/{tenant}/tokens/{token}', [TenantController::class, 'revokeToken'])->name('tenants.tokens.destroy'); }); diff --git a/tests/Feature/Integration/PosApiTest.php b/tests/Feature/Integration/PosApiTest.php new file mode 100644 index 0000000..38b3f04 --- /dev/null +++ b/tests/Feature/Integration/PosApiTest.php @@ -0,0 +1,202 @@ +domain = 'test-' . \Illuminate\Support\Str::random(8) . '.erp.local'; + $tenantId = 'test_tenant_' . \Illuminate\Support\Str::random(8); + + // Ensure we are in central context + tenancy()->central(function () use ($tenantId) { + // Create a tenant + $this->tenant = Tenant::create([ + 'id' => $tenantId, + 'name' => 'Test Tenant', + ]); + + $this->tenant->domains()->create(['domain' => $this->domain]); + }); + + // Initialize to create User and Token + tenancy()->initialize($this->tenant); + + \Artisan::call('tenants:migrate'); + + $this->user = User::factory()->create([ + 'email' => 'admin@test.local', + 'name' => 'Admin', + ]); + + $this->token = $this->user->createToken('POS-Test-Token')->plainTextToken; + + $category = \App\Modules\Inventory\Models\Category::create(['name' => 'General', 'code' => 'GEN']); + + // Create a product for testing + Product::create([ + 'name' => 'Existing Product', + 'code' => 'P-001', + 'external_pos_id' => 'EXT-001', + 'sku' => 'SKU-001', + 'price' => 100, + 'is_active' => true, + 'category_id' => $category->id, + ]); + + // End tenancy initialization to simulate external request + tenancy()->end(); + } + + protected function tearDown(): void + { + if ($this->tenant) { + $this->tenant->delete(); + } + parent::tearDown(); + } + + public function test_upsert_product_creates_new_product() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $payload = [ + 'external_pos_id' => 'EXT-NEW-002', + 'name' => 'New Product', + 'price' => 200, + 'sku' => 'SKU-NEW', + ]; + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->postJson('/api/v1/integration/products/upsert', $payload); + + $response->assertStatus(200) + ->assertJsonPath('message', 'Product synced successfully'); + + // Verify in Tenant DB + tenancy()->initialize($this->tenant); + $this->assertDatabaseHas('products', [ + 'external_pos_id' => 'EXT-NEW-002', + 'name' => 'New Product', + ]); + tenancy()->end(); + } + + public function test_upsert_product_updates_existing_product() + { + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $payload = [ + 'external_pos_id' => 'EXT-001', + 'name' => 'Updated Name', + 'price' => 150, + ]; + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->postJson('/api/v1/integration/products/upsert', $payload); + + $response->assertStatus(200); + + tenancy()->initialize($this->tenant); + $this->assertDatabaseHas('products', [ + 'external_pos_id' => 'EXT-001', + 'name' => 'Updated Name', + 'price' => 150, + ]); + tenancy()->end(); + } + + public function test_create_order_deducts_inventory() + { + // Setup inventory first + tenancy()->initialize($this->tenant); + $product = Product::where('external_pos_id', 'EXT-001')->first(); + + // We need a warehouse + $warehouse = \App\Modules\Inventory\Models\Warehouse::create(['name' => 'Main Warehouse', 'code' => 'MAIN']); + + // Add initial stock + \App\Modules\Inventory\Models\Inventory::create([ + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'quantity' => 100, + 'batch_number' => 'BATCH-TEST-001', + 'arrival_date' => now()->toDateString(), + 'origin_country' => 'TW', + ]); + + $warehouseId = $warehouse->id; + tenancy()->end(); + + \Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']); + + $payload = [ + 'external_order_id' => 'ORD-001', + 'warehouse_id' => $warehouseId, + 'sold_at' => now()->toIso8601String(), + 'items' => [ + [ + 'pos_product_id' => 'EXT-001', + 'qty' => 5, + 'price' => 100 + ] + ] + ]; + + $response = $this->withHeaders([ + 'X-Tenant-Domain' => $this->domain, + 'Accept' => 'application/json', + ])->postJson('/api/v1/integration/orders', $payload); + + $response->assertStatus(201) + ->assertJsonPath('message', 'Order synced and stock deducted successfully'); + + // Verify Order and Inventory + tenancy()->initialize($this->tenant); + + $this->assertDatabaseHas('sales_orders', [ + 'external_order_id' => 'ORD-001', + ]); + + $this->assertDatabaseHas('sales_order_items', [ + 'product_id' => $product->id, // We need to fetch ID again or rely on correct ID + 'quantity' => 5, + ]); + + // Verify stock deducted: 100 - 5 = 95 + $this->assertDatabaseHas('inventories', [ + 'product_id' => $product->id, + 'warehouse_id' => $warehouseId, + 'quantity' => 95, + ]); + + tenancy()->end(); + } +}