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
@@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('labors', function (Blueprint $t) {
$t->string('pricing_mode', 12)->default('hourly')->after('hours'); // hourly / fixed
$t->decimal('fixed_price', 10, 2)->default(0)->after('pricing_mode');
});
Schema::create('labor_parts', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('labor_id')->constrained()->cascadeOnDelete();
$t->foreignId('part_id')->constrained()->cascadeOnDelete();
$t->decimal('qty', 8, 2)->default(1);
$t->string('unit', 16)->default('buc');
$t->timestamps();
$t->index(['company_id', 'labor_id']);
});
Schema::create('service_templates', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->string('name');
$t->string('category')->nullable();
$t->text('notes')->nullable();
$t->boolean('is_active')->default(true);
$t->timestamps();
$t->softDeletes();
$t->index(['company_id', 'is_active']);
});
Schema::create('service_template_items', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained()->cascadeOnDelete();
$t->foreignId('service_template_id')->constrained()->cascadeOnDelete();
$t->string('kind', 8); // labor / part
$t->foreignId('labor_id')->nullable()->constrained()->nullOnDelete();
$t->foreignId('part_id')->nullable()->constrained()->nullOnDelete();
$t->string('name'); // snapshot label
$t->decimal('qty', 8, 2)->default(1); // for parts (and labor hours fallback)
$t->decimal('hours', 5, 2)->nullable();// for labor
$t->timestamps();
$t->index(['company_id', 'service_template_id']);
});
}
public function down(): void
{
Schema::dropIfExists('service_template_items');
Schema::dropIfExists('service_templates');
Schema::dropIfExists('labor_parts');
Schema::table('labors', function (Blueprint $t) {
$t->dropColumn(['pricing_mode', 'fixed_price']);
});
}
};