feat(injector-protocols): full module — resource + PDF export via Browsershot

Implements the injector diagnostic protocol module per spec:
- Migration: injector_protocols + injector_protocol_rows with company_id
  (multi-tenant), vehicle_id (not car_id — this CRM uses Vehicle),
  master_id FK to users, snapshot fields (car_model/plate/vin/mileage/year/
  injector_brand) captured at protocol time.
- Models with BelongsToTenant; auto-generate protocol_number as
  PS-{tenantId}-{year}-{seq6}; auto-seed 8 empty rows on create.
- Permissions: injector_protocols.view + injector_protocols.conclude.
  Default roles: owner/admin (both), manager (both), receptionist (view
  only), mechanic (both). Test permission count updated 52→54.
- InjectorProtocolResource under Service nav group with 4 sections:
  data auto+client (with client_id/vehicle_id lookups auto-filling
  snapshot fields), reason checkboxes, 8-row repeater for measurements
  (Repeater with position hidden; fixed 8, non-addable/deletable),
  conclusion + comment + master signature.
- Table with columns, badge filters, PDF action.
- Blade PDF template: pixel-close to reference (graphite/gold/blue
  brand tokens, striped rows, rotated 'ФОРСУНКИ' header).
- Route /app/injector-protocols/{id}/pdf with permission gate,
  streams PDF via new InjectorProtocolPdfService (Browsershot).
- Dockerfile: install nodejs 22 + chromium + noto fonts + puppeteer-core
  in /opt/browsershot; env vars for Browsershot binary paths.
- +71 RU/EN translations covering resource + PDF template.

