## 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>
777 lines
30 KiB
PHP
777 lines
30 KiB
PHP
<?php
|
|
|
|
namespace helpers;
|
|
|
|
use classes\economic;
|
|
use Exception;
|
|
use objects\currency_conversion_rates_o;
|
|
use objects\departments_o;
|
|
use objects\orders_o;
|
|
|
|
class economic_invoice_draft
|
|
{
|
|
public const DEFAULT_LINE_BATCH_SIZE = 500;
|
|
|
|
/**
|
|
* The Economic draftInvoiceNumber
|
|
* @var int $draft_invoice_number
|
|
*/
|
|
protected int $draft_invoice_number;
|
|
|
|
/**
|
|
* The draft lines
|
|
* @var array $draft_lines
|
|
*/
|
|
protected array $draft_lines = [];
|
|
|
|
/**
|
|
* The raw draft invoice data
|
|
* @var object $draft_invoice_data
|
|
*/
|
|
protected object $draft_invoice_data;
|
|
/**
|
|
* The currency for the draft invoice
|
|
* @var string $currency
|
|
* @note The currency is set to DKK by default
|
|
*/
|
|
protected string $currency = 'DKK';
|
|
/**
|
|
* The currency conversion rate
|
|
* @var float $conversion_rate
|
|
* @note The conversion rate is set to 1.0 by default
|
|
*/
|
|
protected float $conversion_rate = 1.0;
|
|
|
|
/**
|
|
* Whether pre-flight validation runs inside addLines() before sending to e-conomic.
|
|
* Defense in depth — even after sanitization, a final check catches anything that slips through.
|
|
* @var bool $preflight_enabled
|
|
*/
|
|
protected bool $preflight_enabled = true;
|
|
|
|
|
|
/**
|
|
* Construct a new Economic draft invoice object
|
|
* @throws Exception if the invoice data is invalid or empty
|
|
*/
|
|
public function __construct(int $draft_invoice_number, string $currency = 'DKK', bool $skip_fetch = false)
|
|
{
|
|
$this->setDraftInvoiceNumber($draft_invoice_number);
|
|
$this->setCurrency(strtoupper($currency));
|
|
// If the skip fetch is set to false, fetch the draft invoice data
|
|
if (!$skip_fetch) {
|
|
$this->fetchDraftInvoiceData();
|
|
} else {
|
|
$this->draft_invoice_data = new \stdClass();
|
|
$this->draft_invoice_data->draftInvoiceNumber = $this->draft_invoice_number;
|
|
}
|
|
$this->requireSelected();
|
|
}
|
|
|
|
/**
|
|
* Set the draft invoice number
|
|
* @param int $draft_invoice_number
|
|
* @return void
|
|
*/
|
|
private function setDraftInvoiceNumber(int $draft_invoice_number): void
|
|
{
|
|
$this->draft_invoice_number = $draft_invoice_number;
|
|
}
|
|
|
|
/**
|
|
* Set the currency for the draft invoice
|
|
* @param string $currency The currency to set
|
|
* @return void
|
|
* @throws Exception If the currency is not valid
|
|
* @throws Exception If the conversion rate is not valid
|
|
*/
|
|
private function setCurrency(string $currency): void
|
|
{
|
|
// Set the currency for the draft invoice
|
|
$this->currency = $currency;
|
|
// Get the conversion rate for the currency
|
|
$this->conversion_rate = (float)(new currency_conversion_rates_o())->convertTo($this->currency, 1);
|
|
}
|
|
|
|
/**
|
|
* Fetch the draft invoice data from the Economic system
|
|
* @throws Exception If the request fails
|
|
* @throws Exception If the invoice is not found
|
|
*/
|
|
private function fetchDraftInvoiceData(): void
|
|
{
|
|
$economic = new economic();
|
|
$this->draft_invoice_data = $economic->invoices->draft->get($this->draft_invoice_number);
|
|
if (!isset($this->draft_invoice_data->draftInvoiceNumber)) {
|
|
throw new Exception('Draft invoice not found');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require that the draft invoice data is selected
|
|
* @throws Exception if the draft invoice data is not set
|
|
*/
|
|
protected function requireSelected(): void
|
|
{
|
|
if (!isset($this->draft_invoice_data->draftInvoiceNumber)) {
|
|
throw new Exception('The draft invoice data is not set');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add the lines to the draft invoice.
|
|
*/
|
|
public function addLines(): void
|
|
{
|
|
if ($this->preflight_enabled) {
|
|
$this->preflightValidate($this->draft_lines, null);
|
|
}
|
|
$this->flushLinesInBatches();
|
|
}
|
|
|
|
/**
|
|
* Pre-flight validation: defense in depth before sending to e-conomic.
|
|
* Validates each line against 5 rules and throws RuntimeException on the first violation.
|
|
*
|
|
* Rules (in order, per line):
|
|
* 1. description — must be non-empty after trim()
|
|
* 2. description — must be <= 250 chars
|
|
* 3. productNumber (if present in product.productNumber) — must match /^[A-Za-z0-9._-]{1,50}$/
|
|
* 4. quantity — must be a positive number (> 0)
|
|
* 5. unitNetPrice — must be a number (>= 0)
|
|
*
|
|
* @param array<int,array<string,mixed>> $lines
|
|
* @param int|null $orderId Optional order id for log context
|
|
* @throws \RuntimeException on any rule violation
|
|
*/
|
|
public function preflightValidate(array $lines, ?int $orderId = null): void
|
|
{
|
|
foreach ($lines as $i => $line) {
|
|
if (!is_array($line)) {
|
|
$this->logAndThrow(
|
|
$i,
|
|
$orderId,
|
|
'line is not an array',
|
|
$line
|
|
);
|
|
}
|
|
|
|
// Rule 1 + 2: description
|
|
$description = $line['description'] ?? null;
|
|
if ($description === null) {
|
|
$description = '';
|
|
}
|
|
if (!is_scalar($description)) {
|
|
$description = (string)$description;
|
|
} else {
|
|
$description = (string)$description;
|
|
}
|
|
$descriptionTrimmed = trim($description);
|
|
if ($descriptionTrimmed === '') {
|
|
$this->logAndThrow(
|
|
$i,
|
|
$orderId,
|
|
'description is empty',
|
|
$description
|
|
);
|
|
}
|
|
if (mb_strlen($descriptionTrimmed) > 250) {
|
|
$this->logAndThrow(
|
|
$i,
|
|
$orderId,
|
|
'description exceeds 250 chars (length=' . mb_strlen($descriptionTrimmed) . ')',
|
|
$description
|
|
);
|
|
}
|
|
|
|
// Rule 3: productNumber (only if present in product.productNumber)
|
|
if (isset($line['product']) && is_array($line['product']) && array_key_exists('productNumber', $line['product'])) {
|
|
$productNumber = $line['product']['productNumber'];
|
|
if ($productNumber === null) {
|
|
$productNumber = '';
|
|
} else {
|
|
$productNumber = (string)$productNumber;
|
|
}
|
|
if (!preg_match('/^[A-Za-z0-9._-]{1,50}$/', $productNumber)) {
|
|
$this->logAndThrow(
|
|
$i,
|
|
$orderId,
|
|
'productNumber does not match /^[A-Za-z0-9._-]{1,50}$/',
|
|
$productNumber
|
|
);
|
|
}
|
|
}
|
|
|
|
// Rule 4: quantity — only required if present in the line (text lines omit it)
|
|
if (array_key_exists('quantity', $line)) {
|
|
$quantity = $line['quantity'];
|
|
if (!is_numeric($quantity) || (float)$quantity <= 0) {
|
|
$this->logAndThrow(
|
|
$i,
|
|
$orderId,
|
|
'quantity is not a positive number',
|
|
$quantity
|
|
);
|
|
}
|
|
}
|
|
|
|
// Rule 5: unitNetPrice — only required if present in the line
|
|
if (array_key_exists('unitNetPrice', $line)) {
|
|
$unitNetPrice = $line['unitNetPrice'];
|
|
if (!is_numeric($unitNetPrice) || (float)$unitNetPrice < 0) {
|
|
$this->logAndThrow(
|
|
$i,
|
|
$orderId,
|
|
'unitNetPrice is not a number >= 0',
|
|
$unitNetPrice
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log the offending line and throw a RuntimeException.
|
|
*/
|
|
private function logAndThrow(int $lineIndex, ?int $orderId, string $rule, mixed $value): never
|
|
{
|
|
$valueTruncated = is_scalar($value) ? (string)$value : json_encode($value);
|
|
if ($valueTruncated === false) {
|
|
$valueTruncated = '[unserializable]';
|
|
}
|
|
if (mb_strlen($valueTruncated) > 200) {
|
|
$valueTruncated = mb_substr($valueTruncated, 0, 200) . '...';
|
|
}
|
|
$orderContext = $orderId === null ? 'order=n/a' : 'order=' . $orderId;
|
|
error_log(sprintf(
|
|
'[preflight] validation failed: %s | line=%d | %s | value=%s',
|
|
$rule,
|
|
$lineIndex,
|
|
$orderContext,
|
|
$valueTruncated
|
|
));
|
|
$orderPart = $orderId === null ? '' : ' (order ' . $orderId . ')';
|
|
throw new \RuntimeException(sprintf(
|
|
'Preflight validation failed for line %d: %s%s',
|
|
$lineIndex,
|
|
$rule,
|
|
$orderPart
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Add queued draft lines using chunked requests.
|
|
*
|
|
* @return array{line_count:int,batch_count:int,batch_sizes:array<int,int>}
|
|
*/
|
|
public function flushLinesInBatches(int $batch_size = self::DEFAULT_LINE_BATCH_SIZE): array
|
|
{
|
|
$lines = array_values($this->draft_lines);
|
|
$line_count = count($lines);
|
|
if ($line_count === 0) {
|
|
return [
|
|
'line_count' => 0,
|
|
'batch_count' => 0,
|
|
'batch_sizes' => [],
|
|
];
|
|
}
|
|
|
|
$batch_size = max(1, $batch_size);
|
|
$batch_sizes = [];
|
|
foreach (array_chunk($lines, $batch_size) as $batch) {
|
|
$this->sendDraftLines($batch);
|
|
$batch_sizes[] = count($batch);
|
|
}
|
|
|
|
$this->draft_lines = [];
|
|
|
|
return [
|
|
'line_count' => $line_count,
|
|
'batch_count' => count($batch_sizes),
|
|
'batch_sizes' => $batch_sizes,
|
|
];
|
|
}
|
|
|
|
public function pendingLineCount(): int
|
|
{
|
|
return count($this->draft_lines);
|
|
}
|
|
|
|
protected function sendDraftLines(array $draft_lines): object
|
|
{
|
|
$economic = new economic();
|
|
return $economic->invoices->draft->add_lines($this->draft_invoice_number, $draft_lines);
|
|
}
|
|
|
|
/**
|
|
* Get the draft invoice number
|
|
* @return int The draft invoice number
|
|
*/
|
|
public function getDraftInvoiceNumber(): int
|
|
{
|
|
return $this->draft_invoice_number;
|
|
}
|
|
|
|
/**
|
|
* Add a new transaction header to the draft invoice
|
|
* @note The lines won't be saved until the addLines() method is called.
|
|
* @note This is the "[28/02/2025 18:29 Roskilde #685]" line in the invoice
|
|
* @param orders_o $order The order to add
|
|
* @return void
|
|
*/
|
|
public function addNewTransactionHeader(orders_o $order): void
|
|
{
|
|
// Get the department name
|
|
$department_name = (new departments_o())->getDepartmentName((int)$order->department_id->value());
|
|
// Parse the date of the transaction.
|
|
$parsed_date = date('d/m/Y H:i', strtotime($order->created_at->value()));
|
|
// Sanitize the department name (could contain "/" or other chars)
|
|
$department_name = \classes\economic_export_sanitizer::sanitizeTextLine($department_name, 100);
|
|
// Add the text line to the draft invoice
|
|
self::addTextLine("[ " . $parsed_date . ' ' . $department_name . ' #' . $order->id . " ]");
|
|
// If there's a PO number, add it to the invoice
|
|
if ($order->po->value() !== '') {
|
|
self::addTextLine('PO: ' . \classes\economic_export_sanitizer::sanitizeTextLine($order->po->value()));
|
|
}
|
|
// If there's a reference, add it to the invoice
|
|
$reference_value = $order->reference->value();
|
|
if ($reference_value !== '') {
|
|
self::addTextLine('Reference:');
|
|
// Sanitize the whole reference (handles "/" → "-" per TRU-188)
|
|
$reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($reference_value);
|
|
// Add "# " in the beginning of each line (If there's more than one line, otherwise we just add the prefix once)
|
|
if (str_contains($reference_sanitized, "\n")) {
|
|
foreach ( explode("\n", $reference_sanitized) as $line ) {
|
|
self::addTextLine('# ' . $line);
|
|
}
|
|
} else {
|
|
self::addTextLine('# ' . $reference_sanitized);
|
|
}
|
|
}
|
|
// Add the registration numbers (if any)
|
|
$line_reg = '';
|
|
if ($order->reg_1->value() !== '') {
|
|
$line_reg .= 'Reg 1: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_1->value(), 50));
|
|
}
|
|
if ($order->reg_2->value() !== '') {
|
|
$line_reg .= ', Reg 2: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_2->value(), 50));
|
|
}
|
|
if ($order->reg_3->value() !== '') {
|
|
$line_reg .= ', Reg 3: ' . strtoupper(\classes\economic_export_sanitizer::sanitizeTextLine($order->reg_3->value(), 50));
|
|
}
|
|
// Add the line to the invoice (If there's any registration numbers)
|
|
if ($line_reg !== '')
|
|
self::addTextLine($line_reg);
|
|
// If there's a note, add it to the invoice
|
|
$notes_value = $order->notes->value();
|
|
if ($notes_value !== '') {
|
|
self::addTextLine('Notat:');
|
|
// Sanitize notes (could contain "/", newlines, special chars)
|
|
$notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($notes_value);
|
|
if (str_contains($notes_sanitized, "\n")) {
|
|
foreach ( explode("\n", $notes_sanitized) as $line ) {
|
|
self::addTextLine('# ' . $line);
|
|
}
|
|
} else {
|
|
self::addTextLine('# ' . $notes_sanitized);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add a text line to the draft invoice
|
|
* @note The lines won't be saved until the addLines() method is called.
|
|
* @param string $text The text to add
|
|
* @return void
|
|
*/
|
|
public function addTextLine(string $text): void
|
|
{
|
|
// Defense in depth: sanitize ALL text lines at insertion time.
|
|
// This catches anything that wasn't pre-sanitized at the call site.
|
|
$sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($text);
|
|
if ($sanitized === '') {
|
|
return; // Skip empty/whitespace-only lines
|
|
}
|
|
$this->draft_lines[] = [
|
|
'description' => $sanitized
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get the current draft lines (read-only view).
|
|
* Used by integration tests; production code uses addLines() to send.
|
|
*/
|
|
public function getDraftLines(): array
|
|
{
|
|
return $this->draft_lines;
|
|
}
|
|
|
|
/**
|
|
* Add an order to the draft invoice
|
|
* @note The lines won't be saved until the addLines() method is called.
|
|
* @param orders_o $order The order to add
|
|
* @return void
|
|
* @throws Exception if the order is not found
|
|
* @throws Exception if the order is not valid
|
|
*/
|
|
public function addOrderItemLines(orders_o $order, bool $use_itemized_discounts = false, int $customer_discount_percentage = 0): void
|
|
{
|
|
// Get the order items
|
|
$order_items = $order->getOrderItems($order->id);
|
|
// Apply the department pricing
|
|
$order_items = $order->applyDepartmentPrices($order_items, $order->department_id->value());
|
|
// Get the department
|
|
$department = $order->getDepartmentByOrderId($order->id);
|
|
// If the department id is 10, check if the user has a default department set
|
|
if ((int)$department['id'] === 10) {
|
|
// If the default department is set, use it
|
|
$department = (new departments_o())->getDepartmentById((int)$department['id'], true);
|
|
}
|
|
// Remove all the order items that are not included in the invoice
|
|
$order_items = array_filter($order_items, function ($order_item) {
|
|
return (bool)$order_item['include_in_invoice'];
|
|
});
|
|
// Define the total discount applied to the order
|
|
$total_discount = 0;
|
|
// Normalize the customer discount percentage (clamp to 0..100)
|
|
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
|
|
// Force itemized discount mode when the customer has a global e-conomic discount
|
|
// so the discount is applied at the line level (e-conomic line API requires per-line
|
|
// discountPercentage; an aggregate TotDiscount line would be ignored when the
|
|
// customer does not have a per-line discount configured for the customer).
|
|
$effective_itemized_discounts = $use_itemized_discounts || $customer_discount_percentage > 0;
|
|
// Loop through the order items
|
|
foreach ( $order_items as $order_item ) {
|
|
if ($this->shouldSkipOrderItemLine($order_item)) {
|
|
continue;
|
|
}
|
|
// Add the order item to the draft invoice
|
|
self::addOrderItemLine($order_item, $department, false, $effective_itemized_discounts, $customer_discount_percentage);
|
|
// Add the line discount to the total discount
|
|
$total_discount += ($order_item['product']['price'] - $order_item['price']) * $order_item['quantity'];
|
|
}
|
|
// If the total discount is greater than 0, add it to the invoice
|
|
if (!$use_itemized_discounts && $customer_discount_percentage === 0 && $total_discount > 0) {
|
|
// Add the discount to the invoice
|
|
self::addProductDiscountLine($total_discount, $department['economic_department_id'] ?? 0, $department['dimension'] ?? 0);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add an order item line to the draft invoice
|
|
* @note The lines won't be saved until the addLines() method is called.
|
|
* @param array $order_item The order item to add
|
|
* @param array $department The department to add
|
|
* @param boolean $show_discount Whether to show the discount or not
|
|
* @return void
|
|
* @throws Exception if the order item is not found
|
|
* @throws Exception if the order item is not valid
|
|
*/
|
|
public function addOrderItemLine(array $order_item, array $department, bool $show_discount = false, bool $use_itemized_discount = false, int $customer_discount_percentage = 0): void
|
|
{
|
|
// Check if the order item is valid
|
|
if (!isset($order_item['id'])) {
|
|
throw new Exception('The order item is not valid');
|
|
}
|
|
if ($this->shouldSkipOrderItemLine($order_item)) {
|
|
return;
|
|
}
|
|
// Get the department id
|
|
$economic_department_id = $department['economic_department_id'] ?? 0;
|
|
// Get the dimension id
|
|
$economic_dimension_id = $department['economic_dimension_id'] ?? 0;
|
|
$pricing = self::resolveOrderItemInvoicePricing($order_item);
|
|
// The customer discount (e.g. kd customer 35131752 with 15% global e-conomic discount)
|
|
// is applied at the line level. Combined with per-item discounts using max() so the
|
|
// biggest discount wins, and so we never accidentally apply a 15% discount on top of
|
|
// an already-discounted per-item price.
|
|
$customer_discount_percentage = max(0, min(100, $customer_discount_percentage));
|
|
$itemized_discount_percentage = $use_itemized_discount
|
|
? $this->resolveItemizedDiscountPercentageForInvoiceCurrency($pricing)
|
|
: 0.0;
|
|
$discount_percentage = (float)max(
|
|
$itemized_discount_percentage,
|
|
(float)$customer_discount_percentage
|
|
);
|
|
// Add the order item to the draft invoice
|
|
self::addProductLine(
|
|
(string)$order_item['product']['economic_product_id'],
|
|
(string)$order_item['product']['name'],
|
|
(int)$order_item['quantity'] ?? 1,
|
|
$use_itemized_discount
|
|
? $pricing['invoice_unit_price']
|
|
: (int)($order_item['product']['price'] == 0 ? $order_item['price'] : $order_item['product']['price']),
|
|
(int)$economic_department_id,
|
|
$economic_dimension_id,
|
|
$discount_percentage
|
|
);
|
|
|
|
// Calculate the discount percentage (If the final price is 0, set the discount percentage to 100)
|
|
if ($order_item['product']['price'] == 0) {
|
|
$show_discount = false;
|
|
$discountPercentage = 0;
|
|
} else {
|
|
// Calculate the discount percentage
|
|
$discountPercentage = round((($order_item['product']['price'] - $order_item['price']) / $order_item['product']['price']) * 100, 0);
|
|
}
|
|
|
|
// If the price is different from the product price, add it to the line
|
|
if ($order_item['price'] !== $order_item['product']['price'] && $show_discount)
|
|
self::addTextLine('Rabat: ' . ($order_item['price'] - $order_item['product']['price']) . ' ' . $this->currency . ' (' . $discountPercentage . '%)');
|
|
|
|
// If there's a reference, add it to the line
|
|
if ($order_item['reference'] !== '') {
|
|
self::addTextLine('Reference:');
|
|
// Sanitize the reference (handles "/" → "-" per TRU-188)
|
|
$item_reference_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['reference']);
|
|
if (str_contains($item_reference_sanitized, "\n")) {
|
|
foreach ( explode("\n", $item_reference_sanitized) as $line ) {
|
|
self::addTextLine('# ' . $line);
|
|
}
|
|
} else {
|
|
self::addTextLine('# ' . $item_reference_sanitized);
|
|
}
|
|
}
|
|
|
|
// If there's a note, add it to the line
|
|
if (!empty($order_item['notes'])) {
|
|
self::addTextLine('Notat:');
|
|
// Sanitize notes (could contain "/", newlines, special chars)
|
|
$item_notes_sanitized = \classes\economic_export_sanitizer::sanitizeTextLine($order_item['notes']);
|
|
if (str_contains($item_notes_sanitized, "\n")) {
|
|
foreach ( explode("\n", $item_notes_sanitized) as $line ) {
|
|
self::addTextLine('# ' . $line);
|
|
}
|
|
} else {
|
|
self::addTextLine('# ' . $item_notes_sanitized);
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
/**
|
|
* Skip line when quantity is zero/negative, or final unit price is zero.
|
|
*/
|
|
private function shouldSkipOrderItemLine(array $order_item): bool
|
|
{
|
|
$quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity'])
|
|
? (float)$order_item['quantity']
|
|
: 0.0;
|
|
if ($quantity <= 0.0) {
|
|
return true;
|
|
}
|
|
|
|
$price = isset($order_item['price']) && is_numeric($order_item['price'])
|
|
? (float)$order_item['price']
|
|
: 0.0;
|
|
|
|
return abs($price) < 0.00001;
|
|
}
|
|
|
|
/**
|
|
* Return exact line discount data from the original department price and stored final item price.
|
|
*
|
|
* @return array{
|
|
* original_unit_price:float,
|
|
* final_unit_price:float,
|
|
* invoice_unit_price:float,
|
|
* quantity:float,
|
|
* discount_unit_amount:float,
|
|
* discount_total_amount:float,
|
|
* discount_percentage:float,
|
|
* has_discount:bool
|
|
* }
|
|
*/
|
|
public static function resolveOrderItemInvoicePricing(array $order_item): array
|
|
{
|
|
$quantity = isset($order_item['quantity']) && is_numeric($order_item['quantity'])
|
|
? (float)$order_item['quantity']
|
|
: 0.0;
|
|
$final_unit_price = isset($order_item['price']) && is_numeric($order_item['price'])
|
|
? (float)$order_item['price']
|
|
: 0.0;
|
|
$original_unit_price = isset($order_item['product']['price']) && is_numeric($order_item['product']['price'])
|
|
? (float)$order_item['product']['price']
|
|
: 0.0;
|
|
|
|
if (abs($original_unit_price) < 0.00001) {
|
|
$original_unit_price = $final_unit_price;
|
|
}
|
|
|
|
$has_discount = $quantity > 0.0
|
|
&& $final_unit_price > 0.0
|
|
&& $original_unit_price > 0.0
|
|
&& $final_unit_price < ($original_unit_price - 0.00001);
|
|
|
|
$discount_unit_amount = $has_discount
|
|
? round($original_unit_price - $final_unit_price, 2)
|
|
: 0.0;
|
|
$discount_total_amount = round($discount_unit_amount * $quantity, 2);
|
|
|
|
return [
|
|
'original_unit_price' => $original_unit_price,
|
|
'final_unit_price' => $final_unit_price,
|
|
'invoice_unit_price' => $has_discount ? $original_unit_price : $final_unit_price,
|
|
'quantity' => $quantity,
|
|
'discount_unit_amount' => $discount_unit_amount,
|
|
'discount_total_amount' => $discount_total_amount,
|
|
'discount_percentage' => $has_discount
|
|
? round((1 - ($final_unit_price / $original_unit_price)) * 100, 10)
|
|
: 0.0,
|
|
'has_discount' => $has_discount,
|
|
];
|
|
}
|
|
|
|
public static function orderItemHasBillableDiscount(array $order_item): bool
|
|
{
|
|
return self::resolveOrderItemInvoicePricing($order_item)['has_discount'];
|
|
}
|
|
|
|
/**
|
|
* Calculate the hidden e-conomic percentage from converted amounts so the rendered monetary discount stays exact.
|
|
*
|
|
* @param array{invoice_unit_price:float,final_unit_price:float,has_discount:bool,discount_percentage:float} $pricing
|
|
*/
|
|
private function resolveItemizedDiscountPercentageForInvoiceCurrency(array $pricing): float
|
|
{
|
|
if (!$pricing['has_discount']) {
|
|
return 0.0;
|
|
}
|
|
|
|
$converted_original = self::convertCurrency($pricing['invoice_unit_price']);
|
|
$converted_final = self::convertCurrency($pricing['final_unit_price']);
|
|
if ($converted_original <= 0.0 || $converted_final <= 0.0) {
|
|
return $pricing['discount_percentage'];
|
|
}
|
|
|
|
return round((1 - ($converted_final / $converted_original)) * 100, 10);
|
|
}
|
|
|
|
/**
|
|
* Add a product line to the draft invoice
|
|
* @note The lines won't be saved until the addLines() method is called.
|
|
*/
|
|
public function addProductLine(string $productNumber, string $description, float $quantity, float $unitNetPrice, int $economic_department_id, int $dimension, float $discountPercentage = 0): void
|
|
{
|
|
// Sanitize product identifier and description (defense in depth — also done at addLines())
|
|
$productNumber = \classes\economic_export_sanitizer::sanitizeProductNumber($productNumber);
|
|
$description = \classes\economic_export_sanitizer::sanitizeProductDescription($description);
|
|
// Skip if sanitization removed everything
|
|
if ($productNumber === '' || $description === '') {
|
|
return;
|
|
}
|
|
// We then add a 0 in front of the product number to make sure it's the right one, made by FlexPOS.
|
|
// Add a line to the invoice
|
|
$line = [
|
|
'product' => [
|
|
'productNumber' => $productNumber,
|
|
],
|
|
'quantity' => $quantity,
|
|
'unitNetPrice' => self::convertCurrency($unitNetPrice),
|
|
'discountPercentage' => $discountPercentage,
|
|
'description' => $description,
|
|
];
|
|
// If the department is set, add it to the line
|
|
if ($economic_department_id && $economic_department_id > 0) {
|
|
$line['departmentalDistribution'] = [
|
|
'departmentalDistributionNumber' => $economic_department_id,
|
|
'DistributionType' => 'Department',
|
|
'dimension' => $dimension
|
|
];
|
|
}
|
|
$this->draft_lines[] = $line;
|
|
}
|
|
|
|
private function convertCurrency(float $amount): float
|
|
{
|
|
// Convert the amount to the correct currency
|
|
$converted_value = $amount * self::getConversionRate();
|
|
// Round the value to 2 decimal places
|
|
return round($converted_value, 2);
|
|
}
|
|
|
|
private function getConversionRate(): float
|
|
{
|
|
// Get the conversion rate for the currency
|
|
return $this->conversion_rate;
|
|
}
|
|
|
|
/**
|
|
* Add a discount line to the draft invoice
|
|
* @note The lines won't be saved until the addLines() method is called.
|
|
* @param float $discount The discount to add
|
|
* @param int $economic_department_id The department id to add
|
|
* @param int $economic_dimension_id The dimension id to add
|
|
* @return void
|
|
*/
|
|
public function addProductDiscountLine(float $discount, int $economic_department_id, int $economic_dimension_id): void
|
|
{
|
|
// Add a line to the invoice
|
|
$line = [
|
|
'product' => [
|
|
'productNumber' => 'TotDiscount',
|
|
],
|
|
'quantity' => 1,
|
|
'unitNetPrice' => self::convertCurrency(-$discount),
|
|
'discountPercentage' => 0,
|
|
'description' => 'Rabat',
|
|
];
|
|
// If the department is set, add it to the line
|
|
if ($economic_department_id && $economic_department_id > 0) {
|
|
$line['departmentalDistribution'] = [
|
|
'departmentalDistributionNumber' => $economic_department_id,
|
|
'DistributionType' => 'Department',
|
|
'dimension' => $economic_dimension_id
|
|
];
|
|
}
|
|
$this->draft_lines[] = $line;
|
|
}
|
|
|
|
/**
|
|
* Check if a draft invoice exists in the Economic system
|
|
* @throws Exception if the draft invoice does not exist
|
|
*/
|
|
protected function requireExists(): void
|
|
{
|
|
if (!self::exists()) {
|
|
throw new Exception('The draft invoice does not exist');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a customer exists in the Economic system
|
|
* @return bool True if the customer exists, false if not
|
|
* @throws Exception
|
|
*/
|
|
public function exists(): bool
|
|
{
|
|
// Make sure the draft invoice number is set before calling
|
|
self::requireDraftInvoiceNumber();
|
|
// Check if the draft invoice exists
|
|
$economic = new economic();
|
|
return $economic->invoices->drafts->exists($this->draft_invoice_number);
|
|
}
|
|
|
|
/**
|
|
* Require that the draft invoice number is set
|
|
* @throws Exception if the draft invoice number is not set
|
|
*/
|
|
protected function requireDraftInvoiceNumber(): void
|
|
{
|
|
if (!isset($this->draft_invoice_number)) {
|
|
throw new Exception('The draft invoice number is not set');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Require that the draft invoice data is set
|
|
* @throws Exception if the draft invoice data is not set
|
|
*/
|
|
protected function requireDraftInvoiceData(): void
|
|
{
|
|
if (!isset($this->draft_invoice_data)) {
|
|
throw new Exception('The draft invoice data is not set');
|
|
}
|
|
}
|
|
}
|