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
+20 -1
View File
@@ -46,13 +46,32 @@ RUN install-php-extensions \
mbstring \ mbstring \
gmp gmp
# System tools # System tools + Chromium + Node.js (for spatie/browsershot PDF rendering)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
git \ git \
unzip \ unzip \
curl \ curl \
ca-certificates \
gnupg \
chromium \
fonts-liberation \
fonts-noto \
fonts-noto-cjk \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Puppeteer + Browsershot deps live in a shared node_modules under /app
# so Browsershot can spawn `node`+puppeteer bundled with Chromium bindings.
RUN mkdir -p /opt/browsershot && cd /opt/browsershot \
&& npm install --omit=dev puppeteer-core@23 \
&& chown -R www-data:www-data /opt/browsershot
ENV BROWSERSHOT_NODE_BINARY=/usr/bin/node \
BROWSERSHOT_NPM_BINARY=/usr/bin/npm \
BROWSERSHOT_NODE_MODULE_PATH=/opt/browsershot/node_modules \
BROWSERSHOT_CHROME_PATH=/usr/bin/chromium
# Copy application code # Copy application code
COPY --link . /app COPY --link . /app
# Composer vendor from stage 1 # Composer vendor from stage 1
+11
View File
@@ -81,6 +81,10 @@ class Permissions
public const AI_ASSISTANT_CONFIGURE_KEYS = 'ai_assistant.configure_keys'; public const AI_ASSISTANT_CONFIGURE_KEYS = 'ai_assistant.configure_keys';
public const ANALYTICS_VIEW = 'analytics.view'; 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. */ /** Full list — used by seeder. */
public static function all(): array 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_SETTINGS_EDIT, self::ADMIN_INTEGRATIONS, self::ADMIN_API_TOKENS_MANAGE,
self::ADMIN_AUDIT_LOG_VIEW, self::ADMIN_BACKUP_DOWNLOAD, self::ADMIN_AUDIT_LOG_VIEW, self::ADMIN_BACKUP_DOWNLOAD,
self::AI_ASSISTANT_USE, self::AI_ASSISTANT_CONFIGURE_KEYS, self::ANALYTICS_VIEW, 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', 'admin' => 'Administrare',
'ai_assistant' => 'AI Assistant', 'ai_assistant' => 'AI Assistant',
'analytics' => 'Analitică', 'analytics' => 'Analitică',
'injector_protocols' => 'Protocol forsunki',
]; ];
} }
@@ -188,6 +194,8 @@ class Permissions
'ai_assistant.use' => 'Folosește AI Assistant', 'ai_assistant.use' => 'Folosește AI Assistant',
'ai_assistant.configure_keys' => 'Configurează chei API pentru AI', 'ai_assistant.configure_keys' => 'Configurează chei API pentru AI',
'analytics.view' => 'Vezi rapoarte & analitică', '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::SUPPLIERS_VIEW, self::SUPPLIERS_EDIT,
self::AI_ASSISTANT_USE, self::AI_ASSISTANT_USE,
self::ANALYTICS_VIEW, self::ANALYTICS_VIEW,
self::INJECTOR_PROTOCOLS_VIEW, self::INJECTOR_PROTOCOLS_CONCLUDE,
]; ];
// accountant: finance + reporting only // accountant: finance + reporting only
@@ -247,6 +256,7 @@ class Permissions
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_OWN,
self::INVENTORY_VIEW, self::INVENTORY_VIEW,
self::AI_ASSISTANT_USE, self::AI_ASSISTANT_USE,
self::INJECTOR_PROTOCOLS_VIEW,
]; ];
// mechanic: only own WOs + inventory 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::WORK_ORDERS_VIEW_OWN_ASSIGNED, self::WORK_ORDERS_CHANGE_STATUS,
self::INVENTORY_VIEW, self::INVENTORY_VIEW,
self::SALARIES_VIEW_OWN, self::SALARIES_VIEW_OWN,
self::INJECTOR_PROTOCOLS_VIEW, self::INJECTOR_PROTOCOLS_CONCLUDE,
]; ];
// viewer: read-only // 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';
}
}
+1
View File
@@ -18,6 +18,7 @@
"minishlink/web-push": "^10.0", "minishlink/web-push": "^10.0",
"phpoffice/phpspreadsheet": "^5.7", "phpoffice/phpspreadsheet": "^5.7",
"resend/resend-laravel": "^1.4", "resend/resend-laravel": "^1.4",
"spatie/browsershot": "^5.4",
"spatie/laravel-activitylog": "^5.0", "spatie/laravel-activitylog": "^5.0",
"spatie/laravel-medialibrary": "^11.22", "spatie/laravel-medialibrary": "^11.22",
"spatie/laravel-permission": "^7.4", "spatie/laravel-permission": "^7.4",
Generated
+69 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "5b5b5d8a2a2a4bac8ef246a2b165992c", "content-hash": "d8f2a53adf427685095dafb1deff3fa9",
"packages": [ "packages": [
{ {
"name": "barryvdh/laravel-dompdf", "name": "barryvdh/laravel-dompdf",
@@ -7380,6 +7380,74 @@
], ],
"time": "2022-12-17T21:53:22+00:00" "time": "2022-12-17T21:53:22+00:00"
}, },
{
"name": "spatie/browsershot",
"version": "5.4.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/browsershot.git",
"reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/browsershot/zipball/dcf7a65fd1d0fc8fd113739b84982377728d0b2f",
"reference": "dcf7a65fd1d0fc8fd113739b84982377728d0b2f",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"ext-json": "*",
"php": "^8.2",
"spatie/temporary-directory": "^2.0",
"symfony/process": "^6.0|^7.0|^8.0"
},
"require-dev": {
"pestphp/pest": "^3.0|^4.0",
"spatie/image": "^3.6",
"spatie/pdf-to-text": "^1.52",
"spatie/phpunit-snapshot-assertions": "^5.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Browsershot\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://github.com/freekmurze",
"role": "Developer"
}
],
"description": "Convert a webpage to an image or pdf using headless Chrome",
"homepage": "https://github.com/spatie/browsershot",
"keywords": [
"chrome",
"convert",
"headless",
"image",
"pdf",
"puppeteer",
"screenshot",
"webpage"
],
"support": {
"source": "https://github.com/spatie/browsershot/tree/5.4.0"
},
"funding": [
{
"url": "https://github.com/spatie",
"type": "github"
}
],
"time": "2026-05-26T13:13:33+00:00"
},
{ {
"name": "spatie/image", "name": "spatie/image",
"version": "3.9.4", "version": "3.9.4",
@@ -0,0 +1,88 @@
<?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::create('injector_protocols', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$t->string('protocol_number', 32)->nullable();
$t->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
$t->foreignId('vehicle_id')->nullable()->constrained('vehicles')->nullOnDelete();
$t->foreignId('work_order_id')->nullable()->constrained('work_orders')->nullOnDelete();
// «Автосервис / Клиент» — who delivered the vehicle
$t->string('source_type', 20)->default('client'); // 'client' | 'service_partner'
$t->string('source_name', 160)->nullable();
$t->string('phone', 40)->nullable();
// vehicle snapshot at protocol time
$t->string('car_model', 120)->nullable();
$t->string('plate_number', 32)->nullable();
$t->string('vin', 32)->nullable();
$t->unsignedInteger('mileage')->nullable();
$t->unsignedSmallInteger('year')->nullable();
$t->string('injector_brand', 60)->nullable();
$t->unsignedTinyInteger('injector_count')->default(8);
// «Причина обращения»
$t->boolean('reason_new_injectors')->default(false);
$t->boolean('reason_engine_overhaul')->default(false);
$t->boolean('reason_check')->default(false);
// «Заключение»
$t->boolean('conclusion_ok')->default(false);
$t->boolean('conclusion_needs_cleaning')->default(false);
$t->boolean('conclusion_needs_replacement')->default(false);
$t->text('master_comment')->nullable();
// Master = User with role='mechanic' (this CRM uses User, not Employee)
$t->foreignId('master_id')->nullable()->constrained('users')->nullOnDelete();
$t->string('master_name_signed', 120)->nullable();
$t->date('issue_date')->nullable();
$t->boolean('client_acknowledged')->default(false);
$t->timestamps();
$t->unique(['company_id', 'protocol_number']);
$t->index(['company_id', 'plate_number']);
$t->index(['company_id', 'vin']);
});
Schema::create('injector_protocol_rows', function (Blueprint $t) {
$t->id();
$t->foreignId('company_id')->constrained('companies')->cascadeOnDelete();
$t->foreignId('injector_protocol_id')->constrained('injector_protocols')->cascadeOnDelete();
$t->unsignedTinyInteger('position'); // 1..8
$t->decimal('resistance_before', 6, 2)->nullable();
$t->decimal('resistance_after', 6, 2)->nullable();
$t->decimal('open_time_before', 6, 2)->nullable();
$t->decimal('open_time_after', 6, 2)->nullable();
$t->decimal('close_time_before', 6, 2)->nullable();
$t->decimal('close_time_after', 6, 2)->nullable();
$t->decimal('close_time_kz_before', 6, 2)->nullable();
$t->decimal('close_time_kz_after', 6, 2)->nullable();
$t->decimal('flow_before', 6, 2)->nullable();
$t->decimal('flow_after', 6, 2)->nullable();
$t->string('tightness_before', 20)->nullable();
$t->string('tightness_after', 20)->nullable();
$t->timestamps();
$t->unique(['injector_protocol_id', 'position']);
});
}
public function down(): void
{
Schema::dropIfExists('injector_protocol_rows');
Schema::dropIfExists('injector_protocols');
}
};
+71 -1
View File
@@ -28,6 +28,7 @@
"2FA dezactivat.": "2FA dezactivat.", "2FA dezactivat.": "2FA dezactivat.",
"2FA resetat": "2FA reset", "2FA resetat": "2FA reset",
"58.5h/10": "58.5h/10", "58.5h/10": "58.5h/10",
"8 forsunki × 6 măsurători (Înainte / După). Introducerea se face pe rând.": "8 injectors × 6 measurements (Before / After). One row at a time.",
":n poziții importate în Purchase nouă": ":n items imported into a new Purchase", ":n poziții importate în Purchase nouă": ":n items imported into a new Purchase",
"> 0 calculează automat prețul client.": "> 0 auto-calculates client price.", "> 0 calculează automat prețul client.": "> 0 auto-calculates client price.",
"A1-03": "A1-03", "A1-03": "A1-03",
@@ -260,11 +261,13 @@
"Autentificare 2FA": "2FA authentication", "Autentificare 2FA": "2FA authentication",
"Auto": "Vehicle", "Auto": "Vehicle",
"Auto / model": "Vehicle / model", "Auto / model": "Vehicle / model",
"Auto CRM": "CRM vehicle",
"Auto-import Excel/CSV": "Auto-import Excel/CSV", "Auto-import Excel/CSV": "Auto-import Excel/CSV",
"AutoCRM PSauto · psauto.service.mir.md": "AutoCRM PSauto · psauto.service.mir.md", "AutoCRM PSauto · psauto.service.mir.md": "AutoCRM PSauto · psauto.service.mir.md",
"AutoCRM SRL": "AutoCRM SRL", "AutoCRM SRL": "AutoCRM SRL",
"Automată": "Automatic", "Automată": "Automatic",
"Automobile": "Vehicles", "Automobile": "Vehicles",
"Autoservis / Client": "Auto service / Client",
"Avans": "Advance", "Avans": "Advance",
"Avans achitat": "Advance paid", "Avans achitat": "Advance paid",
"Avg zile": "Avg days", "Avg zile": "Avg days",
@@ -363,6 +366,7 @@
"Caută": "Search", "Caută": "Search",
"Caută client, mașină, număr...": "Search client, vehicle, number...", "Caută client, mașină, număr...": "Search client, vehicle, number...",
"Caută în tabel": "Search in table", "Caută în tabel": "Search in table",
"Cauza adresării": "Reason for request",
"Caz asigurare": "Insurance case", "Caz asigurare": "Insurance case",
"Caz de asigurare": "Insurance case", "Caz de asigurare": "Insurance case",
"Ce a spus clientul...": "What the client said...", "Ce a spus clientul...": "What the client said...",
@@ -403,6 +407,7 @@
"Client ID": "Client ID", "Client ID": "Client ID",
"Client VIP": "VIP client", "Client VIP": "VIP client",
"Client existent": "Existing client", "Client existent": "Existing client",
"Client informat / a luat cunoștință": "Client informed / acknowledged",
"Client legat (CRM)": "Linked client (CRM)", "Client legat (CRM)": "Linked client (CRM)",
"Client name": "Client name", "Client name": "Client name",
"Client nou": "New client", "Client nou": "New client",
@@ -445,6 +450,7 @@
"Comandă nouă #": "New order #", "Comandă nouă #": "New order #",
"Comandă primită": "Order received", "Comandă primită": "Order received",
"Combustibil": "Fuel", "Combustibil": "Fuel",
"Comentariu / recomandări maistru": "Master comment / recommendations",
"Comenzi": "Orders", "Comenzi": "Orders",
"Comercial": "Commercial", "Comercial": "Commercial",
"Comercial (van/camion)": "Commercial (van/truck)", "Comercial (van/camion)": "Commercial (van/truck)",
@@ -527,6 +533,7 @@
"Creează cerere": "Create lead", "Creează cerere": "Create lead",
"Creează comandă de aprovizionare": "Create purchase order", "Creează comandă de aprovizionare": "Create purchase order",
"Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.": "Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.", "Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.": "Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.",
"Creează primul protocol de diagnostic forsunki. Numărul se generează automat (PS-{tenant}-{an}-{seq}).": "Create the first injector diagnostic protocol. Number is auto-generated (PS-{tenant}-{year}-{seq}).",
"Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook": "Create a bot with @BotFather, paste the token here and click \"Set webhook\".", "Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook": "Create a bot with @BotFather, paste the token here and click \"Set webhook\".",
"Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook\". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.": "Create a bot with @BotFather, paste the token here and click \"Set webhook\". Clients write to the bot, share their phone, and the code auto-links to their order.", "Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook\". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.": "Create a bot with @BotFather, paste the token here and click \"Set webhook\". Clients write to the bot, share their phone, and the code auto-links to their order.",
"Crescător": "Ascending", "Crescător": "Ascending",
@@ -546,6 +553,7 @@
"Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.": "Stackable = multiplies with other coefficients. Non-stackable = only the largest non-stackable applies.", "Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.": "Stackable = multiplies with other coefficients. Non-stackable = only the largest non-stackable applies.",
"Curier": "Courier", "Curier": "Courier",
"Currency": "Currency", "Currency": "Currency",
"Curățare": "Cleaning",
"Curăță": "Clear", "Curăță": "Clear",
"Custom": "Custom", "Custom": "Custom",
"Cutie": "Gearbox", "Cutie": "Gearbox",
@@ -559,6 +567,7 @@
"DENY — interzice dreptul": "DENY — deny the permission", "DENY — interzice dreptul": "DENY — deny the permission",
"DOT": "DOT", "DOT": "DOT",
"DSG": "DSG", "DSG": "DSG",
"DUPĂ": "AFTER",
"Da, confirmă": "Yes, confirm", "Da, confirmă": "Yes, confirm",
"Da, șterge": "Yes, delete", "Da, șterge": "Yes, delete",
"Dacă e gol, generăm 10 caractere random.": "If empty, we generate 10 random characters.", "Dacă e gol, generăm 10 caractere random.": "If empty, we generate 10 random characters.",
@@ -570,6 +579,7 @@
"Data comandă": "Order date", "Data comandă": "Order date",
"Data creării": "Created at", "Data creării": "Created at",
"Data deschiderii:": "Data deschiderii:", "Data deschiderii:": "Data deschiderii:",
"Data emiterii": "Issue date",
"Data factură": "Invoice date", "Data factură": "Invoice date",
"Data ieșire": "Delivery date", "Data ieșire": "Delivery date",
"Data intrare": "Intake date", "Data intrare": "Intake date",
@@ -580,6 +590,7 @@
"Data început": "Start date", "Data început": "Start date",
"Data închiderii:": "Close date:", "Data închiderii:": "Close date:",
"Date": "Date", "Date": "Date",
"Date auto & client": "Vehicle & client data",
"Date generale": "General data", "Date generale": "General data",
"Date insuficiente": "Insufficient data", "Date insuficiente": "Insufficient data",
"Date legale (apar pe facturi)": "Date legale (apar pe facturi)", "Date legale (apar pe facturi)": "Date legale (apar pe facturi)",
@@ -605,6 +616,7 @@
"Deal-uri active": "Active deals", "Deal-uri active": "Active deals",
"Deals": "Deals", "Deals": "Deals",
"Debit": "Debit", "Debit": "Debit",
"Debit ÎNAINTE": "Flow BEFORE",
"Deblochează": "Unblock", "Deblochează": "Unblock",
"Decembrie": "December", "Decembrie": "December",
"Decode VIN": "Decode VIN", "Decode VIN": "Decode VIN",
@@ -644,6 +656,7 @@
"Deschide panoul pe telefon și acceptă notificările întâi.": "Open the panel on the phone and accept notifications first.", "Deschide panoul pe telefon și acceptă notificările întâi.": "Open the panel on the phone and accept notifications first.",
"Deschide tenantul": "Deschide tenantul", "Deschide tenantul": "Deschide tenantul",
"Deschide →": "Open →", "Deschide →": "Open →",
"Deschidere ÎNAINTE": "Opening BEFORE",
"Deschis": "Opened", "Deschis": "Opened",
"Deschis la": "Opened at", "Deschis la": "Opened at",
"Descrescător": "Descending", "Descrescător": "Descending",
@@ -682,6 +695,7 @@
"Doar arhivate": "Archived only", "Doar arhivate": "Archived only",
"Doar clienți VIP": "VIP clients only", "Doar clienți VIP": "VIP clients only",
"Doar clienții mei": "My clients only", "Doar clienții mei": "My clients only",
"Doar dacă maistrul nu e în listă.": "Only if the master is not in the list.",
"Doar la manopere (proprii + subcontract). Baza salariu = preț client × (1 marjă/100). Lasă gol pentru a folosi valoarea implicită a companiei.": "Only for labor (own + subcontract). Salary base = client price × (1 margin/100). Leave empty to use company default.", "Doar la manopere (proprii + subcontract). Baza salariu = preț client × (1 marjă/100). Lasă gol pentru a folosi valoarea implicită a companiei.": "Only for labor (own + subcontract). Salary base = client price × (1 margin/100). Leave empty to use company default.",
"Doar pentru cazuri speciale. Lasă gol → folosește marja mecanicului.": "For special cases only. Leave empty → uses the mechanic's margin.", "Doar pentru cazuri speciale. Lasă gol → folosește marja mecanicului.": "For special cases only. Leave empty → uses the mechanic's margin.",
"Doar tu ești aici": "Only you are here", "Doar tu ești aici": "Only you are here",
@@ -750,6 +764,7 @@
"Engine": "Engine", "Engine": "Engine",
"Eroare": "Error", "Eroare": "Error",
"Eroare la recepție": "Receiving error", "Eroare la recepție": "Receiving error",
"Etanșeitate ÎNAINTE": "Tightness BEFORE",
"Etape": "Stages", "Etape": "Stages",
"Etapă": "Stage", "Etapă": "Stage",
"Etichete piese": "Etichete piese", "Etichete piese": "Etichete piese",
@@ -830,6 +845,7 @@
"Fișă asociată": "Linked order", "Fișă asociată": "Linked order",
"Fișă de lucru": "Work order", "Fișă de lucru": "Work order",
"Fișă lucru": "Work order", "Fișă lucru": "Work order",
"Fișă lucru (opțional)": "Work order (opt.)",
"Folie PPF": "PPF film", "Folie PPF": "PPF film",
"Folosește AI Assistant": "Use AI Assistant", "Folosește AI Assistant": "Use AI Assistant",
"Folosește „Check-in depozit": "Use \"Check-in storage\"", "Folosește „Check-in depozit": "Use \"Check-in storage\"",
@@ -840,6 +856,7 @@
"Force delete": "Force delete", "Force delete": "Force delete",
"Force logout": "Force logout", "Force logout": "Force logout",
"Format:": "Format:", "Format:": "Format:",
"Forsunka": "Injector",
"Foto": "Photo", "Foto": "Photo",
"Foto factură": "Invoice photo", "Foto factură": "Invoice photo",
"Foto piesă": "Part photo", "Foto piesă": "Part photo",
@@ -942,6 +959,8 @@
"Informația de bază — click pe titlul secțiunilor de mai jos pentru detalii.": "Basic information — click on the section titles below for details.", "Informația de bază — click pe titlul secțiunilor de mai jos pentru detalii.": "Basic information — click on the section titles below for details.",
"Informație": "Info", "Informație": "Info",
"Injectoare": "Injectors", "Injectoare": "Injectors",
"Injectoare noi": "New injectors",
"Injectoarele în normă, apte pentru exploatare": "Injectors OK, fit for use",
"Instrucțiuni adiționale": "Additional instructions", "Instrucțiuni adiționale": "Additional instructions",
"Integrări": "Integrations", "Integrări": "Integrations",
"Integrări de plată": "Payment integrations", "Integrări de plată": "Payment integrations",
@@ -1082,7 +1101,8 @@
"Manual": "Manual", "Manual": "Manual",
"Manuală": "Manual", "Manuală": "Manual",
"Mapare coloane": "Column mapping", "Mapare coloane": "Column mapping",
"Marca": "Make", "Marca": "Brand",
"Marca injectoarelor": "Injector brand",
"Marca/Model": "Make/Model", "Marca/Model": "Make/Model",
"Marca/Model:": "Marca/Model:", "Marca/Model:": "Marca/Model:",
"Marchează Gata": "Mark ready", "Marchează Gata": "Mark ready",
@@ -1160,6 +1180,7 @@
"Model Claude": "Claude model", "Model Claude": "Claude model",
"Model Gemini": "Gemini model", "Model Gemini": "Gemini model",
"Model OpenAI": "OpenAI model", "Model OpenAI": "OpenAI model",
"Model auto": "Vehicle model",
"Model implicit": "Default model", "Model implicit": "Default model",
"Model year": "Model year", "Model year": "Model year",
"Modele": "Models", "Modele": "Models",
@@ -1178,6 +1199,7 @@
"Mulțumim": "Thank you", "Mulțumim": "Thank you",
"Mâine": "Tomorrow", "Mâine": "Tomorrow",
"Mărci auto suportate (separate prin virgulă)": "Supported makes (comma-separated)", "Mărci auto suportate (separate prin virgulă)": "Supported makes (comma-separated)",
"Măsurători mecanica injectoarelor": "Injector mechanics measurements",
"Name": "Name", "Name": "Name",
"Neachitat": "Unpaid", "Neachitat": "Unpaid",
"Necesită acțiune": "Needs action", "Necesită acțiune": "Needs action",
@@ -1185,6 +1207,7 @@
"Necesită aprobare client": "Needs client approval", "Necesită aprobare client": "Needs client approval",
"Necesită atenție": "Needs attention", "Necesită atenție": "Needs attention",
"Necesită cel puțin 2 recepții complete cu data așteptată setată.": "Requires at least 2 full receipts with expected date set.", "Necesită cel puțin 2 recepții complete cu data așteptată setată.": "Requires at least 2 full receipts with expected date set.",
"Necesită curățare repetată": "Repeat cleaning needed",
"Necompletat": "Empty", "Necompletat": "Empty",
"Neconfirmat": "Unconfirmed", "Neconfirmat": "Unconfirmed",
"Neconfirmate": "Unconfirmed", "Neconfirmate": "Unconfirmed",
@@ -1238,6 +1261,7 @@
"Niciun mecanic nu a finalizat lucrări în această perioadă.": "No mechanic has completed work in this period.", "Niciun mecanic nu a finalizat lucrări în această perioadă.": "No mechanic has completed work in this period.",
"Niciun plan definit": "Niciun plan definit", "Niciun plan definit": "Niciun plan definit",
"Niciun preț înregistrat": "No prices recorded", "Niciun preț înregistrat": "No prices recorded",
"Niciun protocol": "No protocols",
"Niciun rest de încasat. 🎉": "No balance to collect. 🎉", "Niciun rest de încasat. 🎉": "No balance to collect. 🎉",
"Niciun rezultat pentru": "No results for", "Niciun rezultat pentru": "No results for",
"Niciun set de anvelope": "No tire sets", "Niciun set de anvelope": "No tire sets",
@@ -1268,10 +1292,13 @@
"Nou tehnician": "New technician", "Nou tehnician": "New technician",
"Nouă": "New", "Nouă": "New",
"Nr.": "No.", "Nr.": "No.",
"Nr. auto": "Plate",
"Nr. dosar daună": "Damage file no.", "Nr. dosar daună": "Damage file no.",
"Nr. factură": "Invoice no.", "Nr. factură": "Invoice no.",
"Nr. fișă": "Order no.", "Nr. fișă": "Order no.",
"Nr. injectoare": "Injector count",
"Nr. poliță": "Policy no.", "Nr. poliță": "Policy no.",
"Nr. protocol": "Protocol no.",
"Nr. telefon": "Phone number", "Nr. telefon": "Phone number",
"Nr. înmatriculare": "License plate", "Nr. înmatriculare": "License plate",
"Nr.:": "Nr.:", "Nr.:": "Nr.:",
@@ -1307,6 +1334,7 @@
"Număr": "Number", "Număr": "Number",
"Numărul de zile gratis după ce un tenant alege acest plan.": "Free trial days when a tenant chooses this plan.", "Numărul de zile gratis după ce un tenant alege acest plan.": "Free trial days when a tenant chooses this plan.",
"OCR eșuat": "OCR failed", "OCR eșuat": "OCR failed",
"OK": "OK",
"Observații": "Notes", "Observații": "Notes",
"Obține token cu": "Get token with", "Obține token cu": "Get token with",
"Octombrie": "October", "Octombrie": "October",
@@ -1556,6 +1584,7 @@
"Progres": "Progress", "Progres": "Progress",
"Progres etapă": "Stage progress", "Progres etapă": "Stage progress",
"Proprietar": "Owner", "Proprietar": "Owner",
"Protocol nou": "New protocol",
"Provider implicit": "Default provider", "Provider implicit": "Default provider",
"Provider:": "Provider:", "Provider:": "Provider:",
"Public": "Public", "Public": "Public",
@@ -1616,6 +1645,7 @@
"Recomandare": "Referral", "Recomandare": "Referral",
"Recomandat": "Recommended", "Recomandat": "Recommended",
"Recomandate": "Recommended", "Recomandate": "Recommended",
"Recomandată înlocuire": "Replacement recommended",
"Recomandă": "Recommend", "Recomandă": "Recommend",
"Recomandări": "Recommendations", "Recomandări": "Recommendations",
"Recomandări AI": "AI recommendations", "Recomandări AI": "AI recommendations",
@@ -1641,6 +1671,7 @@
"Renunță": "Cancel", "Renunță": "Cancel",
"Reordonează": "Reorder", "Reordonează": "Reorder",
"Reparație": "Repair", "Reparație": "Repair",
"Reparație capitală motor": "Engine overhaul",
"Reparație caroserie": "Body repair", "Reparație caroserie": "Body repair",
"Reparații": "Repairs", "Reparații": "Repairs",
"Report": "Report", "Report": "Report",
@@ -1671,6 +1702,7 @@
"Rezervare": "Reservation", "Rezervare": "Reservation",
"Rezervat": "Reserved", "Rezervat": "Reserved",
"Rezervă o programare": "Rezervă o programare", "Rezervă o programare": "Rezervă o programare",
"Rezistență ÎNAINTE": "Resistance BEFORE",
"Rezultat": "Result", "Rezultat": "Result",
"Rezultate": "Results", "Rezultate": "Results",
"Rezumat": "Summary", "Rezumat": "Summary",
@@ -1762,6 +1794,7 @@
"Selectează perioadă": "Select period", "Selectează perioadă": "Select period",
"Semnat": "Signed", "Semnat": "Signed",
"Semnat de": "Signed by", "Semnat de": "Signed by",
"Semnat de (manual)": "Signed by (manual)",
"Semnează": "Sign", "Semnează": "Sign",
"Semnătură": "Signature", "Semnătură": "Signature",
"Semnătură digitală": "Digital signature", "Semnătură digitală": "Digital signature",
@@ -2094,6 +2127,7 @@
"Venit manopere": "Labor revenue", "Venit manopere": "Labor revenue",
"Venituri": "Revenue", "Venituri": "Revenue",
"Verificare": "Verification", "Verificare": "Verification",
"Verificare / diagnostic": "Check / diagnosis",
"Verificat": "Verified", "Verificat": "Verified",
"Verifică emailul": "Check your email", "Verifică emailul": "Check your email",
"Verifică și amintește": "Verify and remind", "Verifică și amintește": "Verify and remind",
@@ -2392,6 +2426,8 @@
"programări": "appointments", "programări": "appointments",
"programări active": "active appointments", "programări active": "active appointments",
"programări neconfirmate &lt; 24h": "unconfirmed appointments < 24h", "programări neconfirmate &lt; 24h": "unconfirmed appointments < 24h",
"protocoale forsunki": "injector protocols",
"protocol forsunki": "injector protocol",
"rata confirmare": "confirmation rate", "rata confirmare": "confirmation rate",
"referral": "Referral", "referral": "Referral",
"reguli markup": "markup rules", "reguli markup": "markup rules",
@@ -2446,6 +2482,8 @@
"Începe lucrul": "Start work", "Începe lucrul": "Start work",
"Închide": "Close", "Închide": "Close",
"Închide fișa": "Close order", "Închide fișa": "Close order",
"Închidere KZ ÎNAINTE": "Closing KZ BEFORE",
"Închidere ÎNAINTE": "Closing BEFORE",
"Închis": "Closed", "Închis": "Closed",
"Închis (Duminică/sărbătoare)": "Closed (Sunday/holiday)", "Închis (Duminică/sărbătoare)": "Closed (Sunday/holiday)",
"Închis la": "Closed at", "Închis la": "Closed at",
@@ -2453,6 +2491,7 @@
"Încărcare STO": "Workshop load", "Încărcare STO": "Workshop load",
"Încărcare celulă": "Cell load", "Încărcare celulă": "Cell load",
"Încărcare service": "Workshop load", "Încărcare service": "Workshop load",
"Înlocuire": "Replacement",
"Înregistrare": "Register", "Înregistrare": "Register",
"Înregistrat": "Registered", "Înregistrat": "Registered",
"Înregistrează cheltuială": "Record expense", "Înregistrează cheltuială": "Record expense",
@@ -2487,10 +2526,41 @@
"șters": "șters", "șters": "șters",
"Țară": "Country", "Țară": "Country",
"Ține-mă minte": "Remember me", "Ține-mă minte": "Remember me",
"Автомобиль": "Vehicle",
"Автосервис / Клиент": "Auto service / Client",
"Время закр. в КЗ, мс": "Closing time (KZ), ms",
"Время закрытия, мс": "Closing time, ms",
"Время открытия, мс": "Opening time, ms",
"Герметичность": "Tightness",
"Год выпуска": "Year",
"Гос. номер": "Plate number",
"ДАННЫЕ АВТОМОБИЛЯ И КЛИЕНТА": "VEHICLE AND CLIENT DATA",
"Дата выдачи": "Issue date",
"ЗАКЛЮЧЕНИЕ": "CONCLUSION",
"Заключение": "Conclusion",
"Заполняйте строки по количеству форсунок; лишние строки оставьте пустыми.": "Fill in rows per injector count; leave extra rows empty.",
"ИЗМЕРЕНИЯ МЕХАНИКИ ФОРСУНОК": "INJECTOR MECHANICS MEASUREMENTS",
"Клиент ознакомлен (подпись)": "Client acknowledged (signature)",
"Кол-во форсунок": "Injector count",
"Комментарий / рекомендации мастера": "Master comment / recommendations",
"Марка форсунок": "Injector brand",
"Мастер (ФИО, подпись)": "Master (name, signature)",
"ПРИЧИНА ОБРАЩЕНИЯ": "REASON FOR REQUEST",
"ПРОТОКОЛ ДИАГНОСТИКИ И ОЧИСТКИ": "DIAGNOSTIC AND CLEANING PROTOCOL",
"Пробег, км": "Mileage, km",
"Расход, мл": "Flow, ml",
"Сопротивление, Ом": "Resistance, Ω",
"Телефон": "Phone",
"ФОРСУНКИ": "INJECTORS",
"ЭЛЕКТРОКЛАПАННЫХ ФОРСУНОК": "OF SOLENOID INJECTORS",
"до": "before",
"после": "after",
"— (multe VIN-uri europene nu respectă checksum-ul NA)": "— (multe VIN-uri europene nu respectă checksum-ul NA)", "— (multe VIN-uri europene nu respectă checksum-ul NA)": "— (multe VIN-uri europene nu respectă checksum-ul NA)",
"— alege —": "— select —", "— alege —": "— select —",
"— fără pod —": "— no bay —", "— fără pod —": "— no bay —",
"— niciunul —": "— niciunul —", "— niciunul —": "— niciunul —",
"№": "No.",
"№ протокола:": "Protocol no.:",
"️": "️", "️": "️",
"← Săpt. anterioară": "← Prev. week", "← Săpt. anterioară": "← Prev. week",
"← Înapoi": "← Back", "← Înapoi": "← Back",
+71 -1
View File
@@ -28,6 +28,7 @@
"2FA dezactivat.": "2FA dezactivat.", "2FA dezactivat.": "2FA dezactivat.",
"2FA resetat": "2FA сброшен", "2FA resetat": "2FA сброшен",
"58.5h/10": "58.5ч/10", "58.5h/10": "58.5ч/10",
"8 forsunki × 6 măsurători (Înainte / După). Introducerea se face pe rând.": "8 форсунок × 6 измерений (До / После). Ввод — по одной строке.",
":n poziții importate în Purchase nouă": ":n позиций импортировано в новый заказ", ":n poziții importate în Purchase nouă": ":n позиций импортировано в новый заказ",
"> 0 calculează automat prețul client.": "> 0 автоматически рассчитывает цену клиента.", "> 0 calculează automat prețul client.": "> 0 автоматически рассчитывает цену клиента.",
"A1-03": "A1-03", "A1-03": "A1-03",
@@ -260,11 +261,13 @@
"Autentificare 2FA": "2FA аутентификация", "Autentificare 2FA": "2FA аутентификация",
"Auto": "Авто", "Auto": "Авто",
"Auto / model": "Авто / модель", "Auto / model": "Авто / модель",
"Auto CRM": "CRM-авто",
"Auto-import Excel/CSV": "Auto-import Excel/CSV", "Auto-import Excel/CSV": "Auto-import Excel/CSV",
"AutoCRM PSauto · psauto.service.mir.md": "AutoCRM PSauto · psauto.service.mir.md", "AutoCRM PSauto · psauto.service.mir.md": "AutoCRM PSauto · psauto.service.mir.md",
"AutoCRM SRL": "AutoCRM SRL", "AutoCRM SRL": "AutoCRM SRL",
"Automată": "Автоматическая", "Automată": "Автоматическая",
"Automobile": "Автомобили", "Automobile": "Автомобили",
"Autoservis / Client": "Автосервис / Клиент",
"Avans": "Аванс", "Avans": "Аванс",
"Avans achitat": "Аванс оплачен", "Avans achitat": "Аванс оплачен",
"Avg zile": "Ср. дней", "Avg zile": "Ср. дней",
@@ -363,6 +366,7 @@
"Caută": "Найти", "Caută": "Найти",
"Caută client, mașină, număr...": "Поиск клиента, авто, номера...", "Caută client, mașină, număr...": "Поиск клиента, авто, номера...",
"Caută în tabel": "Поиск в таблице", "Caută în tabel": "Поиск в таблице",
"Cauza adresării": "Причина обращения",
"Caz asigurare": "Страховой случай", "Caz asigurare": "Страховой случай",
"Caz de asigurare": "Страховой случай", "Caz de asigurare": "Страховой случай",
"Ce a spus clientul...": "Что сказал клиент...", "Ce a spus clientul...": "Что сказал клиент...",
@@ -399,10 +403,11 @@
"Client & Auto": "Клиент и авто", "Client & Auto": "Клиент и авто",
"Client (nume, semnătură):": "Клиент (имя, подпись):", "Client (nume, semnătură):": "Клиент (имя, подпись):",
"Client / Auto": "Client / Auto", "Client / Auto": "Client / Auto",
"Client CRM": "Клиент CRM", "Client CRM": "CRM-клиент",
"Client ID": "ID клиента", "Client ID": "ID клиента",
"Client VIP": "VIP-клиент", "Client VIP": "VIP-клиент",
"Client existent": "Существующий клиент", "Client existent": "Существующий клиент",
"Client informat / a luat cunoștință": "Клиент проинформирован / ознакомлен",
"Client legat (CRM)": "Привязанный клиент (CRM)", "Client legat (CRM)": "Привязанный клиент (CRM)",
"Client name": "Имя клиента", "Client name": "Имя клиента",
"Client nou": "Новый клиент", "Client nou": "Новый клиент",
@@ -445,6 +450,7 @@
"Comandă nouă #": "Новый заказ #", "Comandă nouă #": "Новый заказ #",
"Comandă primită": "Заказ получен", "Comandă primită": "Заказ получен",
"Combustibil": "Топливо", "Combustibil": "Топливо",
"Comentariu / recomandări maistru": "Комментарий / рекомендации мастера",
"Comenzi": "Заказы", "Comenzi": "Заказы",
"Comercial": "Коммерческий", "Comercial": "Коммерческий",
"Comercial (van/camion)": "Коммерческий (фургон/грузовик)", "Comercial (van/camion)": "Коммерческий (фургон/грузовик)",
@@ -527,6 +533,7 @@
"Creează cerere": "Создать заявку", "Creează cerere": "Создать заявку",
"Creează comandă de aprovizionare": "Создать заказ на закупку", "Creează comandă de aprovizionare": "Создать заказ на закупку",
"Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.": "Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.", "Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.": "Creează planuri (ex: Free, Basic, Pro) și atribuie-le companiilor.",
"Creează primul protocol de diagnostic forsunki. Numărul se generează automat (PS-{tenant}-{an}-{seq}).": "Создайте первый протокол диагностики форсунок. Номер генерируется автоматически (PS-{тенант}-{год}-{seq}).",
"Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook": "Создайте бота у @BotFather, вставьте токен здесь и нажмите «Настроить webhook».", "Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook": "Создайте бота у @BotFather, вставьте токен здесь и нажмите «Настроить webhook».",
"Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook\". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.": "Создайте бота у @BotFather, вставьте токен здесь и нажмите «Настроить webhook». Клиенты пишут боту, делятся номером, и код автоматически связывается с их нарядом.", "Creează un bot la @BotFather, lipește token-ul aici și apasă „Setează webhook\". Clienții îți scriu la bot, partajează telefonul, iar codul se leagă automat de fișa lor.": "Создайте бота у @BotFather, вставьте токен здесь и нажмите «Настроить webhook». Клиенты пишут боту, делятся номером, и код автоматически связывается с их нарядом.",
"Crescător": "По возрастанию", "Crescător": "По возрастанию",
@@ -546,6 +553,7 @@
"Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.": "Накопительный = умножается с другими коэффициентами. Ненакопительный = применяется только наибольший ненакопительный.", "Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.": "Накопительный = умножается с другими коэффициентами. Ненакопительный = применяется только наибольший ненакопительный.",
"Curier": "Курьер", "Curier": "Курьер",
"Currency": "Валюта", "Currency": "Валюта",
"Curățare": "Чистка",
"Curăță": "Очистить", "Curăță": "Очистить",
"Custom": "Свой", "Custom": "Свой",
"Cutie": "Коробка", "Cutie": "Коробка",
@@ -559,6 +567,7 @@
"DENY — interzice dreptul": "DENY — запретить право", "DENY — interzice dreptul": "DENY — запретить право",
"DOT": "DOT", "DOT": "DOT",
"DSG": "DSG", "DSG": "DSG",
"DUPĂ": "ПОСЛЕ",
"Da, confirmă": "Да, подтвердить", "Da, confirmă": "Да, подтвердить",
"Da, șterge": "Да, удалить", "Da, șterge": "Да, удалить",
"Dacă e gol, generăm 10 caractere random.": "Если пусто — сгенерируем 10 случайных символов.", "Dacă e gol, generăm 10 caractere random.": "Если пусто — сгенерируем 10 случайных символов.",
@@ -570,6 +579,7 @@
"Data comandă": "Дата заказа", "Data comandă": "Дата заказа",
"Data creării": "Дата создания", "Data creării": "Дата создания",
"Data deschiderii:": "Data deschiderii:", "Data deschiderii:": "Data deschiderii:",
"Data emiterii": "Дата выдачи",
"Data factură": "Дата счёта", "Data factură": "Дата счёта",
"Data ieșire": "Дата выдачи", "Data ieșire": "Дата выдачи",
"Data intrare": "Дата приёма", "Data intrare": "Дата приёма",
@@ -580,6 +590,7 @@
"Data început": "Дата начала", "Data început": "Дата начала",
"Data închiderii:": "Дата закрытия:", "Data închiderii:": "Дата закрытия:",
"Date": "Дата", "Date": "Дата",
"Date auto & client": "Данные авто и клиента",
"Date generale": "Общая информация", "Date generale": "Общая информация",
"Date insuficiente": "Недостаточно данных", "Date insuficiente": "Недостаточно данных",
"Date legale (apar pe facturi)": "Date legale (apar pe facturi)", "Date legale (apar pe facturi)": "Date legale (apar pe facturi)",
@@ -605,6 +616,7 @@
"Deal-uri active": "Активные сделки", "Deal-uri active": "Активные сделки",
"Deals": "Сделки", "Deals": "Сделки",
"Debit": "Дебет", "Debit": "Дебет",
"Debit ÎNAINTE": "Расход ДО",
"Deblochează": "Разблокировать", "Deblochează": "Разблокировать",
"Decembrie": "Декабрь", "Decembrie": "Декабрь",
"Decode VIN": "Декодировать VIN", "Decode VIN": "Декодировать VIN",
@@ -644,6 +656,7 @@
"Deschide panoul pe telefon și acceptă notificările întâi.": "Сначала откройте панель на телефоне и разрешите уведомления.", "Deschide panoul pe telefon și acceptă notificările întâi.": "Сначала откройте панель на телефоне и разрешите уведомления.",
"Deschide tenantul": "Deschide tenantul", "Deschide tenantul": "Deschide tenantul",
"Deschide →": "Открыть →", "Deschide →": "Открыть →",
"Deschidere ÎNAINTE": "Открытие ДО",
"Deschis": "Открыт", "Deschis": "Открыт",
"Deschis la": "Открыт", "Deschis la": "Открыт",
"Descrescător": "По убыванию", "Descrescător": "По убыванию",
@@ -682,6 +695,7 @@
"Doar arhivate": "Только архивные", "Doar arhivate": "Только архивные",
"Doar clienți VIP": "Только VIP клиенты", "Doar clienți VIP": "Только VIP клиенты",
"Doar clienții mei": "Только мои клиенты", "Doar clienții mei": "Только мои клиенты",
"Doar dacă maistrul nu e în listă.": "Только если мастер не в списке.",
"Doar la manopere (proprii + subcontract). Baza salariu = preț client × (1 marjă/100). Lasă gol pentru a folosi valoarea implicită a companiei.": "Только для работ (собственных + субподряд). База зарплаты = цена клиенту × (1 − маржа/100). Оставьте пустым для значения по умолчанию компании.", "Doar la manopere (proprii + subcontract). Baza salariu = preț client × (1 marjă/100). Lasă gol pentru a folosi valoarea implicită a companiei.": "Только для работ (собственных + субподряд). База зарплаты = цена клиенту × (1 − маржа/100). Оставьте пустым для значения по умолчанию компании.",
"Doar pentru cazuri speciale. Lasă gol → folosește marja mecanicului.": "Только для особых случаев. Оставьте пустым → используется маржа механика.", "Doar pentru cazuri speciale. Lasă gol → folosește marja mecanicului.": "Только для особых случаев. Оставьте пустым → используется маржа механика.",
"Doar tu ești aici": "Только вы здесь", "Doar tu ești aici": "Только вы здесь",
@@ -750,6 +764,7 @@
"Engine": "Двигатель", "Engine": "Двигатель",
"Eroare": "Ошибка", "Eroare": "Ошибка",
"Eroare la recepție": "Ошибка при приёмке", "Eroare la recepție": "Ошибка при приёмке",
"Etanșeitate ÎNAINTE": "Герметичность ДО",
"Etape": "Этапы", "Etape": "Этапы",
"Etapă": "Этап", "Etapă": "Этап",
"Etichete piese": "Etichete piese", "Etichete piese": "Etichete piese",
@@ -830,6 +845,7 @@
"Fișă asociată": "Связанный наряд", "Fișă asociată": "Связанный наряд",
"Fișă de lucru": "Заказ-наряд", "Fișă de lucru": "Заказ-наряд",
"Fișă lucru": "Заказ-наряд", "Fișă lucru": "Заказ-наряд",
"Fișă lucru (opțional)": "Заказ-наряд (опц.)",
"Folie PPF": "Плёнка PPF", "Folie PPF": "Плёнка PPF",
"Folosește AI Assistant": "Использовать AI Assistant", "Folosește AI Assistant": "Использовать AI Assistant",
"Folosește „Check-in depozit": "Используйте «Приёмка на склад»", "Folosește „Check-in depozit": "Используйте «Приёмка на склад»",
@@ -840,6 +856,7 @@
"Force delete": "Удалить безвозвратно", "Force delete": "Удалить безвозвратно",
"Force logout": "Выйти со всех устройств", "Force logout": "Выйти со всех устройств",
"Format:": "Формат:", "Format:": "Формат:",
"Forsunka": "Форсунка",
"Foto": "Фото", "Foto": "Фото",
"Foto factură": "Фото счёта", "Foto factură": "Фото счёта",
"Foto piesă": "Фото запчасти", "Foto piesă": "Фото запчасти",
@@ -942,6 +959,8 @@
"Informația de bază — click pe titlul secțiunilor de mai jos pentru detalii.": "Основная информация — нажмите на заголовок секции ниже для деталей.", "Informația de bază — click pe titlul secțiunilor de mai jos pentru detalii.": "Основная информация — нажмите на заголовок секции ниже для деталей.",
"Informație": "Информация", "Informație": "Информация",
"Injectoare": "Форсунки", "Injectoare": "Форсунки",
"Injectoare noi": "Новые форсунки",
"Injectoarele în normă, apte pentru exploatare": "Форсунки в норме, годны к эксплуатации",
"Instrucțiuni adiționale": "Дополнительные инструкции", "Instrucțiuni adiționale": "Дополнительные инструкции",
"Integrări": "Интеграции", "Integrări": "Интеграции",
"Integrări de plată": "Платёжные интеграции", "Integrări de plată": "Платёжные интеграции",
@@ -1083,6 +1102,7 @@
"Manuală": "Ручная", "Manuală": "Ручная",
"Mapare coloane": "Сопоставление колонок", "Mapare coloane": "Сопоставление колонок",
"Marca": "Марка", "Marca": "Марка",
"Marca injectoarelor": "Марка форсунок",
"Marca/Model": "Марка/Модель", "Marca/Model": "Марка/Модель",
"Marca/Model:": "Marca/Model:", "Marca/Model:": "Marca/Model:",
"Marchează Gata": "Отметить готово", "Marchează Gata": "Отметить готово",
@@ -1160,6 +1180,7 @@
"Model Claude": "Модель Claude", "Model Claude": "Модель Claude",
"Model Gemini": "Модель Gemini", "Model Gemini": "Модель Gemini",
"Model OpenAI": "Модель OpenAI", "Model OpenAI": "Модель OpenAI",
"Model auto": "Модель авто",
"Model implicit": "Модель по умолчанию", "Model implicit": "Модель по умолчанию",
"Model year": "Модельный год", "Model year": "Модельный год",
"Modele": "Модели", "Modele": "Модели",
@@ -1178,6 +1199,7 @@
"Mulțumim": "Спасибо", "Mulțumim": "Спасибо",
"Mâine": "Завтра", "Mâine": "Завтра",
"Mărci auto suportate (separate prin virgulă)": "Поддерживаемые марки (через запятую)", "Mărci auto suportate (separate prin virgulă)": "Поддерживаемые марки (через запятую)",
"Măsurători mecanica injectoarelor": "Измерения механики форсунок",
"Name": "Имя", "Name": "Имя",
"Neachitat": "Не оплачено", "Neachitat": "Не оплачено",
"Necesită acțiune": "Требует действия", "Necesită acțiune": "Требует действия",
@@ -1185,6 +1207,7 @@
"Necesită aprobare client": "Требуется одобрение клиента", "Necesită aprobare client": "Требуется одобрение клиента",
"Necesită atenție": "Требует внимания", "Necesită atenție": "Требует внимания",
"Necesită cel puțin 2 recepții complete cu data așteptată setată.": "Требуется минимум 2 полные приёмки с установленной ожидаемой датой.", "Necesită cel puțin 2 recepții complete cu data așteptată setată.": "Требуется минимум 2 полные приёмки с установленной ожидаемой датой.",
"Necesită curățare repetată": "Требуется повторная чистка",
"Necompletat": "Не заполнено", "Necompletat": "Не заполнено",
"Neconfirmat": "Не подтверждено", "Neconfirmat": "Не подтверждено",
"Neconfirmate": "Не подтверждено", "Neconfirmate": "Не подтверждено",
@@ -1238,6 +1261,7 @@
"Niciun mecanic nu a finalizat lucrări în această perioadă.": "Ни один механик не завершил работы за этот период.", "Niciun mecanic nu a finalizat lucrări în această perioadă.": "Ни один механик не завершил работы за этот период.",
"Niciun plan definit": "Niciun plan definit", "Niciun plan definit": "Niciun plan definit",
"Niciun preț înregistrat": "Цены не зарегистрированы", "Niciun preț înregistrat": "Цены не зарегистрированы",
"Niciun protocol": "Нет протоколов",
"Niciun rest de încasat. 🎉": "Нет остатка к получению. 🎉", "Niciun rest de încasat. 🎉": "Нет остатка к получению. 🎉",
"Niciun rezultat pentru": "Нет результатов для", "Niciun rezultat pentru": "Нет результатов для",
"Niciun set de anvelope": "Нет комплектов шин", "Niciun set de anvelope": "Нет комплектов шин",
@@ -1268,10 +1292,13 @@
"Nou tehnician": "Новый техник", "Nou tehnician": "Новый техник",
"Nouă": "Новая", "Nouă": "Новая",
"Nr.": "№", "Nr.": "№",
"Nr. auto": "№ авто",
"Nr. dosar daună": "№ дела о повреждении", "Nr. dosar daună": "№ дела о повреждении",
"Nr. factură": "№ счёта", "Nr. factură": "№ счёта",
"Nr. fișă": "№ наряда", "Nr. fișă": "№ наряда",
"Nr. injectoare": "Кол-во форсунок",
"Nr. poliță": "№ полиса", "Nr. poliță": "№ полиса",
"Nr. protocol": "№ протокола",
"Nr. telefon": "Тел. номер", "Nr. telefon": "Тел. номер",
"Nr. înmatriculare": "Гос. номер", "Nr. înmatriculare": "Гос. номер",
"Nr.:": "Nr.:", "Nr.:": "Nr.:",
@@ -1307,6 +1334,7 @@
"Număr": "Номер", "Număr": "Номер",
"Numărul de zile gratis după ce un tenant alege acest plan.": "Дни бесплатного пробного периода при выборе плана.", "Numărul de zile gratis după ce un tenant alege acest plan.": "Дни бесплатного пробного периода при выборе плана.",
"OCR eșuat": "OCR не удалось", "OCR eșuat": "OCR не удалось",
"OK": "OK",
"Observații": "Комментарии", "Observații": "Комментарии",
"Obține token cu": "Получить токен с помощью", "Obține token cu": "Получить токен с помощью",
"Octombrie": "Октябрь", "Octombrie": "Октябрь",
@@ -1556,6 +1584,7 @@
"Progres": "Прогресс", "Progres": "Прогресс",
"Progres etapă": "Прогресс этапа", "Progres etapă": "Прогресс этапа",
"Proprietar": "Владелец", "Proprietar": "Владелец",
"Protocol nou": "Новый протокол",
"Provider implicit": "Провайдер по умолчанию", "Provider implicit": "Провайдер по умолчанию",
"Provider:": "Provider:", "Provider:": "Provider:",
"Public": "Публично", "Public": "Публично",
@@ -1616,6 +1645,7 @@
"Recomandare": "Рекомендация", "Recomandare": "Рекомендация",
"Recomandat": "Рекомендуется", "Recomandat": "Рекомендуется",
"Recomandate": "Рекомендуемые", "Recomandate": "Рекомендуемые",
"Recomandată înlocuire": "Рекомендована замена",
"Recomandă": "Рекомендуется", "Recomandă": "Рекомендуется",
"Recomandări": "Рекомендации", "Recomandări": "Рекомендации",
"Recomandări AI": "AI-рекомендации", "Recomandări AI": "AI-рекомендации",
@@ -1641,6 +1671,7 @@
"Renunță": "Отменить", "Renunță": "Отменить",
"Reordonează": "Изменить порядок", "Reordonează": "Изменить порядок",
"Reparație": "Ремонт", "Reparație": "Ремонт",
"Reparație capitală motor": "Кап. ремонт двигателя",
"Reparație caroserie": "Ремонт кузова", "Reparație caroserie": "Ремонт кузова",
"Reparații": "Ремонты", "Reparații": "Ремонты",
"Report": "Отчёт", "Report": "Отчёт",
@@ -1671,6 +1702,7 @@
"Rezervare": "Резерв", "Rezervare": "Резерв",
"Rezervat": "Зарезервировано", "Rezervat": "Зарезервировано",
"Rezervă o programare": "Rezervă o programare", "Rezervă o programare": "Rezervă o programare",
"Rezistență ÎNAINTE": "Сопротивление ДО",
"Rezultat": "Результат", "Rezultat": "Результат",
"Rezultate": "Результаты", "Rezultate": "Результаты",
"Rezumat": "Резюме", "Rezumat": "Резюме",
@@ -1762,6 +1794,7 @@
"Selectează perioadă": "Выберите период", "Selectează perioadă": "Выберите период",
"Semnat": "Подписано", "Semnat": "Подписано",
"Semnat de": "Подписано", "Semnat de": "Подписано",
"Semnat de (manual)": "Подписано (вручную)",
"Semnează": "Подписать", "Semnează": "Подписать",
"Semnătură": "Подпись", "Semnătură": "Подпись",
"Semnătură digitală": "Цифровая подпись", "Semnătură digitală": "Цифровая подпись",
@@ -2094,6 +2127,7 @@
"Venit manopere": "Доход работ", "Venit manopere": "Доход работ",
"Venituri": "Доходы", "Venituri": "Доходы",
"Verificare": "Проверка", "Verificare": "Проверка",
"Verificare / diagnostic": "Проверка / диагностика",
"Verificat": "Проверено", "Verificat": "Проверено",
"Verifică emailul": "Проверьте email", "Verifică emailul": "Проверьте email",
"Verifică și amintește": "Проверить и напомнить", "Verifică și amintește": "Проверить и напомнить",
@@ -2392,6 +2426,8 @@
"programări": "записи", "programări": "записи",
"programări active": "активные записи", "programări active": "активные записи",
"programări neconfirmate &lt; 24h": "неподтверждённые записи < 24ч", "programări neconfirmate &lt; 24h": "неподтверждённые записи < 24ч",
"protocoale forsunki": "протоколы форсунок",
"protocol forsunki": "протокол форсунок",
"rata confirmare": "коэф. подтверждения", "rata confirmare": "коэф. подтверждения",
"referral": "Рекомендация", "referral": "Рекомендация",
"reguli markup": "правила наценки", "reguli markup": "правила наценки",
@@ -2446,6 +2482,8 @@
"Începe lucrul": "Начать работу", "Începe lucrul": "Начать работу",
"Închide": "Закрыть", "Închide": "Закрыть",
"Închide fișa": "Закрыть наряд", "Închide fișa": "Закрыть наряд",
"Închidere KZ ÎNAINTE": "Закрытие КЗ ДО",
"Închidere ÎNAINTE": "Закрытие ДО",
"Închis": "Закрыт", "Închis": "Закрыт",
"Închis (Duminică/sărbătoare)": "Закрыто (Воскр./праздник)", "Închis (Duminică/sărbătoare)": "Закрыто (Воскр./праздник)",
"Închis la": "Закрыт", "Închis la": "Закрыт",
@@ -2453,6 +2491,7 @@
"Încărcare STO": "Загрузка СТО", "Încărcare STO": "Загрузка СТО",
"Încărcare celulă": "Загрузка ячейки", "Încărcare celulă": "Загрузка ячейки",
"Încărcare service": "Загрузка сервиса", "Încărcare service": "Загрузка сервиса",
"Înlocuire": "Замена",
"Înregistrare": "Регистрация", "Înregistrare": "Регистрация",
"Înregistrat": "Зарегистрирован", "Înregistrat": "Зарегистрирован",
"Înregistrează cheltuială": "Регистрация расхода", "Înregistrează cheltuială": "Регистрация расхода",
@@ -2487,10 +2526,41 @@
"șters": "șters", "șters": "șters",
"Țară": "Страна", "Țară": "Страна",
"Ține-mă minte": "Запомнить меня", "Ține-mă minte": "Запомнить меня",
"Автомобиль": "Автомобиль",
"Автосервис / Клиент": "Автосервис / Клиент",
"Время закр. в КЗ, мс": "Время закр. в КЗ, мс",
"Время закрытия, мс": "Время закрытия, мс",
"Время открытия, мс": "Время открытия, мс",
"Герметичность": "Герметичность",
"Год выпуска": "Год выпуска",
"Гос. номер": "Гос. номер",
"ДАННЫЕ АВТОМОБИЛЯ И КЛИЕНТА": "ДАННЫЕ АВТОМОБИЛЯ И КЛИЕНТА",
"Дата выдачи": "Дата выдачи",
"ЗАКЛЮЧЕНИЕ": "ЗАКЛЮЧЕНИЕ",
"Заключение": "Заключение",
"Заполняйте строки по количеству форсунок; лишние строки оставьте пустыми.": "Заполняйте строки по количеству форсунок; лишние строки оставьте пустыми.",
"ИЗМЕРЕНИЯ МЕХАНИКИ ФОРСУНОК": "ИЗМЕРЕНИЯ МЕХАНИКИ ФОРСУНОК",
"Клиент ознакомлен (подпись)": "Клиент ознакомлен (подпись)",
"Кол-во форсунок": "Кол-во форсунок",
"Комментарий / рекомендации мастера": "Комментарий / рекомендации мастера",
"Марка форсунок": "Марка форсунок",
"Мастер (ФИО, подпись)": "Мастер (ФИО, подпись)",
"ПРИЧИНА ОБРАЩЕНИЯ": "ПРИЧИНА ОБРАЩЕНИЯ",
"ПРОТОКОЛ ДИАГНОСТИКИ И ОЧИСТКИ": "ПРОТОКОЛ ДИАГНОСТИКИ И ОЧИСТКИ",
"Пробег, км": "Пробег, км",
"Расход, мл": "Расход, мл",
"Сопротивление, Ом": "Сопротивление, Ом",
"Телефон": "Телефон",
"ФОРСУНКИ": "ФОРСУНКИ",
"ЭЛЕКТРОКЛАПАННЫХ ФОРСУНОК": "ЭЛЕКТРОКЛАПАННЫХ ФОРСУНОК",
"до": "до",
"после": "после",
"— (multe VIN-uri europene nu respectă checksum-ul NA)": "— (multe VIN-uri europene nu respectă checksum-ul NA)", "— (multe VIN-uri europene nu respectă checksum-ul NA)": "— (multe VIN-uri europene nu respectă checksum-ul NA)",
"— alege —": "— выберите —", "— alege —": "— выберите —",
"— fără pod —": "— без поста —", "— fără pod —": "— без поста —",
"— niciunul —": "— niciunul —", "— niciunul —": "— niciunul —",
"№": "№",
"№ протокола:": "№ протокола:",
"️": "️", "️": "️",
"← Săpt. anterioară": "← Пред. неделя", "← Săpt. anterioară": "← Пред. неделя",
"← Înapoi": "← Назад", "← Înapoi": "← Назад",
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 KiB

