## Summary
Customer indicator chips (e.g. *Faktura pr. ordre*, *Fastpris*,
*Tankrengøring*) currently only render on the matching view tab because
the period paged response strips customer data from every non-active
view bucket. The front-end therefore cannot determine which other
categories a customer belongs to from the *Alle* tab.
This change projects a deduplicated lightweight customer marker
`{customer_number, membership_only: true}` onto every non-active view
bucket in `applyPeriodPagination`. The active bucket still carries full
customer cards so paginated full-data output, type_counts and
type_totals are unchanged. Filters, search, sort, flag tab and workflow
filters upstream of the membership projection make the non-active
membership set match the active-bucket semantics for the same request.
## Contract change (openapi.yaml)
* New schema `InvoicingPeriodCustomerMembership` with `{
customer_number, membership_only: true }` and `additionalProperties:
false`.
* `InvoicingPeriodData.types[view].items` is now a `oneOf` of
`InvoicingPeriodCustomer` and `InvoicingPeriodCustomerMembership`.
* `InvoicingPeriodCustomer.required` relaxed to `customer_number` only
(other fields are now reported per-view).
## Implementation
* New helper `summarizeNonActiveCustomerMemberships()` projects +
deduplicates by `customer_number`.
* Pagination emits full cards for the active bucket and lightweight
memberships everywhere else.
## Tests
* Updated existing `fixed_pricing` assertion to include the membership
marker.
* Added four new tests:
* default projection across all view buckets (with explicit dedup
assertion),
* search filter propagation,
* flag-tab filter propagation,
* single-customer active-bucket edge case.
All 15 `InvoicingPeriodPaginationTest` tests pass locally (158
assertions).
🤖 Generated by [OpenHands](https://docs.openhands.dev/) on behalf of
copenhagentruckwash.
Co-authored-by: openhands <openhands@all-hands.dev>
4479 lines
196 KiB
PHP
4479 lines
196 KiB
PHP
<?php
|
|
|
|
namespace routes;
|
|
|
|
use classes\authentication;
|
|
use classes\economic;
|
|
use classes\economic_transfer_queue;
|
|
use classes\economic_v2_distribution_service;
|
|
use classes\economic_v2_versioning_service;
|
|
use classes\invoice_collection_bulk_action_conflict;
|
|
use classes\invoice_collection_bulk_action_service;
|
|
use classes\invoice_collection_bulk_action_validation;
|
|
use classes\invoice_collection_schema_bootstrap;
|
|
use classes\invoice_period_flag_service;
|
|
use classes\invoicing_period_utils;
|
|
use classes\slack;
|
|
use Exception;
|
|
use objects\collected_order_invoices_o;
|
|
use objects\customer_vehicles_o;
|
|
use objects\logs_o;
|
|
use objects\order_items_o;
|
|
use objects\orders_o;
|
|
use objects\products_o;
|
|
use objects\users_o;
|
|
use traits\route_t;
|
|
|
|
class InvoicingPeriodRoute
|
|
{
|
|
use route_t;
|
|
|
|
/**
|
|
* Cache department metadata to avoid repeated object loads in large loops.
|
|
* @var array<int, string>
|
|
*/
|
|
private static array $departmentNameCache = [];
|
|
|
|
/**
|
|
* Cache whether a department is excluded from invoicing.
|
|
* @var array<int, bool>
|
|
*/
|
|
private static array $departmentExcludedFromInvoicingCache = [];
|
|
|
|
private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null;
|
|
private static bool $suppressInvoicePeriodExternalEffects = false;
|
|
|
|
/**
|
|
* Local-only booked status caches used by the period response.
|
|
* The period endpoint must not call e-conomic for each order.
|
|
* @var array<int, bool>
|
|
*/
|
|
private static array $periodOrderBookedCache = [];
|
|
private static array $periodInvoiceCollectionBookedCache = [];
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getDepartmentNameCached(int $departmentId): string
|
|
{
|
|
if (!isset(self::$departmentNameCache[$departmentId])) {
|
|
$departmentName = (new \objects\departments_o())->select($departmentId)->name->value();
|
|
self::$departmentNameCache[$departmentId] = !empty($departmentName) ? $departmentName : 'Unknown Department (' . $departmentId . ')';
|
|
}
|
|
return self::$departmentNameCache[$departmentId];
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function isDepartmentExcludedFromInvoicingCached(int $departmentId): bool
|
|
{
|
|
if (!array_key_exists($departmentId, self::$departmentExcludedFromInvoicingCache)) {
|
|
self::$departmentExcludedFromInvoicingCache[$departmentId] = (new \objects\departments_o())
|
|
->select($departmentId)
|
|
->isExcludedFromInvoicing();
|
|
}
|
|
return self::$departmentExcludedFromInvoicingCache[$departmentId];
|
|
}
|
|
|
|
private static function getLocalCustomerName(int $customerNumber): string
|
|
{
|
|
$names = (new users_o())->getCustomerNames([$customerNumber], false);
|
|
return (string)($names[$customerNumber] ?? 'Unknown Customer');
|
|
}
|
|
|
|
private static function getEconomicFallbackDepartmentId(): int
|
|
{
|
|
$department_id = (new economic())->getDefaultDistributionDepartmentId();
|
|
return $department_id > 0 ? $department_id : economic::DEFAULT_DISTRIBUTION_DEPARTMENT_ID;
|
|
}
|
|
|
|
/**
|
|
* Slack summaries are expensive on request latency, so they are opt-in.
|
|
* Enable with query param `sendSlackSummary=1` or env `INVOICING_PERIOD_SEND_SLACK_SUMMARY=true`.
|
|
*/
|
|
private static function shouldSendSlackSummary(): bool
|
|
{
|
|
if (self::$suppressInvoicePeriodExternalEffects) {
|
|
return false;
|
|
}
|
|
$requestOverride = $_GET['sendSlackSummary'] ?? null;
|
|
if ($requestOverride !== null) {
|
|
return in_array(strtolower((string)$requestOverride), ['1', 'true', 'yes'], true);
|
|
}
|
|
|
|
$envFlag = getenv('INVOICING_PERIOD_SEND_SLACK_SUMMARY');
|
|
if ($envFlag === false) {
|
|
return false;
|
|
}
|
|
|
|
return in_array(strtolower((string)$envFlag), ['1', 'true', 'yes'], true);
|
|
}
|
|
|
|
/**
|
|
* Resolve the backend-controlled object-tree rollout. Missing config is deliberately disabled.
|
|
* Module: InvoicingPeriod. Variables: object_tree_v2_enabled and
|
|
* object_tree_v2_superuser_allowlist (JSON array or comma-separated IDs).
|
|
*/
|
|
public static function isInvoicePeriodObjectTreeV2Enabled(int $actorUserId): bool
|
|
{
|
|
global $db;
|
|
if ($actorUserId < 1) {
|
|
return false;
|
|
}
|
|
try {
|
|
$result = $db->query(
|
|
"SELECT variable, value
|
|
FROM module_config
|
|
WHERE module = 'InvoicingPeriod'
|
|
AND variable IN (
|
|
'object_tree_v2_enabled',
|
|
'object_tree_v2_superuser_allowlist',
|
|
'object_tree_v2_allowlisted_user_ids'
|
|
)"
|
|
);
|
|
if ($result) {
|
|
$config = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$config[(string)$row['variable']] = $row['value'];
|
|
}
|
|
if (self::isTruthyObjectTreeConfigValue($config['object_tree_v2_enabled'] ?? null)) {
|
|
return invoice_collection_schema_bootstrap::hasRequiredColumns();
|
|
}
|
|
$allowlist = $config['object_tree_v2_superuser_allowlist']
|
|
?? $config['object_tree_v2_allowlisted_user_ids']
|
|
?? null;
|
|
if (in_array($actorUserId, self::parseObjectTreeIntegerList($allowlist), true)) {
|
|
return invoice_collection_schema_bootstrap::hasRequiredColumns();
|
|
}
|
|
if ($config !== []) {
|
|
return false;
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
// Deployment/test fallback below; the default remains disabled.
|
|
}
|
|
|
|
return self::isTruthyObjectTreeConfigValue(getenv('INVOICING_PERIOD_OBJECT_TREE_V2'))
|
|
&& invoice_collection_schema_bootstrap::hasRequiredColumns();
|
|
}
|
|
|
|
private static function isTruthyObjectTreeConfigValue(mixed $value): bool
|
|
{
|
|
return in_array(strtolower(trim((string)$value)), ['1', 'true', 'yes', 'on'], true);
|
|
}
|
|
|
|
/** @return int[] */
|
|
private static function parseObjectTreeIntegerList(mixed $value): array
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return [];
|
|
}
|
|
$decoded = is_string($value) ? json_decode($value, true) : null;
|
|
$values = is_array($decoded) ? $decoded : explode(',', (string)$value);
|
|
return array_values(array_unique(array_filter(
|
|
array_map('intval', $values),
|
|
static fn(int $id): bool => $id > 0
|
|
)));
|
|
}
|
|
|
|
/** @return int[] */
|
|
private static function setInvoicePeriodObjectTreeV2CanaryUser(int $actorUserId, bool $enabled): array
|
|
{
|
|
global $db;
|
|
if ($actorUserId < 1) {
|
|
throw new \InvalidArgumentException('Invalid canary user');
|
|
}
|
|
|
|
$lockResult = $db->query("SELECT GET_LOCK('invoice_period_object_tree_rollout', 5) AS acquired");
|
|
$lockRow = is_object($lockResult) && method_exists($lockResult, 'fetch_assoc')
|
|
? $lockResult->fetch_assoc()
|
|
: null;
|
|
if (!is_array($lockRow) || (int)($lockRow['acquired'] ?? 0) !== 1) {
|
|
throw new \RuntimeException('Could not lock invoice period rollout configuration');
|
|
}
|
|
|
|
try {
|
|
$result = $db->query(
|
|
"SELECT value
|
|
FROM module_config
|
|
WHERE module = 'InvoicingPeriod'
|
|
AND variable = 'object_tree_v2_superuser_allowlist'
|
|
LIMIT 1"
|
|
);
|
|
if ($result === false || !is_object($result) || !method_exists($result, 'fetch_assoc')) {
|
|
throw new \RuntimeException('Could not read invoice period rollout configuration');
|
|
}
|
|
$row = $result->fetch_assoc();
|
|
$seedValue = is_array($row) ? ($row['value'] ?? null) : null;
|
|
if (!is_array($row)) {
|
|
$legacyResult = $db->query(
|
|
"SELECT value
|
|
FROM module_config
|
|
WHERE module = 'InvoicingPeriod'
|
|
AND variable = 'object_tree_v2_allowlisted_user_ids'
|
|
LIMIT 1"
|
|
);
|
|
if ($legacyResult === false || !is_object($legacyResult) || !method_exists($legacyResult, 'fetch_assoc')) {
|
|
throw new \RuntimeException('Could not read legacy invoice period rollout configuration');
|
|
}
|
|
$legacyRow = $legacyResult->fetch_assoc();
|
|
$seedValue = is_array($legacyRow) ? ($legacyRow['value'] ?? null) : null;
|
|
}
|
|
$allowlist = self::parseObjectTreeIntegerList($seedValue);
|
|
$allowlist = array_values(array_filter(
|
|
$allowlist,
|
|
static fn(int $userId): bool => $userId !== $actorUserId
|
|
));
|
|
if ($enabled) {
|
|
$allowlist[] = $actorUserId;
|
|
}
|
|
$allowlist = array_values(array_unique($allowlist));
|
|
sort($allowlist, SORT_NUMERIC);
|
|
|
|
$value = $db->escape_string((string)json_encode($allowlist, JSON_THROW_ON_ERROR));
|
|
$query = is_array($row)
|
|
? "UPDATE module_config
|
|
SET value = '{$value}', type = 'json'
|
|
WHERE module = 'InvoicingPeriod'
|
|
AND variable = 'object_tree_v2_superuser_allowlist'"
|
|
: "INSERT INTO module_config (module, variable, value, type)
|
|
VALUES ('InvoicingPeriod', 'object_tree_v2_superuser_allowlist', '{$value}', 'json')";
|
|
if ($db->query($query) === false) {
|
|
throw new \RuntimeException('Could not update invoice period rollout configuration');
|
|
}
|
|
|
|
return $allowlist;
|
|
} finally {
|
|
try {
|
|
$db->query("SELECT RELEASE_LOCK('invoice_period_object_tree_rollout')");
|
|
} catch (\Throwable) {
|
|
// The connection also releases advisory locks automatically.
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function applyInvoicePeriodObjectTreeCapability(array $period, bool $enabled): array
|
|
{
|
|
foreach (($period['types'] ?? []) as $type => $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
foreach ($customers as $index => $customer) {
|
|
if (is_array($customer)) {
|
|
$period['types'][$type][$index]['capabilities']['object_tree_v2'] = $enabled;
|
|
}
|
|
}
|
|
}
|
|
$period['capabilities']['object_tree_v2'] = $enabled;
|
|
return $period;
|
|
}
|
|
|
|
/**
|
|
* @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 int[]|null
|
|
*/
|
|
private function getOptionalCustomerNumbersParameter(): ?array
|
|
{
|
|
if (!self::isParametersSet(['customerNumbers'])) {
|
|
return null;
|
|
}
|
|
|
|
return self::normalizeCustomerNumbers(self::getParameter('customerNumbers'));
|
|
}
|
|
|
|
/**
|
|
* @return int[]
|
|
*/
|
|
private static function normalizeCustomerNumbers(mixed $customerNumbers): array
|
|
{
|
|
if ($customerNumbers === null || $customerNumbers === '') {
|
|
return [];
|
|
}
|
|
|
|
$rawValues = is_array($customerNumbers)
|
|
? $customerNumbers
|
|
: explode(',', (string)$customerNumbers);
|
|
|
|
$normalized = [];
|
|
foreach ($rawValues as $value) {
|
|
$parsed = (int)trim((string)$value);
|
|
if ($parsed < 1) {
|
|
continue;
|
|
}
|
|
$normalized[$parsed] = $parsed;
|
|
}
|
|
|
|
return array_values($normalized);
|
|
}
|
|
|
|
/**
|
|
* @param int[]|null $onlyCustomerNumbers
|
|
* @return int[]
|
|
*/
|
|
private static function filterCustomerNumbers(array $customerNumbers, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
$customerNumbers = self::normalizeCustomerNumbers($customerNumbers);
|
|
if ($onlyCustomerNumbers === null) {
|
|
return $customerNumbers;
|
|
}
|
|
|
|
$allowed = array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true);
|
|
if (empty($allowed)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter($customerNumbers, static function (int $customerNumber) use ($allowed): bool {
|
|
return isset($allowed[$customerNumber]);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $customers
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private static function indexCustomersByNumber(array $customers): array
|
|
{
|
|
$customersByNumber = [];
|
|
foreach ($customers as $customer) {
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
if ($customerNumber > 0) {
|
|
$customersByNumber[$customerNumber] = $customer;
|
|
}
|
|
}
|
|
return $customersByNumber;
|
|
}
|
|
|
|
/**
|
|
* Response cache TTL (seconds) for v2 distribution endpoints.
|
|
* Set `INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL` to override.
|
|
*/
|
|
private function getDistributionV2CacheTtl(): int
|
|
{
|
|
$raw = getenv('INVOICING_PERIOD_DISTRIBUTION_V2_CACHE_TTL');
|
|
if ($raw === false || trim((string)$raw) === '') {
|
|
return 300;
|
|
}
|
|
|
|
return max(0, (int)$raw);
|
|
}
|
|
|
|
private function getDistributionV2CacheKey(string $scope, string $dateFrom, string $dateTo): string
|
|
{
|
|
return 'invoicing_period:distribution:v2:' . $scope . ':' . md5($dateFrom . '|' . $dateTo);
|
|
}
|
|
|
|
/**
|
|
* Best-effort Redis cache wrapper for v2 distribution payloads.
|
|
* Falls back to direct computation when Redis is unavailable or TTL is disabled.
|
|
*
|
|
* @param callable():array $resolver
|
|
* @return array
|
|
*/
|
|
private function withCachedDistributionV2(string $scope, string $dateFrom, string $dateTo, callable $resolver): array
|
|
{
|
|
$cacheTtl = $this->getDistributionV2CacheTtl();
|
|
if ($cacheTtl <= 0 || !defined('redis')) {
|
|
return (array)$resolver();
|
|
}
|
|
|
|
$cacheKey = $this->getDistributionV2CacheKey($scope, $dateFrom, $dateTo);
|
|
|
|
try {
|
|
$cached = redis->get($cacheKey);
|
|
if (is_string($cached) && $cached !== '') {
|
|
$decoded = json_decode($cached, true);
|
|
if (is_array($decoded)) {
|
|
return $decoded;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Best-effort cache read.
|
|
}
|
|
|
|
$result = (array)$resolver();
|
|
|
|
try {
|
|
$encoded = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if (is_string($encoded)) {
|
|
redis->setEx($cacheKey, $encoded, $cacheTtl);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
// Best-effort cache write.
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
|
|
/**
|
|
* @param array $collective_results
|
|
* @return array
|
|
* @throws Exception
|
|
*/
|
|
private static function parseTheDepartmentIdsToDepartmentNames(array $collective_results): array
|
|
{
|
|
foreach ( $collective_results['total_department_totals'] as $department_id => $amount ) {
|
|
$collective_results['total_department_totals_parsed'][self::getDepartmentNameCached((int)$department_id)] = $amount;
|
|
}
|
|
foreach ( $collective_results['total_department_totals_relative'] as $department_id => $amount ) {
|
|
$collective_results['total_department_totals_relative_parsed'][self::getDepartmentNameCached((int)$department_id)] = $amount;
|
|
}
|
|
return $collective_results;
|
|
}
|
|
|
|
private static function jsonFragment(mixed $value): string
|
|
{
|
|
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
return is_string($json) ? $json : 'null';
|
|
}
|
|
|
|
private static function streamInvoicingPeriodResponse(array $period): void
|
|
{
|
|
global $response;
|
|
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
http_response_code(200);
|
|
|
|
echo '{"success":true,"data":{';
|
|
echo '"dateFrom":' . self::jsonFragment($period['dateFrom'] ?? null);
|
|
echo ',"dateTo":' . self::jsonFragment($period['dateTo'] ?? null);
|
|
echo ',"types":{';
|
|
|
|
$types = is_array($period['types'] ?? null) ? $period['types'] : [];
|
|
$firstType = true;
|
|
foreach ($types as $typeName => $customers) {
|
|
if (!$firstType) {
|
|
echo ',';
|
|
}
|
|
$firstType = false;
|
|
echo self::jsonFragment((string)$typeName) . ':[';
|
|
|
|
$firstCustomer = true;
|
|
foreach ((array)$customers as $customer) {
|
|
if (!$firstCustomer) {
|
|
echo ',';
|
|
}
|
|
$firstCustomer = false;
|
|
echo self::jsonFragment($customer);
|
|
}
|
|
echo ']';
|
|
}
|
|
|
|
echo '}';
|
|
foreach ($period as $key => $value) {
|
|
if (in_array((string)$key, ['dateFrom', 'dateTo', 'types'], true)) {
|
|
continue;
|
|
}
|
|
echo ',' . self::jsonFragment((string)$key) . ':' . self::jsonFragment($value);
|
|
}
|
|
echo '}';
|
|
echo ',"meta":' . self::jsonFragment($response->get_meta());
|
|
echo ',"includes":' . self::jsonFragment($response->get_includes());
|
|
echo '}';
|
|
exit;
|
|
}
|
|
|
|
private static function periodTypeNames(): array
|
|
{
|
|
return [
|
|
'all',
|
|
'vehicle_subscriptions',
|
|
'fixed_pricing',
|
|
'tank_cleaning',
|
|
'special_arrangements',
|
|
'invoice_per_order',
|
|
'possible_duplicates',
|
|
];
|
|
}
|
|
|
|
private static function getPeriodPaginationOptionsFromRequest(): ?array
|
|
{
|
|
global $response;
|
|
|
|
$paginationKeys = [
|
|
'periodView',
|
|
'page',
|
|
'limit',
|
|
'search',
|
|
'flagTab',
|
|
'includeRequiresAction',
|
|
'includeBooked',
|
|
'reviewState',
|
|
'severity',
|
|
'invoiceState',
|
|
'departmentId',
|
|
'sort',
|
|
'direction',
|
|
];
|
|
|
|
$isPaginatedRequest = false;
|
|
foreach ($paginationKeys as $key) {
|
|
if ($response->isRequestParameterSet($key)) {
|
|
$isPaginatedRequest = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$isPaginatedRequest) {
|
|
return null;
|
|
}
|
|
|
|
$parameters = $response->getAllRequestParameters();
|
|
foreach (['reviewState', 'severity', 'invoiceState', 'departmentId'] as $repeatableKey) {
|
|
$repeatedValues = self::getRepeatedPeriodQueryValues($repeatableKey);
|
|
if ($repeatedValues !== []) {
|
|
$parameters[$repeatableKey] = $repeatedValues;
|
|
}
|
|
}
|
|
|
|
return self::normalizePeriodPaginationOptions($parameters);
|
|
}
|
|
|
|
private static function getRepeatedPeriodQueryValues(string $key): array
|
|
{
|
|
$queryString = (string)($_SERVER['QUERY_STRING'] ?? '');
|
|
if ($queryString === '') {
|
|
return [];
|
|
}
|
|
|
|
$values = [];
|
|
foreach (explode('&', $queryString) as $part) {
|
|
[$rawName, $rawValue] = array_pad(explode('=', $part, 2), 2, '');
|
|
$name = urldecode($rawName);
|
|
if ($name === $key || $name === $key . '[]') {
|
|
$values[] = urldecode(str_replace('+', ' ', $rawValue));
|
|
}
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
private static function normalizePeriodPaginationOptions(array $parameters): array
|
|
{
|
|
$allowedViews = array_fill_keys(self::periodTypeNames(), true);
|
|
$periodView = trim((string)($parameters['periodView'] ?? 'all'));
|
|
if ($periodView === '' || !isset($allowedViews[$periodView])) {
|
|
$periodView = 'all';
|
|
}
|
|
|
|
$page = (int)($parameters['page'] ?? 1);
|
|
if ($page < 1) {
|
|
$page = 1;
|
|
}
|
|
|
|
$limitParameter = strtolower(trim((string)($parameters['limit'] ?? '100')));
|
|
if ($limitParameter === 'all') {
|
|
$limit = 'all';
|
|
} else {
|
|
$limit = (int)$limitParameter;
|
|
if ($limit < 1) {
|
|
$limit = 100;
|
|
}
|
|
$limit = min(500, $limit);
|
|
}
|
|
|
|
$allowedFlagTabs = ['all' => true, 'red' => true, 'yellow' => true, 'none' => true, 'filters' => true];
|
|
$flagTab = trim((string)($parameters['flagTab'] ?? 'all'));
|
|
if ($flagTab === '' || !isset($allowedFlagTabs[$flagTab])) {
|
|
$flagTab = 'all';
|
|
}
|
|
|
|
$allowedSortFields = ['priority', 'customer_name', 'customer_number', 'total_amount'];
|
|
$sort = trim((string)($parameters['sort'] ?? 'customer_name'));
|
|
if (!in_array($sort, $allowedSortFields, true)) {
|
|
$sort = 'customer_name';
|
|
}
|
|
$direction = strtolower(trim((string)($parameters['direction'] ?? 'asc')));
|
|
if (!in_array($direction, ['asc', 'desc'], true)) {
|
|
$direction = 'asc';
|
|
}
|
|
|
|
return [
|
|
'periodView' => $periodView,
|
|
'page' => $page,
|
|
'limit' => $limit,
|
|
'search' => trim((string)($parameters['search'] ?? '')),
|
|
'flagTab' => $flagTab,
|
|
'reviewStates' => self::normalizePeriodFilterValues(
|
|
$parameters['reviewState'] ?? null,
|
|
['blocked', 'attention', 'queued', 'ready', 'completed']
|
|
),
|
|
'severities' => self::normalizePeriodFilterValues(
|
|
$parameters['severity'] ?? null,
|
|
['red', 'yellow', 'blue', 'green']
|
|
),
|
|
'invoiceStates' => self::normalizePeriodFilterValues(
|
|
$parameters['invoiceState'] ?? null,
|
|
['open', 'closed', 'economic_draft', 'economic_booked']
|
|
),
|
|
'departmentIds' => self::normalizePeriodIntegerFilterValues($parameters['departmentId'] ?? null),
|
|
'sort' => $sort,
|
|
'direction' => $direction,
|
|
'includeRequiresAction' => self::parsePeriodBooleanOption(
|
|
$parameters['includeRequiresAction'] ?? null,
|
|
true
|
|
),
|
|
'includeBooked' => self::parsePeriodBooleanOption($parameters['includeBooked'] ?? null, true),
|
|
];
|
|
}
|
|
|
|
private static function normalizePeriodFilterValues(mixed $value, array $allowed): array
|
|
{
|
|
$values = is_array($value) ? $value : [$value];
|
|
$normalized = [];
|
|
foreach ($values as $entry) {
|
|
foreach (explode(',', (string)$entry) as $candidate) {
|
|
$candidate = strtolower(trim($candidate));
|
|
if ($candidate !== '' && in_array($candidate, $allowed, true)) {
|
|
$normalized[$candidate] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_keys($normalized);
|
|
}
|
|
|
|
private static function normalizePeriodIntegerFilterValues(mixed $value): array
|
|
{
|
|
$values = is_array($value) ? $value : [$value];
|
|
$normalized = [];
|
|
foreach ($values as $entry) {
|
|
foreach (explode(',', (string)$entry) as $candidate) {
|
|
$candidate = (int)trim($candidate);
|
|
if ($candidate > 0) {
|
|
$normalized[$candidate] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_map('intval', array_keys($normalized));
|
|
}
|
|
|
|
private static function parsePeriodBooleanOption(mixed $value, bool $default): bool
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return $default;
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$normalized = strtolower(trim((string)$value));
|
|
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
|
return false;
|
|
}
|
|
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
|
return true;
|
|
}
|
|
|
|
return $default;
|
|
}
|
|
|
|
private static function applyPeriodPagination(array $period, array $options): array
|
|
{
|
|
$types = is_array($period['types'] ?? null) ? $period['types'] : [];
|
|
$types = self::ensurePeriodTypeKeys($types);
|
|
$types = self::enrichPeriodCustomerMetaFromTypes($types);
|
|
$types = self::enrichPeriodCustomerReview($types);
|
|
$types = self::filterPeriodTypesBySearch($types, (string)($options['search'] ?? ''));
|
|
$types = self::filterPeriodTypesByVisibility(
|
|
$types,
|
|
(bool)($options['includeRequiresAction'] ?? true),
|
|
(bool)($options['includeBooked'] ?? true)
|
|
);
|
|
|
|
$periodView = (string)($options['periodView'] ?? 'all');
|
|
if (!array_key_exists($periodView, $types)) {
|
|
$periodView = 'all';
|
|
}
|
|
|
|
$typeCounts = self::summarizePeriodTypes($types);
|
|
$facets = self::summarizePeriodReviewFacets($types[$periodView] ?? []);
|
|
|
|
if (!empty($options['flagTab'])) {
|
|
$types = self::filterPeriodTypesByFlagTab($types, (string)$options['flagTab']);
|
|
}
|
|
$types = self::filterPeriodTypesByWorkflow($types, $options);
|
|
$types = self::sortPeriodTypes(
|
|
$types,
|
|
(string)($options['sort'] ?? 'customer_name'),
|
|
(string)($options['direction'] ?? 'asc')
|
|
);
|
|
|
|
$total = count($types[$periodView] ?? []);
|
|
$limit = $options['limit'] ?? 100;
|
|
$isAllLimit = $limit === 'all';
|
|
$perPage = $isAllLimit ? 'all' : max(1, min(500, (int)$limit));
|
|
$totalPages = $isAllLimit || $total === 0 ? 1 : (int)ceil($total / $perPage);
|
|
$page = $isAllLimit ? 1 : max(1, (int)($options['page'] ?? 1));
|
|
$page = min($page, $totalPages);
|
|
|
|
$pagedTypes = array_fill_keys(array_keys($types), []);
|
|
if ($isAllLimit) {
|
|
$pagedTypes[$periodView] = array_values($types[$periodView] ?? []);
|
|
} else {
|
|
$offset = ($page - 1) * $perPage;
|
|
$pagedTypes[$periodView] = array_slice($types[$periodView], $offset, $perPage);
|
|
}
|
|
|
|
// Surface lightweight customer memberships for every non-active
|
|
// view bucket so the front-end can render category indicator
|
|
// chips (e.g. "Faktura pr. ordre") regardless of which tab the
|
|
// user is currently looking at. Filters, search, sort, flag tab
|
|
// and workflow filters have already been applied to `$types`
|
|
// above, so the membership set matches the active bucket's
|
|
// semantics for this request.
|
|
foreach ($types as $typeName => $customers) {
|
|
if ($typeName === $periodView) {
|
|
continue;
|
|
}
|
|
$pagedTypes[$typeName] = self::summarizePeriodCustomerMemberships(
|
|
is_array($customers) ? $customers : []
|
|
);
|
|
}
|
|
|
|
$period['types'] = $pagedTypes;
|
|
$period['type_counts'] = $typeCounts;
|
|
$period['type_totals'] = self::summarizePeriodTypeTotals($types);
|
|
|
|
return [
|
|
'period' => $period,
|
|
'pagination' => [
|
|
'page' => $page,
|
|
'per_page' => $perPage,
|
|
'total' => $total,
|
|
'total_pages' => $totalPages,
|
|
'search' => (string)($options['search'] ?? ''),
|
|
'filters' => [
|
|
'includeRequiresAction' => (bool)($options['includeRequiresAction'] ?? true),
|
|
'includeBooked' => (bool)($options['includeBooked'] ?? true),
|
|
'flagTab' => (string)($options['flagTab'] ?? 'all'),
|
|
'reviewState' => array_values($options['reviewStates'] ?? []),
|
|
'severity' => array_values($options['severities'] ?? []),
|
|
'invoiceState' => array_values($options['invoiceStates'] ?? []),
|
|
'departmentId' => array_values($options['departmentIds'] ?? []),
|
|
],
|
|
'order' => [
|
|
'field' => (string)($options['sort'] ?? 'customer_name'),
|
|
'direction' => (string)($options['direction'] ?? 'asc'),
|
|
],
|
|
'facets' => $facets,
|
|
],
|
|
];
|
|
}
|
|
|
|
private static function enrichPeriodCustomerReview(array $types): array
|
|
{
|
|
foreach ($types as $typeName => $customers) {
|
|
foreach ((array)$customers as $index => $customer) {
|
|
if (is_array($customer)) {
|
|
$types[$typeName][$index]['review'] = self::derivePeriodCustomerReview($customer);
|
|
}
|
|
}
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function derivePeriodCustomerReview(array $customer): array
|
|
{
|
|
$flagCounts = self::getActivePeriodFlagCounts($customer);
|
|
$counts = [
|
|
'active_manual_flags' => $flagCounts['manual'],
|
|
'active_automatic_flags' => $flagCounts['automatic'],
|
|
'collection_errors' => 0,
|
|
'active_queue_jobs' => 0,
|
|
'booked_transactions' => 0,
|
|
'unbooked_transactions' => 0,
|
|
];
|
|
$reasons = [];
|
|
|
|
foreach ((array)($customer['invoice_collections'] ?? []) as $collection) {
|
|
if (is_array($collection) && trim((string)($collection['error_message'] ?? '')) !== '') {
|
|
$counts['collection_errors']++;
|
|
}
|
|
}
|
|
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
|
|
if (!is_array($transaction) || (bool)($transaction['excluded'] ?? false)) {
|
|
continue;
|
|
}
|
|
$key = (bool)($transaction['booked'] ?? false) ? 'booked_transactions' : 'unbooked_transactions';
|
|
$counts[$key]++;
|
|
}
|
|
if ((bool)($customer['queue']['has_active_job'] ?? false)) {
|
|
$counts['active_queue_jobs'] = max(
|
|
1,
|
|
count((array)($customer['queue']['invoice_collection_ids'] ?? []))
|
|
);
|
|
}
|
|
|
|
if ($counts['active_manual_flags'] > 0) {
|
|
$reasons[] = self::periodReviewReason('manual_flags', 'red', $counts['active_manual_flags']);
|
|
}
|
|
if ($counts['collection_errors'] > 0) {
|
|
$reasons[] = self::periodReviewReason('collection_errors', 'red', $counts['collection_errors']);
|
|
}
|
|
if ((bool)($customer['draft']['is_action_blocked'] ?? false)) {
|
|
$reasons[] = self::periodReviewReason('draft_blocks_action', 'red', 1);
|
|
}
|
|
$requiresAttention = (bool)($customer['requires_action'] ?? false)
|
|
&& $counts['unbooked_transactions'] === 0;
|
|
if ($requiresAttention) {
|
|
$reasons[] = self::periodReviewReason('requires_action', 'yellow', 1);
|
|
}
|
|
if ($counts['active_automatic_flags'] > 0) {
|
|
$reasons[] = self::periodReviewReason('automatic_warnings', 'yellow', $counts['active_automatic_flags']);
|
|
}
|
|
if ($counts['active_queue_jobs'] > 0) {
|
|
$reasons[] = self::periodReviewReason('export_in_progress', 'blue', $counts['active_queue_jobs']);
|
|
}
|
|
if ($counts['unbooked_transactions'] > 0) {
|
|
$reasons[] = self::periodReviewReason('unbooked_transactions', 'green', $counts['unbooked_transactions']);
|
|
}
|
|
|
|
if ($counts['active_manual_flags'] > 0) {
|
|
[$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_manual_flags'];
|
|
} elseif ($counts['collection_errors'] > 0) {
|
|
[$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_collection_errors'];
|
|
} elseif ((bool)($customer['draft']['is_action_blocked'] ?? false)) {
|
|
[$state, $severity, $nextAction] = ['blocked', 'red', 'resolve_draft'];
|
|
} elseif ($counts['active_automatic_flags'] > 0 || $requiresAttention) {
|
|
[$state, $severity, $nextAction] = ['attention', 'yellow', 'review_warnings'];
|
|
} elseif ($counts['active_queue_jobs'] > 0) {
|
|
[$state, $severity, $nextAction] = ['queued', 'blue', 'wait_for_export'];
|
|
} elseif ($counts['booked_transactions'] > 0 && $counts['unbooked_transactions'] === 0) {
|
|
[$state, $severity, $nextAction] = ['completed', 'green', 'none'];
|
|
} else {
|
|
[$state, $severity, $nextAction] = ['ready', 'green', 'create_invoice'];
|
|
}
|
|
|
|
return [
|
|
'state' => $state,
|
|
'severity' => $severity,
|
|
'reasons' => $reasons,
|
|
'next_action' => $nextAction,
|
|
'is_actionable' => in_array($state, ['blocked', 'attention', 'ready'], true),
|
|
'counts' => $counts,
|
|
];
|
|
}
|
|
|
|
private static function periodReviewReason(string $code, string $severity, int $count): array
|
|
{
|
|
return ['code' => $code, 'severity' => $severity, 'count' => $count];
|
|
}
|
|
|
|
private static function filterPeriodTypesByFlagTab(array $types, string $flagTab): array
|
|
{
|
|
if (in_array($flagTab, ['all', 'filters', ''], true)) {
|
|
return $types;
|
|
}
|
|
|
|
foreach ($types as $viewName => $entries) {
|
|
$types[$viewName] = array_values(array_filter(
|
|
is_array($entries) ? $entries : [],
|
|
static function (array $customer) use ($flagTab): bool {
|
|
$tab = 'none';
|
|
$flagCounts = self::getActivePeriodFlagCounts($customer);
|
|
if ($flagCounts['manual'] > 0) {
|
|
$tab = 'red';
|
|
} elseif ($flagCounts['automatic'] > 0) {
|
|
$tab = 'yellow';
|
|
}
|
|
|
|
return $tab === $flagTab;
|
|
}
|
|
));
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function filterPeriodTypesByWorkflow(array $types, array $options): array
|
|
{
|
|
$reviewStates = array_fill_keys((array)($options['reviewStates'] ?? []), true);
|
|
$severities = array_fill_keys((array)($options['severities'] ?? []), true);
|
|
$invoiceStates = array_fill_keys((array)($options['invoiceStates'] ?? []), true);
|
|
$departmentIds = array_fill_keys(array_map('intval', (array)($options['departmentIds'] ?? [])), true);
|
|
|
|
if ($reviewStates === [] && $severities === [] && $invoiceStates === [] && $departmentIds === []) {
|
|
return $types;
|
|
}
|
|
|
|
foreach ($types as $typeName => $customers) {
|
|
$types[$typeName] = array_values(array_filter(
|
|
is_array($customers) ? $customers : [],
|
|
static function (array $customer) use (
|
|
$reviewStates,
|
|
$severities,
|
|
$invoiceStates,
|
|
$departmentIds
|
|
): bool {
|
|
if ($reviewStates !== [] && !isset($reviewStates[(string)($customer['review']['state'] ?? '')])) {
|
|
return false;
|
|
}
|
|
if ($severities !== [] && !isset($severities[(string)($customer['review']['severity'] ?? '')])) {
|
|
return false;
|
|
}
|
|
if ($invoiceStates !== [] && !self::periodCustomerMatchesInvoiceStates($customer, $invoiceStates)) {
|
|
return false;
|
|
}
|
|
if ($departmentIds !== [] && !self::periodCustomerMatchesDepartmentIds($customer, $departmentIds)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
));
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function periodCustomerMatchesInvoiceStates(array $customer, array $allowed): bool
|
|
{
|
|
foreach ((array)($customer['invoice_collections'] ?? []) as $collection) {
|
|
if (is_array($collection) && isset($allowed[(string)($collection['state'] ?? '')])) {
|
|
return true;
|
|
}
|
|
}
|
|
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
|
|
if (is_array($transaction) && isset($allowed[(string)($transaction['invoice_state'] ?? '')])) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function periodCustomerMatchesDepartmentIds(array $customer, array $allowed): bool
|
|
{
|
|
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
|
|
if (is_array($transaction) && isset($allowed[(int)($transaction['department_id'] ?? 0)])) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function sortPeriodTypes(array $types, string $field, string $direction): array
|
|
{
|
|
foreach ($types as $typeName => $customers) {
|
|
$customers = is_array($customers) ? array_values($customers) : [];
|
|
usort($customers, static function (array $a, array $b) use ($field, $direction): int {
|
|
$comparison = self::comparePeriodCustomers($a, $b, $field);
|
|
if ($comparison !== 0) {
|
|
return $direction === 'desc' ? -$comparison : $comparison;
|
|
}
|
|
|
|
return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0));
|
|
});
|
|
$types[$typeName] = $customers;
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function comparePeriodCustomers(array $a, array $b, string $field): int
|
|
{
|
|
if ($field === 'priority') {
|
|
$rank = ['blocked' => 0, 'attention' => 1, 'queued' => 2, 'ready' => 3, 'completed' => 4];
|
|
return ($rank[(string)($a['review']['state'] ?? '')] ?? 99)
|
|
<=> ($rank[(string)($b['review']['state'] ?? '')] ?? 99);
|
|
}
|
|
if ($field === 'customer_number') {
|
|
return ((int)($a['customer_number'] ?? 0)) <=> ((int)($b['customer_number'] ?? 0));
|
|
}
|
|
if ($field === 'total_amount') {
|
|
return self::getPeriodCustomerTotalAmount($a) <=> self::getPeriodCustomerTotalAmount($b);
|
|
}
|
|
|
|
return strnatcasecmp((string)($a['customer_name'] ?? ''), (string)($b['customer_name'] ?? ''));
|
|
}
|
|
|
|
private static function summarizePeriodReviewFacets(array $customers): array
|
|
{
|
|
$facets = [
|
|
'review_states' => [],
|
|
'severities' => [],
|
|
'invoice_states' => [],
|
|
'department_ids' => [],
|
|
];
|
|
foreach ($customers as $customer) {
|
|
if (!is_array($customer)) {
|
|
continue;
|
|
}
|
|
self::incrementPeriodFacet($facets['review_states'], (string)($customer['review']['state'] ?? ''));
|
|
self::incrementPeriodFacet($facets['severities'], (string)($customer['review']['severity'] ?? ''));
|
|
|
|
$customerInvoiceStates = [];
|
|
$customerDepartmentIds = [];
|
|
foreach ((array)($customer['invoice_collections'] ?? []) as $collection) {
|
|
if (is_array($collection)) {
|
|
$state = (string)($collection['state'] ?? '');
|
|
if ($state !== '') {
|
|
$customerInvoiceStates[$state] = true;
|
|
}
|
|
}
|
|
}
|
|
foreach ((array)($customer['transactions'] ?? []) as $transaction) {
|
|
if (!is_array($transaction)) {
|
|
continue;
|
|
}
|
|
$state = (string)($transaction['invoice_state'] ?? '');
|
|
if ($state !== '') {
|
|
$customerInvoiceStates[$state] = true;
|
|
}
|
|
$departmentId = (int)($transaction['department_id'] ?? 0);
|
|
if ($departmentId > 0) {
|
|
$customerDepartmentIds[$departmentId] = true;
|
|
}
|
|
}
|
|
foreach (array_keys($customerInvoiceStates) as $state) {
|
|
self::incrementPeriodFacet($facets['invoice_states'], (string)$state);
|
|
}
|
|
foreach (array_keys($customerDepartmentIds) as $departmentId) {
|
|
self::incrementPeriodFacet($facets['department_ids'], (string)$departmentId);
|
|
}
|
|
}
|
|
|
|
foreach ($facets as $name => $counts) {
|
|
ksort($counts, SORT_NATURAL);
|
|
$facets[$name] = array_map(
|
|
static fn(string|int $value, int $count): array => ['value' => (string)$value, 'count' => $count],
|
|
array_keys($counts),
|
|
array_values($counts)
|
|
);
|
|
}
|
|
|
|
return $facets;
|
|
}
|
|
|
|
private static function incrementPeriodFacet(array &$counts, string $value): void
|
|
{
|
|
if ($value !== '') {
|
|
$counts[$value] = ($counts[$value] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
private static function ensurePeriodTypeKeys(array $types): array
|
|
{
|
|
foreach (self::periodTypeNames() as $typeName) {
|
|
if (!array_key_exists($typeName, $types) || !is_array($types[$typeName])) {
|
|
$types[$typeName] = [];
|
|
}
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function enrichPeriodCustomerMetaFromTypes(array $types): array
|
|
{
|
|
$metaByCustomerNumber = [];
|
|
foreach (['fixed_pricing', 'vehicle_subscriptions'] as $typeName) {
|
|
foreach (($types[$typeName] ?? []) as $customer) {
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
if ($customerNumber < 1) {
|
|
continue;
|
|
}
|
|
$meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : [];
|
|
if ($meta === []) {
|
|
continue;
|
|
}
|
|
$metaByCustomerNumber[$customerNumber] = array_merge(
|
|
$metaByCustomerNumber[$customerNumber] ?? [],
|
|
$meta
|
|
);
|
|
}
|
|
}
|
|
|
|
if ($metaByCustomerNumber === []) {
|
|
return $types;
|
|
}
|
|
|
|
foreach ($types as $typeName => $customers) {
|
|
foreach ($customers as $index => $customer) {
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
if ($customerNumber < 1 || !isset($metaByCustomerNumber[$customerNumber])) {
|
|
continue;
|
|
}
|
|
|
|
$types[$typeName][$index]['meta'] = array_merge(
|
|
is_array($customer['meta'] ?? null) ? $customer['meta'] : [],
|
|
$metaByCustomerNumber[$customerNumber]
|
|
);
|
|
}
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function filterPeriodTypesBySearch(array $types, string $search): array
|
|
{
|
|
$search = self::normalizePeriodSearchTerm($search);
|
|
if ($search === '') {
|
|
return $types;
|
|
}
|
|
|
|
foreach ($types as $typeName => $customers) {
|
|
$types[$typeName] = array_values(array_filter(
|
|
is_array($customers) ? $customers : [],
|
|
static fn(array $customer): bool => self::periodCustomerMatchesSearch($customer, $search)
|
|
));
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function filterPeriodTypesByVisibility(
|
|
array $types,
|
|
bool $includeRequiresAction,
|
|
bool $includeBooked
|
|
): array {
|
|
foreach ($types as $typeName => $customers) {
|
|
$types[$typeName] = array_values(array_filter(
|
|
is_array($customers) ? $customers : [],
|
|
static function (array $customer) use ($includeRequiresAction, $includeBooked): bool {
|
|
if (!$includeRequiresAction && (bool)($customer['requires_action'] ?? false)) {
|
|
return false;
|
|
}
|
|
|
|
if (
|
|
!$includeBooked
|
|
&& !((bool)($customer['requires_action'] ?? false))
|
|
&& self::areAllPeriodCustomerTransactionsBooked($customer)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
));
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function periodCustomerMatchesSearch(array $customer, string $search): bool
|
|
{
|
|
$values = [
|
|
$customer['customer_number'] ?? '',
|
|
$customer['customer_name'] ?? '',
|
|
];
|
|
|
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
|
if (!is_array($transaction)) {
|
|
continue;
|
|
}
|
|
foreach (['id', 'reference', 'po', 'notes', 'reg_1', 'reg_2', 'reg_3'] as $field) {
|
|
$values[] = $transaction[$field] ?? '';
|
|
}
|
|
}
|
|
|
|
foreach (['invoice_collections', 'flags', 'review', 'queue', 'draft', 'meta', 'flag_counts'] as $field) {
|
|
self::appendPeriodSearchValues($values, $customer[$field] ?? null);
|
|
}
|
|
|
|
foreach ($values as $value) {
|
|
if (str_contains(self::normalizePeriodSearchTerm((string)$value), $search)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function appendPeriodSearchValues(array &$values, mixed $value): void
|
|
{
|
|
if (is_array($value)) {
|
|
foreach ($value as $entry) {
|
|
self::appendPeriodSearchValues($values, $entry);
|
|
}
|
|
return;
|
|
}
|
|
if (is_scalar($value)) {
|
|
$values[] = $value;
|
|
}
|
|
}
|
|
|
|
private static function normalizePeriodSearchTerm(string $value): string
|
|
{
|
|
return mb_strtolower(trim($value), 'UTF-8');
|
|
}
|
|
|
|
private static function areAllPeriodCustomerTransactionsBooked(array $customer): bool
|
|
{
|
|
$transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : [];
|
|
foreach ($transactions as $transaction) {
|
|
if (!is_array($transaction) || (bool)($transaction['booked'] ?? false) !== true) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static function summarizePeriodTypes(array $types): array
|
|
{
|
|
$counts = [];
|
|
foreach ($types as $typeName => $customers) {
|
|
$counts[$typeName] = self::summarizePeriodType(is_array($customers) ? $customers : []);
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* Build a deduplicated list of lightweight `{customer_number}` markers
|
|
* for a single non-active view bucket. These entries let the front-end
|
|
* know which customers belong to a category without shipping the full
|
|
* card (transactions, invoice_collections, queue, draft, meta, …).
|
|
*
|
|
* Filters, search, sort, flag tab and workflow filters are expected to
|
|
* have been applied to `$customers` upstream — we only de-duplicate and
|
|
* project the `customer_number` field here.
|
|
*
|
|
* @param array<int, array<string, mixed>> $customers
|
|
* @return array<int, array{customer_number: int, membership_only: true}>
|
|
*/
|
|
private static function summarizePeriodCustomerMemberships(array $customers): array
|
|
{
|
|
$memberships = [];
|
|
$seen = [];
|
|
foreach ($customers as $customer) {
|
|
if (!is_array($customer)) {
|
|
continue;
|
|
}
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
if ($customerNumber < 1 || isset($seen[$customerNumber])) {
|
|
continue;
|
|
}
|
|
$seen[$customerNumber] = true;
|
|
$memberships[] = [
|
|
'customer_number' => $customerNumber,
|
|
'membership_only' => true,
|
|
];
|
|
}
|
|
|
|
return $memberships;
|
|
}
|
|
|
|
private static function summarizePeriodTypeTotals(array $types): array
|
|
{
|
|
$totals = [];
|
|
foreach ($types as $typeName => $customers) {
|
|
$totals[$typeName] = self::summarizePeriodTypeTotalsForCustomers(
|
|
is_array($customers) ? $customers : []
|
|
);
|
|
}
|
|
|
|
return $totals;
|
|
}
|
|
|
|
private static function summarizePeriodTypeTotalsForCustomers(array $customers): array
|
|
{
|
|
$total = 0.0;
|
|
$booked = 0.0;
|
|
|
|
foreach ($customers as $customer) {
|
|
if (!is_array($customer)) {
|
|
continue;
|
|
}
|
|
|
|
$total += self::getPeriodCustomerTotalAmount($customer);
|
|
$booked += self::sumPeriodCustomerTransactions($customer, true);
|
|
}
|
|
|
|
return [
|
|
'total' => $total,
|
|
'booked' => $booked,
|
|
'not_booked' => $total - $booked,
|
|
];
|
|
}
|
|
|
|
private static function getPeriodCustomerTotalAmount(array $customer): float
|
|
{
|
|
$fixedPrice = $customer['meta']['fixed_pricing']['price'] ?? null;
|
|
if ($fixedPrice !== null && $fixedPrice !== '') {
|
|
return (float)$fixedPrice;
|
|
}
|
|
|
|
return self::sumPeriodCustomerTransactions($customer, false);
|
|
}
|
|
|
|
private static function sumPeriodCustomerTransactions(array $customer, bool $bookedOnly): float
|
|
{
|
|
$total = 0.0;
|
|
$transactions = is_array($customer['transactions'] ?? null) ? $customer['transactions'] : [];
|
|
foreach ($transactions as $transaction) {
|
|
if (!is_array($transaction)) {
|
|
continue;
|
|
}
|
|
if ((bool)($transaction['excluded'] ?? false)) {
|
|
continue;
|
|
}
|
|
if ($bookedOnly && (bool)($transaction['booked'] ?? false) !== true) {
|
|
continue;
|
|
}
|
|
|
|
$total += (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0);
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
private static function summarizePeriodType(array $customers): array
|
|
{
|
|
$requiresAction = 0;
|
|
$draft = 0;
|
|
$manualFlags = 0;
|
|
$automaticFlags = 0;
|
|
|
|
foreach ($customers as $customer) {
|
|
if ((bool)($customer['requires_action'] ?? false)) {
|
|
$requiresAction++;
|
|
}
|
|
if (($customer['draft']['is_action_blocked'] ?? false) === true) {
|
|
$draft++;
|
|
}
|
|
|
|
$flagCounts = self::getActivePeriodFlagCounts($customer);
|
|
if ($flagCounts['manual'] > 0) {
|
|
$manualFlags++;
|
|
}
|
|
if ($flagCounts['automatic'] > 0) {
|
|
$automaticFlags++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'requires_action' => $requiresAction,
|
|
'draft' => $draft,
|
|
'manual_flags' => $manualFlags,
|
|
'automatic_flags' => $automaticFlags,
|
|
'completed' => max(0, count($customers) - $requiresAction - $draft),
|
|
'total' => count($customers),
|
|
];
|
|
}
|
|
|
|
private static function getActivePeriodFlagCounts(array $customer): array
|
|
{
|
|
$manual = 0;
|
|
$automatic = 0;
|
|
if (is_array($customer['flags'] ?? null)) {
|
|
foreach ($customer['flags'] as $flag) {
|
|
if (!is_array($flag) || (string)($flag['status'] ?? 'active') !== 'active') {
|
|
continue;
|
|
}
|
|
if (($flag['source'] ?? null) === 'manual') {
|
|
$manual++;
|
|
} elseif (($flag['source'] ?? null) === 'automatic') {
|
|
$automatic++;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'manual' => $manual,
|
|
'automatic' => $automatic,
|
|
'total' => $manual + $automatic,
|
|
];
|
|
}
|
|
|
|
return [
|
|
'manual' => (int)($customer['flag_counts']['manual'] ?? 0),
|
|
'automatic' => (int)($customer['flag_counts']['automatic'] ?? 0),
|
|
'total' => (int)($customer['flag_counts']['total'] ?? 0),
|
|
];
|
|
}
|
|
|
|
public function run(): void
|
|
{
|
|
$this->post('/superuser/invoicing/period/object-tree/canary', function () {
|
|
global $response;
|
|
$this->requirePermission('superuser');
|
|
self::requireParameters(['enabled']);
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
$enabled = filter_var(
|
|
$this->getParameter('enabled'),
|
|
FILTER_VALIDATE_BOOLEAN,
|
|
FILTER_NULL_ON_FAILURE
|
|
);
|
|
if ($enabled === null) {
|
|
$response->error('Parameter enabled must be a boolean', 400);
|
|
}
|
|
$allowlist = self::setInvoicePeriodObjectTreeV2CanaryUser((int)$user->id, $enabled);
|
|
$effectiveEnabled = self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id);
|
|
if ($enabled && !$effectiveEnabled) {
|
|
self::setInvoicePeriodObjectTreeV2CanaryUser((int)$user->id, false);
|
|
(new logs_o())->add(
|
|
'invoicing_period',
|
|
'global',
|
|
1,
|
|
(int)$user->id,
|
|
'OBJECT_TREE_V2_CANARY_ENABLE_FAILED',
|
|
'Invoice period object-tree canary failed schema readiness verification and was rolled back'
|
|
);
|
|
$response->error('Invoice period object-tree schema is not ready', 503);
|
|
}
|
|
(new logs_o())->add(
|
|
'invoicing_period',
|
|
'global',
|
|
1,
|
|
(int)$user->id,
|
|
$enabled ? 'OBJECT_TREE_V2_CANARY_ENABLED' : 'OBJECT_TREE_V2_CANARY_DISABLED',
|
|
$enabled
|
|
? 'Enabled invoice period object-tree canary for current superuser'
|
|
: 'Disabled invoice period object-tree canary for current superuser'
|
|
);
|
|
$response->success([
|
|
'configured_enabled' => $enabled,
|
|
'effective_enabled' => $effectiveEnabled,
|
|
'user_id' => (int)$user->id,
|
|
'allowlisted_user_ids' => $allowlist,
|
|
]);
|
|
}, [
|
|
'superuser' => 'Enable or disable the invoice period object-tree canary for the current superuser',
|
|
]);
|
|
|
|
$this->get('/superuser/invoicing/period', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
$this->requirePermission('superuser_invoicing_period');
|
|
// Get the user object
|
|
$user = (new authentication())->get_user();
|
|
// Check if the request was successful
|
|
if ($user) {
|
|
$dateRange = $this->requireAndNormalizeDateRange();
|
|
$dateFrom = $dateRange['dateFrom'];
|
|
$dateTo = $dateRange['dateTo'];
|
|
$customerNumbers = $this->getOptionalCustomerNumbersParameter();
|
|
// Add date from and date to to the response meta
|
|
$response->add_meta('date_from', $dateFrom);
|
|
$response->add_meta('date_to', $dateTo);
|
|
if ($customerNumbers !== null) {
|
|
$response->add_meta('customer_numbers', $customerNumbers);
|
|
}
|
|
$paginationOptions = self::getPeriodPaginationOptionsFromRequest();
|
|
$includeInvoicePeriodFlags = $this->hasPermission('list_invoice_period_flags');
|
|
$objectTreeV2Enabled = self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id);
|
|
// Get the invoicing period for the user
|
|
$period = self::getInvoicingPeriod($dateFrom, $dateTo, $customerNumbers, $includeInvoicePeriodFlags);
|
|
$period = self::applyInvoicePeriodObjectTreeCapability($period, $objectTreeV2Enabled);
|
|
$response->add_meta('invoice_period_object_tree_v2', $objectTreeV2Enabled);
|
|
if ($paginationOptions !== null) {
|
|
$paginated = self::applyPeriodPagination($period, $paginationOptions);
|
|
$period = $paginated['period'];
|
|
$response->add_meta('pagination', $paginated['pagination']);
|
|
}
|
|
self::streamInvoicingPeriodResponse($period);
|
|
} else {
|
|
// Log the incident
|
|
(new logs_o())->add('vehicles', 'global', 1, 0, 'LIST_OWN_VEHICLES', 'No user found, or invalid session');
|
|
// Return an error
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
},
|
|
[
|
|
'superuser_invoicing_period' => 'Get the invoicing period for superusers',
|
|
'list_invoice_period_flags' => 'List invoice period flags in the period response',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/tree', function () {
|
|
global $response;
|
|
$this->requirePermission('superuser_invoicing_period');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
if (!self::isInvoicePeriodObjectTreeV2Enabled((int)$user->id)) {
|
|
$response->error('Invoice-period object tree is not enabled.', 403);
|
|
}
|
|
self::requireParameters(['customerNumber', 'dateFrom', 'dateTo']);
|
|
$customerNumber = (int)$this->getParameter('customerNumber');
|
|
if ($customerNumber < 1) {
|
|
$response->error('customerNumber must be a positive integer', 400);
|
|
}
|
|
$dateRange = $this->requireAndNormalizeDateRange();
|
|
|
|
try {
|
|
$tree = $this->buildInvoicePeriodTreeSnapshot(
|
|
$customerNumber,
|
|
$dateRange['dateFrom'],
|
|
$dateRange['dateTo'],
|
|
(int)$user->id
|
|
);
|
|
$response->success($tree);
|
|
} catch (invoice_collection_bulk_action_validation $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
} catch (invoice_collection_bulk_action_conflict $e) {
|
|
$response->error($e->getMessage(), 409);
|
|
} catch (\Throwable $e) {
|
|
$response->error($e->getMessage(), 500);
|
|
}
|
|
},
|
|
[
|
|
'superuser_invoicing_period' => 'Get one selected customer invoice-period object tree snapshot.',
|
|
'list_invoice_period_flags' => 'Include invoice period flags when permitted.',
|
|
]
|
|
);
|
|
|
|
$this->post('/superuser/invoicing/period/flags', function () {
|
|
global $response;
|
|
$this->requirePermission('add_invoice_period_flag');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
try {
|
|
$flag = (new invoice_period_flag_service())->createManualFlag(
|
|
$this->getParametersAsArray(),
|
|
(int)$user->id
|
|
);
|
|
$response->success($flag);
|
|
} catch (\InvalidArgumentException $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
} catch (\Throwable $e) {
|
|
$response->error($e->getMessage(), 500);
|
|
}
|
|
},
|
|
[
|
|
'add_invoice_period_flag' => 'Add a manual invoice period flag',
|
|
]
|
|
);
|
|
|
|
$this->patch('/superuser/invoicing/period/flags/{id}/status', function () {
|
|
global $response;
|
|
$this->requirePermission('update_invoice_period_flag_status');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
$id = (int)($this->fromRoute('id') ?? 0);
|
|
try {
|
|
$flag = (new invoice_period_flag_service())->updateManualFlagStatus(
|
|
$id,
|
|
(string)$this->getParameter('status'),
|
|
$this->isParametersSet(['reason']) ? (string)$this->getParameter('reason') : null,
|
|
(int)$user->id
|
|
);
|
|
$response->success($flag);
|
|
} catch (\InvalidArgumentException $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
} catch (\Throwable $e) {
|
|
$response->error($e->getMessage(), 500);
|
|
}
|
|
},
|
|
[
|
|
'update_invoice_period_flag_status' => 'Update a manual invoice period flag status',
|
|
]
|
|
);
|
|
|
|
$this->post('/superuser/invoicing/period/flags/automatic/status', function () {
|
|
global $response;
|
|
$this->requirePermission('update_invoice_period_flag_status');
|
|
$user = (new authentication())->get_user();
|
|
if (!$user) {
|
|
$response->error('Invalid session', 400);
|
|
}
|
|
|
|
try {
|
|
$flag = (new invoice_period_flag_service())->updateAutomaticFlagStatus(
|
|
$this->getParametersAsArray(),
|
|
(int)$user->id
|
|
);
|
|
$response->success($flag);
|
|
} catch (\InvalidArgumentException $e) {
|
|
$response->error($e->getMessage(), 400);
|
|
} catch (\Throwable $e) {
|
|
$response->error($e->getMessage(), 500);
|
|
}
|
|
},
|
|
[
|
|
'update_invoice_period_flag_status' => 'Update an automatic invoice period flag status',
|
|
]
|
|
);
|
|
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/all', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
$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);
|
|
$result = [
|
|
'subscriptions' => self::getTransactionsWithItemsNotIncludedInInvoices(self::getVehicleSubscriptions($dateFrom, $dateTo)),
|
|
'fixed_pricing' => self::getOriginalPrice(self::getFixedPricing($dateFrom, $dateTo)),
|
|
|
|
];
|
|
$response->success($result);
|
|
},
|
|
[
|
|
'superuser_invoicing_period' => 'Get the invoicing period for superusers',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/fixed-pricing', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
$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);
|
|
$response->success([...self::getOriginalPrice(self::getFixedPricing($dateFrom, $dateTo))]);
|
|
},
|
|
[
|
|
'superuser_invoicing_period' => 'Get the invoicing period for superusers',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
$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);
|
|
$response->success([...self::getTransactionsWithItemsNotIncludedInInvoices(self::getVehicleSubscriptions($dateFrom, $dateTo))]);
|
|
},
|
|
[
|
|
'superuser_invoicing_period' => 'Get the invoicing period for superusers',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/v2/all', 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('all', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) {
|
|
return (new economic_v2_distribution_service())->getAllDistributions($dateFrom, $dateTo);
|
|
}));
|
|
},
|
|
[
|
|
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware departmental distribution (all categories).',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/v2/fixed-pricing', 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('fixed-pricing', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) {
|
|
return (new economic_v2_distribution_service())->getFixedPricingDistribution($dateFrom, $dateTo);
|
|
}));
|
|
},
|
|
[
|
|
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware fixed pricing distribution.',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/v2/wash-subscriptions', 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('wash-subscriptions', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) {
|
|
return (new economic_v2_distribution_service())->getWashSubscriptionsDistribution($dateFrom, $dateTo);
|
|
}));
|
|
},
|
|
[
|
|
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware wash subscription distribution.',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/v2/customer-prices', 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('customer-prices', $dateFrom, $dateTo, static function () use ($dateFrom, $dateTo) {
|
|
return (new economic_v2_distribution_service())->getCustomerPricesDistribution($dateFrom, $dateTo);
|
|
}));
|
|
},
|
|
[
|
|
'superuser_invoicing_period_distribution_v2' => 'Get historical version-aware customer discount distribution.',
|
|
]
|
|
);
|
|
|
|
$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');
|
|
self::requireParameters(['customer_number']);
|
|
self::requireType((int)self::getParameter('customer_number'), self::type_int());
|
|
$customer_number = (int)self::getParameter('customer_number');
|
|
self::requireMinValue($customer_number, 1);
|
|
self::requireMaxValue($customer_number, 999999999);
|
|
|
|
$dateRange = $this->requireAndNormalizeDateRange();
|
|
$dateFrom = $dateRange['dateFrom'];
|
|
$dateTo = $dateRange['dateTo'];
|
|
|
|
$versioning = new economic_v2_versioning_service();
|
|
$fixed_pricing = $versioning->listFixedPricingVersions($customer_number, $dateFrom, $dateTo);
|
|
$vehicle_subscriptions = $versioning->listVehicleSubscriptionVersions($customer_number, $dateFrom, $dateTo);
|
|
$discount_overrides = $versioning->listDiscountOverrideVersions($customer_number, $dateFrom, $dateTo);
|
|
|
|
$timeline = [];
|
|
foreach ($fixed_pricing as $row) {
|
|
$timeline[] = [
|
|
'type' => 'fixed_pricing',
|
|
...$row,
|
|
];
|
|
}
|
|
foreach ($vehicle_subscriptions as $row) {
|
|
$timeline[] = [
|
|
'type' => 'vehicle_subscription',
|
|
...$row,
|
|
];
|
|
}
|
|
foreach ($discount_overrides as $row) {
|
|
$timeline[] = [
|
|
'type' => 'discount_override',
|
|
...$row,
|
|
];
|
|
}
|
|
usort($timeline, static function ($a, $b) {
|
|
$left = strtotime((string)($a['effective_from'] ?? '1970-01-01 00:00:00'));
|
|
$right = strtotime((string)($b['effective_from'] ?? '1970-01-01 00:00:00'));
|
|
if ($left === $right) {
|
|
return ((int)($a['id'] ?? 0)) <=> ((int)($b['id'] ?? 0));
|
|
}
|
|
return $left <=> $right;
|
|
});
|
|
|
|
$response->add_meta('date_from', $dateFrom);
|
|
$response->add_meta('date_to', $dateTo);
|
|
$response->success([
|
|
'customer_number' => $customer_number,
|
|
'fixed_pricing' => $fixed_pricing,
|
|
'vehicle_subscriptions' => $vehicle_subscriptions,
|
|
'discount_overrides' => $discount_overrides,
|
|
'timeline' => $timeline,
|
|
]);
|
|
},
|
|
[
|
|
'superuser_customer_pricing_history_v2' => 'Get customer pricing/subscription/discount timeline with confidence and provenance.',
|
|
]
|
|
);
|
|
|
|
$this->get('/superuser/invoicing/period/distribution/wash-subscriptions/historical', function () {
|
|
// Require the user to be logged in
|
|
global $response;
|
|
$this->requirePermission('superuser_invoicing_period');
|
|
$dateRange = $this->requireAndNormalizeDateRange();
|
|
$dateFrom = $dateRange['dateFrom'];
|
|
$dateTo = $dateRange['dateTo'];
|
|
// Get all orders in the date range that have:
|
|
// - Department ID: 10
|
|
// - Reference: Vaskeabonnementer
|
|
$orders_o = new orders_o();
|
|
$orders = $orders_o->getFieldsWhere([
|
|
'department_id' => 10,
|
|
'reference' => 'Vaskeabonnementer',
|
|
], ['id', 'created_at']);
|
|
// Filter out the orders to only include those in the date range
|
|
$orders = array_filter($orders, function ($order) use ($dateFrom, $dateTo) {
|
|
$order_date = strtotime($order['created_at']);
|
|
return $order_date >= strtotime($dateFrom) && $order_date <= strtotime($dateTo);
|
|
});
|
|
// Construct the order objects
|
|
$orders = array_map(function ($order) {
|
|
return (new orders_o())->select((int)$order['id']);
|
|
}, $orders);
|
|
// Filter out deleted orders
|
|
$orders = array_filter($orders, function ($order) {
|
|
return $order->deleted_at->value() === null;
|
|
});
|
|
// Define the warnings variable
|
|
$warnings = [];
|
|
// Define logs variable
|
|
$logs = [];
|
|
// Define the customer variable
|
|
$unique_customers = [];
|
|
foreach ( $orders as $order ) {
|
|
$customer_id = (int)$order->customer_id->value();
|
|
if (!in_array($customer_id, $unique_customers)) {
|
|
$unique_customers[] = $customer_id;
|
|
} else {
|
|
$warnings[] = "Duplicate order for customer ID " . $customer_id . " in order ID " . $order->id;
|
|
}
|
|
}
|
|
// Define the total subscription amount
|
|
$total_subscription_amount = 0;
|
|
// Define the collection of order items for all subscriptions
|
|
$all_subscription_order_items = [];
|
|
// Add up the total subscription amount and collect the order items
|
|
/** @var orders_o $order */
|
|
foreach ( $orders as $order ) {
|
|
$customer_id = (int)$order->customer_id->value();
|
|
$logs[] = "[{$order->id}] Processing order items for customer ID {$customer_id}";
|
|
// Get the order items for the order
|
|
$tmp_items = $order->getOrderItemObjects();
|
|
/** @var order_items_o $item */
|
|
foreach ( $tmp_items as $item ) {
|
|
$price = (int)$item->price->value();
|
|
$amount = (int)$item->quantity->value();
|
|
$total_subscription_amount += $price * $amount;
|
|
$all_subscription_order_items[] = $item;
|
|
$logs[] = "[{$order->created_at->value()}] Processing order item ID {$item->id}, price: {$price}, quantity: {$amount}, subtotal: +" . ($price * $amount) . " (total: {$total_subscription_amount}, items: " . count($all_subscription_order_items) . ")";
|
|
}
|
|
}
|
|
unset($tmp_items, $item, $order, $customer_id, $price, $amount);
|
|
// Define the total fixed pricing amount
|
|
$total_fixed_pricing_amount = 0;
|
|
$all_fixed_pricing_orders = [];
|
|
$all_fixed_pricing_order_items = [];
|
|
// Get all fixed pricing orders in the date range
|
|
$fixed_pricing_orders = $orders_o->getFieldsWhere([
|
|
'department_id' => 10,
|
|
'reference' => 'Fast pris aftale',
|
|
], ['id', 'created_at']);
|
|
// Filter out the orders to only include those in the date range
|
|
$fixed_pricing_orders = array_filter($fixed_pricing_orders, function ($order) use ($dateFrom, $dateTo) {
|
|
$order_date = strtotime($order['created_at']);
|
|
$dateFrom = date('Y-m-d 00:00:00', strtotime($dateFrom));
|
|
$dateToMonthEnd = date('Y-m-d 23:59:59', strtotime($dateTo));
|
|
return $order_date >= strtotime($dateFrom) && $order_date <= strtotime($dateToMonthEnd);
|
|
});
|
|
// Construct the order objects
|
|
$fixed_pricing_orders = array_map(function ($order) {
|
|
return (new orders_o())->select((int)$order['id']);
|
|
}, $fixed_pricing_orders);
|
|
// Filter out deleted orders
|
|
$fixed_pricing_orders = array_filter($fixed_pricing_orders, function ($order) {
|
|
/** @var orders_o $order */
|
|
return !$order->deleted_at->value();
|
|
});
|
|
$unique_customers_fixed_pricing = [];
|
|
// Add up the total fixed pricing amount and collect the order items
|
|
foreach ( $fixed_pricing_orders as $order ) {
|
|
$customer_id = (int)$order->customer_id->value();
|
|
$logs[] = "[{$order->id}] Processing fixed pricing order items for customer ID {$customer_id}";
|
|
if (!in_array($customer_id, $unique_customers_fixed_pricing)) {
|
|
$unique_customers_fixed_pricing[] = $customer_id;
|
|
} else {
|
|
$warnings[] = "Duplicate fixed pricing order for customer ID " . $customer_id . " in order ID " . $order->id;
|
|
}
|
|
$all_fixed_pricing_orders[] = $order;
|
|
// Get the order items for the order
|
|
$tmp_items = $order->getOrderItemObjects();
|
|
/** @var order_items_o $item */
|
|
foreach ( $tmp_items as $item ) {
|
|
$price = (int)$item->price->value();
|
|
$amount = (int)$item->quantity->value();
|
|
$total_fixed_pricing_amount += $price * $amount;
|
|
$all_fixed_pricing_order_items[] = $item;
|
|
$logs[] = "[{$order->created_at->value()}] Processing fixed pricing order item ID {$item->id}, price: {$price}, quantity: {$amount}, subtotal: +" . ($price * $amount) . " (total fixed pricing: {$total_fixed_pricing_amount}, items: " . count($all_fixed_pricing_order_items) . ")";
|
|
}
|
|
}
|
|
|
|
// Get the total combined amount
|
|
$total_combined_amount = $total_subscription_amount + $total_fixed_pricing_amount;
|
|
|
|
// Return the orders
|
|
$response->success([
|
|
"hello" => "world",
|
|
"orders" => count($orders),
|
|
"unique_customers" => count($unique_customers),
|
|
"warnings" => $warnings,
|
|
"logs" => $logs,
|
|
"total_subscription_amount" => $total_subscription_amount,
|
|
"all_subscription_order_items" => count($all_subscription_order_items),
|
|
"total_fixed_pricing_amount" => $total_fixed_pricing_amount,
|
|
"all_fixed_pricing_order_items" => count($all_fixed_pricing_order_items),
|
|
"total_combined_amount" => $total_combined_amount,
|
|
"unique_customers_fixed_pricing" => count($unique_customers_fixed_pricing),
|
|
"fixed_pricing_orders" => count($fixed_pricing_orders),
|
|
]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the transactions with items, that are not included in invoices. (include_in_invoice = 0 - order_items)
|
|
* Requirements:
|
|
* - The transaction must not be deleted. (deleted_at is null - orders))
|
|
* - The transaction contains at least one item that is not included in invoices. (include_in_invoice = 0 - order_items)
|
|
* - The transaction must be within the specified date range. (created_at between dateFrom and dateTo - orders)
|
|
* - The transaction item must not be deleted. (deleted_at is null - order_items)
|
|
* Returns an array of customers with their transactions and items.
|
|
* @param array $customersWithSubscriptions The customers with subscriptions.
|
|
* @param array $dateRange The date range to filter the transactions. (dateFrom, dateTo)
|
|
* @return array The customers with their transactions and items.
|
|
* @throws Exception
|
|
* @example
|
|
* [
|
|
* [
|
|
* 'customer_number' => 12345678,
|
|
* 'customer_name' => 'Customer Name',
|
|
* 'transactions' => [
|
|
* [
|
|
* 'id' => 1,
|
|
* 'date' => '2023-01-01',
|
|
* 'amount' => 100.00,
|
|
* 'booked' => true,
|
|
* ],
|
|
* [
|
|
* 'id' => 2,
|
|
* 'date' => '2023-01-02',
|
|
* 'amount' => 200.00,
|
|
* 'booked' => false,
|
|
* ],
|
|
* ],
|
|
* 'requires_action' => false,
|
|
* 'meta' => [
|
|
* 'subscription' => [
|
|
* 'id' => 1,
|
|
* 'name' => 'Subscription Name',
|
|
* 'price' => 100.00,
|
|
* 'description' => 'Subscription Description',
|
|
* ],
|
|
* ],
|
|
* ],
|
|
* [
|
|
* 'customer_number' => 87654321,
|
|
* 'customer_name' => 'Another Customer',
|
|
* 'transactions' => [
|
|
* [
|
|
* 'id' => 3,
|
|
* 'date' => '2023-01-03',
|
|
* 'amount' => 300.00,
|
|
* 'booked' => true,
|
|
* ],
|
|
* ],
|
|
* 'requires_action' => true,
|
|
* 'meta' => [
|
|
* 'subscription' => [
|
|
* 'id' => 2,
|
|
* 'name' => 'Another Subscription',
|
|
* 'price' => 200.00,
|
|
* 'description' => 'Another Description',
|
|
* ],
|
|
* ],
|
|
* ],
|
|
* ]
|
|
* @see orders_o::getTransactionsWithItemsNotIncludedInInvoices
|
|
* @see order_items_o::include_in_invoice
|
|
* @see orders_o::deleted_at
|
|
* @see order_items_o::deleted_at
|
|
* @see orders_o::created_at
|
|
* @see orders_o::customer_id
|
|
* @see self::getVehicleSubscriptions()
|
|
*/
|
|
private static function getTransactionsWithItemsNotIncludedInInvoices(array $customersWithSubscriptions): array
|
|
{
|
|
global $response;
|
|
// Loop through each customer and get their transactions with items not included in invoices
|
|
foreach ( $customersWithSubscriptions as &$customer ) {
|
|
// Initialize the meta['subscription'] array if it doesn't exist
|
|
if (!isset($customer['meta']['subscription'])) {
|
|
$customer['meta']['subscription'] = [
|
|
'vehicles' => [], // Array of vehicle registration numbers
|
|
'subscription_total' => 0, // Total price of the subscriptions for the customer
|
|
'subscriptions' => [], // Array of subscriptions for the customer (['registration' => ['type' => (int), 'price' => (int), 'distributions' => <(int: departmentId)>[]])
|
|
'subscription_price_department_distribution' => [
|
|
// departmentId => price / (number of unique distributions)
|
|
// This is calculated after the subscriptions have been added
|
|
// This is used to distribute the subscription price across the transactions
|
|
]
|
|
];
|
|
}
|
|
// Get the vehicles for the customer
|
|
$vehicles_o = new customer_vehicles_o();
|
|
// Get the vehicles for the customer (as an array of customer_vehicles_o objects)
|
|
$customer_vehicles = array_map(function ($vehicle) {
|
|
return (new customer_vehicles_o())->select((int)$vehicle['id']);
|
|
}, $vehicles_o->getFieldsWhere([
|
|
'customer_id' => (int)$customer['customer_number'],
|
|
'wash_subscription' => 1, // Only get vehicles with a wash subscription
|
|
], ['id']));
|
|
// Add the vehicle registration numbers to the meta['subscription']['vehicles'] array
|
|
foreach ( $customer_vehicles as $vehicle ) {
|
|
if ($vehicle instanceof customer_vehicles_o) {
|
|
$customer['meta']['subscription']['vehicles'][] = $vehicle->reg->value();
|
|
} else {
|
|
$customer['meta']['subscription']['vehicles'][] = 'Unknown Vehicle';
|
|
}
|
|
}
|
|
// Calculate the total price of the subscriptions for the customer
|
|
foreach ( $customer_vehicles as $vehicle ) {
|
|
if ($vehicle instanceof customer_vehicles_o) {
|
|
// Get the transaction ids covered by the subscription for the vehicle
|
|
// This is done to be able to distribute the subscription price across the transactions
|
|
// We only want to get the transactions that are in the customer's transactions array, (this is to avoid getting transactions that are outside the date range)
|
|
// We also want to make sure that the transactions are for the correct vehicle (reg_1 = vehicle reg), and that the transactions are not deleted (deleted_at is null)
|
|
$transaction_ids_covered_by_subscription = array_map(function ($transaction) {
|
|
return (int)$transaction;
|
|
}, $vehicle->getSubscriptionAppliedTransactionsFromList(array_map(
|
|
function ($transaction) {
|
|
return (int)$transaction['id'];
|
|
}, (new orders_o())->getFieldsWhereIn([
|
|
'id' => array_map(function ($transaction) {
|
|
return (int)$transaction['id'];
|
|
}, $customer['transactions']),
|
|
'reg_1' => $vehicle->reg->value(),
|
|
], ['id'])
|
|
)));
|
|
// Get the subscription details for the vehicle
|
|
$subscription = [
|
|
'type' => (int)$vehicle->type->value(),
|
|
'price' => (int)(new products_o())->select((int)$vehicle->type->value())->getSubscriptionMonthlyPrice(),
|
|
// Get the unique department ids for the transactions covered by the subscription
|
|
'distributions' => array_unique(array_map(function ($transaction_id) {
|
|
// Get the department id for the transaction(s) covered by the subscription
|
|
$transaction = (new orders_o())->select((int)$transaction_id);
|
|
return (int)$transaction->department_id->value();
|
|
}, $transaction_ids_covered_by_subscription)),
|
|
];
|
|
$customer['meta']['subscription']['subscriptions'][$vehicle->reg->value()] = $subscription;
|
|
$customer['meta']['subscription']['subscription_total'] += $subscription['price'];
|
|
// Add the subscription price per transaction to the meta['subscription']['subscription_price_department_distribution'] array
|
|
foreach ( $subscription['distributions'] as $department_id ) {
|
|
if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) {
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0;
|
|
}
|
|
// Divide the subscription price by the number of unique distributions
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += $subscription['price'] / count($subscription['distributions']);
|
|
}
|
|
// If the vehicle has no transactions covered by the subscription, we need to run through the list of fallback options
|
|
if (count($transaction_ids_covered_by_subscription) === 0) {
|
|
// Run fallback options
|
|
self::attemptSubscriptionFallbacks($vehicle, $customer);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Get the department distribution for all customers (sum of all customers)
|
|
$collective_results = [
|
|
'total_subscription_price' => 0,
|
|
'subscription_price_department_distribution' => [
|
|
// departmentId => price
|
|
],
|
|
];
|
|
unset($customer); // Unset the reference to avoid issues
|
|
foreach ( $customersWithSubscriptions as $customer ) {
|
|
if (isset($customer['meta']['subscription'])) {
|
|
$collective_results['total_subscription_price'] += $customer['meta']['subscription']['subscription_total'];
|
|
foreach ( $customer['meta']['subscription']['subscription_price_department_distribution'] as $department_id => $price ) {
|
|
if (!isset($collective_results['subscription_price_department_distribution'][$department_id])) {
|
|
$collective_results['subscription_price_department_distribution'][$department_id] = 0;
|
|
}
|
|
$collective_results['subscription_price_department_distribution'][$department_id] += $price;
|
|
}
|
|
// Verify that the sum of the department distribution equals the total subscription price for the customer
|
|
$sum_of_distribution = array_sum($customer['meta']['subscription']['subscription_price_department_distribution']);
|
|
$difference = $customer['meta']['subscription']['subscription_total'] - $sum_of_distribution;
|
|
if (abs($difference) > 0.01) {
|
|
// Debug info
|
|
$debug_info = [
|
|
'customer_number' => $customer['customer_number'],
|
|
'subscription_total' => $customer['meta']['subscription']['subscription_total'],
|
|
'sum_of_distribution' => $sum_of_distribution,
|
|
'difference' => $difference,
|
|
];
|
|
// Send slack alert
|
|
$message = "Subscription price distribution mismatch for customer " . $customer['customer_number'] . " (" . self::getLocalCustomerName((int)$customer['customer_number']) . ")\n";
|
|
$message .= "Subscription total: " . $customer['meta']['subscription']['subscription_total'] . "\n";
|
|
$message .= "Distribution total: " . $sum_of_distribution . "\n";
|
|
$message .= "Difference: " . $difference . "\n";
|
|
$message .= "Debug info: " . print_r($debug_info, true);
|
|
// Describe what to check
|
|
if (!self::$suppressInvoicePeriodExternalEffects) {
|
|
(new slack())->send_message($message, 'Subscription Price Distribution Mismatch');
|
|
}
|
|
// Throw an error
|
|
throw new Exception('Subscription price distribution does not equal total subscription price for customer ' . $customer['customer_number'] . '. Difference: ' . $difference);
|
|
}
|
|
}
|
|
}
|
|
// Parse the department ids to department names
|
|
$parsed_distribution = [];
|
|
foreach ( $collective_results['subscription_price_department_distribution'] as $department_id => $price ) {
|
|
$parsed_distribution[self::getDepartmentNameCached((int)$department_id)] = $price;
|
|
}
|
|
$collective_results['subscription_price_department_distribution_parsed'] = $parsed_distribution;
|
|
// Include the collective results in the response
|
|
$response->add_include('collective_subscription_results', $collective_results);
|
|
if (self::shouldSendSlackSummary()) {
|
|
// Send summary to slack
|
|
$slack_message = "Subscription Price Distribution Summary:\n";
|
|
$slack_message .= "Total Subscription Price: " . $collective_results['total_subscription_price'] . "\n";
|
|
$slack_message .= "Department Distribution:\n";
|
|
$tmp_total = 0;
|
|
foreach ( $collective_results['subscription_price_department_distribution_parsed'] as $department_name => $price ) {
|
|
$slack_message .= "- " . $department_name . ": " . $price . "\n";
|
|
$tmp_total += $price;
|
|
}
|
|
$slack_message .= "Total Distribution: " . $tmp_total . "\n";
|
|
(new slack())->send_message($slack_message, 'Subscription Price Distribution Summary');
|
|
}
|
|
return $customersWithSubscriptions;
|
|
}
|
|
|
|
/**
|
|
* Attempt fallback options to find transactions covered by the subscription.
|
|
* This is used when a vehicle has a subscription, but no transactions in the given date range.
|
|
* This function processes the following fallback options:
|
|
* 1. Attempt to divide the subscription price across the departments the customer has subscription transactions in.
|
|
* 2. Attempt to get the last transaction the vehicle was washed in, and use that department.
|
|
* 3. If no transactions are found, assign the subscription to the departments used in the most recent 10 transactions by the customer.
|
|
* 4. If no departments are found, assign the subscription to a default department (e.g., department ID 1).
|
|
* @param customer_vehicles_o $vehicle The vehicle object.
|
|
* @param array $customer The customer object (by reference).
|
|
* @retuns bool True if a fallback was applied, false otherwise.
|
|
* @throws Exception If an error occurs while processing the fallbacks.
|
|
*/
|
|
private static function attemptSubscriptionFallbacks(customer_vehicles_o $vehicle, array &$customer): bool
|
|
{
|
|
if (self::debug) echo "Attempting fallbacks for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . " - " . self::getLocalCustomerName((int)$customer['customer_number']) . "\n";
|
|
// Run the fallback options in order
|
|
$subscription_price = (int)(new products_o())->select((int)$vehicle->type->value())->getSubscriptionMonthlyPrice();
|
|
if (self::divideSubscriptionAcrossCustomerDepartments($customer, $subscription_price)) {
|
|
return true;
|
|
}
|
|
if (self::useLastTransactionDepartment($customer, $vehicle, $subscription_price)) {
|
|
return true;
|
|
}
|
|
if (self::useRecentCustomerTransactions($customer, $subscription_price)) {
|
|
return true;
|
|
}
|
|
if (self::useDefaultDepartment($customer, $subscription_price)) {
|
|
return true;
|
|
}
|
|
// If no fallback was applied, return false
|
|
if (self::debug) echo "All fallbacks failed for vehicle " . $vehicle->reg->value() . " of customer " . $customer['customer_number'] . "\n";
|
|
return false; // No fallback applied
|
|
}
|
|
const debug = false;
|
|
// 1. Attempt to divide the subscription price across the departments the customer has transactions in.
|
|
private static function divideSubscriptionAcrossCustomerDepartments(array &$customer, int $subscription_price): bool
|
|
{
|
|
if (self::debug) echo "Fallback 1: Dividing subscription price across customer departments\n";
|
|
// Get the unique department ids for the customer's transactions
|
|
$department_ids = array_unique(array_map(/**
|
|
* @throws Exception
|
|
*/ function ($transaction) {
|
|
$transaction_obj = (new orders_o())->select((int)$transaction['id']);
|
|
return (int)$transaction_obj->department_id->value();
|
|
}, $customer['transactions']));
|
|
// Remove department id 10 (automatic) from the list
|
|
$department_ids = array_filter($department_ids, function ($department_id) {
|
|
return $department_id !== 10;
|
|
});
|
|
if (count($department_ids) > 0) {
|
|
foreach ( $department_ids as $department_id ) {
|
|
// If the department id is 10 (automatic), skip it
|
|
if ($department_id === 10) {
|
|
continue;
|
|
}
|
|
if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) {
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0;
|
|
}
|
|
// Divide the subscription price by the number of unique departments
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += $subscription_price / count($department_ids);
|
|
}
|
|
// Debug:
|
|
if (self::debug) echo "Fallback 1 applied: Divided subscription price across customer departments. Departments: " . implode(', ', $department_ids) . "\n";
|
|
return true; // Fallback applied
|
|
}
|
|
if (self::debug) echo "Fallback 1 not applied: No departments found in customer's transactions\n";
|
|
return false; // No departments found
|
|
}
|
|
// 2. Attempt to get the last transaction the vehicle was washed in, and use that department.
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function useLastTransactionDepartment(array &$customer, customer_vehicles_o $vehicle, int $subscription_price): bool
|
|
{
|
|
if (self::debug) echo "Fallback 2: Using last transactions departments\n";
|
|
// Get the last transaction the vehicle was washed in
|
|
$last_transaction_ids = $vehicle->getLastTransactions(10); // Get the last 10 transactions
|
|
$distribution_department_ids = []; // Array to hold the department ids from the last transactions, together with their counts (1 = 10%, 2 = 20%, etc.)
|
|
// Calculate the department distribution from the last transactions
|
|
foreach ( $last_transaction_ids as $transaction_id ) {
|
|
$transaction = (new orders_o())->select((int)$transaction_id);
|
|
if ($transaction instanceof orders_o) {
|
|
$department_id = (int)$transaction->department_id->value();
|
|
// If the department id is 10 (automatic), skip it
|
|
if ($department_id === 10) {
|
|
continue;
|
|
}
|
|
if (!isset($distribution_department_ids[$department_id])) {
|
|
$distribution_department_ids[$department_id] = 0;
|
|
}
|
|
$distribution_department_ids[$department_id]++;
|
|
}
|
|
}
|
|
// If we have department ids from the last transactions, use them to distribute the subscription price
|
|
if (count($distribution_department_ids) > 0) {
|
|
$total_counts = array_sum($distribution_department_ids);
|
|
foreach ( $distribution_department_ids as $department_id => $count ) {
|
|
if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) {
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0;
|
|
}
|
|
// Distribute the subscription price based on the count of the department in the last transactions
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += ($count / $total_counts) * $subscription_price;
|
|
}
|
|
// Debug:
|
|
if (self::debug) echo "Fallback 2 applied: Used last transaction departments. Departments: " . implode(', ', array_keys($distribution_department_ids)) . "\n";
|
|
return true; // Fallback applied
|
|
}
|
|
if (self::debug) echo "Fallback 2 not applied: No last transaction found for vehicle\n";
|
|
return false; // No last transaction found
|
|
}
|
|
// 3. If no transactions are found, assign the subscription to the departments used in the most recent 10 transactions by the customer.
|
|
private static function useRecentCustomerTransactions(array &$customer, int $subscription_price): bool
|
|
{
|
|
if (self::debug) echo "Fallback 3: Using recent customer transactions\n";
|
|
// Get the last 10 transactions of the customer
|
|
$recent_transaction_ids = array_slice(array_map(function ($transaction) {
|
|
return (int)$transaction['id'];
|
|
}, (new orders_o())->getFieldsWhere([
|
|
'customer_id' => (int)$customer['customer_number'],
|
|
'deleted_at' => null,
|
|
], ['id', 'created_at'])), 0, 10);
|
|
$distribution_department_ids = []; // Array to hold the department ids from the recent transactions, together with their counts (1 = 10%, 2 = 20%, etc.)
|
|
// Calculate the department distribution from the recent transactions
|
|
foreach ( $recent_transaction_ids as $transaction_id ) {
|
|
$transaction = (new orders_o())->select((int)$transaction_id);
|
|
if ($transaction instanceof orders_o) {
|
|
$department_id = (int)$transaction->department_id->value();
|
|
// If the department id is 10 (automatic), skip it
|
|
if ($department_id === 10) {
|
|
continue;
|
|
}
|
|
if (!isset($distribution_department_ids[$department_id])) {
|
|
$distribution_department_ids[$department_id] = 0;
|
|
}
|
|
$distribution_department_ids[$department_id]++;
|
|
}
|
|
}
|
|
// If we have department ids from the recent transactions, use them to distribute the subscription price
|
|
if (count($distribution_department_ids) > 0) {
|
|
$total_counts = array_sum($distribution_department_ids);
|
|
foreach ( $distribution_department_ids as $department_id => $count ) {
|
|
// If the department id is 10 (automatic), skip it
|
|
if ($department_id === 10) {
|
|
continue;
|
|
}
|
|
if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$department_id])) {
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] = 0;
|
|
}
|
|
// Distribute the subscription price based on the count of the department in the recent transactions
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$department_id] += ($count / $total_counts) * $subscription_price;
|
|
}
|
|
// Debug:
|
|
if (self::debug) echo "Fallback 3 applied: Used recent customer transaction departments. Departments: " . implode(', ', array_keys($distribution_department_ids)) . "\n";
|
|
return true; // Fallback applied
|
|
}
|
|
if (self::debug) echo "Fallback 3 not applied: No recent transactions found for customer\n";
|
|
return false; // No recent transactions found
|
|
}
|
|
// 4. If no departments are found, assign the subscription to customer default department
|
|
// or fallback to e-conomic default distribution department.
|
|
private static function useDefaultDepartment(array &$customer, int $subscription_price): bool
|
|
{
|
|
if (self::debug) echo "Fallback 4: Using default department\n";
|
|
$customer_default_department_id = (int)(new users_o)->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment();
|
|
$default_department_id = $customer_default_department_id > 0
|
|
? $customer_default_department_id
|
|
: self::getEconomicFallbackDepartmentId();
|
|
|
|
if ($default_department_id > 0) {
|
|
if (!isset($customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id])) {
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id] = 0;
|
|
}
|
|
$customer['meta']['subscription']['subscription_price_department_distribution'][$default_department_id] += $subscription_price;
|
|
// Debug:
|
|
if (self::debug) echo "Fallback 4 applied: Used default department ID " . $default_department_id . "\n";
|
|
return true; // Fallback applied
|
|
}
|
|
if (self::debug) echo "Fallback 4 not applied: No default department found for customer\n";
|
|
return false; // No default department found
|
|
}
|
|
|
|
/**
|
|
* Get the original price for each customer, and transaction in the fixed pricing array.
|
|
*
|
|
* @param array $fixed_pricing The fixed pricing array.
|
|
* @return array The fixed pricing array with the original price added.
|
|
* @throws Exception
|
|
*/
|
|
private static function getOriginalPrice(array $fixed_pricing): array
|
|
{
|
|
global $response;
|
|
$order_items = new order_items_o();
|
|
foreach ( $fixed_pricing as &$customer ) {
|
|
if (isset($customer['meta']['fixed_pricing'])) {
|
|
$original_price = 0;
|
|
$department_totals = []; // Array to hold totals per department
|
|
|
|
$eligible_transaction_department_ids = [];
|
|
foreach ( $customer['transactions'] as $transaction ) {
|
|
$department_id = (int)($transaction['department_id'] ?? 0);
|
|
$excluded = (bool)($transaction['excluded'] ?? false);
|
|
|
|
// Skip excluded transactions and automatic department.
|
|
if ($excluded || $department_id === 10) {
|
|
continue;
|
|
}
|
|
$eligible_transaction_department_ids[(int)$transaction['id']] = $department_id;
|
|
}
|
|
|
|
if (!empty($eligible_transaction_department_ids)) {
|
|
$transaction_original_prices = [];
|
|
$product_cache = [];
|
|
$department_price_cache = [];
|
|
$discount_cache = [];
|
|
$user = (new users_o())->getUserByCustomerNumber((int)$customer['customer_number']);
|
|
$rows = $order_items->getFieldsWhere(
|
|
['order_id' => array_keys($eligible_transaction_department_ids)],
|
|
['order_id', 'product_id', 'price', 'quantity']
|
|
);
|
|
|
|
foreach ( $rows as $row ) {
|
|
$order_id = (int)$row['order_id'];
|
|
$department_id = (int)($eligible_transaction_department_ids[$order_id] ?? 0);
|
|
if ($department_id <= 0) {
|
|
continue;
|
|
}
|
|
$product_id = (int)$row['product_id'];
|
|
$price = (int)$row['price'];
|
|
$quantity = (int)$row['quantity'];
|
|
|
|
if ($price > 0 && $quantity > 0) {
|
|
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + ($price * $quantity));
|
|
continue;
|
|
}
|
|
|
|
if (!isset($product_cache[$product_id])) {
|
|
$product_cache[$product_id] = (new products_o())->select($product_id);
|
|
}
|
|
if (!isset($department_price_cache[$department_id][$product_id])) {
|
|
$department_price_cache[$department_id][$product_id] = $product_cache[$product_id]->getDepartmentPriceResolution($department_id);
|
|
}
|
|
if (!array_key_exists($product_id, $discount_cache)) {
|
|
$unit_price = (int)$department_price_cache[$department_id][$product_id]['price'];
|
|
if (!products_o::priceResolutionIsCustomMissing($department_price_cache[$department_id][$product_id])) {
|
|
$unit_price = $user->applyProductCustomerPricing($product_id, $unit_price, false, $department_id);
|
|
}
|
|
$discount_cache[$product_id] = $unit_price;
|
|
}
|
|
$post_discount = (int)$discount_cache[$product_id] * $quantity;
|
|
$transaction_original_prices[$order_id] = (int)(($transaction_original_prices[$order_id] ?? 0) + $post_discount);
|
|
}
|
|
|
|
foreach ( $eligible_transaction_department_ids as $transaction_id => $department_id ) {
|
|
$transaction_original_price = (int)($transaction_original_prices[$transaction_id] ?? 0);
|
|
$original_price += $transaction_original_price;
|
|
// Initialize the department total if it doesn't exist
|
|
if (!isset($department_totals[$department_id])) {
|
|
$department_totals[$department_id] = 0;
|
|
}
|
|
// Add the transaction amount to the department total
|
|
$department_totals[$department_id] += $transaction_original_price;
|
|
}
|
|
} else {
|
|
$customer_default_department_id = (int)(new users_o())->getUserByCustomerNumber((int)$customer['customer_number'])->getDefaultDepartment();
|
|
$fallback_department_id = $customer_default_department_id > 0
|
|
? $customer_default_department_id
|
|
: self::getEconomicFallbackDepartmentId();
|
|
|
|
if ($fallback_department_id > 0) {
|
|
$department_totals[$fallback_department_id] = (float)$customer['meta']['fixed_pricing']['price'];
|
|
}
|
|
}
|
|
|
|
$customer['meta']['fixed_pricing']['original_price'] = $original_price;
|
|
$customer['meta']['fixed_pricing']['department_totals'] = $department_totals;
|
|
// Take the relative price of the department totals in relation to the fixed price
|
|
// This is done to see how much each department contributes to the fixed price
|
|
$total = array_sum($department_totals);
|
|
foreach ( $department_totals as $department_id => $amount ) {
|
|
if ($total > 0) {
|
|
$department_totals[$department_id] = ($amount / $total) * $customer['meta']['fixed_pricing']['price'];
|
|
} else {
|
|
$department_totals[$department_id] = 0;
|
|
}
|
|
}
|
|
$customer['meta']['fixed_pricing']['department_totals_relative'] = $department_totals;
|
|
}
|
|
}
|
|
// Calculate the total original price for all customers
|
|
$collective_results = [
|
|
'total_fixed_price' => 0,
|
|
'total_original_price' => 0,
|
|
'total_department_totals' => [],
|
|
'total_department_totals_relative' => [],
|
|
];
|
|
unset($customer); // Unset the reference to avoid issues
|
|
foreach ( $fixed_pricing as $customer ) {
|
|
if (isset($customer['meta']['fixed_pricing'])) {
|
|
$collective_results['total_fixed_price'] += $customer['meta']['fixed_pricing']['price'];
|
|
$collective_results['total_original_price'] += $customer['meta']['fixed_pricing']['original_price'];
|
|
// Sum the department totals
|
|
foreach ( $customer['meta']['fixed_pricing']['department_totals'] as $department_id => $amount ) {
|
|
if (!isset($collective_results['total_department_totals'][$department_id])) {
|
|
$collective_results['total_department_totals'][$department_id] = 0;
|
|
}
|
|
$collective_results['total_department_totals'][$department_id] += $amount;
|
|
}
|
|
// Sum the relative department totals
|
|
foreach ( $customer['meta']['fixed_pricing']['department_totals_relative'] as $department_id => $amount ) {
|
|
if (!isset($collective_results['total_department_totals_relative'][$department_id])) {
|
|
$collective_results['total_department_totals_relative'][$department_id] = 0;
|
|
}
|
|
$collective_results['total_department_totals_relative'][$department_id] += $amount;
|
|
}
|
|
}
|
|
}
|
|
// Parse the department ids to department names
|
|
$collective_results = self::parseTheDepartmentIdsToDepartmentNames($collective_results);
|
|
// Include the collective results in the response
|
|
$response->add_include('collective_fixed_pricing_results', $collective_results);
|
|
if (self::shouldSendSlackSummary()) {
|
|
// Send slack message with the collective results
|
|
$slack_message = "Fixed Pricing Invoicing Period Summary:\n";
|
|
$slack_message .= "Total Fixed Price: " . number_format($collective_results['total_fixed_price'], 2) . " DKK\n";
|
|
$slack_message .= "Total Original Price: " . number_format($collective_results['total_original_price'], 2) . " DKK\n";
|
|
$slack_message .= "Department Totals:\n";
|
|
$tmp_sum = 0;
|
|
foreach ( $collective_results['total_department_totals_parsed'] as $department_name => $amount ) {
|
|
$slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n";
|
|
$tmp_sum += $amount;
|
|
}
|
|
$slack_message .= "Total Department Totals: " . number_format($tmp_sum, 2) . " DKK\n";
|
|
$slack_message .= "Relative Department Totals:\n";
|
|
$tmp_sum = 0;
|
|
foreach ( $collective_results['total_department_totals_relative_parsed'] as $department_name => $amount ) {
|
|
$slack_message .= "- " . $department_name . ": " . number_format($amount, 2) . " DKK\n";
|
|
$tmp_sum += $amount;
|
|
}
|
|
$slack_message .= "Total Relative Department Totals: " . number_format($tmp_sum, 2) . " DKK\n";
|
|
(new slack())->send_message($slack_message, 'Fixed Pricing Invoicing Period Summary');
|
|
}
|
|
return $fixed_pricing;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getInvoicingPeriod(
|
|
string $dateFrom,
|
|
string $dateTo,
|
|
?array $onlyCustomerNumbers = null,
|
|
bool $includeInvoicePeriodFlags = false
|
|
): array
|
|
{
|
|
//$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo)
|
|
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
|
|
? self::normalizeCustomerNumbers($onlyCustomerNumbers)
|
|
: null;
|
|
|
|
$customersWithTransactions = self::debugGetTime(function () use ($dateFrom, $dateTo, $onlyCustomerNumbers) {
|
|
return self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
|
}, 'customers_with_transactions');
|
|
$types = [];
|
|
// Add the customers with transactions to the types array
|
|
$types['all'] = $customersWithTransactions;
|
|
$types['vehicle_subscriptions'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
|
return self::getVehicleSubscriptions($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
|
}, 'vehicle_subscriptions');
|
|
$types['fixed_pricing'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
|
return self::getFixedPricing($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
|
}, 'fixed_pricing');
|
|
$types['tank_cleaning'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
|
return self::getTankCleaning($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
|
}, 'tank_cleaning');
|
|
$types['special_arrangements'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
|
return self::getSpecialArrangements($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
|
}, 'special_arrangements');
|
|
$types['invoice_per_order'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
|
return self::getInvoicingPerOrder($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
|
}, 'invoice_per_order');
|
|
$types['possible_duplicates'] = self::debugGetTime(function () use ($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers) {
|
|
return self::getPossibleDuplicates($dateFrom, $dateTo, $customersWithTransactions, $onlyCustomerNumbers);
|
|
}, 'possible_duplicates');
|
|
$queueOverlay = self::debugGetTime(function () use ($dateFrom, $dateTo) {
|
|
return self::getActiveCollectedInvoiceQueueOverlay($dateFrom, $dateTo);
|
|
}, 'active_collected_invoice_queue_overlay');
|
|
$types = self::applyCollectedInvoiceQueueOverlayToPeriodTypes(
|
|
$types,
|
|
$queueOverlay['by_collection_id'] ?? [],
|
|
$queueOverlay['by_customer_number'] ?? [],
|
|
);
|
|
$draftOverlay = self::debugGetTime(function () use ($types, $dateFrom, $dateTo) {
|
|
return self::getValidCollectedInvoiceDraftOverlay($types, $dateFrom, $dateTo);
|
|
}, 'valid_collected_invoice_draft_overlay');
|
|
$types = self::applyCollectedInvoiceDraftOverlayToPeriodTypes(
|
|
$types,
|
|
$draftOverlay['by_collection_id'] ?? [],
|
|
$draftOverlay['by_customer_number'] ?? [],
|
|
);
|
|
$invoiceCollectionMetadata = self::debugGetTime(function () use ($types) {
|
|
return self::getPeriodInvoiceCollectionMetadata($types);
|
|
}, 'invoice_collection_metadata');
|
|
$types = self::applyPeriodInvoiceStateOverlayToTypes($types, $invoiceCollectionMetadata);
|
|
if ($includeInvoicePeriodFlags) {
|
|
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
|
|
return (new invoice_period_flag_service())->applyFlagsToPeriodTypes(
|
|
$types,
|
|
$dateFrom,
|
|
$dateTo,
|
|
$onlyCustomerNumbers
|
|
);
|
|
}, 'invoice_period_flags');
|
|
} else {
|
|
$types = self::debugGetTime(function () use ($types, $dateFrom, $dateTo, $onlyCustomerNumbers) {
|
|
return (new invoice_period_flag_service())->applyManualFlagCountsToPeriodTypes(
|
|
$types,
|
|
$dateFrom,
|
|
$dateTo,
|
|
$onlyCustomerNumbers
|
|
);
|
|
}, 'invoice_period_manual_flag_counts');
|
|
}
|
|
$types = self::enrichPeriodCustomerReview($types);
|
|
return [
|
|
'dateFrom' => $dateFrom,
|
|
'dateTo' => $dateTo,
|
|
'types' => $types,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Debugging function to measure the time taken by a function.
|
|
*
|
|
* @param callable $function The function to debug.
|
|
* @return mixed The result of the function.
|
|
*/
|
|
private static function debugGetTime(callable $function, string $debug_label = ''): mixed
|
|
{
|
|
global $response;
|
|
$start_time = microtime(true) * 1000; // Start time in milliseconds
|
|
$result = $function();
|
|
$end_time = microtime(true) * 1000; // End time in milliseconds
|
|
$execution_time = $end_time - $start_time;
|
|
$response->add_include(
|
|
'debug_invoicing_period_' . $debug_label,
|
|
[
|
|
'execution_time' => $execution_time,
|
|
]
|
|
);
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getCustomersWithTransactions(string $dateFrom, string $dateTo, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
$onlyCustomerNumbers = $onlyCustomerNumbers !== null
|
|
? self::normalizeCustomerNumbers($onlyCustomerNumbers)
|
|
: null;
|
|
if ($onlyCustomerNumbers !== null && empty($onlyCustomerNumbers)) {
|
|
return [];
|
|
}
|
|
|
|
$customer_number_transactions = [];
|
|
self::debugGetTime(function () use ($onlyCustomerNumbers, $dateFrom, $dateTo, &$customer_number_transactions) {
|
|
$customer_number_transactions = (new orders_o())->getPeriodTransactionsForCustomersInDateRange(
|
|
$onlyCustomerNumbers,
|
|
$dateFrom,
|
|
$dateTo
|
|
);
|
|
}, 'get_transactions_for_customers_in_date_range');
|
|
|
|
$customer_numbers = [];
|
|
$tmp = [];
|
|
self::debugGetTime(function () use ($customer_number_transactions, &$customer_numbers) {
|
|
foreach ( $customer_number_transactions as $customer_number => $transactions ) {
|
|
$customer_number = (int)$customer_number;
|
|
if (empty($customer_number)) {
|
|
continue;
|
|
}
|
|
if (isset($customer_numbers[$customer_number])) {
|
|
continue;
|
|
}
|
|
$firstTransaction = is_array($transactions) ? ($transactions[0] ?? []) : [];
|
|
$userId = (int)($firstTransaction['user_id'] ?? 0);
|
|
$customer_numbers[$customer_number] = $userId > 0 ? $userId : null;
|
|
}
|
|
}, 'process_customer_numbers');
|
|
self::debugGetTime(static function (): void {
|
|
// Net totals are resolved by getPeriodTransactionsForCustomersInDateRange().
|
|
}, 'calculate_transaction_totals');
|
|
// Get the customer names from the cache
|
|
$customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_numbers), false);
|
|
// Process the customer numbers to ensure they are unique
|
|
self::debugGetTime(function () use ($customer_numbers, $customer_number_transactions, &$tmp, $customer_names) {
|
|
foreach ( $customer_numbers as $customer_number => $user_id ) {
|
|
if (empty($customer_number)) {
|
|
// Skip if the customer number is empty
|
|
continue;
|
|
}
|
|
// Check if the customer number is already in the array
|
|
if (isset($tmp[$customer_number])) {
|
|
// If the customer number is already in the array, skip it
|
|
continue;
|
|
}
|
|
// If the customer number is not in the array, add it
|
|
// Construct the customer object with transactions
|
|
$tmp[] = self::constructCustomerObject(
|
|
(int)$customer_number,
|
|
$customer_names[(int)$customer_number] ?? 'Unknown Customer',
|
|
$customer_number_transactions[(int)$customer_number] ?? [],
|
|
false,
|
|
(int)$user_id > 0 ? (int)$user_id : null,
|
|
);
|
|
}
|
|
}, 'construct_customer_objects');
|
|
return $tmp;
|
|
}
|
|
|
|
/**
|
|
* @param int $customer_number
|
|
* @param string $customer_name
|
|
* @param orders_o[] $transactions
|
|
* @param bool $requires_action
|
|
* @return array
|
|
* @throws Exception
|
|
*/
|
|
protected static function constructCustomerObject(
|
|
int $customer_number,
|
|
string $customer_name,
|
|
array $transactions = [],
|
|
bool $requires_action = false,
|
|
?int $user_id = null,
|
|
?array $meta = null
|
|
): array
|
|
{
|
|
$user = null;
|
|
if ($user_id === null) {
|
|
$user = (new users_o())->getUserByCustomerNumber((int)$customer_number);
|
|
}
|
|
return [
|
|
'id' => $user_id ?? ($user !== null && $user->exists() ? $user->id : null),
|
|
'customer_number' => $customer_number,
|
|
'customer_name' => $customer_name,
|
|
'transactions' => $parsed_transactions = array_map(function ($transaction) {
|
|
return self::constructTransactionObject($transaction);
|
|
}, $transactions),
|
|
'requires_action' => self::checkRequiresAction($parsed_transactions, $requires_action),
|
|
'meta' => $meta ?? [],
|
|
'queue' => self::getDefaultQueueSummary(),
|
|
'draft' => self::getDefaultDraftSummary(),
|
|
'invoice_collections' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function constructTransactionObject(mixed $transaction): array
|
|
{
|
|
if (is_array($transaction)) {
|
|
return self::constructTransactionObjectFromPeriodRow($transaction);
|
|
}
|
|
if (!$transaction instanceof orders_o) {
|
|
throw new \InvalidArgumentException('Invalid period transaction row.');
|
|
}
|
|
|
|
$departmentId = (int)$transaction->department_id->value();
|
|
$invoiceCollectionId = (int)$transaction->invoice_collection_id->value();
|
|
$booked = self::isTransactionBookedFromLocalState($transaction);
|
|
$completedAt = !empty($transaction->completed_at->value())
|
|
? (string)$transaction->completed_at->value()
|
|
: null;
|
|
return [
|
|
'id' => $transaction->id,
|
|
'date' => $transaction->created_at->value(),
|
|
'amount' => $transaction->temporary_net_amount, // Use the temporary net amount set earlier
|
|
'booked' => $booked,
|
|
'department_id' => $departmentId,
|
|
'customer_number' => (int)$transaction->customer_id->value(),
|
|
'reference' => (string)$transaction->reference->value(),
|
|
'po' => (string)$transaction->po->value(),
|
|
'notes' => (string)$transaction->notes->value(),
|
|
'reg_1' => (string)$transaction->reg_1->value(),
|
|
'reg_2' => (string)$transaction->reg_2->value(),
|
|
'reg_3' => (string)$transaction->reg_3->value(),
|
|
'completed_at' => $completedAt,
|
|
'excluded' => !$transaction->isIncludedInInvoicing(),
|
|
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
|
'invoice_state' => self::periodOrderState($booked, $completedAt),
|
|
'queue_status' => null,
|
|
'queue_job_id' => null,
|
|
];
|
|
}
|
|
|
|
private static function constructTransactionObjectFromPeriodRow(array $transaction): array
|
|
{
|
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
|
return [
|
|
'id' => (int)($transaction['id'] ?? $transaction['order_id'] ?? 0),
|
|
'date' => (string)($transaction['date'] ?? $transaction['created_at'] ?? ''),
|
|
'amount' => (float)($transaction['amount'] ?? $transaction['net_amount'] ?? 0),
|
|
'booked' => (bool)($transaction['booked'] ?? false),
|
|
'department_id' => (int)($transaction['department_id'] ?? 0),
|
|
'customer_number' => (int)($transaction['customer_number'] ?? $transaction['customer_id'] ?? 0),
|
|
'reference' => (string)($transaction['reference'] ?? $transaction['order_reference'] ?? ''),
|
|
'po' => (string)($transaction['po'] ?? $transaction['order_po'] ?? ''),
|
|
'notes' => (string)($transaction['notes'] ?? $transaction['order_notes'] ?? ''),
|
|
'reg_1' => (string)($transaction['reg_1'] ?? ''),
|
|
'reg_2' => (string)($transaction['reg_2'] ?? ''),
|
|
'reg_3' => (string)($transaction['reg_3'] ?? ''),
|
|
'completed_at' => !empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null,
|
|
'excluded' => (bool)($transaction['excluded'] ?? ((int)($transaction['include_in_invoice_effective'] ?? 1) !== 1)),
|
|
'invoice_collection_id' => $invoiceCollectionId > 0 ? $invoiceCollectionId : null,
|
|
'invoice_state' => self::periodOrderState(
|
|
(bool)($transaction['booked'] ?? false),
|
|
!empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null
|
|
),
|
|
'queue_status' => $transaction['queue_status'] ?? null,
|
|
'queue_job_id' => $transaction['queue_job_id'] ?? null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Resolve booked state from local stored invoice metadata only.
|
|
* Remote e-conomic invoice lookups are intentionally avoided here because this method runs for every
|
|
* transaction in the period response.
|
|
*/
|
|
private static function isTransactionBookedFromLocalState(orders_o $transaction): bool
|
|
{
|
|
global $db;
|
|
|
|
$orderId = (int)$transaction->id;
|
|
if ($orderId < 1) {
|
|
return false;
|
|
}
|
|
if (array_key_exists($orderId, self::$periodOrderBookedCache)) {
|
|
return self::$periodOrderBookedCache[$orderId];
|
|
}
|
|
|
|
$invoiceCollectionId = (int)$transaction->invoice_collection_id->value();
|
|
if ($invoiceCollectionId > 0) {
|
|
if (!array_key_exists($invoiceCollectionId, self::$periodInvoiceCollectionBookedCache)) {
|
|
$result = $db->query("SELECT booked_invoice_id FROM collected_order_invoices WHERE id = {$invoiceCollectionId} LIMIT 1");
|
|
$row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null;
|
|
self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId] = !empty($row['booked_invoice_id'] ?? null);
|
|
}
|
|
return self::$periodOrderBookedCache[$orderId] = self::$periodInvoiceCollectionBookedCache[$invoiceCollectionId];
|
|
}
|
|
|
|
$result = $db->query("SELECT invoice_id FROM economic_module_orders WHERE id = {$orderId} LIMIT 1");
|
|
$row = $result && $result->num_rows > 0 ? $result->fetch_assoc() : null;
|
|
return self::$periodOrderBookedCache[$orderId] = !empty($row['invoice_id'] ?? null);
|
|
}
|
|
|
|
private static function periodOrderState(bool $booked, ?string $completedAt): string
|
|
{
|
|
if ($booked) {
|
|
return 'economic_booked';
|
|
}
|
|
|
|
return !empty($completedAt) ? 'closed' : 'open';
|
|
}
|
|
|
|
private static function checkRequiresAction(array $parsed_transactions, bool $requires_action): bool
|
|
{
|
|
// If requires_action is already set to true, return true
|
|
if ($requires_action) {
|
|
return true;
|
|
}
|
|
// Check if any transaction is not booked
|
|
foreach ( $parsed_transactions as $transaction ) {
|
|
if (!$transaction['booked'] && !$transaction['excluded']) {
|
|
return true;
|
|
}
|
|
}
|
|
// If all transactions are booked, return false
|
|
return false;
|
|
}
|
|
|
|
private static function getDefaultQueueSummary(): array
|
|
{
|
|
return [
|
|
'has_active_job' => false,
|
|
'statuses' => [],
|
|
'invoice_collection_ids' => [],
|
|
'is_action_blocked' => false,
|
|
];
|
|
}
|
|
|
|
private static function getDefaultDraftSummary(): array
|
|
{
|
|
return [
|
|
'has_valid_draft' => false,
|
|
'invoice_collection_ids' => [],
|
|
'is_action_blocked' => false,
|
|
];
|
|
}
|
|
|
|
private static function getActiveCollectedInvoiceQueueOverlay(string $dateFrom, string $dateTo): array
|
|
{
|
|
$overlay = [
|
|
'by_collection_id' => [],
|
|
'by_customer_number' => [],
|
|
];
|
|
|
|
try {
|
|
$queue = new economic_transfer_queue();
|
|
$offset = 0;
|
|
$limit = 250;
|
|
|
|
do {
|
|
$jobs = $queue->listJobs(
|
|
[
|
|
economic_transfer_queue::STATUS_QUEUED,
|
|
economic_transfer_queue::STATUS_PROCESSING,
|
|
],
|
|
$limit,
|
|
$offset,
|
|
economic_transfer_queue::TYPE_COLLECTED_INVOICE_EXPORT
|
|
);
|
|
|
|
foreach ($jobs as $job) {
|
|
$normalizedJob = self::normalizeCollectedInvoiceQueueJob($job, $dateFrom, $dateTo);
|
|
if ($normalizedJob === null || empty($normalizedJob['is_period_relevant'])) {
|
|
continue;
|
|
}
|
|
|
|
$invoiceCollectionId = (int)($normalizedJob['invoice_collection_id'] ?? 0);
|
|
if ($invoiceCollectionId > 0 && !isset($overlay['by_collection_id'][$invoiceCollectionId])) {
|
|
$overlay['by_collection_id'][$invoiceCollectionId] = $normalizedJob;
|
|
}
|
|
|
|
$customerNumber = (int)($normalizedJob['customer_number'] ?? 0);
|
|
if ($customerNumber > 0) {
|
|
$overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? [];
|
|
$overlay['by_customer_number'][$customerNumber][] = $normalizedJob;
|
|
}
|
|
}
|
|
|
|
$offset += count($jobs);
|
|
} while (count($jobs) === $limit);
|
|
} catch (\Throwable) {
|
|
return $overlay;
|
|
}
|
|
|
|
return $overlay;
|
|
}
|
|
|
|
private static function normalizeCollectedInvoiceQueueJob(array $job, string $dateFrom, string $dateTo): ?array
|
|
{
|
|
$invoiceCollectionId = (int)(
|
|
$job['payload']['collected_invoice_id']
|
|
?? $job['collected_invoice_id']
|
|
?? $job['invoice_collection_id']
|
|
?? 0
|
|
);
|
|
|
|
if ($invoiceCollectionId < 1) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$invoiceCollection = (new collected_order_invoices_o())->select($invoiceCollectionId);
|
|
if (!$invoiceCollection->exists()) {
|
|
return null;
|
|
}
|
|
|
|
$customerNumber = (int)$invoiceCollection->customer_number->value();
|
|
$closedAt = (string)$invoiceCollection->closed_at->value();
|
|
$createdAt = (string)$invoiceCollection->created_at->value();
|
|
|
|
return [
|
|
'queue_job_id' => (int)($job['id'] ?? $job['queue_job_id'] ?? 0),
|
|
'queue_status' => (string)($job['status'] ?? $job['queue_status'] ?? ''),
|
|
'invoice_collection_id' => $invoiceCollectionId,
|
|
'customer_number' => $customerNumber,
|
|
'created_at' => $createdAt,
|
|
'closed_at' => $closedAt,
|
|
'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod($createdAt, $closedAt, $dateFrom, $dateTo),
|
|
];
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private static function isInvoiceCollectionRelevantToPeriod(
|
|
?string $createdAt,
|
|
?string $closedAt,
|
|
string $dateFrom,
|
|
string $dateTo
|
|
): bool {
|
|
return self::isTimestampWithinPeriod($closedAt, $dateFrom, $dateTo)
|
|
|| self::isTimestampWithinPeriod($createdAt, $dateFrom, $dateTo);
|
|
}
|
|
|
|
private static function isTimestampWithinPeriod(?string $timestamp, string $dateFrom, string $dateTo): bool
|
|
{
|
|
if (empty($timestamp)) {
|
|
return false;
|
|
}
|
|
|
|
$normalizedTimestamp = substr((string)$timestamp, 0, 10);
|
|
return $normalizedTimestamp >= $dateFrom && $normalizedTimestamp <= $dateTo;
|
|
}
|
|
|
|
private static function collectedOrderInvoicesHasDeletedAtColumn(): bool
|
|
{
|
|
if (self::$collectedOrderInvoicesHasDeletedAtColumn !== null) {
|
|
return self::$collectedOrderInvoicesHasDeletedAtColumn;
|
|
}
|
|
|
|
global $db;
|
|
|
|
try {
|
|
$result = $db->query("SHOW COLUMNS FROM `collected_order_invoices` LIKE 'deleted_at'");
|
|
self::$collectedOrderInvoicesHasDeletedAtColumn = $result !== false && (int)$result->num_rows > 0;
|
|
} catch (\Throwable) {
|
|
self::$collectedOrderInvoicesHasDeletedAtColumn = false;
|
|
}
|
|
|
|
return self::$collectedOrderInvoicesHasDeletedAtColumn;
|
|
}
|
|
|
|
private static function applyCollectedInvoiceQueueOverlayToPeriodTypes(
|
|
array $types,
|
|
array $queueJobsByCollectionId,
|
|
array $queueJobsByCustomerNumber
|
|
): array {
|
|
foreach ($types as $type => $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
|
|
$types[$type] = array_map(function ($customer) use ($queueJobsByCollectionId, $queueJobsByCustomerNumber) {
|
|
if (!is_array($customer)) {
|
|
return $customer;
|
|
}
|
|
|
|
return self::applyCollectedInvoiceQueueOverlayToCustomer(
|
|
$customer,
|
|
$queueJobsByCollectionId,
|
|
$queueJobsByCustomerNumber
|
|
);
|
|
}, $customers);
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function applyCollectedInvoiceQueueOverlayToCustomer(
|
|
array $customer,
|
|
array $queueJobsByCollectionId,
|
|
array $queueJobsByCustomerNumber
|
|
): array {
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
$activeCustomerJobs = array_values(array_filter(
|
|
$queueJobsByCustomerNumber[$customerNumber] ?? [],
|
|
static function ($job): bool {
|
|
return !empty($job['is_period_relevant']);
|
|
}
|
|
));
|
|
|
|
$transactions = [];
|
|
$actionableTransactionCount = 0;
|
|
$queuedActionableTransactionCount = 0;
|
|
|
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
|
if (!is_array($transaction)) {
|
|
continue;
|
|
}
|
|
|
|
$transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0
|
|
? (int)$transaction['invoice_collection_id']
|
|
: null;
|
|
$transaction['queue_status'] = $transaction['queue_status'] ?? null;
|
|
$transaction['queue_job_id'] = $transaction['queue_job_id'] ?? null;
|
|
|
|
$isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false);
|
|
if ($isActionable) {
|
|
$actionableTransactionCount++;
|
|
}
|
|
|
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
|
if ($invoiceCollectionId > 0 && isset($queueJobsByCollectionId[$invoiceCollectionId])) {
|
|
$queueJob = $queueJobsByCollectionId[$invoiceCollectionId];
|
|
$transaction['queue_status'] = $queueJob['queue_status'] ?? null;
|
|
$transaction['queue_job_id'] = $queueJob['queue_job_id'] ?? null;
|
|
|
|
if ($isActionable) {
|
|
$queuedActionableTransactionCount++;
|
|
}
|
|
}
|
|
|
|
$transactions[] = $transaction;
|
|
}
|
|
|
|
$customerLevelQueueBlock = false;
|
|
if ($actionableTransactionCount === 0 && self::customerSupportsCustomerLevelQueueBlocking($customer) && !empty($activeCustomerJobs)) {
|
|
$customerLevelQueueBlock = true;
|
|
}
|
|
|
|
$isActionBlocked = false;
|
|
if ($actionableTransactionCount > 0) {
|
|
$isActionBlocked = $queuedActionableTransactionCount > 0
|
|
&& $queuedActionableTransactionCount === $actionableTransactionCount;
|
|
} elseif ($customerLevelQueueBlock) {
|
|
$isActionBlocked = true;
|
|
}
|
|
|
|
$statuses = [];
|
|
$invoiceCollectionIds = [];
|
|
foreach ($activeCustomerJobs as $job) {
|
|
$status = (string)($job['queue_status'] ?? '');
|
|
if ($status !== '' && !in_array($status, $statuses, true)) {
|
|
$statuses[] = $status;
|
|
}
|
|
|
|
$invoiceCollectionId = (int)($job['invoice_collection_id'] ?? 0);
|
|
if ($invoiceCollectionId > 0 && !in_array($invoiceCollectionId, $invoiceCollectionIds, true)) {
|
|
$invoiceCollectionIds[] = $invoiceCollectionId;
|
|
}
|
|
}
|
|
|
|
$customer['transactions'] = $transactions;
|
|
$customer['queue'] = [
|
|
'has_active_job' => !empty($activeCustomerJobs),
|
|
'statuses' => $statuses,
|
|
'invoice_collection_ids' => $invoiceCollectionIds,
|
|
'is_action_blocked' => $isActionBlocked,
|
|
];
|
|
|
|
if ($isActionBlocked) {
|
|
$customer['requires_action'] = false;
|
|
}
|
|
|
|
return $customer;
|
|
}
|
|
|
|
private static function getValidCollectedInvoiceDraftOverlay(array $types, string $dateFrom, string $dateTo): array
|
|
{
|
|
$overlay = [
|
|
'by_collection_id' => [],
|
|
'by_customer_number' => [],
|
|
];
|
|
|
|
$candidateInvoiceCollectionIds = [];
|
|
$customerLevelCandidateNumbers = [];
|
|
|
|
foreach ($types as $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($customers as $customer) {
|
|
if (!is_array($customer)) {
|
|
continue;
|
|
}
|
|
|
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
|
if ($invoiceCollectionId > 0) {
|
|
$candidateInvoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
|
}
|
|
}
|
|
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
if ($customerNumber > 0 && self::customerSupportsCustomerLevelQueueBlocking($customer)) {
|
|
$customerLevelCandidateNumbers[$customerNumber] = $customerNumber;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($candidateInvoiceCollectionIds) && empty($customerLevelCandidateNumbers)) {
|
|
return $overlay;
|
|
}
|
|
|
|
global $db;
|
|
|
|
try {
|
|
$whereCandidates = [];
|
|
if (!empty($candidateInvoiceCollectionIds)) {
|
|
$whereCandidates[] = 'id IN (' . implode(',', array_map('intval', array_values($candidateInvoiceCollectionIds))) . ')';
|
|
}
|
|
if (!empty($customerLevelCandidateNumbers)) {
|
|
$dateFromEscaped = $db->escape_string($dateFrom);
|
|
$dateToEscaped = $db->escape_string($dateTo);
|
|
$whereCandidates[] = '(customer_number IN (' . implode(',', array_map('intval', array_values($customerLevelCandidateNumbers))) . ')
|
|
AND (
|
|
DATE(closed_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\'
|
|
OR DATE(created_at) BETWEEN \'' . $dateFromEscaped . '\' AND \'' . $dateToEscaped . '\'
|
|
))';
|
|
}
|
|
|
|
if (!defined('\objects\ECONOMIC_PROCESSOR')) {
|
|
class_exists(collected_order_invoices_o::class);
|
|
}
|
|
$processor = defined('\objects\ECONOMIC_PROCESSOR')
|
|
? (int)constant('\objects\ECONOMIC_PROCESSOR')
|
|
: 1;
|
|
$deletedAtFilter = self::collectedOrderInvoicesHasDeletedAtColumn()
|
|
? 'deleted_at IS NULL
|
|
AND '
|
|
: '';
|
|
$sql = "SELECT id, customer_number, created_at, closed_at
|
|
FROM collected_order_invoices
|
|
WHERE {$deletedAtFilter}processor = $processor
|
|
AND external_id IS NOT NULL
|
|
AND external_id <> ''
|
|
AND booked_invoice_id IS NULL
|
|
AND error_message IS NULL
|
|
AND (" . implode(' OR ', $whereCandidates) . ")";
|
|
|
|
$result = $db->query($sql);
|
|
if (!$result) {
|
|
return $overlay;
|
|
}
|
|
|
|
while ($row = $result->fetch_assoc()) {
|
|
$invoiceCollectionId = (int)($row['id'] ?? 0);
|
|
$customerNumber = (int)($row['customer_number'] ?? 0);
|
|
if ($invoiceCollectionId < 1 || $customerNumber < 1) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedDraft = [
|
|
'invoice_collection_id' => $invoiceCollectionId,
|
|
'customer_number' => $customerNumber,
|
|
'created_at' => (string)($row['created_at'] ?? ''),
|
|
'closed_at' => (string)($row['closed_at'] ?? ''),
|
|
'is_period_relevant' => self::isInvoiceCollectionRelevantToPeriod(
|
|
(string)($row['created_at'] ?? ''),
|
|
(string)($row['closed_at'] ?? ''),
|
|
$dateFrom,
|
|
$dateTo
|
|
),
|
|
];
|
|
|
|
$overlay['by_collection_id'][$invoiceCollectionId] = $normalizedDraft;
|
|
$overlay['by_customer_number'][$customerNumber] = $overlay['by_customer_number'][$customerNumber] ?? [];
|
|
$overlay['by_customer_number'][$customerNumber][] = $normalizedDraft;
|
|
}
|
|
} catch (\Throwable) {
|
|
return $overlay;
|
|
}
|
|
|
|
return $overlay;
|
|
}
|
|
|
|
private static function applyCollectedInvoiceDraftOverlayToPeriodTypes(
|
|
array $types,
|
|
array $draftsByCollectionId,
|
|
array $draftsByCustomerNumber
|
|
): array {
|
|
foreach ($types as $type => $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
|
|
$types[$type] = array_map(function ($customer) use ($draftsByCollectionId, $draftsByCustomerNumber) {
|
|
if (!is_array($customer)) {
|
|
return $customer;
|
|
}
|
|
|
|
return self::applyCollectedInvoiceDraftOverlayToCustomer(
|
|
$customer,
|
|
$draftsByCollectionId,
|
|
$draftsByCustomerNumber
|
|
);
|
|
}, $customers);
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function applyCollectedInvoiceDraftOverlayToCustomer(
|
|
array $customer,
|
|
array $draftsByCollectionId,
|
|
array $draftsByCustomerNumber
|
|
): array {
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
$activeCustomerDrafts = array_values(array_filter(
|
|
$draftsByCustomerNumber[$customerNumber] ?? [],
|
|
static function ($draft): bool {
|
|
return !empty($draft['is_period_relevant']);
|
|
}
|
|
));
|
|
|
|
$transactions = [];
|
|
$actionableTransactionCount = 0;
|
|
$coveredActionableTransactionCount = 0;
|
|
$queuedActionableTransactionCount = 0;
|
|
$draftActionableTransactionCount = 0;
|
|
$invoiceCollectionIds = [];
|
|
|
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
|
if (!is_array($transaction)) {
|
|
continue;
|
|
}
|
|
|
|
$transaction['invoice_collection_id'] = isset($transaction['invoice_collection_id']) && (int)$transaction['invoice_collection_id'] > 0
|
|
? (int)$transaction['invoice_collection_id']
|
|
: null;
|
|
|
|
$isActionable = !($transaction['booked'] ?? false) && !($transaction['excluded'] ?? false);
|
|
if (!$isActionable) {
|
|
$transactions[] = $transaction;
|
|
continue;
|
|
}
|
|
|
|
$actionableTransactionCount++;
|
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
|
$isQueued = !empty($transaction['queue_status']);
|
|
$isDraft = $invoiceCollectionId > 0 && isset($draftsByCollectionId[$invoiceCollectionId]);
|
|
|
|
if ($isQueued || $isDraft) {
|
|
$coveredActionableTransactionCount++;
|
|
}
|
|
if ($isQueued) {
|
|
$queuedActionableTransactionCount++;
|
|
}
|
|
if ($isDraft) {
|
|
$draftActionableTransactionCount++;
|
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
|
}
|
|
|
|
$transactions[] = $transaction;
|
|
}
|
|
|
|
foreach ($activeCustomerDrafts as $draft) {
|
|
$invoiceCollectionId = (int)($draft['invoice_collection_id'] ?? 0);
|
|
if ($invoiceCollectionId > 0) {
|
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
|
}
|
|
}
|
|
|
|
$isDraftActionBlocked = false;
|
|
$queue = is_array($customer['queue'] ?? null) ? $customer['queue'] : self::getDefaultQueueSummary();
|
|
if ($actionableTransactionCount > 0 && $coveredActionableTransactionCount === $actionableTransactionCount) {
|
|
if ($queuedActionableTransactionCount > 0) {
|
|
$queue['is_action_blocked'] = true;
|
|
} elseif ($draftActionableTransactionCount > 0) {
|
|
$isDraftActionBlocked = true;
|
|
}
|
|
} elseif (
|
|
$actionableTransactionCount === 0
|
|
&& self::customerSupportsCustomerLevelQueueBlocking($customer)
|
|
&& !empty($activeCustomerDrafts)
|
|
&& empty($queue['is_action_blocked'])
|
|
) {
|
|
$isDraftActionBlocked = true;
|
|
}
|
|
|
|
$customer['transactions'] = $transactions;
|
|
$customer['queue'] = $queue;
|
|
$customer['draft'] = [
|
|
'has_valid_draft' => !empty($invoiceCollectionIds),
|
|
'invoice_collection_ids' => array_values($invoiceCollectionIds),
|
|
'is_action_blocked' => $isDraftActionBlocked,
|
|
];
|
|
|
|
if ($isDraftActionBlocked || !empty($queue['is_action_blocked'])) {
|
|
$customer['requires_action'] = false;
|
|
}
|
|
|
|
return $customer;
|
|
}
|
|
|
|
/**
|
|
* Fetch the collected-invoice rows referenced by the complete period payload in one local query.
|
|
* No e-conomic calls are allowed from the period endpoint.
|
|
*
|
|
* @return array<int,array<string,mixed>> Rows keyed by collected invoice ID.
|
|
*/
|
|
private static function getPeriodInvoiceCollectionMetadata(array $types): array
|
|
{
|
|
global $db;
|
|
|
|
$invoiceCollectionIds = [];
|
|
foreach ($types as $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($customers as $customer) {
|
|
if (!is_array($customer)) {
|
|
continue;
|
|
}
|
|
|
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
|
if ($invoiceCollectionId > 0) {
|
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
|
}
|
|
}
|
|
|
|
foreach (['queue', 'draft'] as $summaryKey) {
|
|
foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) {
|
|
$invoiceCollectionId = (int)$invoiceCollectionId;
|
|
if ($invoiceCollectionId > 0) {
|
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($invoiceCollectionIds)) {
|
|
return [];
|
|
}
|
|
|
|
$sql = 'SELECT id, customer_number, name, notes, processor, external_id, booked_invoice_id, '
|
|
. 'po_number, error_message, closed_at, created_at, updated_at '
|
|
. 'FROM collected_order_invoices '
|
|
. 'WHERE id IN (' . implode(',', array_map('intval', array_values($invoiceCollectionIds))) . ') '
|
|
. 'ORDER BY id';
|
|
|
|
try {
|
|
$result = $db->query($sql);
|
|
if (!$result) {
|
|
return [];
|
|
}
|
|
|
|
$metadata = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$invoiceCollectionId = (int)($row['id'] ?? 0);
|
|
if ($invoiceCollectionId < 1) {
|
|
continue;
|
|
}
|
|
|
|
$row['id'] = $invoiceCollectionId;
|
|
$row['invoice_collection_id'] = $invoiceCollectionId;
|
|
$row['customer_number'] = (int)($row['customer_number'] ?? 0);
|
|
$row['processor'] = (int)($row['processor'] ?? 0);
|
|
$row['booked_invoice_id'] = !empty($row['booked_invoice_id'])
|
|
? (int)$row['booked_invoice_id']
|
|
: null;
|
|
$row['state'] = self::periodInvoiceCollectionState($row);
|
|
$metadata[$invoiceCollectionId] = $row;
|
|
}
|
|
|
|
return $metadata;
|
|
} catch (\Throwable) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static function periodInvoiceCollectionState(array $invoiceCollection): string
|
|
{
|
|
if (!empty($invoiceCollection['booked_invoice_id'])) {
|
|
return 'economic_booked';
|
|
}
|
|
|
|
if (!defined('\\objects\\ECONOMIC_PROCESSOR')) {
|
|
class_exists(collected_order_invoices_o::class);
|
|
}
|
|
$economicProcessor = defined('\\objects\\ECONOMIC_PROCESSOR')
|
|
? (int)constant('\\objects\\ECONOMIC_PROCESSOR')
|
|
: 1;
|
|
if (
|
|
(int)($invoiceCollection['processor'] ?? 0) === $economicProcessor
|
|
&& trim((string)($invoiceCollection['external_id'] ?? '')) !== ''
|
|
&& trim((string)($invoiceCollection['error_message'] ?? '')) === ''
|
|
) {
|
|
return 'economic_draft';
|
|
}
|
|
|
|
return !empty($invoiceCollection['closed_at']) ? 'closed' : 'open';
|
|
}
|
|
|
|
private static function applyPeriodInvoiceStateOverlayToTypes(array $types, array $invoiceCollectionsById): array
|
|
{
|
|
foreach ($types as $type => $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
|
|
$types[$type] = array_map(static function ($customer) use ($invoiceCollectionsById) {
|
|
if (!is_array($customer)) {
|
|
return $customer;
|
|
}
|
|
|
|
return self::applyPeriodInvoiceStateOverlayToCustomer($customer, $invoiceCollectionsById);
|
|
}, $customers);
|
|
}
|
|
|
|
return $types;
|
|
}
|
|
|
|
private static function applyPeriodInvoiceStateOverlayToCustomer(array $customer, array $invoiceCollectionsById): array
|
|
{
|
|
$customerNumber = (int)($customer['customer_number'] ?? 0);
|
|
$collectionStats = [];
|
|
$invoiceCollectionIds = [];
|
|
$transactions = [];
|
|
|
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
|
if (!is_array($transaction)) {
|
|
continue;
|
|
}
|
|
|
|
$invoiceCollectionId = (int)($transaction['invoice_collection_id'] ?? 0);
|
|
$invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId] ?? null;
|
|
$canUseCollection = is_array($invoiceCollection)
|
|
&& (int)($invoiceCollection['customer_number'] ?? 0) === $customerNumber;
|
|
|
|
if ($canUseCollection) {
|
|
$transaction['invoice_state'] = (string)$invoiceCollection['state'];
|
|
$transaction['booked'] = $transaction['invoice_state'] === 'economic_booked';
|
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
|
$collectionStats[$invoiceCollectionId] = $collectionStats[$invoiceCollectionId] ?? [
|
|
'order_ids' => [],
|
|
'total_net_amount' => 0.0,
|
|
];
|
|
$orderId = (int)($transaction['id'] ?? 0);
|
|
if ($orderId > 0) {
|
|
$collectionStats[$invoiceCollectionId]['order_ids'][$orderId] = $orderId;
|
|
}
|
|
$collectionStats[$invoiceCollectionId]['total_net_amount'] += (float)($transaction['amount'] ?? 0);
|
|
} else {
|
|
$transaction['invoice_state'] = (string)($transaction['invoice_state'] ?? self::periodOrderState(
|
|
(bool)($transaction['booked'] ?? false),
|
|
!empty($transaction['completed_at']) ? (string)$transaction['completed_at'] : null
|
|
));
|
|
}
|
|
|
|
$transactions[] = $transaction;
|
|
}
|
|
|
|
foreach (['queue', 'draft'] as $summaryKey) {
|
|
foreach (($customer[$summaryKey]['invoice_collection_ids'] ?? []) as $invoiceCollectionId) {
|
|
$invoiceCollectionId = (int)$invoiceCollectionId;
|
|
if (
|
|
$invoiceCollectionId > 0
|
|
&& isset($invoiceCollectionsById[$invoiceCollectionId])
|
|
&& (int)($invoiceCollectionsById[$invoiceCollectionId]['customer_number'] ?? 0) === $customerNumber
|
|
) {
|
|
$invoiceCollectionIds[$invoiceCollectionId] = $invoiceCollectionId;
|
|
}
|
|
}
|
|
}
|
|
|
|
$invoiceCollections = [];
|
|
foreach ($invoiceCollectionIds as $invoiceCollectionId) {
|
|
$invoiceCollection = $invoiceCollectionsById[$invoiceCollectionId];
|
|
$stats = $collectionStats[$invoiceCollectionId] ?? [
|
|
'order_ids' => [],
|
|
'total_net_amount' => 0.0,
|
|
];
|
|
$orderIds = array_values($stats['order_ids']);
|
|
$invoiceCollection['order_ids'] = $orderIds;
|
|
$invoiceCollection['order_count'] = count($orderIds);
|
|
$invoiceCollection['total_net_amount'] = (float)$stats['total_net_amount'];
|
|
$invoiceCollections[] = $invoiceCollection;
|
|
}
|
|
|
|
$customer['transactions'] = $transactions;
|
|
$customer['invoice_collections'] = $invoiceCollections;
|
|
return $customer;
|
|
}
|
|
|
|
/**
|
|
* @return array<int,array<int,array<string,mixed>>>
|
|
*/
|
|
private static function getInvoicePeriodTreeSnapshotRows(
|
|
array $invoiceCollectionIds,
|
|
?int $customerNumber = null,
|
|
?string $dateFrom = null,
|
|
?string $dateTo = null
|
|
): array
|
|
{
|
|
global $db;
|
|
|
|
$invoiceCollectionIds = array_values(array_unique(array_filter(array_map('intval', $invoiceCollectionIds))));
|
|
if ($invoiceCollectionIds === [] && ($customerNumber === null || $dateFrom === null || $dateTo === null)) {
|
|
return [];
|
|
}
|
|
$collectionCondition = $invoiceCollectionIds === []
|
|
? '0 = 1'
|
|
: 'o.invoice_collection_id IN (' . implode(',', $invoiceCollectionIds) . ')';
|
|
if ($customerNumber !== null && $customerNumber > 0 && $dateFrom !== null && $dateTo !== null) {
|
|
$safeFrom = $db->escape_string($dateFrom);
|
|
$safeTo = $db->escape_string($dateTo);
|
|
$collectionCondition = '(' . $collectionCondition . ")
|
|
OR (
|
|
o.customer_id = {$customerNumber}
|
|
AND (o.invoice_collection_id IS NULL OR o.invoice_collection_id = 0)
|
|
AND o.created_at BETWEEN '{$safeFrom}' AND '{$safeTo}'
|
|
)";
|
|
}
|
|
|
|
$sql = "SELECT
|
|
o.id AS order_id,
|
|
o.invoice_collection_id,
|
|
o.customer_id,
|
|
o.reference,
|
|
o.notes,
|
|
o.department_id,
|
|
o.reg_1,
|
|
o.reg_2,
|
|
o.reg_3,
|
|
o.created_at,
|
|
o.completed_at,
|
|
o.include_in_invoice AS order_include_in_invoice,
|
|
o.po,
|
|
o.safety_seal,
|
|
o.booking_id,
|
|
o.wash_id,
|
|
oi.id AS order_item_id,
|
|
oi.product_id,
|
|
oi.reference AS order_item_reference,
|
|
oi.notes AS order_item_notes,
|
|
oi.price,
|
|
oi.quantity,
|
|
oi.related_item_id,
|
|
oi.include_in_invoice AS order_item_include_in_invoice,
|
|
p.name AS product_name
|
|
FROM orders o
|
|
LEFT JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL
|
|
LEFT JOIN products p ON p.id = oi.product_id
|
|
WHERE ({$collectionCondition})
|
|
AND o.deleted_at IS NULL
|
|
ORDER BY o.invoice_collection_id ASC, o.id ASC, oi.id ASC";
|
|
|
|
$result = $db->query($sql);
|
|
if (!$result) {
|
|
throw new \RuntimeException('Failed to load the complete invoice-period collection order tree.');
|
|
}
|
|
|
|
$rowsByCollection = [];
|
|
while ($row = $result->fetch_assoc()) {
|
|
$collectionId = (int)($row['invoice_collection_id'] ?? 0);
|
|
$rowsByCollection[$collectionId > 0 ? $collectionId : 0][] = $row;
|
|
}
|
|
return $rowsByCollection;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,array<string,mixed>> $rows
|
|
* @return array<int,array<string,mixed>>
|
|
*/
|
|
private static function buildInvoicePeriodTreeSnapshotOrders(array $rows, int $customerNumber): array
|
|
{
|
|
$orders = [];
|
|
foreach ($rows as $row) {
|
|
if ((int)($row['customer_id'] ?? 0) !== $customerNumber) {
|
|
continue;
|
|
}
|
|
$orderId = (int)($row['order_id'] ?? 0);
|
|
if ($orderId < 1) {
|
|
continue;
|
|
}
|
|
if (!isset($orders[$orderId])) {
|
|
$completedAt = !empty($row['completed_at']) ? (string)$row['completed_at'] : null;
|
|
$orders[$orderId] = [
|
|
'id' => $orderId,
|
|
'date' => (string)($row['created_at'] ?? ''),
|
|
'amount' => 0.0,
|
|
'booked' => false,
|
|
'department_id' => (int)($row['department_id'] ?? 0),
|
|
'customer_number' => (int)($row['customer_id'] ?? 0),
|
|
'reference' => (string)($row['reference'] ?? ''),
|
|
'po' => (string)($row['po'] ?? ''),
|
|
'notes' => (string)($row['notes'] ?? ''),
|
|
'reg_1' => (string)($row['reg_1'] ?? ''),
|
|
'reg_2' => (string)($row['reg_2'] ?? ''),
|
|
'reg_3' => (string)($row['reg_3'] ?? ''),
|
|
'completed_at' => $completedAt,
|
|
'excluded' => (int)($row['order_include_in_invoice'] ?? 1) !== 1,
|
|
'invoice_collection_id' => (int)($row['invoice_collection_id'] ?? 0),
|
|
'booking_id' => (int)($row['booking_id'] ?? 0) ?: null,
|
|
'wash_id' => !empty($row['wash_id']) ? (string)$row['wash_id'] : null,
|
|
'safety_seal' => (string)($row['safety_seal'] ?? ''),
|
|
'invoice_state' => self::periodOrderState(false, $completedAt),
|
|
'queue_status' => null,
|
|
'queue_job_id' => null,
|
|
'order_items' => [],
|
|
];
|
|
}
|
|
$itemId = (int)($row['order_item_id'] ?? 0);
|
|
if ($itemId > 0) {
|
|
$quantity = (float)($row['quantity'] ?? 0);
|
|
$price = (float)($row['price'] ?? 0);
|
|
$includeInInvoice = (int)($row['order_item_include_in_invoice'] ?? 1) === 1;
|
|
$orders[$orderId]['order_items'][] = [
|
|
'id' => $itemId,
|
|
'order_item_id' => $itemId,
|
|
'order_id' => $orderId,
|
|
'product_id' => (int)($row['product_id'] ?? 0),
|
|
'product_name' => (string)($row['product_name'] ?? ''),
|
|
'reference' => (string)($row['order_item_reference'] ?? ''),
|
|
'notes' => (string)($row['order_item_notes'] ?? ''),
|
|
'price' => $price,
|
|
'quantity' => $quantity,
|
|
'related_item_id' => !empty($row['related_item_id']) ? (int)$row['related_item_id'] : null,
|
|
'include_in_invoice' => $includeInInvoice ? 1 : 0,
|
|
];
|
|
if ($includeInInvoice) {
|
|
$orders[$orderId]['amount'] += $price * $quantity;
|
|
}
|
|
}
|
|
}
|
|
|
|
return array_values($orders);
|
|
}
|
|
|
|
private function buildInvoicePeriodTreeSnapshot(
|
|
int $customerNumber,
|
|
string $dateFrom,
|
|
string $dateTo,
|
|
int $actorUserId
|
|
): array {
|
|
$previousSideEffectSuppression = self::$suppressInvoicePeriodExternalEffects;
|
|
self::$suppressInvoicePeriodExternalEffects = true;
|
|
try {
|
|
$period = self::getInvoicingPeriod(
|
|
$dateFrom,
|
|
$dateTo,
|
|
[$customerNumber],
|
|
$this->hasPermission('list_invoice_period_flags')
|
|
);
|
|
} finally {
|
|
self::$suppressInvoicePeriodExternalEffects = $previousSideEffectSuppression;
|
|
}
|
|
$customer = self::mergeInvoicePeriodTreeCustomer($period['types'] ?? [], $customerNumber);
|
|
$collectionMetadata = self::invoicePeriodTreeCollectionMetadata(
|
|
$period['types'] ?? [],
|
|
$customerNumber
|
|
);
|
|
$collectionIds = array_keys($collectionMetadata);
|
|
$rowsByCollection = self::getInvoicePeriodTreeSnapshotRows(
|
|
$collectionIds,
|
|
$customerNumber,
|
|
$dateFrom,
|
|
$dateTo
|
|
);
|
|
|
|
$ordersByCollection = [];
|
|
foreach ($collectionIds as $collectionId) {
|
|
$ordersByCollection[$collectionId] = self::buildInvoicePeriodTreeSnapshotOrders(
|
|
$rowsByCollection[$collectionId] ?? [],
|
|
$customerNumber
|
|
);
|
|
}
|
|
$uncollectedOrders = self::buildInvoicePeriodTreeSnapshotOrders(
|
|
$rowsByCollection[0] ?? [],
|
|
$customerNumber
|
|
);
|
|
self::enrichInvoicePeriodTreeOrders($ordersByCollection, $uncollectedOrders);
|
|
|
|
$collections = [];
|
|
foreach ($collectionMetadata as $collectionId => $metadata) {
|
|
$orders = $ordersByCollection[$collectionId] ?? [];
|
|
foreach ($orders as &$order) {
|
|
$createdAt = (string)($order['date'] ?? '');
|
|
$order['in_selected_period'] = $createdAt >= $dateFrom && $createdAt <= $dateTo;
|
|
$order['total_net_amount'] = (float)($order['amount'] ?? 0);
|
|
$order['items'] = $order['order_items'] ?? [];
|
|
unset($order['order_items']);
|
|
}
|
|
unset($order);
|
|
$periodOrders = array_values(array_filter(
|
|
$orders,
|
|
static fn(array $order): bool => !empty($order['in_selected_period'])
|
|
));
|
|
$collectionObject = (new collected_order_invoices_o())->select((int)$collectionId);
|
|
$supersession = $collectionObject->exists()
|
|
? $collectionObject->getSupersessionMetadata()
|
|
: null;
|
|
$notes = preg_replace(
|
|
'/\n?\[\[invoice_collection_superseded:(\{.*?\})\]\]/',
|
|
'',
|
|
(string)($metadata['notes'] ?? '')
|
|
);
|
|
$collections[] = [
|
|
'id' => (int)$collectionId,
|
|
'customer_number' => $customerNumber,
|
|
'name' => (string)($metadata['name'] ?? ''),
|
|
'notes' => rtrim((string)$notes),
|
|
'processor' => (int)($metadata['processor'] ?? 0),
|
|
'external_id' => !empty($metadata['external_id']) ? (string)$metadata['external_id'] : null,
|
|
'booked_invoice_id' => (int)($metadata['booked_invoice_id'] ?? 0) ?: null,
|
|
'po_number' => !empty($metadata['po_number']) ? (string)$metadata['po_number'] : null,
|
|
'error_message' => !empty($metadata['error_message']) ? (string)$metadata['error_message'] : null,
|
|
'closed_at' => !empty($metadata['closed_at']) ? (string)$metadata['closed_at'] : null,
|
|
'created_at' => $metadata['created_at'] ?? null,
|
|
'updated_at' => $metadata['updated_at'] ?? null,
|
|
'state' => (string)($metadata['state'] ?? self::periodInvoiceCollectionState($metadata)),
|
|
'in_selected_period' => $periodOrders !== [],
|
|
'complete_order_count' => count($orders),
|
|
'complete_total_net_amount' => array_sum(array_column($orders, 'total_net_amount')),
|
|
'period_order_count' => count($periodOrders),
|
|
'period_total_net_amount' => array_sum(array_column($periodOrders, 'total_net_amount')),
|
|
'superseded_by_invoice_collection_id' => $supersession['target_invoice_collection_id'] ?? null,
|
|
'superseded_by_user_id' => $supersession['superseded_by_user_id'] ?? null,
|
|
'superseded_at' => $supersession['superseded_at'] ?? null,
|
|
'orders' => $orders,
|
|
];
|
|
}
|
|
foreach ($uncollectedOrders as &$order) {
|
|
$order['in_selected_period'] = true;
|
|
$order['total_net_amount'] = (float)($order['amount'] ?? 0);
|
|
$order['items'] = $order['order_items'] ?? [];
|
|
unset($order['order_items']);
|
|
}
|
|
unset($order);
|
|
|
|
$binding = (new invoice_collection_bulk_action_service())->createSnapshotBinding(
|
|
$actorUserId,
|
|
$customerNumber,
|
|
substr($dateFrom, 0, 10),
|
|
substr($dateTo, 0, 10),
|
|
array_map(static fn(array $collection): int => (int)$collection['id'], $collections)
|
|
);
|
|
$agreements = self::invoicePeriodTreeAgreements($period['types'] ?? [], $customerNumber);
|
|
$payments = [];
|
|
foreach ([...array_values($ordersByCollection), $uncollectedOrders] as $orderGroup) {
|
|
foreach ($orderGroup as $order) {
|
|
foreach (($order['payments'] ?? []) as $payment) {
|
|
$payment['invoice_collection_id'] = (int)($order['invoice_collection_id'] ?? 0) ?: null;
|
|
$payments[] = $payment;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'complete' => true,
|
|
'snapshot_revision' => (string)$binding['snapshot_revision'],
|
|
'customer_number' => $customerNumber,
|
|
// Keep the public snapshot identity aligned with the Y-m-d request
|
|
// contract; full-day timestamps are internal query boundaries.
|
|
'date_from' => substr($dateFrom, 0, 10),
|
|
'date_to' => substr($dateTo, 0, 10),
|
|
'capabilities' => [
|
|
'object_tree_v2' => true,
|
|
'actions' => [
|
|
invoice_collection_bulk_action_service::ACTION_CLEAN_CUSTOMER_RULES =>
|
|
$this->hasPermission('reset_collected_invoice_economic'),
|
|
invoice_collection_bulk_action_service::ACTION_MERGE =>
|
|
$this->hasPermission('move_collected_invoice'),
|
|
invoice_collection_bulk_action_service::ACTION_SPLIT_BY_MONTH =>
|
|
$this->hasPermission('split_collected_invoice'),
|
|
invoice_collection_bulk_action_service::ACTION_RESET_HIDDEN_PRICES =>
|
|
$this->hasPermission('reset_collected_invoice_economic'),
|
|
invoice_collection_bulk_action_service::ACTION_QUEUE_ECONOMIC =>
|
|
$this->hasPermission('add_collected_invoice_economic'),
|
|
],
|
|
],
|
|
'customer' => $customer,
|
|
'collections' => $collections,
|
|
'uncollected_orders' => $uncollectedOrders,
|
|
'agreements' => $agreements,
|
|
'payments' => $payments,
|
|
'economic_invoices' => array_values(array_map(static fn(array $collection): array => [
|
|
'invoice_collection_id' => (int)$collection['id'],
|
|
'state' => (string)$collection['state'],
|
|
'external_id' => $collection['external_id'],
|
|
'booked_invoice_id' => $collection['booked_invoice_id'],
|
|
'available_type' => !empty($collection['booked_invoice_id'])
|
|
? 'booked'
|
|
: (!empty($collection['external_id']) ? 'draft' : null),
|
|
], $collections)),
|
|
];
|
|
}
|
|
|
|
private static function mergeInvoicePeriodTreeCustomer(array $types, int $customerNumber): array
|
|
{
|
|
$matches = [];
|
|
foreach ($types as $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
foreach ($customers as $customer) {
|
|
if (is_array($customer) && (int)($customer['customer_number'] ?? 0) === $customerNumber) {
|
|
$matches[] = $customer;
|
|
}
|
|
}
|
|
}
|
|
$customer = $matches[0] ?? [
|
|
'id' => null,
|
|
'customer_number' => $customerNumber,
|
|
'customer_name' => self::getLocalCustomerName($customerNumber),
|
|
'requires_action' => false,
|
|
'meta' => [],
|
|
];
|
|
foreach ($matches as $match) {
|
|
$customer['requires_action'] = !empty($customer['requires_action']) || !empty($match['requires_action']);
|
|
$customer['meta'] = array_replace_recursive(
|
|
is_array($customer['meta'] ?? null) ? $customer['meta'] : [],
|
|
is_array($match['meta'] ?? null) ? $match['meta'] : []
|
|
);
|
|
foreach (['flags', 'flag_counts', 'review', 'queue', 'draft'] as $key) {
|
|
if (!empty($match[$key])) {
|
|
$customer[$key] = $match[$key];
|
|
}
|
|
}
|
|
}
|
|
unset($customer['transactions'], $customer['invoice_collections'], $customer['tree_snapshot']);
|
|
$customer['capabilities']['object_tree_v2'] = true;
|
|
return $customer;
|
|
}
|
|
|
|
/** @return array<int,array<string,mixed>> */
|
|
private static function invoicePeriodTreeCollectionMetadata(array $types, int $customerNumber): array
|
|
{
|
|
$metadata = [];
|
|
foreach ($types as $customers) {
|
|
if (!is_array($customers)) {
|
|
continue;
|
|
}
|
|
foreach ($customers as $customer) {
|
|
if (!is_array($customer) || (int)($customer['customer_number'] ?? 0) !== $customerNumber) {
|
|
continue;
|
|
}
|
|
foreach (($customer['invoice_collections'] ?? []) as $collection) {
|
|
$id = (int)($collection['id'] ?? $collection['invoice_collection_id'] ?? 0);
|
|
if ($id > 0 && (int)($collection['customer_number'] ?? $customerNumber) === $customerNumber) {
|
|
$metadata[$id] = is_array($collection) ? $collection : [];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ksort($metadata, SORT_NUMERIC);
|
|
return $metadata;
|
|
}
|
|
|
|
private static function invoicePeriodTreeAgreements(array $types, int $customerNumber): array
|
|
{
|
|
$agreements = [];
|
|
foreach ($types as $type => $customers) {
|
|
if ($type === 'all' || !is_array($customers)) {
|
|
continue;
|
|
}
|
|
foreach ($customers as $customer) {
|
|
if (!is_array($customer) || (int)($customer['customer_number'] ?? 0) !== $customerNumber) {
|
|
continue;
|
|
}
|
|
$meta = is_array($customer['meta'] ?? null) ? $customer['meta'] : [];
|
|
if ($meta !== []) {
|
|
$agreements[] = [
|
|
'type' => (string)$type,
|
|
'requires_action' => (bool)($customer['requires_action'] ?? false),
|
|
'meta' => $meta,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
return $agreements;
|
|
}
|
|
|
|
/**
|
|
* Add locally available child domains without exposing attachment storage object names.
|
|
*
|
|
* @param array<int,array<int,array<string,mixed>>> $ordersByCollection
|
|
* @param array<int,array<string,mixed>> $uncollectedOrders
|
|
*/
|
|
private static function enrichInvoicePeriodTreeOrders(
|
|
array &$ordersByCollection,
|
|
array &$uncollectedOrders
|
|
): void {
|
|
global $db;
|
|
$orderIds = [];
|
|
foreach ($ordersByCollection as $orders) {
|
|
foreach ($orders as $order) {
|
|
$orderIds[(int)$order['id']] = (int)$order['id'];
|
|
}
|
|
}
|
|
foreach ($uncollectedOrders as $order) {
|
|
$orderIds[(int)$order['id']] = (int)$order['id'];
|
|
}
|
|
$orderIds = array_values(array_filter($orderIds));
|
|
if ($orderIds === []) {
|
|
return;
|
|
}
|
|
$ids = implode(',', $orderIds);
|
|
$attachments = [];
|
|
$attachmentResult = $db->query(
|
|
"SELECT id, object_id, content, created_at, updated_at
|
|
FROM object_attachments
|
|
WHERE object_id IN ({$ids})
|
|
AND object_type = 'orders'
|
|
AND deleted_at IS NULL
|
|
ORDER BY object_id ASC, id ASC"
|
|
);
|
|
if (!$attachmentResult) {
|
|
throw new \RuntimeException('Failed to load invoice-period tree attachments.');
|
|
}
|
|
if ($attachmentResult) {
|
|
while ($row = $attachmentResult->fetch_assoc()) {
|
|
$content = json_decode((string)($row['content'] ?? ''), true);
|
|
$content = is_array($content) ? $content : [];
|
|
$other = is_scalar($content['other'] ?? null) ? (string)$content['other'] : null;
|
|
$isWashCertificate = strtolower((string)$other) === 'wash_certificate';
|
|
$attachments[(int)$row['object_id']][] = [
|
|
'id' => (int)$row['id'],
|
|
'kind' => !empty($content['document'])
|
|
? 'document'
|
|
: (!empty($content['image']) ? 'image' : 'other'),
|
|
'filename' => !$isWashCertificate && $other !== '' ? $other : null,
|
|
'has_file' => !empty($content['document']) || !empty($content['image']),
|
|
'is_wash_certificate' => $isWashCertificate,
|
|
'created_at' => $row['created_at'] ?? null,
|
|
'updated_at' => $row['updated_at'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
|
|
$bookings = [];
|
|
$bookingResult = $db->query(
|
|
"SELECT id, customer_number, department, reg_1, reg_2, reg_3, datetime,
|
|
note, reference, po, pickup, items, order_id, created_at, updated_at
|
|
FROM order_bookings
|
|
WHERE order_id IN ({$ids}) AND deleted_at IS NULL
|
|
ORDER BY order_id ASC, id ASC"
|
|
);
|
|
if (!$bookingResult) {
|
|
throw new \RuntimeException('Failed to load invoice-period tree bookings.');
|
|
}
|
|
if ($bookingResult) {
|
|
while ($row = $bookingResult->fetch_assoc()) {
|
|
$items = json_decode((string)($row['items'] ?? ''), true);
|
|
$bookings[(int)$row['order_id']][] = [
|
|
'id' => (int)$row['id'],
|
|
'customer_number' => (int)$row['customer_number'],
|
|
'department_id' => (int)$row['department'],
|
|
'registrations' => array_values(array_filter([
|
|
(string)($row['reg_1'] ?? ''),
|
|
(string)($row['reg_2'] ?? ''),
|
|
(string)($row['reg_3'] ?? ''),
|
|
], static fn(string $reg): bool => $reg !== '')),
|
|
'datetime' => $row['datetime'] ?? null,
|
|
'note' => (string)($row['note'] ?? ''),
|
|
'reference' => (string)($row['reference'] ?? ''),
|
|
'po' => (string)($row['po'] ?? ''),
|
|
'pickup' => (bool)($row['pickup'] ?? false),
|
|
'items' => is_array($items) ? $items : [],
|
|
'created_at' => $row['created_at'] ?? null,
|
|
'updated_at' => $row['updated_at'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
|
|
$payments = [];
|
|
$economicResult = $db->query(
|
|
"SELECT id, invoice_draft_id, invoice_id, created_at, updated_at
|
|
FROM economic_module_orders WHERE id IN ({$ids})"
|
|
);
|
|
if (!$economicResult) {
|
|
throw new \RuntimeException('Failed to load invoice-period tree e-conomic links.');
|
|
}
|
|
if ($economicResult) {
|
|
while ($row = $economicResult->fetch_assoc()) {
|
|
$orderId = (int)$row['id'];
|
|
$payments[$orderId][] = [
|
|
'provider' => 'economic',
|
|
'order_id' => $orderId,
|
|
'state' => !empty($row['invoice_id'])
|
|
? 'booked'
|
|
: (!empty($row['invoice_draft_id']) ? 'draft' : 'unlinked'),
|
|
'invoice_draft_id' => (int)($row['invoice_draft_id'] ?? 0) ?: null,
|
|
'invoice_id' => (int)($row['invoice_id'] ?? 0) ?: null,
|
|
'created_at' => $row['created_at'] ?? null,
|
|
'updated_at' => $row['updated_at'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
$stripeResult = $db->query(
|
|
"SELECT smo.id, smo.invoice_id, smo.customer_id, smo.email_sent, smo.created_at,
|
|
spi.payment_intent_id
|
|
FROM stripe_module_orders smo
|
|
LEFT JOIN stripe_payment_intents spi ON spi.order_id = smo.id
|
|
WHERE smo.id IN ({$ids})"
|
|
);
|
|
if (!$stripeResult) {
|
|
throw new \RuntimeException('Failed to load invoice-period tree Stripe links.');
|
|
}
|
|
if ($stripeResult) {
|
|
while ($row = $stripeResult->fetch_assoc()) {
|
|
$orderId = (int)$row['id'];
|
|
$payments[$orderId][] = [
|
|
'provider' => 'stripe',
|
|
'order_id' => $orderId,
|
|
'state' => !empty($row['invoice_id']) || !empty($row['payment_intent_id'])
|
|
? 'linked'
|
|
: 'unlinked',
|
|
'invoice_id' => $row['invoice_id'] ?? null,
|
|
'payment_intent_id' => $row['payment_intent_id'] ?? null,
|
|
'email_sent_at' => $row['email_sent'] ?? null,
|
|
'created_at' => $row['created_at'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
|
|
$xlvask = [];
|
|
$washIds = [];
|
|
$allOrders = [];
|
|
foreach ($ordersByCollection as $orders) {
|
|
foreach ($orders as $order) {
|
|
$allOrders[] = $order;
|
|
}
|
|
}
|
|
$allOrders = [...$allOrders, ...$uncollectedOrders];
|
|
foreach ($allOrders as $order) {
|
|
if (!empty($order['wash_id'])) {
|
|
$washIds[(string)$order['wash_id']] = true;
|
|
}
|
|
}
|
|
if ($washIds !== []) {
|
|
$quoted = implode(',', array_map(
|
|
static fn(string $washId): string => "'" . $db->escape_string($washId) . "'",
|
|
array_keys($washIds)
|
|
));
|
|
$xlvaskResult = $db->query(
|
|
"SELECT id, WashId, CustomerId, Customer, Location, Hall, HallId, StartTime,
|
|
FinishTime, RegistrationNumber, VehicleType, FinishStatus,
|
|
cached_total_net_amount, cached_primary_product_name, cached_amount_at
|
|
FROM xlvask_usage_logs WHERE WashId IN ({$quoted}) ORDER BY id ASC"
|
|
);
|
|
if (!$xlvaskResult) {
|
|
throw new \RuntimeException('Failed to load invoice-period tree XL Vask usage.');
|
|
}
|
|
if ($xlvaskResult) {
|
|
while ($row = $xlvaskResult->fetch_assoc()) {
|
|
$xlvask[(string)$row['WashId']][] = [
|
|
'usage_log_id' => (int)$row['id'],
|
|
'wash_id' => (string)$row['WashId'],
|
|
'customer_id' => $row['CustomerId'] ?? null,
|
|
'customer_name' => $row['Customer'] ?? null,
|
|
'location' => $row['Location'] ?? null,
|
|
'hall' => $row['Hall'] ?? null,
|
|
'hall_id' => $row['HallId'] ?? null,
|
|
'start_time' => $row['StartTime'] ?? null,
|
|
'finish_time' => $row['FinishTime'] ?? null,
|
|
'registration_number' => $row['RegistrationNumber'] ?? null,
|
|
'vehicle_type' => $row['VehicleType'] ?? null,
|
|
'finish_status' => $row['FinishStatus'] ?? null,
|
|
'total_net_amount' => $row['cached_total_net_amount'] === null
|
|
? null
|
|
: (float)$row['cached_total_net_amount'],
|
|
'primary_product_name' => $row['cached_primary_product_name'] ?? null,
|
|
'amount_cached_at' => $row['cached_amount_at'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
$enrich = static function (array &$orders) use ($attachments, $bookings, $payments, $xlvask): void {
|
|
foreach ($orders as &$order) {
|
|
$orderId = (int)$order['id'];
|
|
$order['attachments'] = $attachments[$orderId] ?? [];
|
|
$order['bookings'] = $bookings[$orderId] ?? [];
|
|
$order['payments'] = $payments[$orderId] ?? [];
|
|
$order['xlvask'] = !empty($order['wash_id'])
|
|
? ($xlvask[(string)$order['wash_id']] ?? [])
|
|
: [];
|
|
}
|
|
unset($order);
|
|
};
|
|
foreach ($ordersByCollection as &$orders) {
|
|
$enrich($orders);
|
|
}
|
|
unset($orders);
|
|
$enrich($uncollectedOrders);
|
|
}
|
|
|
|
private static function customerSupportsCustomerLevelQueueBlocking(array $customer): bool
|
|
{
|
|
$meta = $customer['meta'] ?? [];
|
|
|
|
return isset($meta['fixed_pricing'])
|
|
|| isset($meta['wash_subscription'])
|
|
|| !empty($meta['has_vehicle_subscription']);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getVehicleSubscriptions(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
|
if ($customersWithTransactions === null) {
|
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
|
}
|
|
// Since these are monthly subscriptions, we don't need to filter by transactions
|
|
$customer_numbers = self::filterCustomerNumbers(
|
|
(new \objects\users_o())->getCustomersWithVehicleSubscriptions(),
|
|
$onlyCustomerNumbers
|
|
);
|
|
// Get all customers with vehicle subscriptions
|
|
$subscriptions = [];
|
|
$customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false);
|
|
$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);
|
|
/** @var int $customer_number */
|
|
foreach ( $customer_numbers as $customer_number ) {
|
|
$customer = $customers_by_number[(int)$customer_number] ?? null;
|
|
if ($customer !== null) {
|
|
$customer['meta'] = array_merge($customer['meta'] ?? [], [
|
|
'has_vehicle_subscription' => true,
|
|
]);
|
|
$subscriptions[] = $customer;
|
|
continue;
|
|
}
|
|
|
|
$subscriptions[] = self::constructCustomerObject(
|
|
(int)$customer_number,
|
|
$customer_names[(int)$customer_number] ?? 'Unknown Customer',
|
|
[],
|
|
true,
|
|
null,
|
|
[
|
|
'has_vehicle_subscription' => true,
|
|
]
|
|
);
|
|
}
|
|
return $subscriptions;
|
|
}
|
|
|
|
/**
|
|
* Get the customer from the list of customers with transactions.
|
|
*
|
|
* @param int $customer_number The customer number to search for.
|
|
* @param array $customersWithTransactions The list of customers with transactions.
|
|
* @return array|null The customer object if found, null otherwise.
|
|
*/
|
|
private static function getCustomerFromList(int $customer_number, array $customersWithTransactions): ?array
|
|
{
|
|
$direct = $customersWithTransactions[$customer_number] ?? null;
|
|
if (is_array($direct) && (int)($direct['customer_number'] ?? 0) === $customer_number) {
|
|
return $direct;
|
|
}
|
|
|
|
// Search for the customer in the list of customers with transactions
|
|
foreach ( $customersWithTransactions as $customer ) {
|
|
if ($customer['customer_number'] === $customer_number) {
|
|
return $customer;
|
|
}
|
|
}
|
|
// If the customer is not found, return null
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getFixedPricing(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
// Get all customers with fixed pricing
|
|
$customer_numbers = self::filterCustomerNumbers(
|
|
(new \objects\users_o())->getCustomersWithFixedPricing(),
|
|
$onlyCustomerNumbers
|
|
);
|
|
|
|
// If customersWithTransactions is not provided, only resolve transaction customers for fixed-pricing customers.
|
|
if ($customersWithTransactions === null) {
|
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $customer_numbers);
|
|
}
|
|
|
|
$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);
|
|
|
|
// Get the customers fixed pricing
|
|
$tmp_fixed_pricing = array_map(function ($arr) {
|
|
// Return the customer number and price
|
|
return [
|
|
'customer_number' => (int)$arr['customer_number'],
|
|
'price' => (float)$arr['price'],
|
|
'description' => (string)($arr['description'] ?? ''),
|
|
];
|
|
}, (new \objects\customer_fixed_pricing_o())->getFieldsWhere(['customer_number' => $customer_numbers], ['customer_number', 'price', 'description']));
|
|
|
|
$fixed_pricing_by_customer_number = [];
|
|
foreach ( $tmp_fixed_pricing as $item ) {
|
|
$fixed_pricing_by_customer_number[(int)$item['customer_number']] = $item;
|
|
}
|
|
|
|
$customer_names = (new \objects\users_o())->getCustomerNames(array_map('intval', $customer_numbers), false);
|
|
|
|
// Get all customers with fixed pricing
|
|
$fixed_pricing = [];
|
|
/** @var int $customer_number */
|
|
foreach ( $customer_numbers as $customer_number ) {
|
|
$customer_number = (int)$customer_number;
|
|
$fixed_pricing[] = $customers_by_number[$customer_number] ?? null;
|
|
// Check if the last entry is null, if so, create a new customer object
|
|
if (end($fixed_pricing) === null) {
|
|
$fixed_pricing[count($fixed_pricing) - 1] = self::constructCustomerObject(
|
|
$customer_number,
|
|
$customer_names[$customer_number] ?? 'Unknown Customer',
|
|
[],
|
|
true,
|
|
);
|
|
}
|
|
// Add the fixed pricing to the customer object
|
|
$fixed_pricing[count($fixed_pricing) - 1]['meta']['fixed_pricing'] = $fixed_pricing_by_customer_number[$customer_number] ?? null;
|
|
}
|
|
return $fixed_pricing;
|
|
}
|
|
|
|
/**
|
|
* Get an object from an array based on a callback function.
|
|
*
|
|
* @param array $array The array to search in.
|
|
* @param callable $callback The callback function to use for searching.
|
|
* @return mixed|null The found object or null if not found.
|
|
*/
|
|
private static function getObjectFromArray(array $array, callable $callback): mixed
|
|
{
|
|
// Iterate through the array and apply the callback function to each element
|
|
foreach ( $array as $item ) {
|
|
// If the callback returns true, return the item
|
|
if ($callback($item)) {
|
|
return $item;
|
|
}
|
|
}
|
|
// If no item matches the callback, return null
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getTankCleaning(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
|
if ($customersWithTransactions === null) {
|
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
|
}
|
|
// Filter out customers that do not have any transactions in the specified date range
|
|
$customer_numbers = self::filterCustomerNumbers(
|
|
(new \objects\users_o())->getCustomersWithTankCleaning(),
|
|
$onlyCustomerNumbers
|
|
);
|
|
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
|
// Get all customers with tank cleaning
|
|
$tank_cleaning = [];
|
|
$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);
|
|
/** @var int $customer_number */
|
|
foreach ( $customer_numbers as $customer_number ) {
|
|
$customer = $customers_by_number[(int)$customer_number] ?? null;
|
|
if ($customer !== null) {
|
|
$tank_cleaning[] = $customer;
|
|
}
|
|
// Add the tank cleaning to the list if it has transactions
|
|
}
|
|
return $tank_cleaning;
|
|
}
|
|
|
|
/**
|
|
* Filters the customer numbers based on whether they have transactions in the specified date range.
|
|
*
|
|
* @param array $customer_numbers The customer numbers to filter.
|
|
* @param array $customersWithTransactions The customers with transactions in the specified date range.
|
|
*/
|
|
private static function filterCustomersWithTransactions(array &$customer_numbers, array $customersWithTransactions): void
|
|
{
|
|
$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);
|
|
$customer_numbers = array_values(array_filter($customer_numbers, static function ($customer_number) use ($customers_by_number): bool {
|
|
return isset($customers_by_number[(int)$customer_number]);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getSpecialArrangements(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
|
if ($customersWithTransactions === null) {
|
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
|
}
|
|
// Get all customers with tank cleaning
|
|
$customer_numbers = self::filterCustomerNumbers(
|
|
(new \objects\users_o())->getCustomersWithSpecialArrangements(),
|
|
$onlyCustomerNumbers
|
|
);
|
|
// Filter out customers that do not have any transactions in the specified date range
|
|
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
|
$special_arrangements = [];
|
|
$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);
|
|
/** @var int $customer_number */
|
|
foreach ( $customer_numbers as $customer_number ) {
|
|
$customer = $customers_by_number[(int)$customer_number] ?? null;
|
|
if ($customer !== null) {
|
|
$special_arrangements[] = $customer;
|
|
}
|
|
}
|
|
return $special_arrangements;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getInvoicingPerOrder(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
// Get all customers with the invoicing per order attribute
|
|
$customer_numbers = self::filterCustomerNumbers(
|
|
(new users_o())->getCustomerNumbersWithAttributes(['invoiceAllOrdersIndividually']),
|
|
$onlyCustomerNumbers
|
|
);
|
|
// Filter out customers that do not have any transactions in the specified date range
|
|
if ($customersWithTransactions === null) {
|
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
|
}
|
|
self::filterCustomersWithTransactions($customer_numbers, $customersWithTransactions);
|
|
|
|
$invoicing_per_order = [];
|
|
$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);
|
|
/** @var int $customer_number */
|
|
foreach ( $customer_numbers as $customer_number ) {
|
|
// Add the invoicing per order to the list
|
|
$customer = $customers_by_number[(int)$customer_number] ?? null;
|
|
if ($customer !== null) {
|
|
$invoicing_per_order[] = $customer;
|
|
}
|
|
}
|
|
return $invoicing_per_order;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private static function getPossibleDuplicates(string $dateFrom, string $dateTo, array $customersWithTransactions = null, ?array $onlyCustomerNumbers = null): array
|
|
{
|
|
// If customersWithTransactions is not provided, get all customers with transactions in the specified date range
|
|
if ($customersWithTransactions === null) {
|
|
$customersWithTransactions = self::getCustomersWithTransactions($dateFrom, $dateTo, $onlyCustomerNumbers);
|
|
}
|
|
$allowedCustomerNumbers = $onlyCustomerNumbers !== null
|
|
? array_fill_keys(self::normalizeCustomerNumbers($onlyCustomerNumbers), true)
|
|
: null;
|
|
$ordersByRegistration = [];
|
|
foreach ($customersWithTransactions as $customer) {
|
|
foreach (($customer['transactions'] ?? []) as $transaction) {
|
|
$transaction = self::constructTransactionObject($transaction);
|
|
$registration = trim((string)($transaction['reg_1'] ?? ''));
|
|
if ($registration === '') {
|
|
continue;
|
|
}
|
|
$customerNumber = (int)($transaction['customer_number'] ?? 0);
|
|
if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customerNumber])) {
|
|
continue;
|
|
}
|
|
$ordersByRegistration[$registration][] = [
|
|
'id' => (int)$transaction['id'],
|
|
'created_at' => (string)$transaction['date'],
|
|
'customer_number' => $customerNumber,
|
|
'object' => $transaction,
|
|
];
|
|
}
|
|
}
|
|
$orders = invoicing_period_utils::filterPossibleDuplicates($ordersByRegistration, 86400);
|
|
$tmp_customer_arr = [];
|
|
// Remove duplicates from the customer numbers
|
|
$possible_duplicates = [];
|
|
$customer_names = [];
|
|
foreach ($orders as $order) {
|
|
$customer_names[(int)($order[0]['customer_number'] ?? 0)] = true;
|
|
}
|
|
$customer_names = (new \objects\users_o())->getCustomerNames(array_keys($customer_names), false);
|
|
$customers_by_number = self::indexCustomersByNumber($customersWithTransactions);
|
|
/** @var int $customer_number */
|
|
foreach ( $orders as $order ) {
|
|
// Get the customer number from the order
|
|
$customer_number = (int)($order[0]['customer_number'] ?? 0);
|
|
if ($allowedCustomerNumbers !== null && !isset($allowedCustomerNumbers[$customer_number])) {
|
|
continue;
|
|
}
|
|
// Check if the customer number is already in the array
|
|
if (isset($tmp_customer_arr[$customer_number])) {
|
|
continue;
|
|
}
|
|
// Add the customer number to the array
|
|
$tmp_customer_arr[$customer_number] = true;
|
|
// Get the customer from the list of customers with transactions
|
|
$customer = $customers_by_number[$customer_number] ?? null;
|
|
// Add the customer to the possible duplicates array
|
|
$possible_duplicates[] = self::constructCustomerObject(
|
|
$customer_number,
|
|
$customer_names[$customer_number] ?? 'Unknown Customer',
|
|
array_map(function ($transaction) {
|
|
// Construct the transaction object from the order
|
|
return $transaction['object'];
|
|
}, $order),
|
|
false, // Requires action because there are possible duplicates
|
|
$customer['id'] ?? null // Use the id from the customer object if available
|
|
);
|
|
}
|
|
return $possible_duplicates;
|
|
}
|
|
}
|