normalizePayload($payload); $this->assertValidNormalizedPayload($normalized); $customerNumber = (int)$normalized['customer_number']; $cvr = (string)$normalized['cvr']; $warnings = []; $economicCustomers = $this->searchEconomicCustomersByCvr($cvr); $localUserExistsBefore = $this->localCustomerNumberExists($customerNumber); $localUser = $localUserExistsBefore ? $this->loadLocalCustomerByNumber($customerNumber) : null; $matchingEconomicCustomer = $this->findEconomicCustomerByNumber($economicCustomers, $customerNumber); if ($matchingEconomicCustomer !== null) { $customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser); $this->syncLocalCustomer($customer, $normalized, $warnings); [$action, $message] = $this->resolveExistingCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer)); return $this->buildSuccessResult( $normalized, $customer, $action, $message, $localUserExistsBefore, true, false, $warnings ); } if (count($economicCustomers) > 0) { $existingEconomicCustomerNumber = $this->extractEconomicCustomerNumber($economicCustomers[0]); $this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [ 'phase' => 'search', 'cvr' => $cvr, 'requestedCustomerNumber' => $customerNumber, 'existingCustomerNumber' => $existingEconomicCustomerNumber, ]); throw new \RuntimeException( 'CVR already registered under customer number ' . $existingEconomicCustomerNumber . '. The submitted phone number must match the customer id. Manual cleanup or reassignment is required before retrying.', 409 ); } $normalized['name'] = $this->resolveCreateName($normalized); $normalized['email'] = $this->resolveCreateEmail($normalized, $warnings); $createResponse = $this->createEconomicCustomer($normalized); $createdCustomerNumber = $this->extractEconomicCustomerNumber($createResponse); if ($createdCustomerNumber !== $customerNumber) { $this->logIssue('CUSTOMER_MASS_IMPORT_CONFLICT', [ 'phase' => 'create', 'cvr' => $cvr, 'requestedCustomerNumber' => $customerNumber, 'createdCustomerNumber' => $createdCustomerNumber, 'response' => $createResponse, ]); throw new \RuntimeException( 'E-conomic created the customer under customer number ' . $createdCustomerNumber . ' instead of the submitted phone number ' . $customerNumber . '. Manual cleanup or reassignment is required before retrying.', 409 ); } $customer = $this->resolveLocalCustomer($customerNumber, $localUserExistsBefore, $localUser); $this->syncLocalCustomer($customer, $normalized, $warnings); [$action, $message] = $this->resolveCreatedCustomerOutcome($localUserExistsBefore, $this->hasLocalAccount($customer)); return $this->buildSuccessResult( $normalized, $customer, $action, $message, $localUserExistsBefore, false, true, $warnings ); } protected function normalizePayload(array $payload): array { return [ 'customer_number' => $this->normalizePositiveInt($payload['customer_number'] ?? $payload['phone'] ?? null), 'phone' => $this->normalizePositiveInt($payload['phone'] ?? $payload['customer_number'] ?? null), 'cvr' => $this->normalizeDigitString($payload['cvr'] ?? null), 'name' => $this->normalizeText($payload['name'] ?? $payload['company_name'] ?? null), 'email' => $this->normalizeEmail($payload['email'] ?? null), 'ean' => $this->normalizeDigitString($payload['ean'] ?? null), ]; } protected function assertValidNormalizedPayload(array $normalized): void { $customerNumber = $normalized['customer_number']; $cvr = $normalized['cvr']; if ($customerNumber === null) { throw new \RuntimeException('Phone number is required.', 400); } $customerNumberLength = strlen((string)$customerNumber); if ($customerNumberLength < 8 || $customerNumberLength > 10) { throw new \RuntimeException('Phone number must be between 8 and 10 digits.', 400); } if ($cvr === null) { throw new \RuntimeException('CVR is required.', 400); } $cvrLength = strlen($cvr); if ($cvrLength < 8 || $cvrLength > 20) { throw new \RuntimeException('CVR must be between 8 and 20 digits.', 400); } if ($normalized['ean'] !== null && strlen((string)$normalized['ean']) > 13) { throw new \RuntimeException('EAN must be at most 13 digits.', 400); } } protected function normalizePositiveInt(mixed $value): ?int { $digits = $this->normalizeDigitString($value); if ($digits === null) { return null; } $normalized = (int)$digits; return $normalized > 0 ? $normalized : null; } protected function normalizeDigitString(mixed $value): ?string { if ($value === null) { return null; } $digits = preg_replace('/\D+/', '', (string)$value); if (!is_string($digits)) { return null; } $digits = trim($digits); return $digits !== '' ? $digits : null; } protected function normalizeText(mixed $value): ?string { if ($value === null) { return null; } $normalized = trim((string)$value); return $normalized !== '' ? $normalized : null; } protected function normalizeEmail(mixed $value): ?string { $email = $this->normalizeText($value); if ($email === null) { return null; } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new \RuntimeException('Invalid email address.', 400); } return $email; } protected function resolveCreateName(array $normalized): string { if ($normalized['name'] !== null) { return $normalized['name']; } $name = trim($this->fetchCompanyNameByCvr((string)$normalized['cvr'])); if ($name === '') { throw new \RuntimeException('Customer name is required to create a new company.', 400); } return $name; } protected function resolveCreateEmail(array $normalized, array &$warnings): string { if ($normalized['email'] !== null) { return $normalized['email']; } $warnings[] = 'No email was provided, defaulted to jb@truckwash.dk for the new e-conomic customer.'; return 'jb@truckwash.dk'; } protected function searchEconomicCustomersByCvr(string $cvr): array { $response = (new economic())->customers->customers->search([ 'corporateIdentificationNumber' => $cvr, ], [ 'skipPages' => 0, 'pageSize' => 1000, ])->collection ?? []; return is_array($response) ? $response : []; } protected function createEconomicCustomer(array $normalized): object { $payload = [ 'customerNumber' => (int)$normalized['customer_number'], 'corporateIdentificationNumber' => (string)$normalized['cvr'], 'customerGroup' => [ 'customerGroupNumber' => 1, ], 'paymentTerms' => [ 'paymentTermsNumber' => 12, ], 'name' => (string)$normalized['name'], 'email' => (string)$normalized['email'], 'phone' => (int)$normalized['phone'], 'telephoneAndFaxNumber' => (string)$normalized['phone'], 'mobilePhone' => (string)$normalized['phone'], 'currency' => 'DKK', 'vatZone' => [ 'vatZoneNumber' => 1, ], ]; if ($normalized['ean'] !== null) { $payload['ean'] = (string)$normalized['ean']; } return (new economic())->customers->customers->create($payload); } protected function localCustomerNumberExists(int $customerNumber): bool { $rows = (new users_o())->getFieldsWhere([ 'customer_number' => (string)$customerNumber, ], ['id']); return count($rows) > 0; } protected function loadLocalCustomerByNumber(int $customerNumber): ?object { $customer = (new users_o())->getUserByCustomerNumber($customerNumber); return $this->localUserExists($customer) ? $customer : null; } protected function resolveLocalCustomer(int $customerNumber, bool $localUserExistsBefore, ?object $localUser): object { if ($localUserExistsBefore && $this->localUserExists($localUser)) { return $localUser; } return $this->bootstrapLocalCustomerOrFail($customerNumber); } protected function bootstrapLocalCustomerOrFail(int $customerNumber): object { $customer = (new users_o())->getUserByCustomerNumber($customerNumber); if ($this->localUserExists($customer)) { return $customer; } $this->logIssue('CUSTOMER_MASS_IMPORT_LOCAL_BOOTSTRAP_FAILED', [ 'customerNumber' => $customerNumber, ]); throw new \RuntimeException('Customer was created in e-conomic but could not be imported locally.', 500); } protected function fetchCompanyNameByCvr(string $cvr): string { return (string)((new virkdata())->getCompanyInformation($cvr, '', [])->name ?? ''); } protected function logIssue(string $action, array $context): void { $message = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($message === false) { $message = 'Unable to encode customer mass import context'; } (new logs_o())->add('customers', 'global', 0, 0, $action, $message); } protected function findEconomicCustomerByNumber(array $customers, int $customerNumber): ?object { foreach ($customers as $customer) { if (!is_object($customer)) { continue; } if ($this->extractEconomicCustomerNumber($customer) === $customerNumber) { return $customer; } } return null; } protected function extractEconomicCustomerNumber(object $customer): int { if (!isset($customer->customerNumber) || !is_numeric($customer->customerNumber)) { return 0; } return (int)$customer->customerNumber; } protected function resolveExistingCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array { if ($localUserExistsBefore && $hasAccount) { return [ 'account_already_exists', 'Customer already exists locally and already has a login account.', ]; } if ($localUserExistsBefore) { return [ 'customer_already_exists', 'Customer already exists locally but does not have a login password yet.', ]; } return [ 'imported_existing_customer', 'Imported an existing e-conomic customer into the local customer database.', ]; } protected function resolveCreatedCustomerOutcome(bool $localUserExistsBefore, bool $hasAccount): array { if ($localUserExistsBefore && $hasAccount) { return [ 'economic_customer_created_for_existing_account', 'Created the e-conomic customer for an existing local login account.', ]; } if ($localUserExistsBefore) { return [ 'economic_customer_created_for_existing_customer', 'Created the e-conomic customer for an existing local customer record.', ]; } return [ 'created_customer', 'Created the customer in e-conomic and imported it locally.', ]; } protected function buildSuccessResult( array $normalized, object $customer, string $action, string $message, bool $existingLocalCustomer, bool $existingEconomicCustomer, bool $createdEconomicCustomer, array $warnings ): array { $customerName = $this->extractLocalUserDisplayName($customer) ?? $normalized['name']; return [ 'customer_number' => (int)$normalized['customer_number'], 'cvr' => (string)$normalized['cvr'], 'name' => $customerName, 'email' => $normalized['email'], 'ean' => $normalized['ean'], 'action' => $action, 'message' => $message, 'user_id' => $this->extractLocalUserId($customer), 'has_account' => $this->hasLocalAccount($customer), 'existing_local_customer' => $existingLocalCustomer, 'existing_economic_customer' => $existingEconomicCustomer, 'created_economic_customer' => $createdEconomicCustomer, 'warnings' => array_values(array_filter($warnings, static fn(mixed $warning): bool => is_string($warning) && trim($warning) !== '')), ]; } protected function syncLocalCustomer(object $customer, array $normalized, array &$warnings): void { if (!$customer instanceof users_o || !$customer->exists()) { return; } $name = $normalized['name'] ?? null; $email = $normalized['email'] ?? null; $phone = $normalized['phone'] ?? null; $displayName = trim((string)($customer->display_name->value() ?? '')); if ($name !== null && ($displayName === '' || strtolower($displayName) === 'unnamed')) { $customer->display_name->set($name); } if ($email !== null && trim((string)($customer->email->value() ?? '')) === '') { try { $customer->setEmail($email); } catch (\Throwable $throwable) { $warnings[] = 'Unable to update local email: ' . $throwable->getMessage(); } } if ($phone !== null && empty($customer->phone->value())) { try { $customer->setPhoneNumber((int)$phone); } catch (\Throwable $throwable) { $warnings[] = 'Unable to update local phone number: ' . $throwable->getMessage(); } } } protected function localUserExists(?object $user): bool { if (!is_object($user)) { return false; } if (method_exists($user, 'exists')) { try { return (bool)$user->exists(); } catch (\Throwable) { return false; } } return isset($user->id) && is_numeric($user->id) && (int)$user->id > 0; } protected function hasLocalAccount(?object $user): bool { if (!$this->localUserExists($user)) { return false; } if (method_exists($user, 'hasPassword')) { try { return (bool)$user->hasPassword(); } catch (\Throwable) { return false; } } return (bool)($user->has_password ?? false); } protected function extractLocalUserId(?object $user): ?int { if (!is_object($user) || !isset($user->id) || !is_numeric($user->id)) { return null; } $userId = (int)$user->id; return $userId > 0 ? $userId : null; } protected function extractLocalUserDisplayName(?object $user): ?string { if (!is_object($user)) { return null; } if ($user instanceof users_o) { $name = trim((string)($user->display_name->value() ?? '')); return $name !== '' ? $name : null; } $name = trim((string)($user->display_name ?? $user->name ?? '')); return $name !== '' ? $name : null; } }