[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

@@ -0,0 +1,211 @@
<?php
namespace Tests\Feature\Integration;
use Tests\TestCase;
use App\Modules\Core\Models\Tenant;
use App\Modules\Core\Models\User;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\Inventory;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
class InventoryQueryApiTest extends TestCase
{
protected $tenant;
protected $user;
protected $domain;
protected $warehouse;
protected function setUp(): void
{
parent::setUp();
Artisan::call('migrate:fresh');
$this->domain = 'inventory-test-' . Str::random(8) . '.erp.local';
$tenantId = 'test_tenant_inv_' . Str::random(8);
tenancy()->central(function () use ($tenantId) {
$this->tenant = Tenant::create([
'id' => $tenantId,
'name' => 'Inventory Test Tenant',
]);
$this->tenant->domains()->create(['domain' => $this->domain]);
});
tenancy()->initialize($this->tenant);
Artisan::call('tenants:migrate');
$this->user = User::factory()->create(['name' => 'Inventory Admin']);
// 建立測試資料
$cat = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
$p1 = Product::create([
'name' => '可口可樂',
'code' => 'COKE-001',
'barcode' => '4710001',
'external_pos_id' => 'POS-COKE',
'price' => 25,
'category_id' => $cat->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
$p2 = Product::create([
'name' => '百事可樂',
'code' => 'PEPSI-001',
'barcode' => '4710002',
'external_pos_id' => 'POS-PEPSI',
'price' => 23,
'category_id' => $cat->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
$this->warehouse = Warehouse::create([
'name' => '台北門市倉',
'code' => 'WH-TP-01',
'type' => 'retail',
]);
// 建立庫存
Inventory::create([
'warehouse_id' => $this->warehouse->id,
'product_id' => $p1->id,
'quantity' => 100,
'unit_cost' => 15,
'total_value' => 1500,
'batch_number' => 'BATCH-001',
'arrival_date' => now(),
]);
Inventory::create([
'warehouse_id' => $this->warehouse->id,
'product_id' => $p2->id,
'quantity' => 50,
'unit_cost' => 12,
'total_value' => 600,
'batch_number' => 'BATCH-002',
'arrival_date' => now(),
]);
tenancy()->end();
}
protected function tearDown(): void
{
if ($this->tenant) {
$this->tenant->delete();
}
parent::tearDown();
}
public function test_can_query_all_inventory_for_warehouse()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}");
$response->assertStatus(200)
->assertJsonPath('status', 'success')
->assertJsonCount(2, 'data');
}
public function test_can_filter_inventory_by_product_id()
{
Sanctum::actingAs($this->user, ['*']);
// 先找出可樂的 ERP ID
$productId = Product::where('code', 'COKE-001')->value('id');
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id={$productId}");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.product_code', 'COKE-001')
->assertJsonPath('data.0.quantity', 100);
}
public function test_can_filter_inventory_by_external_pos_id()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?external_pos_id=POS-COKE");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.external_pos_id', 'POS-COKE')
->assertJsonPath('data.0.quantity', 100);
}
public function test_can_filter_inventory_by_barcode()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?barcode=4710002");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.product_code', 'PEPSI-001')
->assertJsonPath('data.0.quantity', 50);
}
public function test_can_filter_inventory_by_code()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?code=COKE-001");
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.product_code', 'COKE-001');
}
public function test_returns_empty_when_no_match()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/{$this->warehouse->code}?product_id=NON-EXISTENT");
$response->assertStatus(200)
->assertJsonCount(0, 'data');
}
public function test_returns_404_when_warehouse_not_found()
{
Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/inventory/INVALID-WH");
$response->assertStatus(404);
}
}

View File

