Add bulk booked invoice line handling and optimized distribution logic for department 75. Update unit tests, Economic endpoint URL encoding, and service integration.

This commit is contained in:
Jeppe Bundgaard
2026-03-17 14:53:20 +01:00
parent 6aa04eb951
commit bd4a90cdaa
5 changed files with 353 additions and 56 deletions
@@ -761,6 +761,7 @@ class economic_v2_distribution_service
{
$line = $this->toArray($raw_line);
$product_number = $line['product']['productNumber']
?? $line['productNumber']
?? $line['product']['product_number']
?? null;
$quantity = isset($line['quantity']) ? (float)$line['quantity'] : 0.0;
@@ -777,7 +778,7 @@ class economic_v2_distribution_service
'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),
'source_line_id' => (int)($line['lineNumber'] ?? $line['line_number'] ?? $line['number'] ?? $line['userInterfaceNumber'] ?? 0),
'department_distribution' => $this->extractBookedDepartment75Distribution($line),
];
}
@@ -786,31 +787,36 @@ class economic_v2_distribution_service
{
$distribution = [];
$departmental_distribution = $line['departmentalDistribution'] ?? $line['departmental_distribution'] ?? null;
if (!is_array($departmental_distribution)) {
return $distribution;
}
if (is_array($departmental_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;
}
$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);
}
}
$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;
}
}
}
if (empty($distribution)) {
$fallback_number = $departmental_distribution['departmentalDistributionNumber']
?? $departmental_distribution['departmental_distribution_number']
?? null;
if ($fallback_number !== null) {
$distribution[(string)$fallback_number] = 100.0;
$department_number = $line['departmentNumber'] ?? $line['department_number'] ?? null;
if ($department_number !== null) {
$distribution[(string)$department_number] = 100.0;
}
}
@@ -9,6 +9,10 @@ class economic_invoices_booked_endpoint
{
use economic_endpoint_t;
private const Q2C_API_URL = 'https://apis.e-conomic.com/q2capi/v5.1.0';
private const BULK_INVOICE_LINE_BATCH_SIZE = 200;
private const INVOICE_LINE_REQUEST_CONCURRENCY = 50;
/**
* List sent invoices
* @param string $filter Set the filter using the self::filter method
@@ -32,15 +36,148 @@ class economic_invoices_booked_endpoint
*/
public function get_invoice_lines(array $invoice_ids, array $filters = []): array
{
$tmp_invoices = [];
foreach ( $invoice_ids as $invoice ) {
$response = $this->send_request(
'/invoices/booked/' . $invoice . '/?filter=' . self::filters($filters),
'GET'
);
$tmp_invoices[$invoice] = json_decode($response)->lines;
$invoice_ids = array_values(array_unique(array_filter(array_map('intval', $invoice_ids), static fn(int $invoice_id): bool => $invoice_id > 0)));
if (empty($invoice_ids)) {
return [];
}
return $tmp_invoices;
if (!empty($filters)) {
return $this->get_invoice_lines_via_detail_requests($invoice_ids, $filters);
}
return $this->get_invoice_lines_via_bulk_api($invoice_ids);
}
private function get_invoice_lines_via_bulk_api(array $invoice_ids): array
{
$invoice_lines = array_fill_keys($invoice_ids, []);
foreach (array_chunk($invoice_ids, self::BULK_INVOICE_LINE_BATCH_SIZE) as $batch) {
$cursor = null;
do {
$url = '/invoices/booked/lines?filter=documentId$in:[' . implode(',', $batch) . ']';
if ($cursor !== null) {
$url .= '&cursor=' . $cursor;
}
$handle = $this->create_curl_handle_for_base_url(self::Q2C_API_URL, $url, 'GET');
$response = curl_exec($handle);
if (curl_errno($handle)) {
$error = curl_error($handle);
curl_close($handle);
throw new \RuntimeException('Curl error while fetching bulk booked invoice lines: ' . $error);
}
curl_close($handle);
$decoded = json_decode($response);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid bulk booked invoice lines JSON: ' . json_last_error_msg());
}
if (isset($decoded->errorCode) || isset($decoded->message)) {
throw new \RuntimeException((string)($decoded->message ?? 'Failed to fetch bulk booked invoice lines.'));
}
foreach ((array)($decoded->items ?? []) as $line) {
$invoice_id = (int)($line->documentId ?? 0);
if (!isset($invoice_lines[$invoice_id])) {
continue;
}
$invoice_lines[$invoice_id][] = $line;
}
$cursor = isset($decoded->cursor) && $decoded->cursor !== '' ? (string)$decoded->cursor : null;
} while ($cursor !== null);
}
ksort($invoice_lines);
return $invoice_lines;
}
private function get_invoice_lines_via_detail_requests(array $invoice_ids, array $filters = []): array
{
$filter_query = self::filters($filters);
$multi_handle = curl_multi_init();
$invoice_lines = [];
$handles = [];
$invoice_by_handle = [];
$next_index = 0;
try {
if (defined('CURLMOPT_PIPELINING') && defined('CURLPIPE_MULTIPLEX')) {
curl_multi_setopt($multi_handle, CURLMOPT_PIPELINING, CURLPIPE_MULTIPLEX);
}
if (defined('CURLMOPT_MAX_HOST_CONNECTIONS')) {
curl_multi_setopt($multi_handle, CURLMOPT_MAX_HOST_CONNECTIONS, self::INVOICE_LINE_REQUEST_CONCURRENCY);
}
if (defined('CURLMOPT_MAX_TOTAL_CONNECTIONS')) {
curl_multi_setopt($multi_handle, CURLMOPT_MAX_TOTAL_CONNECTIONS, self::INVOICE_LINE_REQUEST_CONCURRENCY);
}
do {
while ($next_index < count($invoice_ids) && count($handles) < self::INVOICE_LINE_REQUEST_CONCURRENCY) {
$invoice_id = $invoice_ids[$next_index++];
$handle = $this->create_curl_handle(
'/invoices/booked/' . $invoice_id . '/?filter=' . $filter_query,
'GET'
);
$handle_id = spl_object_id($handle);
$handles[$handle_id] = $handle;
$invoice_by_handle[$handle_id] = $invoice_id;
curl_multi_add_handle($multi_handle, $handle);
}
do {
$multi_status = curl_multi_exec($multi_handle, $running_handles);
} while ($multi_status === CURLM_CALL_MULTI_PERFORM);
if ($multi_status !== CURLM_OK) {
throw new \RuntimeException('Curl multi error: ' . curl_multi_strerror($multi_status));
}
while ($completed = curl_multi_info_read($multi_handle)) {
$handle = $completed['handle'];
$handle_id = spl_object_id($handle);
$invoice_id = (int)($invoice_by_handle[$handle_id] ?? 0);
if ($completed['result'] !== CURLE_OK) {
throw new \RuntimeException(
'Curl error for booked invoice ' . $invoice_id . ': ' . curl_error($handle)
);
}
$response = curl_multi_getcontent($handle);
$decoded = json_decode($response);
if ($decoded === null && json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException(
'Invalid JSON for booked invoice ' . $invoice_id . ': ' . json_last_error_msg()
);
}
$invoice_lines[$invoice_id] = is_array($decoded->lines ?? null) ? $decoded->lines : [];
curl_multi_remove_handle($multi_handle, $handle);
curl_close($handle);
unset($handles[$handle_id], $invoice_by_handle[$handle_id]);
}
if (($running_handles > 0 || $next_index < count($invoice_ids)) && curl_multi_select($multi_handle, 1.0) === -1) {
usleep(10000);
}
} while ($running_handles > 0 || $next_index < count($invoice_ids));
} finally {
foreach ($handles as $handle) {
curl_multi_remove_handle($multi_handle, $handle);
curl_close($handle);
}
curl_multi_close($multi_handle);
}
ksort($invoice_lines);
return $invoice_lines;
}
/**
@@ -76,4 +213,4 @@ class economic_invoices_booked_endpoint
return json_decode($response);
}
}
}
@@ -0,0 +1,48 @@
<?php
app_require('traits/economic_endpoint_t.php');
use traits\economic_endpoint_t;
if (!class_exists('EconomicEndpointUrlEncodingProbe')) {
class EconomicEndpointUrlEncodingProbe
{
use economic_endpoint_t {
build_request_url as public buildRequestUrl;
}
}
}
beforeEach(function (): void {
global $ECONOMIC_API;
$ECONOMIC_API = [
'app_secret_token' => 'test-secret',
'app_access_grant' => 'test-grant',
'app_access_grant2' => 'test-grant-2',
];
});
it('encodes raw filter query values that include timestamps', function (): void {
$probe = new EconomicEndpointUrlEncodingProbe();
$url = $probe->buildRequestUrl(
'/invoices/booked/?filter=(date$gte:2026-01-01 00:00:00$and:date$lte:2026-01-31 23:59:59)&pageSize=100&skipPages=0'
);
expect($url)->toBe(
'https://restapi.e-conomic.com/invoices/booked/?filter=%28date%24gte%3A2026-01-01%2000%3A00%3A00%24and%3Adate%24lte%3A2026-01-31%2023%3A59%3A59%29&pageSize=100&skipPages=0'
);
});
it('avoids double encoding query values that are already escaped', function (): void {
$probe = new EconomicEndpointUrlEncodingProbe();
$url = $probe->buildRequestUrl(
'/invoices/booked/?filter=references.other%24eq%3AEXT%20123&pageSize=100'
);
expect($url)->toBe(
'https://restapi.e-conomic.com/invoices/booked/?filter=references.other%24eq%3AEXT%20123&pageSize=100'
);
});
@@ -372,3 +372,64 @@ it('keeps unclassified booked department 75 lines undistributed with warnings',
expect($warning_text)->toContain('Unable to classify booked department 75 line');
expect($warning_text)->toContain('could not be classified and remains undistributed');
});
it('supports bulk booked invoice line payloads with top-level department numbers', function (): void {
$versioning = new FakeEconomicV2BookedDepartment75VersioningService();
$versioning->fixedVersion = [
'id' => 92,
'price' => 100.0,
'description' => 'Fixed pricing agreement',
'source' => 'test.fixed.bulk',
'confidence' => 1.0,
'inferred' => false,
'effective_from' => '2026-01-01 00:00:00',
'effective_to' => null,
];
$service = new TestableEconomicV2BookedDepartment75DistributionService($versioning);
$service->stubOrders = [[
'id' => 201,
'customer_id' => 54321,
'department_id' => 3,
'created_at' => '2026-01-10 09:00:00',
'reg_1' => '',
'reference' => 'Internal fixed pricing basis',
]];
$service->stubOrderItems = [
201 => [[
'product_id' => 41,
'price' => 100.0,
'quantity' => 1,
'reference' => '',
]],
];
$service->stubBookedInvoices = [[
'bookedInvoiceNumber' => 7004,
'date' => '2026-01-31',
'customer' => [
'customerNumber' => 54321,
],
]];
$service->stubBookedInvoiceLines = [
7004 => [
['number' => 1, 'description' => '[ 01/01/2026 00:00 Auto #1002 ]'],
['number' => 2, 'description' => '# Fast pris aftale'],
[
'number' => 3,
'description' => 'Fixed price',
'productNumber' => '41',
'quantity' => 1,
'unitNetPrice' => 100,
'totalNetAmount' => 100,
'departmentNumber' => 75,
],
],
];
$result = $service->getBookedDepartment75Distribution('2026-01-01', '2026-01-31');
expect($result['warnings'])->toBe([]);
expect($result['collective_results']['booked_net_amount'])->toBe(100.0);
expect($result['collective_results']['distributed_net_amount'])->toBe(100.0);
expect($result['collective_results']['department_distribution']['3'])->toBe(100.0);
});
@@ -56,25 +56,58 @@ trait economic_endpoint_t
return $pagination_string;
}
/**
* Send a request to the Economic API
* @param string $url
* @param string $method
* @param string $data
* @param bool $authToken2
* @return string
*/
public function send_request($url, $method, $data = '', bool $authToken2 = false): string
protected function build_request_url_with_base_url(string $base_url, string $url): string
{
$path = '/' . ltrim($url, '/');
$query_separator_position = strpos($path, '?');
if ($query_separator_position === false) {
return $base_url . $path;
}
$path_without_query = substr($path, 0, $query_separator_position);
$query_string = substr($path, $query_separator_position + 1);
if ($query_string === '') {
return $base_url . $path_without_query;
}
$normalized_pairs = [];
foreach (explode('&', $query_string) as $pair) {
if ($pair === '') {
continue;
}
$parts = explode('=', $pair, 2);
if (count($parts) === 1) {
$normalized_pairs[] = rawurlencode(rawurldecode($parts[0]));
continue;
}
$normalized_pairs[] = rawurlencode(rawurldecode($parts[0]))
. '='
. rawurlencode(rawurldecode($parts[1]));
}
return $base_url . $path_without_query . '?' . implode('&', $normalized_pairs);
}
protected function build_request_url(string $url): string
{
return $this->build_request_url_with_base_url($this->api_url, $url);
}
protected function create_curl_handle_for_base_url(string $base_url, string $url, string $method, string $data = '', bool $authToken2 = false)
{
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => $this->api_url . $url,
CURLOPT_URL => $this->build_request_url_with_base_url($base_url, $url),
CURLOPT_RETURNTRANSFER => true,
//CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_2TLS,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => array(
'X-AppSecretToken: ' . $this->app_token,
@@ -86,12 +119,34 @@ trait economic_endpoint_t
if ($method === 'POST') {
curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
}
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
return $curl;
}
protected function create_curl_handle(string $url, string $method, string $data = '', bool $authToken2 = false)
{
return $this->create_curl_handle_for_base_url($this->api_url, $url, $method, $data, $authToken2);
}
/**
* Send a request to the Economic API
* @param string $url
* @param string $method
* @param string $data
* @param bool $authToken2
* @return string
*/
public function send_request($url, $method, $data = '', bool $authToken2 = false): string
{
$curl = $this->create_curl_handle($url, $method, $data, $authToken2);
$response = curl_exec($curl);
// Check for errors
if (curl_errno($curl)) {
echo 'Curl error: ' . curl_error($curl);
return curl_error($curl);
$error = curl_error($curl);
curl_close($curl);
throw new \RuntimeException('Curl error: ' . $error);
}
curl_close($curl);
return $response;
@@ -100,26 +155,16 @@ trait economic_endpoint_t
public function send_file_download_request($url, $method, $data = '', bool $authToken2 = false, $outputFile = null)
{
// Initialize cURL session
$curl = curl_init();
curl_setopt_array($curl, array(
CURLOPT_URL => $this->api_url . $url,
CURLOPT_RETURNTRANSFER => true, // Get the response as a string
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => array(
'X-AppSecretToken: ' . $this->app_token,
'X-AgreementGrantToken: ' . ($authToken2 ? $this->appAccessGrant2 : $this->appAccessGrant),
'Content-Type: application/json'
),
));
$curl = $this->create_curl_handle($url, $method, $data, $authToken2);
// Execute the request
$response = curl_exec($curl);
// Handle errors
if (curl_errno($curl)) {
echo 'Curl error: ' . curl_error($curl);
return curl_error($curl);
$error = curl_error($curl);
curl_close($curl);
throw new \RuntimeException('Curl error: ' . $error);
}
// Close the cURL session
@@ -135,4 +180,4 @@ trait economic_endpoint_t
// Otherwise, return the raw content (e.g., for inline use)
return $response;
}
}
}