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:
+129
-1
@@ -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');
|
||||
|
||||
+374
@@ -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')");
|
||||
|
||||
Reference in New Issue
Block a user