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>
This commit is contained in:
2026-05-28 06:16:50 +00:00
parent c90c35d930
commit a1be01b0d5
16 changed files with 788 additions and 5 deletions
+136
View File
@@ -0,0 +1,136 @@
<?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',
]);
}
}