Files
api/services/nginx/app/objects/xlvask_usage_logs_o.php
T
Jeppe Bandopenhands 5441fea665 fix(api): simplify XLVask Selvvask surface by removing AI autopilot pipeline (#367)
## Summary

Removes the XLVask autopilot / automation / MiniMax / OpenAI pipeline
and the related module config, CLI, cron, and migration scaffolding. The
Selvvask view (Superuser -> Fakturaer -> Periode -> Selvvask) is reduced
to a single read-only listing of usage logs plus operator-driven ignore
/ unignore / accept / reject endpoints gated on the
`review_xlvask_usage_order` permission.

See `inventory/self-serve-inventory.md` for the full surface map.

## Test plan

- [x] `vendor/bin/pest --testsuite=Unit` -> **1266 passed**, 1 unrelated
pre-existing failure (`BirdControlPlaneActivationTest`, needs
`PLENO_REPO_ROOT_FOR_TESTS`).
- [x] `php -l` on every modified PHP file -> no syntax errors.
- [x] Grep validation -> zero production-code references to removed
surfaces (`xlvask_autopilot_service`, `xlvask_automation_service`,
`xlvask_automation_policy_service`, `EnsureXLVaskAutomationSchema`,
`runScheduledAutomationIfReady`, `processAutopilotQueue`, `MiniMax`,
`minimax`, ...).
- [ ] Qodana + Tests workflows green on this PR.

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
2026-08-12 20:02:30 +02:00

650 lines
27 KiB
PHP

<?php
namespace objects;
use classes\db;
use classes\object_property;
use classes\xlvask;
use classes\xlvask_usage_logs_schema_bootstrap;
use Exception;
use helpers\xlvask_customer;
use helpers\xlvask_usage_log;
use helpers\xlvask_vehicle;
use traits\db_object_t;
class xlvask_usage_logs_o extends db
{
use db_object_t;
public object_property $WashId;
public object_property $CustomerId;
public object_property $Customer;
public object_property $VatNumber;
public object_property $Location;
public object_property $Hall;
public object_property $HallId;
public object_property $StartTime;
public object_property $FinishTime;
public object_property $RegistrationNumber;
public object_property $VehicleType;
public object_property $IdentificationType;
public object_property $IdentificationId;
public object_property $Info;
public object_property $Updated;
public object_property $Prepaid;
public object_property $FinishStatus;
public object_property $CustomerGuid;
public object_property $VehicleId;
public object_property $WashItems;
public object_property $ignored_at;
public object_property $ignored_by;
public object_property $ignored_reason;
public function structure(): void
{
$this->setTable('xlvask_usage_logs');
}
/**
* Add a new XL Vask customer
* @param array $data The properties
* @returns void
* @throws Exception If the object was not created successfully
*/
public function add(array $data): void
{
$tmp_id = self::add_object($data);
$this->id = $tmp_id;
self::getObjectProperties();
self::objectChanged();
}
public function getObjectProperties(): void
{
$this->WashId = new object_property($this->table, $this->id, 'WashId', 'string', false);
$this->CustomerId = new object_property($this->table, $this->id, 'CustomerId', 'string', false);
$this->Customer = new object_property($this->table, $this->id, 'Customer', 'string', false);
$this->VatNumber = new object_property($this->table, $this->id, 'VatNumber', 'string', false);
$this->Location = new object_property($this->table, $this->id, 'Location', 'string', false);
$this->Hall = new object_property($this->table, $this->id, 'Hall', 'string', false);
$this->HallId = new object_property($this->table, $this->id, 'HallId', 'string', false);
$this->StartTime = new object_property($this->table, $this->id, 'StartTime', 'string', false);
$this->FinishTime = new object_property($this->table, $this->id, 'FinishTime', 'string', false);
$this->RegistrationNumber = new object_property($this->table, $this->id, 'RegistrationNumber', 'string', false);
$this->VehicleType = new object_property($this->table, $this->id, 'VehicleType', 'string', false);
$this->IdentificationType = new object_property($this->table, $this->id, 'IdentificationType', 'string', false);
$this->IdentificationId = new object_property($this->table, $this->id, 'IdentificationId', 'string', false);
$this->Info = new object_property($this->table, $this->id, 'Info', 'string', false);
$this->Updated = new object_property($this->table, $this->id, 'Updated', 'string', false);
$this->Prepaid = new object_property($this->table, $this->id, 'Prepaid', 'string', false);
$this->FinishStatus = new object_property($this->table, $this->id, 'FinishStatus', 'string', false);
$this->CustomerGuid = new object_property($this->table, $this->id, 'CustomerGuid', 'string', false);
$this->VehicleId = new object_property($this->table, $this->id, 'VehicleId', 'string', false);
$this->WashItems = new object_property($this->table, $this->id, 'WashItems', 'string', false);
$this->ignored_at = new object_property($this->table, $this->id, 'ignored_at', 'datetime', false);
$this->ignored_by = new object_property($this->table, $this->id, 'ignored_by', 'int', false);
$this->ignored_reason = new object_property($this->table, $this->id, 'ignored_reason', 'string', false);
}
public function objectChanged(): void
{
//TODO: Add cache invalidation
}
public function getCachedAmountSummaryFromRow(array $row): array
{
$cached_amount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null);
$cached_at = trim((string)($row['cached_amount_at'] ?? ''));
if ($cached_amount !== null && $cached_at !== '') {
return [
'total_net_amount' => $cached_amount,
'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''),
'cached' => true,
];
}
$summary = self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []);
$id = (int)($row['id'] ?? 0);
if ($id > 0) {
self::cacheAmountSummary($id, $summary);
}
return [
...$summary,
'cached' => false,
];
}
/** Read-only amount projection for GET routes. */
public function getAmountSummaryReadOnly(array $row): array
{
$cachedAmount = self::normalizeMoneyValue($row['cached_total_net_amount'] ?? null);
$cachedAt = trim((string)($row['cached_amount_at'] ?? ''));
if ($cachedAmount !== null && $cachedAt !== '') {
return [
'total_net_amount' => $cachedAmount,
'primary_product_name' => (string)($row['cached_primary_product_name'] ?? ''),
'cached' => true,
];
}
return [...self::calculateAmountSummaryFromWashItems($row['WashItems'] ?? []), 'cached' => false];
}
public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array
{
if (is_string($washItems)) {
$decoded = json_decode($washItems, true);
$washItems = is_array($decoded) ? $decoded : [];
}
$total = 0.0;
$primaryProductName = '';
foreach (is_array($washItems) ? $washItems : [] as $item) {
if (!is_array($item)) {
continue;
}
if ($primaryProductName === '' && isset($item['OriginalProductName'])) {
$primaryProductName = trim((string)$item['OriginalProductName']);
}
$priceIncVat = self::normalizeMoneyValue($item['PriceIncVat'] ?? null);
$vat = self::normalizeMoneyValue($item['Vat'] ?? 0.0) ?? 0.0;
if ($priceIncVat === null) {
continue;
}
$total += $priceIncVat - $vat;
}
return [
'total_net_amount' => round($total, 2),
'primary_product_name' => $primaryProductName,
];
}
private static function cacheAmountSummary(int $id, array $summary): void
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
$amount = number_format((float)($summary['total_net_amount'] ?? 0.0), 2, '.', '');
$primaryProductName = $db->escape_string((string)($summary['primary_product_name'] ?? ''));
$db->query(
"UPDATE xlvask_usage_logs
SET cached_total_net_amount = {$amount},
cached_primary_product_name = " . ($primaryProductName === '' ? 'NULL' : "'{$primaryProductName}'") . ",
cached_amount_at = NOW()
WHERE id = {$id}"
);
}
private static function normalizeMoneyValue(mixed $value): ?float
{
if ($value === null || $value === '') {
return null;
}
if (is_int($value) || is_float($value)) {
return (float)$value;
}
$normalized = preg_replace('/[^\d,.\-]/', '', (string)$value);
if ($normalized === null || $normalized === '') {
return null;
}
if (str_contains($normalized, ',') && !str_contains($normalized, '.')) {
$normalized = str_replace(',', '.', $normalized);
} else {
$normalized = str_replace(',', '', $normalized);
}
return is_numeric($normalized) ? (float)$normalized : null;
}
/**
* Import the usage logs from XL Vask
* @param string|null $dateFrom Optional import start date or date-time modifier. Defaults to '-7 days'.
* @param string|null $dateTo Optional inclusive import end date.
* @throws Exception If the objects were not successfully added.
* @returns void
*/
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): void
{
$this->importUsageLogsWithSummary($dateFrom, $dateTo);
}
/**
* Revision-aware and idempotent XL Vask import.
*
* @return array{fetched:int,new:int,updated:int,unchanged:int,invalid:int,errors:array<int,array<string,string>>}
*/
public function importUsageLogsWithSummary(?string $dateFrom = null, ?string $dateTo = null, array $allowedHallIds = []): array
{
global $db;
if (!empty($this->id)) {
throw new Exception('To prevent issues, having a selected object is not allowed.');
}
xlvask_usage_logs_schema_bootstrap::ensureTables();
$usage_logs = $this->getUsageLogsFromXLVask(
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
);
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
$upstreamFetched = count($usage_logs);
$allowedHallIds = array_values(array_unique(array_filter(array_map(
static fn(mixed $id): string => trim((string)$id),
$allowedHallIds
), static fn(string $id): bool => $id !== '')));
if ($allowedHallIds !== []) {
$usage_logs = array_values(array_filter(
$usage_logs,
static fn(xlvask_usage_log $log): bool => in_array(trim((string)$log->HallId), $allowedHallIds, true)
));
}
$washIds = array_values(array_filter(array_map(
static fn(xlvask_usage_log $log): string => trim((string)$log->WashId),
$usage_logs
)));
$knownRows = $washIds === [] ? [] : self::getFieldsWhere(
['WashId' => $washIds],
['id', 'WashId', 'source_hash', 'source_revision', 'expected_version']
);
$knownByWashId = [];
foreach ($knownRows as $knownRow) {
$knownByWashId[(string)$knownRow['WashId']] = $knownRow;
}
$summary = [
'fetched' => count($usage_logs),
'upstream_fetched' => $upstreamFetched,
'new' => 0,
'updated' => 0,
'unchanged' => 0,
'invalid' => 0,
'errors' => [],
];
foreach ($usage_logs as $log) {
$washId = trim((string)$log->WashId);
if ($washId === '' || !$log->isValid()) {
$summary['invalid']++;
$summary['errors'][] = [
'wash_id' => $washId,
'error' => 'XL Vask usage log failed structural validation.',
];
if ($washId !== '' && isset($knownByWashId[$washId])) {
$this->markImportState((int)$knownByWashId[$washId]['id'], 'invalid', 'XL Vask-kildedata kunne ikke valideres.');
}
continue;
}
$payload = self::normalizeSourcePayload($log->toArray());
$sourceHash = self::sourceHashForAutomation($payload);
$sourceRevision = trim((string)($payload['Updated'] ?? '')) ?: $sourceHash;
$known = $knownByWashId[$washId] ?? null;
if ($known === null) {
$this->add($payload);
$this->updateSourceMetadata((int)$this->id, $sourceHash, $sourceRevision, 'new', true);
$knownByWashId[$washId] = ['id' => (int)$this->id, 'WashId' => $washId, 'source_hash' => $sourceHash];
$summary['new']++;
continue;
}
$existingHash = trim((string)($known['source_hash'] ?? ''));
if ($existingHash !== '' && hash_equals($existingHash, $sourceHash)) {
$this->updateSourceMetadata((int)$known['id'], $sourceHash, $sourceRevision, 'unchanged', false);
$summary['unchanged']++;
continue;
}
$before = $known;
$connection = $db->conn();
$connection->begin_transaction();
try {
$this->updateExistingSourceRow((int)$known['id'], $payload, $sourceHash, $sourceRevision);
$this->supersedeSuggestionsForSourceRevision((int)$known['id']);
$this->recordSourceRevision((int)$known['id'], $washId, $before, $payload, $sourceHash, $sourceRevision);
$connection->commit();
} catch (\Throwable $throwable) {
$connection->rollback();
throw $throwable;
}
$knownByWashId[$washId]['source_hash'] = $sourceHash;
$summary['updated']++;
}
return $summary;
}
public static function sourceHashForAutomation(array $payload): string
{
$payload = self::normalizeSourcePayload($payload);
$encoded = json_encode(
self::sortSourceValue($payload),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
);
if ($encoded === false) {
throw new Exception('Could not build XL Vask source hash.');
}
return hash('sha256', $encoded);
}
private static function normalizeSourcePayload(array $payload): array
{
$allowed = [
'WashId', 'CustomerId', 'Customer', 'VatNumber', 'Location', 'Hall', 'HallId',
'StartTime', 'FinishTime', 'RegistrationNumber', 'VehicleType', 'IdentificationType',
'IdentificationId', 'Info', 'Updated', 'Prepaid', 'FinishStatus', 'CustomerGuid',
'VehicleId', 'WashItems',
];
$payload = array_intersect_key($payload, array_flip($allowed));
if (isset($payload['WashItems']) && is_string($payload['WashItems'])) {
$decoded = json_decode($payload['WashItems'], true);
if (is_array($decoded)) {
$payload['WashItems'] = $decoded;
}
}
if (isset($payload['WashItems']) && is_array($payload['WashItems'])) {
$payload['WashItems'] = array_values($payload['WashItems']);
usort($payload['WashItems'], static function (mixed $left, mixed $right): int {
$leftJson = json_encode(self::sortSourceValue($left), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION) ?: '';
$rightJson = json_encode(self::sortSourceValue($right), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION) ?: '';
return strcmp($leftJson, $rightJson);
});
}
return $payload;
}
private static function sortSourceValue(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}
$sorted = array_map(static fn(mixed $item): mixed => self::sortSourceValue($item), $value);
$isList = $sorted === [] || array_keys($sorted) === range(0, count($sorted) - 1);
if (!$isList) {
ksort($sorted, SORT_STRING);
}
return $sorted;
}
private function updateExistingSourceRow(int $id, array $payload, string $sourceHash, string $sourceRevision): void
{
global $db;
$assignments = [];
foreach ($payload as $column => $value) {
$assignments[] = '`' . str_replace('`', '', $column) . '` = ' . $this->sqlValue($value);
}
$assignments[] = "source_hash = '" . $db->escape_string($sourceHash) . "'";
$assignments[] = "source_revision = '" . $db->escape_string($sourceRevision) . "'";
$assignments[] = 'source_observed_at = NOW()';
$assignments[] = 'source_stable_since = NOW()';
$assignments[] = 'source_observation_count = 1';
$assignments[] = "import_state = 'updated'";
$assignments[] = "resolution_state = 'needs_review'";
$assignments[] = "certainty = 'none'";
$assignments[] = "planned_action = 'recheck'";
$assignments[] = "state_reason = 'XL Vask-kildedata blev opdateret.'";
$assignments[] = 'expected_version = expected_version + 1';
$assignments[] = 'cached_total_net_amount = NULL';
$assignments[] = 'cached_primary_product_name = NULL';
$assignments[] = 'cached_amount_at = NULL';
if ($db->query('UPDATE xlvask_usage_logs SET ' . implode(', ', $assignments) . " WHERE id = {$id}") === false
|| $db->conn()->affected_rows !== 1) {
throw new Exception('Could not atomically update the XL Vask source row.');
}
}
private function supersedeSuggestionsForSourceRevision(int $usageLogId): void
{
global $db;
if ($db->query(
"UPDATE xlvask_automation_suggestions
SET status = 'superseded', updated_at = NOW()
WHERE usage_log_id = {$usageLogId} AND status <> 'superseded'"
) === false) {
throw new Exception('Could not invalidate stale XL Vask automation suggestions.');
}
}
private function updateSourceMetadata(int $id, string $sourceHash, string $sourceRevision, string $state, bool $new): void
{
global $db;
$sourceHash = $db->escape_string($sourceHash);
$sourceRevision = $db->escape_string($sourceRevision);
$state = $db->escape_string($state);
$stable = $new ? 'NOW()' : "COALESCE(source_stable_since, NOW())";
$observations = $new ? '1' : 'GREATEST(1, source_observation_count) + 1';
$db->query(
"UPDATE xlvask_usage_logs SET
source_hash = '{$sourceHash}', source_revision = '{$sourceRevision}',
source_observed_at = NOW(), source_stable_since = {$stable},
source_observation_count = {$observations}, import_state = '{$state}'
WHERE id = {$id}"
);
}
private function markImportState(int $id, string $state, string $reason): void
{
global $db;
$state = $db->escape_string($state);
$reason = $db->escape_string($reason);
$connection = $db->conn();
$connection->begin_transaction();
try {
if ($db->query(
"UPDATE xlvask_usage_logs SET import_state = '{$state}', resolution_state = 'failed',
certainty = 'none', planned_action = 'none', state_reason = '{$reason}',
source_stable_since = NULL, source_observation_count = 0, source_observed_at = NOW(),
expected_version = expected_version + 1 WHERE id = {$id}"
) === false || $db->conn()->affected_rows !== 1) {
throw new Exception('Could not atomically mark invalid XL Vask source data.');
}
$this->supersedeSuggestionsForSourceRevision($id);
$connection->commit();
} catch (\Throwable $throwable) {
$connection->rollback();
throw $throwable;
}
}
private function recordSourceRevision(
int $id,
string $washId,
array $before,
array $after,
string $sourceHash,
string $sourceRevision
): void {
global $db;
$beforeJson = $db->escape_string(json_encode([
'source_hash' => (string)($before['source_hash'] ?? ''),
'source_revision' => (string)($before['source_revision'] ?? ''),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
$afterJson = $db->escape_string(json_encode([
'source_hash' => $sourceHash,
'source_revision' => $sourceRevision,
'observed_fields' => array_values(array_keys($after)),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
$washId = $db->escape_string($washId);
$sourceHash = $db->escape_string($sourceHash);
$sourceRevision = $db->escape_string($sourceRevision);
if ($db->query(
"INSERT INTO xlvask_automation_audit
(usage_log_id, wash_id, event_type, policy_version, input_hash, source_revision, before_json, after_json)
VALUES ({$id}, '{$washId}', 'source_updated', 'xlvask-autopilot-v1', '{$sourceHash}', '{$sourceRevision}', '{$beforeJson}', '{$afterJson}')"
) === false || $db->conn()->affected_rows !== 1) {
throw new Exception('Could not record the XL Vask source revision audit event.');
}
}
private function sqlValue(mixed $value): string
{
global $db;
if ($value === null) {
return 'NULL';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
if (is_int($value) || is_float($value)) {
return (string)$value;
}
if (is_array($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
}
return "'" . $db->escape_string((string)$value) . "'";
}
private static function formatImportDateFrom(?string $dateFrom): string
{
$dateFrom = trim((string)($dateFrom ?? ''));
$timestamp = strtotime($dateFrom === '' ? '-7 days' : $dateFrom);
if ($timestamp === false) {
throw new Exception('Invalid XL Vask usage import dateFrom');
}
return date('Y-m-d\TH:i:s.000', $timestamp);
}
/**
* @param xlvask_usage_log[] $usageLogs
* @return xlvask_usage_log[]
* @throws Exception
*/
private static function filterUsageLogsUntil(array $usageLogs, ?string $dateTo): array
{
$dateTo = trim((string)($dateTo ?? ''));
if ($dateTo === '') {
return $usageLogs;
}
$dateToTimestamp = strtotime($dateTo);
if ($dateToTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
$inclusiveEndTimestamp = strtotime(date('Y-m-d 23:59:59', $dateToTimestamp));
if ($inclusiveEndTimestamp === false) {
throw new Exception('Invalid XL Vask usage import dateTo');
}
return array_values(array_filter($usageLogs, function (xlvask_usage_log $log) use ($inclusiveEndTimestamp) {
$startTimestamp = strtotime((string)$log->StartTime);
return $startTimestamp !== false && $startTimestamp <= $inclusiveEndTimestamp;
}));
}
/**
* This function retrieves the usage logs from XL Vask
* @param string $fromDate The date from which to retrieve the usage logs, in ISO 8601 format (e.g., '2025-05-01T00:00:00.000')
* @returns xlvask_usage_log[]
* @throws Exception
*/
public function getUsageLogsFromXLVask(string $fromDate): array
{
// Validate the date format
if (!preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/', $fromDate)) {
throw new Exception('Invalid date format. Expected format: YYYY-MM-DDTHH:MM:SS.SSS (e.g., 2025-05-01T00:00:00.000)');
}
// Set the memory limit to 512MB, as the import can be quite large
ini_set('memory_limit', '512M');
/** Get the vehicles from XL Vask */
$xlvask = new xlvask();
$vehicle_class = $xlvask->new($xlvask->helpers->xlvask_usage_log);
/** @var xlvask_usage_log[] $vehicles */
$vehicles = $vehicle_class->toObjects($xlvask->getUsageLog(
$fromDate, // Example: '2025-05-01T00:00:00.000'
));
return $vehicles;
}
/**
* Read-only per-period usage-log summary used by the Selvvask view.
* Replaces the legacy autopilot-service summary with a thin SQL aggregate
* over xlvask_usage_logs that stays well within the operator's hall scope.
*
* @param array<int,string> $allowedHallIds
* @return array{counts: array<string,int>, total_net_amount: float, ignored_count: int, window: array{from:?string,to:?string}}
*/
public function summarizeUsageOrdersReadOnly(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array
{
global $db;
if ($allowedHallIds === []) {
return [
'counts' => ['total' => 0, 'needs_review' => 0, 'ignored' => 0],
'total_net_amount' => 0.0,
'ignored_count' => 0,
'window' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
$hallSql = implode(',', array_map(
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
$allowedHallIds
));
$fromSql = $dateFrom !== null && $dateFrom !== ''
? "'" . $db->escape_string(xlvask_usage_logs_o::formatImportDateFrom($dateFrom)) . "'"
: 'DATE_SUB(NOW(), INTERVAL 30 DAY)';
$toSql = $dateTo !== null && $dateTo !== ''
? "'" . $db->escape_string((string)$dateTo) . " 23:59:59'"
: 'NOW()';
$sql = "SELECT
COUNT(*) AS total,
SUM(CASE WHEN ignored_at IS NULL THEN 1 ELSE 0 END) AS needs_review,
SUM(CASE WHEN ignored_at IS NOT NULL THEN 1 ELSE 0 END) AS ignored_count
FROM xlvask_usage_logs
WHERE StartTime >= {$fromSql}
AND StartTime <= {$toSql}
AND FinishStatus = 1
AND HallId IN ({$hallSql})";
$result = $db->query($sql);
$row = ($result !== false && $result->num_rows > 0)
? $db->fetch_all($result)[0]
: ['total' => 0, 'needs_review' => 0, 'ignored_count' => 0];
$netSql = "SELECT
COALESCE(SUM(
CAST(
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].PriceIncVat')), '\"', '') AS DECIMAL(10,2)
)
- COALESCE(
CAST(
REPLACE(JSON_UNQUOTE(JSON_EXTRACT(WashItems, '$[0].Vat')), '\"', '') AS DECIMAL(10,2)
), 0
)
), 0) AS period_net
FROM xlvask_usage_logs
WHERE StartTime >= {$fromSql}
AND StartTime <= {$toSql}
AND FinishStatus = 1
AND HallId IN ({$hallSql})";
$netResult = $db->query($netSql);
$netRow = ($netResult !== false && $netResult->num_rows > 0)
? $db->fetch_all($netResult)[0]
: ['period_net' => 0];
return [
'counts' => [
'total' => (int)($row['total'] ?? 0),
'needs_review' => (int)($row['needs_review'] ?? 0),
'ignored' => (int)($row['ignored_count'] ?? 0),
],
'total_net_amount' => round((float)($netRow['period_net'] ?? 0), 2),
'ignored_count' => (int)($row['ignored_count'] ?? 0),
'window' => ['from' => $dateFrom, 'to' => $dateTo],
];
}
}