mandant-crm/backend/app/Modules/Import/Actions/ImportClientsAction.php

226 lines
7.7 KiB
PHP
Raw Normal View History

<?php
namespace App\Modules\Import\Actions;
use App\Models\User;
use App\Modules\Client\Models\Client;
use App\Modules\CustomField\Actions\UpdateCustomFieldValueAction;
use App\Modules\CustomField\Models\CustomFieldDefinition;
use App\Modules\CustomField\Models\CustomFieldValue;
use App\Modules\Import\DTOs\ImportResult;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use League\Csv\Reader;
use RuntimeException;
use Throwable;
/**
* Imports clients from the legacy semicolon-delimited CSV export.
*
* Per row, keyed on "Mandantennr." (client_number):
* - new number create client + populate custom fields, silently (no history)
* - existing number skip, unless "Ueberschreiben" = "ja" (case-insensitive),
* in which case the mapped custom fields are updated (blanks
* skipped) and each change is audited via the shared
* UpdateCustomFieldValueAction, authored by a system user.
*/
final class ImportClientsAction
{
/** Custom field definition keys the import maps onto. */
private const FIELD_KEYS = ['telefonnummer', 'e_mail', 'ansprechpartner', 'anschrift'];
private const OVERWRITE_TRIGGER = 'ja';
private const IMPORT_USER_NAME = 'Mandanten-Import';
private const IMPORT_USER_EMAIL = 'import@system.local';
public function __construct(
private readonly UpdateCustomFieldValueAction $updateValue,
) {}
public function handle(string $path): ImportResult
{
/** @var \Illuminate\Support\Collection<string, CustomFieldDefinition> $definitions */
$definitions = CustomFieldDefinition::query()
->whereIn('key', self::FIELD_KEYS)
->get()
->keyBy('key');
$missing = array_diff(self::FIELD_KEYS, $definitions->keys()->all());
if ($missing !== []) {
throw new RuntimeException(
'Fehlende Custom-Field-Definitionen: '.implode(', ', $missing).
'. Bitte den CustomFieldDefinitionSeeder ausführen.'
);
}
$importUser = $this->systemUser();
$rows = $this->readRows($path);
if ($rows === []) {
return new ImportResult(0, 0, 0, 0, []);
}
$header = array_map(static fn (string $h): string => trim($h), array_shift($rows));
/** @var array<string, int> $index header name → column offset */
$index = array_flip($header);
$created = 0;
$updated = 0;
$skipped = 0;
/** @var array<int, array{row: int, client_number: ?string, message: string}> $errors */
$errors = [];
foreach ($rows as $offset => $row) {
$rowNumber = (int) $offset + 2; // +1 for header, +1 for 1-based
$clientNumber = null;
try {
$clientNumber = $this->value($row, $index, 'Mandantennr.');
if ($clientNumber === null) {
$errors[] = ['row' => $rowNumber, 'client_number' => null, 'message' => 'Keine Mandantennummer.'];
continue;
}
$mapped = $this->mapFields($row, $index);
$client = Client::query()->where('client_number', $clientNumber)->first();
if ($client !== null) {
if (! $this->isOverwrite($row, $index)) {
$skipped++;
continue;
}
foreach (self::FIELD_KEYS as $key) {
if ($mapped[$key] === null) {
continue; // skip blanks — never wipe existing data
}
$this->updateValue->handle($client, $definitions[$key], $mapped[$key], $importUser);
}
$updated++;
continue;
}
$client = Client::create([
'client_number' => $clientNumber,
'name' => $this->value($row, $index, 'Mandant') ?? $clientNumber,
]);
foreach (self::FIELD_KEYS as $key) {
if ($mapped[$key] === null) {
continue;
}
CustomFieldValue::create([
'client_id' => $client->id,
'custom_field_definition_id' => $definitions[$key]->id,
'value' => $mapped[$key],
'author_id' => $importUser->id,
]);
}
$created++;
} catch (Throwable $e) {
$errors[] = ['row' => $rowNumber, 'client_number' => $clientNumber, 'message' => $e->getMessage()];
}
}
return new ImportResult(count($rows), $created, $updated, $skipped, $errors);
}
/**
* Read the CSV into numeric record arrays (no header offset) so ragged data
* rows far shorter than the ~66-column header never throw. Decodes the
* legacy ISO-8859-1 encoding to UTF-8 and strips a UTF-8 BOM if present.
*
* @return array<int, array<int, string>>
*/
private function readRows(string $path): array
{
$contents = (string) file_get_contents($path);
$contents = preg_replace('/^\xEF\xBB\xBF/', '', $contents) ?? $contents;
if (! mb_check_encoding($contents, 'UTF-8')) {
$contents = mb_convert_encoding($contents, 'UTF-8', 'Windows-1252');
}
$csv = Reader::createFromString($contents);
$csv->setDelimiter(';');
return array_values(iterator_to_array($csv->getRecords(), false));
}
/**
* Map the four target custom fields from a row. Anschrift 18 are joined
* with newlines, blank parts dropped.
*
* @param array<int, string> $row
* @param array<string, int> $index
* @return array<string, ?string>
*/
private function mapFields(array $row, array $index): array
{
$address = [];
for ($n = 1; $n <= 8; $n++) {
$part = $this->value($row, $index, "Anschrift {$n}");
if ($part !== null) {
$address[] = $part;
}
}
return [
'telefonnummer' => $this->value($row, $index, 'Telefon'),
'e_mail' => $this->value($row, $index, 'E-Mail'),
'ansprechpartner' => $this->value($row, $index, 'Ansprechpartner 1'),
'anschrift' => $address === [] ? null : implode("\n", $address),
];
}
/**
* @param array<int, string> $row
* @param array<string, int> $index
*/
private function isOverwrite(array $row, array $index): bool
{
$flag = $this->value($row, $index, 'Ueberschreiben');
return $flag !== null && mb_strtolower($flag) === self::OVERWRITE_TRIGGER;
}
/**
* Resolve a cell by header name (bounds-safe for ragged rows), trim it,
* strip a single leading Excel apostrophe, and normalise empty null.
*
* @param array<int, string> $row
* @param array<string, int> $index
*/
private function value(array $row, array $index, string $name): ?string
{
if (! array_key_exists($name, $index)) {
return null;
}
$raw = $row[$index[$name]] ?? '';
$raw = trim($raw);
if ($raw !== '' && $raw[0] === "'") {
$raw = trim(substr($raw, 1));
}
return $raw === '' ? null : $raw;
}
private function systemUser(): User
{
return User::firstOrCreate(
['email' => self::IMPORT_USER_EMAIL],
[
'name' => self::IMPORT_USER_NAME,
'password' => Hash::make(Str::random(40)),
'role' => 'staff',
],
);
}
}