Deploy 2: 2FA (App + Email) + REST API + CSV import-export + auto backup

- Filament v5 multiFactorAuthentication enabled on both panels (App + Email)
- HasAppAuthentication + HasEmailAuthentication on User and SuperAdmin
- Migration: app_authentication_secret + recovery_codes + email_authentication_at
- Sanctum REST API: /api/v1/login, /me, clients, vehicles, work-orders
- EnsureTokenMatchesTenant middleware blocks cross-tenant token usage
- CsvImportExport service: clients + vehicles bulk via plain CSV
- Import/Export buttons on Client + Vehicle list pages
- ApiTokens page in tenant panel (generate/revoke + last-used)
- BackupAllTenantsCommand + scheduler (daily 03:00, retain 14 days)
- Background scheduler in entrypoint.sh
This commit is contained in:
2026-05-07 19:25:27 +00:00
parent ce4e21220f
commit eaa05d68c1
22 changed files with 1068 additions and 6 deletions
@@ -0,0 +1,88 @@
<?php
namespace App\Console\Commands;
use App\Models\Central\Company;
use App\Services\TenantBackupService;
use App\Tenancy\TenantManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class BackupAllTenantsCommand extends Command
{
protected $signature = 'backup:tenants
{--keep=14 : How many daily backups to retain per tenant}
{--slug= : Backup only one tenant by slug}';
protected $description = 'Export ZIP backup for every active tenant. Stored in storage/app/backups/{date}/';
public function handle(TenantBackupService $service): int
{
$query = Company::query()->where('status', '!=', 'archived');
if ($slug = $this->option('slug')) {
$query->where('slug', $slug);
}
$companies = $query->get();
if ($companies->isEmpty()) {
$this->warn('No tenants to back up.');
return self::SUCCESS;
}
$date = date('Y-m-d');
$base = storage_path("app/backups/{$date}");
if (! is_dir($base)) @mkdir($base, 0775, true);
$manager = app(TenantManager::class);
$ok = 0;
$failed = 0;
foreach ($companies as $company) {
try {
$manager->setCurrent($company);
app(\Spatie\Permission\PermissionRegistrar::class)
->setPermissionsTeamId($company->id);
$tmp = $service->export($company);
$dest = "{$base}/{$company->slug}.zip";
@rename($tmp, $dest);
$size = round(filesize($dest) / 1024, 1);
$this->info("{$company->slug}{$size}KB");
$ok++;
} catch (\Throwable $e) {
$this->error("{$company->slug}: {$e->getMessage()}");
$failed++;
} finally {
$manager->setCurrent(null);
}
}
$this->cleanupOld((int) $this->option('keep'));
$this->newLine();
$this->info("Backup completed: {$ok} ok, {$failed} failed.");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function cleanupOld(int $keep): void
{
$backupsDir = storage_path('app/backups');
if (! is_dir($backupsDir)) return;
$dates = collect(scandir($backupsDir))
->filter(fn ($d) => preg_match('/^\d{4}-\d{2}-\d{2}$/', $d))
->sortDesc()
->values();
$toDelete = $dates->slice($keep);
foreach ($toDelete as $dateDir) {
$abs = "{$backupsDir}/{$dateDir}";
if (is_dir($abs)) {
array_map('unlink', glob("{$abs}/*"));
@rmdir($abs);
$this->line("Removed old backup: {$dateDir}");
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Filament\Tenant\Pages;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
class ApiTokens extends Page
{
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationLabel = 'API Tokens';
protected static string|\UnitEnum|null $navigationGroup = 'Admin';
protected static ?int $navigationSort = 95;
protected static ?string $title = 'API Tokens';
protected string $view = 'filament.tenant.pages.api-tokens';
public ?string $newToken = null;
public function getTokens(): array
{
$u = auth()->user();
if (! $u) return [];
return $u->tokens()->latest()->get()->map(fn ($t) => [
'id' => $t->id,
'name' => $t->name,
'last_used_at' => $t->last_used_at?->diffForHumans() ?? '—',
'created_at' => $t->created_at->diffForHumans(),
'abilities' => is_array($t->abilities) ? implode(', ', $t->abilities) : '*',
])->all();
}
protected function getHeaderActions(): array
{
return [
Actions\Action::make('create')
->label('Generează token')
->icon('heroicon-m-plus')
->schema([
Forms\Components\TextInput::make('name')
->label('Nume (descriere)')
->required()
->placeholder('ex: app-mobil, integrare-erp'),
])
->action(function (array $data) {
$u = auth()->user();
if (! $u) return;
$token = $u->createToken($data['name']);
$this->newToken = $token->plainTextToken;
Notification::make()
->title('Token generat!')
->body('Copiază token-ul ACUM — nu va mai fi afișat.')
->success()
->persistent()
->send();
}),
];
}
public function deleteToken(int $id): void
{
$u = auth()->user();
if (! $u) return;
$u->tokens()->where('id', $id)->delete();
Notification::make()->title('Token revocat')->success()->send();
}
public function dismissNew(): void
{
$this->newToken = null;
}
}
@@ -3,7 +3,10 @@
namespace App\Filament\Tenant\Resources\ClientResource\Pages;
use App\Filament\Tenant\Resources\ClientResource;
use App\Services\CsvImportExport;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
class ListClients extends ListRecords
@@ -12,6 +15,37 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
return [
Actions\Action::make('export')
->label('Export CSV')
->icon('heroicon-m-arrow-down-tray')
->color('gray')
->action(fn () => app(CsvImportExport::class)->exportClients()),
Actions\Action::make('import')
->label('Import CSV')
->icon('heroicon-m-arrow-up-tray')
->color('gray')
->modalHeading('Import clienți din CSV')
->modalDescription('CSV cu header: ' . implode(', ', CsvImportExport::CLIENT_COLUMNS) . '. Deduplicare după telefon.')
->schema([
Forms\Components\FileUpload::make('file')
->required()
->disk('local')
->directory('imports')
->acceptedFileTypes(['text/csv', 'text/plain', 'application/csv'])
->maxSize(5120),
])
->action(function (array $data) {
$abs = \Illuminate\Support\Facades\Storage::disk('local')->path($data['file']);
$r = app(CsvImportExport::class)->importClients($abs);
@unlink($abs);
Notification::make()
->title("Import: {$r['imported']} adăugați, {$r['skipped']} ignorați")
->body(empty($r['errors']) ? null : implode("\n", array_slice($r['errors'], 0, 8)))
->{empty($r['errors']) ? 'success' : 'warning'}()
->send();
}),
Actions\CreateAction::make(),
];
}
}
@@ -3,7 +3,10 @@
namespace App\Filament\Tenant\Resources\VehicleResource\Pages;
use App\Filament\Tenant\Resources\VehicleResource;
use App\Services\CsvImportExport;
use Filament\Actions;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
class ListVehicles extends ListRecords
@@ -12,6 +15,37 @@ class ListVehicles extends ListRecords
protected function getHeaderActions(): array
{
return [Actions\CreateAction::make()];
return [
Actions\Action::make('export')
->label('Export CSV')
->icon('heroicon-m-arrow-down-tray')
->color('gray')
->action(fn () => app(CsvImportExport::class)->exportVehicles()),
Actions\Action::make('import')
->label('Import CSV')
->icon('heroicon-m-arrow-up-tray')
->color('gray')
->modalHeading('Import mașini din CSV')
->modalDescription('CSV cu header: ' . implode(', ', CsvImportExport::VEHICLE_COLUMNS) . '. Coloana client_phone trebuie să existe deja la clienți.')
->schema([
Forms\Components\FileUpload::make('file')
->required()
->disk('local')
->directory('imports')
->acceptedFileTypes(['text/csv', 'text/plain', 'application/csv'])
->maxSize(5120),
])
->action(function (array $data) {
$abs = \Illuminate\Support\Facades\Storage::disk('local')->path($data['file']);
$r = app(CsvImportExport::class)->importVehicles($abs);
@unlink($abs);
Notification::make()
->title("Import: {$r['imported']} adăugate, {$r['skipped']} ignorate")
->body(empty($r['errors']) ? null : implode("\n", array_slice($r['errors'], 0, 8)))
->{empty($r['errors']) ? 'success' : 'warning'}()
->send();
}),
Actions\CreateAction::make(),
];
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Tenant\User;
use App\Tenancy\TenantManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class ApiAuthController
{
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
'device' => 'sometimes|string|max:80',
]);
$tenant = app(TenantManager::class)->current();
if (! $tenant) {
throw ValidationException::withMessages([
'email' => 'Tenant subdomain required.',
]);
}
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => 'Invalid credentials.',
]);
}
if ($user->company_id !== $tenant->id) {
throw ValidationException::withMessages([
'email' => 'User does not belong to this tenant.',
]);
}
if ($user->status !== 'active') {
throw ValidationException::withMessages([
'email' => 'Account inactive.',
]);
}
$token = $user->createToken($request->input('device', 'api'))->plainTextToken;
return response()->json([
'token' => $token,
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
],
'tenant' => [
'slug' => $tenant->slug,
'name' => $tenant->display_name ?? $tenant->name,
],
]);
}
public function me(Request $request): JsonResponse
{
$u = $request->user();
return response()->json([
'id' => $u->id,
'name' => $u->name,
'email' => $u->email,
'role' => $u->role,
'tenant_slug' => app(TenantManager::class)->current()?->slug,
]);
}
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Tenant\Client;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ClientApiController
{
public function index(Request $request): JsonResponse
{
$q = Client::query();
if ($s = $request->query('search')) {
$q->where(function ($qq) use ($s) {
$qq->where('name', 'like', "%{$s}%")
->orWhere('phone', 'like', "%{$s}%")
->orWhere('email', 'like', "%{$s}%");
});
}
return response()->json($q->orderBy('name')->paginate(min((int) $request->query('per_page', 25), 100)));
}
public function show(Client $client): JsonResponse
{
return response()->json($client->load('vehicles'));
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => 'required|string|max:120',
'phone' => 'required|string|max:40',
'phone_alt' => 'nullable|string|max:40',
'email' => 'nullable|email|max:120',
'company_name' => 'nullable|string|max:160',
'type' => 'in:individual,company',
'status' => 'in:new,active,vip,debtor,blocked,lost',
'discount_pct' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
]);
$client = Client::create($data + ['type' => 'individual', 'status' => 'active']);
return response()->json($client, 201);
}
public function update(Request $request, Client $client): JsonResponse
{
$data = $request->validate([
'name' => 'sometimes|string|max:120',
'phone' => 'sometimes|string|max:40',
'phone_alt' => 'nullable|string|max:40',
'email' => 'nullable|email|max:120',
'company_name' => 'nullable|string|max:160',
'status' => 'in:new,active,vip,debtor,blocked,lost',
'discount_pct' => 'nullable|numeric|min:0|max:100',
'notes' => 'nullable|string',
]);
$client->update($data);
return response()->json($client);
}
public function destroy(Client $client): JsonResponse
{
$client->delete();
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Tenant\Vehicle;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class VehicleApiController
{
public function index(Request $request): JsonResponse
{
$q = Vehicle::query()->with('client:id,name,phone');
if ($s = $request->query('search')) {
$q->where(function ($qq) use ($s) {
$qq->where('plate', 'like', "%{$s}%")
->orWhere('vin', 'like', "%{$s}%")
->orWhere('brand', 'like', "%{$s}%")
->orWhere('model', 'like', "%{$s}%");
});
}
return response()->json($q->orderBy('plate')->paginate(min((int) $request->query('per_page', 25), 100)));
}
public function show(Vehicle $vehicle): JsonResponse
{
return response()->json($vehicle->load(['client', 'workOrders' => fn ($q) => $q->latest()->limit(10)]));
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'client_id' => 'required|exists:clients,id',
'plate' => 'required|string|max:30',
'vin' => 'nullable|string|max:30',
'brand' => 'nullable|string|max:60',
'model' => 'nullable|string|max:60',
'year' => 'nullable|integer|min:1900|max:2100',
'mileage' => 'nullable|integer|min:0',
'fuel' => 'nullable|string|max:30',
'engine' => 'nullable|string|max:60',
'gearbox' => 'nullable|string|max:30',
'color' => 'nullable|string|max:30',
'notes' => 'nullable|string',
]);
$v = Vehicle::create($data);
return response()->json($v, 201);
}
public function update(Request $request, Vehicle $vehicle): JsonResponse
{
$data = $request->validate([
'plate' => 'sometimes|string|max:30',
'mileage' => 'nullable|integer|min:0',
'notes' => 'nullable|string',
]);
$vehicle->update($data);
return response()->json($vehicle);
}
public function destroy(Vehicle $vehicle): JsonResponse
{
$vehicle->delete();
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class WorkOrderApiController
{
public function index(Request $request): JsonResponse
{
$q = WorkOrder::query()->with(['client:id,name,phone', 'vehicle:id,plate,brand,model']);
if ($status = $request->query('status')) {
$q->where('status', $status);
}
if ($s = $request->query('search')) {
$q->where(function ($qq) use ($s) {
$qq->where('number', 'like', "%{$s}%")
->orWhereHas('vehicle', fn ($v) => $v->where('plate', 'like', "%{$s}%"));
});
}
return response()->json($q->latest()->paginate(min((int) $request->query('per_page', 25), 100)));
}
public function show(WorkOrder $workOrder): JsonResponse
{
return response()->json($workOrder->load(['client', 'vehicle', 'master', 'works', 'parts', 'payments']));
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'client_id' => 'required|exists:clients,id',
'vehicle_id' => 'required|exists:vehicles,id',
'master_id' => 'nullable|exists:users,id',
'opened_at' => 'nullable|date',
'mileage_in' => 'nullable|integer|min:0',
'complaint' => 'nullable|string',
'status' => 'in:new,diagnosis,agreement,approved,in_work,awaiting_parts,ready,done,cancelled',
]);
$tenantId = app(TenantManager::class)->currentId();
$data['number'] = WorkOrder::generateNumber($tenantId);
$data['opened_at'] ??= now();
$data['status'] ??= 'new';
$data['pay_status'] = 'unpaid';
$data['discount_pct'] = 0;
$data['total'] = 0;
$wo = WorkOrder::create($data);
return response()->json($wo, 201);
}
public function update(Request $request, WorkOrder $workOrder): JsonResponse
{
$data = $request->validate([
'status' => 'sometimes|in:new,diagnosis,agreement,approved,in_work,awaiting_parts,ready,done,cancelled',
'mileage_out' => 'nullable|integer|min:0',
'diagnosis' => 'nullable|string',
'recommendations' => 'nullable|string',
'discount_pct' => 'nullable|numeric|min:0|max:100',
]);
$workOrder->update($data);
$workOrder->recalcTotal();
return response()->json($workOrder->fresh());
}
public function destroy(WorkOrder $workOrder): JsonResponse
{
$workOrder->delete();
return response()->json(['ok' => true]);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Tenancy\TenantManager;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Defense in depth: even though Sanctum auth + TenantScope should isolate
* tenants, this middleware EXPLICITLY rejects any request where the
* authenticated user's company_id does not match the resolved tenant.
*
* Stops the entire class of "stolen token used on wrong subdomain" attacks.
*/
class EnsureTokenMatchesTenant
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
$tenant = app(TenantManager::class)->current();
if (! $user || ! $tenant) {
throw new AccessDeniedHttpException('Tenant context required.');
}
if ($user->company_id !== $tenant->id) {
throw new AccessDeniedHttpException('Token does not belong to this tenant.');
}
return $next($request);
}
}
+48 -2
View File
@@ -2,21 +2,27 @@
namespace App\Models\Central;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
class SuperAdmin extends Authenticatable implements FilamentUser
class SuperAdmin extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication
{
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
protected $table = 'super_admins';
protected $fillable = [
'name', 'email', 'password', 'is_active', 'last_login_at',
'email_authentication_at',
'app_authentication_secret', 'app_authentication_recovery_codes',
];
protected $hidden = [
@@ -28,8 +34,11 @@ class SuperAdmin extends Authenticatable implements FilamentUser
return [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'email_authentication_at' => 'datetime',
'password' => 'hashed',
'is_active' => 'boolean',
'app_authentication_secret' => 'encrypted',
'app_authentication_recovery_codes' => 'encrypted:array',
];
}
@@ -37,4 +46,41 @@ class SuperAdmin extends Authenticatable implements FilamentUser
{
return $panel->getId() === 'central' && $this->is_active;
}
public function hasEmailAuthentication(): bool
{
return $this->email_authentication_at !== null;
}
public function toggleEmailAuthentication(bool $condition): void
{
$this->forceFill([
'email_authentication_at' => $condition ? now() : null,
])->saveQuietly();
}
public function getAppAuthenticationSecret(): ?string
{
return $this->app_authentication_secret;
}
public function saveAppAuthenticationSecret(?string $secret): void
{
$this->forceFill(['app_authentication_secret' => $secret])->saveQuietly();
}
public function getAppAuthenticationHolderName(): string
{
return $this->email;
}
public function getAppAuthenticationRecoveryCodes(): ?array
{
return $this->app_authentication_recovery_codes;
}
public function saveAppAuthenticationRecoveryCodes(?array $codes): void
{
$this->forceFill(['app_authentication_recovery_codes' => $codes])->saveQuietly();
}
}
+48 -2
View File
@@ -3,12 +3,16 @@
namespace App\Models\Tenant;
use App\Models\Concerns\BelongsToTenant;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
/**
@@ -16,9 +20,9 @@ use Spatie\Permission\Traits\HasRoles;
* UNIQUE(company_id, email) same email can exist in different tenants
* as completely separate accounts.
*/
class User extends Authenticatable implements FilamentUser
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication
{
use BelongsToTenant, HasFactory, HasRoles, Notifiable, SoftDeletes;
use BelongsToTenant, HasApiTokens, HasFactory, HasRoles, Notifiable, SoftDeletes;
/** Spatie Permission scope key matches the team_foreign_key (company_id). */
protected $guard_name = 'web';
@@ -28,6 +32,8 @@ class User extends Authenticatable implements FilamentUser
'role', 'status', 'locale',
'specialization', 'color', 'hourly_rate',
'email_verified_at', 'password', 'last_login_at',
'email_authentication_at',
'app_authentication_secret', 'app_authentication_recovery_codes',
];
protected $hidden = [
@@ -39,7 +45,10 @@ class User extends Authenticatable implements FilamentUser
return [
'email_verified_at' => 'datetime',
'last_login_at' => 'datetime',
'email_authentication_at' => 'datetime',
'password' => 'hashed',
'app_authentication_secret' => 'encrypted',
'app_authentication_recovery_codes' => 'encrypted:array',
];
}
@@ -53,4 +62,41 @@ class User extends Authenticatable implements FilamentUser
{
return $this->role === 'admin';
}
public function hasEmailAuthentication(): bool
{
return $this->email_authentication_at !== null;
}
public function toggleEmailAuthentication(bool $condition): void
{
$this->forceFill([
'email_authentication_at' => $condition ? now() : null,
])->saveQuietly();
}
public function getAppAuthenticationSecret(): ?string
{
return $this->app_authentication_secret;
}
public function saveAppAuthenticationSecret(?string $secret): void
{
$this->forceFill(['app_authentication_secret' => $secret])->saveQuietly();
}
public function getAppAuthenticationHolderName(): string
{
return $this->email;
}
public function getAppAuthenticationRecoveryCodes(): ?array
{
return $this->app_authentication_recovery_codes;
}
public function saveAppAuthenticationRecoveryCodes(?array $codes): void
{
$this->forceFill(['app_authentication_recovery_codes' => $codes])->saveQuietly();
}
}
@@ -37,6 +37,11 @@ class CentralPanelProvider extends PanelProvider
])
->authGuard('central')
->authPasswordBroker('super_admins')
->multiFactorAuthentication([
\Filament\Auth\MultiFactor\App\AppAuthentication::make(),
\Filament\Auth\MultiFactor\Email\EmailAuthentication::make(),
])
->profile()
->discoverResources(in: app_path('Filament/Central/Resources'), for: 'App\\Filament\\Central\\Resources')
->discoverPages(in: app_path('Filament/Central/Pages'), for: 'App\\Filament\\Central\\Pages')
->pages([
@@ -41,6 +41,11 @@ class TenantPanelProvider extends PanelProvider
->databaseNotifications()
->databaseNotificationsPolling('30s')
->globalSearchKeyBindings(['command+k', 'ctrl+k'])
->multiFactorAuthentication([
\Filament\Auth\MultiFactor\App\AppAuthentication::make(),
\Filament\Auth\MultiFactor\Email\EmailAuthentication::make(),
])
->profile()
->discoverResources(in: app_path('Filament/Tenant/Resources'), for: 'App\\Filament\\Tenant\\Resources')
->discoverPages(in: app_path('Filament/Tenant/Pages'), for: 'App\\Filament\\Tenant\\Pages')
->pages([
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace App\Services;
use App\Models\Tenant\Client;
use App\Models\Tenant\Vehicle;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Plain-CSV import/export for the most-imported entities (Clients, Vehicles).
* No external package uses fputcsv / fgetcsv with explicit BOM for Excel UTF-8.
*/
class CsvImportExport
{
public const CLIENT_COLUMNS = [
'name', 'phone', 'phone_alt', 'email', 'company_name',
'type', 'status', 'source', 'marketing_channel',
'discount_pct', 'balance', 'notes',
];
public const VEHICLE_COLUMNS = [
'plate', 'vin', 'brand', 'model', 'year',
'engine', 'gearbox', 'fuel', 'mileage',
'color', 'notes', 'client_phone',
];
public function exportClients(): StreamedResponse
{
return $this->stream('clienti-' . date('Y-m-d') . '.csv', self::CLIENT_COLUMNS, function ($out) {
Client::orderBy('name')->chunk(500, function ($rows) use ($out) {
foreach ($rows as $row) {
fputcsv($out, [
$row->name, $row->phone, $row->phone_alt, $row->email, $row->company_name,
$row->type, $row->status, $row->source, $row->marketing_channel,
$row->discount_pct, $row->balance, $row->notes,
]);
}
});
});
}
public function exportVehicles(): StreamedResponse
{
return $this->stream('automobile-' . date('Y-m-d') . '.csv', self::VEHICLE_COLUMNS, function ($out) {
Vehicle::with('client:id,phone')->orderBy('plate')->chunk(500, function ($rows) use ($out) {
foreach ($rows as $row) {
fputcsv($out, [
$row->plate, $row->vin, $row->brand, $row->model, $row->year,
$row->engine, $row->gearbox, $row->fuel, $row->mileage,
$row->color, $row->notes, $row->client?->phone,
]);
}
});
});
}
/**
* Import clients from CSV. Returns ['imported' => N, 'skipped' => M, 'errors' => [...]].
*/
public function importClients(string $absolutePath): array
{
return $this->import($absolutePath, self::CLIENT_COLUMNS, function (array $row) {
$phone = $row['phone'] ?? null;
if (empty($phone) || empty($row['name'])) {
return ['skip', 'phone & name required'];
}
$existing = Client::where('phone', $phone)->first();
if ($existing) {
return ['skip', 'phone already exists'];
}
Client::create([
'name' => $row['name'],
'phone' => $phone,
'phone_alt' => $row['phone_alt'] ?? null,
'email' => $row['email'] ?? null,
'company_name' => $row['company_name'] ?? null,
'type' => $row['type'] ?? 'individual',
'status' => $row['status'] ?? 'active',
'source' => $row['source'] ?? null,
'marketing_channel' => $row['marketing_channel'] ?? null,
'discount_pct' => (float) ($row['discount_pct'] ?? 0),
'balance' => (float) ($row['balance'] ?? 0),
'notes' => $row['notes'] ?? null,
]);
return ['ok', null];
});
}
public function importVehicles(string $absolutePath): array
{
return $this->import($absolutePath, self::VEHICLE_COLUMNS, function (array $row) {
if (empty($row['plate'])) {
return ['skip', 'plate required'];
}
$client = null;
if (! empty($row['client_phone'])) {
$client = Client::where('phone', $row['client_phone'])->first();
}
if (! $client) {
return ['skip', 'client_phone not found'];
}
if (Vehicle::where('plate', $row['plate'])->exists()) {
return ['skip', 'plate already exists'];
}
Vehicle::create([
'client_id' => $client->id,
'plate' => $row['plate'],
'vin' => $row['vin'] ?? null,
'brand' => $row['brand'] ?? null,
'model' => $row['model'] ?? null,
'year' => (int) ($row['year'] ?? 0) ?: null,
'engine' => $row['engine'] ?? null,
'gearbox' => $row['gearbox'] ?? null,
'fuel' => $row['fuel'] ?? null,
'mileage' => (int) ($row['mileage'] ?? 0) ?: null,
'color' => $row['color'] ?? null,
'notes' => $row['notes'] ?? null,
]);
return ['ok', null];
});
}
private function stream(string $filename, array $columns, \Closure $writer): StreamedResponse
{
return response()->streamDownload(function () use ($columns, $writer) {
$out = fopen('php://output', 'w');
// UTF-8 BOM so Excel opens with correct diacritics.
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, $columns);
$writer($out);
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=utf-8']);
}
private function import(string $absolutePath, array $columns, \Closure $rowHandler): array
{
if (! file_exists($absolutePath)) {
return ['imported' => 0, 'skipped' => 0, 'errors' => ['file not found']];
}
$h = fopen($absolutePath, 'r');
// Strip UTF-8 BOM if present.
$bom = fread($h, 3);
if ($bom !== "\xEF\xBB\xBF") {
rewind($h);
}
$header = fgetcsv($h);
if (! $header) {
fclose($h);
return ['imported' => 0, 'skipped' => 0, 'errors' => ['empty file']];
}
$header = array_map('trim', $header);
$imported = 0;
$skipped = 0;
$errors = [];
$line = 1;
DB::beginTransaction();
try {
while (($cells = fgetcsv($h)) !== false) {
$line++;
if (count($cells) !== count($header)) {
$errors[] = "L{$line}: column count mismatch";
$skipped++;
continue;
}
$row = array_combine($header, array_map(fn ($c) => $c === '' ? null : $c, $cells));
[$status, $err] = $rowHandler($row);
if ($status === 'ok') {
$imported++;
} else {
$skipped++;
if ($err) $errors[] = "L{$line}: {$err}";
}
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
$errors[] = 'fatal: ' . $e->getMessage();
}
fclose($h);
return ['imported' => $imported, 'skipped' => $skipped, 'errors' => array_slice($errors, 0, 50)];
}
}