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
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace App\Services;
use App\Models\Central\Company;
use App\Models\Tenant\Labor;
use App\Models\Tenant\Part;
use App\Models\Tenant\ServiceTemplate;
use App\Models\Tenant\WorkOrder;
use App\Models\Tenant\WorkOrderPart;
use App\Models\Tenant\WorkOrderWork;
use Illuminate\Support\Facades\DB;
/**
* Composes work order lines from the labor catalog + templates:
* - adds a labor (hourly or fixed) and optionally its default parts
* - applies a full service template (labor + part lines) in one shot
*/
class ServiceComposer
{
public function hourlyRate(int $companyId): float
{
$company = Company::withoutGlobalScopes()->find($companyId);
return (float) data_get($company?->settings, 'labor_rate', 400);
}
/**
* Add a labor line to a WO. Fixed-price labors set total directly via
* hours=1 × price_per_hour=fixed_price. Returns the created work line.
*/
public function addLabor(WorkOrder $wo, Labor $labor, bool $withParts = true): WorkOrderWork
{
$rate = $this->hourlyRate($wo->company_id);
return DB::transaction(function () use ($wo, $labor, $withParts, $rate) {
if ($labor->pricing_mode === 'fixed') {
$hours = 1;
$pricePerHour = (float) $labor->fixed_price;
} else {
$hours = (float) $labor->hours ?: 1;
$pricePerHour = $rate;
}
$work = WorkOrderWork::create([
'work_order_id' => $wo->id,
'labor_id' => $labor->id,
'name' => $labor->name_ro,
'hours' => $hours,
'price_per_hour' => $pricePerHour,
'status' => 'todo',
]);
if ($withParts) {
foreach ($labor->laborParts as $lp) {
$part = $lp->part;
if (! $part) continue;
$this->addPart($wo, $part, (float) $lp->qty, $lp->unit ?: $part->unit);
}
}
return $work;
});
}
public function addPart(WorkOrder $wo, Part $part, float $qty, ?string $unit = null): WorkOrderPart
{
return WorkOrderPart::create([
'work_order_id' => $wo->id,
'part_id' => $part->id,
'name' => $part->name,
'article' => $part->article,
'brand' => $part->brand,
'qty' => $qty,
'unit' => $unit ?: $part->unit ?: 'buc',
'buy_price' => (float) $part->buy_price,
'sell_price' => (float) $part->sell_price,
'status' => 'needed',
]);
}
/**
* Apply a full template to a WO: every labor + part item becomes a WO line.
* Labor items are added WITHOUT their own default parts (template is explicit).
*
* @return array{labor:int, parts:int}
*/
public function applyTemplate(WorkOrder $wo, ServiceTemplate $template): array
{
return DB::transaction(function () use ($wo, $template) {
$laborCount = 0;
$partCount = 0;
foreach ($template->items as $item) {
if ($item->kind === 'labor') {
if ($item->labor_id && ($labor = Labor::find($item->labor_id))) {
$work = $this->addLabor($wo, $labor, withParts: false);
if ($item->hours) {
$work->hours = (float) $item->hours;
$work->save();
}
} else {
// Free-text labor line from snapshot.
WorkOrderWork::create([
'work_order_id' => $wo->id,
'name' => $item->name,
'hours' => (float) ($item->hours ?: 1),
'price_per_hour' => $this->hourlyRate($wo->company_id),
'status' => 'todo',
]);
}
$laborCount++;
} elseif ($item->kind === 'part') {
if ($item->part_id && ($part = Part::find($item->part_id))) {
$this->addPart($wo, $part, (float) ($item->qty ?: 1));
} else {
WorkOrderPart::create([
'work_order_id' => $wo->id,
'name' => $item->name,
'qty' => (float) ($item->qty ?: 1),
'unit' => 'buc',
'sell_price' => 0,
'status' => 'needed',
]);
}
$partCount++;
}
}
$wo->recalcTotal();
return ['labor' => $laborCount, 'parts' => $partCount];
});
}
}