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 = [];
if (isset($decoded['errors']) && is_array($decoded['errors'])) {
$safeErrors = [];
foreach ( $decoded['errors'] as $error ) {
if (is_scalar($error)) {
$safeErrors[] = (string)$error;
}
$flattenedErrors = array_values(array_unique($this->flatten_economic_errors($decoded['errors'], 'errors')));
if (!empty($flattenedErrors)) {
$details['errors'] = $flattenedErrors;
}
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='
. 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) {
$recipient['publicEntryNumber'] = $public_entry_number;
}
$attention_customer_contact_number = $customer->getAttentionCustomerContactNumber();
if ($attention_customer_contact_number !== null) {
$recipient['attention'] = [
'customerContactNumber' => $attention_customer_contact_number,
];
$attention_reference = $customer->getAttentionReference();
if ($attention_reference !== null) {
$recipient['attention'] = $attention_reference;
}
$references = [
'other' => $external_id
];
$reference_customer_contact_number = $customer->getReferenceCustomerContactNumber();
if ($reference_customer_contact_number !== null) {
$references['customerContact'] = [
'customerContactNumber' => $reference_customer_contact_number,
];
$customer_contact_reference = $customer->getReferenceCustomerContact();
if ($customer_contact_reference !== null) {
$references['customerContact'] = $customer_contact_reference;
}
$sales_person_employee_number = $customer->getSalesPersonEmployeeNumber();
if ($sales_person_employee_number !== null) {
$references['salesPerson'] = [
'employeeNumber' => $sales_person_employee_number,
];
$sales_person_reference = $customer->getSalesPersonReference();
if ($sales_person_reference !== null) {
$references['salesPerson'] = $sales_person_reference;
}
$payload = [
@@ -204,11 +198,9 @@ class economic_invoices_drafts_endpoint
'recipient' => $recipient,
];
$default_delivery_location_number = $customer->getDefaultDeliveryLocationNumber();
if ($default_delivery_location_number !== null) {
$payload['deliveryLocation'] = [
'deliveryLocationNumber' => $default_delivery_location_number,
];
$default_delivery_location_reference = $customer->getDefaultDeliveryLocationReference();
if ($default_delivery_location_reference !== null) {
$payload['deliveryLocation'] = $default_delivery_location_reference;
}
// Send the request
@@ -164,31 +164,61 @@ class economic_customer
public function getAttentionCustomerContactNumber(): ?int
{
self::requireSelected();
return $this->nestedPositiveIntField(['attention', 'customerContactNumber']);
return $this->getAttentionReference()['customerContactNumber'] ?? null;
}
public function getReferenceCustomerContactNumber(): ?int
{
self::requireSelected();
return $this->nestedPositiveIntField(['customerContact', 'customerContactNumber']);
return $this->getReferenceCustomerContact()['customerContactNumber'] ?? null;
}
public function getSalesPersonEmployeeNumber(): ?int
{
self::requireSelected();
return $this->nestedPositiveIntField(['salesPerson', 'employeeNumber']);
return $this->getSalesPersonReference()['employeeNumber'] ?? null;
}
public function getVendorReferenceEmployeeNumber(): ?int
{
self::requireSelected();
return $this->nestedPositiveIntField(['vendorReference', 'employeeNumber']);
return $this->getVendorReferenceReference()['employeeNumber'] ?? null;
}
public function getDefaultDeliveryLocationNumber(): ?int
{
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
@@ -206,6 +236,51 @@ class economic_customer
* @param string[] $segments
*/
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;
foreach ($segments as $segment) {
@@ -215,12 +290,7 @@ class economic_customer
$value = $value->{$segment};
}
if (!is_int($value) && !is_float($value) && !is_string($value)) {
return null;
}
$number = (int)$value;
return $number > 0 ? $number : null;
return $value;
}
/**
@@ -63,31 +63,23 @@ class economic_invoice_draft_mo extends economicInvoicesDrafts
if ($public_entry_number !== null) {
$recipient['publicEntryNumber'] = $public_entry_number;
}
$attention_customer_contact_number = $customer->getAttentionCustomerContactNumber();
if ($attention_customer_contact_number !== null) {
$recipient['attention'] = [
'customerContactNumber' => $attention_customer_contact_number,
];
$attention_reference = $customer->getAttentionReference();
if ($attention_reference !== null) {
$recipient['attention'] = $attention_reference;
}
$references = [];
$reference_customer_contact_number = $customer->getReferenceCustomerContactNumber();
if ($reference_customer_contact_number !== null) {
$references['customerContact'] = [
'customerContactNumber' => $reference_customer_contact_number,
];
$customer_contact_reference = $customer->getReferenceCustomerContact();
if ($customer_contact_reference !== null) {
$references['customerContact'] = $customer_contact_reference;
}
$sales_person_employee_number = $customer->getSalesPersonEmployeeNumber();
if ($sales_person_employee_number !== null) {
$references['salesPerson'] = [
'employeeNumber' => $sales_person_employee_number,
];
$sales_person_reference = $customer->getSalesPersonReference();
if ($sales_person_reference !== null) {
$references['salesPerson'] = $sales_person_reference;
}
$vendor_reference_employee_number = $customer->getVendorReferenceEmployeeNumber();
if ($vendor_reference_employee_number !== null) {
$references['vendorReference'] = [
'employeeNumber' => $vendor_reference_employee_number,
];
$vendor_reference = $customer->getVendorReferenceReference();
if ($vendor_reference !== null) {
$references['vendorReference'] = $vendor_reference;
}
$data = [
@@ -112,11 +104,9 @@ class economic_invoice_draft_mo extends economicInvoicesDrafts
$data['references'] = $references;
}
$default_delivery_location_number = $customer->getDefaultDeliveryLocationNumber();
if ($default_delivery_location_number !== null) {
$data['deliveryLocation'] = [
'deliveryLocationNumber' => $default_delivery_location_number,
];
$default_delivery_location_reference = $customer->getDefaultDeliveryLocationReference();
if ($default_delivery_location_reference !== null) {
$data['deliveryLocation'] = $default_delivery_location_reference;
}
return $this->createInvoiceDraft($data);
@@ -23,23 +23,48 @@ it('exposes optional EAN and public entry number from fetched e-conomic customer
'publicEntryNumber' => ' DK123456789 ',
'attention' => (object)[
'customerContactNumber' => 9,
'self' => 'https://restapi.e-conomic.com/customers/42331123/contacts/9',
],
'customerContact' => (object)[
'customerContactNumber' => 10,
'self' => 'https://restapi.e-conomic.com/customers/42331123/contacts/10',
],
'salesPerson' => (object)[
'employeeNumber' => 12,
'self' => 'https://restapi.e-conomic.com/employees/12',
],
'vendorReference' => (object)[
'employeeNumber' => 14,
'self' => 'https://restapi.e-conomic.com/employees/14',
],
'defaultDeliveryLocation' => (object)[
'deliveryLocationNumber' => 7,
'self' => 'https://restapi.e-conomic.com/customers/42331123/delivery-locations/7',
],
]);
expect($customer->getEan())->toBe('5790001234567');
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->getReferenceCustomerContactNumber())->toBe(10);
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->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->getReferenceCustomerContactNumber())->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('$customer->getPublicEntryNumber()');
expect($content)->toContain("\$recipient['publicEntryNumber']");
expect($content)->toContain('$customer->getAttentionCustomerContactNumber()');
expect($content)->toContain('$customer->getAttentionReference()');
expect($content)->toContain("\$recipient['attention']");
expect($content)->toContain('$customer->getReferenceCustomerContactNumber()');
expect($content)->toContain('$customer->getReferenceCustomerContact()');
expect($content)->toContain("\$references['customerContact']");
expect($content)->toContain('$customer->getSalesPersonEmployeeNumber()');
expect($content)->toContain('$customer->getSalesPersonReference()');
expect($content)->toContain("\$references['salesPerson']");
expect($content)->toContain('$customer->getDefaultDeliveryLocationNumber()');
expect($content)->toContain('$customer->getDefaultDeliveryLocationReference()');
expect($content)->toContain("\$payload['deliveryLocation']");
expect($content)->toContain("'recipient' => \$recipient");
});
@@ -9,10 +9,15 @@ it('wires legacy draft creation payload with customer e-conomic metadata and EAN
expect($content)
->toContain('$customer = $this->economic->getCustomer((int)$this->customer_number);')
->toContain("\$recipient['nemHandelType'] = 'ean';")
->toContain('$customer->getAttentionReference()')
->toContain("\$recipient['attention']")
->toContain('$customer->getReferenceCustomerContact()')
->toContain("\$references['customerContact']")
->toContain('$customer->getSalesPersonReference()')
->toContain("\$references['salesPerson']")
->toContain('$customer->getVendorReferenceReference()')
->toContain("\$references['vendorReference']")
->toContain('$customer->getDefaultDeliveryLocationReference()')
->toContain("\$data['deliveryLocation']")
->toContain("'paymentTermsNumber' => (int)\$customer->getPaymentTermsNumber()")
->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');
});