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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+146
-9
@@ -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'
|
||||
);
|
||||
});
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user