From a1be01b0d5d24f044311255449f8edd2bf6ddd44 Mon Sep 17 00:00:00 2001 From: Vasyka Date: Thu, 28 May 2026 06:16:50 +0000 Subject: [PATCH] =?UTF-8?q?Stage=204=20=E2=80=94=20Labor=20Catalog:=20fixe?= =?UTF-8?q?d=20price=20+=20default=20parts=20+=20service=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Tenant/Resources/LaborResource.php | 30 +++- .../DefaultPartsRelationManager.php | 57 ++++++++ .../Resources/ServiceTemplateResource.php | 88 ++++++++++++ .../Pages/CreateServiceTemplate.php | 11 ++ .../Pages/EditServiceTemplate.php | 17 +++ .../Pages/ListServiceTemplates.php | 17 +++ .../RelationManagers/ItemsRelationManager.php | 79 ++++++++++ .../WorkOrderResource/Pages/EditWorkOrder.php | 20 +++ .../RelationManagers/WorksRelationManager.php | 18 ++- app/Models/Tenant/Labor.php | 23 ++- app/Models/Tenant/LaborPart.php | 26 ++++ app/Models/Tenant/ServiceTemplate.php | 32 +++++ app/Models/Tenant/ServiceTemplateItem.php | 39 +++++ app/Services/ServiceComposer.php | 134 +++++++++++++++++ ...26_05_28_170000_create_labor_templates.php | 66 +++++++++ tests/Feature/ServiceComposerTest.php | 136 ++++++++++++++++++ 16 files changed, 788 insertions(+), 5 deletions(-) create mode 100644 app/Filament/Tenant/Resources/LaborResource/RelationManagers/DefaultPartsRelationManager.php create mode 100644 app/Filament/Tenant/Resources/ServiceTemplateResource.php create mode 100644 app/Filament/Tenant/Resources/ServiceTemplateResource/Pages/CreateServiceTemplate.php create mode 100644 app/Filament/Tenant/Resources/ServiceTemplateResource/Pages/EditServiceTemplate.php create mode 100644 app/Filament/Tenant/Resources/ServiceTemplateResource/Pages/ListServiceTemplates.php create mode 100644 app/Filament/Tenant/Resources/ServiceTemplateResource/RelationManagers/ItemsRelationManager.php create mode 100644 app/Models/Tenant/LaborPart.php create mode 100644 app/Models/Tenant/ServiceTemplate.php create mode 100644 app/Models/Tenant/ServiceTemplateItem.php create mode 100644 app/Services/ServiceComposer.php create mode 100644 database/migrations/2026_05_28_170000_create_labor_templates.php create mode 100644 tests/Feature/ServiceComposerTest.php diff --git a/app/Filament/Tenant/Resources/LaborResource.php b/app/Filament/Tenant/Resources/LaborResource.php index bdda374..2c847fe 100644 --- a/app/Filament/Tenant/Resources/LaborResource.php +++ b/app/Filament/Tenant/Resources/LaborResource.php @@ -3,6 +3,7 @@ namespace App\Filament\Tenant\Resources; use App\Filament\Tenant\Resources\LaborResource\Pages; +use App\Filament\Tenant\Resources\LaborResource\RelationManagers; use App\Models\Tenant\Labor; use Filament\Actions; use Filament\Forms; @@ -42,8 +43,17 @@ class LaborResource extends Resource Forms\Components\TextInput::make('code')->label('Cod')->maxLength(32), Forms\Components\TextInput::make('name_ro')->label('Nume (RO)')->required()->maxLength(160), Forms\Components\TextInput::make('name_ru')->label('Nume (RU)')->maxLength(160), - Forms\Components\TextInput::make('hours')->label('Ore')->numeric()->default(1)->required(), - Forms\Components\TextInput::make('price')->label('Preț (MDL)')->numeric()->default(0), + Forms\Components\Select::make('pricing_mode') + ->label('Mod tarifare') + ->options(Labor::PRICING_MODES) + ->default('hourly') + ->live() + ->required(), + Forms\Components\TextInput::make('hours')->label('Ore (normă)')->numeric()->default(1) + ->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') !== 'fixed'), + Forms\Components\TextInput::make('fixed_price')->label('Preț fix (MDL)')->numeric()->default(0) + ->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') === 'fixed'), + Forms\Components\TextInput::make('price')->label('Preț orientativ (MDL)')->numeric()->default(0), Forms\Components\Toggle::make('is_active')->label('Activă')->default(true), ]), Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), @@ -56,8 +66,15 @@ class LaborResource extends Resource ->columns([ Tables\Columns\TextColumn::make('category')->label('Categorie')->badge()->sortable(), Tables\Columns\TextColumn::make('name_ro')->label('Manoperă')->searchable()->sortable(), + Tables\Columns\TextColumn::make('pricing_mode') + ->label('Tarifare') + ->formatStateUsing(fn ($s) => $s === 'fixed' ? 'Fix' : 'Pe oră') + ->badge() + ->color(fn ($s) => $s === 'fixed' ? 'info' : 'gray'), Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(), - Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(), + Tables\Columns\TextColumn::make('fixed_price')->label('Preț fix')->money('MDL')->alignRight() + ->placeholder('—')->toggleable(), + Tables\Columns\TextColumn::make('laborParts_count')->counts('laborParts')->label('Piese impl.')->alignRight()->toggleable(), Tables\Columns\IconColumn::make('is_active')->label('Activă')->boolean(), ]) ->filters([ @@ -73,6 +90,13 @@ class LaborResource extends Resource ->defaultGroup('category'); } + public static function getRelations(): array + { + return [ + RelationManagers\DefaultPartsRelationManager::class, + ]; + } + public static function getPages(): array { return [ diff --git a/app/Filament/Tenant/Resources/LaborResource/RelationManagers/DefaultPartsRelationManager.php b/app/Filament/Tenant/Resources/LaborResource/RelationManagers/DefaultPartsRelationManager.php new file mode 100644 index 0000000..53f6edf --- /dev/null +++ b/app/Filament/Tenant/Resources/LaborResource/RelationManagers/DefaultPartsRelationManager.php @@ -0,0 +1,57 @@ +components([ + Forms\Components\Select::make('part_id') + ->label('Piesă') + ->options(fn () => Part::where('is_active', true) + ->get() + ->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')]) + ->toArray()) + ->searchable() + ->required() + ->live() + ->afterStateUpdated(function ($state, Set $set) { + if ($state && $p = Part::find($state)) { + $set('unit', $p->unit); + } + }), + Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)->required(), + Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('part.name') + ->columns([ + Tables\Columns\TextColumn::make('part.name')->label('Piesă')->wrap(), + Tables\Columns\TextColumn::make('part.article')->label('Cod')->placeholder('—'), + Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(), + Tables\Columns\TextColumn::make('unit')->label('UM'), + ]) + ->headerActions([Actions\CreateAction::make()]) + ->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]) + ->emptyStateHeading('Nicio piesă implicită') + ->emptyStateDescription('Adaugă piesele care se montează de obicei la această manoperă — se adaugă automat în fișă când selectezi manopera.'); + } +} diff --git a/app/Filament/Tenant/Resources/ServiceTemplateResource.php b/app/Filament/Tenant/Resources/ServiceTemplateResource.php new file mode 100644 index 0000000..e299489 --- /dev/null +++ b/app/Filament/Tenant/Resources/ServiceTemplateResource.php @@ -0,0 +1,88 @@ +components([ + Schemas\Components\Section::make() + ->columns(2) + ->schema([ + Forms\Components\TextInput::make('name')->label('Denumire')->required() + ->placeholder('ex: Revizie completă 15.000 km')->columnSpanFull(), + Forms\Components\Select::make('category') + ->label('Categorie') + ->options(array_combine(Labor::CATEGORIES, Labor::CATEGORIES)) + ->searchable(), + Forms\Components\Toggle::make('is_active')->label('Activ')->default(true), + Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name')->searchable()->sortable(), + Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'), + Tables\Columns\TextColumn::make('items_count')->counts('items')->label('Linii')->alignRight(), + Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_active')->label('Active'), + ]) + ->actions([ + Actions\EditAction::make(), + Actions\DeleteAction::make(), + ]) + ->emptyStateHeading('Niciun șablon') + ->emptyStateDescription('Grupează manopere + piese frecvente într-un șablon (ex: „Schimb ulei complet") și aplică-l pe o fișă cu un click.') + ->emptyStateIcon('heroicon-o-clipboard-document-list') + ->defaultSort('name'); + } + + public static function getRelations(): array + { + return [ + RelationManagers\ItemsRelationManager::class, + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListServiceTemplates::route('/'), + 'create' => Pages\CreateServiceTemplate::route('/create'), + 'edit' => Pages\EditServiceTemplate::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Tenant/Resources/ServiceTemplateResource/Pages/CreateServiceTemplate.php b/app/Filament/Tenant/Resources/ServiceTemplateResource/Pages/CreateServiceTemplate.php new file mode 100644 index 0000000..b8fd251 --- /dev/null +++ b/app/Filament/Tenant/Resources/ServiceTemplateResource/Pages/CreateServiceTemplate.php @@ -0,0 +1,11 @@ +components([ + Forms\Components\Select::make('kind') + ->label('Tip') + ->options(ServiceTemplateItem::KINDS) + ->default('labor') + ->live() + ->required(), + Forms\Components\Select::make('labor_id') + ->label('Manoperă') + ->options(fn () => Labor::where('is_active', true)->pluck('name_ro', 'id')) + ->searchable() + ->visible(fn (Get $get) => $get('kind') === 'labor') + ->live() + ->afterStateUpdated(function ($state, Set $set) { + if ($state && $l = Labor::find($state)) { + $set('name', $l->name_ro); + $set('hours', $l->hours); + } + }), + Forms\Components\Select::make('part_id') + ->label('Piesă') + ->options(fn () => Part::where('is_active', true) + ->get()->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')])->toArray()) + ->searchable() + ->visible(fn (Get $get) => $get('kind') === 'part') + ->live() + ->afterStateUpdated(function ($state, Set $set) { + if ($state && $p = Part::find($state)) $set('name', $p->name); + }), + Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpanFull(), + Forms\Components\TextInput::make('hours')->label('Ore')->numeric() + ->visible(fn (Get $get) => $get('kind') === 'labor'), + Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1) + ->visible(fn (Get $get) => $get('kind') === 'part'), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('kind') + ->label('Tip') + ->formatStateUsing(fn ($s) => ServiceTemplateItem::KINDS[$s] ?? $s) + ->badge() + ->color(fn ($s) => $s === 'labor' ? 'info' : 'gray'), + Tables\Columns\TextColumn::make('name')->wrap(), + Tables\Columns\TextColumn::make('hours')->label('Ore')->placeholder('—')->alignRight(), + Tables\Columns\TextColumn::make('qty')->label('Cant.')->placeholder('—')->alignRight(), + ]) + ->headerActions([Actions\CreateAction::make()]) + ->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]); + } +} diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php index c6591c9..c3ccbbc 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/Pages/EditWorkOrder.php @@ -15,6 +15,26 @@ class EditWorkOrder extends EditRecord protected function getHeaderActions(): array { return [ + Actions\Action::make('apply_template') + ->label('Aplică șablon') + ->icon('heroicon-m-clipboard-document-list') + ->color('gray') + ->schema([ + \Filament\Forms\Components\Select::make('template_id') + ->label('Șablon serviciu') + ->options(fn () => \App\Models\Tenant\ServiceTemplate::where('is_active', true)->pluck('name', 'id')) + ->searchable() + ->required(), + ]) + ->action(function (array $data) { + $template = \App\Models\Tenant\ServiceTemplate::with('items')->find($data['template_id']); + if (! $template) return; + $r = app(\App\Services\ServiceComposer::class)->applyTemplate($this->record, $template); + $this->fillForm(); + \Filament\Notifications\Notification::make() + ->title("Șablon aplicat: {$r['labor']} manopere, {$r['parts']} piese") + ->success()->send(); + }), Actions\Action::make('ai_diagnose') ->label('AI: sugerează diagnostic') ->icon('heroicon-m-sparkles') diff --git a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php index dc7d099..613068a 100644 --- a/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php +++ b/app/Filament/Tenant/Resources/WorkOrderResource/RelationManagers/WorksRelationManager.php @@ -69,7 +69,23 @@ class WorksRelationManager extends RelationManager ->colors(['gray' => ['todo'], 'warning' => ['in_progress'], 'success' => ['done']]), ]) ->headerActions([ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->after(function (WorkOrderWork $record) { + // Auto-add the labor's default parts to the parent WO. + if (! $record->labor_id) return; + $labor = Labor::with('laborParts.part')->find($record->labor_id); + $wo = $record->workOrder; + if (! $labor || ! $wo || $labor->laborParts->isEmpty()) return; + $composer = app(\App\Services\ServiceComposer::class); + foreach ($labor->laborParts as $lp) { + if ($lp->part) { + $composer->addPart($wo, $lp->part, (float) $lp->qty, $lp->unit); + } + } + \Filament\Notifications\Notification::make() + ->title('Piese implicite adăugate (' . $labor->laborParts->count() . ')') + ->success()->send(); + }), ]) ->actions([ Actions\EditAction::make(), diff --git a/app/Models/Tenant/Labor.php b/app/Models/Tenant/Labor.php index 4c8ef88..08ab7e1 100644 --- a/app/Models/Tenant/Labor.php +++ b/app/Models/Tenant/Labor.php @@ -4,6 +4,7 @@ namespace App\Models\Tenant; use App\Models\Concerns\BelongsToTenant; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; class Labor extends Model @@ -15,14 +16,34 @@ class Labor extends Model 'Caroserie', 'Electrică', 'Climatizare', 'Eșapament', 'Altele', ]; + public const PRICING_MODES = [ + 'hourly' => 'Pe oră (normă × tarif)', + 'fixed' => 'Preț fix', + ]; + protected $fillable = [ 'company_id', 'category', 'name_ro', 'name_ru', 'code', - 'hours', 'price', 'is_active', 'notes', + 'hours', 'pricing_mode', 'fixed_price', 'price', 'is_active', 'notes', ]; protected $casts = [ 'hours' => 'decimal:2', + 'fixed_price' => 'decimal:2', 'price' => 'decimal:2', 'is_active' => 'boolean', ]; + + public function laborParts(): HasMany + { + return $this->hasMany(LaborPart::class); + } + + /** Effective line total for this labor given the tenant hourly rate. */ + public function effectiveTotal(float $hourlyRate): float + { + if ($this->pricing_mode === 'fixed') { + return (float) $this->fixed_price; + } + return round((float) $this->hours * $hourlyRate, 2); + } } diff --git a/app/Models/Tenant/LaborPart.php b/app/Models/Tenant/LaborPart.php new file mode 100644 index 0000000..8d40e76 --- /dev/null +++ b/app/Models/Tenant/LaborPart.php @@ -0,0 +1,26 @@ + 'decimal:2']; + + public function labor(): BelongsTo + { + return $this->belongsTo(Labor::class); + } + + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } +} diff --git a/app/Models/Tenant/ServiceTemplate.php b/app/Models/Tenant/ServiceTemplate.php new file mode 100644 index 0000000..8b8e2c9 --- /dev/null +++ b/app/Models/Tenant/ServiceTemplate.php @@ -0,0 +1,32 @@ + 'boolean']; + + public function items(): HasMany + { + return $this->hasMany(ServiceTemplateItem::class); + } + + public function laborItems(): HasMany + { + return $this->items()->where('kind', 'labor'); + } + + public function partItems(): HasMany + { + return $this->items()->where('kind', 'part'); + } +} diff --git a/app/Models/Tenant/ServiceTemplateItem.php b/app/Models/Tenant/ServiceTemplateItem.php new file mode 100644 index 0000000..e442f82 --- /dev/null +++ b/app/Models/Tenant/ServiceTemplateItem.php @@ -0,0 +1,39 @@ + 'Manoperă', 'part' => 'Piesă']; + + protected $fillable = [ + 'company_id', 'service_template_id', 'kind', + 'labor_id', 'part_id', 'name', 'qty', 'hours', + ]; + + protected $casts = [ + 'qty' => 'decimal:2', + 'hours' => 'decimal:2', + ]; + + public function template(): BelongsTo + { + return $this->belongsTo(ServiceTemplate::class, 'service_template_id'); + } + + public function labor(): BelongsTo + { + return $this->belongsTo(Labor::class); + } + + public function part(): BelongsTo + { + return $this->belongsTo(Part::class); + } +} diff --git a/app/Services/ServiceComposer.php b/app/Services/ServiceComposer.php new file mode 100644 index 0000000..1760b20 --- /dev/null +++ b/app/Services/ServiceComposer.php @@ -0,0 +1,134 @@ +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]; + }); + } +} diff --git a/database/migrations/2026_05_28_170000_create_labor_templates.php b/database/migrations/2026_05_28_170000_create_labor_templates.php new file mode 100644 index 0000000..c89ec2c --- /dev/null +++ b/database/migrations/2026_05_28_170000_create_labor_templates.php @@ -0,0 +1,66 @@ +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']); + }); + } +}; diff --git a/tests/Feature/ServiceComposerTest.php b/tests/Feature/ServiceComposerTest.php new file mode 100644 index 0000000..2fa486a --- /dev/null +++ b/tests/Feature/ServiceComposerTest.php @@ -0,0 +1,136 @@ +composer = app(ServiceComposer::class); + $this->company = $this->makeCompany('compose', laborRate: 500); + } + + public function test_add_hourly_labor_uses_rate_times_hours(): void + { + $labor = Labor::create(['category' => 'Motor', 'name_ro' => 'Schimb ulei', 'hours' => 1.5, 'pricing_mode' => 'hourly', 'is_active' => true]); + $wo = $this->makeWorkOrder(); + + $work = $this->composer->addLabor($wo, $labor); + + $this->assertEquals(1.5, (float) $work->hours); + $this->assertEquals(500.0, (float) $work->price_per_hour); + $this->assertEquals(750.0, (float) $work->total); // 1.5 × 500 + } + + public function test_add_fixed_labor_uses_fixed_price(): void + { + $labor = Labor::create(['category' => 'ITP', 'name_ro' => 'Diagnostic', 'hours' => 1, 'pricing_mode' => 'fixed', 'fixed_price' => 300, 'is_active' => true]); + $wo = $this->makeWorkOrder(); + + $work = $this->composer->addLabor($wo, $labor); + + $this->assertEquals(300.0, (float) $work->total); + } + + public function test_labor_auto_adds_default_parts(): void + { + $labor = Labor::create(['category' => 'Motor', 'name_ro' => 'Schimb ulei', 'hours' => 1, 'pricing_mode' => 'hourly', 'is_active' => true]); + $oil = Part::create(['name' => 'Ulei 5W30', 'sell_price' => 60, 'buy_price' => 40, 'qty' => 100, 'unit' => 'L', 'is_active' => true]); + $filter = Part::create(['name' => 'Filtru ulei', 'sell_price' => 80, 'buy_price' => 50, 'qty' => 20, 'unit' => 'buc', 'is_active' => true]); + LaborPart::create(['labor_id' => $labor->id, 'part_id' => $oil->id, 'qty' => 4, 'unit' => 'L']); + LaborPart::create(['labor_id' => $labor->id, 'part_id' => $filter->id, 'qty' => 1, 'unit' => 'buc']); + + $wo = $this->makeWorkOrder(); + $this->composer->addLabor($wo, $labor, withParts: true); + + $this->assertEquals(2, $wo->parts()->count()); + $oilLine = $wo->parts()->where('part_id', $oil->id)->first(); + $this->assertEquals(4.0, (float) $oilLine->qty); + $this->assertEquals(60.0, (float) $oilLine->sell_price); + } + + public function test_add_labor_without_parts_skips_defaults(): void + { + $labor = Labor::create(['category' => 'Motor', 'name_ro' => 'X', 'hours' => 1, 'pricing_mode' => 'hourly', 'is_active' => true]); + $p = Part::create(['name' => 'P', 'sell_price' => 10, 'buy_price' => 5, 'qty' => 5, 'unit' => 'buc', 'is_active' => true]); + LaborPart::create(['labor_id' => $labor->id, 'part_id' => $p->id, 'qty' => 1]); + + $wo = $this->makeWorkOrder(); + $this->composer->addLabor($wo, $labor, withParts: false); + + $this->assertEquals(0, $wo->parts()->count()); + } + + public function test_apply_template_adds_all_lines_and_recalcs(): void + { + $labor = Labor::create(['category' => 'Motor', 'name_ro' => 'Schimb ulei', 'hours' => 1, 'pricing_mode' => 'hourly', 'is_active' => true]); + $oil = Part::create(['name' => 'Ulei', 'sell_price' => 60, 'buy_price' => 40, 'qty' => 100, 'unit' => 'L', 'is_active' => true]); + + $tpl = ServiceTemplate::create(['name' => 'Revizie', 'is_active' => true]); + ServiceTemplateItem::create(['service_template_id' => $tpl->id, 'kind' => 'labor', 'labor_id' => $labor->id, 'name' => 'Schimb ulei', 'hours' => 1]); + ServiceTemplateItem::create(['service_template_id' => $tpl->id, 'kind' => 'part', 'part_id' => $oil->id, 'name' => 'Ulei', 'qty' => 4]); + + $wo = $this->makeWorkOrder(); + $r = $this->composer->applyTemplate($wo, $tpl->load('items')); + + $this->assertEquals(1, $r['labor']); + $this->assertEquals(1, $r['parts']); + $this->assertEquals(1, $wo->works()->count()); + $this->assertEquals(1, $wo->parts()->count()); + + $wo->refresh(); + // labor 1×500 + oil 4×60 = 500 + 240 = 740 + $this->assertEquals(740.0, (float) $wo->total); + } + + public function test_templates_isolated_per_tenant(): void + { + ServiceTemplate::create(['name' => 'A', 'is_active' => true]); + $other = $this->makeCompany('other', laborRate: 400); + app(TenantManager::class)->setCurrent($other); + $this->assertEquals(0, ServiceTemplate::count()); + } + + private function makeCompany(string $slug, float $laborRate): 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', 'settings' => ['labor_rate' => $laborRate], + ]); + 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', + ]); + } +}