svc = app(WarehouseService::class); } public function test_receive_creates_batch_and_event(): void { $part = $this->makePart('alpha'); $batch = $this->svc->receive($part, 10, 50.0); $this->assertEquals(10.0, (float) $batch->qty_in); $this->assertEquals(10.0, (float) $batch->qty_remaining); $this->assertEquals(50.0, (float) $batch->buy_price); $events = WarehouseEvent::where('part_id', $part->id)->get(); $this->assertCount(1, $events); $this->assertEquals('receipt', $events[0]->type); $this->assertEquals(10.0, (float) $events[0]->qty_delta); $part->refresh(); $this->assertEquals(10.0, (float) $part->qty); } public function test_issue_consumes_oldest_batches_first(): void { $part = $this->makePart('beta'); // 3 batches received in chronological order with different prices. $b1 = $this->svc->receive($part, 5, 10.0, occurredAt: now()->subDays(3)); $b2 = $this->svc->receive($part, 5, 20.0, occurredAt: now()->subDays(2)); $b3 = $this->svc->receive($part, 5, 30.0, occurredAt: now()->subDays(1)); // Issue 7 → should consume all of b1 (5) + 2 from b2. $events = $this->svc->issue($part, 7); $b1->refresh(); $b2->refresh(); $b3->refresh(); $this->assertEquals(0.0, (float) $b1->qty_remaining, 'oldest batch should be fully consumed'); $this->assertEquals(3.0, (float) $b2->qty_remaining, 'second batch should have 3 left'); $this->assertEquals(5.0, (float) $b3->qty_remaining, 'newest batch untouched'); // Two events written. $this->assertCount(2, $events); } public function test_issue_throws_when_insufficient_stock(): void { $part = $this->makePart('gamma'); $this->svc->receive($part, 3, 10.0); $this->expectException(InsufficientStockException::class); $this->svc->issue($part, 5); } public function test_reservation_blocks_other_reservations_but_not_stock(): void { $part = $this->makePart('delta'); $this->svc->receive($part, 10, 25.0); $wo = $this->makeWorkOrder($part); $wop = WorkOrderPart::create([ 'work_order_id' => $wo->id, 'part_id' => $part->id, 'name' => $part->name, 'qty' => 6, 'unit' => 'buc', 'buy_price' => 25, 'sell_price' => 40, ]); $part->refresh(); $this->assertEquals(10.0, (float) $part->qty, 'on hand stays the same — reservation does not deplete'); $this->assertEquals(6.0, (float) $part->qty_reserved, 'qty_reserved tracks reservation'); $this->assertCount(1, PartReservation::where('work_order_part_id', $wop->id)->where('status', 'active')->get()); } public function test_wo_done_consumes_reservations(): void { $part = $this->makePart('epsilon'); $this->svc->receive($part, 10, 25.0); $wo = $this->makeWorkOrder($part); WorkOrderPart::create([ 'work_order_id' => $wo->id, 'part_id' => $part->id, 'name' => $part->name, 'qty' => 4, 'unit' => 'buc', 'buy_price' => 25, 'sell_price' => 40, ]); $wo->status = 'done'; $wo->save(); $part->refresh(); $this->assertEquals(6.0, (float) $part->qty, 'on hand decreased by 4'); $this->assertEquals(0.0, (float) $part->qty_reserved, 'reservation consumed'); $r = PartReservation::where('work_order_id', $wo->id)->first(); $this->assertEquals('consumed', $r->status); } public function test_wo_cancelled_releases_reservations(): void { $part = $this->makePart('zeta'); $this->svc->receive($part, 10, 25.0); $wo = $this->makeWorkOrder($part); WorkOrderPart::create([ 'work_order_id' => $wo->id, 'part_id' => $part->id, 'name' => $part->name, 'qty' => 4, 'unit' => 'buc', 'buy_price' => 25, 'sell_price' => 40, ]); $wo->status = 'cancelled'; $wo->save(); $part->refresh(); $this->assertEquals(10.0, (float) $part->qty, 'on hand untouched after cancel'); $this->assertEquals(0.0, (float) $part->qty_reserved, 'reservation released'); $r = PartReservation::where('work_order_id', $wo->id)->first(); $this->assertEquals('released', $r->status); } public function test_batches_are_tenant_isolated(): void { $partA = $this->makePart('aa'); $this->svc->receive($partA, 5, 10.0); $companyB = $this->makeCompany('bb'); app(TenantManager::class)->setCurrent($companyB); $this->assertEquals(0, PartBatch::count(), 'tenant B sees no batches from tenant A'); } public function test_transfer_moves_qty_between_warehouses(): void { $part = $this->makePart('eta'); $main = Warehouse::where('is_default', true)->first(); $secondary = Warehouse::create(['code' => 'BR1', 'name' => 'Sucursală', 'is_active' => true]); $this->svc->receive($part, 10, 15.0, $main); $this->svc->transfer($part, 4, $main, $secondary); $this->assertEquals(6.0, $this->svc->availableForIssue($part, $main)); $this->assertEquals(4.0, $this->svc->availableForIssue($part, $secondary)); $events = WarehouseEvent::where('part_id', $part->id)->get(); $this->assertEqualsCanonicalizing( ['receipt', 'transfer_out', 'transfer_in'], $events->pluck('type')->sort()->values()->all() === ['receipt', 'transfer_in', 'transfer_out'] ? ['receipt', 'transfer_in', 'transfer_out'] : $events->pluck('type')->sort()->values()->all() ); } private function makePart(string $companySlug): Part { $company = $this->makeCompany($companySlug); app(TenantManager::class)->setCurrent($company); // Ensure default warehouse exists (migration creates one for existing // companies; new test companies need it created on-the-fly). $wh = Warehouse::create([ 'code' => 'MAIN', 'name' => 'Depozit principal', 'is_default' => true, 'is_active' => true, ]); $company->forceFill(['default_warehouse_id' => $wh->id])->saveQuietly(); return Part::create([ 'name' => 'Filtru ulei', 'unit' => 'buc', 'qty' => 0, 'buy_price' => 0, 'sell_price' => 0, 'is_active' => true, ]); } private function makeCompany(string $slug): Company { $plan = Plan::firstOrCreate(['slug' => 'test'], [ 'name' => 'Test', 'price' => 0, 'features' => [], ]); return Company::create([ 'plan_id' => $plan->id, 'slug' => $slug . '-' . uniqid(), 'name' => ucfirst($slug), 'status' => 'active', ]); } private function makeWorkOrder(Part $part): WorkOrder { $client = Client::create([ 'name' => 'WOClient', 'phone' => '+37399' . random_int(100000, 999999), 'type' => 'individual', 'status' => 'active', ]); $vehicle = Vehicle::create([ 'client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'WO' . random_int(100, 999), ]); return WorkOrder::create([ 'number' => WorkOrder::generateNumber($part->company_id), 'client_id' => $client->id, 'vehicle_id' => $vehicle->id, 'opened_at' => now(), 'status' => 'in_work', ]); } }