diff --git a/app/Modules/Integration/Controllers/OrderSyncController.php b/app/Modules/Integration/Controllers/OrderSyncController.php index dfa4f89..1dd09eb 100644 --- a/app/Modules/Integration/Controllers/OrderSyncController.php +++ b/app/Modules/Integration/Controllers/OrderSyncController.php @@ -6,66 +6,79 @@ use App\Http\Controllers\Controller; use Illuminate\Http\Request; use App\Modules\Integration\Models\SalesOrder; use App\Modules\Integration\Models\SalesOrderItem; -use App\Modules\Inventory\Services\InventoryService; -use App\Modules\Inventory\Models\Product; -use App\Modules\Inventory\Models\Warehouse; +use App\Modules\Inventory\Contracts\InventoryServiceInterface; +use App\Modules\Inventory\Contracts\ProductServiceInterface; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class OrderSyncController extends Controller { protected $inventoryService; + protected $productService; - public function __construct(InventoryService $inventoryService) - { + public function __construct( + InventoryServiceInterface $inventoryService, + ProductServiceInterface $productService + ) { $this->inventoryService = $inventoryService; + $this->productService = $productService; } public function store(Request $request) { + // 冪等性處理:若訂單已存在,回傳已建立的訂單資訊 + $existingOrder = SalesOrder::where('external_order_id', $request->external_order_id)->first(); + if ($existingOrder) { + return response()->json([ + 'message' => 'Order already exists', + 'order_id' => $existingOrder->id, + ], 200); + } + $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', + 'warehouse_id' => 'nullable|integer', + 'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other', + 'sold_at' => 'nullable|date', + 'items' => 'required|array|min:1', 'items.*.pos_product_id' => 'required|string', 'items.*.qty' => 'required|numeric|min:0.0001', - 'items.*.price' => 'required|numeric', + 'items.*.price' => 'required|numeric|min:0', ]); try { return DB::transaction(function () use ($request) { - // 1. Create Order + // 1. 建立訂單 $order = SalesOrder::create([ 'external_order_id' => $request->external_order_id, 'status' => 'completed', 'payment_method' => $request->payment_method ?? 'cash', - 'total_amount' => 0, // Will calculate + 'total_amount' => 0, 'sold_at' => $request->sold_at ?? now(), 'raw_payload' => $request->all(), ]); - // Find Warehouse (Default to "銷售倉庫") + // 2. 查找或建立倉庫 $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, - ]); + $warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName); $warehouseId = $warehouse->id; } $totalAmount = 0; + // 3. 處理訂單明細 foreach ($request->items as $itemData) { - // Find product by external ID (Strict Check) - $product = Product::where('external_pos_id', $itemData['pos_product_id'])->first(); + // 透過介面查找產品 + $product = $this->productService->findByExternalPosId($itemData['pos_product_id']); if (!$product) { - throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first."); + throw new \Exception( + "Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first." + ); } $qty = $itemData['qty']; @@ -73,23 +86,23 @@ class OrderSyncController extends Controller $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 + 'product_name' => $product->name, 'quantity' => $qty, 'price' => $price, 'total' => $lineTotal, ]); - // 3. Deduct Stock (Force negative allowed for POS orders) + // 4. 扣除庫存(強制模式,允許負庫存) $this->inventoryService->decreaseStock( $product->id, $warehouseId, $qty, "POS Order: " . $order->external_order_id, - true // Force = true + true ); } diff --git a/app/Modules/Integration/Controllers/ProductSyncController.php b/app/Modules/Integration/Controllers/ProductSyncController.php index 8cd1bf6..f1dcf4c 100644 --- a/app/Modules/Integration/Controllers/ProductSyncController.php +++ b/app/Modules/Integration/Controllers/ProductSyncController.php @@ -4,14 +4,14 @@ namespace App\Modules\Integration\Controllers; use App\Http\Controllers\Controller; use Illuminate\Http\Request; -use App\Modules\Inventory\Services\ProductService; +use App\Modules\Inventory\Contracts\ProductServiceInterface; use Illuminate\Support\Facades\Log; class ProductSyncController extends Controller { protected $productService; - public function __construct(ProductService $productService) + public function __construct(ProductServiceInterface $productService) { $this->productService = $productService; } @@ -21,7 +21,7 @@ class ProductSyncController extends Controller $request->validate([ 'external_pos_id' => 'required|string', 'name' => 'required|string', - 'price' => 'nullable|numeric', + 'price' => 'nullable|numeric|min:0', 'barcode' => 'nullable|string', 'category' => 'nullable|string', 'unit' => 'nullable|string', @@ -40,7 +40,9 @@ class ProductSyncController extends Controller ]); } catch (\Exception $e) { Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]); - return response()->json(['message' => 'Sync failed'], 500); + return response()->json([ + 'message' => 'Sync failed: ' . $e->getMessage(), + ], 500); } } } diff --git a/app/Modules/Integration/IntegrationServiceProvider.php b/app/Modules/Integration/IntegrationServiceProvider.php index 1ea7da9..659fdbb 100644 --- a/app/Modules/Integration/IntegrationServiceProvider.php +++ b/app/Modules/Integration/IntegrationServiceProvider.php @@ -4,6 +4,9 @@ namespace App\Modules\Integration; use Illuminate\Support\ServiceProvider; use Illuminate\Support\Facades\Route; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; use App\Modules\Integration\Middleware\TenantIdentificationMiddleware; class IntegrationServiceProvider extends ServiceProvider @@ -13,8 +16,13 @@ class IntegrationServiceProvider extends ServiceProvider $this->loadRoutesFrom(__DIR__ . '/Routes/api.php'); $this->loadMigrationsFrom(__DIR__ . '/Database/Migrations'); - // Register Middleware Alias + // 註冊 Middleware 別名 Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class); + + // 定義 Integration API 速率限制(每分鐘 60 次,依 Token 使用者識別) + RateLimiter::for('integration', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); } public function register() diff --git a/app/Modules/Integration/Routes/api.php b/app/Modules/Integration/Routes/api.php index 571c93c..3479f0b 100644 --- a/app/Modules/Integration/Routes/api.php +++ b/app/Modules/Integration/Routes/api.php @@ -5,7 +5,7 @@ use App\Modules\Integration\Controllers\ProductSyncController; use App\Modules\Integration\Controllers\OrderSyncController; Route::prefix('api/v1/integration') - ->middleware(['api', 'integration.tenant', 'auth:sanctum']) // integration.tenant middleware to identify tenant + ->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum']) ->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 f71053d..ee4590b 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -131,4 +131,12 @@ interface InventoryServiceInterface * @return array */ public function getDashboardStats(): array; + + /** + * 依倉庫名稱查找或建立倉庫(供外部整合用)。 + * + * @param string $warehouseName + * @return object + */ + public function findOrCreateWarehouseByName(string $warehouseName); } \ No newline at end of file diff --git a/app/Modules/Inventory/Contracts/ProductServiceInterface.php b/app/Modules/Inventory/Contracts/ProductServiceInterface.php new file mode 100644 index 0000000..ca5314b --- /dev/null +++ b/app/Modules/Inventory/Contracts/ProductServiceInterface.php @@ -0,0 +1,25 @@ +app->bind(InventoryServiceInterface::class, InventoryService::class); + $this->app->bind(ProductServiceInterface::class, ProductService::class); } public function boot(): void diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 7823085..f0b081f 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -590,5 +590,22 @@ class InventoryService implements InventoryServiceInterface 'abnormalItems' => $abnormalItems, ]; } -} + /** + * 依倉庫名稱查找或建立倉庫(供外部整合用)。 + * + * @param string $warehouseName + * @return Warehouse + */ + public function findOrCreateWarehouseByName(string $warehouseName) + { + return Warehouse::firstOrCreate( + ['name' => $warehouseName], + [ + 'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))), + 'type' => 'system_sales', + 'is_active' => true, + ] + ); + } +} diff --git a/app/Modules/Inventory/Services/ProductService.php b/app/Modules/Inventory/Services/ProductService.php index 3df7832..3b79b92 100644 --- a/app/Modules/Inventory/Services/ProductService.php +++ b/app/Modules/Inventory/Services/ProductService.php @@ -2,13 +2,14 @@ namespace App\Modules\Inventory\Services; +use App\Modules\Inventory\Contracts\ProductServiceInterface; use App\Modules\Inventory\Models\Product; use App\Modules\Inventory\Models\Category; use App\Modules\Inventory\Models\Unit; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; -class ProductService +class ProductService implements ProductServiceInterface { /** * Upsert product from external POS source. @@ -45,8 +46,8 @@ class ProductService $product->code = $data['code'] ?? $product->external_pos_id; } - // Handle Category (Default: 未分類) - if (empty($product->category_id)) { + // Handle Category — 每次同步都更新(若有傳入) + if (!empty($data['category']) || empty($product->category_id)) { $categoryName = $data['category'] ?? '未分類'; $category = Category::firstOrCreate( ['name' => $categoryName], @@ -55,8 +56,8 @@ class ProductService $product->category_id = $category->id; } - // Handle Base Unit (Default: 個) - if (empty($product->base_unit_id)) { + // Handle Base Unit — 每次同步都更新(若有傳入) + if (!empty($data['unit']) || empty($product->base_unit_id)) { $unitName = $data['unit'] ?? '個'; $unit = Unit::firstOrCreate(['name' => $unitName]); $product->base_unit_id = $unit->id; @@ -69,4 +70,15 @@ class ProductService return $product; }); } + + /** + * 透過外部 POS ID 查找產品。 + * + * @param string $externalPosId + * @return Product|null + */ + public function findByExternalPosId(string $externalPosId) + { + return Product::where('external_pos_id', $externalPosId)->first(); + } } diff --git a/docker/8.5/supervisord.conf b/docker/8.5/supervisord.conf index 656da8a..61faa9b 100644 --- a/docker/8.5/supervisord.conf +++ b/docker/8.5/supervisord.conf @@ -12,3 +12,16 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:npm] +command=/usr/bin/npm run dev +user=%(ENV_SUPERVISOR_PHP_USER)s +environment=LARAVEL_SAIL="1" +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true diff --git a/resources/js/Pages/System/Manual/Index.tsx b/resources/js/Pages/System/Manual/Index.tsx index 7601e69..cc0b77b 100644 --- a/resources/js/Pages/System/Manual/Index.tsx +++ b/resources/js/Pages/System/Manual/Index.tsx @@ -130,7 +130,8 @@ export default function ManualIndex({ toc, currentSlug, content }: Props) { #manual-article blockquote { margin: 0.75rem 0 !important; padding: 0.25rem 1rem !important; border-left: 4px solid var(--primary-main); background: #f8fafc; border-radius: 0 4px 4px 0; } #manual-article img { margin: 1rem 0 !important; border-radius: 8px; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1); } #manual-article code { padding: 0.1rem 0.3rem; background: #e6f7f3; color: #018a6a; border-radius: 4px; font-size: 0.85em; font-family: ui-monospace, monospace; } - #manual-article pre { margin: 0.75rem 0 !important; padding: 0.75rem !important; background: #1e293b; color: #f8fafc; border-radius: 8px; overflow-x: auto; } + #manual-article pre { margin: 0.75rem 0 !important; padding: 1rem !important; background: #f8fafc; color: #334155; border: 1px solid #e2e8f0; border-radius: 8px; overflow-x: auto; box-shadow: inset 0 1px 2px rgba(0,0,0,0.02); } + #manual-article pre code { background: transparent; padding: 0; color: inherit; font-size: 13.5px; } `}} />