502 lines
17 KiB
PHP
502 lines
17 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
use objects\logs_o;
|
|
use objects\users_o;
|
|
|
|
class customer_mass_import_service
|
|
{
|
|
/**
|
|
* Import or create a company customer from one normalized spreadsheet row.
|
|
*
|
|
* @throws \RuntimeException
|
|
*/
|
|
public function import(array $payload): array
|
|
{
|
|
$normalized = $this->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;
|
|
}
|
|
}
|