Stage 9 — Subcontractor System: outsourced work with cost+markup

Schema:
- subcontractors (specialty, rating, contact)
- subcontract_jobs (work_order link, cost, markup_pct, client_price, status
  workflow, sent_at/eta/returned_at, paid_to_sub)

Models:
- SubcontractJob: auto number (SC-YY-NNNN), client_price = cost×(1+markup/100)
  when markup>0 (else manual), margin() helper, recalcs parent WO on save/delete
- WorkOrder.recalcTotal now includes non-cancelled subcontract job client_price

Filament (new "Subcontractare" nav group):
- SubcontractorResource (specialty/rating CRUD)
- SubcontractJobResource board with cost/client/margin columns + status filters,
  nav badge = open jobs
- SubcontractJobsRelationManager on WorkOrder

Tests (7 new):
- client_price from markup; manual price without markup; auto number;
  WO total includes jobs; cancelled excluded; delete recalcs; tenant isolation

Closes roadmap to 16/18 stages (only Stage 10 Bodyshop remains).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 06:43:15 +00:00
parent 94938f24d7
commit e8078f157a
15 changed files with 680 additions and 1 deletions
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature;
use App\Models\Central\Company;
use App\Models\Central\Plan;
use App\Models\Tenant\Client;
use App\Models\Tenant\Subcontractor;
use App\Models\Tenant\SubcontractJob;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SubcontractorTest extends TestCase
{
use RefreshDatabase;
private Company $company;
protected function setUp(): void
{
parent::setUp();
$this->company = $this->makeCompany('subs');
}
public function test_client_price_computed_from_cost_and_markup(): void
{
$job = SubcontractJob::create([
'category' => 'Turbo', 'cost' => 1000, 'markup_pct' => 25, 'status' => 'sent',
]);
$this->assertEquals(1250.0, (float) $job->client_price);
$this->assertEquals(250.0, $job->margin());
}
public function test_manual_client_price_when_no_markup(): void
{
$job = SubcontractJob::create([
'category' => 'Vopsitorie', 'cost' => 800, 'markup_pct' => 0, 'client_price' => 1100, 'status' => 'sent',
]);
$this->assertEquals(1100.0, (float) $job->client_price);
$this->assertEquals(300.0, $job->margin());
}
public function test_number_auto_generated(): void
{
$job = SubcontractJob::create(['cost' => 100, 'markup_pct' => 10, 'status' => 'sent']);
$this->assertStringStartsWith('SC-', $job->number);
}
public function test_wo_total_includes_subcontract_jobs(): void
{
$wo = $this->makeWorkOrder();
SubcontractJob::create([
'work_order_id' => $wo->id, 'category' => 'Cutie viteze',
'cost' => 2000, 'markup_pct' => 20, 'status' => 'sent',
]);
$wo->refresh();
// client_price = 2000 × 1.20 = 2400 → WO total = 2400
$this->assertEquals(2400.0, (float) $wo->total);
}
public function test_cancelled_job_excluded_from_total(): void
{
$wo = $this->makeWorkOrder();
$job = SubcontractJob::create([
'work_order_id' => $wo->id, 'cost' => 1000, 'markup_pct' => 50, 'status' => 'sent',
]);
$wo->refresh();
$this->assertEquals(1500.0, (float) $wo->total);
$job->update(['status' => 'cancelled']);
$wo->refresh();
$this->assertEquals(0.0, (float) $wo->total);
}
public function test_deleting_job_recalcs_wo(): void
{
$wo = $this->makeWorkOrder();
$job = SubcontractJob::create([
'work_order_id' => $wo->id, 'cost' => 500, 'markup_pct' => 100, 'status' => 'sent',
]);
$wo->refresh();
$this->assertEquals(1000.0, (float) $wo->total);
$job->delete();
$wo->refresh();
$this->assertEquals(0.0, (float) $wo->total);
}
public function test_subcontractors_isolated_per_tenant(): void
{
Subcontractor::create(['name' => 'TurboFix', 'is_active' => true]);
$other = $this->makeCompany('othersubs');
app(TenantManager::class)->setCurrent($other);
$this->assertEquals(0, Subcontractor::count());
}
private function makeCompany(string $slug): 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']);
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',
]);
}
}