Files
autocrm/app/Services/ServiceComposer.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

135 lines
4.8 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 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];
});
}
}