eaa05d68c1
- 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
185 lines
7.0 KiB
PHP
185 lines
7.0 KiB
PHP
<?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)];
|
|
}
|
|
}
|