diff --git a/tests/Feature/Accessories/Api/AdjustAccessoryQuantityApiTest.php b/tests/Feature/Accessories/Api/AdjustAccessoryQuantityApiTest.php index 5f8c603e60..484c3b91fc 100644 --- a/tests/Feature/Accessories/Api/AdjustAccessoryQuantityApiTest.php +++ b/tests/Feature/Accessories/Api/AdjustAccessoryQuantityApiTest.php @@ -50,7 +50,7 @@ class AdjustAccessoryQuantityApiTest extends TestCase $this->assertSame('restock from PO', $log->note); // order_number moved off the log row to the Orders table — the // log carries order_id pointing at the newly-created Order. - $order = \App\Models\Order::findOrFail($log->order_id); + $order = $log->orderItem->order; $this->assertSame('PO-API-1', $order->order_number); $this->assertSame($actor->id, (int) $log->created_by); } @@ -163,4 +163,118 @@ class AdjustAccessoryQuantityApiTest extends TestCase ->count(), 'No separate uploaded log entry should have been created.'); $this->assertSame($uploadsBefore + 1, $accessory->uploads()->count()); } + + public function test_supplier_purchase_date_unit_cost_and_currency_land_on_order_and_order_item() + { + $accessory = Accessory::factory()->create(['qty' => 2]); + $supplier = \App\Models\Supplier::factory()->create(); + + $this->actingAsForApi(User::factory()->editAccessories()->create()) + ->postJson(route('api.accessories.adjust-quantity', $accessory), [ + 'amount' => 5, + 'note' => 'PO arrived', + 'order_number' => 'PO-FULL-META', + 'supplier_id' => $supplier->id, + 'purchase_date' => '2026-03-15', + 'unit_cost' => 12.3456, + 'currency' => 'EUR', + ]) + ->assertOk(); + + $log = Actionlog::where('item_type', Accessory::class) + ->where('item_id', $accessory->id) + ->where('action_type', ActionType::QuantityAdjust->value) + ->latest('id') + ->firstOrFail(); + + $order = $log->orderItem->order; + $this->assertSame('PO-FULL-META', $order->order_number); + $this->assertSame($supplier->id, (int) $order->supplier_id); + $this->assertSame('2026-03-15', $order->purchase_date->toDateString()); + $this->assertSame('EUR', $order->currency); + + $line = $order->orderItems()->firstOrFail(); + $this->assertSame(Accessory::class, $line->item_type); + $this->assertSame($accessory->id, (int) $line->item_id); + $this->assertSame(5, (int) $line->qty); + $this->assertEquals(12.3456, (float) $line->price); + } + + public function test_audit_only_zero_delta_with_no_order_metadata_skips_order_creation() + { + // Zero delta and no supplier / order number / date / cost / + // currency = pure inventory audit. No Order row should be + // created for these, since there was no transaction. + $accessory = Accessory::factory()->create(['qty' => 5]); + $ordersBefore = \App\Models\Order::count(); + + $this->actingAsForApi(User::factory()->editAccessories()->create()) + ->postJson(route('api.accessories.adjust-quantity', $accessory), [ + 'amount' => 0, + 'note' => 'shelf count matches', + ]) + ->assertOk(); + + $this->assertSame($ordersBefore, \App\Models\Order::count()); + + $log = Actionlog::where('item_type', Accessory::class) + ->where('item_id', $accessory->id) + ->where('action_type', ActionType::QuantityAdjust->value) + ->latest('id') + ->firstOrFail(); + + $this->assertNull($log->order_item_id); + } + + public function test_multiple_adjusts_with_same_order_number_dedupe_to_one_order() + { + // Repeated qty adjusts referencing the same order_number should + // reuse the existing Order row (dedupe on order_number + + // supplier + company), while each adjust still produces its own + // OrderItem line (one line per event). + $accessory = Accessory::factory()->create(['qty' => 0]); + $supplier = \App\Models\Supplier::factory()->create(); + $actor = User::factory()->editAccessories()->create(); + + foreach ([3, 4] as $delta) { + $this->actingAsForApi($actor) + ->postJson(route('api.accessories.adjust-quantity', $accessory), [ + 'amount' => $delta, + 'note' => 'staggered receipt', + 'order_number' => 'PO-DEDUPE', + 'supplier_id' => $supplier->id, + ]) + ->assertOk(); + } + + $orders = \App\Models\Order::where('order_number', 'PO-DEDUPE')->get(); + $this->assertCount(1, $orders, 'Same order_number should reuse the existing Order row.'); + + $items = $orders->first()->orderItems; + $this->assertCount(2, $items, 'Each adjust event should have its own OrderItem line.'); + $this->assertEqualsCanonicalizing([3, 4], $items->pluck('qty')->map(fn ($q) => (int) $q)->all()); + } + + public function test_blank_order_number_creates_a_distinct_order_per_event() + { + // A blank order_number is a distinct transaction each time, not + // a bucket to pool anonymous receipts into. Each adjust with + // no order_number gets its own Order row. + $accessory = Accessory::factory()->create(['qty' => 0]); + $supplier = \App\Models\Supplier::factory()->create(); + $actor = User::factory()->editAccessories()->create(); + $ordersBefore = \App\Models\Order::count(); + + foreach ([2, 3] as $delta) { + $this->actingAsForApi($actor) + ->postJson(route('api.accessories.adjust-quantity', $accessory), [ + 'amount' => $delta, + 'note' => 'anonymous receipt', + 'supplier_id' => $supplier->id, + ]) + ->assertOk(); + } + + $this->assertSame($ordersBefore + 2, \App\Models\Order::count()); + } } diff --git a/tests/Feature/Accessories/Api/UpdateAccessoryTest.php b/tests/Feature/Accessories/Api/UpdateAccessoryTest.php index 6d735fc91e..df2b526e92 100644 --- a/tests/Feature/Accessories/Api/UpdateAccessoryTest.php +++ b/tests/Feature/Accessories/Api/UpdateAccessoryTest.php @@ -185,7 +185,7 @@ class UpdateAccessoryTest extends TestCase implements TestsFullMultipleCompanies ->firstOrFail(); $this->assertSame(7, (int) $log->quantity); - $this->assertSame('PO-API', \App\Models\Order::findOrFail($log->order_id)->order_number); + $this->assertSame('PO-API', $log->orderItem->order->order_number); $this->assertNotEmpty($log->note, 'Adjustment note must be synthesized when the API caller omits one'); } diff --git a/tests/Feature/Accessories/Ui/AdjustAccessoryQuantityTest.php b/tests/Feature/Accessories/Ui/AdjustAccessoryQuantityTest.php index ca4d3d0f86..797b502ad9 100644 --- a/tests/Feature/Accessories/Ui/AdjustAccessoryQuantityTest.php +++ b/tests/Feature/Accessories/Ui/AdjustAccessoryQuantityTest.php @@ -51,7 +51,7 @@ class AdjustAccessoryQuantityTest extends TestCase $this->assertNotNull($log); $this->assertSame(3, (int) $log->quantity); $this->assertSame('restock from PO', $log->note); - $this->assertSame('PO-42', \App\Models\Order::findOrFail($log->order_id)->order_number); + $this->assertSame('PO-42', $log->orderItem->order->order_number); $this->assertSame($actor->id, (int) $log->created_by); } @@ -76,7 +76,7 @@ class AdjustAccessoryQuantityTest extends TestCase ->first(); $this->assertSame(-4, (int) $log->quantity); - $this->assertNull($log->order_id); + $this->assertNull($log->order_item_id); } public function test_decrement_below_zero_is_rejected() diff --git a/tests/Feature/Assets/Api/StoreAssetTest.php b/tests/Feature/Assets/Api/StoreAssetTest.php index 9614dd2a4b..87a6ab8711 100644 --- a/tests/Feature/Assets/Api/StoreAssetTest.php +++ b/tests/Feature/Assets/Api/StoreAssetTest.php @@ -74,10 +74,7 @@ class StoreAssetTest extends TestCase $this->assertTrue($asset->model->is($model)); $this->assertEquals('A New Asset', $asset->name); $this->assertEquals('Some notes', $asset->notes); - // order_number moved off the parent Asset column to the Orders / - // OrderItems data model. A create-time order_number in the request - // body no longer lands on the model — acquisition tracking is via - // the adjust-quantity flow or the CSV importer. + $this->assertEquals('5678', $asset->order_number); $this->assertEquals('123.45', $asset->purchase_cost); $this->assertTrue($asset->purchase_date->is('2023-09-02')); $this->assertEquals('1', $asset->requestable); diff --git a/tests/Feature/Assets/Api/UpdateAssetTest.php b/tests/Feature/Assets/Api/UpdateAssetTest.php index 1eacab4fa7..80c63e1ac4 100644 --- a/tests/Feature/Assets/Api/UpdateAssetTest.php +++ b/tests/Feature/Assets/Api/UpdateAssetTest.php @@ -95,8 +95,7 @@ class UpdateAssetTest extends TestCase $this->assertTrue($updatedAsset->model->is($model)); $this->assertEquals('A New Asset', $updatedAsset->name); $this->assertEquals('Some notes', $updatedAsset->notes); - // See StoreAssetTest for why order_number is no longer on the - // parent Asset column. + $this->assertEquals('5678', $updatedAsset->order_number); $this->assertEquals('123.45', $updatedAsset->purchase_cost); $this->assertTrue($updatedAsset->purchase_date->is('2023-09-02')); $this->assertEquals('1', $updatedAsset->requestable); diff --git a/tests/Feature/Assets/AssetObserverOrderTest.php b/tests/Feature/Assets/AssetObserverOrderTest.php new file mode 100644 index 0000000000..1238b569f9 --- /dev/null +++ b/tests/Feature/Assets/AssetObserverOrderTest.php @@ -0,0 +1,102 @@ +createAssets()->create(); + $supplier = Supplier::factory()->create(); + + $asset = Asset::factory()->create([ + 'created_by' => $actor->id, + 'company_id' => $actor->company_id, + 'supplier_id' => $supplier->id, + 'order_number' => 'PO-OBS-1', + 'purchase_date' => '2026-04-01', + 'purchase_cost' => 199.99, + ]); + + $line = OrderItem::where('item_type', Asset::class) + ->where('item_id', $asset->id) + ->firstOrFail(); + + $this->assertSame(1, (int) $line->qty); + $this->assertEquals(199.99, (float) $line->price); + + $order = Order::findOrFail($line->order_id); + $this->assertSame('PO-OBS-1', $order->order_number); + $this->assertSame($supplier->id, (int) $order->supplier_id); + $this->assertSame($actor->company_id, $order->company_id); + $this->assertSame('2026-04-01', $order->purchase_date->toDateString()); + } + + public function test_two_assets_with_same_order_tuple_share_one_order_row(): void + { + $actor = User::factory()->createAssets()->create(); + $supplier = Supplier::factory()->create(); + + $shared = [ + 'created_by' => $actor->id, + 'company_id' => $actor->company_id, + 'supplier_id' => $supplier->id, + 'order_number' => 'PO-OBS-SHARED', + 'purchase_date' => '2026-04-02', + ]; + + $first = Asset::factory()->create($shared + ['purchase_cost' => 100]); + $second = Asset::factory()->create($shared + ['purchase_cost' => 200]); + + $orders = Order::where('order_number', 'PO-OBS-SHARED') + ->where('supplier_id', $supplier->id) + ->where('company_id', $actor->company_id) + ->get(); + + $this->assertCount(1, $orders, 'Same (order_number, supplier, company) tuple must dedupe.'); + + $lineIds = OrderItem::where('item_type', Asset::class) + ->whereIn('item_id', [$first->id, $second->id]) + ->pluck('order_id') + ->unique() + ->values(); + + $this->assertCount(1, $lineIds); + $this->assertSame($orders->first()->id, (int) $lineIds->first()); + } + + public function test_blank_order_number_creates_a_distinct_order_per_asset(): void + { + $actor = User::factory()->createAssets()->create(); + $supplier = Supplier::factory()->create(); + $ordersBefore = Order::count(); + + $shared = [ + 'created_by' => $actor->id, + 'company_id' => $actor->company_id, + 'supplier_id' => $supplier->id, + 'order_number' => null, + ]; + + Asset::factory()->create($shared); + Asset::factory()->create($shared); + + // Blank order_number = distinct transaction, so both assets get + // their own Order rows even though supplier + company match. + $this->assertSame($ordersBefore + 2, Order::count()); + } +} diff --git a/tests/Feature/Components/Api/AdjustComponentQuantityApiTest.php b/tests/Feature/Components/Api/AdjustComponentQuantityApiTest.php index a1f8fadab2..3c87421481 100644 --- a/tests/Feature/Components/Api/AdjustComponentQuantityApiTest.php +++ b/tests/Feature/Components/Api/AdjustComponentQuantityApiTest.php @@ -46,7 +46,7 @@ class AdjustComponentQuantityApiTest extends TestCase ->firstOrFail(); $this->assertSame(3, (int) $log->quantity); - $this->assertSame('PO-CMP', \App\Models\Order::findOrFail($log->order_id)->order_number); + $this->assertSame('PO-CMP', $log->orderItem->order->order_number); } public function test_note_is_required()