Add logic for booked department 75 redistribution with fixed pricing and subscription weights. Update OpenAPI spec, unit tests, and service integration.

This commit is contained in:
Jeppe Bundgaard
2026-03-16 13:40:03 +01:00
parent 425c2c9142
commit 6aa04eb951
6 changed files with 1116 additions and 4 deletions
+129 -1
View File
@@ -5721,6 +5721,33 @@ paths:
'403': { $ref: '#/components/responses/Forbidden' }
'500': { $ref: '#/components/responses/InternalServerError' }
/superuser/invoicing/period/distribution/v2/booked-department-75:
get:
tags:
- Invoices
summary: Get booked e-conomic department 75 redistribution
operationId: getInvoicingPeriodDistributionV2BookedDepartment75
parameters:
- name: dateFrom
in: query
required: true
schema: { type: string, format: date }
- name: dateTo
in: query
required: true
schema: { type: string, format: date }
responses:
'200':
description: Actual booked e-conomic department 75 net amounts redistributed to internal departments
content:
application/json:
schema:
$ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'500': { $ref: '#/components/responses/InternalServerError' }
/superuser/customers/pricing-history:
get:
tags:
@@ -11207,6 +11234,105 @@ components:
InvoicingDistributionV2CustomerPricesResponse:
$ref: '#/components/schemas/InvoicingDistributionV2CategoryResponse'
InvoicingDistributionV2BookedDepartment75Group:
type: object
properties:
month:
type: string
example: '2026-01'
source_category:
type: string
enum: [fixed_pricing, wash_subscriptions, unclassified]
invoice_ids:
type: array
items:
type: integer
booked_net_amount:
type: number
department_distribution:
$ref: '#/components/schemas/EconomicV2DepartmentDistribution'
undistributed_net_amount:
type: number
required:
- month
- source_category
- invoice_ids
- booked_net_amount
- department_distribution
- undistributed_net_amount
InvoicingDistributionV2BookedDepartment75Meta:
type: object
properties:
booked_net_amount:
type: number
distributed_net_amount:
type: number
undistributed_net_amount:
type: number
department_distribution:
$ref: '#/components/schemas/EconomicV2DepartmentDistribution'
booked_groups:
type: array
items:
$ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Group'
required:
- booked_net_amount
- distributed_net_amount
- undistributed_net_amount
- department_distribution
- booked_groups
InvoicingDistributionV2BookedDepartment75Customer:
allOf:
- $ref: '#/components/schemas/InvoicingDistributionV2Customer'
- type: object
properties:
meta:
type: object
properties:
booked_department_75:
$ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Meta'
required:
- booked_department_75
InvoicingDistributionV2BookedDepartment75CollectiveResults:
type: object
properties:
booked_net_amount:
type: number
distributed_net_amount:
type: number
undistributed_net_amount:
type: number
department_distribution:
$ref: '#/components/schemas/EconomicV2DepartmentDistribution'
department_distribution_parsed:
type: object
additionalProperties:
type: number
required:
- booked_net_amount
- distributed_net_amount
- undistributed_net_amount
- department_distribution
- department_distribution_parsed
InvoicingDistributionV2BookedDepartment75Response:
type: object
properties:
customers:
type: array
items:
$ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Customer'
collective_results:
$ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75CollectiveResults'
warnings:
type: array
items:
type: string
required: [customers, collective_results, warnings]
InvoicingDistributionV2AllResponse:
type: object
properties:
@@ -11216,7 +11342,9 @@ components:
$ref: '#/components/schemas/InvoicingDistributionV2WashSubscriptionsResponse'
customer_prices:
$ref: '#/components/schemas/InvoicingDistributionV2CustomerPricesResponse'
required: [fixed_pricing, wash_subscriptions, customer_prices]
booked_department_75:
$ref: '#/components/schemas/InvoicingDistributionV2BookedDepartment75Response'
required: [fixed_pricing, wash_subscriptions, customer_prices, booked_department_75]
PricingHistoryVersionEntry:
type: object
@@ -14,10 +14,15 @@ use objects\users_o;
class economic_v2_distribution_service
{
private const SYSTEM_ORDER_DEPARTMENT_ID = 10;
private const BOOKED_DEPARTMENT_75 = 75;
private const BOOKED_INVOICE_PAGE_SIZE = 1000;
private const BOOKED_INVOICE_MAX_PAGES = 10;
private const EPSILON = 0.00001;
private const FIXED_PRICING_SYSTEM_ORDER_REFERENCE = 'Fast pris aftale';
private const WASH_SUBSCRIPTION_SYSTEM_ORDER_REFERENCE = 'Vaskeabonnementer';
private economic_v2_versioning_service $versioning;
private ?economic $economic = null;
private array $department_name_cache = [];
private array $department_excluded_cache = [];
private array $customer_name_cache = [];
@@ -26,13 +31,16 @@ class economic_v2_distribution_service
private array $discount_resolution_cache = [];
private array $orders_in_range_cache = [];
private array $order_items_by_order_ids_cache = [];
private array $booked_invoices_cache = [];
private array $booked_invoice_lines_cache = [];
private array $vehicle_subscription_versions_in_range_cache = [];
private array $version_table_has_rows_cache = [];
private bool $best_effort_backfill_attempted = false;
public function __construct(?economic_v2_versioning_service $versioning = null)
public function __construct(?economic_v2_versioning_service $versioning = null, ?economic $economic = null)
{
$this->versioning = $versioning ?? new economic_v2_versioning_service();
$this->economic = $economic;
}
public function getAllDistributions(string $date_from, string $date_to): array
@@ -43,13 +51,37 @@ class economic_v2_distribution_service
'discount_overrides',
]);
$fixed_pricing = $this->getFixedPricingDistribution($date_from, $date_to);
$wash_subscriptions = $this->getWashSubscriptionsDistribution($date_from, $date_to);
return [
'fixed_pricing' => $this->getFixedPricingDistribution($date_from, $date_to),
'wash_subscriptions' => $this->getWashSubscriptionsDistribution($date_from, $date_to),
'fixed_pricing' => $fixed_pricing,
'wash_subscriptions' => $wash_subscriptions,
'customer_prices' => $this->getCustomerPricesDistribution($date_from, $date_to),
'booked_department_75' => $this->buildBookedDepartment75Distribution(
$date_from,
$date_to,
$fixed_pricing,
$wash_subscriptions
),
];
}
public function getBookedDepartment75Distribution(string $date_from, string $date_to): array
{
$this->ensureVersionHistoryAvailable([
'fixed_pricing',
'vehicle_subscriptions',
]);
return $this->buildBookedDepartment75Distribution(
$date_from,
$date_to,
$this->getFixedPricingDistribution($date_from, $date_to),
$this->getWashSubscriptionsDistribution($date_from, $date_to)
);
}
public function getFixedPricingDistribution(string $date_from, string $date_to): array
{
$this->ensureVersionHistoryAvailable(['fixed_pricing']);
@@ -401,6 +433,542 @@ class economic_v2_distribution_service
];
}
private function buildBookedDepartment75Distribution(
string $date_from,
string $date_to,
array $fixed_pricing,
array $wash_subscriptions
): array {
$weight_index = $this->buildBookedDepartment75WeightIndex($fixed_pricing, $wash_subscriptions);
$transaction_map = $this->buildBookedDepartment75TransactionMap($fixed_pricing, $wash_subscriptions);
$warnings = [];
$groups = $this->collectBookedDepartment75Groups($date_from, $date_to, $warnings);
$customers = [];
$collective = [
'booked_net_amount' => 0.0,
'distributed_net_amount' => 0.0,
'undistributed_net_amount' => 0.0,
'department_distribution' => [],
];
foreach ($groups as $group) {
$customer_number = (int)$group['customer_number'];
if (!isset($customers[$customer_number])) {
$customers[$customer_number] = $this->buildBookedDepartment75CustomerEnvelope(
$customer_number,
$transaction_map[$customer_number] ?? []
);
$customers[$customer_number]['meta']['booked_department_75'] = [
'booked_net_amount' => 0.0,
'distributed_net_amount' => 0.0,
'undistributed_net_amount' => 0.0,
'department_distribution' => [],
'booked_groups' => [],
];
}
$redistributed = $this->redistributeBookedDepartment75Group($group, $weight_index, $warnings);
$distributed_amount = array_sum($redistributed['department_distribution']);
$booked_amount = (float)$group['booked_net_amount'];
$invoice_ids = array_values(array_unique(array_map('intval', $group['invoice_ids'] ?? [])));
sort($invoice_ids);
$customers[$customer_number]['meta']['booked_department_75']['booked_net_amount'] += $booked_amount;
$customers[$customer_number]['meta']['booked_department_75']['distributed_net_amount'] += $distributed_amount;
$customers[$customer_number]['meta']['booked_department_75']['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount'];
foreach ($redistributed['department_distribution'] as $department_id => $amount) {
$department_id = (int)$department_id;
if (!isset($customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id])) {
$customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] = 0.0;
}
$customers[$customer_number]['meta']['booked_department_75']['department_distribution'][$department_id] += (float)$amount;
if (!isset($collective['department_distribution'][$department_id])) {
$collective['department_distribution'][$department_id] = 0.0;
}
$collective['department_distribution'][$department_id] += (float)$amount;
}
$customers[$customer_number]['meta']['booked_department_75']['booked_groups'][] = [
'month' => (string)$group['month'],
'source_category' => (string)$group['source_category'],
'invoice_ids' => $invoice_ids,
'booked_net_amount' => round($booked_amount, 5),
'department_distribution' => $this->roundMap($redistributed['department_distribution']),
'undistributed_net_amount' => round((float)$redistributed['undistributed_net_amount'], 5),
];
$collective['booked_net_amount'] += $booked_amount;
$collective['distributed_net_amount'] += $distributed_amount;
$collective['undistributed_net_amount'] += (float)$redistributed['undistributed_net_amount'];
}
$customers = array_values(array_map(function (array $customer): array {
if (!isset($customer['meta']['booked_department_75'])) {
return $customer;
}
$customer['meta']['booked_department_75']['booked_net_amount'] = round(
(float)$customer['meta']['booked_department_75']['booked_net_amount'],
5
);
$customer['meta']['booked_department_75']['distributed_net_amount'] = round(
(float)$customer['meta']['booked_department_75']['distributed_net_amount'],
5
);
$customer['meta']['booked_department_75']['undistributed_net_amount'] = round(
(float)$customer['meta']['booked_department_75']['undistributed_net_amount'],
5
);
$customer['meta']['booked_department_75']['department_distribution'] = $this->roundMap(
$customer['meta']['booked_department_75']['department_distribution']
);
usort($customer['meta']['booked_department_75']['booked_groups'], static function (array $left, array $right): int {
$month_compare = strcmp((string)$left['month'], (string)$right['month']);
if ($month_compare !== 0) {
return $month_compare;
}
return strcmp((string)$left['source_category'], (string)$right['source_category']);
});
return $customer;
}, $customers));
usort($customers, static fn(array $left, array $right): int => ((int)$left['customer_number']) <=> ((int)$right['customer_number']));
return [
'customers' => $customers,
'collective_results' => [
'booked_net_amount' => round((float)$collective['booked_net_amount'], 5),
'distributed_net_amount' => round((float)$collective['distributed_net_amount'], 5),
'undistributed_net_amount' => round((float)$collective['undistributed_net_amount'], 5),
'department_distribution' => $this->roundMap($collective['department_distribution']),
'department_distribution_parsed' => $this->parseDepartmentMap($collective['department_distribution']),
],
'warnings' => array_values(array_unique($warnings)),
];
}
private function buildBookedDepartment75WeightIndex(array $fixed_pricing, array $wash_subscriptions): array
{
$weights = [];
foreach (($fixed_pricing['customers'] ?? []) as $customer) {
$customer_number = (int)($customer['customer_number'] ?? 0);
foreach ((array)($customer['meta']['fixed_pricing']['version_groups'] ?? []) as $group) {
$this->accumulateBookedDepartment75Weights(
$weights,
$customer_number,
(string)($group['month'] ?? ''),
'fixed_pricing',
(array)($group['department_totals_relative'] ?? [])
);
}
}
foreach (($wash_subscriptions['customers'] ?? []) as $customer) {
$customer_number = (int)($customer['customer_number'] ?? 0);
foreach ((array)($customer['meta']['subscription']['version_groups'] ?? []) as $group) {
$this->accumulateBookedDepartment75Weights(
$weights,
$customer_number,
(string)($group['month'] ?? ''),
'wash_subscriptions',
(array)($group['department_distribution'] ?? [])
);
}
}
return $weights;
}
private function accumulateBookedDepartment75Weights(
array &$weights,
int $customer_number,
string $month_key,
string $source_category,
array $distribution
): void {
if ($customer_number <= 0 || $month_key === '' || $source_category === '') {
return;
}
foreach ($distribution as $department_id => $amount) {
$department_id = (int)$department_id;
if (!$this->isDepartmentEligible($department_id)) {
continue;
}
if (!isset($weights[$customer_number][$month_key][$source_category][$department_id])) {
$weights[$customer_number][$month_key][$source_category][$department_id] = 0.0;
}
$weights[$customer_number][$month_key][$source_category][$department_id] += (float)$amount;
}
}
private function buildBookedDepartment75TransactionMap(array $fixed_pricing, array $wash_subscriptions): array
{
$transaction_map = [];
foreach ([$fixed_pricing, $wash_subscriptions] as $response) {
foreach (($response['customers'] ?? []) as $customer) {
$customer_number = (int)($customer['customer_number'] ?? 0);
if ($customer_number <= 0) {
continue;
}
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
$transaction_id = (int)($transaction['id'] ?? 0);
if ($transaction_id > 0) {
$transaction_map[$customer_number][$transaction_id] = $transaction;
continue;
}
$transaction_map[$customer_number][] = $transaction;
}
}
}
return $transaction_map;
}
private function buildBookedDepartment75CustomerEnvelope(int $customer_number, array $transaction_map): array
{
try {
return $this->buildCustomerEnvelope($customer_number, $transaction_map);
} catch (Exception $e) {
return [
'id' => null,
'customer_number' => $customer_number,
'customer_name' => $this->getCustomerName($customer_number),
'transactions' => array_values($transaction_map),
'requires_action' => false,
'meta' => [],
];
}
}
private function collectBookedDepartment75Groups(string $date_from, string $date_to, array &$warnings): array
{
$booked_invoices = $this->fetchBookedInvoicesInDateRange($date_from, $date_to, $warnings);
$invoice_ids = [];
foreach ($booked_invoices as $invoice_raw) {
$invoice = $this->toArray($invoice_raw);
$invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0);
if ($invoice_id > 0) {
$invoice_ids[] = $invoice_id;
}
}
$invoice_lines = $this->fetchBookedInvoiceLines($invoice_ids, $warnings);
$groups = [];
foreach ($booked_invoices as $invoice_raw) {
$invoice = $this->toArray($invoice_raw);
$invoice_id = (int)($invoice['bookedInvoiceNumber'] ?? $invoice['booked_invoice_number'] ?? 0);
$customer_number = (int)($invoice['customer']['customerNumber'] ?? $invoice['customer']['customer_number'] ?? 0);
$invoice_date = (string)($invoice['date'] ?? '');
if ($invoice_id <= 0 || $customer_number <= 0 || $invoice_date === '') {
continue;
}
$month_key = substr($invoice_date, 0, 7);
foreach ($this->parseBookedDepartment75InvoiceLines($invoice_id, (array)($invoice_lines[$invoice_id] ?? []), $warnings) as $line) {
if (abs((float)$line['booked_net_amount']) <= self::EPSILON) {
continue;
}
$source_category = (string)($line['source_category'] ?? 'unclassified');
$group_key = $customer_number . '|' . $month_key . '|' . $source_category;
if (!isset($groups[$group_key])) {
$groups[$group_key] = [
'customer_number' => $customer_number,
'month' => $month_key,
'source_category' => $source_category,
'invoice_ids' => [],
'booked_net_amount' => 0.0,
];
}
$groups[$group_key]['invoice_ids'][$invoice_id] = true;
$groups[$group_key]['booked_net_amount'] += (float)$line['booked_net_amount'];
}
}
foreach ($groups as &$group) {
$group['invoice_ids'] = array_values(array_map('intval', array_keys((array)$group['invoice_ids'])));
sort($group['invoice_ids']);
}
unset($group);
return $groups;
}
private function parseBookedDepartment75InvoiceLines(int $invoice_id, array $invoice_lines, array &$warnings): array
{
$parsed = [];
$active_category = null;
foreach ($invoice_lines as $line_raw) {
$line = $this->normalizeBookedDepartment75Line($line_raw);
$description = trim((string)($line['description'] ?? ''));
if ($description !== '' && $this->isBookedDepartment75TransactionHeader($description)) {
$active_category = null;
continue;
}
$marker = $this->resolveBookedDepartment75Marker($description);
if ($marker !== null) {
$active_category = $marker;
continue;
}
if (!(bool)($line['billable'] ?? false)) {
continue;
}
$department_share = (float)(
$line['department_distribution'][(string)self::BOOKED_DEPARTMENT_75]
?? $line['department_distribution'][self::BOOKED_DEPARTMENT_75]
?? 0.0
);
if (abs($department_share) <= self::EPSILON) {
continue;
}
$booked_net_amount = (float)($line['line_net_amount'] ?? 0.0) * ($department_share / 100.0);
$source_category = $active_category;
if ($source_category === null) {
$warnings[] = 'Unable to classify booked department 75 line on invoice '
. $invoice_id
. ' line '
. (int)($line['source_line_id'] ?? 0)
. '; amount remains undistributed.';
$source_category = 'unclassified';
}
$parsed[] = [
'source_category' => $source_category,
'booked_net_amount' => $booked_net_amount,
];
}
return $parsed;
}
private function normalizeBookedDepartment75Line(mixed $raw_line): array
{
$line = $this->toArray($raw_line);
$product_number = $line['product']['productNumber']
?? $line['product']['product_number']
?? null;
$quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0;
$unit_net_price = isset($line['unitNetPrice'])
? (float)$line['unitNetPrice']
: (isset($line['unit_net_price']) ? (float)$line['unit_net_price'] : 0.0);
$line_net_amount = isset($line['totalNetAmount'])
? (float)$line['totalNetAmount']
: (isset($line['total_net_amount']) ? (float)$line['total_net_amount'] : $quantity * $unit_net_price);
return [
'description' => (string)($line['description'] ?? ''),
'product_number' => $product_number !== null ? (string)$product_number : null,
'quantity' => $quantity,
'line_net_amount' => $line_net_amount,
'billable' => ($product_number !== null) || abs($line_net_amount) > self::EPSILON || abs($quantity) > self::EPSILON,
'source_line_id' => (int)($line['lineNumber'] ?? $line['line_number'] ?? 0),
'department_distribution' => $this->extractBookedDepartment75Distribution($line),
];
}
private function extractBookedDepartment75Distribution(array $line): array
{
$distribution = [];
$departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null;
if (!is_array($departmental_distribution)) {
return $distribution;
}
$distributions = $departmental_distribution['distributions'] ?? null;
if (is_array($distributions)) {
foreach ($distributions as $entry_raw) {
$entry = $this->toArray($entry_raw);
$department_number = $entry['department']['departmentNumber']
?? $entry['department']['department_number']
?? null;
if ($department_number === null) {
continue;
}
$distribution[(string)$department_number] = (float)($entry['percentage'] ?? 0.0);
}
}
if (empty($distribution)) {
$fallback_number = $departmental_distribution['departmentalDistributionNumber']
?? $departmental_distribution['departmental_distribution_number']
?? null;
if ($fallback_number !== null) {
$distribution[(string)$fallback_number] = 100.0;
}
}
return $distribution;
}
private function resolveBookedDepartment75Marker(string $description): ?string
{
$normalized = strtolower(trim(preg_replace('/\s+/', ' ', $description)));
return match ($normalized) {
'# fast pris aftale', 'fast pris aftale' => 'fixed_pricing',
'# vaskeabonnementer', 'vaskeabonnementer' => 'wash_subscriptions',
default => null,
};
}
private function isBookedDepartment75TransactionHeader(string $description): bool
{
return str_starts_with($description, '[') && str_ends_with($description, ']');
}
private function redistributeBookedDepartment75Group(array $group, array $weight_index, array &$warnings): array
{
$booked_amount = (float)($group['booked_net_amount'] ?? 0.0);
if (abs($booked_amount) <= self::EPSILON) {
return [
'department_distribution' => [],
'undistributed_net_amount' => 0.0,
];
}
$customer_number = (int)($group['customer_number'] ?? 0);
$month_key = (string)($group['month'] ?? '');
$source_category = (string)($group['source_category'] ?? 'unclassified');
$invoice_ids = implode(', ', array_map('intval', (array)($group['invoice_ids'] ?? [])));
if ($source_category === 'unclassified') {
$warnings[] = 'Booked department 75 amount for customer '
. $customer_number
. ' in '
. $month_key
. ' on invoice(s) '
. $invoice_ids
. ' could not be classified and remains undistributed.';
return [
'department_distribution' => [],
'undistributed_net_amount' => $booked_amount,
];
}
$weights = $weight_index[$customer_number][$month_key][$source_category] ?? [];
$eligible_weights = [];
foreach ($weights as $department_id => $amount) {
$department_id = (int)$department_id;
if (!$this->isDepartmentEligible($department_id)) {
continue;
}
$eligible_weights[$department_id] = (float)$amount;
}
$weight_total = array_sum($eligible_weights);
if (abs($weight_total) <= self::EPSILON) {
$warnings[] = 'Booked department 75 '
. $source_category
. ' amount for customer '
. $customer_number
. ' in '
. $month_key
. ' on invoice(s) '
. $invoice_ids
. ' has no redistribution basis and remains undistributed.';
return [
'department_distribution' => [],
'undistributed_net_amount' => $booked_amount,
];
}
$distribution = [];
foreach ($eligible_weights as $department_id => $weight) {
$distribution[$department_id] = $booked_amount * ($weight / $weight_total);
}
return [
'department_distribution' => $distribution,
'undistributed_net_amount' => 0.0,
];
}
protected function fetchBookedInvoicesInDateRange(string $date_from, string $date_to, array &$warnings): array
{
$cache_key = $date_from . '|' . $date_to;
if (isset($this->booked_invoices_cache[$cache_key])) {
return $this->booked_invoices_cache[$cache_key];
}
$filters = [
'(date$gte:' . $date_from . '$and:date$lte:' . $date_to . ')' => '',
];
$all = [];
for ($page = 0; $page < self::BOOKED_INVOICE_MAX_PAGES; $page++) {
try {
$response = $this->getEconomicClient()->invoices->booked->get(
$filters,
[
'skipPages' => $page,
'pageSize' => self::BOOKED_INVOICE_PAGE_SIZE,
]
);
} catch (\Throwable $e) {
$warnings[] = 'Failed to fetch booked e-conomic invoices for department 75 distribution: ' . $e->getMessage();
break;
}
$collection = is_array($response->collection ?? null) ? $response->collection : [];
$all = array_merge($all, $collection);
if (count($collection) < self::BOOKED_INVOICE_PAGE_SIZE) {
break;
}
if ($page + 1 >= self::BOOKED_INVOICE_MAX_PAGES) {
$warnings[] = 'Reached pagination safety limit (max_pages='
. self::BOOKED_INVOICE_MAX_PAGES
. ') while fetching booked department 75 invoices.';
}
}
$this->booked_invoices_cache[$cache_key] = $all;
return $this->booked_invoices_cache[$cache_key];
}
protected function fetchBookedInvoiceLines(array $invoice_ids, array &$warnings): array
{
$invoice_ids = array_values(array_unique(array_filter(array_map('intval', $invoice_ids), static fn(int $id): bool => $id > 0)));
sort($invoice_ids);
if (empty($invoice_ids)) {
return [];
}
$cache_key = implode(',', $invoice_ids);
if (isset($this->booked_invoice_lines_cache[$cache_key])) {
return $this->booked_invoice_lines_cache[$cache_key];
}
try {
$this->booked_invoice_lines_cache[$cache_key] = $this->getEconomicClient()->invoices->booked->get_invoice_lines($invoice_ids);
} catch (\Throwable $e) {
$warnings[] = 'Failed to fetch booked invoice lines for department 75 distribution: ' . $e->getMessage();
$this->booked_invoice_lines_cache[$cache_key] = [];
}
return $this->booked_invoice_lines_cache[$cache_key];
}
protected function ensureVersionHistoryAvailable(array $areas): void
{
if ($this->best_effort_backfill_attempted) {
@@ -919,6 +1487,27 @@ class economic_v2_distribution_service
return (float)$product->getSubscriptionMonthlyPrice();
}
private function getEconomicClient(): economic
{
if ($this->economic === null) {
$this->economic = new economic();
}
return $this->economic;
}
protected function toArray(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (!is_object($value)) {
return [];
}
return json_decode(json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), true) ?: [];
}
private function getProduct(int $product_id): ?products_o
{
if (!isset($this->product_cache[$product_id])) {
@@ -340,6 +340,24 @@ class InvoicingPeriodRoute
]
);
$this->get('/superuser/invoicing/period/distribution/v2/booked-department-75', function () {
global $response;
$this->requirePermission('superuser_invoicing_period_distribution_v2');
$dateRange = $this->requireAndNormalizeDateRange();
$dateFrom = $dateRange['dateFrom'];
$dateTo = $dateRange['dateTo'];
$response->add_meta('date_from', $dateFrom);
$response->add_meta('date_to', $dateTo);
$response->success($this->withCachedDistributionV2('booked-department-75', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) {
return (new economic_v2_distribution_service())->getBookedDepartment75Distribution($dateFrom, $dateTo);
}));
},
[
'superuser_invoicing_period_distribution_v2' => 'Get booked e-conomic department 75 redistribution based on actual booked net amounts.',
]
);
$this->get('/superuser/customers/pricing-history', function () {
global $response;
$this->requirePermission('superuser_customer_pricing_history_v2');
@@ -0,0 +1,374 @@
<?php
app_require('classes/economic_v2_versioning_service.php');
app_require('classes/economic_v2_distribution_service.php');
use classes\economic_v2_distribution_service;
use classes\economic_v2_versioning_service;
if (!class_exists('FakeEconomicV2BookedDepartment75VersioningService')) {
class FakeEconomicV2BookedDepartment75VersioningService extends economic_v2_versioning_service
{
public ?array $fixedVersion = null;
public array $subscriptionVersions = [];
public function __construct()
{
}
public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array
{
return $this->fixedVersion;
}
public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array
{
return $this->subscriptionVersions;
}
public function resolveDiscountOverrideAt(int $customer_number, bool $is_category, string|int $object_id, string $timestamp): ?array
{
return null;
}
public function runBestEffortBackfill(): array
{
return [
'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0],
'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0],
'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0],
'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0],
'warnings' => [],
];
}
}
}
if (!class_exists('TestableEconomicV2BookedDepartment75DistributionService')) {
class TestableEconomicV2BookedDepartment75DistributionService extends economic_v2_distribution_service
{
public array $stubOrders = [];
public array $stubOrderItems = [];
public array $stubVersionRows = [];
public array $subscriptionPrices = [];
public array $stubBookedInvoices = [];
public array $stubBookedInvoiceLines = [];
public function __construct(?economic_v2_versioning_service $versioning = null)
{
parent::__construct($versioning);
}
protected function fetchOrdersInRange(string $from_ts, string $to_ts): array
{
return $this->stubOrders;
}
protected function fetchOrderItemsByOrderIds(array $order_ids): array
{
return $this->stubOrderItems;
}
protected function fetchVehicleSubscriptionVersionRows(string $from_ts, string $to_ts): array
{
return $this->stubVersionRows;
}
protected function calculateOrderOriginalPrice(array $order_items, int $customer_number, int $department_id, string $timestamp): float
{
$total = 0.0;
foreach ($order_items as $item) {
$total += (float)($item['price'] ?? 0.0) * (float)($item['quantity'] ?? 0.0);
}
return $total;
}
protected function getSubscriptionMonthlyPrice(int $vehicle_type): float
{
return (float)($this->subscriptionPrices[$vehicle_type] ?? 0.0);
}
protected function fetchBookedInvoicesInDateRange(string $date_from, string $date_to, array &$warnings): array
{
return $this->stubBookedInvoices;
}
protected function fetchBookedInvoiceLines(array $invoice_ids, array &$warnings): array
{
return $this->stubBookedInvoiceLines;
}
protected function buildCustomerEnvelope(int $customer_number, array $transaction_map): array
{
return [
'id' => $customer_number,
'customer_number' => $customer_number,
'customer_name' => 'Customer ' . $customer_number,
'transactions' => array_values($transaction_map),
'requires_action' => false,
'meta' => [],
];
}
protected function buildTransactionObject(int $order_id, string $created_at, int $department_id, ?float $amount = null): array
{
return [
'id' => $order_id,
'date' => $created_at,
'amount' => round((float)($amount ?? 0.0), 5),
'booked' => true,
'department_id' => $department_id,
'excluded' => !$this->isDepartmentEligible($department_id),
];
}
protected function isDepartmentEligible(int $department_id): bool
{
return $department_id > 0 && $department_id !== 10;
}
protected function parseDepartmentMap(array $department_map): array
{
$parsed = [];
foreach ($department_map as $department_id => $amount) {
$parsed['Department ' . $department_id] = round((float)$amount, 5);
}
return $parsed;
}
protected function versionTableHasRows(string $table): bool
{
return true;
}
}
}
it('redistributes booked department 75 net amounts using fixed pricing and wash subscription weights', function (): void {
$versioning = new FakeEconomicV2BookedDepartment75VersioningService();
$versioning->fixedVersion = [
'id' => 91,
'price' => 500.0,
'description' => 'Fixed pricing agreement',
'source' => 'test.fixed',
'confidence' => 1.0,
'inferred' => false,
'effective_from' => '2026-01-01 00:00:00',
'effective_to' => null,
];
$versioning->subscriptionVersions = [[
'id' => 42,
'reg' => 'AB12345',
'vehicle_type' => 77,
'source' => 'test.subscription',
'confidence' => 1.0,
'inferred' => false,
]];
$service = new TestableEconomicV2BookedDepartment75DistributionService($versioning);
$service->subscriptionPrices = [
77 => 120.0,
];
$service->stubOrders = [
[
'id' => 101,
'customer_id' => 12345,
'department_id' => 1,
'created_at' => '2026-01-10 10:00:00',
'reg_1' => '',
'reference' => 'Internal fixed pricing basis',
],
[
'id' => 102,
'customer_id' => 12345,
'department_id' => 2,
'created_at' => '2026-01-11 10:00:00',
'reg_1' => 'AB12345',
'reference' => '',
],
];
$service->stubOrderItems = [
101 => [[
'product_id' => 61,
'price' => 400.0,
'quantity' => 1,
'reference' => '',
]],
102 => [[
'product_id' => 77,
'price' => 60.0,
'quantity' => 2,
'reference' => 'AB12345',
]],
];
$service->stubBookedInvoices = [[
'bookedInvoiceNumber' => 7001,
'date' => '2026-01-31',
'customer' => [
'customerNumber' => 12345,
],
]];
$service->stubBookedInvoiceLines = [
7001 => [
['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #999 ]'],
['lineNumber' => 2, 'description' => 'Reference:'],
['lineNumber' => 3, 'description' => '# Fast pris aftale'],
[
'lineNumber' => 4,
'description' => 'Fixed price',
'quantity' => 1,
'unitNetPrice' => 200,
'totalNetAmount' => 200,
'product' => ['productNumber' => 61],
'departmentalDistribution' => [
'distributions' => [
['percentage' => 50, 'department' => ['departmentNumber' => 75]],
['percentage' => 50, 'department' => ['departmentNumber' => 1]],
],
],
],
[
'lineNumber' => 5,
'description' => 'Rabat',
'quantity' => 1,
'unitNetPrice' => -40,
'totalNetAmount' => -40,
'product' => ['productNumber' => 'TotDiscount'],
'departmentalDistribution' => [
'distributions' => [
['percentage' => 50, 'department' => ['departmentNumber' => 75]],
['percentage' => 50, 'department' => ['departmentNumber' => 1]],
],
],
],
['lineNumber' => 6, 'description' => '[ 01/01/2026 00:00 Auto #1000 ]'],
['lineNumber' => 7, 'description' => 'Reference:'],
['lineNumber' => 8, 'description' => '# Vaskeabonnementer'],
[
'lineNumber' => 9,
'description' => 'Subscription',
'quantity' => 1,
'unitNetPrice' => 120,
'totalNetAmount' => 120,
'product' => ['productNumber' => 77],
'departmentalDistribution' => [
'distributions' => [
['percentage' => 100, 'department' => ['departmentNumber' => 75]],
],
],
],
],
];
$result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31');
expect($result['warnings'])->toBe([]);
expect($result['customers'])->toHaveCount(1);
$meta = $result['customers'][0]['meta']['booked_department_75'];
expect($meta['booked_net_amount'])->toBe(200.0);
expect($meta['distributed_net_amount'])->toBe(200.0);
expect($meta['undistributed_net_amount'])->toBe(0.0);
expect($meta['department_distribution']['1'])->toBe(61.53846);
expect($meta['department_distribution']['2'])->toBe(138.46154);
$groups = [];
foreach ($meta['booked_groups'] as $group) {
$groups[$group['source_category']] = $group;
}
expect($groups['fixed_pricing']['booked_net_amount'])->toBe(80.0);
expect($groups['fixed_pricing']['department_distribution']['1'])->toBe(61.53846);
expect($groups['fixed_pricing']['department_distribution']['2'])->toBe(18.46154);
expect($groups['fixed_pricing']['undistributed_net_amount'])->toBe(0.0);
expect($groups['wash_subscriptions']['booked_net_amount'])->toBe(120.0);
expect($groups['wash_subscriptions']['department_distribution']['2'])->toBe(120.0);
expect($groups['wash_subscriptions']['undistributed_net_amount'])->toBe(0.0);
expect($result['collective_results']['booked_net_amount'])->toBe(200.0);
expect($result['collective_results']['distributed_net_amount'])->toBe(200.0);
expect($result['collective_results']['undistributed_net_amount'])->toBe(0.0);
expect($result['collective_results']['department_distribution']['1'])->toBe(61.53846);
expect($result['collective_results']['department_distribution']['2'])->toBe(138.46154);
});
it('keeps classified booked department 75 amounts undistributed when no monthly basis exists', function (): void {
$service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService());
$service->stubBookedInvoices = [[
'bookedInvoiceNumber' => 7002,
'date' => '2026-01-31',
'customer' => [
'customerNumber' => 67890,
],
]];
$service->stubBookedInvoiceLines = [
7002 => [
['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1001 ]'],
['lineNumber' => 2, 'description' => 'Reference:'],
['lineNumber' => 3, 'description' => '# Fast pris aftale'],
[
'lineNumber' => 4,
'description' => 'Fixed price',
'quantity' => 1,
'unitNetPrice' => 60,
'totalNetAmount' => 60,
'product' => ['productNumber' => 61],
'departmentalDistribution' => [
'distributions' => [
['percentage' => 100, 'department' => ['departmentNumber' => 75]],
],
],
],
],
];
$result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31');
expect($result['customers'])->toHaveCount(1);
expect($result['customers'][0]['meta']['booked_department_75']['booked_groups'][0]['source_category'])->toBe('fixed_pricing');
expect($result['customers'][0]['meta']['booked_department_75']['distributed_net_amount'])->toBe(0.0);
expect($result['customers'][0]['meta']['booked_department_75']['undistributed_net_amount'])->toBe(60.0);
expect($result['collective_results']['undistributed_net_amount'])->toBe(60.0);
expect(implode("\n", $result['warnings']))->toContain('has no redistribution basis');
});
it('keeps unclassified booked department 75 lines undistributed with warnings', function (): void {
$service = new TestableEconomicV2BookedDepartment75DistributionService(new FakeEconomicV2BookedDepartment75VersioningService());
$service->stubBookedInvoices = [[
'bookedInvoiceNumber' => 7003,
'date' => '2026-01-31',
'customer' => [
'customerNumber' => 77777,
],
]];
$service->stubBookedInvoiceLines = [
7003 => [
['lineNumber' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1002 ]'],
[
'lineNumber' => 2,
'description' => 'Unknown booked line',
'quantity' => 1,
'unitNetPrice' => 25,
'totalNetAmount' => 25,
'product' => ['productNumber' => 99],
'departmentalDistribution' => [
'distributions' => [
['percentage' => 100, 'department' => ['departmentNumber' => 75]],
],
],
],
],
];
$result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31');
expect($result['customers'])->toHaveCount(1);
expect($result['customers'][0]['meta']['booked_department_75']['booked_groups'][0]['source_category'])->toBe('unclassified');
expect($result['customers'][0]['meta']['booked_department_75']['distributed_net_amount'])->toBe(0.0);
expect($result['customers'][0]['meta']['booked_department_75']['undistributed_net_amount'])->toBe(25.0);
expect($result['collective_results']['undistributed_net_amount'])->toBe(25.0);
$warning_text = implode("\n", $result['warnings']);
expect($warning_text)->toContain('Unable to classify booked department 75 line');
expect($warning_text)->toContain('could not be classified and remains undistributed');
});
@@ -38,6 +38,7 @@ it('documents v2 historical distribution and pricing history paths in openapi',
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing:');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions:');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices:');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/booked-department-75:');
expect($content)->toContain('/superuser/customers/pricing-history:');
});
@@ -67,4 +68,5 @@ it('defines new reusable v2 schemas for normalization comparison versioning and
expect($content)->toContain('EconomicV2RevenueSummary:');
expect($content)->toContain('PricingHistoryVersionEntry:');
expect($content)->toContain('InvoicingDistributionV2AllResponse:');
expect($content)->toContain('InvoicingDistributionV2BookedDepartment75Response:');
});
@@ -25,6 +25,7 @@ it('registers version-aware distribution and pricing history v2 routes', functio
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/fixed-pricing');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/wash-subscriptions');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/customer-prices');
expect($content)->toContain('/superuser/invoicing/period/distribution/v2/booked-department-75');
expect($content)->toContain('/superuser/customers/pricing-history');
expect($content)->toContain("requirePermission('superuser_invoicing_period_distribution_v2')");
expect($content)->toContain("requirePermission('superuser_customer_pricing_history_v2')");