Automate XL-Vask invoice-period resolution (#340)

Deploy the revision-aware XL-Vask import and guarded autopilot infrastructure. Automatic actions remain fail-closed pending production readiness, calibration, dry-run, and canary gates.
This commit is contained in:
Jeppe B
2026-08-03 15:33:55 +02:00
committed by GitHub
parent 0b304a2203
commit 6d888a455d
17 changed files with 3637 additions and 223 deletions
+13 -2
View File
@@ -44,12 +44,21 @@ class openai implements openai_i
* *
* @throws Exception * @throws Exception
*/ */
public function jsonTask(string $schemaName, string $prompt, array $payload, array $schema, float $temperature = 0.1): array public function jsonTask(
string $schemaName,
string $prompt,
array $payload,
array $schema,
float $temperature = 0.1,
?string $model = null
): array
{ {
$this->requireModuleEnabled(); $this->requireModuleEnabled();
$data = [ $data = [
'model' => $this->model, 'model' => $model ?? $this->model,
// The caller owns the durable audit record. Do not retain application state at OpenAI.
'store' => false,
'input' => [ 'input' => [
[ [
'role' => 'user', 'role' => 'user',
@@ -278,6 +287,8 @@ class openai implements openai_i
$curl = curl_init($this->api_url); $curl = curl_init($this->api_url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_POST, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($curl, CURLOPT_TIMEOUT, 45);
curl_setopt($curl, CURLOPT_HTTPHEADER, [ curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Content-Type: application/json', 'Content-Type: application/json',
'Authorization: Bearer ' . $this->config->api_key->getVariableValue() 'Authorization: Bearer ' . $this->config->api_key->getVariableValue()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,924 @@
<?php
namespace classes;
require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
use Exception;
use objects\xlvask_usage_logs_o;
use Throwable;
/**
* Durable orchestration and read models for XL Vask invoice-period automation.
*
* GET callers use only read methods. Schema bootstrap and state mutation happen
* explicitly from run/decision/cron entry points.
*/
class xlvask_autopilot_service
{
public const POLICY_VERSION = 'xlvask-autopilot-v1';
private const PREVIEW_TTL_SECONDS = 900;
public static function modeCapabilities(string $mode): array
{
return match ($mode) {
'execute' => ['import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true],
'dry_run' => ['import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true],
'replay' => ['import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true],
default => throw new Exception('Invalid XL Vask autopilot mode.'),
};
}
public static function normalizeHallScope(array $hallIds): array
{
$normalized = array_values(array_unique(array_filter(array_map(
static fn(mixed $id): string => trim((string)$id),
$hallIds
), static fn(string $id): bool => $id !== '' && strlen($id) <= 191)));
sort($normalized, SORT_STRING);
return $normalized;
}
public static function idempotencyKey(string $providedKey, ?int $actorId, string $nonce): string
{
$material = trim($providedKey) !== '' ? trim($providedKey) : $nonce;
return hash('sha256', self::POLICY_VERSION . ':' . $material . ':' . (string)$actorId);
}
public static function requestFingerprint(array $request): string
{
return hash('sha256', xlvask_automation_service::stableJsonForAutomation($request));
}
public static function previewSnapshotMatches(int $expectedVersion, string $sourceHash, array $current): bool
{
return (int)($current['expected_version'] ?? 0) === $expectedVersion
&& $sourceHash !== ''
&& hash_equals($sourceHash, (string)($current['source_hash'] ?? ''));
}
public function createRun(array $input, ?int $actorId = null, array $allowedHallIds = []): array
{
xlvask_usage_logs_schema_bootstrap::ensureTables();
global $db;
$mode = strtolower(trim((string)($input['mode'] ?? 'execute')));
self::modeCapabilities($mode);
$dateFrom = $this->normalizeDate($input['dateFrom'] ?? null, 'dateFrom');
$dateTo = $this->normalizeDate($input['dateTo'] ?? null, 'dateTo');
if ($dateFrom !== null && $dateTo !== null && $dateFrom > $dateTo) {
throw new Exception('dateFrom must not be after dateTo.');
}
$ids = array_values(array_unique(array_filter(
array_map('intval', is_array($input['ids'] ?? null) ? $input['ids'] : []),
static fn(int $id): bool => $id > 0
)));
if (count($ids) > 500) {
throw new Exception('At most 500 XL Vask usage logs can be included in one run.');
}
sort($ids, SORT_NUMERIC);
$requestedLimit = max(1, min(500, (int)($input['limit'] ?? 500)));
if ($dateFrom !== null && $dateTo !== null) {
$rangeDays = (int)floor((strtotime($dateTo) - strtotime($dateFrom)) / 86400) + 1;
$maxRange = $mode === 'replay' ? 366 : 90;
if ($rangeDays > $maxRange) {
throw new Exception("XL Vask {$mode} range cannot exceed {$maxRange} days.");
}
}
$forceRefetch = filter_var($input['forceRefetch'] ?? false, FILTER_VALIDATE_BOOL);
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
if ($allowedHallIds === []) {
throw new Exception('No XL Vask hall scope is available for this user.');
}
// An explicit retry key is stable; otherwise every requested rerun is a new durable run.
$providedKey = trim((string)($input['idempotency_key'] ?? ''));
$key = self::idempotencyKey($providedKey, $actorId, $this->uuidV4());
$requestHash = self::requestFingerprint([
'mode' => $mode,
'date_from' => $dateFrom,
'date_to' => $dateTo,
'ids' => $ids,
'limit' => $requestedLimit,
'force_refetch' => $forceRefetch,
'scope_hall_ids' => $allowedHallIds,
]);
$idsJson = $db->escape_string(json_encode($ids, JSON_UNESCAPED_SLASHES) ?: '[]');
$scopeJson = $db->escape_string(json_encode($allowedHallIds, JSON_UNESCAPED_SLASHES) ?: '[]');
$dateFromSql = $dateFrom === null ? 'NULL' : "'" . $db->escape_string($dateFrom) . "'";
$dateToSql = $dateTo === null ? 'NULL' : "'" . $db->escape_string($dateTo) . "'";
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
$keySql = $db->escape_string($key);
$modeSql = $db->escape_string($mode);
$db->query(
"INSERT INTO xlvask_autopilot_runs
(idempotency_key, request_hash, mode, date_from, date_to, force_refetch, requested_ids_json,
requested_limit, scope_hall_ids_json, created_by)
VALUES ('{$keySql}', '{$requestHash}', '{$modeSql}', {$dateFromSql}, {$dateToSql}, " . ($forceRefetch ? '1' : '0') . ",
'{$idsJson}', {$requestedLimit}, '{$scopeJson}', {$actorSql})
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"
);
$runId = (int)$db->insert_id();
$existing = $db->fetch_assoc($db->query("SELECT request_hash FROM xlvask_autopilot_runs WHERE id = {$runId} LIMIT 1"));
if (!hash_equals($requestHash, (string)($existing['request_hash'] ?? ''))) {
throw new Exception('XL Vask idempotency key was already used with a different request payload.');
}
return $this->getRun($runId, $actorId, $allowedHallIds);
}
public function processQueuedRuns(int $limit = 3): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
$limit = max(1, min(10, $limit));
$db->query(
"UPDATE xlvask_autopilot_runs
SET status = 'failed', phase = 'failed', error = 'XL Vask autopilot retry budget exhausted.',
lease_token = NULL, lease_expires_at = NULL, finished_at = NOW()
WHERE status IN ('queued', 'running') AND attempt_count >= max_attempts
AND (status = 'queued' OR lease_expires_at < NOW())"
);
$rows = $db->fetch_all($db->query(
"SELECT id FROM xlvask_autopilot_runs
WHERE attempt_count < max_attempts AND (
(status = 'queued' AND (next_attempt_at IS NULL OR next_attempt_at <= NOW()))
OR (status = 'running' AND lease_expires_at < NOW())
)
ORDER BY id ASC LIMIT {$limit}"
));
$runs = [];
foreach ($rows as $row) {
$runs[] = $this->processRun((int)$row['id']);
}
return $runs;
}
public function processRun(int $runId): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
$connection = $db->conn();
$connection->begin_transaction();
try {
$result = $db->query("SELECT * FROM xlvask_autopilot_runs WHERE id = {$runId} FOR UPDATE");
if ($result === false || $result->num_rows < 1) {
throw new Exception('XL Vask autopilot run not found.');
}
$run = $db->fetch_assoc($result);
$leaseExpired = $run['lease_expires_at'] !== null && strtotime((string)$run['lease_expires_at']) < time();
if (in_array((string)$run['status'], ['completed', 'completed_with_warnings', 'failed'], true)
|| ((string)$run['status'] === 'running' && !$leaseExpired)) {
$connection->commit();
return $this->formatRun($run);
}
$leaseToken = $this->uuidV4();
$db->query(
"UPDATE xlvask_autopilot_runs SET status = 'running', phase = 'importing',
lease_token = '{$leaseToken}', lease_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE),
attempt_count = attempt_count + 1, next_attempt_at = NULL,
started_at = COALESCE(started_at, NOW()) WHERE id = {$runId}"
);
$run['attempt_count'] = (int)($run['attempt_count'] ?? 0) + 1;
$connection->commit();
} catch (Throwable $throwable) {
$connection->rollback();
throw $throwable;
}
try {
$dateFrom = $run['date_from'] ?: null;
$dateTo = $run['date_to'] ?: null;
$ids = json_decode((string)($run['requested_ids_json'] ?? '[]'), true);
$ids = is_array($ids) ? array_values(array_filter(array_map('intval', $ids))) : [];
$scopeHallIds = json_decode((string)($run['scope_hall_ids_json'] ?? '[]'), true);
$scopeHallIds = is_array($scopeHallIds) ? $this->normalizeHallIds($scopeHallIds) : [];
$importSummary = ['fetched' => 0, 'upstream_fetched' => 0, 'new' => 0, 'updated' => 0, 'unchanged' => 0, 'invalid' => 0, 'errors' => []];
$capabilities = self::modeCapabilities((string)$run['mode']);
$isReplay = !$capabilities['persist_plans'];
if ($capabilities['import'] && ($ids === [] || (int)$run['force_refetch'] === 1)) {
$importSummary = (new xlvask_usage_logs_o())->importUsageLogsWithSummary($dateFrom, $dateTo, $scopeHallIds);
}
$leaseTokenSql = $db->escape_string($leaseToken);
$renewLease = static function () use ($db, $runId, $leaseTokenSql): void {
if ($db->query(
"UPDATE xlvask_autopilot_runs
SET lease_expires_at = DATE_ADD(NOW(), INTERVAL 15 MINUTE)
WHERE id = {$runId} AND status = 'running' AND lease_token = '{$leaseTokenSql}'"
) === false) {
throw new Exception('XL Vask autopilot lease ownership was lost during processing.');
}
if ($db->conn()->affected_rows !== 1) {
$owned = $db->query(
"SELECT id FROM xlvask_autopilot_runs
WHERE id = {$runId} AND status = 'running' AND lease_token = '{$leaseTokenSql}' LIMIT 1"
);
if ($owned === false || $owned->num_rows !== 1) {
throw new Exception('XL Vask autopilot lease ownership was lost during processing.');
}
}
};
$db->query("UPDATE xlvask_autopilot_runs SET phase = 'evaluating' WHERE id = {$runId}");
$renewLease();
$automation = new xlvask_automation_service();
$automation->setRunContext($runId);
$allowExecute = $capabilities['execute_actions'];
$results = $automation->runPending(
$dateFrom,
$dateTo,
$ids,
max(1, min(500, (int)($run['requested_limit'] ?? 500))),
isset($run['created_by']) ? (int)$run['created_by'] : null,
$allowExecute,
$runId,
$scopeHallIds,
$isReplay,
$renewLease
);
$this->persistRunItems($runId, $results['results'] ?? []);
$summary = $this->getSummary($dateFrom, $dateTo, $scopeHallIds);
$summary['import'] = $importSummary;
$circuitBreaker = $results['circuit_breaker'] ?? null;
$warning = $circuitBreaker === null ? null : 'Autopilot stopped early: ' . (string)$circuitBreaker;
$summary['circuit_breaker'] = $circuitBreaker;
$summaryJson = $db->escape_string(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
$processed = (int)($results['processed'] ?? 0);
$total = (int)($results['eligible_total'] ?? $results['selected'] ?? $processed);
if ($circuitBreaker === null && $processed < $total) {
$circuitBreaker = 'batch_limit_reached';
$warning = 'Autopilot stopped early: batch_limit_reached';
$summary['circuit_breaker'] = $circuitBreaker;
$summaryJson = $db->escape_string(json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
}
$status = $circuitBreaker === null ? 'completed' : 'completed_with_warnings';
$phase = $circuitBreaker === null ? 'completed' : 'circuit_breaker';
$warningSql = $warning === null ? 'NULL' : "'" . $db->escape_string($warning) . "'";
$db->query(
"UPDATE xlvask_autopilot_runs SET status = '{$status}', phase = '{$phase}', processed = {$processed},
total = {$total}, summary_json = '{$summaryJson}', warning = {$warningSql}, finished_at = NOW()
WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'"
);
if ($db->conn()->affected_rows !== 1) {
throw new Exception('XL Vask autopilot lease ownership was lost before completion.');
}
if (!$isReplay && $dateFrom !== null && $dateTo !== null) {
(new redis())->clear_invoice_period_automatic_flags($dateFrom, $dateTo);
}
} catch (Throwable $throwable) {
$message = $db->escape_string('XL Vask autopilot run failed. Review the server-side audit trail.');
$attempt = (int)($run['attempt_count'] ?? 1);
$maxAttempts = max(1, (int)($run['max_attempts'] ?? 3));
if ($attempt < $maxAttempts) {
$backoffMinutes = min(15, 2 ** max(0, $attempt - 1));
$db->query(
"UPDATE xlvask_autopilot_runs SET status = 'queued', phase = 'retry_wait', error = '{$message}',
warning = 'Transient failure; retry scheduled.', lease_token = NULL, lease_expires_at = NULL,
next_attempt_at = DATE_ADD(NOW(), INTERVAL {$backoffMinutes} MINUTE)
WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'"
);
} else {
$db->query(
"UPDATE xlvask_autopilot_runs SET status = 'failed', phase = 'failed', error = '{$message}',
lease_token = NULL, lease_expires_at = NULL, finished_at = NOW()
WHERE id = {$runId} AND lease_token = '" . $db->escape_string($leaseToken) . "'"
);
}
}
return $this->getRun($runId, isset($run['created_by']) ? (int)$run['created_by'] : null, $scopeHallIds ?? []);
}
public function getRun(int $runId, ?int $actorId = null, array $allowedHallIds = []): array
{
global $db;
$result = $db->query("SELECT * FROM xlvask_autopilot_runs WHERE id = {$runId} LIMIT 1");
if ($result === false || $result->num_rows < 1) {
throw new Exception('XL Vask autopilot run not found.');
}
$row = $db->fetch_assoc($result);
$runScope = json_decode((string)($row['scope_hall_ids_json'] ?? '[]'), true);
$runScope = is_array($runScope) ? $this->normalizeHallIds($runScope) : [];
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
if ($actorId !== null && $allowedHallIds === []) {
throw new Exception('No XL Vask hall scope is available for this user.');
}
if ($actorId !== null && (int)($row['created_by'] ?? 0) !== $actorId) {
throw new Exception('XL Vask autopilot run belongs to another user.');
}
if ($allowedHallIds !== [] && array_diff($runScope, $allowedHallIds) !== []) {
throw new Exception('XL Vask autopilot run is outside the current hall scope.');
}
return $this->formatRun($row);
}
public function getSummary(?string $dateFrom = null, ?string $dateTo = null, array $allowedHallIds = []): array
{
global $db;
$states = [
'total', 'new', 'updated', 'unchanged', 'invalid', 'already_linked', 'auto_linked',
'auto_created', 'needs_review', 'blocked', 'ignored', 'failed', 'certain', 'uncertain', 'none',
];
$summary = array_fill_keys($states, 0);
$startExpression = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
$where = ['1=1'];
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
if ($allowedHallIds === []) {
throw new Exception('No XL Vask hall scope is available for this user.');
}
$where[] = 'HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')';
if ($dateFrom !== null && strtotime($dateFrom) !== false) {
$where[] = "{$startExpression} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'";
}
if ($dateTo !== null && strtotime($dateTo) !== false) {
$where[] = "{$startExpression} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
}
try {
$row = $db->fetch_assoc($db->query(
"SELECT COUNT(*) total,
SUM(import_state = 'new') `new`, SUM(import_state = 'updated') updated,
SUM(import_state = 'unchanged') unchanged, SUM(import_state = 'invalid') invalid,
SUM(resolution_state = 'already_linked') already_linked,
SUM(resolution_state = 'auto_linked') auto_linked,
SUM(resolution_state = 'auto_created') auto_created,
SUM(resolution_state = 'needs_review') needs_review,
SUM(resolution_state = 'blocked') blocked,
SUM(resolution_state = 'ignored') ignored,
SUM(resolution_state = 'failed') failed,
SUM(certainty = 'certain') certain, SUM(certainty = 'uncertain') uncertain,
SUM(certainty = 'none') none
FROM xlvask_usage_logs WHERE " . implode(' AND ', $where)
));
foreach ($summary as $key => $_) {
$summary[$key] = (int)($row[$key] ?? 0);
}
} catch (Throwable) {
$row = $db->fetch_assoc($db->query(
'SELECT COUNT(*) total FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where)
));
$summary['total'] = (int)($row['total'] ?? 0);
$summary['needs_review'] = $summary['total'];
}
return $summary;
}
public function activationReadiness(): array
{
global $db;
$active = [];
try {
$result = $db->query(
"SELECT id, policy_version, segment_key, precision_value, wilson_lower_bound,
holdout_examples, segment_examples, contradictions, calibrated_probability,
artifact_hash, backtest_json, activated_at
FROM xlvask_automation_calibrations WHERE active = 1 ORDER BY segment_key"
);
if ($result !== false) {
$verified = [];
foreach ($db->fetch_all($result) as $row) {
$artifact = json_decode((string)($row['backtest_json'] ?? ''), true);
if (!is_array($artifact)
|| ($artifact['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|| ($artifact['policy_version'] ?? null) !== (string)$row['policy_version']
|| ($artifact['segment_key'] ?? null) !== (string)$row['segment_key']
|| !hash_equals(
(string)($row['artifact_hash'] ?? ''),
hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact))
)) {
continue;
}
$verified[] = [
...$artifact,
'id' => (int)$row['id'],
'active' => true,
'artifact_hash' => (string)$row['artifact_hash'],
'activated_at' => $row['activated_at'] ?? null,
];
}
$active = array_values(array_filter(
$verified,
static fn(array $artifact): bool => xlvask_automation_service::classifyCertaintyForAutomation($artifact) === 'certain'
));
}
} catch (Throwable) {
// Readiness GET is intentionally read-only and fails closed when schema is not ready.
}
return [
'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(),
'active_calibrations' => $active,
'automatic_actions_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady() && $active !== [],
'wash_id_activation_phrase' => 'ACTIVATE-WASH-ID-UNIQUENESS',
];
}
public function adjudicateCalibrationLabel(int $suggestionId, string $outcome, ?int $actorId): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
if ($suggestionId < 1 || !in_array($outcome, ['correct', 'incorrect'], true) || $actorId === null) {
throw new Exception('Invalid XL Vask calibration adjudication.');
}
$suggestion = $db->query(
"SELECT id, policy_version, source, action FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1"
);
if ($suggestion === false || $suggestion->num_rows < 1) {
throw new Exception('XL Vask suggestion not found for calibration adjudication.');
}
$outcomeSql = $db->escape_string($outcome);
if ($db->query(
"INSERT INTO xlvask_automation_calibration_label_events
(suggestion_id, outcome, adjudicated_by, adjudicated_at)
VALUES ({$suggestionId}, '{$outcomeSql}', {$actorId}, NOW())"
) === false) {
throw new Exception('XL Vask calibration adjudication could not be stored.');
}
return ['suggestion_id' => $suggestionId, 'outcome' => $outcome, 'adjudicated' => true];
}
/** Generate an inactive, PII-free artifact from exact admin-adjudicated suggestion labels. */
public function generateCalibrationArtifact(string $segmentKey, ?int $actorId): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
if (!preg_match('/^(deterministic|fuzzy|history):(attach_order|create_order)$/', $segmentKey)) {
throw new Exception('Invalid XL Vask calibration segment.');
}
[$source, $action] = explode(':', $segmentKey, 2);
$sourceSql = $db->escape_string($source);
$actionSql = $db->escape_string($action);
$labels = $db->fetch_all($db->query(
"SELECT l.id, l.suggestion_id, l.outcome, l.adjudicated_at,
s.policy_version, s.source, s.action, s.created_at AS suggestion_created_at
FROM xlvask_automation_calibration_label_events l
LEFT JOIN xlvask_automation_calibration_label_events newer
ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id
INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id
WHERE s.source = '{$sourceSql}' AND s.action = '{$actionSql}'
AND s.policy_version = '" . self::POLICY_VERSION . "'
AND newer.id IS NULL
ORDER BY s.created_at ASC, s.id ASC, l.id ASC"
));
$overallRow = $db->fetch_assoc($db->query(
"SELECT COUNT(*) AS total
FROM xlvask_automation_calibration_label_events l
LEFT JOIN xlvask_automation_calibration_label_events newer
ON newer.suggestion_id = l.suggestion_id AND newer.id > l.id
INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id
WHERE s.policy_version = '" . self::POLICY_VERSION . "' AND newer.id IS NULL"
));
$overallExamples = (int)($overallRow['total'] ?? 0);
$total = count($labels);
$trainingExamples = (int)floor($total * 0.8);
$holdout = array_slice($labels, $trainingExamples);
$holdoutExamples = count($holdout);
$accepted = count(array_filter($holdout, static fn(array $label): bool => $label['outcome'] === 'correct'));
$denied = count(array_filter($holdout, static fn(array $label): bool => $label['outcome'] === 'incorrect'));
$contradictions = count(array_filter($labels, static fn(array $label): bool => $label['outcome'] === 'incorrect'));
$precision = $holdoutExamples > 0 ? $accepted / $holdoutExamples : 0.0;
$wilson = $this->wilsonLowerBound($accepted, $holdoutExamples);
$snapshot = array_map(static fn(array $label): array => [
'id' => (int)$label['id'],
'suggestion_id' => (int)$label['suggestion_id'],
'outcome' => (string)$label['outcome'],
'adjudicated_at' => (string)$label['adjudicated_at'],
'suggestion_created_at' => (string)$label['suggestion_created_at'],
'policy_version' => (string)$label['policy_version'],
'source' => (string)$label['source'],
'action' => (string)$label['action'],
], $labels);
$artifact = [
'policy_version' => self::POLICY_VERSION,
'segment_key' => $segmentKey,
'split_rule' => 'chronological_80_20_by_suggestion_created_at_and_id',
'training_examples' => $trainingExamples,
'holdout_examples' => $holdoutExamples,
'overall_examples' => $overallExamples,
'segment_examples' => $total,
'correct_holdout_examples' => $accepted,
'incorrect_holdout_examples' => $denied,
'contradictions' => $contradictions,
'precision_value' => round($precision, 6),
'wilson_lower_bound' => round($wilson, 6),
'calibrated_probability' => round($precision, 6),
'label_snapshot' => $snapshot,
'label_snapshot_hash' => hash('sha256', xlvask_automation_service::stableJsonForAutomation($snapshot)),
];
$artifactHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact));
$artifactJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($artifact));
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
$db->query(
"INSERT INTO xlvask_automation_calibrations
(policy_version, segment_key, precision_value, wilson_lower_bound, holdout_examples,
segment_examples, contradictions, calibrated_probability, artifact_hash, active, backtest_json, created_by)
VALUES ('" . self::POLICY_VERSION . "', '" . $db->escape_string($segmentKey) . "', " . round($precision, 6) . ",
" . round($wilson, 6) . ", {$holdoutExamples}, {$total}, {$contradictions}, " . round($precision, 6) . ",
'{$artifactHash}', 0, '{$artifactJson}', {$actorSql})
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)"
);
$id = (int)$db->insert_id();
$qualifies = xlvask_automation_service::classifyCertaintyForAutomation([
...$artifact,
'active' => true,
]) === 'certain';
return [
'id' => $id,
...$artifact,
'artifact_hash' => $artifactHash,
'active' => false,
'qualifies_for_activation' => $qualifies,
'activation_phrase' => 'ACTIVATE-CALIBRATION-' . $id,
];
}
public function activateCalibration(int $id, string $artifactHash, string $confirmationText, ?int $actorId): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
if ($id < 1 || !preg_match('/^[0-9a-f]{64}$/', $artifactHash)
|| !hash_equals('ACTIVATE-CALIBRATION-' . $id, trim($confirmationText))) {
throw new Exception('Invalid XL Vask calibration activation confirmation.');
}
$connection = $db->conn();
$connection->begin_transaction();
try {
$result = $db->query("SELECT * FROM xlvask_automation_calibrations WHERE id = {$id} FOR UPDATE");
$row = $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null;
if ($row === null || !hash_equals((string)$row['artifact_hash'], $artifactHash)) {
throw new Exception('XL Vask calibration artifact not found or changed.');
}
$backtest = json_decode((string)($row['backtest_json'] ?? ''), true);
if (!is_array($backtest)
|| ($backtest['split_rule'] ?? null) !== 'chronological_80_20_by_suggestion_created_at_and_id'
|| ($backtest['policy_version'] ?? null) !== (string)$row['policy_version']
|| ($backtest['segment_key'] ?? null) !== (string)$row['segment_key']
|| !hash_equals($artifactHash, hash('sha256', xlvask_automation_service::stableJsonForAutomation($backtest)))) {
throw new Exception('XL Vask calibration artifact payload failed integrity verification.');
}
if (xlvask_automation_service::classifyCertaintyForAutomation([...$backtest, 'active' => true]) !== 'certain') {
throw new Exception('XL Vask calibration artifact does not meet the activation thresholds.');
}
$policy = $db->escape_string((string)$row['policy_version']);
$segment = $db->escape_string((string)$row['segment_key']);
$db->query(
"UPDATE xlvask_automation_calibrations SET active = 0
WHERE policy_version = '{$policy}' AND segment_key = '{$segment}' AND active = 1"
);
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
if ($db->query(
"UPDATE xlvask_automation_calibrations
SET active = 1, activated_by = {$actorSql}, activated_at = NOW() WHERE id = {$id} AND active = 0"
) === false || $db->conn()->affected_rows !== 1) {
throw new Exception('XL Vask calibration artifact could not be activated atomically.');
}
$connection->commit();
} catch (Throwable $throwable) {
$connection->rollback();
throw $throwable;
}
return ['id' => $id, 'active' => true, 'segment_key' => (string)$row['segment_key']];
}
public function activateWashIdUniqueness(string $confirmationText): array
{
if (!hash_equals('ACTIVATE-WASH-ID-UNIQUENESS', trim($confirmationText))) {
throw new Exception('Invalid wash-id uniqueness activation confirmation.');
}
$ready = xlvask_usage_logs_schema_bootstrap::applyWashIdUniquenessMigration();
if (!$ready) {
throw new Exception('Wash-id uniqueness activation is blocked by schema readiness or duplicate wash IDs.');
}
return ['wash_id_uniqueness_ready' => true];
}
public function createDecisionPreview(array $input, ?int $actorId = null, array $allowedHallIds = []): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
$ids = array_values(array_unique(array_filter(array_map(
'intval',
is_array($input['usage_log_ids'] ?? null) ? $input['usage_log_ids'] : []
))));
if ($ids === []) {
throw new Exception('At least one XL Vask usage log is required.');
}
if (count($ids) > 100) {
throw new Exception('At most 100 XL Vask usage logs can be reviewed at once.');
}
$action = strtolower(trim((string)($input['action'] ?? '')));
if (!in_array($action, ['accept', 'deny', 'ignore', 'attach_order', 'create_order'], true)) {
throw new Exception('Invalid XL Vask review action.');
}
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
if ($allowedHallIds === []) {
throw new Exception('No XL Vask hall scope is available for this user.');
}
$result = $db->query(
'SELECT id, WashId, import_state, resolution_state, certainty, planned_action, expected_version, source_hash '
. 'FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ') AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ') ORDER BY id'
);
$rows = $db->fetch_all($result);
if (count($rows) !== count($ids)) {
throw new Exception('One or more XL Vask usage logs were not found.');
}
$items = [];
foreach ($rows as $row) {
$usageId = (int)$row['id'];
if ((string)($row['import_state'] ?? '') === 'invalid') {
throw new Exception("XL Vask usage log {$usageId} has invalid source data.");
}
$suggestion = null;
if ($action !== 'ignore') {
$suggestionResult = $db->query(
"SELECT id, usage_log_id, action, matched_order_id FROM xlvask_automation_suggestions
WHERE usage_log_id = {$usageId} AND status = 'suggested' ORDER BY id DESC LIMIT 1"
);
$suggestion = $suggestionResult !== false && $suggestionResult->num_rows > 0
? $db->fetch_assoc($suggestionResult) : null;
if ($suggestion === null) {
throw new Exception("XL Vask usage log {$usageId} has no actionable suggestion.");
}
if (isset($input['suggestion_id']) && count($rows) === 1 && (int)$suggestion['id'] !== (int)$input['suggestion_id']) {
throw new Exception('The selected XL Vask suggestion is stale.');
}
if ($action === 'attach_order' && (int)($input['order_id'] ?? 0) > 0) {
$candidate = (new xlvask_automation_service())->validateManualCandidate(
$usageId,
(int)$input['order_id']
);
$suggestion['matched_order_id'] = (int)$candidate['id'];
}
}
$items[] = [
'usage_log_id' => $usageId,
'suggestion_id' => $suggestion === null ? null : (int)$suggestion['id'],
'candidate_order_id' => $suggestion === null || $suggestion['matched_order_id'] === null
? null : (int)$suggestion['matched_order_id'],
'suggested_action' => $suggestion['action'] ?? null,
'before' => [
'resolution_state' => (string)$row['resolution_state'],
'certainty' => (string)$row['certainty'],
'planned_action' => (string)$row['planned_action'],
'expected_version' => (int)$row['expected_version'],
'source_hash' => (string)$row['source_hash'],
],
'after' => ['action' => $action],
'warnings' => [],
];
}
$requiresConfirmation = count($items) > 1 || in_array($action, ['deny', 'ignore'], true);
$confirmationPhrase = $requiresConfirmation ? 'CONFIRM-' . strtoupper(bin2hex(random_bytes(4))) : null;
$payload = [
'action' => $action,
'items' => $items,
'reason' => mb_substr(trim((string)($input['reason'] ?? '')), 0, 1000),
'confirmation_phrase' => $confirmationPhrase,
];
$selectionHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($payload));
$id = $this->uuidV4();
$expiresAt = date('Y-m-d H:i:s', time() + self::PREVIEW_TTL_SECONDS);
$payloadJson = $db->escape_string(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
$db->query(
"INSERT INTO xlvask_automation_decision_previews
(id, selection_hash, action, payload_json, created_by, expires_at)
VALUES ('{$id}', '{$selectionHash}', '" . $db->escape_string($action) . "', '{$payloadJson}', {$actorSql}, '{$expiresAt}')"
);
return [
'id' => $id,
'selection_hash' => $selectionHash,
'expires_at' => $expiresAt,
'action' => $action,
'items' => $items,
'requires_confirmation' => $requiresConfirmation,
'confirmation_phrase' => $confirmationPhrase,
];
}
public function applyDecision(array $input, ?int $actorId = null, array $allowedHallIds = []): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
$previewId = trim((string)($input['preview_id'] ?? ''));
$selectionHash = trim((string)($input['selection_hash'] ?? ''));
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $previewId)
|| !preg_match('/^[0-9a-f]{64}$/i', $selectionHash)) {
throw new Exception('Invalid XL Vask decision preview identifiers.');
}
$connection = $db->conn();
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
if ($allowedHallIds === []) {
throw new Exception('No XL Vask hall scope is available for this user.');
}
$connection->begin_transaction();
try {
$result = $db->query(
"SELECT * FROM xlvask_automation_decision_previews WHERE id = '" . $db->escape_string($previewId) . "' FOR UPDATE"
);
if ($result === false || $result->num_rows < 1) {
throw new Exception('XL Vask decision preview not found.');
}
$preview = $db->fetch_assoc($result);
if ((int)($preview['created_by'] ?? 0) !== (int)($actorId ?? 0)) {
throw new Exception('XL Vask decision preview belongs to another user.');
}
if (!hash_equals((string)$preview['selection_hash'], $selectionHash)) {
throw new Exception('XL Vask decision preview changed.');
}
if ($preview['applied_at'] !== null || strtotime((string)$preview['expires_at']) < time()) {
throw new Exception('XL Vask decision preview has expired or was already applied.');
}
$payload = json_decode((string)$preview['payload_json'], true);
if (!is_array($payload)) {
throw new Exception('XL Vask decision preview is invalid.');
}
$requiresConfirmation = count($payload['items'] ?? []) > 1 || in_array($payload['action'] ?? '', ['deny', 'ignore'], true);
$confirmationPhrase = (string)($payload['confirmation_phrase'] ?? '');
if ($requiresConfirmation && ($confirmationPhrase === ''
|| !hash_equals($confirmationPhrase, trim((string)($input['confirmation_text'] ?? ''))))) {
throw new Exception('Confirmation text does not match the preview-issued phrase.');
}
$automation = new xlvask_automation_service();
$results = [];
foreach ($payload['items'] as $item) {
$usageId = (int)$item['usage_log_id'];
$expected = (int)$item['before']['expected_version'];
$locked = $db->query(
"SELECT expected_version, source_hash, HallId FROM xlvask_usage_logs WHERE id = {$usageId} FOR UPDATE"
);
$current = $locked !== false && $locked->num_rows > 0 ? $db->fetch_assoc($locked) : null;
if ($current === null
|| !in_array(trim((string)$current['HallId']), $allowedHallIds, true)
|| !self::previewSnapshotMatches($expected, (string)$item['before']['source_hash'], $current)) {
throw new Exception("XL Vask usage log {$usageId} changed after preview.");
}
$action = (string)$payload['action'];
if ($action === 'ignore') {
$reason = $db->escape_string((string)$payload['reason']);
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
if ($db->query(
"UPDATE xlvask_usage_logs SET ignored_at = NOW(), ignored_by = {$actorSql}, ignored_reason = '{$reason}',
resolution_state = 'ignored', certainty = 'none', planned_action = 'none', expected_version = expected_version + 1
WHERE id = {$usageId}"
) === false || $db->conn()->affected_rows !== 1) {
throw new Exception('The XL Vask ignore decision could not be applied atomically.');
}
$results[] = ['usage_log_id' => $usageId, 'resolution_state' => 'ignored'];
} else {
$results[] = $automation->applyBoundDecisionWithinTransaction(
$usageId,
$action,
isset($item['suggestion_id']) ? (int)$item['suggestion_id'] : null,
isset($item['candidate_order_id']) && $item['candidate_order_id'] !== null
? (int)$item['candidate_order_id'] : null,
$expected,
(string)$item['before']['source_hash'],
$actorId,
$payload['reason'] ?: null
);
}
}
if ($db->query(
"UPDATE xlvask_automation_decision_previews SET applied_at = NOW() WHERE id = '" . $db->escape_string($previewId) . "'"
) === false || $db->conn()->affected_rows !== 1) {
throw new Exception('The XL Vask decision preview could not be finalized atomically.');
}
$connection->commit();
} catch (Throwable $throwable) {
$connection->rollback();
throw $throwable;
}
return ['applied' => count($results), 'results' => $results, 'failed' => []];
}
private function persistRunItems(int $runId, array $results): void
{
global $db;
foreach ($results as $result) {
$usageId = (int)($result['usage_log_id'] ?? 0);
if ($usageId < 1) {
continue;
}
$rowResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageId} LIMIT 1");
if ($rowResult === false || $rowResult->num_rows < 1) {
continue;
}
$row = $db->fetch_assoc($rowResult);
$durableResult = array_intersect_key($result, array_flip([
'usage_log_id', 'id', 'status', 'action', 'certainty', 'calibrated_probability',
'source', 'matched_order_id', 'created_order_id', 'risk_flags', 'policy_version',
]));
$resultJson = $db->escape_string(json_encode($durableResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
$db->query(
"INSERT INTO xlvask_autopilot_run_items
(run_id, usage_log_id, wash_id, import_state, resolution_state, certainty, planned_action, source_hash, expected_version, result_json)
VALUES ({$runId}, {$usageId}, '" . $db->escape_string((string)$row['WashId']) . "',
'" . $db->escape_string((string)($row['import_state'] ?? 'unchanged')) . "',
'" . $db->escape_string((string)($result['resolution_state'] ?? $row['resolution_state'] ?? 'needs_review')) . "',
'" . $db->escape_string((string)($result['certainty'] ?? $row['certainty'] ?? 'none')) . "',
'" . $db->escape_string((string)($result['planned_action'] ?? $row['planned_action'] ?? 'none')) . "',
'" . $db->escape_string((string)($row['source_hash'] ?? '')) . "', " . (int)($row['expected_version'] ?? 1) . ", '{$resultJson}')
ON DUPLICATE KEY UPDATE resolution_state = VALUES(resolution_state), certainty = VALUES(certainty),
planned_action = VALUES(planned_action), result_json = VALUES(result_json), updated_at = NOW()"
);
}
}
private function formatRun(array $row): array
{
$summary = json_decode((string)($row['summary_json'] ?? ''), true);
return [
'id' => (int)$row['id'],
'status' => (string)$row['status'],
'mode' => (string)$row['mode'],
'date_from' => $row['date_from'] ?: null,
'date_to' => $row['date_to'] ?: null,
'phase' => (string)$row['phase'],
'processed' => (int)$row['processed'],
'total' => (int)$row['total'],
'summary' => is_array($summary) ? $summary : null,
'warning' => $row['warning'] ?: null,
'error' => $row['error'] ?: null,
'created_at' => $row['created_at'],
'started_at' => $row['started_at'] ?: null,
'finished_at' => $row['finished_at'] ?: null,
'attempt_count' => (int)($row['attempt_count'] ?? 0),
'max_attempts' => (int)($row['max_attempts'] ?? 3),
'next_attempt_at' => $row['next_attempt_at'] ?: null,
];
}
public function pruneExpiredData(): array
{
global $db;
xlvask_usage_logs_schema_bootstrap::ensureTables();
$deleted = [];
foreach ([
'previews' => 'DELETE FROM xlvask_automation_decision_previews WHERE expires_at < DATE_SUB(NOW(), INTERVAL 1 DAY)',
'run_items' => 'DELETE FROM xlvask_autopilot_run_items WHERE created_at < DATE_SUB(NOW(), INTERVAL 90 DAY)',
'audit_events' => 'DELETE FROM xlvask_automation_audit WHERE created_at < DATE_SUB(NOW(), INTERVAL 730 DAY)',
] as $key => $sql) {
if ($db->query($sql) === false) {
throw new Exception('XL Vask autopilot retention cleanup failed.');
}
$deleted[$key] = (int)$db->conn()->affected_rows;
}
return $deleted;
}
private function normalizeDate(mixed $value, string $name): ?string
{
$value = trim((string)($value ?? ''));
if ($value === '') {
return null;
}
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
throw new Exception("Invalid {$name}.");
}
[$year, $month, $day] = array_map('intval', explode('-', $value));
if (!checkdate($month, $day, $year)) {
throw new Exception("Invalid {$name}.");
}
return $value;
}
private function wilsonLowerBound(int $successes, int $total): float
{
if ($total < 1) {
return 0.0;
}
$z = 1.959963984540054;
$p = $successes / $total;
$zSquared = $z * $z;
$denominator = 1 + ($zSquared / $total);
$centre = $p + ($zSquared / (2 * $total));
$margin = $z * sqrt((($p * (1 - $p)) + ($zSquared / (4 * $total))) / $total);
return max(0.0, ($centre - $margin) / $denominator);
}
private function normalizeHallIds(array $hallIds): array
{
return self::normalizeHallScope($hallIds);
}
private function quotedHallIds(array $hallIds): string
{
global $db;
return implode(',', array_map(
static fn(string $id): string => "'" . $db->escape_string($id) . "'",
$hallIds
));
}
private function uuidV4(): string
{
$data = random_bytes(16);
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
}
}
@@ -31,6 +31,19 @@ class xlvask_usage_logs_schema_bootstrap
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name'); self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_hash', 'CHAR(64) NULL AFTER cached_amount_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_revision', 'VARCHAR(128) NULL AFTER source_hash');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observed_at', 'DATETIME NULL AFTER source_revision');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_stable_since', 'DATETIME NULL AFTER source_observed_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observation_count', 'INT NOT NULL DEFAULT 0 AFTER source_stable_since');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'import_state', "VARCHAR(24) NOT NULL DEFAULT 'unchanged' AFTER source_observation_count");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'resolution_state', "VARCHAR(32) NOT NULL DEFAULT 'needs_review' AFTER import_state");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'certainty', "VARCHAR(16) NOT NULL DEFAULT 'none' AFTER resolution_state");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'planned_action', "VARCHAR(32) NOT NULL DEFAULT 'none' AFTER certainty");
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'state_reason', 'TEXT NULL AFTER planned_action');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'expected_version', 'INT NOT NULL DEFAULT 1 AFTER state_reason');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_run_id', 'BIGINT NULL AFTER expected_version');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
self::ensureAutomationTables($db); self::ensureAutomationTables($db);
self::$initialized = true; self::$initialized = true;
@@ -68,6 +81,23 @@ class xlvask_usage_logs_schema_bootstrap
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
); );
foreach ([
'run_id' => 'BIGINT NULL AFTER usage_log_id',
'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source",
'model' => 'VARCHAR(96) NULL AFTER policy_version',
'model_confidence' => 'DECIMAL(5,4) NULL AFTER model',
'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence',
'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability",
'evidence_json' => 'LONGTEXT NULL AFTER certainty',
'contradictions_json' => 'LONGTEXT NULL AFTER evidence_json',
'risk_flags_json' => 'LONGTEXT NULL AFTER contradictions_json',
'plan_steps_json' => 'LONGTEXT NULL AFTER risk_flags_json',
'expected_version' => 'INT NULL AFTER plan_steps_json',
'input_hash' => 'CHAR(64) NULL AFTER expected_version',
] as $column => $definition) {
self::addColumnIfMissing($db, 'xlvask_automation_suggestions', $column, $definition);
}
$db->query( $db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` ( "CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
`id` INT NOT NULL AUTO_INCREMENT, `id` INT NOT NULL AUTO_INCREMENT,
@@ -104,6 +134,220 @@ class xlvask_usage_logs_schema_bootstrap
KEY `idx_xlvask_openai_cache_schema` (`schema_name`) KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
); );
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_runs` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`idempotency_key` CHAR(64) NOT NULL,
`mode` VARCHAR(16) NOT NULL,
`status` VARCHAR(24) NOT NULL DEFAULT 'queued',
`phase` VARCHAR(32) NOT NULL DEFAULT 'queued',
`date_from` DATE NULL,
`date_to` DATE NULL,
`force_refetch` TINYINT(1) NOT NULL DEFAULT 0,
`requested_ids_json` LONGTEXT NULL,
`requested_limit` INT NOT NULL DEFAULT 500,
`request_hash` CHAR(64) NOT NULL,
`scope_hall_ids_json` LONGTEXT NOT NULL,
`processed` INT NOT NULL DEFAULT 0,
`total` INT NOT NULL DEFAULT 0,
`summary_json` LONGTEXT NULL,
`warning` TEXT NULL,
`error` TEXT NULL,
`lease_token` CHAR(36) NULL,
`lease_expires_at` DATETIME NULL,
`attempt_count` INT NOT NULL DEFAULT 0,
`max_attempts` INT NOT NULL DEFAULT 3,
`next_attempt_at` DATETIME NULL,
`created_by` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`started_at` DATETIME NULL,
`finished_at` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`),
KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_token', 'CHAR(36) NULL AFTER error');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_expires_at', 'DATETIME NULL AFTER lease_token');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit");
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`run_id` BIGINT NOT NULL,
`usage_log_id` INT NULL,
`wash_id` VARCHAR(128) NULL,
`import_state` VARCHAR(24) NOT NULL DEFAULT 'unchanged',
`resolution_state` VARCHAR(32) NOT NULL DEFAULT 'needs_review',
`certainty` VARCHAR(16) NOT NULL DEFAULT 'none',
`planned_action` VARCHAR(32) NOT NULL DEFAULT 'none',
`source_hash` CHAR(64) NULL,
`expected_version` INT NULL,
`result_json` LONGTEXT NULL,
`error` TEXT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_autopilot_run_usage` (`run_id`, `usage_log_id`),
KEY `idx_xlvask_autopilot_run_item_state` (`run_id`, `resolution_state`, `certainty`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_audit` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`run_id` BIGINT NULL,
`usage_log_id` INT NULL,
`wash_id` VARCHAR(128) NULL,
`event_type` VARCHAR(48) NOT NULL,
`action` VARCHAR(32) NULL,
`policy_version` VARCHAR(64) NOT NULL,
`input_hash` CHAR(64) NULL,
`source_revision` VARCHAR(128) NULL,
`expected_version` INT NULL,
`before_json` LONGTEXT NULL,
`after_json` LONGTEXT NULL,
`evidence_json` LONGTEXT NULL,
`actor_id` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_audit_usage` (`usage_log_id`, `created_at`),
KEY `idx_xlvask_audit_run` (`run_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibrations` (
`id` INT NOT NULL AUTO_INCREMENT,
`policy_version` VARCHAR(64) NOT NULL,
`segment_key` VARCHAR(191) NOT NULL,
`precision_value` DECIMAL(7,6) NOT NULL,
`wilson_lower_bound` DECIMAL(7,6) NOT NULL,
`holdout_examples` INT NOT NULL,
`segment_examples` INT NOT NULL,
`contradictions` INT NOT NULL DEFAULT 0,
`calibrated_probability` DECIMAL(7,6) NOT NULL,
`artifact_hash` CHAR(64) NOT NULL,
`active` TINYINT(1) NOT NULL DEFAULT 0,
`backtest_json` LONGTEXT NULL,
`created_by` INT NULL,
`activated_by` INT NULL,
`activated_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`),
KEY `idx_xlvask_calibration_lookup` (`policy_version`, `segment_key`, `active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'backtest_json', 'LONGTEXT NULL AFTER active');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by');
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by');
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`suggestion_id` INT NOT NULL,
`outcome` VARCHAR(16) NOT NULL,
`adjudicated_by` INT NOT NULL,
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_calibration_label_suggestion` (`suggestion_id`),
KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
// Immutable adjudication events supersede the legacy one-row-per-suggestion table.
// The nullable legacy id supports an idempotent, non-destructive backfill.
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_label_events` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`suggestion_id` INT NOT NULL,
`outcome` VARCHAR(16) NOT NULL,
`adjudicated_by` INT NOT NULL,
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`legacy_label_id` BIGINT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_xlvask_calibration_legacy_label` (`legacy_label_id`),
KEY `idx_xlvask_calibration_event_suggestion` (`suggestion_id`, `id`),
KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"INSERT IGNORE INTO xlvask_automation_calibration_label_events
(suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id)
SELECT suggestion_id, outcome, adjudicated_by, adjudicated_at, id
FROM xlvask_automation_calibration_labels"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_decision_previews` (
`id` CHAR(36) NOT NULL,
`selection_hash` CHAR(64) NOT NULL,
`action` VARCHAR(32) NOT NULL,
`payload_json` LONGTEXT NOT NULL,
`created_by` INT NULL,
`expires_at` DATETIME NOT NULL,
`applied_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
}
public static function washIdUniquenessReady(): bool
{
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return false;
}
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
}
/**
* Explicit activation migration. Never call this from constructors, GETs, or normal runs.
* Returns false without modifying conflicting records when duplicate wash IDs exist.
*/
public static function applyWashIdUniquenessMigration(): bool
{
global $db;
if (!self::tableExists($db, 'orders') || !self::columnExists($db, 'orders', 'wash_id')) {
return false;
}
if (self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id')) {
return true;
}
$duplicates = $db->query(
"SELECT LOWER(TRIM(`wash_id`)) normalized_wash_id FROM `orders`
WHERE `wash_id` IS NOT NULL AND TRIM(`wash_id`) <> ''
GROUP BY LOWER(TRIM(`wash_id`)) HAVING COUNT(*) > 1 LIMIT 1"
);
if ($duplicates !== false && is_object($duplicates) && (int)$duplicates->num_rows === 0) {
if (!self::columnExists($db, 'orders', 'xlvask_normalized_wash_id')) {
$db->query(
"ALTER TABLE `orders` ADD COLUMN `xlvask_normalized_wash_id` VARCHAR(128)
GENERATED ALWAYS AS (NULLIF(LOWER(TRIM(`wash_id`)), '')) STORED"
);
}
$db->query(
"ALTER TABLE `orders` ADD UNIQUE KEY `uniq_orders_xlvask_wash_id` (`xlvask_normalized_wash_id`)"
);
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
}
return false;
} }
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
@@ -138,6 +382,18 @@ class xlvask_usage_logs_schema_bootstrap
return (int)$result->num_rows > 0; return (int)$result->num_rows > 0;
} }
private static function indexExists(object $db, string $table, string $index): bool
{
if (!self::tableExists($db, $table)) {
return false;
}
$table = self::escapeIdentifier($table);
$index = self::escapeIdentifier($index);
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
return $result !== false && is_object($result) && (int)$result->num_rows > 0;
}
private static function escapeIdentifier(string $value): string private static function escapeIdentifier(string $value): string
{ {
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value); return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
+15
View File
@@ -150,6 +150,12 @@ $cron_tasks = [
'next_run' => 0, 'next_run' => 0,
'function' => 'SyncXLVaskModuleCron', 'function' => 'SyncXLVaskModuleCron',
], ],
'ProcessXLVaskAutopilotQueueCron' => [
'interval' => 60,
'last_run' => 0,
'next_run' => 0,
'function' => 'ProcessXLVaskAutopilotQueueCron',
],
'SystemSearchCacheMaintenanceCron' => [ 'SystemSearchCacheMaintenanceCron' => [
'interval' => 300, // 5 minutes 'interval' => 300, // 5 minutes
'last_run' => 0, 'last_run' => 0,
@@ -656,6 +662,15 @@ function SyncXLVaskModuleCron(): void
} }
} }
function ProcessXLVaskAutopilotQueueCron(): array
{
$xlvask = new xlvask();
if (!$xlvask->config->enabled->isTrue()) {
return [];
}
return $xlvask->getTasks()->processAutopilotQueue(3);
}
function EconomicTransferQueueCron(): void function EconomicTransferQueueCron(): void
{ {
try { try {
@@ -28,10 +28,12 @@ class xlvask_request implements xlvask_request_i
}; };
// Set the headers // Set the headers
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
// Set options to return the response and handle SSL // Verify the upstream certificate and keep requests bounded.
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
// Execute the request // Execute the request
$response = curl_exec($ch); $response = curl_exec($ch);
// Check for errors // Check for errors
@@ -45,9 +47,12 @@ class xlvask_request implements xlvask_request_i
// Check if the response is successful // Check if the response is successful
if ($httpCode < 200 || $httpCode >= 300) { if ($httpCode < 200 || $httpCode >= 300) {
$slack = new slack(); $slack = new slack();
$urlParts = parse_url($url);
$safeEndpoint = (string)($urlParts['host'] ?? 'unknown-host') . (string)($urlParts['path'] ?? '');
$slack->send_message( $slack->send_message(
'XLVask API Request Failed', 'XLVask API Request Failed',
"URL: $url\nMethod: $method\nData: " . json_encode($data) . "\nHeaders: " . implode(', ', $headers) . "\nHTTP Code: $httpCode\nResponse: $response" "Endpoint: {$safeEndpoint}\nMethod: {$method}\nHTTP Code: {$httpCode}\n"
. 'Request fields: ' . count($data) . "\nResponse bytes: " . strlen((string)$response)
); );
throw new \Exception('Request failed with status code ' . $httpCode); throw new \Exception('Request failed with status code ' . $httpCode);
} }
@@ -1,6 +1,18 @@
<?php <?php
return [ return [
[
'id' => 'xlvask.autopilot_queue',
'legacy_name' => 'ProcessXLVaskAutopilotQueueCron',
'name' => 'Process XL Vask autopilot queue',
'description' => 'Claims and processes a bounded batch of queued XL Vask autopilot runs.',
'module' => 'xlvask',
'handler' => 'ProcessXLVaskAutopilotQueueCron',
'schedule' => ['type' => 'interval', 'seconds' => 60],
'timeout_seconds' => 300,
'estimated_duration_ms' => 5000,
'priority' => 40,
],
[ [
'id' => 'xlvask.sync_module', 'id' => 'xlvask.sync_module',
'legacy_name' => 'SyncXLVaskModuleCron', 'legacy_name' => 'SyncXLVaskModuleCron',
@@ -2,15 +2,15 @@
namespace helpers; namespace helpers;
require_once WD . '/classes/xlvask_automation_service.php'; require_once WD . '/classes/xlvask_autopilot_service.php';
use classes\xlvask_automation_service; use classes\xlvask_autopilot_service;
use Exception; use Exception;
use objects\orders_o; use objects\orders_o;
use objects\plate_scanners_o;
use objects\users_o; use objects\users_o;
use objects\xlvask_customers_o; use objects\xlvask_customers_o;
use objects\xlvask_potential_order_matches_o; use objects\xlvask_potential_order_matches_o;
use objects\xlvask_usage_logs_o;
use objects\xlvask_vehicles_o; use objects\xlvask_vehicles_o;
class xlvask_tasks class xlvask_tasks
@@ -69,11 +69,45 @@ class xlvask_tasks
// - xlvask_usage_logs // - xlvask_usage_logs
(new xlvask_customers_o())->importCustomers(); (new xlvask_customers_o())->importCustomers();
(new xlvask_vehicles_o())->importVehicles(); (new xlvask_vehicles_o())->importVehicles();
(new xlvask_usage_logs_o())->importUsageLogs(); $hallIds = $this->configuredHallIds();
(new xlvask_automation_service())->runPending(null, null, [], 100, null); if ($hallIds !== []) {
$autopilot = new xlvask_autopilot_service();
$autopilot->createRun(['mode' => 'execute'], null, $hallIds);
$autopilot->processQueuedRuns(3);
}
}; };
} }
/** Drain the durable autopilot queue without running the hourly upstream synchronization. */
public function processAutopilotQueue(int $limit = 3): array
{
$xlvask = new \classes\xlvask();
$xlvask->requireModuleEnabled();
if (!$xlvask->config->synchronization_enabled->isTrue()) {
return [];
}
return (new xlvask_autopilot_service())->processQueuedRuns($limit);
}
/** Hall GUIDs are configuration, not derived from already-imported usage rows. */
private function configuredHallIds(): array
{
global $db;
(new plate_scanners_o())->structure();
plate_scanners_o::ensureHallIdSchemaForMutation();
$result = $db->query(
"SELECT DISTINCT HallId FROM plate_scanners
WHERE HallId IS NOT NULL AND TRIM(HallId) <> '' AND deleted_at IS NULL"
);
if ($result === false) {
return [];
}
return array_values(array_unique(array_filter(array_map(
static fn(array $row): string => trim((string)($row['HallId'] ?? '')),
$db->fetch_all($result)
), static fn(string $id): bool => $id !== '' && strlen($id) <= 191)));
}
/** /**
* Run the synchronize users task * Run the synchronize users task
* This task fetches the users from XL Vask, and synchronizes them with this system. * This task fetches the users from XL Vask, and synchronizes them with this system.
@@ -324,6 +358,6 @@ class xlvask_tasks
$xlvask = new \classes\xlvask(); $xlvask = new \classes\xlvask();
// Require the module to be enabled // Require the module to be enabled
$xlvask->requireModuleEnabled(); $xlvask->requireModuleEnabled();
// Run (new xlvask_autopilot_service())->pruneExpiredData();
} }
} }
@@ -201,10 +201,18 @@ class plate_scanners_o extends db
if (!self::tableHasColumn('plate_scanners', 'lane_id')) { if (!self::tableHasColumn('plate_scanners', 'lane_id')) {
$db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`"); $db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`");
} }
self::$schemaInitialized = true; self::$schemaInitialized = true;
} }
/** Explicit mutation-path compatibility bootstrap for XL Vask hall scoping. */
public static function ensureHallIdSchemaForMutation(): void
{
global $db;
if (!self::tableHasColumn('plate_scanners', 'HallId')) {
$db->query("ALTER TABLE `plate_scanners` ADD COLUMN `HallId` VARCHAR(191) NULL AFTER `lane_id`");
}
}
private static function tableHasColumn(string $table, string $column): bool private static function tableHasColumn(string $table, string $column): bool
{ {
global $db; global $db;
@@ -42,7 +42,6 @@ class xlvask_usage_logs_o extends db
public function structure(): void public function structure(): void
{ {
xlvask_usage_logs_schema_bootstrap::ensureTables();
$this->setTable('xlvask_usage_logs'); $this->setTable('xlvask_usage_logs');
} }
@@ -117,6 +116,22 @@ class xlvask_usage_logs_o extends db
]; ];
} }
/** 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 public static function calculateAmountSummaryFromWashItems(array|string|null $washItems): array
{ {
if (is_string($washItems)) { if (is_string($washItems)) {
@@ -202,40 +217,284 @@ class xlvask_usage_logs_o extends db
*/ */
public function importUsageLogs(?string $dateFrom = null, ?string $dateTo = null): 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)) { if (!empty($this->id)) {
throw new Exception('To prevent issues, having a selected object is not allowed.'); throw new Exception('To prevent issues, having a selected object is not allowed.');
} }
xlvask_usage_logs_schema_bootstrap::ensureTables();
$usage_logs = $this->getUsageLogsFromXLVask( $usage_logs = $this->getUsageLogsFromXLVask(
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000' self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
); );
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo); $usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */ $upstreamFetched = count($usage_logs);
$known_usage_logIds = array_map(function ($log) { $allowedHallIds = array_values(array_unique(array_filter(array_map(
return $log['WashId']; static fn(mixed $id): string => trim((string)$id),
}, $allowedHallIds
self::getFieldsWhere( ), static fn(string $id): bool => $id !== '')));
['WashId' => array_column($usage_logs, 'WashId')], if ($allowedHallIds !== []) {
['WashId'] $usage_logs = array_values(array_filter(
$usage_logs,
static fn(xlvask_usage_log $log): bool => in_array(trim((string)$log->HallId), $allowedHallIds, true)
)); ));
/** The XL Vask usage logs without a matching WashId in the database */ }
$new_usage_logs = array_filter($usage_logs, function ($log) use ($known_usage_logIds) {
return !in_array($log->WashId, $known_usage_logIds); $washIds = array_values(array_filter(array_map(
}); static fn(xlvask_usage_log $log): string => trim((string)$log->WashId),
unset($known_usage_logIds); $usage_logs
unset($usage_logs); )));
/** Adding the new usage logs */ $knownRows = $washIds === [] ? [] : self::getFieldsWhere(
foreach ($new_usage_logs as $log) { ['WashId' => $washIds],
if (!$log->isValid()) { ['id', 'WashId', 'source_hash', 'source_revision', 'expected_version']
echo $log->formattedDetails(); );
throw new Exception('A usage log from XL Vask is not valid, have the structure changed?'); $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
{
$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;
} }
} }
// Actually save the usage log
foreach ($new_usage_logs as $log) { return $payload;
/** @var xlvask_usage_log $log */ }
$this->add($log->toArray());
private static function sortSourceValue(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
} }
unset($new_usage_logs); $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 private static function formatImportDateFrom(?string $dateFrom): string
+332
View File
@@ -10624,6 +10624,338 @@ paths:
application/json: application/json:
schema: {} schema: {}
/modules/xlvask/services/usage/orders/summary:
get:
tags:
- Modules
summary: Summarize XLVask usage automation states
description: Returns read-only import, resolution, and certainty counts for XLVask usage rows in an invoice period.
operationId: summarizeXlvaskUsageAutomation
parameters:
- in: query
name: dateFrom
required: false
schema:
type: string
format: date
- in: query
name: dateTo
required: false
schema:
type: string
format: date
responses:
'200':
description: XLVask usage automation summary returned successfully
content:
application/json:
schema:
type: object
properties:
summary:
type: object
additionalProperties:
type: integer
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/autopilot-runs:
post:
tags:
- Modules
summary: Create an XLVask usage autopilot run
description: Queues an idempotent invoice-period import and automation evaluation run. Dry-run mode evaluates without automatic execution.
operationId: createXlvaskUsageAutopilotRun
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
dateFrom:
type: string
format: date
dateTo:
type: string
format: date
ids:
type: array
items:
type: integer
limit:
type: integer
minimum: 1
maximum: 500
forceRefetch:
type: boolean
mode:
type: string
enum: [execute, dry_run, replay]
responses:
'202':
description: XLVask usage autopilot run queued successfully
content:
application/json:
schema:
type: object
properties:
run:
type: object
properties:
id:
type: integer
status:
type: string
phase:
type: string
mode:
type: string
processed:
type: integer
total:
type: integer
summary:
type: object
additionalProperties:
type: integer
warning:
type: string
error:
type: string
created_at:
type: string
nullable: true
started_at:
type: string
nullable: true
finished_at:
type: string
nullable: true
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/autopilot-runs/{id}:
get:
tags:
- Modules
summary: Get an XLVask usage autopilot run
description: Returns status and summary metadata for a previously requested autopilot run.
operationId: getXlvaskUsageAutopilotRun
parameters:
- in: path
name: id
required: true
schema:
type: integer
responses:
'200':
description: XLVask usage autopilot run returned successfully
content:
application/json:
schema:
type: object
properties:
run:
type: object
'400':
description: Invalid XLVask autopilot run id
'404':
description: XLVask autopilot run not found
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/decisions/preview:
post:
tags:
- Modules
summary: Preview an XLVask automation decision
description: Creates a short-lived preview token for applying bulk accept, deny, ignore, or link decisions after source revision revalidation.
operationId: previewXlvaskUsageAutomationDecision
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
usage_log_ids:
type: array
items:
type: integer
action:
type: string
enum: [accept, attach_order, create_order, deny, ignore]
suggestion_id:
type: integer
nullable: true
order_id:
type: integer
nullable: true
reason:
type: string
responses:
'200':
description: XLVask automation decision preview created successfully
content:
application/json:
schema:
type: object
properties:
preview:
type: object
properties:
id: { type: string }
selection_hash: { type: string }
requires_confirmation: { type: boolean }
confirmation_phrase:
type: string
nullable: true
description: Opaque preview-issued phrase that must be submitted exactly when confirmation is required.
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/decisions/apply:
post:
tags:
- Modules
summary: Apply an XLVask automation decision preview
description: Applies a previewed decision inside a transactional policy boundary after source hash, expected version, hall scope, and selection hash are revalidated.
operationId: applyXlvaskUsageAutomationDecision
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [preview_id, selection_hash]
properties:
preview_id:
type: string
selection_hash:
type: string
confirmation_text:
type: string
responses:
'200':
description: XLVask automation decision applied successfully
content:
application/json:
schema:
type: object
properties:
applied:
type: integer
results:
type: array
items:
type: object
failed:
type: array
items:
type: object
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/readiness:
get:
tags: [Modules]
summary: Inspect XLVask automation activation readiness
description: Read-only, fail-closed view of wash-id uniqueness and active calibration artifacts.
operationId: getXlvaskAutomationActivationReadiness
responses:
'200':
description: Activation readiness returned successfully
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/calibrations/labels:
post:
tags: [Modules]
summary: Adjudicate one exact XLVask suggestion
description: Stores an administrator-adjudicated correct or incorrect label bound to one suggestion ID.
operationId: adjudicateXlvaskCalibrationLabel
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [suggestion_id, outcome]
properties:
suggestion_id: { type: integer }
outcome: { type: string, enum: [correct, incorrect] }
responses:
'200': { description: Calibration label stored successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/calibrations/backtest:
post:
tags: [Modules]
summary: Generate an inactive XLVask calibration artifact
description: Uses exact adjudicated labels and a chronological 80/20 holdout; generation never activates the artifact.
operationId: generateXlvaskCalibrationArtifact
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [segment_key]
properties:
segment_key: { type: string }
responses:
'200': { description: Inactive calibration artifact generated successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate:
post:
tags: [Modules]
summary: Activate a qualifying XLVask calibration artifact
operationId: activateXlvaskCalibrationArtifact
parameters:
- in: path
name: id
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [artifact_hash, confirmation_text]
properties:
artifact_hash: { type: string }
confirmation_text: { type: string }
responses:
'200': { description: Calibration artifact activated successfully }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate:
post:
tags: [Modules]
summary: Activate guarded wash-id uniqueness
description: Explicitly verifies duplicates, adds the normalized wash-id column and unique index, and fails closed on conflicts.
operationId: activateXlvaskWashIdUniqueness
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [confirmation_text]
properties:
confirmation_text: { type: string }
responses:
'200': { description: Wash-id uniqueness activated successfully }
'409': { description: Duplicate wash IDs or schema readiness blocked activation }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/modules/action-logs: /modules/action-logs:
get: get:
tags: tags:
+21 -12
View File
@@ -2,13 +2,13 @@
namespace routes; namespace routes;
require_once WD . '/classes/xlvask_automation_service.php'; require_once WD . '/classes/xlvask_autopilot_service.php';
use classes\authentication; use classes\authentication;
use classes\response; use classes\response;
use classes\router; use classes\router;
use classes\xlvask; use classes\xlvask;
use classes\xlvask_automation_service; use classes\xlvask_autopilot_service;
use objects\orders_o; use objects\orders_o;
use objects\users_o; use objects\users_o;
use objects\xlvask_customers_o; use objects\xlvask_customers_o;
@@ -255,18 +255,27 @@ class moduleXLVaskRoute
$this->get('/modules/xlvask/tasks/import-usage', function () { $this->get('/modules/xlvask/tasks/import-usage', function () {
global $response; global $response;
self::requirePermission('modules_xlvask_import_usage'); self::requirePermission('modules_xlvask_import_usage');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null; $dateFrom = $this->isParametersSet(['dateFrom']) ? trim((string)$this->getParameter('dateFrom')) : null;
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null; $dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
// Create the xlvask_usage_logs_o object $hallIds = array_values(array_filter(array_map(
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o(); static fn(mixed $id): string => trim((string)$id),
// Import usage logs (array)$user->getGroup()->getDepartmentsScannersHallIds()
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo); ), static fn(string $id): bool => $id !== '' && strlen($id) <= 191));
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null); $run = (new xlvask_autopilot_service())->createRun([
// Response 'dateFrom' => $dateFrom,
$response->success( 'dateTo' => $dateTo,
'Usage logs imported', 'mode' => 'execute',
200 'forceRefetch' => true,
); ], (int)$user->id, $hallIds);
$response->success([
'deprecated' => true,
'replacement' => '/modules/xlvask/services/usage/autopilot-runs',
'run' => $run,
], 202);
}, },
[ [
'modules_xlvask_import_usage' => 'Import usage from the xlvask module' 'modules_xlvask_import_usage' => 'Import usage from the xlvask module'
@@ -3,17 +3,18 @@
namespace routes; namespace routes;
require_once WD . '/classes/xlvask_automation_service.php'; require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
use classes\authentication; use classes\authentication;
use classes\redis; use classes\redis;
use classes\response; use classes\response;
use classes\stripe; use classes\stripe;
use classes\xlvask; use classes\xlvask;
use classes\xlvask_autopilot_service;
use classes\xlvask_automation_service; use classes\xlvask_automation_service;
use objects\collected_order_invoices_o; use objects\collected_order_invoices_o;
use objects\departments_o; use objects\departments_o;
use objects\economic_module_orders; use objects\economic_module_orders;
use objects\logs_o;
use objects\orders_o; use objects\orders_o;
use objects\stripe_module_orders_o; use objects\stripe_module_orders_o;
use objects\stripe_payment_intents_o; use objects\stripe_payment_intents_o;
@@ -31,7 +32,6 @@ class xlvaskUsageLogsRoute
$permission_list_own = 'list_xlvask_usage_orders_own'; // Permission to list own orders (Without department filter) $permission_list_own = 'list_xlvask_usage_orders_own'; // Permission to list own orders (Without department filter)
$permission_list_all = 'list_xlvask_usage_orders_all'; // Permission to list all orders (With department filter) $permission_list_all = 'list_xlvask_usage_orders_all'; // Permission to list all orders (With department filter)
$response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly) $response_includes_items = false; // Whether to include items in the response (This would increase the memory usage significantly)
$response_includes_items_link = true; // Whether to include a cached direct link to fetch the order with items (This is used to avoid memory issues when the items are not needed)
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
self::requirePermission($permission_list_own); self::requirePermission($permission_list_own);
@@ -45,8 +45,11 @@ class xlvaskUsageLogsRoute
$user = (new authentication())->get_user(); $user = (new authentication())->get_user();
// Check if the request was successful // Check if the request was successful
if ($user) { if ($user) {
// Log the incident $allowedHallIds = self::allowedHallIdsForUser($user);
(new logs_o())->add('xlvask_usage_orders', 'global', 1, $user->id, 'LIST_XLVASK_USAGE_ORDERS', 'User accessed the list of xlvask usage orders'); if ($allowedHallIds === []) {
$response->error('No XL Vask hall scope is available', 403);
return;
}
$xlvask_usage_logs = new xlvask_usage_logs_o(); $xlvask_usage_logs = new xlvask_usage_logs_o();
$xlvask = new xlvask(); $xlvask = new xlvask();
$automation_service = new xlvask_automation_service(); $automation_service = new xlvask_automation_service();
@@ -58,19 +61,16 @@ class xlvaskUsageLogsRoute
if (ini_get('memory_limit') < '5120M') { if (ini_get('memory_limit') < '5120M') {
ini_set('memory_limit', '5120M'); ini_set('memory_limit', '5120M');
} }
// Set debug php
error_reporting(E_ALL);
ini_set('display_errors', '1');
// Return the list of usage logs // Return the list of usage logs
$result = $xlvask_usage_logs $result = $xlvask_usage_logs
// Make sure the Customer is not in the default customers list // Make sure the Customer is not in the default customers list
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')") ->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
->listObjectsWithPaginationIfSet( ->listObjectsWithPaginationIfSet(
function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) { function ($log) use ($response_includes_items, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false);
// Remove the 'id' field from the log // Remove the 'id' field from the log
$id = (int)$log['id']; $id = (int)$log['id'];
$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log); $automation = $automation_service->readAutomationStateByUsageLogId($id, $log);
$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log);
unset($log['id']); unset($log['id']);
// Convert the 'WashItems' field from JSON to an array // Convert the 'WashItems' field from JSON to an array
$log['WashItems'] = json_decode($log['WashItems'], true); $log['WashItems'] = json_decode($log['WashItems'], true);
@@ -98,6 +98,19 @@ class xlvaskUsageLogsRoute
'ignored_at', 'ignored_at',
'ignored_by', 'ignored_by',
'ignored_reason', 'ignored_reason',
'source_hash',
'source_revision',
'source_observed_at',
'source_stable_since',
'source_observation_count',
'import_state',
'resolution_state',
'certainty',
'planned_action',
'state_reason',
'expected_version',
'last_run_id',
'last_evaluated_at',
])); ]));
// Create a new xlvask usage log object // Create a new xlvask usage log object
$tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log); $tmp = $xlvask->new($xlvask->helpers->xlvask_usage_log);
@@ -111,16 +124,6 @@ class xlvaskUsageLogsRoute
$linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null; $linked_order_id = $wash_id !== '' ? $linked_order_ids_by_wash_id[$wash_id] : null;
// Define the result structure // Define the result structure
$isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true); $isEligibleForAutomaticContinuance = $tmp->isEligibleForAutomaticContinuance(true);
// Generate a fast link key for the order, used to retrieve the order with items later.
if ($response_includes_items_link && $isEligibleForAutomaticContinuance) {
// Add a link to the order with items
$fast_link_key = redis->generateTemporaryCacheKey();
redis->set(
$fast_link_key,
json_encode($tmp->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
redis->expire($fast_link_key, 3600); // Set the link to expire in 1 hour
}
// Return the result // Return the result
$tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []); $tmp_res = ($isEligibleForAutomaticContinuance ? (new orders_o())->simulateOrderFromXLVask($tmp, $response_includes_items) : []);
$tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order $tmp_res['order']['customer_name'] = $tmp->Customer; // Add the customer name to the order
@@ -129,12 +132,24 @@ class xlvaskUsageLogsRoute
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached']; $tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
// Clear memory // Clear memory
unset($tmp); unset($tmp);
unset($log);
// Return the result // Return the result
return [ return [
'id' => $id, // Return the ID of the log 'id' => $id, // Return the ID of the log
'fast_link_key' => $fast_link_key ?? null, // Return the fast link key if it was generated 'fast_link_key' => null,
'automation' => $automation, 'automation' => $automation,
'source_hash' => $log['source_hash'] ?? null,
'source_revision' => $log['source_revision'] ?? null,
'source_observed_at' => $log['source_observed_at'] ?? null,
'source_stable_since' => $log['source_stable_since'] ?? null,
'source_observation_count' => (int)($log['source_observation_count'] ?? 0),
'import_state' => $log['import_state'] ?? 'unchanged',
'resolution_state' => $log['resolution_state'] ?? 'needs_review',
'certainty' => $log['certainty'] ?? 'none',
'planned_action' => $log['planned_action'] ?? 'none',
'state_reason' => $log['state_reason'] ?? null,
'expected_version' => isset($log['expected_version']) ? (int)$log['expected_version'] : 1,
'last_run_id' => isset($log['last_run_id']) ? (int)$log['last_run_id'] : null,
'last_evaluated_at' => $log['last_evaluated_at'] ?? null,
...$tmp_res['order'], // Return the simulated order from XLVask (with or without items) ...$tmp_res['order'], // Return the simulated order from XLVask (with or without items)
'usage_log_id' => $id, 'usage_log_id' => $id,
'linked_order_id' => $linked_order_id, 'linked_order_id' => $linked_order_id,
@@ -143,7 +158,7 @@ class xlvaskUsageLogsRoute
$xlvask_usage_logs->forceRestrictFilters( $xlvask_usage_logs->forceRestrictFilters(
[ [
// This makes sure that the user can only see department logs that belong to their departments // This makes sure that the user can only see department logs that belong to their departments
'HallId' => $user->getGroup()->getDepartmentsScannersHallIds(), 'HallId' => $allowedHallIds,
'FinishStatus' => ['1'], // Only show finished logs 'FinishStatus' => ['1'], // Only show finished logs
] ]
) )
@@ -151,8 +166,6 @@ class xlvaskUsageLogsRoute
// Return the response // Return the response
$response->success($result); $response->success($result);
} else { } else {
// Log the incident
(new logs_o())->add('xlvask_usage_orders', 'global', 1, 0, 'LIST_XLVASK_USAGE_ORDERS', 'No user found, or invalid session');
// Return an error // Return an error
$response->error('Invalid session', 400); $response->error('Invalid session', 400);
} }
@@ -163,6 +176,197 @@ class xlvaskUsageLogsRoute
] ]
); );
$this->get('/modules/xlvask/services/usage/orders/summary', function () {
global $response;
$this->requirePermission('list_xlvask_usage_orders_own');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
$response->success([
'summary' => (new xlvask_autopilot_service())->getSummary(
$dateFrom,
$dateTo,
self::allowedHallIdsForUser($user)
),
]);
},
[
'list_xlvask_usage_orders_own' => 'Read XL Vask usage-log automation summary',
]
);
$this->post('/modules/xlvask/services/usage/autopilot-runs', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach (['ids', 'dateFrom', 'dateTo', 'limit', 'forceRefetch', 'mode', 'idempotency_key'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success([
'run' => (new xlvask_autopilot_service())->createRun(
$input,
(int)$user->id,
self::allowedHallIdsForUser($user)
),
], 202);
},
[
'manage_xlvask_usage_automation' => 'Create an XL Vask usage-log autopilot run',
]
);
$this->get('/modules/xlvask/services/usage/automation/admin/readiness', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
$response->success((new xlvask_autopilot_service())->activationReadiness());
}, [
'superuser_xlvask_automation_activate' => 'Inspect XL Vask automation activation readiness',
]);
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/backtest', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['segment_key']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success([
'artifact' => (new xlvask_autopilot_service())->generateCalibrationArtifact(
trim((string)$this->getParameter('segment_key')),
(int)$user->id
),
]);
}, [
'superuser_xlvask_automation_activate' => 'Generate an inactive XL Vask historical calibration artifact',
]);
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/labels', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['suggestion_id', 'outcome']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success((new xlvask_autopilot_service())->adjudicateCalibrationLabel(
(int)$this->getParameter('suggestion_id'),
trim((string)$this->getParameter('outcome')),
(int)$user->id
));
}, [
'superuser_xlvask_automation_activate' => 'Adjudicate one exact XL Vask suggestion for calibration evidence',
]);
$this->post('/modules/xlvask/services/usage/automation/admin/calibrations/{id}/activate', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['artifact_hash', 'confirmation_text']);
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$response->success((new xlvask_autopilot_service())->activateCalibration(
(int)($this->fromRoute('id') ?? 0),
trim((string)$this->getParameter('artifact_hash')),
(string)$this->getParameter('confirmation_text'),
(int)$user->id
));
}, [
'superuser_xlvask_automation_activate' => 'Explicitly activate a qualifying XL Vask calibration artifact',
]);
$this->post('/modules/xlvask/services/usage/automation/admin/wash-id-uniqueness/activate', function () {
global $response;
$this->requirePermission('superuser_xlvask_automation_activate');
self::requireParameters(['confirmation_text']);
$response->success((new xlvask_autopilot_service())->activateWashIdUniqueness(
(string)$this->getParameter('confirmation_text')
));
}, [
'superuser_xlvask_automation_activate' => 'Explicitly activate the guarded XL Vask wash-id uniqueness migration',
]);
$this->get('/modules/xlvask/services/usage/autopilot-runs/{id}', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$id = (int)($this->fromRoute('id') ?? 0);
if ($id < 1) {
$response->error('Invalid XL Vask autopilot run id', 400);
}
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$run = (new xlvask_autopilot_service())->getRun(
$id,
(int)$user->id,
self::allowedHallIdsForUser($user)
);
$response->success(['run' => $run]);
},
[
'manage_xlvask_usage_automation' => 'Read XL Vask usage-log autopilot run status',
]
);
$this->post('/modules/xlvask/services/usage/automation/decisions/preview', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach (['usage_log_ids', 'action', 'suggestion_id', 'order_id', 'reason'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success([
'preview' => (new xlvask_autopilot_service())->createDecisionPreview(
$input,
(int)$user->id,
self::allowedHallIdsForUser($user)
),
]);
}, ['manage_xlvask_usage_automation' => 'Preview an XL Vask automation decision']);
$this->post('/modules/xlvask/services/usage/automation/decisions/apply', function () {
global $response;
$this->requirePermission('manage_xlvask_usage_automation');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
$input = [];
foreach (['preview_id', 'selection_hash', 'confirmation_text'] as $key) {
if ($this->isParametersSet([$key])) {
$input[$key] = $this->getParameter($key);
}
}
$response->success(
(new xlvask_autopilot_service())->applyDecision(
$input,
(int)$user->id,
self::allowedHallIdsForUser($user)
)
);
}, ['manage_xlvask_usage_automation' => 'Apply a previewed XL Vask automation decision']);
$this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () { $this->patch('/modules/xlvask/services/usage/orders/{id}/ignore', function () {
global $db, $response; global $db, $response;
$this->requirePermission('ignore_xlvask_usage_order'); $this->requirePermission('ignore_xlvask_usage_order');
@@ -176,8 +380,9 @@ class xlvaskUsageLogsRoute
if ($id < 1) { if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400); $response->error('Invalid XL Vask usage log id', 400);
} }
self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user));
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null; $reason = $this->isParametersSet(['reason']) ? mb_substr(trim((string)$this->getParameter('reason')), 0, 1000) : null;
$reasonSql = $reason === null || $reason === '' $reasonSql = $reason === null || $reason === ''
? 'NULL' ? 'NULL'
: "'" . $db->escape_string($reason) . "'"; : "'" . $db->escape_string($reason) . "'";
@@ -187,7 +392,9 @@ class xlvaskUsageLogsRoute
"UPDATE xlvask_usage_logs "UPDATE xlvask_usage_logs
SET ignored_at = NOW(), SET ignored_at = NOW(),
ignored_by = " . (int)$user->id . ", ignored_by = " . (int)$user->id . ",
ignored_reason = {$reasonSql} ignored_reason = {$reasonSql},
resolution_state = 'ignored', certainty = 'none', planned_action = 'none',
expected_version = expected_version + 1
WHERE id = {$id}" WHERE id = {$id}"
); );
@@ -218,10 +425,20 @@ class xlvaskUsageLogsRoute
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null; $dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null; $dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
$limit = $this->isParametersSet(['limit']) ? (int)$this->getParameter('limit') : 100; $limit = $this->isParametersSet(['limit']) ? (int)$this->getParameter('limit') : 100;
$allowedHallIds = self::allowedHallIdsForUser($user);
if ($allowedHallIds === []) {
$response->error('No XL Vask hall scope is available', 403);
}
$response->success( $response->success([
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, $ids, $limit, (int)$user->id) 'run' => (new xlvask_autopilot_service())->createRun([
); 'mode' => 'execute',
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'ids' => $ids,
'limit' => $limit,
], (int)$user->id, $allowedHallIds),
], 202);
}, },
[ [
'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation', 'manage_xlvask_usage_automation' => 'Evaluate and execute XL Vask usage-log automation',
@@ -241,10 +458,15 @@ class xlvaskUsageLogsRoute
if ($id < 1) { if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400); $response->error('Invalid XL Vask usage log id', 400);
} }
self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user));
$response->success( $response->success([
(new xlvask_automation_service())->evaluateUsageLogById($id, (int)$user->id, true) 'run' => (new xlvask_autopilot_service())->createRun([
); 'mode' => 'dry_run',
'ids' => [$id],
'limit' => 1,
], (int)$user->id, self::allowedHallIdsForUser($user)),
], 202);
}, },
[ [
'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation', 'manage_xlvask_usage_automation' => 'Evaluate XL Vask usage-log automation',
@@ -264,13 +486,9 @@ class xlvaskUsageLogsRoute
if ($id < 1) { if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400); $response->error('Invalid XL Vask usage log id', 400);
} }
self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user));
$suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; $response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
$response->success(
(new xlvask_automation_service())->acceptUsageLogById($id, (int)$user->id, $suggestionId, $reason)
);
}, },
[ [
'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion', 'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion',
@@ -290,13 +508,9 @@ class xlvaskUsageLogsRoute
if ($id < 1) { if ($id < 1) {
$response->error('Invalid XL Vask usage log id', 400); $response->error('Invalid XL Vask usage log id', 400);
} }
self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user));
$suggestionId = $this->isParametersSet(['suggestion_id']) ? (int)$this->getParameter('suggestion_id') : null; $response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
$response->success(
(new xlvask_automation_service())->denyUsageLogById($id, (int)$user->id, $suggestionId, $reason)
);
}, },
[ [
'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion', 'manage_xlvask_usage_automation' => 'Deny an XL Vask usage-log automation suggestion',
@@ -305,6 +519,11 @@ class xlvaskUsageLogsRoute
$this->get('/modules/xlvask/services/usage/orders/fast-link', function () { $this->get('/modules/xlvask/services/usage/orders/fast-link', function () {
global $response; global $response;
$this->requirePermission('list_xlvask_usage_orders_own');
$user = (new authentication())->get_user();
if (!$user) {
$response->error('Invalid session', 400);
}
self::requireParameters([ self::requireParameters([
'fast_link_key', // Example: 'temporary_cache_6878cf0603d77' 'fast_link_key', // Example: 'temporary_cache_6878cf0603d77'
]); ]);
@@ -324,6 +543,10 @@ class xlvaskUsageLogsRoute
$data = json_decode($cached_data, true); $data = json_decode($cached_data, true);
// Check if the data is valid // Check if the data is valid
if (is_array($data)) { if (is_array($data)) {
$allowedHallIds = self::allowedHallIdsForUser($user);
if (!in_array(trim((string)($data['HallId'] ?? '')), $allowedHallIds, true)) {
$response->error('Fast link is outside the current XL Vask hall scope', 403);
}
// Delete the cached data from Redis // Delete the cached data from Redis
redis->delete($fast_link_key); redis->delete($fast_link_key);
// Return the data // Return the data
@@ -387,4 +610,30 @@ class xlvaskUsageLogsRoute
] ]
); );
} }
private static function allowedHallIdsForUser(object $user): array
{
return array_values(array_unique(array_filter(
array_map(static fn(mixed $id): string => trim((string)$id), (array)$user->getGroup()->getDepartmentsScannersHallIds()),
static fn(string $id): bool => $id !== '' && strlen($id) <= 191
)));
}
private static function requireUsageLogInHallScope(int $usageLogId, array $allowedHallIds): void
{
global $db, $response;
if ($allowedHallIds === []) {
$response->error('XL Vask usage log not found', 404);
}
$hallSql = implode(',', array_map(
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
$allowedHallIds
));
$result = $db->query(
"SELECT id FROM xlvask_usage_logs WHERE id = {$usageLogId} AND HallId IN ({$hallSql}) LIMIT 1"
);
if ($result === false || $result->num_rows < 1) {
$response->error('XL Vask usage log not found', 404);
}
}
} }
@@ -524,6 +524,7 @@ CREATE TABLE IF NOT EXISTS `plate_scanners` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`department_id` INT NOT NULL, `department_id` INT NOT NULL,
`lane_id` INT NULL, `lane_id` INT NULL,
`HallId` VARCHAR(191) NULL,
`name` VARCHAR(255) NOT NULL, `name` VARCHAR(255) NOT NULL,
`notes` TEXT NULL, `notes` TEXT NULL,
`api_key` VARCHAR(191) NOT NULL, `api_key` VARCHAR(191) NOT NULL,
@@ -7,7 +7,7 @@ it('discovers module-owned cron task definitions', function (): void {
$registry = new cron_task_registry(app_path('modules')); $registry = new cron_task_registry(app_path('modules'));
$definitions = $registry->definitions(); $definitions = $registry->definitions();
expect($definitions)->toHaveCount(23); expect($definitions)->toHaveCount(24);
expect(array_keys($definitions))->toContain( expect(array_keys($definitions))->toContain(
'system.sync_logs', 'system.sync_logs',
'backups.process_jobs', 'backups.process_jobs',
@@ -16,6 +16,7 @@ it('discovers module-owned cron task definitions', function (): void {
'dynamicimages.pre_render', 'dynamicimages.pre_render',
'weatherapi.preload_department_responses', 'weatherapi.preload_department_responses',
'goals.progress_alerts', 'goals.progress_alerts',
'xlvask.autopilot_queue',
'account.process_deletion_requests', 'account.process_deletion_requests',
'selfserve.activate_opening_cleaner_relays' 'selfserve.activate_opening_cleaner_relays'
); );
@@ -1,8 +1,12 @@
<?php <?php
use classes\xlvask_automation_service; use classes\xlvask_automation_service;
use classes\xlvask_autopilot_service;
use objects\xlvask_usage_logs_o;
require_once WD . '/classes/xlvask_automation_service.php'; require_once WD . '/classes/xlvask_automation_service.php';
require_once WD . '/classes/xlvask_autopilot_service.php';
require_once WD . '/objects/xlvask_usage_logs_o.php';
it('normalizes registrations for XL Vask automation signatures', function (): void { it('normalizes registrations for XL Vask automation signatures', function (): void {
expect(xlvask_automation_service::normalizeRegistrationForAutomation(' ec 21-233 ')) expect(xlvask_automation_service::normalizeRegistrationForAutomation(' ec 21-233 '))
@@ -130,6 +134,17 @@ it('declares a persistent OpenAI cache table for XL Vask automation', function (
->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)'); ->toContain('UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`)');
}); });
it('declares auditable and idempotent XL Vask autopilot run tables', function (): void {
$bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
expect($bootstrapContent)
->toContain('xlvask_autopilot_runs')
->toContain('xlvask_autopilot_run_items')
->toContain('xlvask_automation_audit')
->toContain('UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)')
->toContain('updated_at');
});
it('declares cached amount summary columns for XL Vask usage logs', function (): void { it('declares cached amount summary columns for XL Vask usage logs', function (): void {
$bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php'); $bootstrapContent = file_get_contents(WD . '/classes/xlvask_usage_logs_schema_bootstrap.php');
@@ -148,12 +163,227 @@ it('keeps automatic XL Vask execution scoped to exact attachments', function ():
->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';"); ->toContain("return 'Automatisk accepteret: Prisoverensstemmelse.';");
}); });
it('does not automatically create XL Vask orders', function (): void { it('keeps automatic order creation behind calibration and uniqueness readiness gates', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php'); $serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent) expect($serviceContent)
->toContain('if ($action === self::ACTION_CREATE) {') ->toContain('if ($action === self::ACTION_CREATE) {')
->toContain('return false;'); ->toContain('automatic_order_creation_enabled->isTrue()')
->toContain("(string)(\$suggestion['certainty'] ?? '') !== 'certain'")
->toContain('washIdUniquenessReady()');
});
it('never classifies missing or undersized calibration evidence as certain', function (): void {
expect(xlvask_automation_service::classifyCertaintyForAutomation([]))->toBe('uncertain')
->and(xlvask_automation_service::classifyCertaintyForAutomation([
'active' => true,
'precision_value' => 0.999,
'wilson_lower_bound' => 0.99,
'holdout_examples' => 40,
'overall_examples' => 199,
'segment_examples' => 30,
'contradictions' => 0,
]))->toBe('uncertain');
});
it('classifies only a qualifying contradiction-free calibration artifact as certain', function (): void {
$artifact = [
'active' => true,
'precision_value' => 0.995,
'wilson_lower_bound' => 0.98,
'holdout_examples' => 200,
'overall_examples' => 200,
'segment_examples' => 30,
'contradictions' => 0,
];
expect(xlvask_automation_service::classifyCertaintyForAutomation($artifact))->toBe('certain')
->and(xlvask_automation_service::classifyCertaintyForAutomation($artifact, true, ['conflict']))->toBe('uncertain');
});
it('requires two source observations and a six-hour stable window for automatic actions', function (): void {
$now = strtotime('2026-08-03 12:00:00');
$stable = [
'source_observation_count' => 2,
'source_observed_at' => '2026-08-03 11:55:00',
'source_stable_since' => '2026-08-03 06:00:00',
];
expect(xlvask_automation_service::sourceIsStableForAutomatic($stable, $now))->toBeTrue()
->and(xlvask_automation_service::sourceIsStableForAutomatic([
...$stable, 'source_observation_count' => 1,
], $now))->toBeFalse()
->and(xlvask_automation_service::sourceIsStableForAutomatic([
...$stable, 'source_stable_since' => '2026-08-03 06:00:01',
], $now))->toBeFalse();
});
it('builds order-independent revision hashes for XL Vask source payloads', function (): void {
$first = ['WashId' => 'wash-1', 'Customer' => 'A', 'WashItems' => [['Count' => 1, 'Name' => 'Vask']]];
$second = ['WashItems' => [['Name' => 'Vask', 'Count' => 1]], 'Customer' => 'A', 'WashId' => 'wash-1'];
expect(xlvask_usage_logs_o::sourceHashForAutomation($first))
->toBe(xlvask_usage_logs_o::sourceHashForAutomation($second));
});
it('uses the existing OpenAI module with strict no-retention planner settings', function (): void {
$openAi = file_get_contents(WD . '/classes/openai.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($openAi)->toContain("'store' => false")
->and($automation)->toContain("private const PLANNER_MODEL = 'gpt-5.6-sol'")
->toContain('candidate_order_id')
->toContain('opaque_context_id');
});
it('verifies XL Vask TLS and never logs authorization headers or response bodies', function (): void {
$client = file_get_contents(WD . '/modules/xlvask/classes/xlvask_request.php');
expect($client)->toContain('CURLOPT_SSL_VERIFYPEER, true')
->toContain('CURLOPT_SSL_VERIFYHOST, 2')
->not->toContain('Headers: " . implode')
->not->toContain('Response: $response');
});
it('uses a dedicated queued XL Vask autopilot service with scoped durable runs', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($serviceContent)
->toContain('public function createRun(')
->toContain('public function processQueuedRuns(')
->toContain('public function getRun(')
->toContain('ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)')
->toContain('scope_hall_ids_json')
->toContain('lease_expires_at')
->toContain('attempt_count')
->toContain('next_attempt_at')
->toContain('$renewLease')
->toContain("phase = 'retry_wait'");
});
it('enforces distinct runtime capabilities for execute dry-run and replay modes', function (): void {
expect(xlvask_autopilot_service::modeCapabilities('execute'))->toBe([
'import' => true, 'persist_plans' => true, 'execute_actions' => true, 'run_artifacts' => true,
])->and(xlvask_autopilot_service::modeCapabilities('dry_run'))->toBe([
'import' => true, 'persist_plans' => true, 'execute_actions' => false, 'run_artifacts' => true,
])->and(xlvask_autopilot_service::modeCapabilities('replay'))->toBe([
'import' => false, 'persist_plans' => false, 'execute_actions' => false, 'run_artifacts' => true,
]);
});
it('preserves GUID hall scopes and rejects empty scope values at runtime', function (): void {
expect(xlvask_autopilot_service::normalizeHallScope([
' 845d29a1-a7d2-4e3b-bbc3-2b13242d744a ', '', '845d29a1-a7d2-4e3b-bbc3-2b13242d744a',
]))->toBe(['845d29a1-a7d2-4e3b-bbc3-2b13242d744a']);
});
it('keeps explicit retry keys idempotent and actor-bound', function (): void {
expect(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-a'))
->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 10, 'nonce-b'))
->not->toBe(xlvask_autopilot_service::idempotencyKey('retry-1', 11, 'nonce-a'))
->and(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-a'))
->not->toBe(xlvask_autopilot_service::idempotencyKey('', 10, 'nonce-b'));
expect(xlvask_autopilot_service::requestFingerprint(['mode' => 'execute', 'ids' => [1]]))
->not->toBe(xlvask_autopilot_service::requestFingerprint(['mode' => 'dry_run', 'ids' => [1]]));
});
it('fails preview snapshots closed when source hash or optimistic version changes', function (): void {
$current = ['expected_version' => 4, 'source_hash' => str_repeat('a', 64)];
expect(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('a', 64), $current))->toBeTrue()
->and(xlvask_autopilot_service::previewSnapshotMatches(5, str_repeat('a', 64), $current))->toBeFalse()
->and(xlvask_autopilot_service::previewSnapshotMatches(4, str_repeat('b', 64), $current))->toBeFalse();
});
it('uses exact adjudicated suggestion labels and a chronological holdout for calibration', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($serviceContent)
->toContain('INNER JOIN xlvask_automation_suggestions s ON s.id = l.suggestion_id')
->toContain('chronological_80_20_by_suggestion_created_at_and_id')
->toContain('xlvask_automation_calibration_label_events')
->toContain("'label_snapshot' => \$snapshot")
->not->toContain("SUM(f.decision = 'accepted')");
});
it('keeps XL Vask automation execution inside a transactional revalidation boundary', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain('$connection->begin_transaction()')
->toContain('$connection->commit()')
->toContain('$connection->rollback()')
->toContain('SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE')
->toContain("SELECT id FROM orders WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM('{\$washId}')) FOR UPDATE")
->toContain('XL Vask-kildedata blev ændret efter evalueringen.');
});
it('keeps OpenAI advisory-only and derives automatic certainty from hard guards', function (): void {
$serviceContent = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($serviceContent)
->toContain("=== self::SOURCE_OPENAI")
->toContain('hardGuardsPassForCertainty($suggestion, $context)')
->toContain('sourceIsStableForAutomatic($context)')
->toContain('washIdUniquenessReady()');
});
it('rotates pending rows fairly and invalidates suggestions atomically on source changes', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php');
expect($automation)
->toContain("ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC")
->toContain('eligible_total')
->and($usageLogs)
->toContain('supersedeSuggestionsForSourceRevision')
->toContain("SET status = 'superseded'")
->toContain('$connection->begin_transaction()');
});
it('captures the eligible population before processing and fails invalid revisions closed', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$usageLogs = file_get_contents(WD . '/objects/xlvask_usage_logs_o.php');
expect(strpos($automation, '$eligibleTotal ='))
->toBeLessThan(strpos($automation, 'foreach ($rows as $row)'))
->and($automation)->toContain("=== 'invalid'")
->toContain("=== 'updated'")
->toContain("=== 'recheck'")
->toContain('$linkedOrder->asArray(true, false)')
->toContain("'certainty' => 'certain'")
->toContain('existing_link_semantically_revalidated')
->toContain('linked_order_revision_mismatch')
->and($autopilot)->toContain('has invalid source data')
->and($usageLogs)->toContain('source_stable_since = NULL')
->toContain('supersedeSuggestionsForSourceRevision($id)');
});
it('keeps read-only replay cache-only without new OpenAI network calls', function (): void {
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
expect($automation)->toContain("if (\$this->readOnlyEvaluation) {\n return null;");
});
it('revalidates linked orders against customer department registration lane date and normalized items', function (): void {
$proposedOrder = [
'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB 12 345',
'lane' => 3, 'created_at' => '2026-08-03 10:00:00',
];
$linkedOrder = [
'customer_id' => 10, 'department_id' => 2, 'reg_1' => 'AB12345',
'lane' => 3, 'created_at' => '2026-08-03 10:05:00',
];
$items = [['product_id' => 5, 'quantity' => 1, 'price' => 500]];
expect(xlvask_automation_service::linkedOrderMatchesForAutomation($proposedOrder, $items, $linkedOrder, $items))
->toBeTrue()
->and(xlvask_automation_service::linkedOrderMatchesForAutomation(
$proposedOrder,
$items,
[...$linkedOrder, 'customer_id' => 11],
$items
))->toBeFalse();
});
it('runs autopilot retention from the hourly XL Vask cleanup path', function (): void {
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
$tasks = file_get_contents(WD . '/modules/xlvask/helpers/xlvask_tasks.php');
expect($autopilot)->toContain('public function pruneExpiredData(): array')
->and($tasks)->toContain('(new xlvask_autopilot_service())->pruneExpiredData();');
}); });
it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void { it('scores same-day orders with matching XL Vask products and extra add-ons as attach suggestions', function (): void {
@@ -23,9 +23,13 @@ it('does not execute XL Vask usage automation while listing usage order rows', f
$route = (string)$route; $route = (string)$route;
expect($route) expect($route)
->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false);') ->toContain('$automation = $automation_service->readAutomationStateByUsageLogId($id, $log);')
->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);') ->and($route)->not->toContain('$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, true);')
->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')"); ->and($route)->toContain("requirePermission('manage_xlvask_usage_automation')");
expect($route)
->toContain("if (\$allowedHallIds === [])")
->toContain("No XL Vask hall scope is available', 403")
->not->toContain('__no_authorized_xlvask_hall__');
}); });
it('returns cached amount summaries on XL Vask usage order rows without widening the usage-log object payload', function (): void { it('returns cached amount summaries on XL Vask usage order rows without widening the usage-log object payload', function (): void {
@@ -36,7 +40,7 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
$route = (string)$route; $route = (string)$route;
expect($route) expect($route)
->toContain('$amount_summary = $xlvask_usage_logs->getCachedAmountSummaryFromRow($log)') ->toContain('$amount_summary = $xlvask_usage_logs->getAmountSummaryReadOnly($log)')
->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([') ->and($route)->toContain('$usage_log_payload = array_intersect_key($log, array_flip([')
->and($route)->toContain('$tmp->setProperties($usage_log_payload)') ->and($route)->toContain('$tmp->setProperties($usage_log_payload)')
->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']") ->and($route)->toContain("\$tmp_res['order']['total_net_amount'] = \$amount_summary['total_net_amount']")
@@ -44,7 +48,7 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']"); ->and($route)->toContain("\$tmp_res['order']['xlvask_amount_cached'] = \$amount_summary['cached']");
}); });
it('scopes manual XL Vask usage import and automation to optional period dates', function (): void { it('adapts the legacy XL Vask usage import route to a scoped queued autopilot run', function (): void {
$route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php'); $route = file_get_contents(WD . '/routes/moduleXLVaskRoute.php');
$automation = file_get_contents(WD . '/classes/xlvask_automation_service.php'); $automation = file_get_contents(WD . '/classes/xlvask_automation_service.php');
@@ -58,7 +62,102 @@ it('scopes manual XL Vask usage import and automation to optional period dates',
expect($route) expect($route)
->toContain("getParameter('dateFrom')") ->toContain("getParameter('dateFrom')")
->toContain("getParameter('dateTo')") ->toContain("getParameter('dateTo')")
->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)') ->toContain("'replacement' => '/modules/xlvask/services/usage/autopilot-runs'")
->toContain('runPending($dateFrom, $dateTo, [], 100, null)') ->toContain("'forceRefetch' => true")
->not->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')"); ->and($automation)->toContain("STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')");
}); });
it('exposes additive XL Vask autopilot run and summary routes', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
$route = (string)$route;
expect($route)
->toContain("get('/modules/xlvask/services/usage/orders/summary'")
->toContain("post('/modules/xlvask/services/usage/autopilot-runs'")
->toContain("get('/modules/xlvask/services/usage/autopilot-runs/{id}'")
->toContain('(new xlvask_autopilot_service())->getSummary(')
->toContain('(new xlvask_autopilot_service())->createRun(')
->toContain('(new xlvask_autopilot_service())->getRun(')
->toContain('self::allowedHallIdsForUser($user)')
->toContain('], 202);');
});
it('routes legacy automation entry points through the durable queue and preview lifecycle', function (): void {
$route = (string)file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)
->toContain("'mode' => 'execute'")
->toContain("'mode' => 'dry_run'")
->toContain('Use the server-generated automation decision preview and apply endpoints.')
->not->toContain('(new xlvask_automation_service())->runPending(')
->not->toContain('(new xlvask_automation_service())->evaluateUsageLogById(');
});
it('returns revision and resolution state on XL Vask usage order rows', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
expect($route)->not->toBeFalse();
$route = (string)$route;
expect($route)
->toContain("'source_hash' => \$log['source_hash'] ?? null")
->toContain("'source_revision' => \$log['source_revision'] ?? null")
->toContain("'import_state' => \$log['import_state'] ?? 'unchanged'")
->toContain("'resolution_state' => \$log['resolution_state'] ?? 'needs_review'")
->toContain("'certainty' => \$log['certainty'] ?? 'none'")
->toContain("'planned_action' => \$log['planned_action'] ?? 'none'")
->toContain("'expected_version' => isset(\$log['expected_version']) ? (int)\$log['expected_version'] : 1")
->toContain("'automation' => \$automation");
});
it('documents XL Vask autopilot summary and run APIs in OpenAPI', function (): void {
$openApi = file_get_contents(WD . '/openapi.yaml');
expect($openApi)->not->toBeFalse();
$openApi = (string)$openApi;
expect($openApi)
->toContain('/modules/xlvask/services/usage/orders/summary:')
->toContain('operationId: summarizeXlvaskUsageAutomation')
->toContain('/modules/xlvask/services/usage/autopilot-runs:')
->toContain('operationId: createXlvaskUsageAutopilotRun')
->toContain('/modules/xlvask/services/usage/autopilot-runs/{id}:')
->toContain('operationId: getXlvaskUsageAutopilotRun')
->toContain('/modules/xlvask/services/usage/automation/decisions/preview:')
->toContain('operationId: previewXlvaskUsageAutomationDecision')
->toContain('/modules/xlvask/services/usage/automation/decisions/apply:')
->toContain('operationId: applyXlvaskUsageAutomationDecision')
->toContain('/modules/xlvask/services/usage/automation/admin/readiness:')
->toContain('operationId: adjudicateXlvaskCalibrationLabel')
->toContain('operationId: generateXlvaskCalibrationArtifact')
->toContain('operationId: activateXlvaskCalibrationArtifact')
->toContain('operationId: activateXlvaskWashIdUniqueness');
});
it('wires preview-bound bulk decisions through the transactional autopilot service', function (): void {
$route = file_get_contents(WD . '/routes/xlvaskUsageLogsRoute.php');
$autopilot = file_get_contents(WD . '/classes/xlvask_autopilot_service.php');
expect($route)
->not->toBeFalse()
->and($autopilot)->not->toBeFalse();
$route = (string)$route;
$autopilot = (string)$autopilot;
expect($route)
->toContain("post('/modules/xlvask/services/usage/automation/decisions/preview'")
->toContain("post('/modules/xlvask/services/usage/automation/decisions/apply'")
->toContain('createDecisionPreview(')
->toContain('applyDecision(')
->and($autopilot)->toContain("SELECT * FROM xlvask_automation_decision_previews WHERE id = '")
->toContain('FOR UPDATE')
->toContain('applyBoundDecisionWithinTransaction(')
->toContain('expected_version')
->toContain('source_hash');
});