[REFACTOR] 統一訂單同步 API 錯誤回應與修正 Linter 警告

This commit is contained in:
2026-03-19 14:07:32 +08:00
parent e3ceedc579
commit 0b4aeacb55
15 changed files with 1173 additions and 108 deletions

View File

@@ -21,9 +21,12 @@ interface InventoryServiceInterface
* @param string|null $reason
* @param bool $force
* @param string|null $slot
* @param string|null $referenceType
* @param int|string|null $referenceId
* @param string|null $batchNumber
* @return void
*/
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void;
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void;
/**
* Get all active warehouses.
@@ -162,9 +165,10 @@ interface InventoryServiceInterface
* Get inventory summary (group by product) for a specific warehouse code
*
* @param string $code
* @param array $filters
* @return \Illuminate\Support\Collection|null
*/
public function getPosInventoryByWarehouseCode(string $code);
public function getPosInventoryByWarehouseCode(string $code, array $filters = []);
/**
* 處理批量入庫邏輯 (含批號產生與現有批號累加)

View File

@@ -31,6 +31,14 @@ interface ProductServiceInterface
*/
public function findByExternalPosIds(array $externalPosIds);
/**
* 透過多個 ERP 內部 ID 查找產品。
*
* @param array $ids
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByIds(array $ids);
/**
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
*
@@ -78,4 +86,13 @@ interface ProductServiceInterface
* @return \App\Modules\Inventory\Models\Product|null
*/
public function findByBarcodeOrCode(?string $barcode, ?string $code);
/**
* 搜尋商品(供外部 API 使用)。
*
* @param array $filters
* @param int $perPage
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function searchProducts(array $filters, int $perPage = 50);
}

View File

@@ -87,42 +87,58 @@ class InventoryService implements InventoryServiceInterface
return $stock >= $quantity;
}
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null): void
public function decreaseStock(int $productId, int $warehouseId, float $quantity, ?string $reason = null, bool $force = false, ?string $slot = null, ?string $referenceType = null, $referenceId = null, ?string $batchNumber = null): void
{
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId) {
$query = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('quantity', '>', 0);
if ($slot) {
$query->where('location', $slot);
}
DB::transaction(function () use ($productId, $warehouseId, $quantity, $reason, $force, $slot, $referenceType, $referenceId, $batchNumber) {
$defaultBatch = 'NO-BATCH';
$targetBatch = $batchNumber ?? $defaultBatch;
$remainingToDecrease = $quantity;
$inventories = $query->lockForUpdate()
// 1. 優先嘗試扣除指定批號(或預設的 NO-BATCH
$inventories = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('batch_number', $targetBatch)
->where('quantity', '>', 0)
->when($slot, fn($q) => $q->where('location', $slot))
->lockForUpdate()
->orderBy('arrival_date', 'asc')
->get();
$remainingToDecrease = $quantity;
foreach ($inventories as $inventory) {
if ($remainingToDecrease <= 0) break;
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
$remainingToDecrease -= $decreaseAmount;
}
// 2. 如果還有剩餘且剛才不是扣 NO-BATCH則嘗試從 NO-BATCH 補位
if ($remainingToDecrease > 0 && $targetBatch !== $defaultBatch) {
$fallbackInventories = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('batch_number', $defaultBatch)
->where('quantity', '>', 0)
->when($slot, fn($q) => $q->where('location', $slot))
->lockForUpdate()
->orderBy('arrival_date', 'asc')
->get();
foreach ($fallbackInventories as $inventory) {
if ($remainingToDecrease <= 0) break;
$decreaseAmount = min($inventory->quantity, $remainingToDecrease);
$this->decreaseInventoryQuantity($inventory->id, $decreaseAmount, $reason, $referenceType, $referenceId);
$remainingToDecrease -= $decreaseAmount;
}
}
// 3. 處理最終仍不足的情況
if ($remainingToDecrease > 0) {
if ($force) {
// Find any existing inventory record in this warehouse/slot to subtract from, or create one
$query = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId);
if ($slot) {
$query->where('location', $slot);
}
$inventory = $query->first();
// 強制模式下,若指定批號或 NO-BATCH 均不足,統一在 NO-BATCH 建立/扣除負庫存
$inventory = Inventory::where('product_id', $productId)
->where('warehouse_id', $warehouseId)
->where('batch_number', $defaultBatch)
->when($slot, fn($q) => $q->where('location', $slot))
->first();
if (!$inventory) {
$inventory = Inventory::create([
@@ -132,7 +148,7 @@ class InventoryService implements InventoryServiceInterface
'quantity' => 0,
'unit_cost' => 0,
'total_value' => 0,
'batch_number' => 'POS-AUTO-' . ($slot ? $slot . '-' : '') . time(),
'batch_number' => $defaultBatch,
'arrival_date' => now(),
'origin_country' => 'TW',
'quality_status' => 'normal',
@@ -141,7 +157,10 @@ class InventoryService implements InventoryServiceInterface
$this->decreaseInventoryQuantity($inventory->id, $remainingToDecrease, $reason, $referenceType, $referenceId);
} else {
throw new \Exception("庫存不足,無法扣除所有請求的數量。");
$context = ($targetBatch !== $defaultBatch)
? "批號 {$targetBatch}{$defaultBatch}"
: "{$defaultBatch}";
throw new \Exception("庫存不足,無法扣除所有請求的數量 ({$context})。");
}
}
});
@@ -658,7 +677,7 @@ class InventoryService implements InventoryServiceInterface
* @param string $code
* @return \Illuminate\Support\Collection|null
*/
public function getPosInventoryByWarehouseCode(string $code)
public function getPosInventoryByWarehouseCode(string $code, array $filters = [])
{
$warehouse = Warehouse::where('code', $code)->first();
@@ -666,19 +685,60 @@ class InventoryService implements InventoryServiceInterface
return null;
}
// 整理該倉庫的庫存,以 product_id 進行 GROUP BY 並加總 quantity
return DB::table('inventories')
$query = DB::table('inventories')
->join('products', 'inventories.product_id', '=', 'products.id')
->leftJoin('categories', 'products.category_id', '=', 'categories.id')
->leftJoin('units', 'products.base_unit_id', '=', 'units.id')
->where('inventories.warehouse_id', $warehouse->id)
->whereNull('inventories.deleted_at')
->whereNull('products.deleted_at')
->select(
'products.id as product_id',
'products.external_pos_id',
'products.code as product_code',
'products.name as product_name',
'products.barcode',
'categories.name as category_name',
'units.name as unit_name',
'products.price',
'products.brand',
'products.specification',
'inventories.batch_number',
'inventories.expiry_date',
DB::raw('SUM(inventories.quantity) as total_quantity')
);
// 加入條件篩選
if (!empty($filters['product_id'])) {
$query->where('products.id', $filters['product_id']);
}
if (!empty($filters['external_pos_id'])) {
$query->where('products.external_pos_id', $filters['external_pos_id']);
}
if (!empty($filters['barcode'])) {
$query->where('products.barcode', $filters['barcode']);
}
if (!empty($filters['code'])) {
$query->where('products.code', $filters['code']);
}
return $query->groupBy(
'inventories.product_id',
'products.external_pos_id',
'products.code',
'products.name',
'products.barcode',
'categories.name',
'units.name',
'products.price',
'products.brand',
'products.specification',
'inventories.batch_number',
'inventories.expiry_date'
)
->groupBy('inventories.product_id', 'products.external_pos_id', 'products.code', 'products.name')
->get();
}

