## 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>
273 lines
10 KiB
PHP
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,
|
|
};
|
|
}
|
|
|
|
|
|
}
|