Files
api/services/nginx/app/modules/economic/customers/economicCustomers.php
T
Jeppe BandMiniMax M3 Subagent 52d43fc16c fix(api): apply e-conomic discount percentage at line level for customer 35131752 (TRU-73 / DRIFT 12) (#400)
## Summary

Fixes **TRU-73 / DRIFT 12** — invoice format must clearly show the
discount given on all services.

For customers with a global e-conomic discount (e.g. `kd` customer
`35131752` with a 15% discount), the discount was being silently dropped
on draft invoice lines. E-conomic's draft invoice line API requires
`discountPercentage` on each line, so an aggregate `TotDiscount` line is
ignored when the customer has a per-line discount configured. The fix
applies the customer discount at the line level.

## What changed

-
`services/nginx/app/modules/economic/helpers/economic_invoice_draft.php`
— `addOrderItemLines()` and `addOrderItemLine()` now accept a
`customer_discount_percentage` argument and combine it with the per-item
discount using `max(per_item, customer)`. The aggregate `TotDiscount`
line is suppressed when a customer-level discount is in play.
- `services/nginx/app/modules/economic/customers/economicCustomers.php`
— logs swallowed missing-currency-price errors so silently-missing
discounts become visible in the application log.
-
`services/nginx/app/modules/economic/endpoints/invoices/draft/economic_invoices_draft_endpoint.php`
— forwards the customer discount percentage to the draft builder.
- `services/nginx/app/objects/collected_order_invoices_o.php` — resolves
the customer discount via Redis cache + e-conomicCustomers and passes it
to the draft builder.
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftCustomerDiscountTest.php`
— new test class covering the customer 35131752 15% case plus edge cases
(per-item + customer discount combined, clamping to 0..100,
zero-discount baseline).
-
`services/nginx/app/tests/Unit/Invoicing/EconomicInvoiceDraftDiscountLineModeWiringTest.php`
— updated for the new parameter and the customer-discount guard on the
aggregate `TotDiscount` line.
-
`services/nginx/app/tests/Unit/Invoicing/CollectedInvoiceEconomicBatchTransferWiringTest.php`
— updated to thread the new parameter through the batch transfer
pipeline.
- `documentation/economic/invoice-discount-format-drift12.md` — new doc
with the before/after invoice layout (the example Jimmy asked for in the
DRIFT 12 description).

## Example (for Jimmy)

Customer 35131752 ("kd") with 15% global e-conomic discount, one wash
line at 100,00 DKK.

### Before
```
Vask                                     1 ×   100,00 DKK     100,00
Subtotal                                                    100,00 DKK
Rabat (15%)                                                  0,00 DKK   ← silently dropped
Total                                                       100,00 DKK
```

### After
```
Vask (15% rabat)                        1 ×   100,00 DKK     100,00
                                                       Rabat:  -15,00 DKK (15%)
Subtotal                                                    100,00 DKK
Rabat                                                        15,00 DKK
Total                                                         85,00 DKK
```

## Test plan

- [x] New `EconomicInvoiceDraftCustomerDiscountTest` covers: 15%
customer discount applied at line level, per-item + customer discount
combined using `max`, clamping to 0..100, zero-discount baseline.
- [x] `EconomicInvoiceDraftDiscountLineModeWiringTest` updated and still
passes.
- [x] `CollectedInvoiceEconomicBatchTransferWiringTest` updated for the
new parameter.
- [ ] Run full `php-ci-test.sh unit` locally to confirm nothing else
regressed.

## Linear

Closes TRU-73 (DRIFT 12).

🤖 Generated via the TRU-73 pickup cron run.

---------

Co-authored-by: MiniMax M3 Subagent <fix@truckwash.local>
2026-08-17 13:41:10 +00:00

273 lines
10 KiB
PHP

<?php
namespace customers;
use economic_m;
use Exception;
use objects\users_o;
class economicCustomers extends economic_m
{
public users_o $users_o;
public function searchCustomers(string|int $search, string $filter, int $limit = 10, int $page = 1): object
{
// Make sure the search string is ready for the API
$search = urlencode($search);
// Search for customers
$url = '/customers?filter=' . $filter . '$like:' . $search . '&pagesize=' . $limit . '&skippages=' . ($page - 1);
$response = $this->send_request($url, 'GET', '');
return json_decode($response);
}
public function getCustomerName(int $customer_number): string|null
{
// Check if the customer name is cached
$tmp_user = self::getUsersObject()->getUserByCustomerNumber($customer_number);
$cached = $tmp_user->getCached('economic_customer');
if ($cached) {
return $cached->name;
}
// Get the customer name from the economic system
$tmp_user->getCustomerEcocomicData($customer_number);
$cached = $tmp_user->getCached('economic_customer');
if ($cached) {
return $cached->name;
}
return null;
}
public function getUsersObject(): users_o
{
if (!isset($this->users_o)) {
$this->users_o = new users_o();
}
return $this->users_o;
}
public function getCustomerId(int $customerNumber): object|bool
{
if ($customerNumber <= 0) {
return false;
}
// Check if the customer exists
$url = '/customers/' . $customerNumber;
$response = $this->send_request($url, 'GET', '');
$response = json_decode($response);
return isset($response->customerNumber) ? $response : false;
}
public function getCustomerProduct(int $customer_number, int $product_id): object
{
// Get the customer product
$url = '/customers/' . $customer_number . '/templates/invoiceline/' . $product_id;
$response = $this->send_request($url, 'GET', '');
return json_decode($response);
}
public function getCustomerDiscountPercentage(int $customer_number): int
{
// If the customer number is 0, return 0
if ($customer_number === 0) {
return 0;
}
// The discount is global, but e-conomic resolves it through a product-specific
// invoice-line template. For foreign-currency customers some templates can fail
// if that product has no price in the customer currency, so try a few products
// before falling back to zero. We log every swallowed currency-price failure so
// silently-missing discounts (e.g. bug #11 customer 35131752 "kd" 15%) become
// visible in the application log instead of vanishing into the void.
$products = $this->getCustomerProducts($customer_number, 10);
$attempted_products = 0;
$swallowed_errors = 0;
foreach ($this->extractCustomerProductNumbers($products) as $product_number) {
$attempted_products++;
try {
$discount = $this->getCustomerProductDiscount($customer_number, $product_number);
return (int)($discount->discountPercentage ?? 0);
} catch (\RuntimeException $exception) {
if (!$this->isMissingCurrencyPriceLookupError($exception)) {
throw $exception;
}
$swallowed_errors++;
error_log(sprintf(
'[economicCustomers] Swallowed missing-currency-price error while resolving discount for customer %d product %d: %s',
$customer_number,
$product_number,
$exception->getMessage()
));
}
}
if ($attempted_products > 0 && $swallowed_errors === $attempted_products) {
error_log(sprintf(
'[economicCustomers] All %d invoice-line template probes failed with missing currency prices for customer %d; falling back to 0%% discount. Verify "economic_customer_discount_percentage" in e-conomic for this customer.',
$attempted_products,
$customer_number
));
}
return 0;
}
/**
* @return int[]
*/
private function extractCustomerProductNumbers(object $products): array
{
if (!isset($products->collection) || !is_array($products->collection)) {
return [];
}
$product_numbers = [];
foreach ( $products->collection as $product ) {
$product_number = $product->product->productNumber ?? null;
if ($product_number === null || $product_number === '') {
continue;
}
$product_numbers[] = (int)$product_number;
}
return array_values(array_unique($product_numbers));
}
private function isMissingCurrencyPriceLookupError(\RuntimeException $exception): bool
{
$message = $exception->getMessage();
return str_contains($message, 'No price in currency')
&& str_contains($message, 'can be found for the product');
}
public function getCustomerProducts(int $customer_number, int $limit = 10, int $page = 1): object
{
// Get the customer products
$url = '/customers/' . $customer_number . '/templates/invoiceline/?pagesize=' . $limit . '&skippages=' . ($page - 1);
$response = $this->send_request($url, 'GET', '');
return json_decode($response);
}
public function getCustomerProductDiscount(int $customer_number, int $product_id)
{
// Get the customer global discount
$url = '/customers/' . $customer_number . '/templates/invoiceline/' . $product_id;
$response = $this->send_request($url, 'GET', '');
$response = json_decode($response);
return $response;
}
/**
* Get the list of customers
* @param int $page
* @param int $limit
* @param string|null $search
* @param mixed $barred_filter Supports true/false values (bool, 1/0, true/false, barred/active)
* @return object The list of customers
* @throws Exception
*/
public function listCustomers(int $page, int $limit, string|null $search = null, mixed $barred_filter = null): object
{
// Normalize pagination parameters
$page = max(1, $page); // Ensure it's at least 1
$skipPages = $page - 1;
// Construct the base URL with pagination
$url = '/customers?pagesize=' . $limit . '&skippages=' . $skipPages;
// Define filterable property groups
$likeSupported = [
'zip', 'customerNumber', 'customerGroup.customerGroupNumber', 'name', 'address',
'city', 'country', 'email', 'telephoneAndFaxNumber', 'website', 'mobilePhone', 'corporateIdentificationNumber'
];
$filter_parts = [];
// If a search term is present, build the filter expressions
if (!empty($search)) {
// Escape special characters in the search string
$escapedSearch = str_replace(
['$', '(', ')', '*', ',', '[', ']'],
['$$', '$(', '$)', '$*', '$,', '$[', '$]'],
$search
);
// Build $like filters
$filters = [];
foreach ( $likeSupported as $property ) {
$filters[] = $property . '$like:' . $escapedSearch;
}
// Join the filters with `$or:` and keep grouping explicit for later $and composition
$filter_parts[] = '(' . implode('$or:', $filters) . ')';
}
// Optional barred filter support (all | true/barred | false/active)
$normalized_barred_filter = $this->normalizeBarredFilter($barred_filter);
if ($normalized_barred_filter !== null) {
$filter_parts[] = 'barred$eq:' . ($normalized_barred_filter ? 'true' : 'false');
}
if (!empty($filter_parts)) {
$filter_string = count($filter_parts) === 1
? $filter_parts[0]
: '(' . implode('$and:', $filter_parts) . ')';
$url .= '&filter=' . urlencode($filter_string);
}
// Send the GET request to the API endpoint
$response = $this->send_request($url, 'GET', '');
// Validate and decode the response safely
$responseObject = json_decode($response);
if ($responseObject === null) {
throw new Exception('Failed to decode the response from the API');
}
if (!is_object($responseObject)) {
throw new \RuntimeException('Malformed e-conomic customers response: expected JSON object.');
}
if (!isset($responseObject->collection) || !is_array($responseObject->collection)) {
$upstreamMessage = isset($responseObject->message) && is_string($responseObject->message)
? trim($responseObject->message)
: 'Missing collection.';
throw new \RuntimeException('Malformed e-conomic customers response: ' . $upstreamMessage);
}
if (!isset($responseObject->pagination) || !is_object($responseObject->pagination)) {
throw new \RuntimeException('Malformed e-conomic customers response: missing pagination.');
}
if (!isset($responseObject->pagination->results) || !is_numeric($responseObject->pagination->results)) {
throw new \RuntimeException('Malformed e-conomic customers response: missing pagination results.');
}
$responseObject->pagination->results = (int)$responseObject->pagination->results;
return $responseObject;
}
private function normalizeBarredFilter(mixed $value): ?bool
{
if ($value === null || $value === '') {
return null;
}
if (is_bool($value)) {
return $value;
}
if (is_int($value)) {
return $value === 1 ? true : ($value === 0 ? false : null);
}
$parsed = strtolower(trim((string)$value));
return match ($parsed) {
'1', 'true', 'yes', 'barred', 'only_barred' => true,
'0', 'false', 'no', 'active', 'not_barred' => false,
default => null,
};
}
}