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:
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+11
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+17
@@ -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'))];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user