Files
api/services/nginx/app/modules/economic/customers/economicCustomers.php
T
MiniMax M3 Subagent 8972b8f2ae fix(api): apply e-conomic discount percentage at line level for customer 35131752
Bug #11: For customer 35131752 ('kd'), the 15% e-conomic discount was
configured on the customer but never applied to the draft invoice line
items. The customer discount was only used in the flag/preview service for
expected price calculations, not when actually building the draft invoice.

Changes:
- economicCustomers.php: log swallowed missing-currency-price errors so
  silently-missing discounts (like customer 35131752) become visible in
  the application log instead of vanishing.
- economic_invoice_draft.php: thread the customer discount percentage
  through addOrderItemLines/addOrderItemLine and apply it at the line
  level (e-conomic's draft invoice line API requires per-line
  discountPercentage; an aggregate TotDiscount line is ignored when the
  customer has a per-line discount configured).
- economic_invoices_draft_endpoint.php: forward the customer discount
  percentage to the draft builder.
- collected_order_invoices_o.php: resolve the customer discount via
  Redis cache + e-conomicCustomers, then pass it to add_orders.
- Tests: new EconomicInvoiceDraftCustomerDiscountTest covering the
  customer 35131752 15% case, plus updates to the existing wiring tests
  to account for the new parameter and the customer-discount guard on
  the aggregate TotDiscount line.
2026-08-10 16:23:05 +02: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,
};
}
}