@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="UTF-8">
<title>{{ $protocol->protocol_number ?? 'Protocol' }}</title>
<style>
/* Brand tokens */
:root {
--graphite: #16161A;
--gold: #C9A961;
--blue: #0093D6;
--row-alt: #E9E6DC;
--gray-text: #8A8A8E;
--line: #D8D5CB;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@page { size: A4; margin: 12mm 14mm; }
html, body {
font-family: 'Roboto', 'Noto Sans', Arial, sans-serif;
color: var(--graphite);
font-size: 10pt;
line-height: 1.35;
}
/* ── HEADER ── */
.hdr { display: flex; align-items: center; gap: 18px; margin-bottom: 8mm; }
.hdr .qr { width: 24mm; height: 24mm; flex: 0 0 24mm; }
.hdr .qr img { width: 100%; height: 100%; object-fit: contain; }
.hdr .brand { flex: 1; }
.hdr .brand-name { font-size: 20pt; font-weight: 800; color: var(--graphite); letter-spacing: -.3px; line-height: 1; }
.hdr .brand-name .accent { color: var(--blue); }
.hdr .brand-tag { color: var(--gray-text); font-size: 9pt; margin-top: 3pt; }
.hdr .brand-contact { font-size: 9pt; margin-top: 5pt; color: #333; }
.hdr .logo { width: 30mm; height: 22mm; flex: 0 0 30mm; }
.hdr .logo img { width: 100%; height: 100%; object-fit: contain; }
/* ── TITLE ── */
.title {
text-align: center;
font-size: 14pt;
font-weight: 800;
letter-spacing: .3px;
color: var(--graphite);
margin: 5mm 0 2mm;
text-transform: uppercase;
}
.title-underline {
width: 42mm; height: 1.4mm; background: var(--gold);
margin: 0 auto 6mm; border-radius: 1mm;
}
/* ── SECTION PLAQUE ── */
.plaque {
display: inline-block;
background: var(--graphite);
color: #fff;
font-weight: 700;
font-size: 9pt;
letter-spacing: .5px;
padding: 2mm 4mm;
text-transform: uppercase;
margin-top: 4mm;
margin-bottom: 2mm;
}
/* ── FIELD GRID (3 cols) ── */
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 3mm 6mm; margin-bottom: 2mm; }
.field { padding-top: 1mm; }
.field-label { font-size: 8.5pt; font-weight: 700; color: var(--graphite); letter-spacing: .2px; }
.field-value {
border-bottom: 1px solid var(--graphite);
min-height: 5mm;
padding: 2pt 0 1pt;
font-size: 10pt;
}
.field-value.number { border-bottom-color: var(--blue); }
/* Field with inline label like «№ протокола:» */
.inline-field { display: flex; align-items: baseline; gap: 3mm; justify-content: flex-end; }
.inline-field .lbl { font-size: 9pt; font-weight: 600; }
.inline-field .val { border-bottom: 1px solid var(--graphite); min-width: 40mm; padding: 1pt 0; }
/* ── CHECKBOX ROW ── */
.cbrow { display: flex; gap: 8mm; flex-wrap: wrap; margin: 3mm 0 4mm; }
.cb { display: flex; align-items: center; gap: 3mm; font-size: 9.5pt; font-weight: 600; }
.cb-box {
width: 3.2mm; height: 3.2mm;
border: 1px solid var(--graphite);
display: inline-block;
position: relative;
}
.cb-box.checked::after {
content: "";
position: absolute;
top: -1mm; left: 0.2mm;
color: var(--graphite);
font-weight: 800;
font-size: 10pt;
}
/* ── MEASUREMENTS TABLE ── */
table.meas { width: 100%; border-collapse: collapse; margin-top: 1mm; table-layout: fixed; }
table.meas th, table.meas td { border: 1px solid var(--line); padding: 1.6mm 1mm; font-size: 8.5pt; text-align: center; vertical-align: middle; }
table.meas thead th {
background: var(--graphite); color: #fff;
font-weight: 700; font-size: 8pt; letter-spacing: .2px;
}
table.meas thead th.subhead {
background: var(--row-alt); color: var(--graphite);
font-weight: 500; font-size: 7.5pt;
}
table.meas th.rot {
width: 8mm;
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 7.5pt;
letter-spacing: 1px;
}
table.meas td.pos {
background: var(--graphite);
color: #fff;
font-weight: 800;
width: 8mm;
}
table.meas tbody td {
background: var(--row-alt);
min-height: 8mm;
height: 8mm;
}
.meas-note { font-style: italic; color: var(--gray-text); font-size: 8.5pt; margin-top: 1.5mm; }
/* ── CONCLUSION ── */
.comment-box {
border: 1px solid var(--line);
min-height: 15mm;
padding: 2mm 3mm;
margin-top: 1.5mm;
font-size: 10pt;
white-space: pre-wrap;
}
.sig-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8mm; margin-top: 5mm; }
.sig { }
.sig .field-label { margin-bottom: 5mm; }
.sig .val { border-bottom: 1px solid var(--graphite); min-height: 6mm; padding-bottom: 1pt; }
.footer {
text-align: center;
color: var(--gray-text);
font-size: 8pt;
margin-top: 6mm;
}
</style>
</head>
<body>
@php
/** @var \App\Models\Tenant\InjectorProtocol $protocol */
$tenant = app(\App\Tenancy\TenantManager::class)->current();
$companyName = $tenant?->display_name ?: ($tenant?->name ?: 'AutoCRM');
$logoUrl = $tenant?->getLogoUrl();
$addressLine = trim(collect([$tenant?->address, $tenant?->phone, $tenant?->email])->filter()->implode(' • '));
$rowsByPos = $protocol->rows->keyBy('position');
$reasons = [
'reason_new_injectors' => __('Injectoare noi'),
'reason_engine_overhaul' => __('Reparație capitală motor'),
'reason_check' => __('Verificare / diagnostic'),
];
$conclusions = [
'conclusion_ok' => __('Injectoarele în normă, apte pentru exploatare'),
'conclusion_needs_cleaning' => __('Necesită curățare repetată'),
'conclusion_needs_replacement' => __('Recomandată înlocuire'),
];
$publicQr = asset('img/injector-protocol/qr-sample.png');
@endphp
<div class="hdr">
<div class="qr">
<img src="{{ $publicQr }}" alt="QR">
</div>
<div class="brand">
@if ($logoUrl)
<div style="font-size:16pt;font-weight:800;line-height:1;">{{ $companyName }}</div>
@else
<div class="brand-name">{{ strtoupper($companyName) }}</div>
@endif
@if ($tenant?->slogan ?? null)
<div class="brand-tag">{{ $tenant->slogan }}</div>
@endif
@if ($addressLine)
<div class="brand-contact">{{ $addressLine }}</div>
@endif
</div>
@if ($logoUrl)
<div class="logo"><img src="{{ $logoUrl }}" alt="logo"></div>
@endif
</div>
<div class="title">{{ __('ПРОТОКОЛ ДИАГНОСТИКИ И ОЧИСТКИ') }}<br>{{ __('ЭЛЕКТРОКЛАПАННЫХ ФОРСУНОК') }}</div>
<div class="title-underline"></div>
{{-- ── SECTION 1: DATA ── --}}
<div style="display:flex;justify-content:space-between;align-items:center;">
<div class="plaque">{{ __('ДАННЫЕ АВТОМОБИЛЯ И КЛИЕНТА') }}</div>
<div class="inline-field">
<span class="lbl">{{ __('№ протокола:') }}</span>
<span class="val number" style="color:var(--blue);font-weight:700;">{{ $protocol->protocol_number ?? '—' }}</span>
</div>
</div>
<div class="grid">
<div class="field">
<div class="field-label">{{ __('Автосервис / Клиент') }}</div>
<div class="field-value">{{ $protocol->source_name }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('Телефон') }}</div>
<div class="field-value">{{ $protocol->phone }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('Автомобиль') }}</div>
<div class="field-value">{{ $protocol->car_model }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('Гос. номер') }}</div>
<div class="field-value">{{ $protocol->plate_number }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('VIN') }}</div>
<div class="field-value" style="font-family:monospace;">{{ $protocol->vin }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('Пробег, км') }}</div>
<div class="field-value">{{ $protocol->mileage ? number_format($protocol->mileage, 0, '.', ' ') : '' }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('Год выпуска') }}</div>
<div class="field-value">{{ $protocol->year }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('Марка форсунок') }}</div>
<div class="field-value">{{ $protocol->injector_brand }}</div>
</div>
<div class="field">
<div class="field-label">{{ __('Кол-во форсунок') }}</div>
<div class="field-value">{{ $protocol->injector_count }}</div>
</div>
</div>
{{-- ── SECTION 2: REASON ── --}}
<div class="plaque">{{ __('ПРИЧИНА ОБРАЩЕНИЯ') }}</div>
<div class="cbrow">
@foreach ($reasons as $field => $label)
<div class="cb">
<span class="cb-box {{ $protocol->{$field} ? 'checked' : '' }}"></span>
<span>{{ $label }}</span>
</div>
@endforeach
</div>
{{-- ── SECTION 3: MEASUREMENTS ── --}}
<div class="plaque">{{ __('ИЗМЕРЕНИЯ МЕХАНИКИ ФОРСУНОК') }}</div>
<table class="meas">
<thead>
<tr>
<th rowspan="2" class="rot">{{ __('ФОРСУНКИ') }}</th>
<th colspan="2">{{ __('Сопротивление, Ом') }}</th>
<th colspan="2">{{ __('Время открытия, мс') }}</th>
<th colspan="2">{{ __('Время закрытия, мс') }}</th>
<th colspan="2">{{ __('Время закр. в КЗ, мс') }}</th>
<th colspan="2">{{ __('Расход, мл') }}</th>
<th colspan="2">{{ __('Герметичность') }}</th>
</tr>
<tr>
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
<th class="subhead">{{ __('до') }}</th><th class="subhead">{{ __('после') }}</th>
</tr>
</thead>
<tbody>
@for ($i = 1; $i <= 8; $i++)
@php $row = $rowsByPos->get($i); @endphp
<tr>
<td class="pos">{{ $i }}</td>
<td>{{ $row?->resistance_before }}</td>
<td>{{ $row?->resistance_after }}</td>
<td>{{ $row?->open_time_before }}</td>
<td>{{ $row?->open_time_after }}</td>
<td>{{ $row?->close_time_before }}</td>
<td>{{ $row?->close_time_after }}</td>
<td>{{ $row?->close_time_kz_before }}</td>
<td>{{ $row?->close_time_kz_after }}</td>
<td>{{ $row?->flow_before }}</td>
<td>{{ $row?->flow_after }}</td>
<td>{{ $row?->tightness_before }}</td>
<td>{{ $row?->tightness_after }}</td>
</tr>
@endfor
</tbody>
</table>
<div class="meas-note">{{ __('Заполняйте строки по количеству форсунок; лишние строки оставьте пустыми.') }}</div>
{{-- ── SECTION 4: CONCLUSION ── --}}
<div class="plaque">{{ __('ЗАКЛЮЧЕНИЕ') }}</div>
<div class="cbrow">
@foreach ($conclusions as $field => $label)
<div class="cb">
<span class="cb-box {{ $protocol->{$field} ? 'checked' : '' }}"></span>
<span>{{ $label }}</span>
</div>
@endforeach
</div>
<div class="field-label" style="margin-top:2mm;">{{ __('Комментарий / рекомендации мастера') }}</div>
<div class="comment-box">{{ $protocol->master_comment }}</div>
<div class="sig-row">
<div class="sig">
<div class="field-label">{{ __('Мастер (ФИО, подпись)') }}</div>
<div class="val">{{ $protocol->master?->name ?: $protocol->master_name_signed }}</div>
</div>
<div class="sig">
<div class="field-label">{{ __('Дата выдачи') }}</div>
<div class="val">{{ $protocol->issue_date?->format('d.m.Y') }}</div>
</div>
</div>
<div class="sig-row">
<div class="sig">
<div class="field-label">{{ __('Клиент ознакомлен (подпись)') }}</div>
<div class="val">@if ($protocol->client_acknowledged) @endif</div>
</div>
<div style="text-align:right;color:var(--gray-text);font-size:9pt;padding-top:8mm;">
{{ parse_url(config('app.url'), PHP_URL_HOST) ?? 'psauto.md' }}
</div>
</div>
</body>
</html>
+13
View File
@@ -72,6 +72,19 @@ Route::middleware(['web', 'auth'])->group(function () {
->name('push.subscribe'); ->name('push.subscribe');
Route::post('/push/unsubscribe', [\App\Http\Controllers\PushSubscriptionController::class, 'unsubscribe']) Route::post('/push/unsubscribe', [\App\Http\Controllers\PushSubscriptionController::class, 'unsubscribe'])
->name('push.unsubscribe'); ->name('push.unsubscribe');
Route::get('/app/injector-protocols/{record}/pdf', function (\App\Models\Tenant\InjectorProtocol $record) {
abort_unless(
auth()->user()?->canDo(\App\Auth\Permissions::INJECTOR_PROTOCOLS_VIEW),
403
);
$svc = app(\App\Services\InjectorProtocolPdfService::class);
return response()->streamDownload(
fn () => print($svc->render($record)),
$svc->filename($record),
['Content-Type' => 'application/pdf']
);
})->name('tenant.injector-protocols.pdf');
}); });
// ─── Telegram webhook (per-tenant, on central domain) ────────────── // ─── Telegram webhook (per-tenant, on central domain) ──────────────
+2 -2
View File
@@ -149,7 +149,7 @@ class RbacApiTest extends TestCase
$this->assertGreaterThanOrEqual(7, $roles->count()); $this->assertGreaterThanOrEqual(7, $roles->count());
$owner = $roles->firstWhere('name', 'owner'); $owner = $roles->firstWhere('name', 'owner');
$this->assertNotNull($owner); $this->assertNotNull($owner);
$this->assertEquals(52, $owner['permissions_count']); $this->assertEquals(54, $owner['permissions_count']);
} }
public function test_role_sync_permissions_updates_role(): void public function test_role_sync_permissions_updates_role(): void
@@ -181,7 +181,7 @@ class RbacApiTest extends TestCase
$resp = $this->getJson('/api/v1/permissions'); $resp = $this->getJson('/api/v1/permissions');
$resp->assertOk(); $resp->assertOk();
$this->assertEquals(52, count($resp->json('data'))); $this->assertEquals(54, count($resp->json('data')));
$this->assertArrayHasKey('grouped', $resp->json()); $this->assertArrayHasKey('grouped', $resp->json());
$this->assertArrayHasKey('clients', $resp->json('grouped')); $this->assertArrayHasKey('clients', $resp->json('grouped'));
$this->assertArrayHasKey('roles', $resp->json()); $this->assertArrayHasKey('roles', $resp->json());
+2 -2
View File
@@ -35,7 +35,7 @@ class RbacTest extends TestCase
public function test_seeder_creates_51_permissions(): void public function test_seeder_creates_51_permissions(): void
{ {
$this->assertEquals(52, Permission::where('guard_name', 'web')->count()); $this->assertEquals(54, Permission::where('guard_name', 'web')->count());
} }
public function test_seeder_creates_7_roles_per_tenant(): void public function test_seeder_creates_7_roles_per_tenant(): void
@@ -48,7 +48,7 @@ class RbacTest extends TestCase
public function test_owner_role_has_all_permissions(): void public function test_owner_role_has_all_permissions(): void
{ {
$owner = Role::where('company_id', $this->company->id)->where('name', 'owner')->first(); $owner = Role::where('company_id', $this->company->id)->where('name', 'owner')->first();
$this->assertEquals(52, $owner->permissions->count()); $this->assertEquals(54, $owner->permissions->count());
} }
public function test_mechanic_role_has_minimal_permissions(): void public function test_mechanic_role_has_minimal_permissions(): void