All 306 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 11:27:53 +00:00
parent 0e85035eff
commit 47eb4f62c1
20 changed files with 1218 additions and 8 deletions
+11
View File
@@ -81,6 +81,10 @@ class Permissions
public const AI_ASSISTANT_CONFIGURE_KEYS = 'ai_assistant.configure_keys';
public const ANALYTICS_VIEW = 'analytics.view';
// Injector diagnostic protocols
public const INJECTOR_PROTOCOLS_VIEW = 'injector_protocols.view';
public const INJECTOR_PROTOCOLS_CONCLUDE = 'injector_protocols.conclude';
/** Full list — used by seeder. */
public static function all(): array
{
@@ -102,6 +106,7 @@ class Permissions
self::ADMIN_SETTINGS_EDIT, self::ADMIN_INTEGRATIONS, self::ADMIN_API_TOKENS_MANAGE,
self::ADMIN_AUDIT_LOG_VIEW, self::ADMIN_BACKUP_DOWNLOAD,
self::AI_ASSISTANT_USE, self::AI_ASSISTANT_CONFIGURE_KEYS, self::ANALYTICS_VIEW,
self::INJECTOR_PROTOCOLS_VIEW, self::INJECTOR_PROTOCOLS_CONCLUDE,
];
}
@@ -129,6 +134,7 @@ class Permissions
'admin' => 'Administrare',
'ai_assistant' => 'AI Assistant',
'analytics' => 'Analitică',
'injector_protocols' => 'Protocol forsunki',
];
}
@@ -188,6 +194,8 @@ class Permissions
'ai_assistant.use' => 'Folosește AI Assistant',
'ai_assistant.configure_keys' => 'Configurează chei API pentru AI',
'analytics.view' => 'Vezi rapoarte & analitică',
'injector_protocols.view' => 'Vezi protocoalele de diagnostic forsunki',
'injector_protocols.conclude' => 'Completează Заключение (concluzii + semnătură)',
];
}
@@ -221,6 +229,7 @@ class Permissions
self::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT,
self::AI_ASSISTANT_USE,
self::ANALYTICS_VIEW,
self::INJECTOR_PROTOCOLS_VIEW, self::INJECTOR_PROTOCOLS_CONCLUDE,
];
// accountant: finance + reporting only
@@ -247,6 +256,7 @@ class Permissions
self::SALARIES_VIEW_OWN,
self::INVENTORY_VIEW,
self::AI_ASSISTANT_USE,
self::INJECTOR_PROTOCOLS_VIEW,
];
// mechanic: only own WOs + inventory view
@@ -254,6 +264,7 @@ class Permissions
self::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CHANGE_STATUS,
self::INVENTORY_VIEW,
self::SALARIES_VIEW_OWN,
self::INJECTOR_PROTOCOLS_VIEW, self::INJECTOR_PROTOCOLS_CONCLUDE,
];
// viewer: read-only
@@ -0,0 +1,288 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Auth\Permissions;
use App\Filament\Tenant\Resources\InjectorProtocolResource\Pages;
use App\Models\Tenant\Client;
use App\Models\Tenant\InjectorProtocol;
use App\Models\Tenant\User;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use Filament\Actions;
use Filament\Forms;
use Filament\Resources\Resource;
use Filament\Schemas;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
class InjectorProtocolResource extends Resource
{
protected static ?string $model = InjectorProtocol::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-beaker';
public static function getNavigationLabel(): string
{
return __('nav.label.Protocoale forsunki');
}
public static function getNavigationGroup(): ?string
{
return __('nav.group.Service');
}
protected static ?int $navigationSort = 27;
public static function getModelLabel(): string
{
return __('protocol forsunki');
}
public static function getPluralModelLabel(): string
{
return __('protocoale forsunki');
}
public static function canViewAny(): bool
{
return auth()->user()?->canDo(Permissions::INJECTOR_PROTOCOLS_VIEW) ?? false;
}
public static function canCreate(): bool
{
return auth()->user()?->canDo(Permissions::INJECTOR_PROTOCOLS_CONCLUDE) ?? false;
}
public static function canEdit($record): bool
{
return auth()->user()?->canDo(Permissions::INJECTOR_PROTOCOLS_CONCLUDE) ?? false;
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Schemas\Components\Section::make(__('Date auto & client'))
->columns(3)
->schema([
Forms\Components\TextInput::make('protocol_number')
->label(__('Nr. protocol'))
->disabled()
->dehydrated(false)
->placeholder(__('Generat automat')),
Forms\Components\Select::make('source_type')
->label(__('Sursă'))
->options(\App\Support\I18n::opts(InjectorProtocol::SOURCE_TYPES))
->default('client')
->required(),
Forms\Components\TextInput::make('source_name')
->label(__('Autoservis / Client'))
->required()
->maxLength(160),
Forms\Components\Select::make('client_id')
->label(__('Client CRM'))
->options(fn () => Client::pluck('name', 'id'))
->searchable()
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $c = Client::find($state)) {
$set('source_name', $c->name);
$set('phone', $c->phone);
}
}),
Forms\Components\Select::make('vehicle_id')
->label(__('Auto CRM'))
->options(fn (Get $get) => $get('client_id')
? Vehicle::where('client_id', $get('client_id'))
->get()->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray()
: Vehicle::get()->mapWithKeys(fn ($v) => [$v->id => "{$v->make} {$v->model} {$v->plate}"])->toArray())
->searchable()
->live()
->afterStateUpdated(function ($state, Set $set) {
if ($state && $v = Vehicle::find($state)) {
$set('car_model', trim(($v->make ?? '') . ' ' . ($v->model ?? '')));
$set('plate_number', $v->plate);
$set('vin', $v->vin);
$set('year', $v->year);
$set('mileage', $v->mileage);
}
}),
Forms\Components\Select::make('work_order_id')
->label(__('Fișă lucru (opțional)'))
->options(fn () => WorkOrder::orderBy('id', 'desc')->limit(50)
->get()->mapWithKeys(fn ($w) => [$w->id => "{$w->number}" . ($w->client?->name ?? '?')])->toArray())
->searchable(),
Forms\Components\TextInput::make('phone')->label(__('Telefon'))->tel()->maxLength(40),
Forms\Components\TextInput::make('plate_number')->label(__('Nr. înmatriculare'))->maxLength(32),
Forms\Components\TextInput::make('vin')->label(__('VIN'))->maxLength(17),
Forms\Components\TextInput::make('car_model')->label(__('Model auto'))->maxLength(120),
Forms\Components\TextInput::make('year')->label(__('An'))->numeric()->minValue(1950)->maxValue(2100),
Forms\Components\TextInput::make('mileage')->label(__('Kilometraj'))->numeric()->suffix(__('km')),
Forms\Components\TextInput::make('injector_brand')->label(__('Marca injectoarelor'))->maxLength(60),
Forms\Components\TextInput::make('injector_count')->label(__('Nr. injectoare'))
->numeric()->minValue(1)->maxValue(8)->default(8)->required(),
]),
Schemas\Components\Section::make(__('Cauza adresării'))
->columns(3)
->schema([
Forms\Components\Checkbox::make('reason_new_injectors')->label(__('Injectoare noi')),
Forms\Components\Checkbox::make('reason_engine_overhaul')->label(__('Reparație capitală motor')),
Forms\Components\Checkbox::make('reason_check')->label(__('Verificare / diagnostic')),
]),
Schemas\Components\Section::make(__('Măsurători mecanica injectoarelor'))
->description(__('8 forsunki × 6 măsurători (Înainte / După). Introducerea se face pe rând.'))
->schema([
Forms\Components\Repeater::make('rows')
->label('')
->relationship('rows')
->schema([
Forms\Components\TextInput::make('position')
->label(__('№'))
->numeric()->minValue(1)->maxValue(8)->required()
->disabled()->dehydrated(true),
Forms\Components\TextInput::make('resistance_before')->label(__('Rezistență ÎNAINTE'))->numeric()->step(0.01)->suffix('Ω'),
Forms\Components\TextInput::make('resistance_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('Ω'),
Forms\Components\TextInput::make('open_time_before')->label(__('Deschidere ÎNAINTE'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('open_time_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_before')->label(__('Închidere ÎNAINTE'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_kz_before')->label(__('Închidere KZ ÎNAINTE'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('close_time_kz_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix('ms'),
Forms\Components\TextInput::make('flow_before')->label(__('Debit ÎNAINTE'))->numeric()->step(0.01)->suffix(__('ml')),
Forms\Components\TextInput::make('flow_after')->label(__('DUPĂ'))->numeric()->step(0.01)->suffix(__('ml')),
Forms\Components\TextInput::make('tightness_before')->label(__('Etanșeitate ÎNAINTE'))->maxLength(20),
Forms\Components\TextInput::make('tightness_after')->label(__('DUPĂ'))->maxLength(20),
])
->columns(2)
->itemLabel(fn (array $state) => __('Forsunka') . ' #' . ($state['position'] ?? '?'))
->addable(false)
->deletable(false)
->reorderable(false)
->collapsed(fn ($record) => (bool) $record),
]),
Schemas\Components\Section::make(__('Заключение'))
->columns(3)
->schema([
Forms\Components\Checkbox::make('conclusion_ok')
->label(__('Injectoarele în normă, apte pentru exploatare')),
Forms\Components\Checkbox::make('conclusion_needs_cleaning')
->label(__('Necesită curățare repetată')),
Forms\Components\Checkbox::make('conclusion_needs_replacement')
->label(__('Recomandată înlocuire')),
Forms\Components\Textarea::make('master_comment')
->label(__('Comentariu / recomandări maistru'))
->rows(3)
->columnSpanFull(),
Forms\Components\Select::make('master_id')
->label(__('Maistru'))
->options(fn () => User::whereIn('role', ['mechanic', 'admin', 'owner'])
->pluck('name', 'id'))
->searchable(),
Forms\Components\TextInput::make('master_name_signed')
->label(__('Semnat de (manual)'))
->maxLength(120)
->helperText(__('Doar dacă maistrul nu e în listă.')),
Forms\Components\DatePicker::make('issue_date')
->label(__('Data emiterii'))
->default(today()),
Forms\Components\Checkbox::make('client_acknowledged')
->label(__('Client informat / a luat cunoștință'))
->columnSpanFull(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('protocol_number')
->label(__('Nr. protocol'))
->searchable()
->sortable()
->copyable()
->weight('bold'),
Tables\Columns\TextColumn::make('issue_date')
->label(__('Data'))
->date('d.m.Y')
->sortable()
->placeholder('—'),
Tables\Columns\TextColumn::make('source_name')
->label(__('Autoservis / Client'))
->searchable()
->wrap(),
Tables\Columns\TextColumn::make('plate_number')
->label(__('Nr. auto'))
->searchable()
->placeholder('—'),
Tables\Columns\TextColumn::make('injector_brand')
->label(__('Marca'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('injector_count')
->label(__('Injectoare'))
->alignCenter(),
Tables\Columns\IconColumn::make('conclusion_ok')
->label(__('OK'))
->boolean()
->toggleable(),
Tables\Columns\IconColumn::make('conclusion_needs_cleaning')
->label(__('Curățare'))
->boolean()
->toggleable(),
Tables\Columns\IconColumn::make('conclusion_needs_replacement')
->label(__('Înlocuire'))
->boolean()
->toggleable(),
Tables\Columns\TextColumn::make('master.name')
->label(__('Maistru'))
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label(__('Creat'))
->dateTime('d.m.Y H:i')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('source_type')
->label(__('Sursă'))
->options(\App\Support\I18n::opts(InjectorProtocol::SOURCE_TYPES)),
Tables\Filters\Filter::make('needs_action')
->label(__('Necesită acțiune'))
->query(fn ($q) => $q->where(fn ($q) => $q
->where('conclusion_needs_cleaning', true)
->orWhere('conclusion_needs_replacement', true))),
])
->actions([
Actions\Action::make('pdf')
->label(__('PDF'))
->icon('heroicon-m-document-arrow-down')
->color('gray')
->url(fn (InjectorProtocol $r) => route('tenant.injector-protocols.pdf', ['record' => $r->id]))
->openUrlInNewTab(),
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->emptyStateHeading(__('Niciun protocol'))
->emptyStateDescription(__('Creează primul protocol de diagnostic forsunki. Numărul se generează automat (PS-{tenant}-{an}-{seq}).'))
->emptyStateIcon('heroicon-o-beaker')
->defaultSort('created_at', 'desc');
}
public static function getPages(): array
{
return [
'index' => Pages\ListInjectorProtocols::route('/'),
'create' => Pages\CreateInjectorProtocol::route('/create'),
'edit' => Pages\EditInjectorProtocol::route('/{record}/edit'),
];
}
}
@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Tenant\Resources\InjectorProtocolResource\Pages;
use App\Filament\Tenant\Resources\InjectorProtocolResource;
use Filament\Resources\Pages\CreateRecord;
class CreateInjectorProtocol extends CreateRecord
{
protected static string $resource = InjectorProtocolResource::class;
}
@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Tenant\Resources\InjectorProtocolResource\Pages;
use App\Filament\Tenant\Resources\InjectorProtocolResource;
use App\Models\Tenant\InjectorProtocol;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditInjectorProtocol extends EditRecord
{
protected static string $resource = InjectorProtocolResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('pdf')
->label(__('Descarcă PDF'))
->icon('heroicon-m-document-arrow-down')
->color('gray')
->url(fn (InjectorProtocol $record) => route('tenant.injector-protocols.pdf', ['record' => $record->id]))
->openUrlInNewTab(),
Actions\DeleteAction::make(),
];
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Filament\Tenant\Resources\InjectorProtocolResource\Pages;
use App\Filament\Tenant\Resources\InjectorProtocolResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListInjectorProtocols extends ListRecords
{
protected static string $resource = InjectorProtocolResource::class;
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()->label(__('Protocol nou'))];
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use App\Tenancy\TenantManager;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class InjectorProtocol extends Model
{
use BelongsToTenant;
public const SOURCE_TYPES = [
'client' => 'Клиент',
'service_partner' => 'Автосервис',
];
protected $fillable = [
'company_id', 'protocol_number',
'client_id', 'vehicle_id', 'work_order_id',
'source_type', 'source_name', 'phone',
'car_model', 'plate_number', 'vin', 'mileage', 'year',
'injector_brand', 'injector_count',
'reason_new_injectors', 'reason_engine_overhaul', 'reason_check',
'conclusion_ok', 'conclusion_needs_cleaning', 'conclusion_needs_replacement',
'master_comment', 'master_id', 'master_name_signed',
'issue_date', 'client_acknowledged',
];
protected $casts = [
'reason_new_injectors' => 'boolean',
'reason_engine_overhaul' => 'boolean',
'reason_check' => 'boolean',
'conclusion_ok' => 'boolean',
'conclusion_needs_cleaning' => 'boolean',
'conclusion_needs_replacement' => 'boolean',
'client_acknowledged' => 'boolean',
'issue_date' => 'date',
'mileage' => 'integer',
'year' => 'integer',
'injector_count' => 'integer',
];
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function vehicle(): BelongsTo
{
return $this->belongsTo(Vehicle::class);
}
public function workOrder(): BelongsTo
{
return $this->belongsTo(WorkOrder::class);
}
public function master(): BelongsTo
{
return $this->belongsTo(User::class, 'master_id');
}
public function rows(): HasMany
{
return $this->hasMany(InjectorProtocolRow::class)->orderBy('position');
}
protected static function booted(): void
{
static::creating(function (InjectorProtocol $p) {
$p->protocol_number ??= static::generateNumber();
});
static::created(function (InjectorProtocol $p) {
// Auto-seed empty rows for each injector position (1..N)
$count = max(1, min(8, (int) $p->injector_count));
for ($i = 1; $i <= $count; $i++) {
$p->rows()->create([
'company_id' => $p->company_id,
'position' => $i,
]);
}
});
}
/** PS-{tenantId}-{YYYY}-{seq6} — per-tenant, per-year sequence. */
public static function generateNumber(): string
{
$companyId = app(TenantManager::class)->current()?->id
?? auth()->user()?->company_id
?? 0;
$year = now()->year;
$seq = static::withoutGlobalScopes()
->where('company_id', $companyId)
->whereYear('created_at', $year)
->count() + 1;
return sprintf('PS-%d-%d-%06d', $companyId, $year, $seq);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InjectorProtocolRow extends Model
{
use BelongsToTenant;
protected $fillable = [
'company_id', 'injector_protocol_id', 'position',
'resistance_before', 'resistance_after',
'open_time_before', 'open_time_after',
'close_time_before', 'close_time_after',
'close_time_kz_before', 'close_time_kz_after',
'flow_before', 'flow_after',
'tightness_before', 'tightness_after',
];
protected $casts = [
'position' => 'integer',
'resistance_before' => 'decimal:2',
'resistance_after' => 'decimal:2',
'open_time_before' => 'decimal:2',
'open_time_after' => 'decimal:2',
'close_time_before' => 'decimal:2',
'close_time_after' => 'decimal:2',
'close_time_kz_before' => 'decimal:2',
'close_time_kz_after' => 'decimal:2',
'flow_before' => 'decimal:2',
'flow_after' => 'decimal:2',
];
public function protocol(): BelongsTo
{
return $this->belongsTo(InjectorProtocol::class, 'injector_protocol_id');
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Services;
use App\Models\Tenant\InjectorProtocol;
use Spatie\Browsershot\Browsershot;
class InjectorProtocolPdfService
{
public function render(InjectorProtocol $protocol): string
{
$html = view('pdf.injector-protocol', ['protocol' => $protocol->load('rows', 'master')])->render();
$shot = Browsershot::html($html)
->format('A4')
->margins(12, 14, 12, 14)
->showBackground()
->waitUntilNetworkIdle();
if ($node = env('BROWSERSHOT_NODE_BINARY')) {
$shot->setNodeBinary($node);
}
if ($npm = env('BROWSERSHOT_NPM_BINARY')) {
$shot->setNpmBinary($npm);
}
if ($modules = env('BROWSERSHOT_NODE_MODULE_PATH')) {
$shot->setNodeModulePath($modules);
}
if ($chrome = env('BROWSERSHOT_CHROME_PATH')) {
$shot->setChromePath($chrome);
}
// Docker sandbox — chromium refuses to run as root without --no-sandbox
$shot->noSandbox();
return $shot->pdf();
}
public function filename(InjectorProtocol $protocol): string
{
$num = $protocol->protocol_number ?: ('protocol-' . $protocol->id);
return $num . '.pdf';
}
}