Files
autocrm/tests/Feature/ServiceComposerTest.php
T
Vasyka a1be01b0d5 Stage 4 — Labor Catalog: fixed price + default parts + service templates
Schema:
- labors.pricing_mode (hourly/fixed) + fixed_price
- labor_parts (default parts auto-added with a labor)
- service_templates + service_template_items (labor/part bundles)

ServiceComposer:
- addLabor(wo, labor, withParts) — hourly (hours×rate) or fixed (fixed_price),
  then auto-adds the labor's default parts
- addPart(wo, part, qty) — catalog price snapshot
- applyTemplate(wo, template) — adds all labor+part lines, recalcs total
- hourlyRate from settings.labor_rate

Filament:
- LaborResource: pricing_mode (live) toggles hours/fixed_price fields,
  DefaultPartsRelationManager
- ServiceTemplateResource (Service group) with ItemsRelationManager
- WorkOrder edit "Aplică șablon" action → applyTemplate
- WorksRelationManager CreateAction auto-adds labor default parts

Tests (6 new):
- hourly rate×hours; fixed uses fixed_price; default parts auto-added;
  withParts=false skips; applyTemplate adds lines + recalcs total;
  templates tenant-isolated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 06:16:50 +00:00

137 lines
5.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Labor;
use App\Models\Tenant\LaborPart;
use App\Models\Tenant\Part;
use App\Models\Tenant\ServiceTemplate;
use App\Models\Tenant\ServiceTemplateItem;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Services\ServiceComposer;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ServiceComposerTest extends TestCase
{
use RefreshDatabase;
private ServiceComposer $composer;
private Company $company;
protected function setUp(): void
{
parent::setUp();
$this->composer = app(ServiceComposer::class);
$this->company = $this->makeCompany('compose', laborRate: 500);
}
public function test_add_hourly_labor_uses_rate_times_hours(): void
{
$labor = Labor::create(['category' => 'Motor', 'name_ro' => 'Schimb ulei', 'hours' => 1.5, 'pricing_mode' => 'hourly', 'is_active' => true]);
$wo = $this->makeWorkOrder();
$work = $this->composer->addLabor($wo, $labor);
$this->assertEquals(1.5, (float) $work->hours);
$this->assertEquals(500.0, (float) $work->price_per_hour);
$this->assertEquals(750.0, (float) $work->total); // 1.5 × 500
}
public function test_add_fixed_labor_uses_fixed_price(): void
{
$labor = Labor::create(['category' => 'ITP', 'name_ro' => 'Diagnostic', 'hours' => 1, 'pricing_mode' => 'fixed', 'fixed_price' => 300, 'is_active' => true]);
$wo = $this->makeWorkOrder();
$work = $this->composer->addLabor($wo, $labor);
$this->assertEquals(300.0, (float) $work->total);
}
public function test_labor_auto_adds_default_parts(): void
{
$labor = Labor::create(['category' => 'Motor', 'name_ro' => 'Schimb ulei', 'hours' => 1, 'pricing_mode' => 'hourly', 'is_active' => true]);
$oil = Part::create(['name' => 'Ulei 5W30', 'sell_price' => 60, 'buy_price' => 40, 'qty' => 100, 'unit' => 'L', 'is_active' => true]);
$filter = Part::create(['name' => 'Filtru ulei', 'sell_price' => 80, 'buy_price' => 50, 'qty' => 20, 'unit' => 'buc', 'is_active' => true]);
LaborPart::create(['labor_id' => $labor->id, 'part_id' => $oil->id, 'qty' => 4, 'unit' => 'L']);
LaborPart::create(['labor_id' => $labor->id, 'part_id' => $filter->id, 'qty' => 1, 'unit' => 'buc']);
$wo = $this->makeWorkOrder();
$this->composer->addLabor($wo, $labor, withParts: true);
$this->assertEquals(2, $wo->parts()->count());
$oilLine = $wo->parts()->where('part_id', $oil->id)->first();
$this->assertEquals(4.0, (float) $oilLine->qty);
$this->assertEquals(60.0, (float) $oilLine->sell_price);
}
public function test_add_labor_without_parts_skips_defaults(): void
{
$labor = Labor::create(['category' => 'Motor', 'name_ro' => 'X', 'hours' => 1, 'pricing_mode' => 'hourly', 'is_active' => true]);
$p = Part::create(['name' => 'P', 'sell_price' => 10, 'buy_price' => 5, 'qty' => 5, 'unit' => 'buc', 'is_active' => true]);
LaborPart::create(['labor_id' => $labor->id, 'part_id' => $p->id, 'qty' => 1]);
$wo = $this->makeWorkOrder();
$this->composer->addLabor($wo, $labor, withParts: false);
$this->assertEquals(0, $wo->parts()->count());
}
public function test_apply_template_adds_all_lines_and_recalcs(): void
{
$labor = Labor::create(['category' => 'Motor', 'name_ro' => 'Schimb ulei', 'hours' => 1, 'pricing_mode' => 'hourly', 'is_active' => true]);
$oil = Part::create(['name' => 'Ulei', 'sell_price' => 60, 'buy_price' => 40, 'qty' => 100, 'unit' => 'L', 'is_active' => true]);
$tpl = ServiceTemplate::create(['name' => 'Revizie', 'is_active' => true]);
ServiceTemplateItem::create(['service_template_id' => $tpl->id, 'kind' => 'labor', 'labor_id' => $labor->id, 'name' => 'Schimb ulei', 'hours' => 1]);
ServiceTemplateItem::create(['service_template_id' => $tpl->id, 'kind' => 'part', 'part_id' => $oil->id, 'name' => 'Ulei', 'qty' => 4]);
$wo = $this->makeWorkOrder();
$r = $this->composer->applyTemplate($wo, $tpl->load('items'));
$this->assertEquals(1, $r['labor']);
$this->assertEquals(1, $r['parts']);
$this->assertEquals(1, $wo->works()->count());
$this->assertEquals(1, $wo->parts()->count());
$wo->refresh();
// labor 1×500 + oil 4×60 = 500 + 240 = 740
$this->assertEquals(740.0, (float) $wo->total);
}
public function test_templates_isolated_per_tenant(): void
{
ServiceTemplate::create(['name' => 'A', 'is_active' => true]);
$other = $this->makeCompany('other', laborRate: 400);
app(TenantManager::class)->setCurrent($other);
$this->assertEquals(0, ServiceTemplate::count());
}
private function makeCompany(string $slug, float $laborRate): Company
{
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
$company = Company::create([
'plan_id' => $plan->id, 'slug' => $slug, 'name' => ucfirst($slug),
'status' => 'active', 'settings' => ['labor_rate' => $laborRate],
]);
app(TenantManager::class)->setCurrent($company);
return $company;
}
private function makeWorkOrder(): WorkOrder
{
$client = Client::create(['name' => 'C', 'phone' => '+3736' . random_int(1000000, 9999999), 'type' => 'individual', 'status' => 'active']);
$vehicle = Vehicle::create(['client_id' => $client->id, 'make' => 'X', 'model' => 'Y', 'plate' => 'P' . random_int(100, 999)]);
return WorkOrder::create([
'number' => WorkOrder::generateNumber($this->company->id),
'client_id' => $client->id, 'vehicle_id' => $vehicle->id,
'opened_at' => now(), 'status' => 'in_work',
]);
}
}