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:
co-authored by
Jeppe Bundgaard
Copilot
parent
bd2ff1be9a
commit
8735bae8d5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-20
@@ -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();
|
||||
|
||||
+4
-4
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user