@@ -7,7 +7,10 @@ use App\Modules\Core\Models\Tenant;
use App\Modules\Core\Models\User;
use App\Modules\Inventory\Models\Product;
use App\Modules\Integration\Models\SalesOrder;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
use Stancl\Tenancy\Facades\Tenancy;
class PosApiTest extends TestCase
@@ -26,9 +29,9 @@ class PosApiTest extends TestCase
{
parent::setUp();
\Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
$this->domain = 'test-' . \Illuminate\Support\Str::random(8) . '.erp.local';
$this->domain = 'test-' . Str::random(8) . '.erp.local';
$tenantId = 'test_tenant_' . \Illuminate\Support\Str::random(8);
// Ensure we are in central context
@@ -45,7 +48,7 @@ class PosApiTest extends TestCase
// Initialize to create User and Token
tenancy()->initialize($this->tenant);
\Artisan::call('tenants:migrate');
Artisan::call('tenants:migrate');
$this->user = User::factory()->create([
'email' => 'admin@test.local',
@@ -83,26 +86,33 @@ class PosApiTest extends TestCase
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$payload = [
'external_pos_id' => 'EXT-NEW-002',
'name' => 'New Product',
'price' => 200,
'sku' => 'SKU-NEW',
$productData = [
'external_pos_id' => 'POS-P-999',
'name' => '新款可口可樂',
'price' => 29.0,
'barcode' => '471000999',
'category' => '飲品',
'unit' => '瓶',
];
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->postJson('/api/v1/integration/products/upsert', $payload);
])->postJson('/api/v1/integration/products/upsert', $productData);
$response->assertStatus(200)
->assertJsonPath('message', 'Product synced successfully');
->assertJsonPath('data.external_pos_id', 'POS-P-999')
->assertJsonStructure([
'data' => [
'id', 'external_pos_id', 'code', 'barcode'
]
]);
// Verify in Tenant DB
tenancy()->initialize($this->tenant);
$this->assertDatabaseHas('products', [
'external_pos_id' => 'EXT-NEW-002',
'name' => 'New Product',
'external_pos_id' => 'POS-P-999',
'name' => '新款可口可樂',
]);
tenancy()->end();
}
@@ -115,6 +125,8 @@ class PosApiTest extends TestCase
'external_pos_id' => 'EXT-001',
'name' => 'Updated Name',
'price' => 150,
'category' => '飲品',
'unit' => '瓶',
];
$response = $this->withHeaders([
@@ -147,7 +159,7 @@ class PosApiTest extends TestCase
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'quantity' => 100,
'batch_number' => 'BATCH-TEST-001',
'batch_number' => 'NO-BATCH', // 改為系統預設值
'arrival_date' => now()->toDateString(),
'origin_country' => 'TW',
]);
@@ -163,7 +175,7 @@ class PosApiTest extends TestCase
'sold_at' => now()->toIso8601String(),
'items' => [
[
'pos_product_id' => 'EXT-001',
'product_id' => $product->id,
'qty' => 5,
'price' => 100
]
@@ -208,6 +220,245 @@ class PosApiTest extends TestCase
'type' => '出庫',
]);
}
public function test_order_creation_with_batch_number_deduction()
{
tenancy()->initialize($this->tenant);
$product = Product::where('code', 'P-001')->first();
$warehouse = \App\Modules\Inventory\Models\Warehouse::firstOrCreate(['code' => 'MAIN'], ['name' => 'Main Warehouse']);
// Scenario: Two records for the same product, one with batch, one without
\App\Modules\Inventory\Models\Inventory::create([
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'quantity' => 10,
'batch_number' => 'BATCH-A',
'arrival_date' => now()->toDateString(),
]);
\App\Modules\Inventory\Models\Inventory::create([
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'quantity' => 5,
'batch_number' => 'NO-BATCH',
'arrival_date' => now()->toDateString(),
]);
tenancy()->end();
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 1. Test deducting from SPECIFIC batch
$payloadA = [
'external_order_id' => 'ORD-BATCH-A',
'warehouse_code' => 'MAIN',
'items' => [
[
'product_id' => $product->id,
'batch_number' => 'BATCH-A',
'qty' => 3,
'price' => 100
]
]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payloadA)
->assertStatus(201);
// 2. Test deducting from NULL batch (default)
$payloadNull = [
'external_order_id' => 'ORD-BATCH-NULL',
'warehouse_code' => 'MAIN',
'items' => [
[
'product_id' => $product->id,
'batch_number' => null, // 測試不傳入批號時應自動轉向 NO-BATCH
'qty' => 2,
'price' => 100
]
]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payloadNull)
->assertStatus(201);
tenancy()->initialize($this->tenant);
// Verify BATCH-A: 10 - 3 = 7
$this->assertDatabaseHas('inventories', [
'product_id' => $product->id,
'batch_number' => 'BATCH-A',
'quantity' => 7,
]);
// Verify NO-BATCH batch: 5 - 2 = 3
$this->assertDatabaseHas('inventories', [
'product_id' => $product->id,
'batch_number' => 'NO-BATCH',
'quantity' => 3,
]);
tenancy()->end();
}
public function test_upsert_auto_generates_code_and_barcode_if_missing()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$productData = [
'external_pos_id' => 'POS-AUTO-GEN-01',
'name' => '自動編號商品',
'price' => 50.0,
'category' => '測試分類',
'unit' => '個',
];
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->postJson('/api/v1/integration/products/upsert', $productData);
$response->assertStatus(200);
$data = $response->json('data');
$this->assertNotEmpty($data['code']);
$this->assertNotEmpty($data['barcode']);
$this->assertEquals(8, strlen($data['code']));
$this->assertEquals(13, strlen($data['barcode']));
// Ensure they are stored in DB
tenancy()->initialize($this->tenant);
$this->assertDatabaseHas('products', [
'external_pos_id' => 'POS-AUTO-GEN-01',
'code' => $data['code'],
'barcode' => $data['barcode'],
]);
tenancy()->end();
}
public function test_inventory_query_returns_detailed_info()
{
tenancy()->initialize($this->tenant);
// 1. 建立具有完整資訊的商品
$category = \App\Modules\Inventory\Models\Category::create(['name' => '測試大類']);
$unit = \App\Modules\Inventory\Models\Unit::create(['name' => '測試單位']);
$product = Product::create([
'external_pos_id' => 'DETAIL-001',
'code' => 'DET-001',
'barcode' => '1234567890123',
'name' => '詳盡商品',
'category_id' => $category->id,
'base_unit_id' => $unit->id,
'price' => 123.45,
'brand' => '品牌A',
'specification' => '規格B',
'is_active' => true,
]);
$warehouse = \App\Modules\Inventory\Models\Warehouse::create(['name' => '詳盡倉庫', 'code' => 'DETAIL-WH']);
// 2. 增加庫存
\App\Modules\Inventory\Models\Inventory::create([
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'quantity' => 10.5,
'batch_number' => 'BATCH-DETAIL-01',
'arrival_date' => now(),
'expiry_date' => now()->addYear(),
'origin_country' => 'TW',
]);
tenancy()->end();
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 3. 查詢該倉庫庫存
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/inventory/DETAIL-WH');
$response->assertStatus(200)
->assertJsonStructure([
'status',
'warehouse_code',
'data' => [
'*' => [
'product_id',
'external_pos_id',
'product_code',
'product_name',
'barcode',
'category_name',
'unit_name',
'price',
'brand',
'specification',
'batch_number',
'expiry_date',
'quantity'
]
]
]);
$data = $response->json('data.0');
$this->assertEquals($product->id, $data['product_id']);
$this->assertEquals('DETAIL-001', $data['external_pos_id']);
$this->assertEquals('DET-001', $data['product_code']);
$this->assertEquals('1234567890123', $data['barcode']);
$this->assertEquals('測試大類', $data['category_name']);
$this->assertEquals('測試單位', $data['unit_name']);
$this->assertEquals(123.45, (float)$data['price']);
$this->assertEquals('品牌A', $data['brand']);
$this->assertEquals('規格B', $data['specification']);
$this->assertEquals('BATCH-DETAIL-01', $data['batch_number']);
$this->assertNotEmpty($data['expiry_date']);
$this->assertEquals(10.5, $data['quantity']);
}
public function test_order_creation_with_insufficient_stock_creates_negative_no_batch()
{
tenancy()->initialize($this->tenant);
$product = Product::where('code', 'P-001')->first();
$warehouse = \App\Modules\Inventory\Models\Warehouse::where('code', 'MAIN')->first();
// 清空該商品的現有庫存以利測試負數
\App\Modules\Inventory\Models\Inventory::where('product_id', $product->id)->delete();
tenancy()->end();
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$payload = [
'external_order_id' => 'ORD-NEGATIVE-TEST',
'warehouse_code' => 'MAIN',
'items' => [
[
'product_id' => $product->id,
'batch_number' => 'ANY-BATCH-NAME',
'qty' => 10,
'price' => 100
]
]
];
$this->withHeaders(['X-Tenant-Domain' => $this->domain])
->postJson('/api/v1/integration/orders', $payload)
->assertStatus(201);
tenancy()->initialize($this->tenant);
// 驗證應該在 NO-BATCH 產生 -10 的庫存
$this->assertDatabaseHas('inventories', [
'product_id' => $product->id,
'warehouse_id' => $warehouse->id,
'batch_number' => 'NO-BATCH',
'quantity' => -10,
]);
tenancy()->end();
}
}

