Fix queue export 400 by aligning e-conomic reference payloads (#351)

## Context
Queue job **#3572** failed in `COLLECTED_INVOICE_EXPORT` with e-conomic
HTTP 400 (`Validation failed. 2 errors found.`) while creating draft for
customer `35131752`.

## Fix
- align draft payload optional references to e-conomic object references
(not id-only fragments):
  - `recipient.attention`
  - `references.customerContact`
  - `references.salesPerson`
  - `references.vendorReference` (legacy path)
  - `deliveryLocation`
- include upstream `self` links when available from customer payload
- keep both collected and legacy draft creation paths consistent
- improve e-conomic error formatting so nested annotated validation
errors and `developerHint` are included in thrown messages

## Tests
- `vendor/bin/pest
tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php
tests/Unit/Invoicing/EconomicLegacyDraftPayloadWiringTest.php
tests/Unit/Invoicing/EconomicUpstreamErrorFormattingTest.php
tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php
tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php
tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php
--colors=never`

Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jeppe B
2026-08-05 14:04:59 +00:00
committed by GitHub
co-authored by Jeppe Bundgaard Copilot
parent bd2ff1be9a
commit 8735bae8d5
8 changed files with 242 additions and 67 deletions
@@ -155,14 +155,15 @@ class economic_m
$details = []; $details = [];
if (isset($decoded['errors']) && is_array($decoded['errors'])) { if (isset($decoded['errors']) && is_array($decoded['errors'])) {
$safeErrors = []; $flattenedErrors = array_values(array_unique($this->flatten_economic_errors($decoded['errors'], 'errors')));
foreach ( $decoded['errors'] as $error ) { if (!empty($flattenedErrors)) {
if (is_scalar($error)) { $details['errors'] = $flattenedErrors;
$safeErrors[] = (string)$error;
}
} }
if (!empty($safeErrors)) { }
$details['errors'] = $safeErrors; if (isset($decoded['developerHint']) && is_string($decoded['developerHint'])) {
$developerHint = trim($decoded['developerHint']);
if ($developerHint !== '') {
$details['developerHint'] = $developerHint;
} }
} }
@@ -184,4 +185,45 @@ class economic_m
. ' | details=' . ' | details='
. json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); . json_encode($details, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} }
/**
* @return string[]
*/
protected function flatten_economic_errors(mixed $value, string $path = ''): array
{
if (is_string($value)) {
$message = trim($value);
if ($message === '') {
return [];
}
return [$path !== '' ? ($path . ': ' . $message) : $message];
}
if (!is_array($value)) {
return [];
}
$messages = [];
if (isset($value['errorMessage']) && is_string($value['errorMessage'])) {
$errorMessage = trim($value['errorMessage']);
if ($errorMessage !== '') {
$messages[] = $path !== '' ? ($path . ': ' . $errorMessage) : $errorMessage;
}
}
foreach ($value as $key => $child) {
if ($key === 'errorMessage') {
continue;
}
$segment = is_int($key) ? ('[' . $key . ']') : (string)$key;
$childPath = $path === ''
? $segment
: (is_int($key) ? ($path . $segment) : ($path . '.' . $segment));
foreach ($this->flatten_economic_errors($child, $childPath) as $childMessage) {
$messages[] = $childMessage;
}
}
return $messages;
}
} }
@@ -147,27 +147,21 @@ class economic_invoices_drafts_endpoint
if ($public_entry_number !== null) { if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number; $recipient['publicEntryNumber'] = $public_entry_number;
} }
$attention_customer_contact_number = $customer->getAttentionCustomerContactNumber(); $attention_reference = $customer->getAttentionReference();
if ($attention_customer_contact_number !== null) { if ($attention_reference !== null) {
$recipient['attention'] = [ $recipient['attention'] = $attention_reference;
'customerContactNumber' => $attention_customer_contact_number,
];
} }
$references = [ $references = [
'other' => $external_id 'other' => $external_id
]; ];
$reference_customer_contact_number = $customer->getReferenceCustomerContactNumber(); $customer_contact_reference = $customer->getReferenceCustomerContact();
if ($reference_customer_contact_number !== null) { if ($customer_contact_reference !== null) {
$references['customerContact'] = [ $references['customerContact'] = $customer_contact_reference;
'customerContactNumber' => $reference_customer_contact_number,
];
} }
$sales_person_employee_number = $customer->getSalesPersonEmployeeNumber(); $sales_person_reference = $customer->getSalesPersonReference();
if ($sales_person_employee_number !== null) { if ($sales_person_reference !== null) {
$references['salesPerson'] = [ $references['salesPerson'] = $sales_person_reference;
'employeeNumber' => $sales_person_employee_number,
];
} }
$payload = [ $payload = [
@@ -204,11 +198,9 @@ class economic_invoices_drafts_endpoint
'recipient' => $recipient, 'recipient' => $recipient,
]; ];
$default_delivery_location_number = $customer->getDefaultDeliveryLocationNumber(); $default_delivery_location_reference = $customer->getDefaultDeliveryLocationReference();
if ($default_delivery_location_number !== null) { if ($default_delivery_location_reference !== null) {
$payload['deliveryLocation'] = [ $payload['deliveryLocation'] = $default_delivery_location_reference;
'deliveryLocationNumber' => $default_delivery_location_number,
];
} }
// Send the request // Send the request
@@ -164,31 +164,61 @@ class economic_customer
public function getAttentionCustomerContactNumber(): ?int public function getAttentionCustomerContactNumber(): ?int
{ {
self::requireSelected(); self::requireSelected();
return $this->nestedPositiveIntField(['attention', 'customerContactNumber']); return $this->getAttentionReference()['customerContactNumber'] ?? null;
} }
public function getReferenceCustomerContactNumber(): ?int public function getReferenceCustomerContactNumber(): ?int
{ {
self::requireSelected(); self::requireSelected();
return $this->nestedPositiveIntField(['customerContact', 'customerContactNumber']); return $this->getReferenceCustomerContact()['customerContactNumber'] ?? null;
} }
public function getSalesPersonEmployeeNumber(): ?int public function getSalesPersonEmployeeNumber(): ?int
{ {
self::requireSelected(); self::requireSelected();
return $this->nestedPositiveIntField(['salesPerson', 'employeeNumber']); return $this->getSalesPersonReference()['employeeNumber'] ?? null;
} }
public function getVendorReferenceEmployeeNumber(): ?int public function getVendorReferenceEmployeeNumber(): ?int
{ {
self::requireSelected(); self::requireSelected();
return $this->nestedPositiveIntField(['vendorReference', 'employeeNumber']); return $this->getVendorReferenceReference()['employeeNumber'] ?? null;
} }
public function getDefaultDeliveryLocationNumber(): ?int public function getDefaultDeliveryLocationNumber(): ?int
{ {
self::requireSelected(); self::requireSelected();
return $this->nestedPositiveIntField(['defaultDeliveryLocation', 'deliveryLocationNumber']); return $this->getDefaultDeliveryLocationReference()['deliveryLocationNumber'] ?? null;
}
public function getAttentionReference(): ?array
{
self::requireSelected();
return $this->nestedReferenceField(['attention'], 'customerContactNumber');
}
public function getReferenceCustomerContact(): ?array
{
self::requireSelected();
return $this->nestedReferenceField(['customerContact'], 'customerContactNumber');
}
public function getSalesPersonReference(): ?array
{
self::requireSelected();
return $this->nestedReferenceField(['salesPerson'], 'employeeNumber');
}
public function getVendorReferenceReference(): ?array
{
self::requireSelected();
return $this->nestedReferenceField(['vendorReference'], 'employeeNumber');
}
public function getDefaultDeliveryLocationReference(): ?array
{
self::requireSelected();
return $this->nestedReferenceField(['defaultDeliveryLocation'], 'deliveryLocationNumber');
} }
protected function nullableStringField(string $field): ?string protected function nullableStringField(string $field): ?string
@@ -206,6 +236,51 @@ class economic_customer
* @param string[] $segments * @param string[] $segments
*/ */
protected function nestedPositiveIntField(array $segments): ?int protected function nestedPositiveIntField(array $segments): ?int
{
$value = $this->nestedObjectField($segments);
if (!is_int($value) && !is_float($value) && !is_string($value)) {
return null;
}
$number = (int)$value;
return $number > 0 ? $number : null;
}
/**
* @param string[] $segments
*/
protected function nestedReferenceField(array $segments, string $idField): ?array
{
$source = $this->nestedObjectField($segments);
if (!is_object($source)) {
return null;
}
$idValue = $source->{$idField} ?? null;
if (!is_int($idValue) && !is_float($idValue) && !is_string($idValue)) {
return null;
}
$id = (int)$idValue;
if ($id <= 0) {
return null;
}
$reference = [
$idField => $id,
];
$self = $source->self ?? null;
if (is_string($self) && trim($self) !== '') {
$reference['self'] = trim($self);
}
return $reference;
}
/**
* @param string[] $segments
*/
protected function nestedObjectField(array $segments): mixed
{ {
$value = $this->customer_data_object; $value = $this->customer_data_object;
foreach ($segments as $segment) { foreach ($segments as $segment) {
@@ -215,12 +290,7 @@ class economic_customer
$value = $value->{$segment}; $value = $value->{$segment};
} }
if (!is_int($value) && !is_float($value) && !is_string($value)) { return $value;
return null;
}
$number = (int)$value;
return $number > 0 ? $number : null;
} }
/** /**
@@ -63,31 +63,23 @@ class economic_invoice_draft_mo extends economicInvoicesDrafts
if ($public_entry_number !== null) { if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number; $recipient['publicEntryNumber'] = $public_entry_number;
} }
$attention_customer_contact_number = $customer->getAttentionCustomerContactNumber(); $attention_reference = $customer->getAttentionReference();
if ($attention_customer_contact_number !== null) { if ($attention_reference !== null) {
$recipient['attention'] = [ $recipient['attention'] = $attention_reference;
'customerContactNumber' => $attention_customer_contact_number,
];
} }
$references = []; $references = [];
$reference_customer_contact_number = $customer->getReferenceCustomerContactNumber(); $customer_contact_reference = $customer->getReferenceCustomerContact();
if ($reference_customer_contact_number !== null) { if ($customer_contact_reference !== null) {
$references['customerContact'] = [ $references['customerContact'] = $customer_contact_reference;
'customerContactNumber' => $reference_customer_contact_number,
];
} }
$sales_person_employee_number = $customer->getSalesPersonEmployeeNumber(); $sales_person_reference = $customer->getSalesPersonReference();
if ($sales_person_employee_number !== null) { if ($sales_person_reference !== null) {
$references['salesPerson'] = [ $references['salesPerson'] = $sales_person_reference;
'employeeNumber' => $sales_person_employee_number,
];
} }
$vendor_reference_employee_number = $customer->getVendorReferenceEmployeeNumber(); $vendor_reference = $customer->getVendorReferenceReference();
if ($vendor_reference_employee_number !== null) { if ($vendor_reference !== null) {
$references['vendorReference'] = [ $references['vendorReference'] = $vendor_reference;
'employeeNumber' => $vendor_reference_employee_number,
];
} }
$data = [ $data = [
@@ -112,11 +104,9 @@ class economic_invoice_draft_mo extends economicInvoicesDrafts
$data['references'] = $references; $data['references'] = $references;
} }
$default_delivery_location_number = $customer->getDefaultDeliveryLocationNumber(); $default_delivery_location_reference = $customer->getDefaultDeliveryLocationReference();
if ($default_delivery_location_number !== null) { if ($default_delivery_location_reference !== null) {
$data['deliveryLocation'] = [ $data['deliveryLocation'] = $default_delivery_location_reference;
'deliveryLocationNumber' => $default_delivery_location_number,
];
} }
return $this->createInvoiceDraft($data); return $this->createInvoiceDraft($data);
@@ -23,23 +23,48 @@ it('exposes optional EAN and public entry number from fetched e-conomic customer
'publicEntryNumber' => ' DK123456789 ', 'publicEntryNumber' => ' DK123456789 ',
'attention' => (object)[ 'attention' => (object)[
'customerContactNumber' => 9, 'customerContactNumber' => 9,
'self' => 'https://restapi.e-conomic.com/customers/42331123/contacts/9',
], ],
'customerContact' => (object)[ 'customerContact' => (object)[
'customerContactNumber' => 10, 'customerContactNumber' => 10,
'self' => 'https://restapi.e-conomic.com/customers/42331123/contacts/10',
], ],
'salesPerson' => (object)[ 'salesPerson' => (object)[
'employeeNumber' => 12, 'employeeNumber' => 12,
'self' => 'https://restapi.e-conomic.com/employees/12',
], ],
'vendorReference' => (object)[ 'vendorReference' => (object)[
'employeeNumber' => 14, 'employeeNumber' => 14,
'self' => 'https://restapi.e-conomic.com/employees/14',
], ],
'defaultDeliveryLocation' => (object)[ 'defaultDeliveryLocation' => (object)[
'deliveryLocationNumber' => 7, 'deliveryLocationNumber' => 7,
'self' => 'https://restapi.e-conomic.com/customers/42331123/delivery-locations/7',
], ],
]); ]);
expect($customer->getEan())->toBe('5790001234567'); expect($customer->getEan())->toBe('5790001234567');
expect($customer->getPublicEntryNumber())->toBe('DK123456789'); expect($customer->getPublicEntryNumber())->toBe('DK123456789');
expect($customer->getAttentionReference())->toBe([
'customerContactNumber' => 9,
'self' => 'https://restapi.e-conomic.com/customers/42331123/contacts/9',
]);
expect($customer->getReferenceCustomerContact())->toBe([
'customerContactNumber' => 10,
'self' => 'https://restapi.e-conomic.com/customers/42331123/contacts/10',
]);
expect($customer->getSalesPersonReference())->toBe([
'employeeNumber' => 12,
'self' => 'https://restapi.e-conomic.com/employees/12',
]);
expect($customer->getVendorReferenceReference())->toBe([
'employeeNumber' => 14,
'self' => 'https://restapi.e-conomic.com/employees/14',
]);
expect($customer->getDefaultDeliveryLocationReference())->toBe([
'deliveryLocationNumber' => 7,
'self' => 'https://restapi.e-conomic.com/customers/42331123/delivery-locations/7',
]);
expect($customer->getAttentionCustomerContactNumber())->toBe(9); expect($customer->getAttentionCustomerContactNumber())->toBe(9);
expect($customer->getReferenceCustomerContactNumber())->toBe(10); expect($customer->getReferenceCustomerContactNumber())->toBe(10);
expect($customer->getSalesPersonEmployeeNumber())->toBe(12); expect($customer->getSalesPersonEmployeeNumber())->toBe(12);
@@ -56,6 +81,11 @@ it('returns null for blank optional e-conomic customer recipient identifiers', f
expect($customer->getEan())->toBeNull(); expect($customer->getEan())->toBeNull();
expect($customer->getPublicEntryNumber())->toBeNull(); expect($customer->getPublicEntryNumber())->toBeNull();
expect($customer->getAttentionReference())->toBeNull();
expect($customer->getReferenceCustomerContact())->toBeNull();
expect($customer->getSalesPersonReference())->toBeNull();
expect($customer->getVendorReferenceReference())->toBeNull();
expect($customer->getDefaultDeliveryLocationReference())->toBeNull();
expect($customer->getAttentionCustomerContactNumber())->toBeNull(); expect($customer->getAttentionCustomerContactNumber())->toBeNull();
expect($customer->getReferenceCustomerContactNumber())->toBeNull(); expect($customer->getReferenceCustomerContactNumber())->toBeNull();
expect($customer->getSalesPersonEmployeeNumber())->toBeNull(); expect($customer->getSalesPersonEmployeeNumber())->toBeNull();
@@ -9,13 +9,13 @@ it('wires EAN and public entry number into e-conomic invoice draft recipients',
expect($content)->toContain("\$recipient['nemHandelType'] = 'ean';"); expect($content)->toContain("\$recipient['nemHandelType'] = 'ean';");
expect($content)->toContain('$customer->getPublicEntryNumber()'); expect($content)->toContain('$customer->getPublicEntryNumber()');
expect($content)->toContain("\$recipient['publicEntryNumber']"); expect($content)->toContain("\$recipient['publicEntryNumber']");
expect($content)->toContain('$customer->getAttentionCustomerContactNumber()'); expect($content)->toContain('$customer->getAttentionReference()');
expect($content)->toContain("\$recipient['attention']"); expect($content)->toContain("\$recipient['attention']");
expect($content)->toContain('$customer->getReferenceCustomerContactNumber()'); expect($content)->toContain('$customer->getReferenceCustomerContact()');
expect($content)->toContain("\$references['customerContact']"); expect($content)->toContain("\$references['customerContact']");
expect($content)->toContain('$customer->getSalesPersonEmployeeNumber()'); expect($content)->toContain('$customer->getSalesPersonReference()');
expect($content)->toContain("\$references['salesPerson']"); expect($content)->toContain("\$references['salesPerson']");
expect($content)->toContain('$customer->getDefaultDeliveryLocationNumber()'); expect($content)->toContain('$customer->getDefaultDeliveryLocationReference()');
expect($content)->toContain("\$payload['deliveryLocation']"); expect($content)->toContain("\$payload['deliveryLocation']");
expect($content)->toContain("'recipient' => \$recipient"); expect($content)->toContain("'recipient' => \$recipient");
}); });
@@ -9,10 +9,15 @@ it('wires legacy draft creation payload with customer e-conomic metadata and EAN
expect($content) expect($content)
->toContain('$customer = $this->economic->getCustomer((int)$this->customer_number);') ->toContain('$customer = $this->economic->getCustomer((int)$this->customer_number);')
->toContain("\$recipient['nemHandelType'] = 'ean';") ->toContain("\$recipient['nemHandelType'] = 'ean';")
->toContain('$customer->getAttentionReference()')
->toContain("\$recipient['attention']") ->toContain("\$recipient['attention']")
->toContain('$customer->getReferenceCustomerContact()')
->toContain("\$references['customerContact']") ->toContain("\$references['customerContact']")
->toContain('$customer->getSalesPersonReference()')
->toContain("\$references['salesPerson']") ->toContain("\$references['salesPerson']")
->toContain('$customer->getVendorReferenceReference()')
->toContain("\$references['vendorReference']") ->toContain("\$references['vendorReference']")
->toContain('$customer->getDefaultDeliveryLocationReference()')
->toContain("\$data['deliveryLocation']") ->toContain("\$data['deliveryLocation']")
->toContain("'paymentTermsNumber' => (int)\$customer->getPaymentTermsNumber()") ->toContain("'paymentTermsNumber' => (int)\$customer->getPaymentTermsNumber()")
->toContain("'vatZoneNumber' => (int)\$customer->getVatZoneNumber()") ->toContain("'vatZoneNumber' => (int)\$customer->getVatZoneNumber()")
@@ -0,0 +1,46 @@
<?php
app_require('modules/economic/economic_m.php');
it('includes nested annotated e-conomic validation errors and developer hint in runtime exceptions', function (): void {
$economic = new class extends economic_m {
public function __construct()
{
// Intentionally skip parent constructor to avoid credential requirements in tests.
}
public function formatForTest(int $httpStatusCode, string $response): string
{
return $this->format_upstream_error_message($httpStatusCode, $response);
}
};
$response = json_encode([
'message' => 'Validation failed. 2 errors found.',
'developerHint' => 'Review annotated errors for payload details.',
'errors' => [
'recipient' => [
'attention' => [
'errorMessage' => 'Customer contact not found for recipient attention.',
],
],
'references' => [
'customerContact' => [
'errorMessage' => 'Customer contact reference is invalid.',
],
],
],
'logId' => 'abc123',
'httpStatusCode' => 400,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
expect($response)->not->toBeFalse();
$formatted = $economic->formatForTest(400, (string)$response);
expect($formatted)
->toContain('Validation failed. 2 errors found.')
->toContain('errors.recipient.attention: Customer contact not found for recipient attention.')
->toContain('errors.references.customerContact: Customer contact reference is invalid.')
->toContain('developerHint')
->toContain('abc123');
});