From 5f8b2a1c2dd2c3b86180f55839ce20d20e6380e4 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Mon, 2 Mar 2026 10:19:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20POS=20=E5=BA=AB=E5=AD=98?= =?UTF-8?q?=E6=9F=A5=E8=A9=A2=20API=EF=BC=9A=E5=AF=A6=E4=BD=9C=20Inventory?= =?UTF-8?q?SyncController=20=E8=88=87=E7=9B=B8=E9=97=9C=20Service=20?= =?UTF-8?q?=E9=82=8F=E8=BC=AF=EF=BC=8C=E4=B8=A6=E6=9B=B4=E6=96=B0=20API=20?= =?UTF-8?q?=E6=95=B4=E5=90=88=E6=89=8B=E5=86=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ActivityLogController.php | 1 + .../Controllers/InventorySyncController.php | 51 ++++++++++++++++ app/Modules/Integration/Routes/api.php | 2 + .../Contracts/InventoryServiceInterface.php | 8 +++ .../Controllers/InventoryController.php | 4 +- app/Modules/Inventory/Models/Inventory.php | 30 ++++++++-- .../Inventory/Models/InventoryTransaction.php | 46 +++++++++++++++ app/Modules/Inventory/Models/Product.php | 58 +++++++++++++------ .../Inventory/Services/InventoryService.php | 30 ++++++++++ resources/markdown/manual/api-integration.md | 52 ++++++++++++++++- 10 files changed, 255 insertions(+), 27 deletions(-) create mode 100644 app/Modules/Integration/Controllers/InventorySyncController.php diff --git a/app/Modules/Core/Controllers/ActivityLogController.php b/app/Modules/Core/Controllers/ActivityLogController.php index 794fcbe..1b4cc3a 100644 --- a/app/Modules/Core/Controllers/ActivityLogController.php +++ b/app/Modules/Core/Controllers/ActivityLogController.php @@ -22,6 +22,7 @@ class ActivityLogController extends Controller 'App\Modules\Procurement\Models\PurchaseOrder' => '採購單', 'App\Modules\Inventory\Models\Warehouse' => '倉庫', 'App\Modules\Inventory\Models\Inventory' => '庫存', + 'App\Modules\Inventory\Models\InventoryTransaction' => '庫存異動紀錄', 'App\Modules\Finance\Models\UtilityFee' => '公共事業費', 'App\Modules\Inventory\Models\GoodsReceipt' => '進貨單', 'App\Modules\Production\Models\ProductionOrder' => '生產工單', diff --git a/app/Modules/Integration/Controllers/InventorySyncController.php b/app/Modules/Integration/Controllers/InventorySyncController.php new file mode 100644 index 0000000..a1f44fa --- /dev/null +++ b/app/Modules/Integration/Controllers/InventorySyncController.php @@ -0,0 +1,51 @@ +inventoryService = $inventoryService; + } + + /** + * 提供外部 POS 查詢指定倉庫的商品庫存餘額 + * + * @param string $warehouseCode + * @return JsonResponse + */ + public function show(string $warehouseCode): JsonResponse + { + // 透過 Service 調用跨模組庫存查詢功能 + $inventoryData = $this->inventoryService->getPosInventoryByWarehouseCode($warehouseCode); + + // 若回傳 null,表示尋無此倉庫代碼 + if (is_null($inventoryData)) { + return response()->json([ + 'status' => 'error', + 'message' => "Warehouse with code '{$warehouseCode}' not found.", + ], 404); + } + + // 以 JSON 格式回傳組合好的商品庫存列表 + return response()->json([ + 'status' => 'success', + 'warehouse_code' => $warehouseCode, + 'data' => $inventoryData->map(function ($item) { + return [ + 'external_pos_id' => $item->external_pos_id, + 'product_code' => $item->product_code, + 'product_name' => $item->product_name, + 'quantity' => (float) $item->total_quantity, + ]; + }) + ], 200); + } +} diff --git a/app/Modules/Integration/Routes/api.php b/app/Modules/Integration/Routes/api.php index bf7a190..f0ee7d7 100644 --- a/app/Modules/Integration/Routes/api.php +++ b/app/Modules/Integration/Routes/api.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route; use App\Modules\Integration\Controllers\ProductSyncController; use App\Modules\Integration\Controllers\OrderSyncController; use App\Modules\Integration\Controllers\VendingOrderSyncController; +use App\Modules\Integration\Controllers\InventorySyncController; Route::prefix('api/v1/integration') ->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum']) @@ -11,4 +12,5 @@ Route::prefix('api/v1/integration') Route::post('products/upsert', [ProductSyncController::class, 'upsert']); Route::post('orders', [OrderSyncController::class, 'store']); Route::post('vending/orders', [VendingOrderSyncController::class, 'store']); + Route::get('inventory/{warehouse_code}', [InventorySyncController::class, 'show']); }); diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index 1976883..4fa2433 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -157,4 +157,12 @@ interface InventoryServiceInterface * Get items expiring soon for dashboard. */ public function getExpiringSoon(int $limit = 5): \Illuminate\Support\Collection; + + /** + * Get inventory summary (group by product) for a specific warehouse code + * + * @param string $code + * @return \Illuminate\Support\Collection|null + */ + public function getPosInventoryByWarehouseCode(string $code); } \ No newline at end of file diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index b5e090b..bc8f615 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -259,7 +259,7 @@ class InventoryController extends Controller $inventory->quantity = $newQty; // 更新總價值 $inventory->total_value = $inventory->quantity * $inventory->unit_cost; - $inventory->save(); + $inventory->saveQuietly(); // 使用 saveQuietly() 避免產生冗餘的「單據已更新」日誌 // 寫入異動紀錄 $inventory->transactions()->create([ @@ -436,7 +436,7 @@ class InventoryController extends Controller $inventory->quantity = $newQty; // 更新總值 $inventory->total_value = $inventory->quantity * $inventory->unit_cost; - $inventory->save(); + $inventory->saveQuietly(); // 使用 saveQuietly() 避免與下方的 transaction 紀錄重複 // 異動類型映射 $type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment'); diff --git a/app/Modules/Inventory/Models/Inventory.php b/app/Modules/Inventory/Models/Inventory.php index 5f24bd7..bf1fbf5 100644 --- a/app/Modules/Inventory/Models/Inventory.php +++ b/app/Modules/Inventory/Models/Inventory.php @@ -58,8 +58,11 @@ class Inventory extends Model public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) { - $properties = $activity->properties; - $attributes = $properties['attributes'] ?? []; + // 核心:轉換為陣列以避免 Indirect modification error + $properties = $activity->properties instanceof \Illuminate\Support\Collection + ? $activity->properties->toArray() + : $activity->properties; + $snapshot = $properties['snapshot'] ?? []; // 始終對名稱進行快照以便於上下文顯示,即使 ID 未更改 @@ -69,11 +72,28 @@ class Inventory extends Model // 如果已設定原因,則進行捕捉 if ($this->activityLogReason) { - $attributes['_reason'] = $this->activityLogReason; + $properties['attributes']['_reason'] = $this->activityLogReason; } - - $properties['attributes'] = $attributes; + $properties['snapshot'] = $snapshot; + + // 全域 ID 轉名稱邏輯 + $resolver = function (&$data) { + if (empty($data) || !is_array($data)) return; + + // 倉庫 ID 轉換 + if (isset($data['warehouse_id']) && is_numeric($data['warehouse_id'])) { + $data['warehouse_id'] = \App\Modules\Inventory\Models\Warehouse::find($data['warehouse_id'])?->name; + } + // 商品 ID 轉換 + if (isset($data['product_id']) && is_numeric($data['product_id'])) { + $data['product_id'] = \App\Modules\Inventory\Models\Product::find($data['product_id'])?->name; + } + }; + + if (isset($properties['attributes'])) $resolver($properties['attributes']); + if (isset($properties['old'])) $resolver($properties['old']); + $activity->properties = $properties; } diff --git a/app/Modules/Inventory/Models/InventoryTransaction.php b/app/Modules/Inventory/Models/InventoryTransaction.php index 0f2f25a..72ec613 100644 --- a/app/Modules/Inventory/Models/InventoryTransaction.php +++ b/app/Modules/Inventory/Models/InventoryTransaction.php @@ -10,6 +10,7 @@ class InventoryTransaction extends Model { /** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */ use HasFactory; + use \Spatie\Activitylog\Traits\LogsActivity; protected $fillable = [ 'inventory_id', @@ -41,4 +42,49 @@ class InventoryTransaction extends Model { return $this->morphTo(); } + + public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions + { + return \Spatie\Activitylog\LogOptions::defaults() + ->logAll() + ->dontLogIfAttributesChangedOnly(['updated_at']) + // 取消 logOnlyDirty,代表新增時(created)也要留紀錄 + ->dontSubmitEmptyLogs(); + } + + public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) + { + $properties = $activity->properties instanceof \Illuminate\Support\Collection + ? $activity->properties->toArray() + : $activity->properties; + + $snapshot = $properties['snapshot'] ?? []; + + // 試著取得商品與倉庫名稱來作為主要顯示依據 + $inventory = $this->inventory; + if ($inventory) { + $snapshot['warehouse_name'] = $inventory->warehouse ? $inventory->warehouse->name : null; + $snapshot['product_name'] = $inventory->product ? $inventory->product->name : null; + $snapshot['batch_number'] = $inventory->batch_number; + } + + // 把異動類型與數量也拉到 snapshot + $snapshot['type'] = $this->type; + $snapshot['quantity'] = $this->quantity; + $snapshot['reason'] = $this->reason; + + // 替換使用者名稱 + $resolver = function (&$data) { + if (empty($data) || !is_array($data)) return; + if (isset($data['user_id']) && is_numeric($data['user_id'])) { + $data['user_id'] = \App\Modules\Core\Models\User::find($data['user_id'])?->name; + } + }; + + if (isset($properties['attributes'])) $resolver($properties['attributes']); + if (isset($properties['old'])) $resolver($properties['old']); + + $properties['snapshot'] = $snapshot; + $activity->properties = $properties; + } } diff --git a/app/Modules/Inventory/Models/Product.php b/app/Modules/Inventory/Models/Product.php index 29bb27d..8b84c4b 100644 --- a/app/Modules/Inventory/Models/Product.php +++ b/app/Modules/Inventory/Models/Product.php @@ -85,30 +85,50 @@ class Product extends Model public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName) { - $properties = $activity->properties; - $attributes = $properties['attributes'] ?? []; + // 核心:轉換為陣列以避免 Indirect modification error + $properties = $activity->properties instanceof \Illuminate\Support\Collection + ? $activity->properties->toArray() + : $activity->properties; + $snapshot = $properties['snapshot'] ?? []; - // 處理分類名稱快照 - if (isset($attributes['category_id'])) { - $category = Category::find($attributes['category_id']); - $snapshot['category_name'] = $category ? $category->name : null; - } - - // 處理單位名稱快照 - $unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id']; - foreach ($unitFields as $field) { - if (isset($attributes[$field])) { - $unit = Unit::find($attributes[$field]); - $nameKey = str_replace('_id', '_name', $field); - $snapshot[$nameKey] = $unit ? $unit->name : null; - } - } - // 始終對自身名稱進行快照以便於上下文顯示(這樣日誌總是顯示 "可樂") $snapshot['name'] = $this->name; - $properties['attributes'] = $attributes; + $properties['snapshot'] = $snapshot; + + // 全域 ID 轉名稱邏輯 + $resolver = function (&$data) use (&$snapshot) { + if (empty($data) || !is_array($data)) return; + + // 處理分類名稱 + if (isset($data['category_id']) && is_numeric($data['category_id'])) { + $categoryName = Category::find($data['category_id'])?->name; + $data['category_id'] = $categoryName; + if (!isset($snapshot['category_name']) && $categoryName) { + $snapshot['category_name'] = $categoryName; + } + } + + // 處理單位名稱 + $unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id']; + foreach ($unitFields as $field) { + if (isset($data[$field]) && is_numeric($data[$field])) { + $unitName = Unit::find($data[$field])?->name; + $data[$field] = $unitName; + + $nameKey = str_replace('_id', '_name', $field); + if (!isset($snapshot[$nameKey]) && $unitName) { + $snapshot[$nameKey] = $unitName; + } + } + } + }; + + if (isset($properties['attributes'])) $resolver($properties['attributes']); + if (isset($properties['old'])) $resolver($properties['old']); + + // 因為 resolver 內部可能更新了 snapshot,所以再覆寫一次 $properties['snapshot'] = $snapshot; $activity->properties = $properties; } diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index defd7a6..a8e8746 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -644,4 +644,34 @@ class InventoryService implements InventoryServiceInterface ] ); } + + /** + * 取得特定倉庫代碼的所屬商品總庫存 (給 POS/外部系統同步使用) + * + * @param string $code + * @return \Illuminate\Support\Collection|null + */ + public function getPosInventoryByWarehouseCode(string $code) + { + $warehouse = Warehouse::where('code', $code)->first(); + + if (!$warehouse) { + return null; + } + + // 整理該倉庫的庫存,以 product_id 進行 GROUP BY 並加總 quantity + return DB::table('inventories') + ->join('products', 'inventories.product_id', '=', 'products.id') + ->where('inventories.warehouse_id', $warehouse->id) + ->whereNull('inventories.deleted_at') + ->whereNull('products.deleted_at') + ->select( + 'products.external_pos_id', + 'products.code as product_code', + 'products.name as product_name', + DB::raw('SUM(inventories.quantity) as total_quantity') + ) + ->groupBy('inventories.product_id', 'products.external_pos_id', 'products.code', 'products.name') + ->get(); + } } diff --git a/resources/markdown/manual/api-integration.md b/resources/markdown/manual/api-integration.md index dfab930..ef0a422 100644 --- a/resources/markdown/manual/api-integration.md +++ b/resources/markdown/manual/api-integration.md @@ -73,7 +73,57 @@ Star ERP 系統提供外部整合 API (Integration API) 供電商前台、POS --- -## 2. 訂單資料寫入與扣庫 (Create Order) +## 2. 門市庫存查詢 (Query Inventory) + +此 API 用於讓外部系統(如 POS)依據特定的「倉庫代碼」,查詢該倉庫目前所有商品的庫存餘額。 +**注意**:此 API 會回傳該倉庫內的所有商品數量,不論該商品是否已綁定外部 POS ID。 + +- **Endpoint**: `/inventory/{warehouse_code}` +- **Method**: `GET` + +### URL 路徑參數 + +| 參數名稱 | 類型 | 必填 | 說明 | +| :--- | :--- | :---: | :--- | +| `warehouse_code` | String | **是** | 要查詢的倉庫代碼 (例如:`STORE-001`) | + +### Response + +**Success (HTTP 200)** +回傳該倉庫內所有的商品目前庫存總數。若商品未建置 `external_pos_id`,該欄位將顯示為 `null`。 +```json +{ + "status": "success", + "warehouse_code": "STORE-001", + "data": [ + { + "external_pos_id": "POS-ITEM-001", + "product_code": "PROD-A001", + "product_name": "特級冷壓初榨橄欖油 500ml", + "quantity": 15 + }, + { + "external_pos_id": null, + "product_code": "MAT-001", + "product_name": "未包裝干貝醬原料", + "quantity": 2.5 + } + ] +} +``` + +**Error: Warehouse Not Found (HTTP 404)** +當傳入系統中不存在的倉庫代碼時發生。 +```json +{ + "status": "error", + "message": "Warehouse with code 'STORE-999' not found." +} +``` + +--- + +## 3. 訂單資料寫入與扣庫 (Create Order) 此 API 用於讓第三方系統(如 POS 結帳後)將「已成交」的訂單推送到 ERP。 **重要提醒**:寫入訂單的同時,ERP 會自動且**強制**扣除對應倉庫的庫存(允許扣至負數,以利後續盤點或補單)。