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:
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user