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 <jb@truckwash.dk>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Jeppe B
2026-08-05 14:44:40 +02:00
committed by GitHub
co-authored by Jeppe Bundgaard Copilot
parent 622fe59f5c
commit 59107a6bb2
10 changed files with 335 additions and 61 deletions
@@ -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")) {
@@ -141,17 +141,36 @@ 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,
];
}
// Send the request
$response = $this->send_request(
'/invoices/drafts/',
'POST',
json_encode([
$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
@@ -176,16 +195,27 @@ class economic_invoices_drafts_endpoint
],
// Set the external id (This is used to link the invoice to the collected order invoice)
'references' => [
'other' => $external_id
],
'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($payload)
);
// Return the response as an object
return json_decode($response);
@@ -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
@@ -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'],
'recipient' => $recipient,
'vatZone' => [
'vatZoneNumber' => 1
]
'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
@@ -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")) {
@@ -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();
});
@@ -0,0 +1,19 @@
<?php
it('only adds TotDiscount aggregate line when itemized discounts are disabled', function (): void {
$content = file_get_contents(app_path('modules/economic/helpers/economic_invoice_draft.php'));
expect($content)->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');
});
@@ -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");
});
@@ -0,0 +1,19 @@
<?php
it('uses line discount percentages instead of explicit Rabat text lines in legacy draft transfer flows', function (): void {
$transfer_executor = file_get_contents(app_path('classes/economic_transfer_executor.php'));
$route = file_get_contents(app_path('routes/economicInvoiceRoute.php'));
expect($transfer_executor)->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:");
});
@@ -0,0 +1,36 @@
<?php
it('wires legacy draft creation payload with customer e-conomic metadata and EAN delivery settings', 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('$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;');
});