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
+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)];
}
}