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:
co-authored by
Jeppe Bundgaard
Copilot
parent
622fe59f5c
commit
59107a6bb2
@@ -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")) {
|
||||
|
||||
+39
-9
@@ -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();
|
||||
});
|
||||
|
||||
+19
@@ -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;');
|
||||
});
|
||||
Reference in New Issue
Block a user