View File

@@ -38,9 +38,22 @@ class ProductService implements ProductServiceInterface
// Map allowed fields
$product->name = $data['name'];
$product->barcode = $data['barcode'] ?? $product->barcode;
$product->price = $data['price'] ?? 0;
// Handle Barcode
if (!empty($data['barcode'])) {
$product->barcode = $data['barcode'];
} elseif (empty($product->barcode)) {
$product->barcode = $this->generateRandomBarcode();
}
// Handle Code (SKU)
if (!empty($data['code'])) {
$product->code = $data['code'];
} elseif (empty($product->code)) {
$product->code = $this->generateRandomCode();
}
// Map newly added extended fields
if (isset($data['brand'])) $product->brand = $data['brand'];
if (isset($data['specification'])) $product->specification = $data['specification'];
@@ -48,11 +61,6 @@ class ProductService implements ProductServiceInterface
if (isset($data['member_price'])) $product->member_price = $data['member_price'];
if (isset($data['wholesale_price'])) $product->wholesale_price = $data['wholesale_price'];
// Generate Code if missing (use code or external_id)
if (empty($product->code)) {
$product->code = $data['code'] ?? $product->external_pos_id;
}
// Handle Category — 每次同步都更新(若有傳入)
if (!empty($data['category']) || empty($product->category_id)) {
$categoryName = $data['category'] ?? '未分類';
@@ -100,6 +108,17 @@ class ProductService implements ProductServiceInterface
return Product::whereIn('external_pos_id', $externalPosIds)->get();
}
/**
* 透過多個 ERP 內部 ID 查找產品。
*
* @param array $ids
* @return \Illuminate\Database\Eloquent\Collection
*/
public function findByIds(array $ids)
{
return Product::whereIn('id', $ids)->get();
}
/**
* 透過多個 ERP 商品代碼查找產品(供販賣機 API 使用)。
*
@@ -171,11 +190,58 @@ class ProductService implements ProductServiceInterface
{
$product = null;
if (!empty($barcode)) {
$product = Product::where('barcode', $barcode)->first();
$product = Product::query()->where('barcode', $barcode)->first();
}
if (!$product && !empty($code)) {
$product = Product::where('code', $code)->first();
$product = Product::query()->where('code', $code)->first();
}
return $product;
}
/**
* 搜尋商品(供外部 API 使用)。
*
* @param array $filters
* @param int $perPage
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function searchProducts(array $filters, int $perPage = 50)
{
/** @var \Illuminate\Database\Eloquent\Builder $query */
$query = Product::query()
->with(['category', 'baseUnit'])
->where('is_active', true);
// 1. 精準過濾 (ID, 條碼, 代碼, 外部 ID)
if (!empty($filters['product_id'])) {
$query->where('id', $filters['product_id']);
}
if (!empty($filters['barcode'])) {
$query->where('barcode', $filters['barcode']);
}
if (!empty($filters['code'])) {
$query->where('code', $filters['code']);
}
if (!empty($filters['external_pos_id'])) {
$query->where('external_pos_id', $filters['external_pos_id']);
}
// 3. 分類過濾
if (!empty($filters['category'])) {
$categoryName = $filters['category'];
$query->whereHas('category', function ($q) use ($categoryName) {
$q->where('name', $categoryName);
});
}
// 4. 增量同步 (Updated After)
if (!empty($filters['updated_after'])) {
$query->where('updated_at', '>=', $filters['updated_after']);
}
// 4. 排序 (預設按更新時間降冪)
$query->orderBy('updated_at', 'desc');
return $query->paginate($perPage);
}
}