mandant-crm/backend/app/Modules/Import/Actions/ImportClientsAction.php
Tim Stollberg f0bb80707a feat(backend): commit uncommitted CustomField, dynamic HistoryType, and Import modules
Mirrors the frontend catch-up in 570f17e: a substantial amount of
finished-but-never-committed backend work had accumulated locally.

- New CustomField module: admin-managed field definitions per client,
  reorderable, typed values stored per client (backing the frontend's
  ClientCustomFieldCard + settings/fields page)
- History entries switch from a hardcoded type enum to a dynamic,
  admin-managed HistoryType module (reorderable, backing
  settings/history-types)
- New Import module: CSV client import (backing settings/import)
- Clients table gains soft deletes (deleted_at)
- Supporting factories, seeders, policies, and feature tests for all
  of the above

All 63 backend tests pass locally before this commit.
2026-07-18 16:34:18 +07:00

225 lines
7.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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',
],
);
}
}