Complete selected-customer invoice period tree (#338)

Add the authoritative revision-bound invoice collection tree and guarded cleanup, merge, price-reset, and transfer operations.
This commit is contained in:
Jeppe B
2026-08-03 12:02:34 +02:00
committed by GitHub
parent f37feef1e6
commit 068f9e254f
12 changed files with 2413 additions and 67 deletions
@@ -7,6 +7,9 @@ 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_period_flag_service;
use classes\invoicing_period_utils;
use classes\slack;
@@ -37,6 +40,7 @@ class InvoicingPeriodRoute
private static array $departmentExcludedFromInvoicingCache = [];
private static ?bool $collectedOrderInvoicesHasDeletedAtColumn = null;
private static bool $suppressInvoicePeriodExternalEffects = false;
/**
* Local-only booked status caches used by the period response.
@@ -89,6 +93,9 @@ class InvoicingPeriodRoute
*/
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);
@@ -102,6 +109,88 @@ class InvoicingPeriodRoute
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 true;
}
$allowlist = $config['object_tree_v2_superuser_allowlist']
?? $config['object_tree_v2_allowlisted_user_ids']
?? null;
if (in_array($actorUserId, self::parseObjectTreeIntegerList($allowlist), true)) {
return true;
}
if ($config !== []) {
return false;
}
}
} catch (\Throwable) {
// Deployment/test fallback below; the default remains disabled.
}
return self::isTruthyObjectTreeConfigValue(getenv('INVOICING_PERIOD_OBJECT_TREE_V2'));
}
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
)));
}
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}
*/
@@ -1213,8 +1302,11 @@ class InvoicingPeriodRoute
}
$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'];
@@ -1234,6 +1326,45 @@ class InvoicingPeriodRoute
]
);
$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');
@@ -1841,7 +1972,9 @@ class InvoicingPeriodRoute
$message .= "Difference: " . $difference . "\n";
$message .= "Debug info: " . print_r($debug_info, true);
// Describe what to check
(new slack())->send_message($message, 'Subscription Price Distribution Mismatch');
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);
}
@@ -3235,6 +3368,610 @@ class InvoicingPeriodRoute
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'] ?? [];