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:
@@ -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'] ?? [];
|
||||
|
||||
Reference in New Issue
Block a user