226 lines
7.7 KiB
PHP
226 lines
7.7 KiB
PHP
|
|
<?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 1–8 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',
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|