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