Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d6a0bfb890 | |||
| 5e255b7b40 | |||
| e8078f157a | |||
| 94938f24d7 | |||
| a1be01b0d5 | |||
| c90c35d930 | |||
| 954ba8f059 | |||
| c413004930 | |||
| e48ef1b755 | |||
| 1ff888131f | |||
| 85ef2f6e00 | |||
| a2026f640a | |||
| 426156fe45 | |||
| edcdba9d53 |
@@ -58,6 +58,11 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="noreply@service.mir.md"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
# Web Push (VAPID) — generate with: php artisan push:vapid
|
||||
VAPID_SUBJECT=mailto:admin@service.mir.md
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
# Storage — local pentru MVP, S3-compatible mai târziu
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
|
||||
+4
-1
@@ -41,7 +41,10 @@ RUN install-php-extensions \
|
||||
opcache \
|
||||
pcntl \
|
||||
sockets \
|
||||
exif
|
||||
exif \
|
||||
curl \
|
||||
mbstring \
|
||||
gmp
|
||||
|
||||
# System tools
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Minishlink\WebPush\VAPID;
|
||||
|
||||
class GenerateVapidKeysCommand extends Command
|
||||
{
|
||||
protected $signature = 'push:vapid';
|
||||
|
||||
protected $description = 'Generate a VAPID keypair for Web Push and print the .env lines.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$keys = VAPID::createVapidKeys();
|
||||
|
||||
$this->info('VAPID keys generated. Add these to your .env:');
|
||||
$this->newLine();
|
||||
$this->line('VAPID_SUBJECT=mailto:admin@service.mir.md');
|
||||
$this->line('VAPID_PUBLIC_KEY=' . $keys['publicKey']);
|
||||
$this->line('VAPID_PRIVATE_KEY=' . $keys['privateKey']);
|
||||
$this->newLine();
|
||||
$this->warn('Keep the private key secret. Re-generating invalidates existing subscriptions.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\Supplier;
|
||||
use App\Services\Warehouse\SupplierAnalytics;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RateSuppliersCommand extends Command
|
||||
{
|
||||
protected $signature = 'suppliers:rate
|
||||
{--days=90 : Look-back window in days}
|
||||
{--slug= : Only one tenant by slug}';
|
||||
|
||||
protected $description = 'Recompute auto-rating for every supplier based on on-time deliveries, speed and volume.';
|
||||
|
||||
public function handle(SupplierAnalytics $analytics): int
|
||||
{
|
||||
$query = Company::query()->where('status', '!=', 'archived');
|
||||
if ($slug = $this->option('slug')) {
|
||||
$query->where('slug', $slug);
|
||||
}
|
||||
$companies = $query->get();
|
||||
$days = (int) $this->option('days');
|
||||
|
||||
$totalUpdated = 0;
|
||||
|
||||
foreach ($companies as $company) {
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$suppliers = Supplier::where('is_active', true)->get();
|
||||
$changed = 0;
|
||||
foreach ($suppliers as $supplier) {
|
||||
$score = $analytics->computedRating($supplier, $days);
|
||||
if ($score !== null && $score !== (int) $supplier->rating) {
|
||||
$supplier->rating = $score;
|
||||
$supplier->saveQuietly();
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('[%s] suppliers rated, %d updated', $company->slug, $changed));
|
||||
$totalUpdated += $changed;
|
||||
}
|
||||
|
||||
$this->info("Total suppliers updated: {$totalUpdated}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\ServiceReminderSent;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Services\NotificationDispatcher;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class SendServiceRemindersCommand extends Command
|
||||
{
|
||||
protected $signature = 'reminders:send
|
||||
{--slug= : Only one tenant by slug}
|
||||
{--dry-run : Show candidates without sending}';
|
||||
|
||||
protected $description = 'Scan vehicles for due service reminders and send via configured channels.';
|
||||
|
||||
public function handle(NotificationDispatcher $dispatcher): int
|
||||
{
|
||||
$query = Company::query()->where('status', '!=', 'archived');
|
||||
if ($slug = $this->option('slug')) {
|
||||
$query->where('slug', $slug);
|
||||
}
|
||||
$companies = $query->get();
|
||||
$dry = (bool) $this->option('dry-run');
|
||||
|
||||
$totalSent = 0;
|
||||
|
||||
foreach ($companies as $company) {
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$settings = (array) ($company->settings ?? []);
|
||||
$reminderDays = (int) data_get($settings, 'reminder.after_days', 365);
|
||||
$cooldownDays = (int) data_get($settings, 'reminder.cooldown_days', 30);
|
||||
|
||||
$cutoff = Carbon::now()->subDays($reminderDays);
|
||||
$cooldown = Carbon::now()->subDays($cooldownDays);
|
||||
|
||||
// Pick vehicles whose last *closed* WO was before $cutoff (or never).
|
||||
$vehicles = Vehicle::with('client')
|
||||
->whereHas('client', fn ($q) => $q->where('status', 'active'))
|
||||
->get();
|
||||
|
||||
$sentThisTenant = 0;
|
||||
foreach ($vehicles as $v) {
|
||||
$lastClosedAt = WorkOrder::where('vehicle_id', $v->id)
|
||||
->whereNotNull('closed_at')
|
||||
->max('closed_at');
|
||||
|
||||
if (! $lastClosedAt) continue; // never serviced — skip (handled by other logic)
|
||||
if (Carbon::parse($lastClosedAt)->gt($cutoff)) continue;
|
||||
|
||||
$recent = ServiceReminderSent::where('vehicle_id', $v->id)
|
||||
->where('sent_at', '>=', $cooldown)
|
||||
->exists();
|
||||
if ($recent) continue;
|
||||
|
||||
if ($dry) {
|
||||
$this->line(" - [{$company->slug}] Vehicle #{$v->id} {$v->plate} last serviced {$lastClosedAt}");
|
||||
continue;
|
||||
}
|
||||
|
||||
$ok = $dispatcher->serviceReminder($v, 'general');
|
||||
if ($ok) {
|
||||
ServiceReminderSent::create([
|
||||
'company_id' => $company->id,
|
||||
'vehicle_id' => $v->id,
|
||||
'client_id' => $v->client_id,
|
||||
'channel' => $v->client?->telegram_chat_id ? 'telegram' : 'email',
|
||||
'type' => 'general',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
$sentThisTenant++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf('[%s] reminders sent: %d', $company->slug, $sentThisTenant));
|
||||
$totalSent += $sentThisTenant;
|
||||
}
|
||||
|
||||
$this->info("Total reminders sent: {$totalSent}" . ($dry ? ' (dry run)' : ''));
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ class AiAssistant extends Page
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getUsage(): array
|
||||
{
|
||||
return app(AiAssistantService::class)->monthlyUsage();
|
||||
}
|
||||
|
||||
public function newChat(): void
|
||||
{
|
||||
$chat = AiChat::create([
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
/**
|
||||
* Mobile-first dashboard for a single mechanic — shows ONLY work orders
|
||||
* assigned to the currently logged-in user (master_id = auth()->id()).
|
||||
* Kanban-style grouped by status.
|
||||
*/
|
||||
class MechanicBoard extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-wrench';
|
||||
|
||||
protected static ?string $navigationLabel = 'Atelierul meu';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Service';
|
||||
|
||||
protected static ?int $navigationSort = 25;
|
||||
|
||||
protected static ?string $title = 'Atelierul meu';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.mechanic-board';
|
||||
|
||||
public function getColumns(): array
|
||||
{
|
||||
$userId = auth()->id();
|
||||
if (! $userId) return [];
|
||||
|
||||
$all = WorkOrder::with(['client', 'vehicle'])
|
||||
->where('master_id', $userId)
|
||||
->whereIn('status', ['in_work', 'awaiting_parts', 'ready', 'done', 'approved', 'diagnosis'])
|
||||
->orderBy('opened_at', 'desc')
|
||||
->get();
|
||||
|
||||
return [
|
||||
[
|
||||
'key' => 'in_work',
|
||||
'label' => 'În lucru',
|
||||
'color' => '#f59e0b',
|
||||
'items' => $all->where('status', 'in_work')->values(),
|
||||
],
|
||||
[
|
||||
'key' => 'awaiting_parts',
|
||||
'label' => 'Așteaptă piese',
|
||||
'color' => '#8b5cf6',
|
||||
'items' => $all->whereIn('status', ['awaiting_parts'])->values(),
|
||||
],
|
||||
[
|
||||
'key' => 'ready',
|
||||
'label' => 'Gata',
|
||||
'color' => '#10b981',
|
||||
'items' => $all->where('status', 'ready')->values(),
|
||||
],
|
||||
[
|
||||
'key' => 'recent',
|
||||
'label' => 'Recente / restul',
|
||||
'color' => '#64748b',
|
||||
'items' => $all->whereIn('status', ['done', 'approved', 'diagnosis'])
|
||||
->take(20)
|
||||
->values(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function getCounts(): array
|
||||
{
|
||||
$userId = auth()->id();
|
||||
return [
|
||||
'active' => $userId
|
||||
? WorkOrder::where('master_id', $userId)
|
||||
->whereIn('status', ['in_work', 'awaiting_parts', 'ready'])
|
||||
->count()
|
||||
: 0,
|
||||
'closed_today' => $userId
|
||||
? WorkOrder::where('master_id', $userId)
|
||||
->where('status', 'done')
|
||||
->whereDate('closed_at', today())
|
||||
->count()
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Models\Tenant\Part;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
/**
|
||||
* Mobile scanner: opens camera in the browser, decodes QR/barcode, looks up
|
||||
* Part by:
|
||||
* - `PART:<article|id>` payload (our own QR labels)
|
||||
* - exact barcode match on parts.barcode
|
||||
* - exact article match on parts.article
|
||||
* On match → redirect to Part edit page.
|
||||
*/
|
||||
class Scanner extends Page
|
||||
{
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-qr-code';
|
||||
|
||||
protected static ?string $navigationLabel = 'Scaner';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
|
||||
|
||||
protected static ?int $navigationSort = 39;
|
||||
|
||||
protected static ?string $title = 'Scaner cod QR / Bare';
|
||||
|
||||
protected string $view = 'filament.tenant.pages.scanner';
|
||||
|
||||
public string $manual = '';
|
||||
|
||||
#[On('scanner-decoded')]
|
||||
public function decoded(string $text): void
|
||||
{
|
||||
$this->resolveAndRedirect(trim($text));
|
||||
}
|
||||
|
||||
public function submitManual(): void
|
||||
{
|
||||
if (trim($this->manual) === '') return;
|
||||
$this->resolveAndRedirect(trim($this->manual));
|
||||
}
|
||||
|
||||
protected function resolveAndRedirect(string $code): void
|
||||
{
|
||||
$clean = $code;
|
||||
if (str_starts_with($clean, 'PART:')) {
|
||||
$clean = substr($clean, 5);
|
||||
}
|
||||
|
||||
$part = Part::where(function ($q) use ($clean, $code) {
|
||||
$q->where('article', $clean)
|
||||
->orWhere('barcode', $clean)
|
||||
->orWhere('barcode', $code);
|
||||
if (ctype_digit($clean)) $q->orWhere('id', (int) $clean);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (! $part) {
|
||||
Notification::make()
|
||||
->title('Cod necunoscut')
|
||||
->body('Nu am găsit nicio piesă pentru: ' . $code)
|
||||
->warning()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Piesă găsită: ' . $part->name)
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(
|
||||
route('filament.tenant.resources.parts.edit', ['record' => $part->id])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Filament\Tenant\Pages;
|
||||
|
||||
use App\Services\Notifications\TelegramService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Page;
|
||||
@@ -50,6 +52,13 @@ class Settings extends Page
|
||||
'notify_payment' => $notify['payment'] ?? true,
|
||||
'notify_appointment' => $notify['appointment'] ?? true,
|
||||
'notify_reminder' => $notify['reminder'] ?? true,
|
||||
'telegram_bot_token' => data_get($settings, 'telegram.bot_token'),
|
||||
'reminder_after_days' => data_get($settings, 'reminder.after_days', 365),
|
||||
'reminder_cooldown_days' => data_get($settings, 'reminder.cooldown_days', 30),
|
||||
'shop_enabled' => data_get($settings, 'shop.enabled', false),
|
||||
'shop_delivery_methods' => data_get($settings, 'shop.delivery_methods', ['pickup']),
|
||||
'shop_delivery_fee' => data_get($settings, 'shop.delivery_fee', 0),
|
||||
'shop_free_delivery_over' => data_get($settings, 'shop.free_delivery_over', 0),
|
||||
'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude',
|
||||
'ai_claude_key' => $settings['ai']['claude_key'] ?? null,
|
||||
'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null,
|
||||
@@ -126,8 +135,8 @@ class Settings extends Page
|
||||
->maxSize(512)
|
||||
->helperText('PNG/ICO, max 512 KB.'),
|
||||
]),
|
||||
Schemas\Components\Section::make('Notificări email')
|
||||
->description('Activează / dezactivează emailurile auto către clienți.')
|
||||
Schemas\Components\Section::make('Notificări')
|
||||
->description('Activează / dezactivează notificările auto către clienți. Telegram are prioritate dacă clientul are cont legat.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('notify_wo_ready')->label('Mașina e gata de ridicat')->default(true),
|
||||
@@ -135,6 +144,46 @@ class Settings extends Page
|
||||
Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true),
|
||||
Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true),
|
||||
]),
|
||||
Schemas\Components\Section::make('Telegram bot')
|
||||
->description('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.')
|
||||
->columns(1)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('telegram_bot_token')
|
||||
->label('Bot token')
|
||||
->password()
|
||||
->revealable()
|
||||
->placeholder('123456:ABC-XYZ...')
|
||||
->helperText(fn () => 'Webhook URL: ' .
|
||||
app(\App\Services\Notifications\TelegramService::class)
|
||||
->webhookUrlFor(app(\App\Tenancy\TenantManager::class)->current())),
|
||||
]),
|
||||
Schemas\Components\Section::make('Reminder service auto')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('reminder_after_days')
|
||||
->label('Trimite reminder după X zile fără vizită')
|
||||
->numeric()
|
||||
->minValue(30)
|
||||
->default(365),
|
||||
Forms\Components\TextInput::make('reminder_cooldown_days')
|
||||
->label('Nu re-trimite mai des de X zile')
|
||||
->numeric()
|
||||
->minValue(7)
|
||||
->default(30),
|
||||
]),
|
||||
Schemas\Components\Section::make('Magazin online')
|
||||
->description('Activează magazinul public la <slug>.service.mir.md/shop. Piesele apar doar dacă sunt marcate „Publicat".')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('shop_enabled')->label('Magazin activ')->columnSpanFull(),
|
||||
Forms\Components\CheckboxList::make('shop_delivery_methods')
|
||||
->label('Metode de livrare')
|
||||
->options(\App\Models\Tenant\OnlineOrder::DELIVERY)
|
||||
->default(['pickup'])
|
||||
->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('shop_delivery_fee')->label('Taxă livrare')->numeric()->default(0),
|
||||
Forms\Components\TextInput::make('shop_free_delivery_over')->label('Livrare gratuită peste')->numeric()->default(0)->helperText('0 = dezactivat'),
|
||||
]),
|
||||
Schemas\Components\Section::make('Asistent AI')
|
||||
->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.')
|
||||
->columns(2)
|
||||
@@ -178,6 +227,20 @@ class Settings extends Page
|
||||
'appointment' => (bool) ($data['notify_appointment'] ?? true),
|
||||
'reminder' => (bool) ($data['notify_reminder'] ?? true),
|
||||
],
|
||||
'telegram' => array_replace(
|
||||
(array) data_get($company->settings, 'telegram', []),
|
||||
['bot_token' => $data['telegram_bot_token'] ?? null]
|
||||
),
|
||||
'reminder' => [
|
||||
'after_days' => (int) ($data['reminder_after_days'] ?? 365),
|
||||
'cooldown_days' => (int) ($data['reminder_cooldown_days'] ?? 30),
|
||||
],
|
||||
'shop' => [
|
||||
'enabled' => (bool) ($data['shop_enabled'] ?? false),
|
||||
'delivery_methods' => array_values((array) ($data['shop_delivery_methods'] ?? ['pickup'])),
|
||||
'delivery_fee' => (float) ($data['shop_delivery_fee'] ?? 0),
|
||||
'free_delivery_over' => (float) ($data['shop_free_delivery_over'] ?? 0),
|
||||
],
|
||||
'ai' => [
|
||||
'default_provider' => $data['ai_default_provider'] ?? 'claude',
|
||||
'claude_key' => $data['ai_claude_key'] ?? null,
|
||||
@@ -201,4 +264,76 @@ class Settings extends Page
|
||||
|
||||
Notification::make()->title('Setări salvate')->success()->send();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('push_test')
|
||||
->label('Test notificare push')
|
||||
->icon('heroicon-m-bell-alert')
|
||||
->color('gray')
|
||||
->action(function () {
|
||||
$svc = app(\App\Services\Notifications\WebPushService::class);
|
||||
if (! $svc->configured()) {
|
||||
Notification::make()
|
||||
->title('Web Push neconfigurat')
|
||||
->body('Rulează `php artisan push:vapid` și adaugă cheile în .env.')
|
||||
->warning()->send();
|
||||
return;
|
||||
}
|
||||
$r = $svc->sendToUser(
|
||||
(int) auth()->id(),
|
||||
'Test AutoCRM',
|
||||
'Notificările push funcționează ✅',
|
||||
'/app',
|
||||
);
|
||||
Notification::make()
|
||||
->title($r['sent'] > 0 ? "Trimis pe {$r['sent']} dispozitiv(e)" : 'Niciun dispozitiv abonat')
|
||||
->body($r['sent'] > 0 ? null : 'Deschide panoul pe telefon și acceptă notificările întâi.')
|
||||
->{$r['sent'] > 0 ? 'success' : 'warning'}()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('telegram_test')
|
||||
->label('Testează bot Telegram')
|
||||
->icon('heroicon-m-bolt')
|
||||
->color('gray')
|
||||
->action(function () {
|
||||
$company = app(TenantManager::class)->current();
|
||||
if (! $company) return;
|
||||
$r = app(TelegramService::class)->getMe($company);
|
||||
if (! ($r['ok'] ?? false)) {
|
||||
Notification::make()
|
||||
->title('Bot Telegram nu răspunde')
|
||||
->body($r['error'] ?? 'Verifică token-ul.')
|
||||
->danger()->send();
|
||||
return;
|
||||
}
|
||||
$name = data_get($r, 'response.result.username', '?');
|
||||
Notification::make()
|
||||
->title("Bot OK: @{$name}")
|
||||
->success()->send();
|
||||
}),
|
||||
Actions\Action::make('telegram_webhook')
|
||||
->label('Setează webhook')
|
||||
->icon('heroicon-m-link')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Telegram va trimite mesajele primite la URL-ul webhook de mai jos.')
|
||||
->action(function () {
|
||||
$company = app(TenantManager::class)->current();
|
||||
if (! $company) return;
|
||||
$r = app(TelegramService::class)->setWebhook($company);
|
||||
if (! ($r['ok'] ?? false)) {
|
||||
Notification::make()
|
||||
->title('Webhook eșuat')
|
||||
->body($r['error'] ?? json_encode($r['response'] ?? []))
|
||||
->danger()->send();
|
||||
return;
|
||||
}
|
||||
Notification::make()
|
||||
->title('Webhook setat — botul e gata')
|
||||
->success()->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||
use App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
|
||||
use App\Models\Tenant\BodyshopJob;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BodyshopJobResource extends Resource
|
||||
{
|
||||
protected static ?string $model = BodyshopJob::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-paint-brush';
|
||||
|
||||
protected static ?string $navigationLabel = 'Tinichigerie / Detailing';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Tinichigerie';
|
||||
|
||||
protected static ?string $modelLabel = 'lucrare caroserie';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'lucrări caroserie';
|
||||
|
||||
protected static ?int $navigationSort = 80;
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$open = static::getModel()::query()
|
||||
->whereNotIn('status', ['delivered', 'cancelled'])->count();
|
||||
return $open > 0 ? (string) $open : null;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make('Lucrare')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'),
|
||||
Forms\Components\Select::make('type')->label('Tip')->options(BodyshopJob::TYPES)->default('body_repair')->required(),
|
||||
Forms\Components\Select::make('status')->label('Status')->options(BodyshopJob::STATUSES)->default('estimate')->required(),
|
||||
Forms\Components\Select::make('client_id')
|
||||
->label('Client')
|
||||
->options(fn () => Client::pluck('name', 'id'))
|
||||
->searchable()->live(),
|
||||
Forms\Components\Select::make('vehicle_id')
|
||||
->label('Auto')
|
||||
->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()
|
||||
: [])
|
||||
->searchable(),
|
||||
Forms\Components\TextInput::make('estimate_amount')->label('Deviz')->numeric()->default(0),
|
||||
Forms\Components\TextInput::make('approved_amount')->label('Aprobat')->numeric()->default(0),
|
||||
]),
|
||||
Schemas\Components\Section::make('Asigurare')
|
||||
->collapsible()
|
||||
->columns(3)
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('is_insurance')->label('Caz de asigurare')->live()->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('insurer')->label('Asigurător')
|
||||
->visible(fn (Get $get) => $get('is_insurance')),
|
||||
Forms\Components\TextInput::make('policy_no')->label('Nr. poliță')
|
||||
->visible(fn (Get $get) => $get('is_insurance')),
|
||||
Forms\Components\TextInput::make('claim_no')->label('Nr. dosar daună')
|
||||
->visible(fn (Get $get) => $get('is_insurance')),
|
||||
Forms\Components\Select::make('insurance_status')->label('Status dosar')
|
||||
->options(BodyshopJob::INSURANCE_STATUSES)
|
||||
->visible(fn (Get $get) => $get('is_insurance')),
|
||||
]),
|
||||
Schemas\Components\Section::make('Foto înainte / după')
|
||||
->columns(2)
|
||||
->schema([
|
||||
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_before')
|
||||
->label('Înainte')->collection('photos_before')->multiple()->image()->reorderable()->maxFiles(20),
|
||||
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos_after')
|
||||
->label('După')->collection('photos_after')->multiple()->image()->reorderable()->maxFiles(20),
|
||||
]),
|
||||
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('vehicle.plate')->label('Auto')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->formatStateUsing(fn ($s) => BodyshopJob::TYPES[$s] ?? $s)
|
||||
->badge()->color('info'),
|
||||
Tables\Columns\IconColumn::make('is_insurance')->label('Asig.')->boolean()->toggleable(),
|
||||
Tables\Columns\TextColumn::make('damage_points_count')->counts('damagePoints')->label('Daune')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('approved_amount')->label('Aprobat')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->formatStateUsing(fn ($s) => BodyshopJob::STATUSES[$s] ?? $s)
|
||||
->badge()
|
||||
->colors([
|
||||
'gray' => ['estimate'],
|
||||
'info' => ['approved', 'in_progress'],
|
||||
'success' => ['done', 'delivered'],
|
||||
'danger' => ['cancelled'],
|
||||
]),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('type')->options(BodyshopJob::TYPES),
|
||||
Tables\Filters\SelectFilter::make('status')->options(BodyshopJob::STATUSES),
|
||||
Tables\Filters\TernaryFilter::make('is_insurance')->label('Caz asigurare'),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->emptyStateHeading('Nicio lucrare de caroserie')
|
||||
->emptyStateDescription('Înregistrează lucrări de tinichigerie, vopsitorie, PDR, detailing, ceramică, PPF sau polish. Hartă daune, dosar asigurare și arhivă foto înainte/după.')
|
||||
->emptyStateIcon('heroicon-o-paint-brush')
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\DamagePointsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBodyshopJobs::route('/'),
|
||||
'create' => Pages\CreateBodyshopJob::route('/create'),
|
||||
'edit' => Pages\EditBodyshopJob::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\BodyshopJobResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBodyshopJob extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BodyshopJobResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\BodyshopJobResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBodyshopJob extends EditRecord
|
||||
{
|
||||
protected static string $resource = BodyshopJobResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\BodyshopJobResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\BodyshopJobResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBodyshopJobs extends ListRecords
|
||||
{
|
||||
protected static string $resource = BodyshopJobResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\BodyshopJobResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant\DamagePoint;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DamagePointsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'damagePoints';
|
||||
|
||||
protected static ?string $title = 'Hartă daune';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Select::make('zone')
|
||||
->label('Zonă')
|
||||
->options(array_combine(DamagePoint::ZONES, DamagePoint::ZONES))
|
||||
->searchable()
|
||||
->required(),
|
||||
Forms\Components\Select::make('kind')
|
||||
->label('Tip daună')
|
||||
->options(array_combine(DamagePoint::KINDS, DamagePoint::KINDS))
|
||||
->required(),
|
||||
Forms\Components\Select::make('severity')
|
||||
->label('Gravitate')
|
||||
->options(DamagePoint::SEVERITIES)
|
||||
->default('minor')
|
||||
->required(),
|
||||
Forms\Components\Textarea::make('notes')->label('Observații')->rows(2)->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('zone')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('zone')->label('Zonă')->badge()->color('gray'),
|
||||
Tables\Columns\TextColumn::make('kind')->label('Tip'),
|
||||
Tables\Columns\TextColumn::make('severity')
|
||||
->label('Gravitate')
|
||||
->formatStateUsing(fn ($s) => DamagePoint::SEVERITIES[$s] ?? $s)
|
||||
->badge()
|
||||
->colors(['gray' => ['minor'], 'warning' => ['medium'], 'danger' => ['severe']]),
|
||||
Tables\Columns\TextColumn::make('notes')->limit(40)->placeholder('—'),
|
||||
])
|
||||
->headerActions([Actions\CreateAction::make()])
|
||||
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
|
||||
->emptyStateHeading('Nicio daună marcată')
|
||||
->emptyStateDescription('Adaugă punctele de daună pe zone (capotă, ușă, aripă) cu tip și gravitate — formează harta de daune a mașinii.');
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ class ClientResource extends Resource
|
||||
])
|
||||
->default('active')
|
||||
->required(),
|
||||
Forms\Components\Toggle::make('is_vip')
|
||||
->label('Client VIP')
|
||||
->helperText('Activează coeficienții de preț VIP pe fișele acestui client.'),
|
||||
]),
|
||||
Schemas\Components\Section::make('Contacte')
|
||||
->columns(2)
|
||||
@@ -73,6 +76,14 @@ class ClientResource extends Resource
|
||||
Forms\Components\TextInput::make('phone_alt')->label('Telefon alternativ')->tel()->maxLength(40),
|
||||
Forms\Components\TextInput::make('email')->email()->maxLength(120),
|
||||
Forms\Components\TextInput::make('telegram')->maxLength(60),
|
||||
Forms\Components\TextInput::make('telegram_chat_id')
|
||||
->label('Telegram chat ID')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->placeholder('Se completează automat când clientul scrie la bot')
|
||||
->helperText(fn ($record) => $record?->telegram_chat_id
|
||||
? '✅ Telegram legat — notificările vor merge prin bot'
|
||||
: null),
|
||||
Forms\Components\TextInput::make('whatsapp')->maxLength(60),
|
||||
Forms\Components\TextInput::make('viber')->maxLength(60),
|
||||
]),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\LaborResource\Pages;
|
||||
use App\Filament\Tenant\Resources\LaborResource\RelationManagers;
|
||||
use App\Models\Tenant\Labor;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
@@ -42,8 +43,17 @@ class LaborResource extends Resource
|
||||
Forms\Components\TextInput::make('code')->label('Cod')->maxLength(32),
|
||||
Forms\Components\TextInput::make('name_ro')->label('Nume (RO)')->required()->maxLength(160),
|
||||
Forms\Components\TextInput::make('name_ru')->label('Nume (RU)')->maxLength(160),
|
||||
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()->default(1)->required(),
|
||||
Forms\Components\TextInput::make('price')->label('Preț (MDL)')->numeric()->default(0),
|
||||
Forms\Components\Select::make('pricing_mode')
|
||||
->label('Mod tarifare')
|
||||
->options(Labor::PRICING_MODES)
|
||||
->default('hourly')
|
||||
->live()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('hours')->label('Ore (normă)')->numeric()->default(1)
|
||||
->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') !== 'fixed'),
|
||||
Forms\Components\TextInput::make('fixed_price')->label('Preț fix (MDL)')->numeric()->default(0)
|
||||
->visible(fn (Schemas\Components\Utilities\Get $get) => $get('pricing_mode') === 'fixed'),
|
||||
Forms\Components\TextInput::make('price')->label('Preț orientativ (MDL)')->numeric()->default(0),
|
||||
Forms\Components\Toggle::make('is_active')->label('Activă')->default(true),
|
||||
]),
|
||||
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||
@@ -56,8 +66,15 @@ class LaborResource extends Resource
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('category')->label('Categorie')->badge()->sortable(),
|
||||
Tables\Columns\TextColumn::make('name_ro')->label('Manoperă')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('pricing_mode')
|
||||
->label('Tarifare')
|
||||
->formatStateUsing(fn ($s) => $s === 'fixed' ? 'Fix' : 'Pe oră')
|
||||
->badge()
|
||||
->color(fn ($s) => $s === 'fixed' ? 'info' : 'gray'),
|
||||
Tables\Columns\TextColumn::make('hours')->label('Ore')->numeric(decimalPlaces: 2)->alignRight(),
|
||||
Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('fixed_price')->label('Preț fix')->money('MDL')->alignRight()
|
||||
->placeholder('—')->toggleable(),
|
||||
Tables\Columns\TextColumn::make('laborParts_count')->counts('laborParts')->label('Piese impl.')->alignRight()->toggleable(),
|
||||
Tables\Columns\IconColumn::make('is_active')->label('Activă')->boolean(),
|
||||
])
|
||||
->filters([
|
||||
@@ -73,6 +90,13 @@ class LaborResource extends Resource
|
||||
->defaultGroup('category');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\DefaultPartsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\LaborResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant\Part;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class DefaultPartsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'laborParts';
|
||||
|
||||
protected static ?string $title = 'Piese implicite';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Select::make('part_id')
|
||||
->label('Piesă')
|
||||
->options(fn () => Part::where('is_active', true)
|
||||
->get()
|
||||
->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')])
|
||||
->toArray())
|
||||
->searchable()
|
||||
->required()
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if ($state && $p = Part::find($state)) {
|
||||
$set('unit', $p->unit);
|
||||
}
|
||||
}),
|
||||
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)->required(),
|
||||
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('part.name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('part.name')->label('Piesă')->wrap(),
|
||||
Tables\Columns\TextColumn::make('part.article')->label('Cod')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
||||
])
|
||||
->headerActions([Actions\CreateAction::make()])
|
||||
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
|
||||
->emptyStateHeading('Nicio piesă implicită')
|
||||
->emptyStateDescription('Adaugă piesele care se montează de obicei la această manoperă — se adaugă automat în fișă când selectezi manopera.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||
use App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
|
||||
use App\Models\Tenant\OnlineOrder;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class OnlineOrderResource extends Resource
|
||||
{
|
||||
protected static ?string $model = OnlineOrder::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-shopping-bag';
|
||||
|
||||
protected static ?string $navigationLabel = 'Comenzi online';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Magazin';
|
||||
|
||||
protected static ?string $modelLabel = 'comandă';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'comenzi online';
|
||||
|
||||
protected static ?int $navigationSort = 50;
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$new = static::getModel()::query()->where('status', 'new')->count();
|
||||
return $new > 0 ? (string) $new : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make('Comandă')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false),
|
||||
Forms\Components\Select::make('status')->options(OnlineOrder::STATUSES)->required(),
|
||||
Forms\Components\Select::make('delivery_method')->label('Livrare')->options(OnlineOrder::DELIVERY)->required(),
|
||||
Forms\Components\TextInput::make('customer_name')->label('Client')->required(),
|
||||
Forms\Components\TextInput::make('customer_phone')->label('Telefon')->required(),
|
||||
Forms\Components\TextInput::make('customer_email')->label('Email'),
|
||||
Forms\Components\TextInput::make('address')->label('Adresă')->columnSpan(2),
|
||||
Forms\Components\TextInput::make('delivery_fee')->label('Taxă livrare')->numeric(),
|
||||
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('created_at')->label('Data')->dateTime('d.m.Y H:i')->sortable(),
|
||||
Tables\Columns\TextColumn::make('customer_name')->label('Client')->searchable(),
|
||||
Tables\Columns\TextColumn::make('customer_phone')->label('Telefon')->copyable(),
|
||||
Tables\Columns\TextColumn::make('delivery_method')
|
||||
->label('Livrare')
|
||||
->formatStateUsing(fn ($s) => OnlineOrder::DELIVERY[$s] ?? $s),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->formatStateUsing(fn ($s) => OnlineOrder::STATUSES[$s] ?? $s)
|
||||
->badge()
|
||||
->colors([
|
||||
'warning' => ['new'],
|
||||
'info' => ['confirmed', 'packed'],
|
||||
'primary' => ['shipped'],
|
||||
'success' => ['delivered'],
|
||||
'danger' => ['cancelled'],
|
||||
]),
|
||||
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight()->sortable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')->options(OnlineOrder::STATUSES),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('fulfill')
|
||||
->label('Onorează (scade stoc)')
|
||||
->icon('heroicon-m-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (OnlineOrder $r) => ! in_array($r->status, ['delivered', 'cancelled'], true))
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Scade din stoc piesele legate de catalog (FIFO) și marchează comanda confirmată.')
|
||||
->action(function (OnlineOrder $r) {
|
||||
$svc = app(\App\Services\Warehouse\WarehouseService::class);
|
||||
$issued = 0; $skipped = 0;
|
||||
foreach ($r->items as $item) {
|
||||
if ($item->fulfilled) continue;
|
||||
if (! $item->part_id) { $skipped++; continue; }
|
||||
$part = \App\Models\Tenant\Part::find($item->part_id);
|
||||
if (! $part) { $skipped++; continue; }
|
||||
try {
|
||||
$svc->issue($part, (float) $item->qty, null, $r, "Comandă online #{$r->number}");
|
||||
$item->fulfilled = true;
|
||||
$item->save();
|
||||
$issued++;
|
||||
} catch (\App\Services\Warehouse\InsufficientStockException $e) {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
if ($r->status === 'new') {
|
||||
$r->status = 'confirmed';
|
||||
$r->save();
|
||||
}
|
||||
Notification::make()
|
||||
->title("Onorat: {$issued} linii scăzute" . ($skipped ? ", {$skipped} sărite (stoc/lipsă link)" : ''))
|
||||
->{$skipped ? 'warning' : 'success'}()
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\ItemsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListOnlineOrders::route('/'),
|
||||
'edit' => Pages\EditOnlineOrder::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\OnlineOrderResource;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditOnlineOrder extends EditRecord
|
||||
{
|
||||
protected static string $resource = OnlineOrderResource::class;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\OnlineOrderResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\OnlineOrderResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListOnlineOrders extends ListRecords
|
||||
{
|
||||
protected static string $resource = OnlineOrderResource::class;
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\OnlineOrderResource\RelationManagers;
|
||||
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ItemsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'items';
|
||||
|
||||
protected static ?string $title = 'Produse';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')->label('Piesă')->wrap(),
|
||||
Tables\Columns\TextColumn::make('article')->label('Cod')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('qty')->label('Cant.')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('price')->label('Preț')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
||||
Tables\Columns\IconColumn::make('fulfilled')->label('Onorat')->boolean(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\PartResource\Pages;
|
||||
use App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\Supplier;
|
||||
use Filament\Actions;
|
||||
@@ -79,6 +80,10 @@ class PartResource extends Resource
|
||||
Forms\Components\TextInput::make('unit')->label('UM')->default('buc')->maxLength(16),
|
||||
Forms\Components\TextInput::make('min_qty')->label('Minim')->numeric()->default(0),
|
||||
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||
Forms\Components\Toggle::make('is_published')
|
||||
->label('Publicat în magazin')
|
||||
->helperText('Apare în magazinul online public.')
|
||||
->default(false),
|
||||
]),
|
||||
Schemas\Components\Section::make('Prețuri')
|
||||
->columns(2)
|
||||
@@ -112,9 +117,16 @@ class PartResource extends Resource
|
||||
->alignRight()
|
||||
->color(fn ($state, $record) => $record->qty <= 0 ? 'danger' : ($record->qty <= $record->min_qty ? 'warning' : null))
|
||||
->weight(fn ($state, $record) => $record->qty <= $record->min_qty ? 'bold' : null),
|
||||
Tables\Columns\TextColumn::make('qty_reserved')
|
||||
->label('Rezervat')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->alignRight()
|
||||
->color(fn ($state) => (float) $state > 0 ? 'info' : null)
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
||||
Tables\Columns\TextColumn::make('location')->label('Loc.')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('sell_price')->label('Preț vz.')->money('MDL')->alignRight(),
|
||||
Tables\Columns\IconColumn::make('is_published')->label('Magazin')->boolean()->toggleable(),
|
||||
Tables\Columns\TextColumn::make('preferredSupplier.name')->label('Furnizor')->placeholder('—')->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
@@ -128,15 +140,115 @@ class PartResource extends Resource
|
||||
->query(fn ($q) => $q->where('qty', '<=', 0)),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('qr')
|
||||
->label('QR')
|
||||
->icon('heroicon-m-qr-code')
|
||||
->color('gray')
|
||||
->modalHeading(fn (Part $r) => 'QR pentru ' . $r->name)
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Închide')
|
||||
->modalContent(function (Part $r) {
|
||||
$payload = 'PART:' . ($r->article ?: $r->id);
|
||||
$svg = (new \chillerlan\QRCode\QRCode(new \chillerlan\QRCode\QROptions([
|
||||
'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG,
|
||||
'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M,
|
||||
'scale' => 8,
|
||||
'imageBase64' => false,
|
||||
'addQuietzone' => true,
|
||||
])))->render($payload);
|
||||
return view('filament.tenant.part-qr', [
|
||||
'part' => $r, 'svg' => $svg, 'payload' => $payload,
|
||||
]);
|
||||
}),
|
||||
Actions\Action::make('ai_price')
|
||||
->label('AI: preț recomandat')
|
||||
->icon('heroicon-m-sparkles')
|
||||
->color('primary')
|
||||
->modalHeading(fn (Part $r) => "AI: preț pentru {$r->name}")
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Închide')
|
||||
->modalContent(function (Part $r) {
|
||||
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
|
||||
->suggestPrice($r);
|
||||
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
|
||||
}),
|
||||
Actions\Action::make('receive')
|
||||
->label('Recepție')
|
||||
->icon('heroicon-m-arrow-down-tray')
|
||||
->color('success')
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->required()->minValue(0.001),
|
||||
Forms\Components\TextInput::make('buy_price')->label('Preț unitar')->numeric()->required(),
|
||||
Forms\Components\Select::make('supplier_id')
|
||||
->label('Furnizor')
|
||||
->options(fn () => \App\Models\Tenant\Supplier::pluck('name', 'id')),
|
||||
Forms\Components\Select::make('warehouse_id')
|
||||
->label('Depozit')
|
||||
->options(fn () => \App\Models\Tenant\Warehouse::where('is_active', true)->pluck('name', 'id'))
|
||||
->default(fn () => \App\Models\Tenant\Warehouse::where('is_default', true)->value('id')),
|
||||
Forms\Components\TextInput::make('batch_ref')->label('Ref. lot/factură')->maxLength(64),
|
||||
])
|
||||
->action(function (Part $record, array $data) {
|
||||
$warehouse = $data['warehouse_id']
|
||||
? \App\Models\Tenant\Warehouse::find($data['warehouse_id'])
|
||||
: null;
|
||||
$supplier = $data['supplier_id']
|
||||
? \App\Models\Tenant\Supplier::find($data['supplier_id'])
|
||||
: null;
|
||||
app(\App\Services\Warehouse\WarehouseService::class)->receive(
|
||||
part: $record,
|
||||
qty: (float) $data['qty'],
|
||||
buyPrice: (float) $data['buy_price'],
|
||||
warehouse: $warehouse,
|
||||
supplier: $supplier,
|
||||
batchRef: $data['batch_ref'] ?? null,
|
||||
);
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Stoc adăugat')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Actions\BulkAction::make('print_labels')
|
||||
->label('Tipărește etichete QR')
|
||||
->icon('heroicon-m-printer')
|
||||
->color('gray')
|
||||
->action(function ($records) {
|
||||
$ids = collect($records)->pluck('id')->implode(',');
|
||||
return redirect()->away('/parts/labels?ids=' . $ids);
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
Actions\BulkAction::make('publish')
|
||||
->label('Publică în magazin')
|
||||
->icon('heroicon-m-globe-alt')
|
||||
->color('success')
|
||||
->action(fn ($records) => collect($records)->each->update(['is_published' => true]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
Actions\BulkAction::make('unpublish')
|
||||
->label('Scoate din magazin')
|
||||
->icon('heroicon-m-eye-slash')
|
||||
->color('gray')
|
||||
->action(fn ($records) => collect($records)->each->update(['is_published' => false]))
|
||||
->deselectRecordsAfterCompletion(),
|
||||
])
|
||||
->emptyStateHeading('Depozit gol')
|
||||
->emptyStateDescription('Adaugă piese manual, sau folosește Achiziții ca să le adaugi prin recepție de la furnizor (cu prețuri și stoc auto). Procentaj poate seta automat prețul de vânzare.')
|
||||
->emptyStateIcon('heroicon-o-cube')
|
||||
->defaultSort('name');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\BatchesRelationManager::class,
|
||||
RelationManagers\PriceHistoryRelationManager::class,
|
||||
RelationManagers\CrossRefsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class BatchesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'batches';
|
||||
|
||||
protected static ?string $title = 'Loturi (FIFO)';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('received_at')
|
||||
->label('Recepție')
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('warehouse.code')->label('Depozit')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('batch_ref')->label('Ref.')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('qty_in')
|
||||
->label('Intrat')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->alignRight(),
|
||||
Tables\Columns\TextColumn::make('qty_remaining')
|
||||
->label('Rămas')
|
||||
->numeric(decimalPlaces: 2)
|
||||
->alignRight()
|
||||
->weight('bold')
|
||||
->color(fn ($state) => (float) $state <= 0 ? 'gray' : 'success'),
|
||||
Tables\Columns\TextColumn::make('buy_price')
|
||||
->label('Preț unit.')
|
||||
->money('MDL')
|
||||
->alignRight(),
|
||||
])
|
||||
->defaultSort('received_at')
|
||||
->emptyStateHeading('Niciun lot înregistrat')
|
||||
->emptyStateDescription('Apasă „Recepție" pe lista de piese pentru a înregistra prima intrare în depozit.');
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class CrossRefsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'crossRefs';
|
||||
|
||||
protected static ?string $title = 'Coduri cross (OEM/echivalente)';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\TextInput::make('cross_article')->label('Cod echivalent')->required()->maxLength(64),
|
||||
Forms\Components\TextInput::make('brand')->label('Brand')->maxLength(64),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('cross_article')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('cross_article')->label('Cod')->searchable(),
|
||||
Tables\Columns\TextColumn::make('brand')->placeholder('—'),
|
||||
])
|
||||
->headerActions([Actions\CreateAction::make()])
|
||||
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()])
|
||||
->emptyStateHeading('Niciun cod cross')
|
||||
->emptyStateDescription('Adaugă coduri echivalente OEM/aftermarket ca să fie găsite în căutarea din magazin.');
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PartResource\RelationManagers;
|
||||
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PriceHistoryRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'priceHistory';
|
||||
|
||||
protected static ?string $title = 'Istoric prețuri furnizori';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('observed_at')
|
||||
->label('Data')
|
||||
->dateTime('d.m.Y H:i')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('supplier.name')->label('Furnizor')->searchable(),
|
||||
Tables\Columns\TextColumn::make('purchase.number')->label('PO')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('price')
|
||||
->money('MDL')
|
||||
->alignRight()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('currency')->label('Val.'),
|
||||
])
|
||||
->defaultSort('observed_at', 'desc')
|
||||
->emptyStateHeading('Niciun preț înregistrat')
|
||||
->emptyStateDescription('Prețurile se înregistrează automat la fiecare recepție de PO.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||
use App\Models\Tenant\PricingCoefficient;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class PricingCoefficientResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PricingCoefficient::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-adjustments-horizontal';
|
||||
|
||||
protected static ?string $navigationLabel = 'Coeficienți preț';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
|
||||
|
||||
protected static ?string $modelLabel = 'coeficient';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'coeficienți preț';
|
||||
|
||||
protected static ?int $navigationSort = 46;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make('Coeficient')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')->label('Denumire')->required()
|
||||
->placeholder('ex: Mașină veche, Client VIP, Express')->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('multiplier')
|
||||
->label('Multiplicator')
|
||||
->numeric()
|
||||
->required()
|
||||
->default(1.10)
|
||||
->helperText('1.15 = +15% peste prețul de bază. 0.95 = -5%.'),
|
||||
Forms\Components\TextInput::make('priority')->label('Prioritate')->numeric()->default(100),
|
||||
Forms\Components\Toggle::make('stackable')
|
||||
->label('Cumulabil')
|
||||
->default(true)
|
||||
->helperText('Cumulabil = se înmulțește cu alți coeficienți. Necumulabil = doar cel mai mare necumulabil se aplică.'),
|
||||
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||
]),
|
||||
Schemas\Components\Section::make('Condiții (toate trebuie îndeplinite)')
|
||||
->description('Lasă gol = se aplică mereu. Combină condițiile pentru a ținti situații specifice.')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\CheckboxList::make('conditions.classes')
|
||||
->label('Clase auto')
|
||||
->options(PricingCoefficient::VEHICLE_CLASSES)
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('conditions.age_min')->label('Vârstă min (ani)')->numeric(),
|
||||
Forms\Components\TextInput::make('conditions.age_max')->label('Vârstă max (ani)')->numeric(),
|
||||
Forms\Components\Toggle::make('conditions.client_vip')->label('Doar clienți VIP'),
|
||||
Forms\Components\CheckboxList::make('conditions.urgency')
|
||||
->label('Urgență')
|
||||
->options(PricingCoefficient::URGENCY)
|
||||
->columns(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('priority')->label('Prio')->sortable()->alignRight(),
|
||||
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('multiplier')
|
||||
->label('Multiplicator')
|
||||
->formatStateUsing(fn ($s) => '×' . rtrim(rtrim(number_format((float) $s, 3), '0'), '.'))
|
||||
->alignRight()
|
||||
->color(fn ($s) => (float) $s >= 1 ? 'success' : 'warning'),
|
||||
Tables\Columns\IconColumn::make('stackable')->label('Cumul.')->boolean(),
|
||||
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active')->label('Active'),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->emptyStateHeading('Niciun coeficient')
|
||||
->emptyStateDescription('Adaugă reguli care ajustează prețul în funcție de vârsta mașinii, clasă (SUV, comercial, hibrid), client VIP sau urgență. Se aplică peste markup-ul de bază pe fișele de lucru.')
|
||||
->emptyStateIcon('heroicon-o-adjustments-horizontal')
|
||||
->defaultSort('priority');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListPricingCoefficients::route('/'),
|
||||
'create' => Pages\CreatePricingCoefficient::route('/create'),
|
||||
'edit' => Pages\EditPricingCoefficient::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PricingCoefficientResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreatePricingCoefficient extends CreateRecord
|
||||
{
|
||||
protected static string $resource = PricingCoefficientResource::class;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PricingCoefficientResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditPricingCoefficient extends EditRecord
|
||||
{
|
||||
protected static string $resource = PricingCoefficientResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\PricingCoefficientResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\PricingCoefficientResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPricingCoefficients extends ListRecords
|
||||
{
|
||||
protected static string $resource = PricingCoefficientResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Filament\Tenant\Resources\PurchaseResource\Pages;
|
||||
use App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
|
||||
use App\Models\Tenant\Purchase;
|
||||
use App\Models\Tenant\Supplier;
|
||||
use App\Models\Tenant\Warehouse;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
@@ -43,6 +44,11 @@ class PurchaseResource extends Resource
|
||||
->options(fn () => Supplier::where('is_active', true)->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->required(),
|
||||
Forms\Components\Select::make('warehouse_id')
|
||||
->label('Depozit țintă')
|
||||
->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id'))
|
||||
->default(fn () => Warehouse::where('is_default', true)->value('id'))
|
||||
->required(),
|
||||
Forms\Components\Select::make('status')
|
||||
->options(Purchase::STATUSES)
|
||||
->default('draft')
|
||||
@@ -71,9 +77,19 @@ class PurchaseResource extends Resource
|
||||
->colors([
|
||||
'gray' => ['draft'],
|
||||
'warning' => ['ordered'],
|
||||
'info' => ['partial'],
|
||||
'success' => ['received'],
|
||||
'danger' => ['cancelled'],
|
||||
]),
|
||||
Tables\Columns\TextColumn::make('received_progress')
|
||||
->label('Progres')
|
||||
->state(function (Purchase $r) {
|
||||
$items = $r->items;
|
||||
$ord = (float) $items->sum('qty');
|
||||
$rec = (float) $items->sum('qty_received');
|
||||
return $ord > 0 ? sprintf('%d%%', (int) round($rec / $ord * 100)) : '—';
|
||||
})
|
||||
->alignRight(),
|
||||
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
||||
])
|
||||
->filters([
|
||||
@@ -83,19 +99,27 @@ class PurchaseResource extends Resource
|
||||
->options(fn () => Supplier::pluck('name', 'id')),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('receive')
|
||||
->label('Recepționează')
|
||||
Actions\Action::make('receive_all')
|
||||
->label('Recepție totală')
|
||||
->icon('heroicon-m-check-circle')
|
||||
->color('success')
|
||||
->visible(fn (Purchase $r) => $r->status !== 'received' && $r->status !== 'cancelled')
|
||||
->visible(fn (Purchase $r) => ! in_array($r->status, ['received', 'cancelled', 'draft'], true))
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Se va incrementa stocul pieselor legate.')
|
||||
->modalDescription('Se vor crea batch-uri pentru toate restanțele rămase în depozitul țintă.')
|
||||
->action(function (Purchase $r) {
|
||||
$r->markReceived();
|
||||
Notification::make()
|
||||
->title('Recepționat — stoc actualizat')
|
||||
->success()
|
||||
->send();
|
||||
try {
|
||||
$r->receiveAllRemaining();
|
||||
Notification::make()
|
||||
->title('Recepție completă — batch-uri create')
|
||||
->success()
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title('Eroare')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
|
||||
+47
-2
@@ -3,8 +3,11 @@
|
||||
namespace App\Filament\Tenant\Resources\PurchaseResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\PurchaseItem;
|
||||
use App\Models\Tenant\Warehouse;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
@@ -52,16 +55,58 @@ class ItemsRelationManager extends RelationManager
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')->wrap(),
|
||||
Tables\Columns\TextColumn::make('article')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('qty')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('qty')->label('Comandat')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('qty_received')
|
||||
->label('Recepționat')
|
||||
->alignRight()
|
||||
->color(fn ($state, $record) => $record->isFullyReceived() ? 'success' : ((float) $state > 0 ? 'warning' : 'gray'))
|
||||
->formatStateUsing(fn ($state, $record) => sprintf('%.2f / %.2f', (float) $state, (float) $record->qty)),
|
||||
Tables\Columns\TextColumn::make('unit')->label('UM'),
|
||||
Tables\Columns\TextColumn::make('buy_price')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('total')->money('MDL')->alignRight(),
|
||||
Tables\Columns\IconColumn::make('received')->boolean()->label('Recepț.'),
|
||||
])
|
||||
->headerActions([
|
||||
Actions\CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('receive_item')
|
||||
->label('Recepționează')
|
||||
->icon('heroicon-m-arrow-down-tray')
|
||||
->color('success')
|
||||
->visible(fn (PurchaseItem $r) => ! $r->isFullyReceived())
|
||||
->schema([
|
||||
Forms\Components\Placeholder::make('outstanding')
|
||||
->label('Restanță')
|
||||
->content(fn (PurchaseItem $r) => sprintf('%.2f %s', $r->outstanding(), $r->unit ?? 'buc')),
|
||||
Forms\Components\TextInput::make('qty')
|
||||
->label('Cantitate recepționată')
|
||||
->numeric()
|
||||
->required()
|
||||
->minValue(0.001)
|
||||
->default(fn (PurchaseItem $r) => $r->outstanding()),
|
||||
Forms\Components\Select::make('warehouse_id')
|
||||
->label('Depozit țintă')
|
||||
->options(fn () => Warehouse::where('is_active', true)->pluck('name', 'id'))
|
||||
->default(fn (PurchaseItem $r) => $r->purchase?->warehouse_id
|
||||
?? Warehouse::where('is_default', true)->value('id'))
|
||||
->required(),
|
||||
])
|
||||
->action(function (PurchaseItem $r, array $data) {
|
||||
$wh = $data['warehouse_id'] ? Warehouse::find($data['warehouse_id']) : null;
|
||||
try {
|
||||
$r->purchase->receiveItem($r, (float) $data['qty'], $wh);
|
||||
Notification::make()
|
||||
->title('Recepționat — batch creat')
|
||||
->success()
|
||||
->send();
|
||||
} catch (\Throwable $e) {
|
||||
Notification::make()
|
||||
->title('Eroare la recepție')
|
||||
->body($e->getMessage())
|
||||
->danger()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||
use App\Filament\Tenant\Resources\ServiceTemplateResource\RelationManagers;
|
||||
use App\Models\Tenant\Labor;
|
||||
use App\Models\Tenant\ServiceTemplate;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ServiceTemplateResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ServiceTemplate::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
|
||||
protected static ?string $navigationLabel = 'Șabloane servicii';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Service';
|
||||
|
||||
protected static ?string $modelLabel = 'șablon';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'șabloane servicii';
|
||||
|
||||
protected static ?int $navigationSort = 33;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make()
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('name')->label('Denumire')->required()
|
||||
->placeholder('ex: Revizie completă 15.000 km')->columnSpanFull(),
|
||||
Forms\Components\Select::make('category')
|
||||
->label('Categorie')
|
||||
->options(array_combine(Labor::CATEGORIES, Labor::CATEGORIES))
|
||||
->searchable(),
|
||||
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('items_count')->counts('items')->label('Linii')->alignRight(),
|
||||
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active')->label('Active'),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->emptyStateHeading('Niciun șablon')
|
||||
->emptyStateDescription('Grupează manopere + piese frecvente într-un șablon (ex: „Schimb ulei complet") și aplică-l pe o fișă cu un click.')
|
||||
->emptyStateIcon('heroicon-o-clipboard-document-list')
|
||||
->defaultSort('name');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\ItemsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListServiceTemplates::route('/'),
|
||||
'create' => Pages\CreateServiceTemplate::route('/create'),
|
||||
'edit' => Pages\EditServiceTemplate::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\ServiceTemplateResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateServiceTemplate extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ServiceTemplateResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\ServiceTemplateResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditServiceTemplate extends EditRecord
|
||||
{
|
||||
protected static string $resource = ServiceTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\ServiceTemplateResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListServiceTemplates extends ListRecords
|
||||
{
|
||||
protected static string $resource = ServiceTemplateResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\ServiceTemplateResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant\Labor;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\ServiceTemplateItem;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ItemsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'items';
|
||||
|
||||
protected static ?string $title = 'Conținut șablon';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Select::make('kind')
|
||||
->label('Tip')
|
||||
->options(ServiceTemplateItem::KINDS)
|
||||
->default('labor')
|
||||
->live()
|
||||
->required(),
|
||||
Forms\Components\Select::make('labor_id')
|
||||
->label('Manoperă')
|
||||
->options(fn () => Labor::where('is_active', true)->pluck('name_ro', 'id'))
|
||||
->searchable()
|
||||
->visible(fn (Get $get) => $get('kind') === 'labor')
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if ($state && $l = Labor::find($state)) {
|
||||
$set('name', $l->name_ro);
|
||||
$set('hours', $l->hours);
|
||||
}
|
||||
}),
|
||||
Forms\Components\Select::make('part_id')
|
||||
->label('Piesă')
|
||||
->options(fn () => Part::where('is_active', true)
|
||||
->get()->mapWithKeys(fn ($p) => [$p->id => "{$p->name} " . ($p->article ? "[{$p->article}]" : '')])->toArray())
|
||||
->searchable()
|
||||
->visible(fn (Get $get) => $get('kind') === 'part')
|
||||
->live()
|
||||
->afterStateUpdated(function ($state, Set $set) {
|
||||
if ($state && $p = Part::find($state)) $set('name', $p->name);
|
||||
}),
|
||||
Forms\Components\TextInput::make('name')->label('Denumire')->required()->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('hours')->label('Ore')->numeric()
|
||||
->visible(fn (Get $get) => $get('kind') === 'labor'),
|
||||
Forms\Components\TextInput::make('qty')->label('Cantitate')->numeric()->default(1)
|
||||
->visible(fn (Get $get) => $get('kind') === 'part'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('kind')
|
||||
->label('Tip')
|
||||
->formatStateUsing(fn ($s) => ServiceTemplateItem::KINDS[$s] ?? $s)
|
||||
->badge()
|
||||
->color(fn ($s) => $s === 'labor' ? 'info' : 'gray'),
|
||||
Tables\Columns\TextColumn::make('name')->wrap(),
|
||||
Tables\Columns\TextColumn::make('hours')->label('Ore')->placeholder('—')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('qty')->label('Cant.')->placeholder('—')->alignRight(),
|
||||
])
|
||||
->headerActions([Actions\CreateAction::make()])
|
||||
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||
use App\Models\Tenant\Subcontractor;
|
||||
use App\Models\Tenant\SubcontractJob;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SubcontractJobResource extends Resource
|
||||
{
|
||||
protected static ?string $model = SubcontractJob::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-arrow-top-right-on-square';
|
||||
|
||||
protected static ?string $navigationLabel = 'Lucrări terți';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Subcontractare';
|
||||
|
||||
protected static ?string $modelLabel = 'lucrare terți';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'lucrări terți';
|
||||
|
||||
protected static ?int $navigationSort = 71;
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$open = static::getModel()::query()->whereNotIn('status', ['done', 'returned', 'cancelled'])->count();
|
||||
return $open > 0 ? (string) $open : null;
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make('Lucrare')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('number')->label('Nr.')->disabled()->dehydrated(false)->placeholder('Generat automat'),
|
||||
Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(),
|
||||
Forms\Components\Select::make('subcontractor_id')
|
||||
->label('Subcontractor')
|
||||
->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id'))
|
||||
->searchable(),
|
||||
Forms\Components\Select::make('work_order_id')
|
||||
->label('Fișă asociată')
|
||||
->options(fn () => WorkOrder::whereNotIn('status', ['done', 'cancelled'])
|
||||
->get()->mapWithKeys(fn ($w) => [$w->id => "#{$w->number} · " . ($w->vehicle?->plate ?? '')])->toArray())
|
||||
->searchable(),
|
||||
Forms\Components\Select::make('category')
|
||||
->label('Categorie')
|
||||
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
|
||||
->searchable(),
|
||||
Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(),
|
||||
]),
|
||||
Schemas\Components\Section::make('Cost & marjă')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('cost')->label('Cost (de la terț)')->numeric()->default(0)->required(),
|
||||
Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0)
|
||||
->helperText('> 0 calculează automat prețul client.'),
|
||||
Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0)
|
||||
->helperText('Setat manual dacă markup = 0.'),
|
||||
Forms\Components\Toggle::make('paid_to_sub')->label('Plătit către terț'),
|
||||
]),
|
||||
Schemas\Components\Section::make('Termene')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Forms\Components\DatePicker::make('sent_at')->label('Trimis')->default(today()),
|
||||
Forms\Components\DatePicker::make('eta')->label('ETA'),
|
||||
Forms\Components\DatePicker::make('returned_at')->label('Returnat'),
|
||||
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('number')->label('Nr.')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('subcontractor.name')->label('Terț')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('workOrder.number')->label('Fișă')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('cost')->label('Cost')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('margin')
|
||||
->label('Marjă')
|
||||
->state(fn (SubcontractJob $r) => $r->margin())
|
||||
->money('MDL')
|
||||
->alignRight()
|
||||
->color(fn ($state) => (float) $state > 0 ? 'success' : ((float) $state < 0 ? 'danger' : 'gray')),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s)
|
||||
->badge()
|
||||
->colors([
|
||||
'warning' => ['sent', 'in_progress'],
|
||||
'success' => ['done', 'returned'],
|
||||
'danger' => ['cancelled'],
|
||||
]),
|
||||
Tables\Columns\IconColumn::make('paid_to_sub')->label('Plătit terț')->boolean()->toggleable(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')->options(SubcontractJob::STATUSES),
|
||||
Tables\Filters\SelectFilter::make('subcontractor_id')
|
||||
->label('Subcontractor')
|
||||
->options(fn () => Subcontractor::pluck('name', 'id')),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->emptyStateHeading('Nicio lucrare la terți')
|
||||
->emptyStateDescription('Înregistrează lucrările trimise la ateliere externe (turbo, cutii, vopsitorie). Costul terțului + markup intră automat în totalul fișei asociate.')
|
||||
->emptyStateIcon('heroicon-o-arrow-top-right-on-square')
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListSubcontractJobs::route('/'),
|
||||
'create' => Pages\CreateSubcontractJob::route('/create'),
|
||||
'edit' => Pages\EditSubcontractJob::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractJobResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateSubcontractJob extends CreateRecord
|
||||
{
|
||||
protected static string $resource = SubcontractJobResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractJobResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditSubcontractJob extends EditRecord
|
||||
{
|
||||
protected static string $resource = SubcontractJobResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\SubcontractJobResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractJobResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListSubcontractJobs extends ListRecords
|
||||
{
|
||||
protected static string $resource = SubcontractJobResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||
use App\Models\Tenant\Subcontractor;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SubcontractorResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Subcontractor::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static ?string $navigationLabel = 'Subcontractori';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Subcontractare';
|
||||
|
||||
protected static ?string $modelLabel = 'subcontractor';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'subcontractori';
|
||||
|
||||
protected static ?int $navigationSort = 70;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make()->columns(2)->schema([
|
||||
Forms\Components\TextInput::make('name')->label('Nume')->required()->maxLength(160),
|
||||
Forms\Components\Select::make('specialty')
|
||||
->label('Specialitate')
|
||||
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
|
||||
->searchable(),
|
||||
Forms\Components\TextInput::make('phone')->label('Telefon')->tel()->maxLength(40),
|
||||
Forms\Components\TextInput::make('email')->email()->maxLength(120),
|
||||
Forms\Components\Select::make('rating')
|
||||
->label('Rating')
|
||||
->options([1 => '★', 2 => '★★', 3 => '★★★', 4 => '★★★★', 5 => '★★★★★'])
|
||||
->default(3),
|
||||
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||
Forms\Components\Textarea::make('notes')->label('Observații')->columnSpanFull()->rows(2),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('specialty')->badge()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('phone')->copyable()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('rating')->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)),
|
||||
Tables\Columns\TextColumn::make('jobs_count')->counts('jobs')->label('Lucrări')->alignRight(),
|
||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active')->label('Activi'),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->emptyStateHeading('Niciun subcontractor')
|
||||
->emptyStateDescription('Adaugă atelierele terțe la care trimiți lucrări (turbo, cutii, vopsitorie, PDR) și urmărește costul + marja.')
|
||||
->emptyStateIcon('heroicon-o-user-group')
|
||||
->defaultSort('name');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListSubcontractors::route('/'),
|
||||
'create' => Pages\CreateSubcontractor::route('/create'),
|
||||
'edit' => Pages\EditSubcontractor::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractorResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateSubcontractor extends CreateRecord
|
||||
{
|
||||
protected static string $resource = SubcontractorResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractorResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditSubcontractor extends EditRecord
|
||||
{
|
||||
protected static string $resource = SubcontractorResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\SubcontractorResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\SubcontractorResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListSubcontractors extends ListRecords
|
||||
{
|
||||
protected static string $resource = SubcontractorResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -68,15 +68,56 @@ class SupplierResource extends Resource
|
||||
Tables\Columns\TextColumn::make('rating')
|
||||
->label('Rating')
|
||||
->formatStateUsing(fn ($s) => str_repeat('★', (int) $s)),
|
||||
Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('on_time_pct')
|
||||
->label('La timp 90d')
|
||||
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->onTimeRate($r))
|
||||
->formatStateUsing(fn ($s) => $s === null ? '—' : "{$s}%")
|
||||
->color(fn ($s) => $s === null ? 'gray' : ($s >= 90 ? 'success' : ($s >= 70 ? 'warning' : 'danger')))
|
||||
->alignRight()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('avg_delivery_days')
|
||||
->label('Avg zile')
|
||||
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->avgDeliveryDays($r))
|
||||
->formatStateUsing(fn ($s) => $s === null ? '—' : (string) $s)
|
||||
->alignRight()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('spend_90d')
|
||||
->label('Cheltuit 90d')
|
||||
->state(fn (Supplier $r) => app(\App\Services\Warehouse\SupplierAnalytics::class)->spend($r))
|
||||
->money('MDL')
|
||||
->alignRight()
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('delivery_days')->label('Livrare (zile)')->alignRight()->toggleable(),
|
||||
Tables\Columns\TextColumn::make('discount_pct')->label('Discount')
|
||||
->formatStateUsing(fn ($s) => $s . '%')->alignRight(),
|
||||
->formatStateUsing(fn ($s) => $s . '%')->alignRight()->toggleable(),
|
||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TernaryFilter::make('is_active')->label('Activi'),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('rate')
|
||||
->label('Rerating')
|
||||
->icon('heroicon-m-arrow-path')
|
||||
->color('gray')
|
||||
->action(function (Supplier $r) {
|
||||
$score = app(\App\Services\Warehouse\SupplierAnalytics::class)
|
||||
->computedRating($r);
|
||||
if ($score === null) {
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Date insuficiente')
|
||||
->body('Necesită cel puțin 2 recepții complete cu data așteptată setată.')
|
||||
->warning()
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
$r->rating = $score;
|
||||
$r->saveQuietly();
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title("Rating actualizat → {$score}★")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||
use App\Filament\Tenant\Resources\TireSetResource\RelationManagers;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\TireSet;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TireSetResource extends Resource
|
||||
{
|
||||
protected static ?string $model = TireSet::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-lifebuoy';
|
||||
|
||||
protected static ?string $navigationLabel = 'Seturi anvelope';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Anvelope';
|
||||
|
||||
protected static ?string $modelLabel = 'set anvelope';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'seturi anvelope';
|
||||
|
||||
protected static ?int $navigationSort = 60;
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
$stored = \App\Models\Tenant\TireStorage::where('status', 'stored')->count();
|
||||
return $stored > 0 ? (string) $stored : null;
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'info';
|
||||
}
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make('Proprietar')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Select::make('client_id')
|
||||
->label('Client')
|
||||
->options(fn () => Client::pluck('name', 'id'))
|
||||
->searchable()
|
||||
->live()
|
||||
->required(),
|
||||
Forms\Components\Select::make('vehicle_id')
|
||||
->label('Auto')
|
||||
->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()
|
||||
: [])
|
||||
->searchable(),
|
||||
Forms\Components\TextInput::make('label')->label('Etichetă')->placeholder('ex: Iarnă Michelin'),
|
||||
Forms\Components\Select::make('season')->label('Sezon')->options(TireSet::SEASONS)->default('winter')->required(),
|
||||
]),
|
||||
Schemas\Components\Section::make('Specificații')
|
||||
->columns(3)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('width')->label('Lățime')->numeric()->placeholder('205'),
|
||||
Forms\Components\TextInput::make('profile')->label('Profil')->numeric()->placeholder('55'),
|
||||
Forms\Components\TextInput::make('diameter')->label('Diametru R')->numeric()->placeholder('16'),
|
||||
Forms\Components\TextInput::make('brand')->maxLength(64),
|
||||
Forms\Components\TextInput::make('model')->maxLength(64),
|
||||
Forms\Components\TextInput::make('dot_year')->label('DOT')->maxLength(8)->placeholder('3621'),
|
||||
Forms\Components\Toggle::make('has_rims')->label('Cu jante'),
|
||||
Forms\Components\Select::make('rim_type')->label('Tip jante')->options(['steel' => 'Tablă', 'alloy' => 'Aliaj']),
|
||||
Forms\Components\Select::make('condition')->label('Stare')->options(TireSet::CONDITIONS),
|
||||
]),
|
||||
Schemas\Components\Section::make('Uzură (mm) per poziție')
|
||||
->columns(4)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('tread.fl')->label('Față-Stânga')->numeric(),
|
||||
Forms\Components\TextInput::make('tread.fr')->label('Față-Dreapta')->numeric(),
|
||||
Forms\Components\TextInput::make('tread.rl')->label('Spate-Stânga')->numeric(),
|
||||
Forms\Components\TextInput::make('tread.rr')->label('Spate-Dreapta')->numeric(),
|
||||
]),
|
||||
Schemas\Components\Section::make('TPMS & foto')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Forms\Components\Toggle::make('tpms')->label('Senzori TPMS'),
|
||||
Forms\Components\TextInput::make('notes')->label('Observații'),
|
||||
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos')
|
||||
->label('Fotografii')
|
||||
->collection('photos')
|
||||
->multiple()
|
||||
->image()
|
||||
->maxFiles(8)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('client.name')->label('Client')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('label')->label('Etichetă')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('size')
|
||||
->label('Dimensiune')
|
||||
->state(fn (TireSet $r) => $r->sizeLabel()),
|
||||
Tables\Columns\TextColumn::make('season')
|
||||
->label('Sezon')
|
||||
->formatStateUsing(fn ($s) => TireSet::SEASONS[$s] ?? $s)
|
||||
->badge()
|
||||
->colors(['warning' => ['summer'], 'info' => ['winter'], 'gray' => ['allseason']]),
|
||||
Tables\Columns\TextColumn::make('tread_min')->label('Uzură min')
|
||||
->formatStateUsing(fn ($s) => $s ? $s . ' mm' : '—')
|
||||
->color(fn ($s) => $s !== null && (float) $s < 3 ? 'danger' : null)
|
||||
->alignRight(),
|
||||
Tables\Columns\IconColumn::make('tpms')->label('TPMS')->boolean()->toggleable(),
|
||||
Tables\Columns\TextColumn::make('storage_status')
|
||||
->label('Depozit')
|
||||
->state(fn (TireSet $r) => $r->isStored() ? ($r->currentStorage()?->location ?? 'da') : '—')
|
||||
->badge()
|
||||
->color(fn ($state) => $state === '—' ? 'gray' : 'success'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('season')->options(TireSet::SEASONS),
|
||||
Tables\Filters\Filter::make('stored')
|
||||
->label('În depozit')
|
||||
->query(fn ($q) => $q->whereHas('storage', fn ($s) => $s->where('status', 'stored'))),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('check_in')
|
||||
->label('Check-in depozit')
|
||||
->icon('heroicon-m-arrow-down-on-square')
|
||||
->color('success')
|
||||
->visible(fn (TireSet $r) => ! $r->isStored())
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('location')->label('Locație (raft)')->required()->placeholder('A1-03'),
|
||||
Forms\Components\TextInput::make('season_label')->label('Perioadă')->placeholder('Iarnă 2025-2026'),
|
||||
Forms\Components\TextInput::make('fee')->label('Taxă depozitare')->numeric()->default(0),
|
||||
])
|
||||
->action(function (TireSet $r, array $data) {
|
||||
\App\Models\Tenant\TireStorage::create([
|
||||
'tire_set_id' => $r->id,
|
||||
'location' => $data['location'],
|
||||
'season_label' => $data['season_label'] ?? null,
|
||||
'fee' => (float) ($data['fee'] ?? 0),
|
||||
'status' => 'stored',
|
||||
'checked_in_at' => now(),
|
||||
]);
|
||||
\Filament\Notifications\Notification::make()->title('Set primit în depozit')->success()->send();
|
||||
}),
|
||||
Actions\Action::make('check_out')
|
||||
->label('Eliberează')
|
||||
->icon('heroicon-m-arrow-up-on-square')
|
||||
->color('warning')
|
||||
->visible(fn (TireSet $r) => $r->isStored())
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Marchează setul ca ridicat de client.')
|
||||
->action(function (TireSet $r) {
|
||||
$storage = $r->currentStorage();
|
||||
if ($storage) {
|
||||
$storage->update(['status' => 'retrieved', 'checked_out_at' => now()]);
|
||||
}
|
||||
\Filament\Notifications\Notification::make()->title('Set eliberat din depozit')->success()->send();
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->emptyStateHeading('Niciun set de anvelope')
|
||||
->emptyStateDescription('Înregistrează seturile de anvelope ale clienților și gestionează depozitarea sezonieră (tire hotel). Urmărește uzura, TPMS și locația în depozit.')
|
||||
->emptyStateIcon('heroicon-o-lifebuoy')
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
RelationManagers\StorageRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListTireSets::route('/'),
|
||||
'create' => Pages\CreateTireSet::route('/create'),
|
||||
'edit' => Pages\EditTireSet::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TireSetResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTireSet extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TireSetResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TireSetResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditTireSet extends EditRecord
|
||||
{
|
||||
protected static string $resource = TireSetResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TireSetResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\TireSetResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListTireSets extends ListRecords
|
||||
{
|
||||
protected static string $resource = TireSetResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\TireSetResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant\TireStorage;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class StorageRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'storage';
|
||||
|
||||
protected static ?string $title = 'Istoric depozitare';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('season_label')->label('Perioadă')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('location')->label('Locație')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('checked_in_at')->label('Primit')->dateTime('d.m.Y'),
|
||||
Tables\Columns\TextColumn::make('checked_out_at')->label('Ridicat')->dateTime('d.m.Y')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->formatStateUsing(fn ($s) => TireStorage::STATUSES[$s] ?? $s)
|
||||
->badge()
|
||||
->colors(['success' => ['stored'], 'gray' => ['retrieved']]),
|
||||
Tables\Columns\TextColumn::make('fee')->money('MDL')->alignRight(),
|
||||
Tables\Columns\IconColumn::make('paid')->label('Plătit')->boolean(),
|
||||
])
|
||||
->defaultSort('checked_in_at', 'desc')
|
||||
->emptyStateHeading('Niciun istoric')
|
||||
->emptyStateDescription('Folosește „Check-in depozit" pe set pentru a înregistra prima depozitare.');
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,10 @@ class VehicleResource extends Resource
|
||||
'Benzină' => 'Benzină', 'Diesel' => 'Diesel', 'Hybrid' => 'Hybrid',
|
||||
'EV' => 'Electric', 'GPL' => 'GPL', 'GNC' => 'GNC',
|
||||
]),
|
||||
Forms\Components\Select::make('vehicle_class')
|
||||
->label('Clasă (pentru pricing)')
|
||||
->options(\App\Models\Tenant\PricingCoefficient::VEHICLE_CLASSES)
|
||||
->helperText('Folosită de coeficienții de preț. Hibrid/EV se deduc și din combustibil.'),
|
||||
Forms\Components\TextInput::make('mileage')->label('Kilometraj')->numeric()->default(0),
|
||||
Forms\Components\TextInput::make('color')->maxLength(40),
|
||||
]),
|
||||
@@ -93,6 +97,31 @@ class VehicleResource extends Resource
|
||||
Tables\Columns\TextColumn::make('created_at')->date()->sortable(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('decode_vin')
|
||||
->label('Decode VIN')
|
||||
->icon('heroicon-m-cpu-chip')
|
||||
->color('gray')
|
||||
->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin) && strlen($r->vin) === 17)
|
||||
->modalHeading(fn (\App\Models\Tenant\Vehicle $r) => 'Decode VIN: ' . $r->vin)
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Închide')
|
||||
->modalContent(function (\App\Models\Tenant\Vehicle $r) {
|
||||
$info = app(\App\Services\Ai\VinDecoder::class)->decode($r->vin);
|
||||
return view('filament.tenant.vin-decode', ['info' => $info, 'vehicle' => $r]);
|
||||
}),
|
||||
Actions\Action::make('ai_recommend')
|
||||
->label('AI: recomandări')
|
||||
->icon('heroicon-m-sparkles')
|
||||
->color('primary')
|
||||
->visible(fn (\App\Models\Tenant\Vehicle $r) => ! empty($r->vin))
|
||||
->modalHeading('Recomandări AI')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Închide')
|
||||
->modalContent(function (\App\Models\Tenant\Vehicle $r) {
|
||||
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
|
||||
->vinRecommendations($r->vin, (int) $r->mileage);
|
||||
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources;
|
||||
|
||||
use App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||
use App\Models\Tenant\Warehouse;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class WarehouseResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Warehouse::class;
|
||||
|
||||
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-building-storefront';
|
||||
|
||||
protected static ?string $navigationLabel = 'Depozite';
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Depozit';
|
||||
|
||||
protected static ?string $modelLabel = 'depozit';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'depozite';
|
||||
|
||||
protected static ?int $navigationSort = 38;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Schemas\Components\Section::make()->columns(2)->schema([
|
||||
Forms\Components\TextInput::make('code')->label('Cod')->required()->maxLength(32),
|
||||
Forms\Components\TextInput::make('name')->label('Denumire')->required()->maxLength(120),
|
||||
Forms\Components\TextInput::make('address')->label('Adresă')->columnSpanFull()->maxLength(200),
|
||||
Forms\Components\Toggle::make('is_default')->label('Depozit implicit'),
|
||||
Forms\Components\Toggle::make('is_active')->label('Activ')->default(true),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('code')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
|
||||
Tables\Columns\TextColumn::make('address')->placeholder('—')->toggleable(),
|
||||
Tables\Columns\IconColumn::make('is_default')->label('Implicit')->boolean(),
|
||||
Tables\Columns\IconColumn::make('is_active')->label('Activ')->boolean(),
|
||||
Tables\Columns\TextColumn::make('batches_count')
|
||||
->counts('batches')
|
||||
->label('Loturi')
|
||||
->alignRight(),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
])
|
||||
->emptyStateHeading('Niciun depozit')
|
||||
->emptyStateDescription('Un depozit implicit a fost creat la migrare. Adaugă altele dacă ai locații fizice separate (sucursală, hală, mobil).')
|
||||
->emptyStateIcon('heroicon-o-building-storefront')
|
||||
->defaultSort('code');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListWarehouses::route('/'),
|
||||
'create' => Pages\CreateWarehouse::route('/create'),
|
||||
'edit' => Pages\EditWarehouse::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\WarehouseResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateWarehouse extends CreateRecord
|
||||
{
|
||||
protected static string $resource = WarehouseResource::class;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\WarehouseResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditWarehouse extends EditRecord
|
||||
{
|
||||
protected static string $resource = WarehouseResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\DeleteAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\WarehouseResource\Pages;
|
||||
|
||||
use App\Filament\Tenant\Resources\WarehouseResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListWarehouses extends ListRecords
|
||||
{
|
||||
protected static string $resource = WarehouseResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [Actions\CreateAction::make()];
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,11 @@ class WorkOrderResource extends Resource
|
||||
->options(WorkOrder::STATUSES)
|
||||
->default('new')
|
||||
->required(),
|
||||
Forms\Components\Select::make('urgency')
|
||||
->label('Urgență')
|
||||
->options(\App\Models\Tenant\PricingCoefficient::URGENCY)
|
||||
->default('normal')
|
||||
->required(),
|
||||
Forms\Components\Select::make('client_id')
|
||||
->label('Client')
|
||||
->options(fn () => Client::pluck('name', 'id'))
|
||||
@@ -101,6 +106,35 @@ class WorkOrderResource extends Resource
|
||||
Forms\Components\Textarea::make('diagnosis')->label('Diagnostic')->rows(3)->columnSpanFull(),
|
||||
Forms\Components\Textarea::make('recommendations')->label('Recomandări')->rows(2)->columnSpanFull(),
|
||||
]),
|
||||
Schemas\Components\Section::make('Foto')
|
||||
->collapsible()
|
||||
->schema([
|
||||
\Filament\Forms\Components\SpatieMediaLibraryFileUpload::make('photos')
|
||||
->label('Fotografii')
|
||||
->collection('photos')
|
||||
->multiple()
|
||||
->reorderable()
|
||||
->image()
|
||||
->imageEditor()
|
||||
->maxFiles(20)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Schemas\Components\Section::make('Tracking & ETA')
|
||||
->columns(3)
|
||||
->collapsible()
|
||||
->schema([
|
||||
Forms\Components\DateTimePicker::make('eta_at')
|
||||
->label('Gata estimat (ETA)')
|
||||
->seconds(false),
|
||||
Forms\Components\TextInput::make('tracking_token')
|
||||
->label('Token public')
|
||||
->disabled()
|
||||
->dehydrated(false)
|
||||
->columnSpan(2)
|
||||
->helperText(fn (?WorkOrder $record) => $record?->tracking_token
|
||||
? 'Link client: ' . $record->trackingUrl()
|
||||
: 'Se generează la salvare'),
|
||||
]),
|
||||
Schemas\Components\Section::make('Plată & total')
|
||||
->columns(3)
|
||||
->schema([
|
||||
@@ -180,6 +214,7 @@ class WorkOrderResource extends Resource
|
||||
return [
|
||||
RelationManagers\WorksRelationManager::class,
|
||||
RelationManagers\PartsRelationManager::class,
|
||||
RelationManagers\SubcontractJobsRelationManager::class,
|
||||
RelationManagers\PaymentsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,49 @@ class EditWorkOrder extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('apply_template')
|
||||
->label('Aplică șablon')
|
||||
->icon('heroicon-m-clipboard-document-list')
|
||||
->color('gray')
|
||||
->schema([
|
||||
\Filament\Forms\Components\Select::make('template_id')
|
||||
->label('Șablon serviciu')
|
||||
->options(fn () => \App\Models\Tenant\ServiceTemplate::where('is_active', true)->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->required(),
|
||||
])
|
||||
->action(function (array $data) {
|
||||
$template = \App\Models\Tenant\ServiceTemplate::with('items')->find($data['template_id']);
|
||||
if (! $template) return;
|
||||
$r = app(\App\Services\ServiceComposer::class)->applyTemplate($this->record, $template);
|
||||
$this->fillForm();
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title("Șablon aplicat: {$r['labor']} manopere, {$r['parts']} piese")
|
||||
->success()->send();
|
||||
}),
|
||||
Actions\Action::make('ai_diagnose')
|
||||
->label('AI: sugerează diagnostic')
|
||||
->icon('heroicon-m-sparkles')
|
||||
->color('primary')
|
||||
->visible(fn () => ! empty($this->record->complaint))
|
||||
->modalHeading('Diagnostic AI bazat pe plângerea clientului')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Închide')
|
||||
->modalContent(function () {
|
||||
[$reply, $meta] = app(\App\Services\Ai\AiAssistantService::class)
|
||||
->suggestDiagnosis($this->record);
|
||||
return view('filament.tenant.ai-reply', ['reply' => $reply, 'meta' => $meta]);
|
||||
}),
|
||||
Actions\Action::make('tracking')
|
||||
->label('Link client (QR)')
|
||||
->icon('heroicon-m-qr-code')
|
||||
->color('primary')
|
||||
->modalHeading(fn () => 'Tracking client — WO #' . $this->record->number)
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Închide')
|
||||
->modalContent(fn () => view('filament.tenant.tracking-qr', [
|
||||
'wo' => $this->record,
|
||||
])),
|
||||
Actions\Action::make('pdf')
|
||||
->label('Descarcă PDF')
|
||||
->icon('heroicon-m-document-arrow-down')
|
||||
|
||||
+71
@@ -3,9 +3,12 @@
|
||||
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\PartReservation;
|
||||
use App\Models\Tenant\WorkOrderPart;
|
||||
use App\Services\Warehouse\WarehouseService;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
@@ -82,6 +85,74 @@ class PartsRelationManager extends RelationManager
|
||||
Actions\CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('smart_price')
|
||||
->label('Preț inteligent')
|
||||
->icon('heroicon-m-sparkles')
|
||||
->color('primary')
|
||||
->visible(fn (WorkOrderPart $r) => (bool) $r->part_id)
|
||||
->modalHeading('Preț contextual')
|
||||
->modalSubmitActionLabel('Aplică prețul')
|
||||
->modalContent(function (WorkOrderPart $r) {
|
||||
$wo = $r->workOrder;
|
||||
$part = $r->part;
|
||||
$quote = app(\App\Services\Pricing\PricingEngine::class)->quote(
|
||||
$part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal'
|
||||
);
|
||||
return view('filament.tenant.smart-price', ['quote' => $quote, 'item' => $r]);
|
||||
})
|
||||
->action(function (WorkOrderPart $r) {
|
||||
$wo = $r->workOrder;
|
||||
$quote = app(\App\Services\Pricing\PricingEngine::class)->quote(
|
||||
$r->part, $wo?->vehicle, $wo?->client, $wo?->urgency ?? 'normal'
|
||||
);
|
||||
$r->sell_price = $quote['final'];
|
||||
$r->save();
|
||||
Notification::make()
|
||||
->title('Preț actualizat: ' . number_format($quote['final'], 2) . ' MDL')
|
||||
->success()->send();
|
||||
}),
|
||||
Actions\Action::make('issue_now')
|
||||
->label('Eliberează')
|
||||
->icon('heroicon-m-arrow-up-on-square')
|
||||
->color('warning')
|
||||
->visible(fn (WorkOrderPart $r) => $r->part_id
|
||||
&& PartReservation::where('work_order_part_id', $r->id)
|
||||
->where('status', PartReservation::STATUS_ACTIVE)
|
||||
->exists())
|
||||
->requiresConfirmation()
|
||||
->modalDescription('Confirmă că mecanicul ia fizic piesa din depozit. Stocul scade acum, fără să aștepți închiderea fișei.')
|
||||
->action(function (WorkOrderPart $r) {
|
||||
$n = app(WarehouseService::class)->issueNow($r);
|
||||
Notification::make()
|
||||
->title("Eliberat: {$n} rezervări consumate")
|
||||
->success()->send();
|
||||
}),
|
||||
Actions\Action::make('return_part')
|
||||
->label('Restituire')
|
||||
->icon('heroicon-m-arrow-uturn-left')
|
||||
->color('gray')
|
||||
->visible(fn (WorkOrderPart $r) => $r->part_id
|
||||
&& PartReservation::where('work_order_part_id', $r->id)
|
||||
->where('status', PartReservation::STATUS_CONSUMED)
|
||||
->exists())
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('qty')
|
||||
->label('Cantitate restituită')
|
||||
->numeric()
|
||||
->required()
|
||||
->minValue(0.001)
|
||||
->default(fn (WorkOrderPart $r) => (float) $r->qty),
|
||||
Forms\Components\Textarea::make('notes')->rows(2)->label('Observații'),
|
||||
])
|
||||
->action(function (WorkOrderPart $r, array $data) {
|
||||
$batch = app(WarehouseService::class)->returnPart(
|
||||
$r, (float) $data['qty'], $data['notes'] ?? null
|
||||
);
|
||||
Notification::make()
|
||||
->title($batch ? 'Piesa returnată în stoc' : 'Nimic de restituit')
|
||||
->{$batch ? 'success' : 'warning'}()
|
||||
->send();
|
||||
}),
|
||||
Actions\EditAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
]);
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Tenant\Resources\WorkOrderResource\RelationManagers;
|
||||
|
||||
use App\Models\Tenant\Subcontractor;
|
||||
use App\Models\Tenant\SubcontractJob;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SubcontractJobsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'subcontractJobs';
|
||||
|
||||
protected static ?string $title = 'Lucrări la terți';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->components([
|
||||
Forms\Components\Select::make('subcontractor_id')
|
||||
->label('Subcontractor')
|
||||
->options(fn () => Subcontractor::where('is_active', true)->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->columnSpanFull(),
|
||||
Forms\Components\Select::make('category')
|
||||
->label('Categorie')
|
||||
->options(array_combine(Subcontractor::SPECIALTIES, Subcontractor::SPECIALTIES))
|
||||
->searchable(),
|
||||
Forms\Components\Select::make('status')->options(SubcontractJob::STATUSES)->default('sent')->required(),
|
||||
Forms\Components\Textarea::make('description')->label('Descriere')->rows(2)->columnSpanFull(),
|
||||
Forms\Components\TextInput::make('cost')->label('Cost (terț)')->numeric()->default(0)->required(),
|
||||
Forms\Components\TextInput::make('markup_pct')->label('Markup %')->numeric()->default(0),
|
||||
Forms\Components\TextInput::make('client_price')->label('Preț client')->numeric()->default(0)
|
||||
->helperText('Folosit dacă markup = 0.'),
|
||||
Forms\Components\DatePicker::make('eta')->label('ETA'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('number')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('number')->label('Nr.'),
|
||||
Tables\Columns\TextColumn::make('subcontractor.name')->label('Terț')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('category')->badge()->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('cost')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('client_price')->label('Preț client')->money('MDL')->alignRight(),
|
||||
Tables\Columns\TextColumn::make('margin')
|
||||
->label('Marjă')
|
||||
->state(fn (SubcontractJob $r) => $r->margin())
|
||||
->money('MDL')->alignRight()
|
||||
->color(fn ($s) => (float) $s > 0 ? 'success' : ((float) $s < 0 ? 'danger' : 'gray')),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->formatStateUsing(fn ($s) => SubcontractJob::STATUSES[$s] ?? $s)
|
||||
->badge()
|
||||
->colors(['warning' => ['sent', 'in_progress'], 'success' => ['done', 'returned'], 'danger' => ['cancelled']]),
|
||||
])
|
||||
->headerActions([Actions\CreateAction::make()])
|
||||
->actions([Actions\EditAction::make(), Actions\DeleteAction::make()]);
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -69,7 +69,23 @@ class WorksRelationManager extends RelationManager
|
||||
->colors(['gray' => ['todo'], 'warning' => ['in_progress'], 'success' => ['done']]),
|
||||
])
|
||||
->headerActions([
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->after(function (WorkOrderWork $record) {
|
||||
// Auto-add the labor's default parts to the parent WO.
|
||||
if (! $record->labor_id) return;
|
||||
$labor = Labor::with('laborParts.part')->find($record->labor_id);
|
||||
$wo = $record->workOrder;
|
||||
if (! $labor || ! $wo || $labor->laborParts->isEmpty()) return;
|
||||
$composer = app(\App\Services\ServiceComposer::class);
|
||||
foreach ($labor->laborParts as $lp) {
|
||||
if ($lp->part) {
|
||||
$composer->addPart($wo, $lp->part, (float) $lp->qty, $lp->unit);
|
||||
}
|
||||
}
|
||||
\Filament\Notifications\Notification::make()
|
||||
->title('Piese implicite adăugate (' . $labor->laborParts->count() . ')')
|
||||
->success()->send();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant\Part;
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PartLabelsController extends Controller
|
||||
{
|
||||
public function sheet(Request $request)
|
||||
{
|
||||
$ids = array_filter(array_map('intval', explode(',', (string) $request->query('ids', ''))));
|
||||
if (empty($ids)) abort(400, 'No parts selected.');
|
||||
|
||||
$parts = Part::whereIn('id', $ids)->orderBy('name')->get();
|
||||
|
||||
$opts = new QROptions([
|
||||
'outputType' => QRCode::OUTPUT_MARKUP_SVG,
|
||||
'eccLevel' => QRCode::ECC_M,
|
||||
'scale' => 4,
|
||||
'imageBase64' => false,
|
||||
'addQuietzone' => true,
|
||||
]);
|
||||
|
||||
$labels = $parts->map(function (Part $p) use ($opts) {
|
||||
$payload = 'PART:' . ($p->article ?: $p->id);
|
||||
return [
|
||||
'part' => $p,
|
||||
'svg' => (new QRCode($opts))->render($payload),
|
||||
'payload' => $payload,
|
||||
];
|
||||
});
|
||||
|
||||
return view('parts.labels', ['labels' => $labels]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant\PushSubscription;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PushSubscriptionController extends Controller
|
||||
{
|
||||
public function subscribe(Request $request)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'endpoint' => 'required|string|max:500',
|
||||
'keys.p256dh' => 'required|string',
|
||||
'keys.auth' => 'required|string',
|
||||
'contentEncoding' => 'nullable|string|max:32',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
PushSubscription::updateOrCreate(
|
||||
['endpoint' => $data['endpoint']],
|
||||
[
|
||||
'company_id' => $user?->company_id,
|
||||
'user_id' => $user?->id,
|
||||
'public_key' => $data['keys']['p256dh'],
|
||||
'auth_token' => $data['keys']['auth'],
|
||||
'content_encoding' => $data['contentEncoding'] ?? 'aesgcm',
|
||||
'user_agent' => substr((string) $request->userAgent(), 0, 255),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
public function unsubscribe(Request $request)
|
||||
{
|
||||
$endpoint = $request->input('endpoint');
|
||||
if ($endpoint) {
|
||||
PushSubscription::where('endpoint', $endpoint)->delete();
|
||||
}
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant\OnlineOrder;
|
||||
use App\Models\Tenant\OnlineOrderItem;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Services\Ai\VinDecoder;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class ShopController extends Controller
|
||||
{
|
||||
private function tenantOrFail()
|
||||
{
|
||||
$tenant = app(TenantManager::class)->current();
|
||||
if (! $tenant) {
|
||||
throw new NotFoundHttpException('Magazinul e disponibil doar pe subdomeniul service-ului.');
|
||||
}
|
||||
if (! data_get($tenant->settings, 'shop.enabled')) {
|
||||
throw new NotFoundHttpException('Magazinul online nu este activ.');
|
||||
}
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function catalog(Request $request)
|
||||
{
|
||||
$tenant = $this->tenantOrFail();
|
||||
|
||||
$term = $request->query('q');
|
||||
$category = $request->query('cat');
|
||||
$inStock = $request->boolean('in_stock');
|
||||
|
||||
$query = Part::searchPublished($term);
|
||||
if ($category) $query->where('category', $category);
|
||||
if ($inStock) $query->where('qty', '>', 0);
|
||||
|
||||
$parts = $query->orderBy('name')->paginate(24)->withQueryString();
|
||||
$categories = Part::published()->distinct()->pluck('category')->filter()->sort()->values();
|
||||
|
||||
return view('shop.catalog', [
|
||||
'tenant' => $tenant,
|
||||
'parts' => $parts,
|
||||
'categories' => $categories,
|
||||
'term' => $term,
|
||||
'category' => $category,
|
||||
'inStock' => $inStock,
|
||||
'cartCount' => $this->cartCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function part(Request $request, int $id)
|
||||
{
|
||||
$tenant = $this->tenantOrFail();
|
||||
$part = Part::published()->with('crossRefs')->find($id);
|
||||
if (! $part) throw new NotFoundHttpException('Piesa nu există sau nu e publicată.');
|
||||
|
||||
return view('shop.part', [
|
||||
'tenant' => $tenant,
|
||||
'part' => $part,
|
||||
'cartCount' => $this->cartCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function vin(Request $request)
|
||||
{
|
||||
$tenant = $this->tenantOrFail();
|
||||
$vin = strtoupper(trim((string) $request->query('vin', '')));
|
||||
$decoded = null;
|
||||
if ($vin !== '') {
|
||||
$decoded = app(VinDecoder::class)->decode($vin);
|
||||
}
|
||||
|
||||
return view('shop.vin', [
|
||||
'tenant' => $tenant,
|
||||
'vin' => $vin,
|
||||
'decoded' => $decoded,
|
||||
'cartCount' => $this->cartCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Cart (session) ───────────────────────────────────────────
|
||||
|
||||
private function cartKey(): string
|
||||
{
|
||||
$tenant = app(TenantManager::class)->current();
|
||||
return 'shop_cart_' . ($tenant?->id ?? '0');
|
||||
}
|
||||
|
||||
private function cart(): array
|
||||
{
|
||||
return (array) session($this->cartKey(), []);
|
||||
}
|
||||
|
||||
private function cartCount(): int
|
||||
{
|
||||
return (int) collect($this->cart())->sum('qty');
|
||||
}
|
||||
|
||||
public function addToCart(Request $request, int $id)
|
||||
{
|
||||
$this->tenantOrFail();
|
||||
$part = Part::published()->findOrFail($id);
|
||||
$qty = max(1, (int) $request->input('qty', 1));
|
||||
|
||||
$cart = $this->cart();
|
||||
$cart[$id] = [
|
||||
'part_id' => $part->id,
|
||||
'name' => $part->name,
|
||||
'article' => $part->article,
|
||||
'price' => (float) $part->sell_price,
|
||||
'qty' => ($cart[$id]['qty'] ?? 0) + $qty,
|
||||
];
|
||||
session([$this->cartKey() => $cart]);
|
||||
|
||||
return redirect('/shop/cart');
|
||||
}
|
||||
|
||||
public function updateCart(Request $request)
|
||||
{
|
||||
$this->tenantOrFail();
|
||||
$cart = $this->cart();
|
||||
foreach ((array) $request->input('qty', []) as $id => $qty) {
|
||||
$qty = (int) $qty;
|
||||
if ($qty <= 0) {
|
||||
unset($cart[$id]);
|
||||
} elseif (isset($cart[$id])) {
|
||||
$cart[$id]['qty'] = $qty;
|
||||
}
|
||||
}
|
||||
session([$this->cartKey() => $cart]);
|
||||
return redirect('/shop/cart');
|
||||
}
|
||||
|
||||
public function showCart()
|
||||
{
|
||||
$tenant = $this->tenantOrFail();
|
||||
$cart = $this->cart();
|
||||
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
||||
|
||||
return view('shop.cart', [
|
||||
'tenant' => $tenant,
|
||||
'cart' => $cart,
|
||||
'subtotal' => $subtotal,
|
||||
'cartCount' => $this->cartCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function checkout()
|
||||
{
|
||||
$tenant = $this->tenantOrFail();
|
||||
$cart = $this->cart();
|
||||
if (empty($cart)) return redirect('/shop');
|
||||
|
||||
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
||||
|
||||
return view('shop.checkout', [
|
||||
'tenant' => $tenant,
|
||||
'cart' => $cart,
|
||||
'subtotal' => $subtotal,
|
||||
'deliveryOptions' => (array) data_get($tenant->settings, 'shop.delivery_methods', ['pickup']),
|
||||
'cartCount' => $this->cartCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function placeOrder(Request $request)
|
||||
{
|
||||
$tenant = $this->tenantOrFail();
|
||||
$cart = $this->cart();
|
||||
if (empty($cart)) return redirect('/shop');
|
||||
|
||||
$data = $request->validate([
|
||||
'customer_name' => 'required|string|max:160',
|
||||
'customer_phone' => 'required|string|max:40',
|
||||
'customer_email' => 'nullable|email|max:160',
|
||||
'delivery_method' => 'required|in:pickup,courier,post',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
]);
|
||||
|
||||
$deliveryFee = 0.0;
|
||||
$subtotal = collect($cart)->sum(fn ($i) => $i['price'] * $i['qty']);
|
||||
if ($data['delivery_method'] !== 'pickup') {
|
||||
$fee = (float) data_get($tenant->settings, 'shop.delivery_fee', 0);
|
||||
$freeOver = (float) data_get($tenant->settings, 'shop.free_delivery_over', 0);
|
||||
$deliveryFee = ($freeOver > 0 && $subtotal >= $freeOver) ? 0.0 : $fee;
|
||||
}
|
||||
|
||||
$order = DB::transaction(function () use ($tenant, $cart, $data, $deliveryFee) {
|
||||
$order = OnlineOrder::create([
|
||||
'number' => OnlineOrder::generateNumber($tenant->id),
|
||||
'customer_name' => $data['customer_name'],
|
||||
'customer_phone' => $data['customer_phone'],
|
||||
'customer_email' => $data['customer_email'] ?? null,
|
||||
'delivery_method' => $data['delivery_method'],
|
||||
'address' => $data['address'] ?? null,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
'status' => 'new',
|
||||
'delivery_fee' => $deliveryFee,
|
||||
]);
|
||||
|
||||
foreach ($cart as $item) {
|
||||
OnlineOrderItem::create([
|
||||
'online_order_id' => $order->id,
|
||||
'part_id' => $item['part_id'] ?? null,
|
||||
'name' => $item['name'],
|
||||
'article' => $item['article'] ?? null,
|
||||
'qty' => $item['qty'],
|
||||
'price' => $item['price'],
|
||||
]);
|
||||
}
|
||||
$order->refresh()->recalcTotal();
|
||||
return $order;
|
||||
});
|
||||
|
||||
session()->forget($this->cartKey());
|
||||
|
||||
// Notify (best-effort): customer + shop staff.
|
||||
try {
|
||||
app(\App\Services\Notifications\ShopOrderNotifier::class)->placed($order);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::debug('shop order notify skipped: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect('/shop/order/' . $order->tracking_token);
|
||||
}
|
||||
|
||||
public function orderStatus(Request $request, string $token)
|
||||
{
|
||||
$tenant = $this->tenantOrFail();
|
||||
$order = OnlineOrder::with('items')->where('tracking_token', $token)->first();
|
||||
if (! $order) throw new NotFoundHttpException('Comanda nu a fost găsită.');
|
||||
|
||||
return view('shop.order', [
|
||||
'tenant' => $tenant,
|
||||
'order' => $order,
|
||||
'cartCount' => $this->cartCount(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Services\Notifications\TelegramService;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Receives Telegram updates per tenant. URL: /telegram/webhook/{slug}
|
||||
*
|
||||
* To link a Telegram account to a Client record, the bot expects the user
|
||||
* to share their phone via Telegram's contact share button (Telegram lets
|
||||
* users send their own phone with one tap). We match the shared phone (or
|
||||
* the message text fallback) to clients.phone and persist chat_id.
|
||||
*/
|
||||
class TelegramWebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request, string $slug, TelegramService $telegram)
|
||||
{
|
||||
$company = Company::where('slug', $slug)->first();
|
||||
if (! $company) return response()->json(['ok' => false], 404);
|
||||
|
||||
$expectedSecret = $telegram->webhookSecretFor($company);
|
||||
$providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');
|
||||
if ($expectedSecret && $providedSecret !== $expectedSecret) {
|
||||
Log::warning('telegram.webhook bad secret', ['tenant' => $slug]);
|
||||
return response()->json(['ok' => false], 401);
|
||||
}
|
||||
|
||||
app(TenantManager::class)->setCurrent($company);
|
||||
|
||||
$message = $request->input('message', []);
|
||||
$chatId = (string) data_get($message, 'chat.id', '');
|
||||
if (! $chatId) {
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
$contact = data_get($message, 'contact');
|
||||
$text = trim((string) data_get($message, 'text', ''));
|
||||
|
||||
$client = null;
|
||||
$phoneRaw = null;
|
||||
|
||||
if ($contact) {
|
||||
$phoneRaw = data_get($contact, 'phone_number');
|
||||
} elseif (preg_match('/(\+?[0-9\-\s\(\)]{7,})/', $text, $m)) {
|
||||
$phoneRaw = $m[1];
|
||||
}
|
||||
|
||||
if ($phoneRaw) {
|
||||
$needle = Client::normalizePhone($phoneRaw);
|
||||
if ($needle) {
|
||||
$client = Client::whereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?",
|
||||
['%' . substr($needle, -9) . '%']
|
||||
)->first();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $client && $text === '/start') {
|
||||
$telegram->sendMessage($company, $chatId,
|
||||
'Salut! Pentru a primi notificări despre mașina ta, ' .
|
||||
'apasă butonul „Share contact" sau trimite numărul tău de telefon.'
|
||||
);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($client) {
|
||||
$client->telegram_chat_id = $chatId;
|
||||
$client->saveQuietly();
|
||||
|
||||
$name = $company->display_name ?? $company->name;
|
||||
$telegram->sendMessage($company, $chatId,
|
||||
"Te-am identificat — <b>{$client->name}</b>.\n" .
|
||||
"Vei primi aici notificări despre fișele tale de la <b>{$name}</b>.\n\n" .
|
||||
"Trimite /stop oricând ca să oprești notificările."
|
||||
);
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($text === '/stop') {
|
||||
Client::where('telegram_chat_id', $chatId)->update(['telegram_chat_id' => null]);
|
||||
$telegram->sendMessage($company, $chatId, 'Notificările au fost oprite.');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($phoneRaw) {
|
||||
$telegram->sendMessage($company, $chatId,
|
||||
"Nu am găsit un client cu acest număr la {$company->name}. " .
|
||||
"Verifică telefonul sau contactează service-ul."
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
class TrackingController extends Controller
|
||||
{
|
||||
/**
|
||||
* Public WO tracking page — accessed via QR code or SMS link.
|
||||
* Tenant is resolved by ResolveTenant from the host, so the global
|
||||
* BelongsToTenant scope already filters to the correct tenant.
|
||||
*/
|
||||
public function show(Request $request, string $token)
|
||||
{
|
||||
$tenant = app(TenantManager::class)->current();
|
||||
if (! $tenant) {
|
||||
throw new NotFoundHttpException('Tracking only available on tenant subdomain.');
|
||||
}
|
||||
|
||||
$wo = WorkOrder::with(['client', 'vehicle', 'master', 'media'])
|
||||
->where('tracking_token', $token)
|
||||
->first();
|
||||
|
||||
if (! $wo) {
|
||||
throw new NotFoundHttpException('Fișa nu a fost găsită.');
|
||||
}
|
||||
|
||||
return view('tracking.show', [
|
||||
'wo' => $wo,
|
||||
'tenant' => $tenant,
|
||||
'photos' => $wo->getMedia('photos'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function qr(Request $request, string $token)
|
||||
{
|
||||
$tenant = app(TenantManager::class)->current();
|
||||
if (! $tenant) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
$wo = WorkOrder::where('tracking_token', $token)->first();
|
||||
if (! $wo) {
|
||||
throw new NotFoundHttpException();
|
||||
}
|
||||
|
||||
$options = new \chillerlan\QRCode\QROptions([
|
||||
'outputType' => \chillerlan\QRCode\QRCode::OUTPUT_MARKUP_SVG,
|
||||
'eccLevel' => \chillerlan\QRCode\QRCode::ECC_M,
|
||||
'scale' => 6,
|
||||
'imageBase64' => false,
|
||||
'svgViewBoxSize' => 200,
|
||||
'addQuietzone' => true,
|
||||
]);
|
||||
|
||||
$svg = (new \chillerlan\QRCode\QRCode($options))->render($wo->trackingUrl());
|
||||
|
||||
return response($svg, 200, [
|
||||
'Content-Type' => 'image/svg+xml',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ trait Auditable
|
||||
return LogOptions::defaults()
|
||||
->logFillable()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs()
|
||||
->dontLogEmptyChanges()
|
||||
->setDescriptionForEvent(fn (string $event) => match ($event) {
|
||||
'created' => 'creat',
|
||||
'updated' => 'modificat',
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
class BodyshopJob extends Model implements HasMedia
|
||||
{
|
||||
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
|
||||
|
||||
public const TYPES = [
|
||||
'body_repair' => 'Tinichigerie',
|
||||
'pdr' => 'PDR (fără vopsire)',
|
||||
'painting' => 'Vopsitorie',
|
||||
'detailing' => 'Detailing',
|
||||
'ceramic' => 'Ceramică',
|
||||
'ppf' => 'Folie PPF',
|
||||
'polishing' => 'Polish',
|
||||
];
|
||||
|
||||
public const STATUSES = [
|
||||
'estimate' => 'Deviz',
|
||||
'approved' => 'Aprobat',
|
||||
'in_progress' => 'În lucru',
|
||||
'done' => 'Finalizat',
|
||||
'delivered' => 'Predat',
|
||||
'cancelled' => 'Anulat',
|
||||
];
|
||||
|
||||
public const INSURANCE_STATUSES = [
|
||||
'submitted' => 'Depus',
|
||||
'approved' => 'Aprobat',
|
||||
'rejected' => 'Respins',
|
||||
'paid' => 'Plătit',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'work_order_id', 'client_id', 'vehicle_id',
|
||||
'number', 'type', 'status',
|
||||
'is_insurance', 'insurer', 'policy_no', 'claim_no', 'insurance_status',
|
||||
'estimate_amount', 'approved_amount', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_insurance' => 'boolean',
|
||||
'estimate_amount' => 'decimal:2',
|
||||
'approved_amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos_before');
|
||||
$this->addMediaCollection('photos_after');
|
||||
}
|
||||
|
||||
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 damagePoints(): HasMany
|
||||
{
|
||||
return $this->hasMany(DamagePoint::class);
|
||||
}
|
||||
|
||||
public static function generateNumber(int $companyId): string
|
||||
{
|
||||
$year = date('y');
|
||||
$count = static::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereYear('created_at', date('Y'))
|
||||
->count();
|
||||
return sprintf('BS-%s-%04d', $year, $count + 1);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $job) {
|
||||
if (empty($job->number)) {
|
||||
$job->number = static::generateNumber(
|
||||
$job->company_id ?: app(\App\Tenancy\TenantManager::class)->currentId()
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,9 @@ class Client extends Model
|
||||
protected $fillable = [
|
||||
'company_id', 'type', 'name', 'company_name',
|
||||
'phone', 'phone_alt', 'email',
|
||||
'telegram', 'whatsapp', 'viber',
|
||||
'source', 'marketing_channel', 'status',
|
||||
'telegram', 'telegram_chat_id', 'whatsapp', 'viber',
|
||||
'notify_prefs',
|
||||
'source', 'marketing_channel', 'status', 'is_vip',
|
||||
'balance', 'discount_pct', 'notes',
|
||||
'assigned_to', 'last_contact_at',
|
||||
];
|
||||
@@ -26,8 +27,18 @@ class Client extends Model
|
||||
'balance' => 'decimal:2',
|
||||
'discount_pct' => 'decimal:2',
|
||||
'last_contact_at' => 'datetime',
|
||||
'notify_prefs' => 'array',
|
||||
'is_vip' => 'boolean',
|
||||
];
|
||||
|
||||
/** Normalize a phone number to E.164-ish digits for matching. */
|
||||
public static function normalizePhone(?string $phone): ?string
|
||||
{
|
||||
if (! $phone) return null;
|
||||
$digits = preg_replace('/[^0-9]/', '', $phone);
|
||||
return $digits ?: null;
|
||||
}
|
||||
|
||||
public function vehicles(): HasMany
|
||||
{
|
||||
return $this->hasMany(Vehicle::class);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DamagePoint extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public const ZONES = [
|
||||
'Bară față', 'Capotă', 'Aripă FS', 'Aripă FD',
|
||||
'Ușă FS', 'Ușă FD', 'Ușă SS', 'Ușă SD',
|
||||
'Aripă SS', 'Aripă SD', 'Bară spate', 'Portbagaj',
|
||||
'Plafon', 'Parbriz', 'Lunetă', 'Prag S', 'Prag D',
|
||||
'Oglindă S', 'Oglindă D', 'Jantă',
|
||||
];
|
||||
|
||||
public const KINDS = [
|
||||
'Zgârietură', 'Lovitură', 'Fisură', 'Rugină', 'Vopsea sărită', 'Spart',
|
||||
];
|
||||
|
||||
public const SEVERITIES = [
|
||||
'minor' => 'Minoră',
|
||||
'medium' => 'Medie',
|
||||
'severe' => 'Gravă',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'bodyshop_job_id', 'zone', 'kind', 'severity', 'notes',
|
||||
];
|
||||
|
||||
public function job(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BodyshopJob::class, 'bodyshop_job_id');
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Labor extends Model
|
||||
@@ -15,14 +16,34 @@ class Labor extends Model
|
||||
'Caroserie', 'Electrică', 'Climatizare', 'Eșapament', 'Altele',
|
||||
];
|
||||
|
||||
public const PRICING_MODES = [
|
||||
'hourly' => 'Pe oră (normă × tarif)',
|
||||
'fixed' => 'Preț fix',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'category', 'name_ro', 'name_ru', 'code',
|
||||
'hours', 'price', 'is_active', 'notes',
|
||||
'hours', 'pricing_mode', 'fixed_price', 'price', 'is_active', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'hours' => 'decimal:2',
|
||||
'fixed_price' => 'decimal:2',
|
||||
'price' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function laborParts(): HasMany
|
||||
{
|
||||
return $this->hasMany(LaborPart::class);
|
||||
}
|
||||
|
||||
/** Effective line total for this labor given the tenant hourly rate. */
|
||||
public function effectiveTotal(float $hourlyRate): float
|
||||
{
|
||||
if ($this->pricing_mode === 'fixed') {
|
||||
return (float) $this->fixed_price;
|
||||
}
|
||||
return round((float) $this->hours * $hourlyRate, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class LaborPart extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['company_id', 'labor_id', 'part_id', 'qty', 'unit'];
|
||||
|
||||
protected $casts = ['qty' => 'decimal:2'];
|
||||
|
||||
public function labor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Labor::class);
|
||||
}
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OnlineOrder extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
public const STATUSES = [
|
||||
'new' => 'Nouă',
|
||||
'confirmed' => 'Confirmată',
|
||||
'packed' => 'Pregătită',
|
||||
'shipped' => 'Expediată',
|
||||
'delivered' => 'Livrată',
|
||||
'cancelled' => 'Anulată',
|
||||
];
|
||||
|
||||
public const DELIVERY = [
|
||||
'pickup' => 'Ridicare din service',
|
||||
'courier' => 'Curier',
|
||||
'post' => 'Poștă',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'number', 'tracking_token', 'client_id',
|
||||
'customer_name', 'customer_phone', 'customer_email',
|
||||
'delivery_method', 'address', 'status',
|
||||
'subtotal', 'delivery_fee', 'total', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'subtotal' => 'decimal:2',
|
||||
'delivery_fee' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(OnlineOrderItem::class);
|
||||
}
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function trackingUrl(): string
|
||||
{
|
||||
return url('/shop/order/' . $this->tracking_token);
|
||||
}
|
||||
|
||||
public function recalcTotal(): void
|
||||
{
|
||||
$this->subtotal = (float) $this->items()->sum('total');
|
||||
$this->total = round((float) $this->subtotal + (float) $this->delivery_fee, 2);
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public static function generateNumber(int $companyId): string
|
||||
{
|
||||
$year = date('y');
|
||||
$count = static::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereYear('created_at', date('Y'))
|
||||
->count();
|
||||
return sprintf('SO-%s-%04d', $year, $count + 1);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $o) {
|
||||
if (empty($o->tracking_token)) {
|
||||
$o->tracking_token = Str::random(24);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class OnlineOrderItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'online_order_id', 'part_id',
|
||||
'name', 'article', 'qty', 'price', 'total', 'fulfilled',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty' => 'decimal:2',
|
||||
'price' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'fulfilled' => 'boolean',
|
||||
];
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(OnlineOrder::class, 'online_order_id');
|
||||
}
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $row) {
|
||||
$row->total = round((float) $row->qty * (float) $row->price, 2);
|
||||
});
|
||||
static::saved(fn (self $row) => $row->order?->recalcTotal());
|
||||
static::deleted(fn (self $row) => $row->order?->recalcTotal());
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models\Tenant;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Part extends Model
|
||||
@@ -18,18 +19,20 @@ class Part extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'name', 'article', 'brand', 'category',
|
||||
'qty', 'unit', 'min_qty',
|
||||
'qty', 'qty_reserved', 'unit', 'min_qty',
|
||||
'buy_price', 'sell_price',
|
||||
'location', 'barcode', 'preferred_supplier_id',
|
||||
'is_active', 'notes',
|
||||
'is_active', 'is_published', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty' => 'decimal:2',
|
||||
'qty_reserved' => 'decimal:3',
|
||||
'min_qty' => 'decimal:2',
|
||||
'buy_price' => 'decimal:2',
|
||||
'sell_price' => 'decimal:2',
|
||||
'is_active' => 'boolean',
|
||||
'is_published' => 'boolean',
|
||||
];
|
||||
|
||||
public function preferredSupplier(): BelongsTo
|
||||
@@ -37,6 +40,69 @@ class Part extends Model
|
||||
return $this->belongsTo(Supplier::class, 'preferred_supplier_id');
|
||||
}
|
||||
|
||||
public function batches(): HasMany
|
||||
{
|
||||
return $this->hasMany(PartBatch::class);
|
||||
}
|
||||
|
||||
public function reservations(): HasMany
|
||||
{
|
||||
return $this->hasMany(PartReservation::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(WarehouseEvent::class);
|
||||
}
|
||||
|
||||
public function priceHistory(): HasMany
|
||||
{
|
||||
return $this->hasMany(SupplierPartPrice::class);
|
||||
}
|
||||
|
||||
public function crossRefs(): HasMany
|
||||
{
|
||||
return $this->hasMany(PartCrossRef::class);
|
||||
}
|
||||
|
||||
public function scopePublished($q)
|
||||
{
|
||||
return $q->where('is_active', true)->where('is_published', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search published parts by free text against name / article / brand and
|
||||
* any registered cross-reference article. Returns a query builder.
|
||||
*/
|
||||
public static function searchPublished(?string $term)
|
||||
{
|
||||
$q = static::published();
|
||||
if ($term = trim((string) $term)) {
|
||||
$like = '%' . $term . '%';
|
||||
$q->where(function ($w) use ($like, $term) {
|
||||
$w->where('name', 'like', $like)
|
||||
->orWhere('article', 'like', $like)
|
||||
->orWhere('brand', 'like', $like)
|
||||
->orWhereHas('crossRefs', fn ($c) => $c->where('cross_article', 'like', $like));
|
||||
});
|
||||
}
|
||||
return $q;
|
||||
}
|
||||
|
||||
/** Live total across all batches of all warehouses (source of truth). */
|
||||
public function qtyOnHand(?int $warehouseId = null): float
|
||||
{
|
||||
$q = $this->batches()->newQuery()->where('part_id', $this->id);
|
||||
if ($warehouseId) $q->where('warehouse_id', $warehouseId);
|
||||
return (float) $q->sum('qty_remaining');
|
||||
}
|
||||
|
||||
/** Available for new reservations = on hand − already reserved. */
|
||||
public function qtyAvailable(?int $warehouseId = null): float
|
||||
{
|
||||
return max(0.0, $this->qtyOnHand($warehouseId) - (float) $this->qty_reserved);
|
||||
}
|
||||
|
||||
public function isLow(): bool
|
||||
{
|
||||
return (float) $this->qty <= (float) $this->min_qty;
|
||||
@@ -47,6 +113,11 @@ class Part extends Model
|
||||
return (float) $this->qty <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy direct-stock adjustment.
|
||||
* NOTE: this only moves the cached `qty` column. Real stock changes
|
||||
* should go through WarehouseService so batches + events stay in sync.
|
||||
*/
|
||||
public function adjustStock(float $delta, ?string $reason = null): void
|
||||
{
|
||||
$this->qty = max(0, (float) $this->qty + $delta);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class PartBatch extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'part_id', 'warehouse_id', 'supplier_id',
|
||||
'batch_ref', 'qty_in', 'qty_remaining', 'buy_price',
|
||||
'received_at', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty_in' => 'decimal:3',
|
||||
'qty_remaining' => 'decimal:3',
|
||||
'buy_price' => 'decimal:2',
|
||||
'received_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function reservations(): HasMany
|
||||
{
|
||||
return $this->hasMany(PartReservation::class, 'batch_id');
|
||||
}
|
||||
|
||||
public function isDepleted(): bool
|
||||
{
|
||||
return (float) $this->qty_remaining <= 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PartCrossRef extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['company_id', 'part_id', 'cross_article', 'brand'];
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PartReservation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
public const STATUS_CONSUMED = 'consumed';
|
||||
public const STATUS_RELEASED = 'released';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'work_order_id', 'work_order_part_id',
|
||||
'part_id', 'batch_id', 'qty', 'status',
|
||||
'reserved_at', 'consumed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty' => 'decimal:3',
|
||||
'reserved_at' => 'datetime',
|
||||
'consumed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function workOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrder::class);
|
||||
}
|
||||
|
||||
public function workOrderPart(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrderPart::class);
|
||||
}
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
|
||||
public function batch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PartBatch::class, 'batch_id');
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class PricingCoefficient extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public const VEHICLE_CLASSES = [
|
||||
'sedan' => 'Sedan / Hatchback',
|
||||
'suv' => 'SUV / Crossover',
|
||||
'commercial' => 'Comercial (van/camion)',
|
||||
'hybrid' => 'Hibrid',
|
||||
'ev' => 'Electric (EV)',
|
||||
'premium' => 'Premium / Lux',
|
||||
];
|
||||
|
||||
public const URGENCY = [
|
||||
'normal' => 'Normal',
|
||||
'urgent' => 'Urgent',
|
||||
'express' => 'Express',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'name', 'multiplier', 'conditions',
|
||||
'priority', 'stackable', 'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'multiplier' => 'decimal:3',
|
||||
'conditions' => 'array',
|
||||
'stackable' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
/**
|
||||
* Does this coefficient apply to the given pricing context?
|
||||
*
|
||||
* @param array{class?:?string, age?:?int, vip?:bool, urgency?:string} $ctx
|
||||
*/
|
||||
public function matches(array $ctx): bool
|
||||
{
|
||||
$c = (array) $this->conditions;
|
||||
|
||||
// Vehicle class — if rule lists classes, context class must be among them.
|
||||
$classes = (array) ($c['classes'] ?? []);
|
||||
if (! empty($classes)) {
|
||||
if (empty($ctx['class']) || ! in_array($ctx['class'], $classes, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Vehicle age range.
|
||||
if (isset($c['age_min']) && $c['age_min'] !== null && $c['age_min'] !== '') {
|
||||
if (($ctx['age'] ?? null) === null || $ctx['age'] < (int) $c['age_min']) return false;
|
||||
}
|
||||
if (isset($c['age_max']) && $c['age_max'] !== null && $c['age_max'] !== '') {
|
||||
if (($ctx['age'] ?? null) === null || $ctx['age'] > (int) $c['age_max']) return false;
|
||||
}
|
||||
|
||||
// VIP requirement (true = only VIP, false/null = ignore).
|
||||
if (! empty($c['client_vip'])) {
|
||||
if (empty($ctx['vip'])) return false;
|
||||
}
|
||||
|
||||
// Urgency — if rule lists urgencies, context must match.
|
||||
$urg = (array) ($c['urgency'] ?? []);
|
||||
if (! empty($urg)) {
|
||||
if (empty($ctx['urgency']) || ! in_array($ctx['urgency'], $urg, true)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,13 @@ class Purchase extends Model
|
||||
public const STATUSES = [
|
||||
'draft' => 'Ciornă',
|
||||
'ordered' => 'Comandată',
|
||||
'partial' => 'Parțial recepționată',
|
||||
'received' => 'Recepționată',
|
||||
'cancelled' => 'Anulată',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'number', 'supplier_id',
|
||||
'company_id', 'number', 'supplier_id', 'warehouse_id',
|
||||
'order_date', 'expected_at', 'received_at', 'paid_at',
|
||||
'status', 'total', 'notes',
|
||||
];
|
||||
@@ -38,6 +39,11 @@ class Purchase extends Model
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseItem::class);
|
||||
@@ -60,24 +66,101 @@ class Purchase extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all items received and increment Part.qty for linked items.
|
||||
* Receive a specific item — qty of buy_price unit cost into target warehouse.
|
||||
* Routes through WarehouseService so a batch is created + receipt event written.
|
||||
* Also records the supplier price for analytics.
|
||||
*/
|
||||
public function markReceived(): void
|
||||
public function receiveItem(PurchaseItem $item, float $qty, ?Warehouse $warehouse = null): void
|
||||
{
|
||||
\Illuminate\Support\Facades\DB::transaction(function () {
|
||||
foreach ($this->items as $item) {
|
||||
if (! $item->received) {
|
||||
if ($item->part_id) {
|
||||
$part = Part::find($item->part_id);
|
||||
$part?->adjustStock((float) $item->qty);
|
||||
if ($qty <= 0) {
|
||||
throw new \InvalidArgumentException('Cantitatea de recepție trebuie să fie pozitivă.');
|
||||
}
|
||||
$outstanding = (float) $item->qty - (float) $item->qty_received;
|
||||
if ($qty > $outstanding + 0.001) {
|
||||
throw new \InvalidArgumentException(sprintf(
|
||||
'Cantitate prea mare: cerut %.2f, restanță %.2f',
|
||||
$qty, $outstanding
|
||||
));
|
||||
}
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($item, $qty, $warehouse) {
|
||||
$warehouse ??= $this->warehouse;
|
||||
if (! $warehouse) {
|
||||
$warehouse = app(\App\Services\Warehouse\WarehouseService::class)
|
||||
->defaultWarehouse($this->company_id);
|
||||
}
|
||||
|
||||
if ($item->part_id) {
|
||||
$part = Part::find($item->part_id);
|
||||
if ($part) {
|
||||
app(\App\Services\Warehouse\WarehouseService::class)->receive(
|
||||
part: $part,
|
||||
qty: $qty,
|
||||
buyPrice: (float) $item->buy_price,
|
||||
warehouse: $warehouse,
|
||||
supplier: $this->supplier,
|
||||
batchRef: $this->number,
|
||||
ref: $this,
|
||||
notes: "PO #{$this->number}",
|
||||
);
|
||||
|
||||
if ($this->supplier_id) {
|
||||
SupplierPartPrice::create([
|
||||
'supplier_id' => $this->supplier_id,
|
||||
'part_id' => $part->id,
|
||||
'purchase_id' => $this->id,
|
||||
'price' => (float) $item->buy_price,
|
||||
'currency' => 'MDL',
|
||||
'observed_at' => now(),
|
||||
]);
|
||||
}
|
||||
$item->received = true;
|
||||
$item->save();
|
||||
}
|
||||
}
|
||||
$this->status = 'received';
|
||||
$this->received_at = now();
|
||||
$this->save();
|
||||
|
||||
$item->qty_received = (float) $item->qty_received + $qty;
|
||||
if ((float) $item->qty_received >= (float) $item->qty) {
|
||||
$item->received = true;
|
||||
}
|
||||
$item->save();
|
||||
|
||||
$this->recomputeStatus();
|
||||
});
|
||||
}
|
||||
|
||||
/** Convenience: receive every outstanding item in full. */
|
||||
public function receiveAllRemaining(?Warehouse $warehouse = null): void
|
||||
{
|
||||
foreach ($this->items()->get() as $item) {
|
||||
$outstanding = (float) $item->qty - (float) $item->qty_received;
|
||||
if ($outstanding > 0) {
|
||||
$this->receiveItem($item, $outstanding, $warehouse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Recalculate status based on item qty_received vs qty. */
|
||||
public function recomputeStatus(): void
|
||||
{
|
||||
if ($this->status === 'cancelled' || $this->status === 'draft') {
|
||||
return;
|
||||
}
|
||||
$items = $this->items()->get();
|
||||
if ($items->isEmpty()) return;
|
||||
|
||||
$totals = $items->reduce(function ($carry, $i) {
|
||||
$carry['ordered'] += (float) $i->qty;
|
||||
$carry['received'] += (float) $i->qty_received;
|
||||
return $carry;
|
||||
}, ['ordered' => 0.0, 'received' => 0.0]);
|
||||
|
||||
if ($totals['received'] <= 0) {
|
||||
$this->status = 'ordered';
|
||||
} elseif ($totals['received'] + 0.001 < $totals['ordered']) {
|
||||
$this->status = 'partial';
|
||||
} else {
|
||||
$this->status = 'received';
|
||||
if (! $this->received_at) $this->received_at = now();
|
||||
}
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,27 @@ class PurchaseItem extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'purchase_id', 'part_id',
|
||||
'name', 'article', 'qty', 'unit', 'buy_price', 'total', 'received',
|
||||
'name', 'article', 'qty', 'qty_received', 'unit', 'buy_price', 'total', 'received',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty' => 'decimal:2',
|
||||
'qty_received' => 'decimal:2',
|
||||
'buy_price' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
'received' => 'boolean',
|
||||
];
|
||||
|
||||
public function isFullyReceived(): bool
|
||||
{
|
||||
return (float) $this->qty_received + 0.001 >= (float) $this->qty;
|
||||
}
|
||||
|
||||
public function outstanding(): float
|
||||
{
|
||||
return max(0.0, (float) $this->qty - (float) $this->qty_received);
|
||||
}
|
||||
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PushSubscription extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'user_id', 'endpoint',
|
||||
'public_key', 'auth_token', 'content_encoding', 'user_agent',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServiceReminderSent extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'service_reminders_sent';
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'vehicle_id', 'client_id',
|
||||
'channel', 'type', 'sent_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function vehicle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Vehicle::class);
|
||||
}
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class ServiceTemplate extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = ['company_id', 'name', 'category', 'notes', 'is_active'];
|
||||
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(ServiceTemplateItem::class);
|
||||
}
|
||||
|
||||
public function laborItems(): HasMany
|
||||
{
|
||||
return $this->items()->where('kind', 'labor');
|
||||
}
|
||||
|
||||
public function partItems(): HasMany
|
||||
{
|
||||
return $this->items()->where('kind', 'part');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ServiceTemplateItem extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public const KINDS = ['labor' => 'Manoperă', 'part' => 'Piesă'];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'service_template_id', 'kind',
|
||||
'labor_id', 'part_id', 'name', 'qty', 'hours',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty' => 'decimal:2',
|
||||
'hours' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function template(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ServiceTemplate::class, 'service_template_id');
|
||||
}
|
||||
|
||||
public function labor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Labor::class);
|
||||
}
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class SubcontractJob extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
public const STATUSES = [
|
||||
'sent' => 'Trimis',
|
||||
'in_progress' => 'În lucru',
|
||||
'done' => 'Gata',
|
||||
'returned' => 'Returnat',
|
||||
'cancelled' => 'Anulat',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'work_order_id', 'subcontractor_id',
|
||||
'number', 'category', 'description',
|
||||
'cost', 'markup_pct', 'client_price',
|
||||
'status', 'sent_at', 'eta', 'returned_at', 'paid_to_sub', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'cost' => 'decimal:2',
|
||||
'markup_pct' => 'decimal:2',
|
||||
'client_price' => 'decimal:2',
|
||||
'sent_at' => 'date',
|
||||
'eta' => 'date',
|
||||
'returned_at' => 'date',
|
||||
'paid_to_sub' => 'boolean',
|
||||
];
|
||||
|
||||
public function workOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(WorkOrder::class);
|
||||
}
|
||||
|
||||
public function subcontractor(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Subcontractor::class);
|
||||
}
|
||||
|
||||
/** Our margin = what we bill the client − what the sub charges us. */
|
||||
public function margin(): float
|
||||
{
|
||||
return round((float) $this->client_price - (float) $this->cost, 2);
|
||||
}
|
||||
|
||||
public static function generateNumber(int $companyId): string
|
||||
{
|
||||
$year = date('y');
|
||||
$count = static::withoutGlobalScopes()
|
||||
->where('company_id', $companyId)
|
||||
->whereYear('created_at', date('Y'))
|
||||
->count();
|
||||
return sprintf('SC-%s-%04d', $year, $count + 1);
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $job) {
|
||||
if (empty($job->number)) {
|
||||
$job->number = static::generateNumber(
|
||||
$job->company_id ?: app(\App\Tenancy\TenantManager::class)->currentId()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
static::saving(function (self $job) {
|
||||
// markup drives client_price unless markup is zero (then keep manual price).
|
||||
if ((float) $job->markup_pct > 0) {
|
||||
$job->client_price = round((float) $job->cost * (1 + (float) $job->markup_pct / 100), 2);
|
||||
}
|
||||
});
|
||||
|
||||
static::saved(fn (self $job) => $job->workOrder?->recalcTotal());
|
||||
static::deleted(fn (self $job) => $job->workOrder?->recalcTotal());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Subcontractor extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
public const SPECIALTIES = [
|
||||
'Turbo', 'Cutie viteze', 'Variator', 'Casetă direcție',
|
||||
'PDR', 'Vopsitorie', 'Electronică', 'Injectoare', 'Altele',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'name', 'specialty', 'phone', 'email',
|
||||
'rating', 'is_active', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function jobs(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubcontractJob::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SupplierPartPrice extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'supplier_id', 'part_id', 'purchase_id',
|
||||
'price', 'currency', 'observed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'observed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function supplier(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Supplier::class);
|
||||
}
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
|
||||
public function purchase(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Purchase::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
class TireSet extends Model implements HasMedia
|
||||
{
|
||||
use BelongsToTenant, InteractsWithMedia, SoftDeletes;
|
||||
|
||||
public const SEASONS = [
|
||||
'summer' => 'Vară',
|
||||
'winter' => 'Iarnă',
|
||||
'allseason' => 'All-season',
|
||||
];
|
||||
|
||||
public const CONDITIONS = [
|
||||
'nou' => 'Nou',
|
||||
'bun' => 'Bun',
|
||||
'uzat' => 'Uzat',
|
||||
'critic' => 'Critic',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'client_id', 'vehicle_id',
|
||||
'label', 'season',
|
||||
'width', 'profile', 'diameter', 'brand', 'model', 'dot_year',
|
||||
'has_rims', 'rim_type',
|
||||
'tread', 'tread_min', 'tpms', 'tpms_ids',
|
||||
'condition', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'tread' => 'array',
|
||||
'tpms_ids' => 'array',
|
||||
'tread_min' => 'decimal:1',
|
||||
'has_rims' => 'boolean',
|
||||
'tpms' => 'boolean',
|
||||
];
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
public function vehicle(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Vehicle::class);
|
||||
}
|
||||
|
||||
public function storage(): HasMany
|
||||
{
|
||||
return $this->hasMany(TireStorage::class);
|
||||
}
|
||||
|
||||
public function currentStorage(): ?TireStorage
|
||||
{
|
||||
return $this->storage()->where('status', 'stored')->latest('checked_in_at')->first();
|
||||
}
|
||||
|
||||
public function isStored(): bool
|
||||
{
|
||||
return $this->storage()->where('status', 'stored')->exists();
|
||||
}
|
||||
|
||||
public function sizeLabel(): string
|
||||
{
|
||||
if (! $this->width || ! $this->profile || ! $this->diameter) {
|
||||
return '—';
|
||||
}
|
||||
return "{$this->width}/{$this->profile} R{$this->diameter}";
|
||||
}
|
||||
|
||||
/** Recompute tread_min from the per-position tread JSON. */
|
||||
public function recomputeTreadMin(): void
|
||||
{
|
||||
$vals = array_filter(array_map('floatval', array_values((array) $this->tread)), fn ($v) => $v > 0);
|
||||
$this->tread_min = $vals ? min($vals) : null;
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (self $set) {
|
||||
$set->recomputeTreadMin();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TireStorage extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $table = 'tire_storage';
|
||||
|
||||
public const STATUSES = [
|
||||
'stored' => 'În depozit',
|
||||
'retrieved' => 'Ridicat',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'tire_set_id',
|
||||
'location', 'season_label', 'status',
|
||||
'checked_in_at', 'checked_out_at', 'fee', 'paid', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'checked_in_at' => 'datetime',
|
||||
'checked_out_at' => 'datetime',
|
||||
'fee' => 'decimal:2',
|
||||
'paid' => 'boolean',
|
||||
];
|
||||
|
||||
public function tireSet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TireSet::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->status === 'stored';
|
||||
}
|
||||
|
||||
public function durationDays(): int
|
||||
{
|
||||
$end = $this->checked_out_at ?? now();
|
||||
return (int) $this->checked_in_at?->diffInDays($end);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class Vehicle extends Model
|
||||
protected $fillable = [
|
||||
'company_id', 'client_id',
|
||||
'make', 'model', 'year', 'vin', 'plate',
|
||||
'engine', 'gearbox', 'fuel', 'mileage', 'color', 'notes',
|
||||
'engine', 'gearbox', 'fuel', 'vehicle_class', 'mileage', 'color', 'notes',
|
||||
];
|
||||
|
||||
public function client(): BelongsTo
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class Warehouse extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'code', 'name', 'address', 'is_default', 'is_active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_default' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
|
||||
public function batches(): HasMany
|
||||
{
|
||||
return $this->hasMany(PartBatch::class);
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(WarehouseEvent::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Tenant;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Immutable ledger of every stock movement. The qty/cost reality of the
|
||||
* warehouse can always be reconstructed by aggregating these events.
|
||||
*/
|
||||
class WarehouseEvent extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
public const TYPES = [
|
||||
'opening' => 'Stoc inițial',
|
||||
'receipt' => 'Recepție',
|
||||
'issue' => 'Consum',
|
||||
'transfer_out' => 'Transfer (ieșire)',
|
||||
'transfer_in' => 'Transfer (intrare)',
|
||||
'adjustment' => 'Ajustare',
|
||||
'write_off' => 'Casare',
|
||||
'return' => 'Retur',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'company_id', 'part_id', 'batch_id', 'warehouse_id',
|
||||
'type', 'qty_delta', 'unit_cost',
|
||||
'ref_type', 'ref_id', 'user_id',
|
||||
'occurred_at', 'notes',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'qty_delta' => 'decimal:3',
|
||||
'unit_cost' => 'decimal:2',
|
||||
'occurred_at' => 'datetime',
|
||||
];
|
||||
|
||||
public $timestamps = true;
|
||||
|
||||
public function part(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Part::class);
|
||||
}
|
||||
|
||||
public function batch(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PartBatch::class, 'batch_id');
|
||||
}
|
||||
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function ref(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
class WorkOrder extends Model
|
||||
class WorkOrder extends Model implements HasMedia
|
||||
{
|
||||
use Auditable, BelongsToTenant, SoftDeletes;
|
||||
use Auditable, BelongsToTenant, InteractsWithMedia, SoftDeletes;
|
||||
|
||||
public const STATUSES = [
|
||||
'new' => 'Nou',
|
||||
@@ -36,19 +38,31 @@ class WorkOrder extends Model
|
||||
'client_id', 'vehicle_id', 'master_id', 'deal_id', 'appointment_id',
|
||||
'opened_at', 'closed_at', 'mileage_in', 'mileage_out',
|
||||
'complaint', 'diagnosis', 'recommendations',
|
||||
'status', 'pay_status', 'approved', 'approved_at',
|
||||
'status', 'urgency', 'pay_status', 'approved', 'approved_at',
|
||||
'discount_pct', 'total',
|
||||
'eta_at', 'tracking_token',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'opened_at' => 'date',
|
||||
'closed_at' => 'date',
|
||||
'approved_at' => 'datetime',
|
||||
'eta_at' => 'datetime',
|
||||
'approved' => 'boolean',
|
||||
'discount_pct' => 'decimal:2',
|
||||
'total' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('photos');
|
||||
}
|
||||
|
||||
public function trackingUrl(): string
|
||||
{
|
||||
return url('/t/' . $this->tracking_token);
|
||||
}
|
||||
|
||||
public function client(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Client::class);
|
||||
@@ -79,6 +93,11 @@ class WorkOrder extends Model
|
||||
return $this->hasMany(Payment::class);
|
||||
}
|
||||
|
||||
public function subcontractJobs(): HasMany
|
||||
{
|
||||
return $this->hasMany(SubcontractJob::class);
|
||||
}
|
||||
|
||||
public function paidAmount(): float
|
||||
{
|
||||
return (float) $this->payments()->sum('amount');
|
||||
@@ -93,7 +112,10 @@ class WorkOrder extends Model
|
||||
{
|
||||
$worksTotal = $this->works()->sum('total');
|
||||
$partsTotal = $this->parts()->sum('total');
|
||||
$sub = (float) $worksTotal + (float) $partsTotal;
|
||||
$subcontractTotal = $this->subcontractJobs()
|
||||
->where('status', '!=', 'cancelled')
|
||||
->sum('client_price');
|
||||
$sub = (float) $worksTotal + (float) $partsTotal + (float) $subcontractTotal;
|
||||
$disc = (float) $this->discount_pct;
|
||||
$this->total = round($sub * (1 - $disc / 100), 2);
|
||||
$this->save();
|
||||
@@ -112,6 +134,12 @@ class WorkOrder extends Model
|
||||
/** Auto-send 'ready' email + broadcast WS event on status change. */
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (self $wo) {
|
||||
if (empty($wo->tracking_token)) {
|
||||
$wo->tracking_token = \Illuminate\Support\Str::random(24);
|
||||
}
|
||||
});
|
||||
|
||||
static::updated(function (self $wo) {
|
||||
if (
|
||||
$wo->wasChanged('status')
|
||||
@@ -121,6 +149,35 @@ class WorkOrder extends Model
|
||||
app(\App\Services\NotificationDispatcher::class)->workOrderReady($wo);
|
||||
}
|
||||
|
||||
// Push the assigned mechanic when a WO gets assigned to them.
|
||||
if ($wo->wasChanged('master_id') && $wo->master_id) {
|
||||
try {
|
||||
app(\App\Services\Notifications\WebPushService::class)->sendToUser(
|
||||
(int) $wo->master_id,
|
||||
'Fișă nouă atribuită',
|
||||
"Fișa #{$wo->number} · " . ($wo->vehicle?->plate ?? ''),
|
||||
'/app/resources/work-orders/' . $wo->id . '/edit',
|
||||
'wo-assign-' . $wo->id,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::debug('WO assign push skipped: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Warehouse lifecycle: status=done → consume reservations into issues;
|
||||
// status=cancelled → release reservations.
|
||||
if ($wo->wasChanged('status')) {
|
||||
$svc = app(\App\Services\Warehouse\WarehouseService::class);
|
||||
if ($wo->status === 'done' && $wo->getOriginal('status') !== 'done') {
|
||||
$svc->consume($wo);
|
||||
}
|
||||
if ($wo->status === 'cancelled' && $wo->getOriginal('status') !== 'cancelled') {
|
||||
foreach ($wo->parts as $wop) {
|
||||
$svc->release($wop);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast real-time update on any field change (skip if broadcasting=log).
|
||||
if (config('broadcasting.default') !== 'log') {
|
||||
try {
|
||||
|
||||
@@ -52,21 +52,37 @@ class WorkOrderPart extends Model
|
||||
$row->total = round($sub * (1 - $disc / 100), 2);
|
||||
});
|
||||
|
||||
// When a part is marked installed, decrement catalog stock once.
|
||||
static::updating(function (self $row) {
|
||||
$wasInstalled = $row->getOriginal('status') === 'installed';
|
||||
$isInstalled = $row->status === 'installed';
|
||||
if (! $wasInstalled && $isInstalled && $row->part_id) {
|
||||
$part = Part::find($row->part_id);
|
||||
$part?->adjustStock(-(float) $row->qty);
|
||||
// Reserve batches as soon as a catalog-linked part line is created.
|
||||
// Reservations don't reduce on-hand qty, only block other reservations.
|
||||
static::created(function (self $row) {
|
||||
if ($row->part_id) {
|
||||
try {
|
||||
app(\App\Services\Warehouse\WarehouseService::class)->reserve($row);
|
||||
} catch (\App\Services\Warehouse\InsufficientStockException $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('WO part reservation skipped: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
// If reverting from installed → restore stock
|
||||
if ($wasInstalled && ! $isInstalled && $row->part_id) {
|
||||
$part = Part::find($row->part_id);
|
||||
$part?->adjustStock((float) $row->qty);
|
||||
});
|
||||
|
||||
// If qty / part link changes, release old reservation and re-reserve.
|
||||
static::updated(function (self $row) {
|
||||
if ($row->wasChanged(['qty', 'part_id'])) {
|
||||
$svc = app(\App\Services\Warehouse\WarehouseService::class);
|
||||
$svc->release($row);
|
||||
if ($row->part_id) {
|
||||
try {
|
||||
$svc->reserve($row);
|
||||
} catch (\App\Services\Warehouse\InsufficientStockException $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('WO part re-reservation skipped: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
static::deleted(function (self $row) {
|
||||
app(\App\Services\Warehouse\WarehouseService::class)->release($row);
|
||||
});
|
||||
|
||||
static::saved(fn (self $row) => $row->workOrder?->recalcTotal());
|
||||
static::deleted(fn (self $row) => $row->workOrder?->recalcTotal());
|
||||
}
|
||||
|
||||
@@ -222,13 +222,63 @@ class TenantPanelProvider extends PanelProvider
|
||||
$reverbPort = config('broadcasting.connections.reverb.options.port');
|
||||
$reverbScheme = config('broadcasting.connections.reverb.options.scheme', 'https');
|
||||
$broadcastEnabled = config('broadcasting.default') === 'reverb' && $reverbKey && $reverbHost;
|
||||
$vapidPublic = config('webpush.vapid.public_key');
|
||||
$csrf = csrf_token();
|
||||
@endphp
|
||||
<button id="autocrm-install" type="button" style="display:none;position:fixed;bottom:16px;right:16px;z-index:60;background:#3b82f6;color:#fff;border:0;border-radius:24px;padding:10px 18px;font-size:13px;font-weight:600;box-shadow:0 4px 12px rgba(0,0,0,.2);cursor:pointer;">
|
||||
⤓ Instalează aplicația
|
||||
</button>
|
||||
<script>
|
||||
// Service worker + Web Push subscription.
|
||||
const AUTOCRM_VAPID = @json($vapidPublic);
|
||||
function urlBase64ToUint8Array(b64) {
|
||||
const pad = '='.repeat((4 - b64.length % 4) % 4);
|
||||
const base64 = (b64 + pad).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const raw = atob(base64);
|
||||
return Uint8Array.from([...raw].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
window.addEventListener('load', async () => {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js');
|
||||
if (AUTOCRM_VAPID && 'PushManager' in window) {
|
||||
const perm = await Notification.requestPermission().catch(() => 'default');
|
||||
if (perm === 'granted') {
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
if (!sub) {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(AUTOCRM_VAPID),
|
||||
});
|
||||
}
|
||||
await fetch('/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': '{{ $csrf }}' },
|
||||
body: JSON.stringify(sub.toJSON()),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) { /* push optional */ }
|
||||
});
|
||||
}
|
||||
// PWA install prompt.
|
||||
let deferredPrompt = null;
|
||||
const installBtn = document.getElementById('autocrm-install');
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
if (installBtn) installBtn.style.display = 'block';
|
||||
});
|
||||
if (installBtn) {
|
||||
installBtn.addEventListener('click', async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
deferredPrompt = null;
|
||||
installBtn.style.display = 'none';
|
||||
});
|
||||
}
|
||||
window.addEventListener('appinstalled', () => { if (installBtn) installBtn.style.display = 'none'; });
|
||||
</script>
|
||||
@if ($broadcastEnabled && $tenant)
|
||||
<script src="https://js.pusher.com/8.4/pusher.min.js"></script>
|
||||
|
||||
@@ -7,8 +7,12 @@ use App\Models\Tenant\AiChat;
|
||||
use App\Models\Tenant\AiMessage;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Lead;
|
||||
use App\Models\Tenant\Part;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Tenancy\TenantManager;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
@@ -175,6 +179,235 @@ TXT;
|
||||
];
|
||||
}
|
||||
|
||||
// ─── One-shot AI helpers ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Single-prompt call without persisting a chat. Used by quick-action buttons
|
||||
* (diagnose / suggest parts / suggest price / VIN recommendations).
|
||||
*
|
||||
* @return array{0:string, 1:array} [reply, meta]
|
||||
*/
|
||||
public function singleShot(string $systemPrompt, string $userPrompt, ?string $provider = null): array
|
||||
{
|
||||
$company = $this->currentCompany();
|
||||
if (! $company) return ['Niciun tenant rezolvat.', ['error' => 'no_tenant']];
|
||||
|
||||
$aiCfg = (array) ($company->settings['ai'] ?? []);
|
||||
$provider ??= $aiCfg['default_provider'] ?? 'claude';
|
||||
$key = $aiCfg["{$provider}_key"] ?? null;
|
||||
if (! $key) {
|
||||
return ['⚠️ API key pentru ' . strtoupper($provider) . ' lipsește în Setări → AI.',
|
||||
['error' => 'no_api_key', 'provider' => $provider]];
|
||||
}
|
||||
|
||||
$start = microtime(true);
|
||||
try {
|
||||
[$reply, $meta] = match ($provider) {
|
||||
'claude' => $this->postClaude($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
|
||||
'gpt' => $this->postOpenAI($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
|
||||
'gemini' => $this->postGemini($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
|
||||
default => ['Provider necunoscut: ' . $provider, []],
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
$reply = '❌ Eroare API: ' . $e->getMessage();
|
||||
$meta = ['error' => $e->getMessage()];
|
||||
}
|
||||
$meta['latency_ms'] = (int) ((microtime(true) - $start) * 1000);
|
||||
$meta['provider'] = $provider;
|
||||
|
||||
return [$reply, $meta];
|
||||
}
|
||||
|
||||
public function suggestDiagnosis(WorkOrder $wo): array
|
||||
{
|
||||
$vehicle = $wo->vehicle;
|
||||
$vinInfo = '';
|
||||
if ($vehicle?->vin) {
|
||||
$d = app(VinDecoder::class)->decode($vehicle->vin);
|
||||
if (! empty($d['manufacturer'])) {
|
||||
$vinInfo = " (VIN decode: {$d['manufacturer']} {$d['year']})";
|
||||
}
|
||||
}
|
||||
$sys = "Ești diagnostician auto cu 20 de ani experiență. Răspunzi scurt, structurat, "
|
||||
. "în română. Listează în ordine de probabilitate cauzele posibile + verificările "
|
||||
. "necesare. Nu inventezi date — dacă plângerea e vagă, sugerezi întrebări de clarificat.";
|
||||
$user = sprintf(
|
||||
"Mașina: %s %s %s%s, %s km.\nPlângere client: %s\n\nDă-mi top 3 cauze probabile și ce trebuie verificat la fiecare.",
|
||||
(string) ($vehicle->make ?? '?'),
|
||||
(string) ($vehicle->model ?? '?'),
|
||||
(string) ($vehicle->year ?? ''),
|
||||
$vinInfo,
|
||||
number_format((float) ($vehicle->mileage ?? 0), 0, '.', ' '),
|
||||
$wo->complaint ?: '(nu e completată)',
|
||||
);
|
||||
return $this->singleShot($sys, $user);
|
||||
}
|
||||
|
||||
public function suggestParts(WorkOrder $wo, string $task): array
|
||||
{
|
||||
$vehicle = $wo->vehicle;
|
||||
$sys = "Ești expert piese auto. Pentru mașina dată și operațiunea solicitată, "
|
||||
. "listează piesele necesare cu coduri OEM tipice când le știi (sau familie generică). "
|
||||
. "Răspunde în română, format listă.";
|
||||
$user = sprintf(
|
||||
"Mașina: %s %s %s.\nOperațiune: %s.\nListează piesele necesare + coduri OEM dacă există + cantități.",
|
||||
(string) ($vehicle->make ?? '?'),
|
||||
(string) ($vehicle->model ?? '?'),
|
||||
(string) ($vehicle->year ?? ''),
|
||||
$task,
|
||||
);
|
||||
return $this->singleShot($sys, $user);
|
||||
}
|
||||
|
||||
public function suggestPrice(Part $part): array
|
||||
{
|
||||
$company = $this->currentCompany();
|
||||
$currency = (string) data_get($company?->settings, 'currency', 'MDL');
|
||||
$sys = "Ești manager comercial pentru un magazin de piese auto din Moldova. "
|
||||
. "Sugerezi preț de vânzare bazat pe costul de achiziție, categorie și brand. "
|
||||
. "Răspunde scurt cu: preț recomandat, markup %, justificare 1-2 fraze.";
|
||||
$user = sprintf(
|
||||
"Piesa: %s\nBrand: %s\nCategorie: %s\nCost achiziție: %.2f %s\nPreț actual: %.2f %s",
|
||||
$part->name,
|
||||
$part->brand ?? '?',
|
||||
$part->category ?? '?',
|
||||
(float) $part->buy_price, $currency,
|
||||
(float) $part->sell_price, $currency,
|
||||
);
|
||||
return $this->singleShot($sys, $user);
|
||||
}
|
||||
|
||||
public function vinRecommendations(string $vin, ?int $mileage = null): array
|
||||
{
|
||||
$decoded = app(VinDecoder::class)->decode($vin);
|
||||
if (empty($decoded['manufacturer'])) {
|
||||
return ["VIN nu poate fi decodat — verifică formatul (17 caractere).", ['error' => 'invalid_vin']];
|
||||
}
|
||||
|
||||
$sys = "Ești expert service auto. Pe baza datelor mașinii și kilometrajului, "
|
||||
. "sugerezi mentenanța programată recomandată de producător + verificările "
|
||||
. "tipice pentru vârsta mașinii. Format listă scurtă.";
|
||||
$user = sprintf(
|
||||
"Mașina decodată din VIN: %s din %d (%s).\n%sCe verificări și mentenanță programată recomandăm acum?",
|
||||
$decoded['manufacturer'],
|
||||
$decoded['year'],
|
||||
$decoded['country'] ?? '?',
|
||||
$mileage ? "Kilometraj actual: " . number_format($mileage, 0, '.', ' ') . " km.\n" : '',
|
||||
);
|
||||
[$reply, $meta] = $this->singleShot($sys, $user);
|
||||
$meta['vin_decoded'] = $decoded;
|
||||
return [$reply, $meta];
|
||||
}
|
||||
|
||||
// ─── Token usage tracking ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Aggregate token spend for current month, grouped by provider.
|
||||
*
|
||||
* @return array<string, array{tokens_in:int, tokens_out:int, calls:int}>
|
||||
*/
|
||||
public function monthlyUsage(): array
|
||||
{
|
||||
$start = Carbon::now()->startOfMonth();
|
||||
$rows = AiMessage::where('role', 'assistant')
|
||||
->where('created_at', '>=', $start)
|
||||
->get(['meta']);
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$meta = (array) $r->meta;
|
||||
$provider = (string) ($meta['provider'] ?? 'unknown');
|
||||
$out[$provider] ??= ['tokens_in' => 0, 'tokens_out' => 0, 'calls' => 0];
|
||||
$out[$provider]['tokens_in'] += (int) ($meta['tokens_in'] ?? 0);
|
||||
$out[$provider]['tokens_out'] += (int) ($meta['tokens_out'] ?? 0);
|
||||
$out[$provider]['calls'] += 1;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ─── Provider HTTP — common form, used by both chat + single-shot ─
|
||||
|
||||
protected function postClaude(string $key, string $system, array $messages): array
|
||||
{
|
||||
$r = Http::withHeaders([
|
||||
'x-api-key' => $key,
|
||||
'anthropic-version' => '2023-06-01',
|
||||
'content-type' => 'application/json',
|
||||
])
|
||||
->timeout(60)
|
||||
->post('https://api.anthropic.com/v1/messages', [
|
||||
'model' => 'claude-sonnet-4-5',
|
||||
'max_tokens' => 1024,
|
||||
'system' => $system,
|
||||
'messages' => $messages,
|
||||
]);
|
||||
if (! $r->successful()) {
|
||||
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic ' . $r->status()), ['status' => $r->status()]];
|
||||
}
|
||||
$body = $r->json();
|
||||
$text = collect($body['content'] ?? [])
|
||||
->where('type', 'text')->pluck('text')->implode("\n");
|
||||
return [$text ?: '(răspuns gol)', [
|
||||
'model' => $body['model'] ?? null,
|
||||
'tokens_in' => $body['usage']['input_tokens'] ?? null,
|
||||
'tokens_out' => $body['usage']['output_tokens'] ?? null,
|
||||
]];
|
||||
}
|
||||
|
||||
protected function postOpenAI(string $key, string $system, array $messages): array
|
||||
{
|
||||
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
|
||||
->timeout(60)
|
||||
->post('https://api.openai.com/v1/chat/completions', [
|
||||
'model' => 'gpt-4o-mini',
|
||||
'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages),
|
||||
'max_tokens' => 1024,
|
||||
]);
|
||||
if (! $r->successful()) {
|
||||
return ['❌ ' . ($r->json('error.message') ?? 'OpenAI ' . $r->status()), ['status' => $r->status()]];
|
||||
}
|
||||
$body = $r->json();
|
||||
return [
|
||||
$body['choices'][0]['message']['content'] ?? '(răspuns gol)',
|
||||
[
|
||||
'model' => $body['model'] ?? null,
|
||||
'tokens_in' => $body['usage']['prompt_tokens'] ?? null,
|
||||
'tokens_out' => $body['usage']['completion_tokens'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function postGemini(string $key, string $system, array $messages): array
|
||||
{
|
||||
$contents = [];
|
||||
foreach ($messages as $m) {
|
||||
$contents[] = [
|
||||
'role' => $m['role'] === 'assistant' ? 'model' : 'user',
|
||||
'parts' => [['text' => $m['content']]],
|
||||
];
|
||||
}
|
||||
$r = Http::withHeaders(['content-type' => 'application/json'])
|
||||
->timeout(60)
|
||||
->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [
|
||||
'systemInstruction' => ['parts' => [['text' => $system]]],
|
||||
'contents' => $contents,
|
||||
'generationConfig' => ['maxOutputTokens' => 1024],
|
||||
]);
|
||||
if (! $r->successful()) {
|
||||
return ['❌ Gemini ' . $r->status(), ['status' => $r->status()]];
|
||||
}
|
||||
$body = $r->json();
|
||||
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
|
||||
return [$text, ['model' => 'gemini-1.5-flash', 'tokens' => $body['usageMetadata'] ?? null]];
|
||||
}
|
||||
|
||||
protected function currentCompany(): ?Company
|
||||
{
|
||||
$id = app(TenantManager::class)->currentId();
|
||||
if (! $id) return null;
|
||||
return Company::withoutGlobalScopes()->find($id);
|
||||
}
|
||||
|
||||
protected function callGemini(string $key, AiChat $chat, string $msg, Company $company): array
|
||||
{
|
||||
$contents = [];
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
/**
|
||||
* Deterministic VIN decoder. Extracts year, country and manufacturer from the
|
||||
* 17-character Vehicle Identification Number per ISO 3779/3780. No external API.
|
||||
*
|
||||
* Reliable signals:
|
||||
* - position 1 → world region (geographic prefix)
|
||||
* - positions 1-3 → WMI (world manufacturer identifier)
|
||||
* - position 10 → model year (cyclical 30-year mapping)
|
||||
* - position 11 → assembly plant code
|
||||
* - position 9 → check digit (NA-spec checksum, optional verification)
|
||||
*
|
||||
* Granular model/trim info requires a licensed database (TecDoc / NHTSA API);
|
||||
* those are out of scope here. We return a "best-effort" identification.
|
||||
*/
|
||||
class VinDecoder
|
||||
{
|
||||
private const YEAR_CODES = [
|
||||
'A' => [1980, 2010], 'B' => [1981, 2011], 'C' => [1982, 2012], 'D' => [1983, 2013],
|
||||
'E' => [1984, 2014], 'F' => [1985, 2015], 'G' => [1986, 2016], 'H' => [1987, 2017],
|
||||
'J' => [1988, 2018], 'K' => [1989, 2019], 'L' => [1990, 2020], 'M' => [1991, 2021],
|
||||
'N' => [1992, 2022], 'P' => [1993, 2023], 'R' => [1994, 2024], 'S' => [1995, 2025],
|
||||
'T' => [1996, 2026], 'V' => [1997, 2027], 'W' => [1998, 2028], 'X' => [1999, 2029],
|
||||
'Y' => [2000, 2030], '1' => [2001, 2031], '2' => [2002, 2032], '3' => [2003, 2033],
|
||||
'4' => [2004, 2034], '5' => [2005, 2035], '6' => [2006, 2036], '7' => [2007, 2037],
|
||||
'8' => [2008, 2038], '9' => [2009, 2039],
|
||||
];
|
||||
|
||||
// Region by first char (ISO 3779 broad regions).
|
||||
private const REGIONS = [
|
||||
'A' => 'Africa', 'B' => 'Africa', 'C' => 'Africa', 'D' => 'Africa',
|
||||
'E' => 'Africa', 'F' => 'Africa', 'G' => 'Africa', 'H' => 'Africa',
|
||||
'J' => 'Asia', 'K' => 'Asia', 'L' => 'Asia', 'M' => 'Asia',
|
||||
'N' => 'Asia', 'P' => 'Asia', 'R' => 'Asia',
|
||||
'S' => 'Europe', 'T' => 'Europe', 'U' => 'Europe', 'V' => 'Europe',
|
||||
'W' => 'Europe', 'X' => 'Europe', 'Y' => 'Europe', 'Z' => 'Europe',
|
||||
'1' => 'North America', '2' => 'North America', '3' => 'North America',
|
||||
'4' => 'North America', '5' => 'North America',
|
||||
'6' => 'Oceania', '7' => 'Oceania',
|
||||
'8' => 'South America', '9' => 'South America',
|
||||
];
|
||||
|
||||
// Selected WMI → manufacturer/country. Covers most common European/Asian/US
|
||||
// brands relevant for a Moldova service shop.
|
||||
private const WMI = [
|
||||
// Volkswagen group
|
||||
'WVW' => ['VW', 'Germany'], 'WV1' => ['VW Commercial', 'Germany'], 'WV2' => ['VW Bus', 'Germany'],
|
||||
'WAU' => ['Audi', 'Germany'], 'WA1' => ['Audi SUV', 'Germany'],
|
||||
'TRU' => ['Audi', 'Hungary'], 'WUA' => ['Audi Sport', 'Germany'],
|
||||
'VWV' => ['VW', 'Spain'], 'VSS' => ['SEAT', 'Spain'], 'TMB' => ['Škoda', 'Czechia'],
|
||||
// BMW
|
||||
'WBA' => ['BMW', 'Germany'], 'WBS' => ['BMW M', 'Germany'], 'WBY' => ['BMW i', 'Germany'],
|
||||
'WBX' => ['BMW X SUV', 'USA'], 'NM0' => ['BMW Mini', 'Turkey'],
|
||||
// Mercedes
|
||||
'WDB' => ['Mercedes-Benz', 'Germany'], 'WDC' => ['Mercedes-Benz SUV', 'USA'],
|
||||
'WDD' => ['Mercedes-Benz', 'Germany'], 'WDF' => ['Mercedes-Benz Van', 'Germany'],
|
||||
// Porsche
|
||||
'WP0' => ['Porsche', 'Germany'], 'WP1' => ['Porsche SUV', 'Germany'],
|
||||
// Opel / Vauxhall
|
||||
'W0L' => ['Opel', 'Germany'], 'W0V' => ['Opel/Vauxhall', 'Germany'],
|
||||
// Ford
|
||||
'1FA' => ['Ford', 'USA'], '1FT' => ['Ford Truck', 'USA'], '1FM' => ['Ford SUV', 'USA'],
|
||||
'WF0' => ['Ford Europe', 'Germany'],
|
||||
// Honda
|
||||
'1HG' => ['Honda', 'USA'], 'JHM' => ['Honda', 'Japan'], 'JHL' => ['Honda SUV', 'Japan'],
|
||||
// Toyota
|
||||
'JT2' => ['Toyota', 'Japan'], 'JTD' => ['Toyota', 'Japan'], 'JTE' => ['Toyota', 'Japan'],
|
||||
'4T1' => ['Toyota', 'USA'], '5TD' => ['Toyota', 'USA'],
|
||||
// Hyundai/Kia
|
||||
'KMH' => ['Hyundai', 'Korea'], 'KNA' => ['Kia', 'Korea'], 'KND' => ['Kia SUV', 'Korea'],
|
||||
// Renault/Dacia
|
||||
'VF1' => ['Renault', 'France'], 'VF6' => ['Renault Trucks', 'France'],
|
||||
'UU1' => ['Dacia', 'Romania'], 'UU3' => ['Dacia Pickup', 'Romania'],
|
||||
// Peugeot/Citroën
|
||||
'VF3' => ['Peugeot', 'France'], 'VF7' => ['Citroën', 'France'],
|
||||
// Fiat group
|
||||
'ZFA' => ['Fiat', 'Italy'], 'ZAR' => ['Alfa Romeo', 'Italy'], 'ZFF' => ['Ferrari', 'Italy'],
|
||||
// Volvo
|
||||
'YV1' => ['Volvo Cars', 'Sweden'], 'YV4' => ['Volvo SUV', 'Sweden'],
|
||||
// Nissan
|
||||
'JN1' => ['Nissan', 'Japan'], 'JN8' => ['Nissan SUV', 'Japan'], '1N4' => ['Nissan', 'USA'],
|
||||
// Mazda
|
||||
'JM1' => ['Mazda', 'Japan'], 'JMZ' => ['Mazda', 'Japan'],
|
||||
// Subaru
|
||||
'JF1' => ['Subaru', 'Japan'], 'JF2' => ['Subaru SUV', 'Japan'],
|
||||
// Mitsubishi
|
||||
'JMB' => ['Mitsubishi', 'Japan'], 'JA3' => ['Mitsubishi', 'Japan'],
|
||||
// Lada / Russian
|
||||
'XTA' => ['Lada/AvtoVAZ', 'Russia'], 'X4X' => ['UAZ', 'Russia'],
|
||||
// Tesla
|
||||
'5YJ' => ['Tesla', 'USA'], 'LRW' => ['Tesla', 'China'],
|
||||
// Chinese brands
|
||||
'LGW' => ['Great Wall', 'China'], 'LJV' => ['JAC', 'China'], 'LSJ' => ['MG/SAIC', 'China'],
|
||||
'LB1' => ['Geely', 'China'],
|
||||
];
|
||||
|
||||
public function decode(string $raw): array
|
||||
{
|
||||
$vin = preg_replace('/[^A-HJ-NPR-Z0-9]/', '', strtoupper($raw));
|
||||
if (strlen($vin) !== 17) {
|
||||
return [
|
||||
'vin' => $vin,
|
||||
'valid_length' => false,
|
||||
'reason' => 'VIN must be exactly 17 characters (no I, O, Q allowed).',
|
||||
];
|
||||
}
|
||||
|
||||
$wmi = substr($vin, 0, 3);
|
||||
$yearCode = $vin[9];
|
||||
$plant = $vin[10];
|
||||
|
||||
[$manufacturer, $country] = self::WMI[$wmi] ?? [null, null];
|
||||
$region = self::REGIONS[$vin[0]] ?? null;
|
||||
|
||||
$year = $this->resolveYear($yearCode, $vin);
|
||||
|
||||
return [
|
||||
'vin' => $vin,
|
||||
'valid_length' => true,
|
||||
'wmi' => $wmi,
|
||||
'region' => $region,
|
||||
'country' => $country,
|
||||
'manufacturer' => $manufacturer,
|
||||
'year' => $year,
|
||||
'plant' => $plant,
|
||||
'checksum_valid' => $this->validateChecksum($vin),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Position 10 encodes year cyclically. Use position 7 as A-Z → 2010+, 0-9 → pre-2010
|
||||
* disambiguator for new-spec VINs (since 2010 NHTSA spec).
|
||||
*/
|
||||
private function resolveYear(string $code, string $vin): ?int
|
||||
{
|
||||
if (! isset(self::YEAR_CODES[$code])) return null;
|
||||
[$old, $new] = self::YEAR_CODES[$code];
|
||||
|
||||
// Position 7 alpha → post-2010 cycle; numeric → pre-2010
|
||||
$p7 = $vin[6];
|
||||
return ctype_alpha($p7) ? $new : $old;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO 3779 / NA-spec checksum. Position 9 = mod-11 check digit (X = 10).
|
||||
* Optional — many European/Asian manufacturers don't follow the spec.
|
||||
*/
|
||||
private function validateChecksum(string $vin): bool
|
||||
{
|
||||
$weights = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2];
|
||||
$values = [
|
||||
'A' => 1, 'B' => 2, 'C' => 3, 'D' => 4, 'E' => 5, 'F' => 6, 'G' => 7, 'H' => 8,
|
||||
'J' => 1, 'K' => 2, 'L' => 3, 'M' => 4, 'N' => 5, 'P' => 7, 'R' => 9,
|
||||
'S' => 2, 'T' => 3, 'U' => 4, 'V' => 5, 'W' => 6, 'X' => 7, 'Y' => 8, 'Z' => 9,
|
||||
];
|
||||
|
||||
$sum = 0;
|
||||
for ($i = 0; $i < 17; $i++) {
|
||||
$c = $vin[$i];
|
||||
$v = ctype_digit($c) ? (int) $c : ($values[$c] ?? null);
|
||||
if ($v === null) return false;
|
||||
$sum += $v * $weights[$i];
|
||||
}
|
||||
$check = $sum % 11;
|
||||
$expected = $check === 10 ? 'X' : (string) $check;
|
||||
return $vin[8] === $expected;
|
||||
}
|
||||
}
|
||||
@@ -8,85 +8,220 @@ use App\Mail\ServiceReminderMail;
|
||||
use App\Mail\WorkOrderReadyMail;
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\Appointment;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\Payment;
|
||||
use App\Models\Tenant\Vehicle;
|
||||
use App\Models\Tenant\WorkOrder;
|
||||
use App\Services\Notifications\TelegramService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Centralizes outbound email notifications, with per-tenant feature toggles
|
||||
* stored in companies.settings.notify (e.g., 'wo_ready' => true).
|
||||
* Multi-channel outbound notifications with per-tenant + per-client opt-in/out.
|
||||
*
|
||||
* Usage:
|
||||
* app(NotificationDispatcher::class)->workOrderReady($workOrder);
|
||||
* Channels tried in order:
|
||||
* 1. Telegram — if client has telegram_chat_id AND tenant bot token AND
|
||||
* tenant has notify.{type}.telegram = true (default true when bot configured)
|
||||
* 2. Email — if client has email AND tenant has notify.{type}.email = true
|
||||
* (default true — preserves legacy behaviour)
|
||||
*
|
||||
* Per-client overrides via clients.notify_prefs JSON (array of channel keys).
|
||||
*
|
||||
* Public methods return TRUE when at least one channel succeeded.
|
||||
*/
|
||||
class NotificationDispatcher
|
||||
{
|
||||
public function __construct(private TelegramService $telegram)
|
||||
{
|
||||
}
|
||||
|
||||
public function workOrderReady(WorkOrder $wo): bool
|
||||
{
|
||||
$company = $this->companyFor($wo);
|
||||
if (! $this->isEnabled($company, 'wo_ready')) return false;
|
||||
$client = $wo->client;
|
||||
if (! $client) return false;
|
||||
|
||||
$email = $wo->client?->email;
|
||||
if (! $email) return false;
|
||||
|
||||
try {
|
||||
Mail::to($email)->send(new WorkOrderReadyMail($wo, $company));
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('workOrderReady mail failed', ['wo' => $wo->id, 'err' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
return $this->dispatch($company, $client, 'wo_ready', [
|
||||
'telegram' => fn () => $this->tgWorkOrderReady($wo, $company, $client),
|
||||
'email' => fn () => $this->emailSafe(
|
||||
fn () => Mail::to($client->email)->send(new WorkOrderReadyMail($wo, $company)),
|
||||
'workOrderReady', ['wo' => $wo->id]
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function paymentReceived(Payment $payment): bool
|
||||
{
|
||||
$company = $this->companyFor($payment);
|
||||
if (! $this->isEnabled($company, 'payment')) return false;
|
||||
$client = $payment->client;
|
||||
if (! $client) return false;
|
||||
|
||||
$email = $payment->client?->email;
|
||||
if (! $email) return false;
|
||||
|
||||
try {
|
||||
Mail::to($email)->send(new PaymentReceivedMail($payment, $company));
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('paymentReceived mail failed', ['payment' => $payment->id, 'err' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
return $this->dispatch($company, $client, 'payment', [
|
||||
'telegram' => fn () => $this->tgPaymentReceived($payment, $company, $client),
|
||||
'email' => fn () => $this->emailSafe(
|
||||
fn () => Mail::to($client->email)->send(new PaymentReceivedMail($payment, $company)),
|
||||
'paymentReceived', ['payment' => $payment->id]
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function appointmentConfirmed(Appointment $a): bool
|
||||
{
|
||||
$company = $this->companyFor($a);
|
||||
if (! $this->isEnabled($company, 'appointment')) return false;
|
||||
$client = $a->client;
|
||||
if (! $client) return false;
|
||||
|
||||
$email = $a->client?->email;
|
||||
if (! $email) return false;
|
||||
|
||||
try {
|
||||
Mail::to($email)->send(new AppointmentConfirmedMail($a, $company));
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('appointmentConfirmed mail failed', ['appt' => $a->id, 'err' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
return $this->dispatch($company, $client, 'appointment', [
|
||||
'telegram' => fn () => $this->tgAppointmentConfirmed($a, $company, $client),
|
||||
'email' => fn () => $this->emailSafe(
|
||||
fn () => Mail::to($client->email)->send(new AppointmentConfirmedMail($a, $company)),
|
||||
'appointmentConfirmed', ['appt' => $a->id]
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function serviceReminder(Vehicle $v, string $type = 'general', ?string $note = null): bool
|
||||
{
|
||||
$company = $this->companyFor($v);
|
||||
if (! $this->isEnabled($company, 'reminder')) return false;
|
||||
$client = $v->client;
|
||||
if (! $client) return false;
|
||||
|
||||
$email = $v->client?->email;
|
||||
if (! $email) return false;
|
||||
return $this->dispatch($company, $client, 'reminder', [
|
||||
'telegram' => fn () => $this->tgServiceReminder($v, $type, $note, $company, $client),
|
||||
'email' => fn () => $this->emailSafe(
|
||||
fn () => Mail::to($client->email)->send(new ServiceReminderMail($v, $type, $note, $company)),
|
||||
'serviceReminder', ['vehicle' => $v->id]
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Channel dispatch ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @param array<string, callable(): bool> $senders channel-key → sender callback
|
||||
* @return bool Returns the channel name that delivered, or null on full miss.
|
||||
*/
|
||||
protected function dispatch(Company $company, Client $client, string $key, array $senders): bool
|
||||
{
|
||||
$any = false;
|
||||
foreach ($this->channelsFor($company, $client, $key) as $channel) {
|
||||
if (! isset($senders[$channel])) continue;
|
||||
try {
|
||||
if (($senders[$channel])() === true) {
|
||||
$any = true;
|
||||
// Try only one channel — first that succeeds is enough.
|
||||
break;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("notify.{$key} {$channel} threw", ['err' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
return $any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which channels to try and in what order, applying per-client
|
||||
* preference if set, otherwise the tenant default.
|
||||
*/
|
||||
protected function channelsFor(Company $company, Client $client, string $key): array
|
||||
{
|
||||
$prefs = (array) ($client->notify_prefs ?? []);
|
||||
|
||||
// Per-client list takes precedence.
|
||||
if (! empty($prefs)) {
|
||||
$candidates = array_values(array_intersect(['telegram', 'email'], $prefs));
|
||||
} else {
|
||||
$candidates = ['telegram', 'email'];
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$candidates,
|
||||
fn (string $ch) => $this->channelEnabled($company, $client, $key, $ch)
|
||||
));
|
||||
}
|
||||
|
||||
protected function channelEnabled(Company $company, Client $client, string $key, string $channel): bool
|
||||
{
|
||||
$notify = (array) data_get($company->settings, 'notify', []);
|
||||
// Tenant-level: notify.{type}.{channel} — accept legacy boolean notify.{type}=true as email-only flag.
|
||||
$tenantValue = $notify[$key] ?? null;
|
||||
if (is_bool($tenantValue)) {
|
||||
// legacy: email default true, telegram default true if bot configured
|
||||
if ($channel === 'email') $tenantValueEnabled = $tenantValue;
|
||||
elseif ($channel === 'telegram') $tenantValueEnabled = $tenantValue && (bool) $this->telegram->tokenFor($company);
|
||||
else $tenantValueEnabled = false;
|
||||
} elseif (is_array($tenantValue)) {
|
||||
$tenantValueEnabled = (bool) ($tenantValue[$channel] ?? true);
|
||||
} else {
|
||||
$tenantValueEnabled = true;
|
||||
}
|
||||
|
||||
if (! $tenantValueEnabled) return false;
|
||||
|
||||
// Per-client field presence
|
||||
if ($channel === 'telegram') {
|
||||
return ! empty($client->telegram_chat_id) && (bool) $this->telegram->tokenFor($company);
|
||||
}
|
||||
if ($channel === 'email') {
|
||||
return ! empty($client->email);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Telegram message builders ────────────────────────────────
|
||||
|
||||
protected function tgWorkOrderReady(WorkOrder $wo, Company $company, Client $client): bool
|
||||
{
|
||||
$brand = htmlspecialchars($company->display_name ?? $company->name);
|
||||
$no = htmlspecialchars((string) $wo->number);
|
||||
$plate = htmlspecialchars((string) ($wo->vehicle->plate ?? ''));
|
||||
$text = "✅ <b>Mașina e gata de ridicat</b>\n"
|
||||
. "Fișa #{$no} · {$plate}\n"
|
||||
. "Total: <b>" . number_format((float) $wo->total, 2) . " " . ($company->settings['currency'] ?? 'MDL') . "</b>\n\n"
|
||||
. "🔗 Detalii: " . $wo->trackingUrl() . "\n\n"
|
||||
. "{$brand}";
|
||||
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
||||
}
|
||||
|
||||
protected function tgPaymentReceived(Payment $payment, Company $company, Client $client): bool
|
||||
{
|
||||
$amt = number_format((float) $payment->amount, 2);
|
||||
$cur = $company->settings['currency'] ?? 'MDL';
|
||||
$text = "💳 <b>Plată primită</b>\n"
|
||||
. "Suma: <b>{$amt} {$cur}</b>\n"
|
||||
. "Mulțumim!";
|
||||
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
||||
}
|
||||
|
||||
protected function tgAppointmentConfirmed(Appointment $a, Company $company, Client $client): bool
|
||||
{
|
||||
$when = $a->starts_at?->isoFormat('D MMM YYYY, HH:mm') ?? '?';
|
||||
$text = "📅 <b>Programare confirmată</b>\n"
|
||||
. "Data: <b>{$when}</b>\n"
|
||||
. ($a->vehicle?->plate ? "Auto: " . htmlspecialchars($a->vehicle->plate) . "\n" : '')
|
||||
. "Te așteptăm.";
|
||||
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
||||
}
|
||||
|
||||
protected function tgServiceReminder(Vehicle $v, string $type, ?string $note, Company $company, Client $client): bool
|
||||
{
|
||||
$brand = htmlspecialchars($company->display_name ?? $company->name);
|
||||
$plate = htmlspecialchars((string) ($v->plate ?? ''));
|
||||
$text = "🔧 <b>Reminder service</b>\n"
|
||||
. "{$brand}: " . htmlspecialchars($v->make . ' ' . $v->model) . " · {$plate}\n"
|
||||
. ($note ?: 'A trecut ceva timp de la ultima vizită — recomandăm o verificare.');
|
||||
return $this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
protected function emailSafe(callable $fn, string $tag, array $ctx = []): bool
|
||||
{
|
||||
try {
|
||||
Mail::to($email)->send(new ServiceReminderMail($v, $type, $note, $company));
|
||||
$fn();
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('serviceReminder mail failed', ['vehicle' => $v->id, 'err' => $e->getMessage()]);
|
||||
Log::warning("{$tag} mail failed", $ctx + ['err' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -95,12 +230,4 @@ class NotificationDispatcher
|
||||
{
|
||||
return Company::withoutGlobalScopes()->findOrFail($model->company_id);
|
||||
}
|
||||
|
||||
protected function isEnabled(Company $company, string $key): bool
|
||||
{
|
||||
$settings = (array) ($company->settings ?? []);
|
||||
$notify = (array) ($settings['notify'] ?? []);
|
||||
// default: enabled (toate notificările active by default)
|
||||
return ($notify[$key] ?? true) === true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use App\Models\Tenant\Client;
|
||||
use App\Models\Tenant\OnlineOrder;
|
||||
use App\Models\Tenant\User;
|
||||
|
||||
/**
|
||||
* Notifies staff of a new online order (Web Push) and confirms to the customer
|
||||
* via Telegram if they have a linked chat. Best-effort — never throws.
|
||||
*/
|
||||
class ShopOrderNotifier
|
||||
{
|
||||
public function __construct(
|
||||
private WebPushService $push,
|
||||
private TelegramService $telegram,
|
||||
) {
|
||||
}
|
||||
|
||||
public function placed(OnlineOrder $order): void
|
||||
{
|
||||
$company = Company::withoutGlobalScopes()->find($order->company_id);
|
||||
if (! $company) return;
|
||||
|
||||
// ── Staff: Web Push to active users of this tenant ──
|
||||
$title = 'Comandă nouă #' . $order->number;
|
||||
$body = $order->customer_name . ' · ' . number_format((float) $order->total, 2) . ' '
|
||||
. ($company->settings['currency'] ?? 'MDL');
|
||||
$url = '/app/resources/online-orders/' . $order->id . '/edit';
|
||||
|
||||
$userIds = User::where('status', 'active')->pluck('id');
|
||||
foreach ($userIds as $uid) {
|
||||
$this->push->sendToUser((int) $uid, $title, $body, $url, 'shop-order-' . $order->id);
|
||||
}
|
||||
|
||||
// ── Customer: Telegram if their phone is linked ──
|
||||
$needle = Client::normalizePhone($order->customer_phone);
|
||||
if ($needle) {
|
||||
$client = Client::whereNotNull('telegram_chat_id')
|
||||
->whereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(phone, ' ', ''), '-', ''), '(', ''), ')', '') LIKE ?",
|
||||
['%' . substr($needle, -9) . '%']
|
||||
)
|
||||
->first();
|
||||
|
||||
if ($client && $client->telegram_chat_id) {
|
||||
$brand = htmlspecialchars($company->display_name ?? $company->name);
|
||||
$text = "🛒 <b>Comanda #{$order->number} primită</b>\n"
|
||||
. "Total: <b>" . number_format((float) $order->total, 2) . " "
|
||||
. ($company->settings['currency'] ?? 'MDL') . "</b>\n\n"
|
||||
. "Urmărește statusul: " . $order->trackingUrl() . "\n\n{$brand}";
|
||||
$this->telegram->sendMessage($company, (string) $client->telegram_chat_id, $text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Notifications;
|
||||
|
||||
use App\Models\Central\Company;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Thin client over Bot API. Tenant-aware: bot token comes from
|
||||
* companies.settings.telegram.bot_token. Webhook secret used to verify
|
||||
* incoming updates (Telegram sends it back as X-Telegram-Bot-Api-Secret-Token).
|
||||
*/
|
||||
class TelegramService
|
||||
{
|
||||
private const API = 'https://api.telegram.org/bot';
|
||||
|
||||
public function tokenFor(Company $company): ?string
|
||||
{
|
||||
return data_get($company->settings, 'telegram.bot_token');
|
||||
}
|
||||
|
||||
public function webhookSecretFor(Company $company): ?string
|
||||
{
|
||||
return data_get($company->settings, 'telegram.webhook_secret');
|
||||
}
|
||||
|
||||
public function webhookUrlFor(Company $company): string
|
||||
{
|
||||
$central = config('app.central_domain') ?: 'service.mir.md';
|
||||
// We expose the webhook on the central domain so Telegram does not
|
||||
// need to know about subdomain wildcards. Slug routes to tenant.
|
||||
return "https://{$central}/telegram/webhook/{$company->slug}";
|
||||
}
|
||||
|
||||
public function sendMessage(Company $company, string $chatId, string $text, array $options = []): bool
|
||||
{
|
||||
$token = $this->tokenFor($company);
|
||||
if (! $token || ! $chatId) return false;
|
||||
|
||||
try {
|
||||
$resp = Http::asJson()
|
||||
->timeout(10)
|
||||
->post(self::API . $token . '/sendMessage', array_merge([
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
'parse_mode' => 'HTML',
|
||||
'disable_web_page_preview' => true,
|
||||
], $options));
|
||||
if (! $resp->ok()) {
|
||||
Log::warning('telegram.send failed', [
|
||||
'tenant' => $company->slug,
|
||||
'status' => $resp->status(),
|
||||
'body' => $resp->body(),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('telegram.send exception', ['err' => $e->getMessage()]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function setWebhook(Company $company): array
|
||||
{
|
||||
$token = $this->tokenFor($company);
|
||||
if (! $token) return ['ok' => false, 'error' => 'Lipsește bot token în setări.'];
|
||||
|
||||
$secret = $this->webhookSecretFor($company);
|
||||
if (! $secret) {
|
||||
$secret = \Illuminate\Support\Str::random(32);
|
||||
$company->update([
|
||||
'settings' => array_replace_recursive((array) $company->settings, [
|
||||
'telegram' => ['webhook_secret' => $secret],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$resp = Http::asJson()->post(self::API . $token . '/setWebhook', [
|
||||
'url' => $this->webhookUrlFor($company),
|
||||
'secret_token' => $secret,
|
||||
'allowed_updates' => ['message', 'callback_query'],
|
||||
]);
|
||||
return ['ok' => $resp->ok(), 'response' => $resp->json()];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
public function getMe(Company $company): array
|
||||
{
|
||||
$token = $this->tokenFor($company);
|
||||
if (! $token) return ['ok' => false, 'error' => 'no_token'];
|
||||
try {
|
||||
$resp = Http::timeout(10)->get(self::API . $token . '/getMe');
|
||||
return ['ok' => $resp->ok(), 'response' => $resp->json()];
|
||||
} catch (\Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user