From 59107a6bb2a8e5d70f14c2adc0ffbf394faf9830 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Wed, 5 Aug 2026 14:44:40 +0200 Subject: [PATCH] Enforce e-conomic discount template + EAN draft metadata (#349) ## What changed - enforce EAN draft delivery wiring by setting `recipient.nemHandelType=ean` when customer EAN is present - copy existing e-conomic customer metadata into draft payload: `recipient.attention`, `references.customerContact`, `references.salesPerson`, and `deliveryLocation` - keep `references.other` external-id mapping intact - remove legacy explicit `Rabat:` text-line injection and use line-level `discountPercentage` instead - add/update unit tests for helper extraction and discount/EAN wiring ## Tests - `vendor/bin/pest tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php tests/Unit/Invoicing/EconomicInvoiceDraftItemizedDiscountTest.php --colors=never` ## Notes - full unit suite in this environment still has an unrelated pre-existing failure in `Tests\\Unit\\Bird\\BirdControlPlaneActivationTest` requiring `PLENO_REPO_ROOT_FOR_TESTS`. - live manual verification against customer `12345679` remains environment-blocked due missing e-conomic credentials. --------- Co-authored-by: Jeppe Bundgaard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../classes/economic_transfer_executor.php | 12 +- .../economic_invoices_drafts_endpoint.php | 100 ++++++++++------ .../economic/helpers/economic_customer.php | 51 ++++++++ .../draft/economic_invoice_draft_mo.php | 110 ++++++++++++++++-- .../nginx/app/routes/economicInvoiceRoute.php | 15 +-- .../EconomicCustomerEanHelperTest.php | 25 ++++ ...InvoiceDraftDiscountLineModeWiringTest.php | 19 +++ ...omicInvoiceDraftRecipientEanWiringTest.php | 9 ++ .../EconomicLegacyDraftDiscountWiringTest.php | 19 +++ .../EconomicLegacyDraftPayloadWiringTest.php | 36 ++++++ 10 files changed, 335 insertions(+), 61 deletions(-) create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php create mode 100644 services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftPayloadWiringTest.php diff --git a/services/nginx/app/classes/economic_transfer_executor.php b/services/nginx/app/classes/economic_transfer_executor.php index c00d3e37..c5efaa41 100644 --- a/services/nginx/app/classes/economic_transfer_executor.php +++ b/services/nginx/app/classes/economic_transfer_executor.php @@ -328,23 +328,21 @@ class economic_transfer_executor $economic_dimension_id = $department['economic_dimension_id'] ?? 0; $order_item_price = (float)($order_item['price'] ?? 0); $product_price = (float)($order_item['product']['price'] ?? 0); + $discount_percentage = 0.0; + if (abs($product_price) > 0.00001 && $order_item_price < $product_price) { + $discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 10); + } $economic_invoice_draft->addLine( $product_number, $product_name, $quantity, $order_item_price, - 0, + $discount_percentage, (int)$economic_department_id ?? 0, (int)$economic_dimension_id ?? 0 ); - $show_discount = abs($order_item_price - $product_price) > 0.00001; - if ($show_discount && abs($product_price) > 0.00001) { - $discount_percentage = round((($product_price - $order_item_price) / $product_price) * 100, 0); - $economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item_price - $product_price) . ' DKK (' . $discount_percentage . '%)'); - } - if ($reference !== '') { $economic_invoice_draft->addLineTEXT('Reference:'); if (str_contains($reference, "\n")) { diff --git a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php index 96e7d721..8582cf1c 100644 --- a/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php +++ b/services/nginx/app/modules/economic/endpoints/invoices/economic_invoices_drafts_endpoint.php @@ -141,51 +141,81 @@ class economic_invoices_drafts_endpoint $customer_ean = $customer->getEan(); if ($customer_ean !== null) { $recipient['ean'] = $customer_ean; + $recipient['nemHandelType'] = 'ean'; } $public_entry_number = $customer->getPublicEntryNumber(); 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, + ]; + } + + $references = [ + 'other' => $external_id + ]; + $reference_customer_contact_number = $customer->getReferenceCustomerContactNumber(); + if ($reference_customer_contact_number !== null) { + $references['customerContact'] = [ + 'customerContactNumber' => $reference_customer_contact_number, + ]; + } + $sales_person_employee_number = $customer->getSalesPersonEmployeeNumber(); + if ($sales_person_employee_number !== null) { + $references['salesPerson'] = [ + 'employeeNumber' => $sales_person_employee_number, + ]; + } + + $payload = [ + // Set the layout number (This is defined in the E-conomic module settings) + 'layout' => [ + 'layoutNumber' => $layout_number + ], + + // Set the date to today + 'date' => $date, + + // Set the payment terms to the default payment terms (This is defined in the E-conomic module settings) + 'paymentTerms' => [ + 'paymentTermsNumber' => (int)$customer->getPaymentTermsNumber(), + ], + + // Set the vat zone (This is defined in the E-conomic module settings) + 'vatZone' => [ + 'vatZoneNumber' => (int)$customer->getVatZoneNumber(), + ], + + // Set the customer number + 'customer' => [ + 'customerNumber' => $customer_number + ], + + // Set the external id (This is used to link the invoice to the collected order invoice) + 'references' => $references, + + // Set the currency + 'currency' => $customer->getCurrency() ?? 'DKK', + + // Set the recipient details + 'recipient' => $recipient, + ]; + + $default_delivery_location_number = $customer->getDefaultDeliveryLocationNumber(); + if ($default_delivery_location_number !== null) { + $payload['deliveryLocation'] = [ + 'deliveryLocationNumber' => $default_delivery_location_number, + ]; + } // Send the request $response = $this->send_request( '/invoices/drafts/', 'POST', - json_encode([ - // Set the layout number (This is defined in the E-conomic module settings) - 'layout' => [ - 'layoutNumber' => $layout_number - ], - - // Set the date to today - 'date' => $date, - - // Set the payment terms to the default payment terms (This is defined in the E-conomic module settings) - 'paymentTerms' => [ - 'paymentTermsNumber' => (int)$customer->getPaymentTermsNumber(), - ], - - // Set the vat zone (This is defined in the E-conomic module settings) - 'vatZone' => [ - 'vatZoneNumber' => (int)$customer->getVatZoneNumber(), - ], - - // Set the customer number - 'customer' => [ - 'customerNumber' => $customer_number - ], - - // Set the external id (This is used to link the invoice to the collected order invoice) - 'references' => [ - 'other' => $external_id - ], - - // Set the currency - 'currency' => $customer->getCurrency() ?? 'DKK', - - // Set the recipient details - 'recipient' => $recipient, - ]) + json_encode($payload) ); // Return the response as an object return json_decode($response); diff --git a/services/nginx/app/modules/economic/helpers/economic_customer.php b/services/nginx/app/modules/economic/helpers/economic_customer.php index 2187780f..2f8b5fb5 100644 --- a/services/nginx/app/modules/economic/helpers/economic_customer.php +++ b/services/nginx/app/modules/economic/helpers/economic_customer.php @@ -161,6 +161,36 @@ class economic_customer return $this->nullableStringField('publicEntryNumber'); } + public function getAttentionCustomerContactNumber(): ?int + { + self::requireSelected(); + return $this->nestedPositiveIntField(['attention', 'customerContactNumber']); + } + + public function getReferenceCustomerContactNumber(): ?int + { + self::requireSelected(); + return $this->nestedPositiveIntField(['customerContact', 'customerContactNumber']); + } + + public function getSalesPersonEmployeeNumber(): ?int + { + self::requireSelected(); + return $this->nestedPositiveIntField(['salesPerson', 'employeeNumber']); + } + + public function getVendorReferenceEmployeeNumber(): ?int + { + self::requireSelected(); + return $this->nestedPositiveIntField(['vendorReference', 'employeeNumber']); + } + + public function getDefaultDeliveryLocationNumber(): ?int + { + self::requireSelected(); + return $this->nestedPositiveIntField(['defaultDeliveryLocation', 'deliveryLocationNumber']); + } + protected function nullableStringField(string $field): ?string { $value = $this->customer_data_object->{$field} ?? null; @@ -172,6 +202,27 @@ class economic_customer return $normalized !== '' ? $normalized : null; } + /** + * @param string[] $segments + */ + protected function nestedPositiveIntField(array $segments): ?int + { + $value = $this->customer_data_object; + foreach ($segments as $segment) { + if (!is_object($value) || !isset($value->{$segment})) { + return null; + } + $value = $value->{$segment}; + } + + if (!is_int($value) && !is_float($value) && !is_string($value)) { + return null; + } + + $number = (int)$value; + return $number > 0 ? $number : null; + } + /** * Get the customer address * @return string The customer address diff --git a/services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php b/services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php index 095ab76c..dabd833d 100644 --- a/services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php +++ b/services/nginx/app/modules/economic/invoices/draft/economic_invoice_draft_mo.php @@ -8,7 +8,7 @@ class economic_invoice_draft_mo extends economicInvoicesDrafts protected float $layout_number; // Layout number of the invoice protected float $payment_terms_number; // Payment terms number of the invoice protected array $recipient; // Recipient of the invoice (Includes name, address, zip, city, and (array)vatZone) - protected array $lines; // Lines of the invoice (Includes product, quantity, unitNetPrice, discountPercentage, and (array)vatRate) + protected array $lines = []; // Lines of the invoice (Includes product, quantity, unitNetPrice, discountPercentage, and (array)vatRate) public function addLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, float $discountPercentage, int $economic_department_id, int $dimension): void { @@ -44,32 +44,118 @@ class economic_invoice_draft_mo extends economicInvoicesDrafts public function createInvoiceDraftExample(): object { + $customer = $this->economic->getCustomer((int)$this->customer_number); + $recipient = [ + 'name' => $customer->getName() ?? ($this->recipient['name'] ?? 'Ukendt'), + 'address' => $customer->getAddress() ?? ($this->recipient['address'] ?? 'Ukendt'), + 'zip' => $customer->getZipCode() ?? ($this->recipient['zip'] ?? 'Ukendt'), + 'city' => $customer->getCity() ?? ($this->recipient['city'] ?? 'Ukendt'), + 'vatZone' => [ + 'vatZoneNumber' => (int)$customer->getVatZoneNumber(), + ], + ]; + $customer_ean = $customer->getEan(); + if ($customer_ean !== null) { + $recipient['ean'] = $customer_ean; + $recipient['nemHandelType'] = 'ean'; + } + $public_entry_number = $customer->getPublicEntryNumber(); + 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, + ]; + } + + $references = []; + $reference_customer_contact_number = $customer->getReferenceCustomerContactNumber(); + if ($reference_customer_contact_number !== null) { + $references['customerContact'] = [ + 'customerContactNumber' => $reference_customer_contact_number, + ]; + } + $sales_person_employee_number = $customer->getSalesPersonEmployeeNumber(); + if ($sales_person_employee_number !== null) { + $references['salesPerson'] = [ + 'employeeNumber' => $sales_person_employee_number, + ]; + } + $vendor_reference_employee_number = $customer->getVendorReferenceEmployeeNumber(); + if ($vendor_reference_employee_number !== null) { + $references['vendorReference'] = [ + 'employeeNumber' => $vendor_reference_employee_number, + ]; + } + $data = [ - 'currency' => 'DKK', + 'currency' => $customer->getCurrency() ?? 'DKK', 'date' => date('Y-m-d'), 'layout' => [ - 'layoutNumber' => (int)$this->economic->config->invoice_layout->getVariableValue() + 'layoutNumber' => $this->resolveLayoutNumber() ], 'paymentTerms' => [ - 'paymentTermsNumber' => 1 + 'paymentTermsNumber' => (int)$customer->getPaymentTermsNumber(), ], - 'recipient' => [ - 'name' => $this->recipient['name'], - 'address' => $this->recipient['address'], - 'zip' => $this->recipient['zip'], - 'city' => $this->recipient['city'], - 'vatZone' => [ - 'vatZoneNumber' => 1 - ] + 'recipient' => $recipient, + 'vatZone' => [ + 'vatZoneNumber' => (int)$customer->getVatZoneNumber(), ], 'customer' => [ 'customerNumber' => (int)$this->customer_number ], 'lines' => $this->lines // Lines added using the addLine method ]; + if (!empty($references)) { + $data['references'] = $references; + } + + $default_delivery_location_number = $customer->getDefaultDeliveryLocationNumber(); + if ($default_delivery_location_number !== null) { + $data['deliveryLocation'] = [ + 'deliveryLocationNumber' => $default_delivery_location_number, + ]; + } + return $this->createInvoiceDraft($data); } + private function resolveLayoutNumber(): int + { + if (!$this->hasDiscountedItemizedLines()) { + return (int)$this->economic->config->invoice_layout->getVariableValue(); + } + + $layout_number = (int)$this->economic->config->invoice_discount_layout->getVariableValue(); + if ($layout_number <= 0) { + throw new \RuntimeException('Discount invoice layout is not configured'); + } + + return $layout_number; + } + + private function hasDiscountedItemizedLines(): bool + { + foreach ($this->lines as $line) { + if (!is_array($line)) { + continue; + } + if (!isset($line['product']) || !is_array($line['product'])) { + continue; + } + $discount_percentage = isset($line['discountPercentage']) && is_numeric($line['discountPercentage']) + ? (float)$line['discountPercentage'] + : 0.0; + if ($discount_percentage > 0.0) { + return true; + } + } + + return false; + } + // Example of a method that uses the createInvoiceDraft method public function createInvoiceDraft(array $data): object diff --git a/services/nginx/app/routes/economicInvoiceRoute.php b/services/nginx/app/routes/economicInvoiceRoute.php index 084a949e..2a97e40a 100644 --- a/services/nginx/app/routes/economicInvoiceRoute.php +++ b/services/nginx/app/routes/economicInvoiceRoute.php @@ -552,22 +552,23 @@ class economicInvoiceRoute $department = $order->getDepartmentByOrderId($order->id); $economic_department_id = $department['economic_department_id'] ?? 0; $economic_dimension_id = $department['economic_dimension_id'] ?? 0; + $line_price = (float)$order_item['price']; + $product_price = (float)$order_item['product']['price']; + $discount_percentage = 0.0; + if (abs($product_price) > 0.00001 && $line_price < $product_price) { + $discount_percentage = round((($product_price - $line_price) / $product_price) * 100, 10); + } $economic_invoice_draft->addLine( (string)$order_item['product']['economic_product_id'], (string)$order_item['product']['name'], (int)$quantity, - (int)$order_item['price'], - 0, + $line_price, + $discount_percentage, (int)$economic_department_id, (int)$economic_dimension_id ); - $discount_percentage = round((($order_item['product']['price'] - $order_item['price']) / $order_item['product']['price']) * 100, 0); - if ($order_item['price'] !== $order_item['product']['price']) { - $economic_invoice_draft->addLineTEXT('Rabat: ' . ($order_item['price'] - $order_item['product']['price']) . ' DKK (' . $discount_percentage . '%)'); - } - if ($order_item['reference'] !== '') { $economic_invoice_draft->addLineTEXT('Reference:'); if (str_contains($order_item['reference'], "\n")) { diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php index 3ca6e80b..32fcb8d4 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicCustomerEanHelperTest.php @@ -21,10 +21,30 @@ it('exposes optional EAN and public entry number from fetched e-conomic customer 'customerNumber' => 42331123, 'ean' => ' 5790001234567 ', 'publicEntryNumber' => ' DK123456789 ', + 'attention' => (object)[ + 'customerContactNumber' => 9, + ], + 'customerContact' => (object)[ + 'customerContactNumber' => 10, + ], + 'salesPerson' => (object)[ + 'employeeNumber' => 12, + ], + 'vendorReference' => (object)[ + 'employeeNumber' => 14, + ], + 'defaultDeliveryLocation' => (object)[ + 'deliveryLocationNumber' => 7, + ], ]); expect($customer->getEan())->toBe('5790001234567'); expect($customer->getPublicEntryNumber())->toBe('DK123456789'); + expect($customer->getAttentionCustomerContactNumber())->toBe(9); + expect($customer->getReferenceCustomerContactNumber())->toBe(10); + expect($customer->getSalesPersonEmployeeNumber())->toBe(12); + expect($customer->getVendorReferenceEmployeeNumber())->toBe(14); + expect($customer->getDefaultDeliveryLocationNumber())->toBe(7); }); it('returns null for blank optional e-conomic customer recipient identifiers', function (): void { @@ -36,4 +56,9 @@ it('returns null for blank optional e-conomic customer recipient identifiers', f expect($customer->getEan())->toBeNull(); expect($customer->getPublicEntryNumber())->toBeNull(); + expect($customer->getAttentionCustomerContactNumber())->toBeNull(); + expect($customer->getReferenceCustomerContactNumber())->toBeNull(); + expect($customer->getSalesPersonEmployeeNumber())->toBeNull(); + expect($customer->getVendorReferenceEmployeeNumber())->toBeNull(); + expect($customer->getDefaultDeliveryLocationNumber())->toBeNull(); }); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php new file mode 100644 index 00000000..14c62a99 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + $content = (string)$content; + + $start = strpos($content, 'public function addOrderItemLines'); + $end = strpos($content, 'public function addOrderItemLine(', (int)$start + 1); + expect($start)->not->toBeFalse(); + expect($end)->not->toBeFalse(); + expect($end)->toBeGreaterThan($start); + + $block = substr($content, (int)$start, (int)$end - (int)$start); + expect($block) + ->toContain('if (!$use_itemized_discounts && $total_discount > 0)') + ->toContain('self::addProductDiscountLine($total_discount'); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php index 2f34bc69..5378591e 100644 --- a/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftRecipientEanWiringTest.php @@ -6,7 +6,16 @@ it('wires EAN and public entry number into e-conomic invoice draft recipients', expect($content)->not->toBeFalse(); expect($content)->toContain('$customer->getEan()'); expect($content)->toContain("\$recipient['ean']"); + expect($content)->toContain("\$recipient['nemHandelType'] = 'ean';"); expect($content)->toContain('$customer->getPublicEntryNumber()'); expect($content)->toContain("\$recipient['publicEntryNumber']"); + expect($content)->toContain('$customer->getAttentionCustomerContactNumber()'); + expect($content)->toContain("\$recipient['attention']"); + expect($content)->toContain('$customer->getReferenceCustomerContactNumber()'); + expect($content)->toContain("\$references['customerContact']"); + expect($content)->toContain('$customer->getSalesPersonEmployeeNumber()'); + expect($content)->toContain("\$references['salesPerson']"); + expect($content)->toContain('$customer->getDefaultDeliveryLocationNumber()'); + expect($content)->toContain("\$payload['deliveryLocation']"); expect($content)->toContain("'recipient' => \$recipient"); }); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php new file mode 100644 index 00000000..9cf4e355 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftDiscountWiringTest.php @@ -0,0 +1,19 @@ +not->toBeFalse(); + expect($route)->not->toBeFalse(); + + expect((string)$transfer_executor) + ->toContain('$economic_invoice_draft->addLine(') + ->toContain('$discount_percentage') + ->not->toContain("addLineTEXT('Rabat:"); + + expect((string)$route) + ->toContain('$economic_invoice_draft->addLine(') + ->toContain('$discount_percentage') + ->not->toContain("addLineTEXT('Rabat:"); +}); diff --git a/services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftPayloadWiringTest.php b/services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftPayloadWiringTest.php new file mode 100644 index 00000000..10e06dd2 --- /dev/null +++ b/services/nginx/app/tests/Unit/Invoicing/EconomicLegacyDraftPayloadWiringTest.php @@ -0,0 +1,36 @@ +not->toBeFalse(); + $content = (string)$content; + + expect($content) + ->toContain('$customer = $this->economic->getCustomer((int)$this->customer_number);') + ->toContain("\$recipient['nemHandelType'] = 'ean';") + ->toContain("\$recipient['attention']") + ->toContain("\$references['customerContact']") + ->toContain("\$references['salesPerson']") + ->toContain("\$references['vendorReference']") + ->toContain("\$data['deliveryLocation']") + ->toContain("'paymentTermsNumber' => (int)\$customer->getPaymentTermsNumber()") + ->toContain("'vatZoneNumber' => (int)\$customer->getVatZoneNumber()") + ->toContain("'currency' => \$customer->getCurrency() ?? 'DKK'"); +}); + +it('selects the discount layout in legacy draft creation when lines carry itemized discount percentages', function (): void { + $content = file_get_contents(app_path('modules/economic/invoices/draft/economic_invoice_draft_mo.php')); + + expect($content)->not->toBeFalse(); + $content = (string)$content; + + expect($content) + ->toContain('private function resolveLayoutNumber(): int') + ->toContain('if (!$this->hasDiscountedItemizedLines())') + ->toContain('invoice_discount_layout') + ->toContain('Discount invoice layout is not configured') + ->toContain('private function hasDiscountedItemizedLines(): bool') + ->toContain("\$line['discountPercentage']") + ->toContain('return true;'); +});