View File

@@ -0,0 +1,194 @@
<?php
namespace Tests\Feature\Integration;
use Tests\TestCase;
use App\Modules\Core\Models\Tenant;
use App\Modules\Core\Models\User;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Stancl\Tenancy\Facades\Tenancy;
use Illuminate\Support\Str;
class ProductSearchApiTest extends TestCase
{
protected $tenant;
protected $user;
protected $domain;
protected function setUp(): void
{
parent::setUp();
Artisan::call('migrate:fresh');
$this->domain = 'search-test-' . Str::random(8) . '.erp.local';
$tenantId = 'test_tenant_s_' . Str::random(8);
tenancy()->central(function () use ($tenantId) {
$this->tenant = Tenant::create([
'id' => $tenantId,
'name' => 'Search Test Tenant',
]);
$this->tenant->domains()->create(['domain' => $this->domain]);
});
tenancy()->initialize($this->tenant);
Artisan::call('tenants:migrate');
$this->user = User::factory()->create(['name' => 'Search Admin']);
// 建立測試資料
$cat1 = Category::firstOrCreate(['name' => '飲品'], ['code' => 'CAT-DRINK']);
$cat2 = Category::firstOrCreate(['name' => '食品'], ['code' => 'CAT-FOOD']);
$unit = Unit::firstOrCreate(['name' => '瓶'], ['code' => 'BO']);
Product::create([
'name' => '可口可樂',
'code' => 'COKE-001',
'barcode' => '4710001',
'price' => 25,
'category_id' => $cat1->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
$pepsi = Product::create([
'name' => '百事可樂',
'code' => 'PEPSI-001',
'barcode' => '4710002',
'price' => 23,
'category_id' => $cat1->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
DB::table('products')->where('id', $pepsi->id)->update(['updated_at' => now()->subDay()]);
Product::create([
'name' => '漢堡',
'code' => 'BURGER-001',
'barcode' => '4710003',
'price' => 50,
'category_id' => $cat2->id,
'base_unit_id' => $unit->id,
'is_active' => true,
]);
Product::create([
'name' => '停用商品',
'code' => 'INACTIVE-001',
'is_active' => false,
'category_id' => $cat1->id,
'base_unit_id' => $unit->id,
]);
tenancy()->end();
}
protected function tearDown(): void
{
if ($this->tenant) {
$this->tenant->delete();
}
parent::tearDown();
}
public function test_can_search_all_active_products()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products');
$response->assertStatus(200)
->assertJsonPath('status', 'success')
->assertJsonCount(3, 'data'); // 3 active products
}
public function test_can_filter_by_category()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?category=食品');
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.name', '漢堡');
}
public function test_can_filter_by_updated_after()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 搜尋今天更新的商品 (百事可樂是昨天更新的,應該被過濾掉)
$timeStr = now()->startOfDay()->format('Y-m-d H:i:s');
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/products?updated_after={$timeStr}");
$response->assertStatus(200)
->assertJsonCount(2, 'data'); // 可口可樂, 漢堡 (百事可樂是昨天)
}
public function test_pagination_works()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?per_page=2');
$response->assertStatus(200)
->assertJsonCount(2, 'data')
->assertJsonPath('meta.per_page', 2)
->assertJsonPath('meta.total', 3);
}
public function test_can_filter_by_precision_params()
{
\Laravel\Sanctum\Sanctum::actingAs($this->user, ['*']);
// 1. By Barcode
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?barcode=4710001');
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '可口可樂');
// 2. By Code
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products?code=PEPSI-001');
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '百事可樂');
// 3. By product_id (ERP ID)
$productId = Product::where('code', 'BURGER-001')->value('id');
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson("/api/v1/integration/products?product_id={$productId}");
$response->assertStatus(200)->assertJsonCount(1, 'data')->assertJsonPath('data.0.name', '漢堡');
}
public function test_is_protected_by_auth()
{
// 不帶 Token
$response = $this->withHeaders([
'X-Tenant-Domain' => $this->domain,
'Accept' => 'application/json',
])->getJson('/api/v1/integration/products');
$response->assertStatus(401);
}
}