Introduce centralized date normalization and duplicate filtering for invoicing logic, and add comprehensive unit tests
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace classes;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
class invoicing_period_utils
|
||||
{
|
||||
/**
|
||||
* Normalize date range for invoicing endpoints to full-day timestamps.
|
||||
*
|
||||
* @return array{dateFrom:string,dateTo:string}
|
||||
*/
|
||||
public static function normalizeDateRange(string $dateFrom, string $dateTo): array
|
||||
{
|
||||
if (!self::isValidDate($dateFrom)) {
|
||||
throw new InvalidArgumentException('Invalid date format. Expected: Y-m-d Got: ' . $dateFrom);
|
||||
}
|
||||
if (!self::isValidDate($dateTo)) {
|
||||
throw new InvalidArgumentException('Invalid date format. Expected: Y-m-d Got: ' . $dateTo);
|
||||
}
|
||||
|
||||
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
|
||||
throw new InvalidArgumentException('Invalid date range provided');
|
||||
}
|
||||
|
||||
if (strtotime($dateFrom) > strtotime($dateTo)) {
|
||||
throw new InvalidArgumentException('Invalid date range. dateFrom must be before or equal to dateTo');
|
||||
}
|
||||
|
||||
return [
|
||||
'dateFrom' => date('Y-m-d 00:00:00', strtotime($dateFrom)),
|
||||
'dateTo' => date('Y-m-d 23:59:59', strtotime($dateTo)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter grouped orders down to orders that have at least one neighbor within the time window.
|
||||
*
|
||||
* @param array<string, array<int, array<string,mixed>>> $ordersByRegistration
|
||||
* @return array<string, array<int, array<string,mixed>>>
|
||||
*/
|
||||
public static function filterPossibleDuplicates(array $ordersByRegistration, int $windowSeconds = 86400): array
|
||||
{
|
||||
$possibleDuplicates = [];
|
||||
|
||||
foreach ( $ordersByRegistration as $registration => $orderList ) {
|
||||
if (count($orderList) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedOrders = [];
|
||||
foreach ( $orderList as $order ) {
|
||||
$createdAt = (string)($order['created_at'] ?? '');
|
||||
$timestamp = strtotime($createdAt);
|
||||
if ($timestamp === false) {
|
||||
continue;
|
||||
}
|
||||
$order['_timestamp'] = $timestamp;
|
||||
$normalizedOrders[] = $order;
|
||||
}
|
||||
|
||||
if (count($normalizedOrders) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
usort($normalizedOrders, function (array $a, array $b) {
|
||||
return (int)$a['_timestamp'] <=> (int)$b['_timestamp'];
|
||||
});
|
||||
|
||||
$duplicateIndexes = [];
|
||||
$count = count($normalizedOrders);
|
||||
for ( $i = 0; $i < $count; $i++ ) {
|
||||
$currentTimestamp = (int)$normalizedOrders[$i]['_timestamp'];
|
||||
for ( $j = $i - 1; $j >= 0; $j-- ) {
|
||||
$delta = $currentTimestamp - (int)$normalizedOrders[$j]['_timestamp'];
|
||||
if ($delta > $windowSeconds) {
|
||||
break;
|
||||
}
|
||||
$duplicateIndexes[$i] = true;
|
||||
$duplicateIndexes[$j] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (count($duplicateIndexes) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$possibleDuplicates[$registration] = [];
|
||||
$indexes = array_keys($duplicateIndexes);
|
||||
sort($indexes);
|
||||
foreach ( $indexes as $index ) {
|
||||
$order = $normalizedOrders[$index];
|
||||
unset($order['_timestamp']);
|
||||
$possibleDuplicates[$registration][] = $order;
|
||||
}
|
||||
}
|
||||
|
||||
return $possibleDuplicates;
|
||||
}
|
||||
|
||||
private static function isValidDate(string $date): bool
|
||||
{
|
||||
$parsed = \DateTime::createFromFormat('Y-m-d', $date);
|
||||
return $parsed !== false && $parsed->format('Y-m-d') === $date;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace objects;
|
||||
use attachments\helpers\attachment_content;
|
||||
use classes\db;
|
||||
use classes\email;
|
||||
use classes\invoicing_period_utils;
|
||||
use classes\pdf_generator;
|
||||
use classes\motorapi;
|
||||
use classes\object_property;
|
||||
@@ -619,6 +620,10 @@ class orders_o extends db
|
||||
*/
|
||||
public function getNetAmountForOrders(array $order_ids): array
|
||||
{
|
||||
if (empty($order_ids)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$order_items = new order_items_o();
|
||||
$tmp = $order_items->getFieldsWhere(
|
||||
[
|
||||
@@ -1053,6 +1058,9 @@ class orders_o extends db
|
||||
public function getTransactionsForCustomersInDateRange(array $customers, string $dateFrom, string $dateTo): array
|
||||
{
|
||||
global $db;
|
||||
if (empty($customers)) {
|
||||
return [];
|
||||
}
|
||||
// Validate the date range
|
||||
if (strtotime($dateFrom) === false || strtotime($dateTo) === false) {
|
||||
throw new Exception('Invalid date range provided');
|
||||
@@ -1126,36 +1134,8 @@ class orders_o extends db
|
||||
'object' => (new orders_o())->select((int)$tmp['id'])
|
||||
];
|
||||
}
|
||||
// Filter out orders with more than one entry for the same registration numbers (in a 24 hour period)
|
||||
$possibleDuplicates = [];
|
||||
// Loop through the registration numbers
|
||||
foreach ( $orders as $reg_1 => $orderList ) {
|
||||
// If there are more than one order for the same registration number, add it to the possible duplicates
|
||||
if (count($orderList) > 1) {
|
||||
|
||||
// Loop through the orders and check if they are within 24 hours of each other
|
||||
$filteredOrders = [];
|
||||
foreach ( $orderList as $order ) {
|
||||
// Check if the order is within 24 hours of the previous order (if any)
|
||||
if (empty($filteredOrders)) {
|
||||
$filteredOrders[] = $order; // Add the first order
|
||||
} else {
|
||||
// Check if the order is within 24 hours of the previous order
|
||||
$firstOrderTime = strtotime($filteredOrders[0]['created_at']);
|
||||
$currentOrderTime = strtotime($order['created_at']);
|
||||
if ($currentOrderTime - $firstOrderTime <= 86400) { // 86400 seconds = 24 hours
|
||||
$filteredOrders[] = $order; // Add the order to the filtered list
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there are more than one order in the filtered list, add it to the possible duplicates
|
||||
if (count($filteredOrders) > 1) {
|
||||
$possibleDuplicates[$reg_1] = $filteredOrders;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return the possible duplicates
|
||||
return $possibleDuplicates;
|
||||
return invoicing_period_utils::filterPossibleDuplicates($orders, 86400);
|
||||
}
|
||||
|
||||
public function setTemporaryNetAmount(float $amount): void
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace routes;
|
||||
|
||||
use classes\authentication;
|
||||
use classes\invoicing_period_utils;
|
||||
use classes\slack;
|
||||
use Exception;
|
||||
use objects\customer_vehicles_o;
|
||||
@@ -73,6 +74,33 @@ class InvoicingPeriodRoute
|
||||
return in_array(strtolower((string)$envFlag), ['1', 'true', 'yes'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{dateFrom:string,dateTo:string}
|
||||
*/
|
||||
private function requireAndNormalizeDateRange(): array
|
||||
{
|
||||
global $response;
|
||||
|
||||
self::requireParameters([
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
]);
|
||||
|
||||
$dateFrom = (string)$this->getParameter('dateFrom');
|
||||
$dateTo = (string)$this->getParameter('dateTo');
|
||||
|
||||
try {
|
||||
return invoicing_period_utils::normalizeDateRange($dateFrom, $dateTo);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$response->error($e->getMessage(), 400);
|
||||
}
|
||||
|
||||
return [
|
||||
'dateFrom' => '',
|
||||
'dateTo' => '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param array $collective_results
|
||||
@@ -100,18 +128,9 @@ class InvoicingPeriodRoute
|
||||
$user = (new authentication())->get_user();
|
||||
// Check if the request was successful
|
||||
if ($user) {
|
||||
self::requireParameters([
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
]);
|
||||
$dateFrom = $this->getParameter('dateFrom');
|
||||
$dateTo = $this->getParameter('dateTo');
|
||||
// Require the dateFrom and dateTo parameters to be valid dates
|
||||
self::requireDateFormat($dateFrom, 'Y-m-d');
|
||||
self::requireDateFormat($dateTo, 'Y-m-d');
|
||||
// Add a day to the dateTo parameter to include the end date in the range
|
||||
$dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom));
|
||||
$dateTo = date('Y-m-d 23:59:59', strtotime($dateTo));
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
$dateFrom = $dateRange['dateFrom'];
|
||||
$dateTo = $dateRange['dateTo'];
|
||||
// Add date from and date to to the response meta
|
||||
$response->add_meta('date_from', $dateFrom);
|
||||
$response->add_meta('date_to', $dateTo);
|
||||
@@ -133,19 +152,10 @@ class InvoicingPeriodRoute
|
||||
$this->get('/superuser/invoicing/period/distribution/all', function () {
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
//$this->requirePermission('superuser_invoicing_period');
|
||||
self::requireParameters([
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
]);
|
||||
$dateFrom = $this->getParameter('dateFrom');
|
||||
$dateTo = $this->getParameter('dateTo');
|
||||
// Require the dateFrom and dateTo parameters to be valid dates
|
||||
self::requireDateFormat($dateFrom, 'Y-m-d');
|
||||
self::requireDateFormat($dateTo, 'Y-m-d');
|
||||
// Add a day to the dateTo parameter to include the end date in the range
|
||||
$dateTo = date('Y-m-d 23:59:59', strtotime($dateTo));
|
||||
$dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom));
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
$dateFrom = $dateRange['dateFrom'];
|
||||
$dateTo = $dateRange['dateTo'];
|
||||
// Add date from and date to to the response meta
|
||||
$response->add_meta('date_from', $dateFrom);
|
||||
$response->add_meta('date_to', $dateTo);
|
||||
@@ -165,18 +175,9 @@ class InvoicingPeriodRoute
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
self::requireParameters([
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
]);
|
||||
$dateFrom = $this->getParameter('dateFrom');
|
||||
$dateTo = $this->getParameter('dateTo');
|
||||
// Require the dateFrom and dateTo parameters to be valid dates
|
||||
self::requireDateFormat($dateFrom, 'Y-m-d');
|
||||
self::requireDateFormat($dateTo, 'Y-m-d');
|
||||
// Add a day to the dateTo parameter to include the end date in the range
|
||||
$dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom));
|
||||
$dateTo = date('Y-m-d 23:59:59', strtotime($dateTo));
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
$dateFrom = $dateRange['dateFrom'];
|
||||
$dateTo = $dateRange['dateTo'];
|
||||
// Add date from and date to to the response meta
|
||||
$response->add_meta('date_from', $dateFrom);
|
||||
$response->add_meta('date_to', $dateTo);
|
||||
@@ -191,18 +192,9 @@ class InvoicingPeriodRoute
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
self::requireParameters([
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
]);
|
||||
$dateFrom = $this->getParameter('dateFrom');
|
||||
$dateTo = $this->getParameter('dateTo');
|
||||
// Require the dateFrom and dateTo parameters to be valid dates
|
||||
self::requireDateFormat($dateFrom, 'Y-m-d');
|
||||
self::requireDateFormat($dateTo, 'Y-m-d');
|
||||
// Add a day to the dateTo parameter to include the end date in the range
|
||||
$dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom));
|
||||
$dateTo = date('Y-m-d 23:59:59', strtotime($dateTo));
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
$dateFrom = $dateRange['dateFrom'];
|
||||
$dateTo = $dateRange['dateTo'];
|
||||
// Add date from and date to to the response meta
|
||||
$response->add_meta('date_from', $dateFrom);
|
||||
$response->add_meta('date_to', $dateTo);
|
||||
@@ -217,18 +209,9 @@ class InvoicingPeriodRoute
|
||||
// Require the user to be logged in
|
||||
global $response;
|
||||
$this->requirePermission('superuser_invoicing_period');
|
||||
self::requireParameters([
|
||||
'dateFrom',
|
||||
'dateTo',
|
||||
]);
|
||||
$dateFrom = $this->getParameter('dateFrom');
|
||||
$dateTo = $this->getParameter('dateTo');
|
||||
// Require the dateFrom and dateTo parameters to be valid dates
|
||||
$this->requireDateFormat($dateFrom, 'Y-m-d');
|
||||
$this->requireDateFormat($dateTo, 'Y-m-d');
|
||||
$dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom));
|
||||
// Add a day to the dateTo parameter to include the end date in the range
|
||||
$dateTo = date('Y-m-d 23:59:59', strtotime($dateTo));
|
||||
$dateRange = $this->requireAndNormalizeDateRange();
|
||||
$dateFrom = $dateRange['dateFrom'];
|
||||
$dateTo = $dateRange['dateTo'];
|
||||
// Get all orders in the date range that have:
|
||||
// - Department ID: 10
|
||||
// - Reference: Vaskeabonnementer
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
it('guards against empty order ids in net amount calculation', function (): void {
|
||||
$ordersFile = app_path('objects/orders_o.php');
|
||||
$content = file_get_contents($ordersFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('public function getNetAmountForOrders(array $order_ids): array');
|
||||
expect($content)->toContain('if (empty($order_ids)) {');
|
||||
expect($content)->toContain('return [];');
|
||||
});
|
||||
|
||||
it('guards against empty customer arrays in date-range transaction fetches', function (): void {
|
||||
$ordersFile = app_path('objects/orders_o.php');
|
||||
$content = file_get_contents($ordersFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('public function getTransactionsForCustomersInDateRange(array $customers, string $dateFrom, string $dateTo): array');
|
||||
expect($content)->toContain('if (empty($customers)) {');
|
||||
expect($content)->toContain('return [];');
|
||||
});
|
||||
|
||||
it('uses centralized duplicate filtering for possible duplicate detection', function (): void {
|
||||
$ordersFile = app_path('objects/orders_o.php');
|
||||
$content = file_get_contents($ordersFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toContain('invoicing_period_utils::filterPossibleDuplicates($orders, 86400)');
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
it('requires superuser permission for invoicing period distribution all endpoint', function (): void {
|
||||
$routeFile = app_path('routes/InvoicingPeriodRoute.php');
|
||||
$content = file_get_contents($routeFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect($content)->toMatch(
|
||||
"/\\/superuser\\/invoicing\\/period\\/distribution\\/all'.*?\\\$this->requirePermission\\('superuser_invoicing_period'\\);/s"
|
||||
);
|
||||
});
|
||||
|
||||
it('uses shared date-range normalization across invoicing period endpoints', function (): void {
|
||||
$routeFile = app_path('routes/InvoicingPeriodRoute.php');
|
||||
$content = file_get_contents($routeFile);
|
||||
|
||||
expect($content)->not->toBeFalse();
|
||||
expect(substr_count((string)$content, 'requireAndNormalizeDateRange()'))->toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use classes\invoicing_period_utils;
|
||||
|
||||
app_require('classes/invoicing_period_utils.php');
|
||||
|
||||
it('normalizes a valid invoicing date range to full-day timestamps', function (): void {
|
||||
$range = invoicing_period_utils::normalizeDateRange('2026-03-01', '2026-03-31');
|
||||
|
||||
expect($range)->toBe([
|
||||
'dateFrom' => '2026-03-01 00:00:00',
|
||||
'dateTo' => '2026-03-31 23:59:59',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects invalid date formats', function (): void {
|
||||
invoicing_period_utils::normalizeDateRange('2026/03/01', '2026-03-31');
|
||||
})->throws(InvalidArgumentException::class, 'Invalid date format. Expected: Y-m-d Got: 2026/03/01');
|
||||
|
||||
it('rejects descending date ranges', function (): void {
|
||||
invoicing_period_utils::normalizeDateRange('2026-04-01', '2026-03-31');
|
||||
})->throws(InvalidArgumentException::class, 'Invalid date range. dateFrom must be before or equal to dateTo');
|
||||
|
||||
it('finds duplicate orders regardless of original input order', function (): void {
|
||||
$input = [
|
||||
'ABC12345' => [
|
||||
['id' => 3, 'created_at' => '2026-03-03 02:00:00'],
|
||||
['id' => 1, 'created_at' => '2026-03-01 12:00:00'],
|
||||
['id' => 2, 'created_at' => '2026-03-02 07:00:00'],
|
||||
],
|
||||
'NON_DUP' => [
|
||||
['id' => 10, 'created_at' => '2026-03-01 00:00:00'],
|
||||
['id' => 11, 'created_at' => '2026-03-03 00:00:01'],
|
||||
],
|
||||
];
|
||||
|
||||
$duplicates = invoicing_period_utils::filterPossibleDuplicates($input, 86400);
|
||||
|
||||
expect(array_keys($duplicates))->toBe(['ABC12345']);
|
||||
expect(array_column($duplicates['ABC12345'], 'id'))->toBe([1, 2, 3]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user