465 lines
16 KiB
PHP
465 lines
16 KiB
PHP
<?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\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
|
|
{
|
|
// Use RefreshDatabase to reset DB state.
|
|
// Note: In Tenancy, this usually resets Central DB.
|
|
// For Tenant DBs, we heavily rely on Stancl's behavior or need to act carefully.
|
|
// For now, assume we can create a tenant and its DB will be migrated or accessible.
|
|
|
|
protected $tenant;
|
|
protected $user;
|
|
protected $token;
|
|
protected $domain;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
Artisan::call('migrate:fresh'); // Force fresh DB for isolation without RefreshDatabase trait
|
|
|
|
$this->domain = 'test-' . 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, ['*']);
|
|
|
|
$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', $productData);
|
|
|
|
$response->assertStatus(200)
|
|
->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' => 'POS-P-999',
|
|
'name' => '新款可口可樂',
|
|
]);
|
|
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,
|
|
'category' => '飲品',
|
|
'unit' => '瓶',
|
|
];
|
|
|
|
$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' => 'NO-BATCH', // 改為系統預設值
|
|
'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_code' => 'MAIN',
|
|
'sold_at' => now()->toIso8601String(),
|
|
'items' => [
|
|
[
|
|
'product_id' => $product->id,
|
|
'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');
|
|
|
|
$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,
|
|
]);
|
|
|
|
$order = \App\Modules\Integration\Models\SalesOrder::where('external_order_id', 'ORD-001')->first();
|
|
$this->assertDatabaseHas('inventory_transactions', [
|
|
'reference_type' => \App\Modules\Integration\Models\SalesOrder::class,
|
|
'reference_id' => $order->id,
|
|
'quantity' => -5,
|
|
'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();
|
|
}
|
|
}
|