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:
@@ -44,12 +44,21 @@ class openai implements openai_i
|
||||
*
|
||||
* @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();
|
||||
|
||||
$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' => [
|
||||
[
|
||||
'role' => 'user',
|
||||
@@ -278,6 +287,8 @@ class openai implements openai_i
|
||||
$curl = curl_init($this->api_url);
|
||||
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($curl, CURLOPT_POST, true);
|
||||
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
|
||||
curl_setopt($curl, CURLOPT_TIMEOUT, 45);
|
||||
curl_setopt($curl, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'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_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', '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::$initialized = true;
|
||||
@@ -68,6 +81,23 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
) 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(
|
||||
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
@@ -104,6 +134,220 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
|
||||
) 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
|
||||
@@ -138,6 +382,18 @@ class xlvask_usage_logs_schema_bootstrap
|
||||
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
|
||||
{
|
||||
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
||||
|
||||
@@ -150,6 +150,12 @@ $cron_tasks = [
|
||||
'next_run' => 0,
|
||||
'function' => 'SyncXLVaskModuleCron',
|
||||
],
|
||||
'ProcessXLVaskAutopilotQueueCron' => [
|
||||
'interval' => 60,
|
||||
'last_run' => 0,
|
||||
'next_run' => 0,
|
||||
'function' => 'ProcessXLVaskAutopilotQueueCron',
|
||||
],
|
||||
'SystemSearchCacheMaintenanceCron' => [
|
||||
'interval' => 300, // 5 minutes
|
||||
'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
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -28,10 +28,12 @@ class xlvask_request implements xlvask_request_i
|
||||
};
|
||||
// Set the 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_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 60);
|
||||
// Execute the request
|
||||
$response = curl_exec($ch);
|
||||
// Check for errors
|
||||
@@ -45,9 +47,12 @@ class xlvask_request implements xlvask_request_i
|
||||
// Check if the response is successful
|
||||
if ($httpCode < 200 || $httpCode >= 300) {
|
||||
$slack = new slack();
|
||||
$urlParts = parse_url($url);
|
||||
$safeEndpoint = (string)($urlParts['host'] ?? 'unknown-host') . (string)($urlParts['path'] ?? '');
|
||||
$slack->send_message(
|
||||
'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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
<?php
|
||||
|
||||
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',
|
||||
'legacy_name' => 'SyncXLVaskModuleCron',
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
|
||||
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 objects\orders_o;
|
||||
use objects\plate_scanners_o;
|
||||
use objects\users_o;
|
||||
use objects\xlvask_customers_o;
|
||||
use objects\xlvask_potential_order_matches_o;
|
||||
use objects\xlvask_usage_logs_o;
|
||||
use objects\xlvask_vehicles_o;
|
||||
|
||||
class xlvask_tasks
|
||||
@@ -69,11 +69,45 @@ class xlvask_tasks
|
||||
// - xlvask_usage_logs
|
||||
(new xlvask_customers_o())->importCustomers();
|
||||
(new xlvask_vehicles_o())->importVehicles();
|
||||
(new xlvask_usage_logs_o())->importUsageLogs();
|
||||
(new xlvask_automation_service())->runPending(null, null, [], 100, null);
|
||||
$hallIds = $this->configuredHallIds();
|
||||
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
|
||||
* 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();
|
||||
// Require the module to be enabled
|
||||
$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')) {
|
||||
$db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`");
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
global $db;
|
||||
|
||||
@@ -42,7 +42,6 @@ class xlvask_usage_logs_o extends db
|
||||
|
||||
public function structure(): void
|
||||
{
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
$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
|
||||
{
|
||||
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
|
||||
{
|
||||
$this->importUsageLogsWithSummary($dateFrom, $dateTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revision-aware and idempotent XL Vask import.
|
||||
*
|
||||
* @return array{fetched:int,new:int,updated:int,unchanged:int,invalid:int,errors:array<int,array<string,string>>}
|
||||
*/
|
||||
public function importUsageLogsWithSummary(?string $dateFrom = null, ?string $dateTo = null, array $allowedHallIds = []): array
|
||||
{
|
||||
global $db;
|
||||
if (!empty($this->id)) {
|
||||
throw new Exception('To prevent issues, having a selected object is not allowed.');
|
||||
}
|
||||
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
||||
$usage_logs = $this->getUsageLogsFromXLVask(
|
||||
self::formatImportDateFrom($dateFrom) // Example: '2025-05-01T00:00:00.000'
|
||||
);
|
||||
$usage_logs = self::filterUsageLogsUntil($usage_logs, $dateTo);
|
||||
/** @var string[] $known_usage_logIds The XL Vask usage logIds currently known */
|
||||
$known_usage_logIds = array_map(function ($log) {
|
||||
return $log['WashId'];
|
||||
},
|
||||
self::getFieldsWhere(
|
||||
['WashId' => array_column($usage_logs, 'WashId')],
|
||||
['WashId']
|
||||
$upstreamFetched = count($usage_logs);
|
||||
$allowedHallIds = array_values(array_unique(array_filter(array_map(
|
||||
static fn(mixed $id): string => trim((string)$id),
|
||||
$allowedHallIds
|
||||
), static fn(string $id): bool => $id !== '')));
|
||||
if ($allowedHallIds !== []) {
|
||||
$usage_logs = array_values(array_filter(
|
||||
$usage_logs,
|
||||
static fn(xlvask_usage_log $log): bool => in_array(trim((string)$log->HallId), $allowedHallIds, true)
|
||||
));
|
||||
/** 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);
|
||||
});
|
||||
unset($known_usage_logIds);
|
||||
unset($usage_logs);
|
||||
/** Adding the new usage logs */
|
||||
foreach ($new_usage_logs as $log) {
|
||||
if (!$log->isValid()) {
|
||||
echo $log->formattedDetails();
|
||||
throw new Exception('A usage log from XL Vask is not valid, have the structure changed?');
|
||||
}
|
||||
|
||||
$washIds = array_values(array_filter(array_map(
|
||||
static fn(xlvask_usage_log $log): string => trim((string)$log->WashId),
|
||||
$usage_logs
|
||||
)));
|
||||
$knownRows = $washIds === [] ? [] : self::getFieldsWhere(
|
||||
['WashId' => $washIds],
|
||||
['id', 'WashId', 'source_hash', 'source_revision', 'expected_version']
|
||||
);
|
||||
$knownByWashId = [];
|
||||
foreach ($knownRows as $knownRow) {
|
||||
$knownByWashId[(string)$knownRow['WashId']] = $knownRow;
|
||||
}
|
||||
|
||||
$summary = [
|
||||
'fetched' => count($usage_logs),
|
||||
'upstream_fetched' => $upstreamFetched,
|
||||
'new' => 0,
|
||||
'updated' => 0,
|
||||
'unchanged' => 0,
|
||||
'invalid' => 0,
|
||||
'errors' => [],
|
||||
];
|
||||
|
||||
foreach ($usage_logs as $log) {
|
||||
$washId = trim((string)$log->WashId);
|
||||
if ($washId === '' || !$log->isValid()) {
|
||||
$summary['invalid']++;
|
||||
$summary['errors'][] = [
|
||||
'wash_id' => $washId,
|
||||
'error' => 'XL Vask usage log failed structural validation.',
|
||||
];
|
||||
if ($washId !== '' && isset($knownByWashId[$washId])) {
|
||||
$this->markImportState((int)$knownByWashId[$washId]['id'], 'invalid', 'XL Vask-kildedata kunne ikke valideres.');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = self::normalizeSourcePayload($log->toArray());
|
||||
$sourceHash = self::sourceHashForAutomation($payload);
|
||||
$sourceRevision = trim((string)($payload['Updated'] ?? '')) ?: $sourceHash;
|
||||
$known = $knownByWashId[$washId] ?? null;
|
||||
|
||||
if ($known === null) {
|
||||
$this->add($payload);
|
||||
$this->updateSourceMetadata((int)$this->id, $sourceHash, $sourceRevision, 'new', true);
|
||||
$knownByWashId[$washId] = ['id' => (int)$this->id, 'WashId' => $washId, 'source_hash' => $sourceHash];
|
||||
$summary['new']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingHash = trim((string)($known['source_hash'] ?? ''));
|
||||
if ($existingHash !== '' && hash_equals($existingHash, $sourceHash)) {
|
||||
$this->updateSourceMetadata((int)$known['id'], $sourceHash, $sourceRevision, 'unchanged', false);
|
||||
$summary['unchanged']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$before = $known;
|
||||
$connection = $db->conn();
|
||||
$connection->begin_transaction();
|
||||
try {
|
||||
$this->updateExistingSourceRow((int)$known['id'], $payload, $sourceHash, $sourceRevision);
|
||||
$this->supersedeSuggestionsForSourceRevision((int)$known['id']);
|
||||
$this->recordSourceRevision((int)$known['id'], $washId, $before, $payload, $sourceHash, $sourceRevision);
|
||||
$connection->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$connection->rollback();
|
||||
throw $throwable;
|
||||
}
|
||||
$knownByWashId[$washId]['source_hash'] = $sourceHash;
|
||||
$summary['updated']++;
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
public static function sourceHashForAutomation(array $payload): string
|
||||
{
|
||||
$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) {
|
||||
/** @var xlvask_usage_log $log */
|
||||
$this->add($log->toArray());
|
||||
|
||||
return $payload;
|
||||
}
|
||||
unset($new_usage_logs);
|
||||
|
||||
private static function sortSourceValue(mixed $value): mixed
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
$sorted = array_map(static fn(mixed $item): mixed => self::sortSourceValue($item), $value);
|
||||
$isList = $sorted === [] || array_keys($sorted) === range(0, count($sorted) - 1);
|
||||
if (!$isList) {
|
||||
ksort($sorted, SORT_STRING);
|
||||
}
|
||||
return $sorted;
|
||||
}
|
||||
|
||||
private function updateExistingSourceRow(int $id, array $payload, string $sourceHash, string $sourceRevision): void
|
||||
{
|
||||
global $db;
|
||||
$assignments = [];
|
||||
foreach ($payload as $column => $value) {
|
||||
$assignments[] = '`' . str_replace('`', '', $column) . '` = ' . $this->sqlValue($value);
|
||||
}
|
||||
$assignments[] = "source_hash = '" . $db->escape_string($sourceHash) . "'";
|
||||
$assignments[] = "source_revision = '" . $db->escape_string($sourceRevision) . "'";
|
||||
$assignments[] = 'source_observed_at = NOW()';
|
||||
$assignments[] = 'source_stable_since = NOW()';
|
||||
$assignments[] = 'source_observation_count = 1';
|
||||
$assignments[] = "import_state = 'updated'";
|
||||
$assignments[] = "resolution_state = 'needs_review'";
|
||||
$assignments[] = "certainty = 'none'";
|
||||
$assignments[] = "planned_action = 'recheck'";
|
||||
$assignments[] = "state_reason = 'XL Vask-kildedata blev opdateret.'";
|
||||
$assignments[] = 'expected_version = expected_version + 1';
|
||||
$assignments[] = 'cached_total_net_amount = NULL';
|
||||
$assignments[] = 'cached_primary_product_name = NULL';
|
||||
$assignments[] = 'cached_amount_at = NULL';
|
||||
if ($db->query('UPDATE xlvask_usage_logs SET ' . implode(', ', $assignments) . " WHERE id = {$id}") === false
|
||||
|| $db->conn()->affected_rows !== 1) {
|
||||
throw new Exception('Could not atomically update the XL Vask source row.');
|
||||
}
|
||||
}
|
||||
|
||||
private function supersedeSuggestionsForSourceRevision(int $usageLogId): void
|
||||
{
|
||||
global $db;
|
||||
if ($db->query(
|
||||
"UPDATE xlvask_automation_suggestions
|
||||
SET status = 'superseded', updated_at = NOW()
|
||||
WHERE usage_log_id = {$usageLogId} AND status <> 'superseded'"
|
||||
) === false) {
|
||||
throw new Exception('Could not invalidate stale XL Vask automation suggestions.');
|
||||
}
|
||||
}
|
||||
|
||||
private function updateSourceMetadata(int $id, string $sourceHash, string $sourceRevision, string $state, bool $new): void
|
||||
{
|
||||
global $db;
|
||||
$sourceHash = $db->escape_string($sourceHash);
|
||||
$sourceRevision = $db->escape_string($sourceRevision);
|
||||
$state = $db->escape_string($state);
|
||||
$stable = $new ? 'NOW()' : "COALESCE(source_stable_since, NOW())";
|
||||
$observations = $new ? '1' : 'GREATEST(1, source_observation_count) + 1';
|
||||
$db->query(
|
||||
"UPDATE xlvask_usage_logs SET
|
||||
source_hash = '{$sourceHash}', source_revision = '{$sourceRevision}',
|
||||
source_observed_at = NOW(), source_stable_since = {$stable},
|
||||
source_observation_count = {$observations}, import_state = '{$state}'
|
||||
WHERE id = {$id}"
|
||||
);
|
||||
}
|
||||
|
||||
private function markImportState(int $id, string $state, string $reason): void
|
||||
{
|
||||
global $db;
|
||||
$state = $db->escape_string($state);
|
||||
$reason = $db->escape_string($reason);
|
||||
$connection = $db->conn();
|
||||
$connection->begin_transaction();
|
||||
try {
|
||||
if ($db->query(
|
||||
"UPDATE xlvask_usage_logs SET import_state = '{$state}', resolution_state = 'failed',
|
||||
certainty = 'none', planned_action = 'none', state_reason = '{$reason}',
|
||||
source_stable_since = NULL, source_observation_count = 0, source_observed_at = NOW(),
|
||||
expected_version = expected_version + 1 WHERE id = {$id}"
|
||||
) === false || $db->conn()->affected_rows !== 1) {
|
||||
throw new Exception('Could not atomically mark invalid XL Vask source data.');
|
||||
}
|
||||
$this->supersedeSuggestionsForSourceRevision($id);
|
||||
$connection->commit();
|
||||
} catch (\Throwable $throwable) {
|
||||
$connection->rollback();
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
private function recordSourceRevision(
|
||||
int $id,
|
||||
string $washId,
|
||||
array $before,
|
||||
array $after,
|
||||
string $sourceHash,
|
||||
string $sourceRevision
|
||||
): void {
|
||||
global $db;
|
||||
$beforeJson = $db->escape_string(json_encode([
|
||||
'source_hash' => (string)($before['source_hash'] ?? ''),
|
||||
'source_revision' => (string)($before['source_revision'] ?? ''),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
||||
$afterJson = $db->escape_string(json_encode([
|
||||
'source_hash' => $sourceHash,
|
||||
'source_revision' => $sourceRevision,
|
||||
'observed_fields' => array_values(array_keys($after)),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
||||
$washId = $db->escape_string($washId);
|
||||
$sourceHash = $db->escape_string($sourceHash);
|
||||
$sourceRevision = $db->escape_string($sourceRevision);
|
||||
if ($db->query(
|
||||
"INSERT INTO xlvask_automation_audit
|
||||
(usage_log_id, wash_id, event_type, policy_version, input_hash, source_revision, before_json, after_json)
|
||||
VALUES ({$id}, '{$washId}', 'source_updated', 'xlvask-autopilot-v1', '{$sourceHash}', '{$sourceRevision}', '{$beforeJson}', '{$afterJson}')"
|
||||
) === false || $db->conn()->affected_rows !== 1) {
|
||||
throw new Exception('Could not record the XL Vask source revision audit event.');
|
||||
}
|
||||
}
|
||||
|
||||
private function sqlValue(mixed $value): string
|
||||
{
|
||||
global $db;
|
||||
if ($value === null) {
|
||||
return 'NULL';
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
if (is_int($value) || is_float($value)) {
|
||||
return (string)$value;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$value = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]';
|
||||
}
|
||||
return "'" . $db->escape_string((string)$value) . "'";
|
||||
}
|
||||
|
||||
private static function formatImportDateFrom(?string $dateFrom): string
|
||||
|
||||
@@ -10624,6 +10624,338 @@ paths:
|
||||
application/json:
|
||||
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:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace routes;
|
||||
|
||||
require_once WD . '/classes/xlvask_automation_service.php';
|
||||
require_once WD . '/classes/xlvask_autopilot_service.php';
|
||||
|
||||
use classes\authentication;
|
||||
use classes\response;
|
||||
use classes\router;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_automation_service;
|
||||
use classes\xlvask_autopilot_service;
|
||||
use objects\orders_o;
|
||||
use objects\users_o;
|
||||
use objects\xlvask_customers_o;
|
||||
@@ -255,18 +255,27 @@ class moduleXLVaskRoute
|
||||
$this->get('/modules/xlvask/tasks/import-usage', function () {
|
||||
global $response;
|
||||
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;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? trim((string)$this->getParameter('dateTo')) : null;
|
||||
// Create the xlvask_usage_logs_o object
|
||||
$xlvask_usage_logs_o = new \objects\xlvask_usage_logs_o();
|
||||
// Import usage logs
|
||||
$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo);
|
||||
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, [], 100, null);
|
||||
// Response
|
||||
$response->success(
|
||||
'Usage logs imported',
|
||||
200
|
||||
);
|
||||
$hallIds = array_values(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));
|
||||
$run = (new xlvask_autopilot_service())->createRun([
|
||||
'dateFrom' => $dateFrom,
|
||||
'dateTo' => $dateTo,
|
||||
'mode' => 'execute',
|
||||
'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'
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
namespace routes;
|
||||
|
||||
require_once WD . '/classes/xlvask_automation_service.php';
|
||||
require_once WD . '/classes/xlvask_autopilot_service.php';
|
||||
|
||||
use classes\authentication;
|
||||
use classes\redis;
|
||||
use classes\response;
|
||||
use classes\stripe;
|
||||
use classes\xlvask;
|
||||
use classes\xlvask_autopilot_service;
|
||||
use classes\xlvask_automation_service;
|
||||
use objects\collected_order_invoices_o;
|
||||
use objects\departments_o;
|
||||
use objects\economic_module_orders;
|
||||
use objects\logs_o;
|
||||
use objects\orders_o;
|
||||
use objects\stripe_module_orders_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_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_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
|
||||
global $response;
|
||||
self::requirePermission($permission_list_own);
|
||||
@@ -45,8 +45,11 @@ class xlvaskUsageLogsRoute
|
||||
$user = (new authentication())->get_user();
|
||||
// Check if the request was successful
|
||||
if ($user) {
|
||||
// Log the incident
|
||||
(new logs_o())->add('xlvask_usage_orders', 'global', 1, $user->id, 'LIST_XLVASK_USAGE_ORDERS', 'User accessed the list of xlvask usage orders');
|
||||
$allowedHallIds = self::allowedHallIdsForUser($user);
|
||||
if ($allowedHallIds === []) {
|
||||
$response->error('No XL Vask hall scope is available', 403);
|
||||
return;
|
||||
}
|
||||
$xlvask_usage_logs = new xlvask_usage_logs_o();
|
||||
$xlvask = new xlvask();
|
||||
$automation_service = new xlvask_automation_service();
|
||||
@@ -58,19 +61,16 @@ class xlvaskUsageLogsRoute
|
||||
if (ini_get('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
|
||||
$result = $xlvask_usage_logs
|
||||
// Make sure the Customer is not in the default customers list
|
||||
->setAdditionalWhereClause("`Customer` NOT IN ('" . implode("', '", $xlvask_usage_log::$default_customers) . "')")
|
||||
->listObjectsWithPaginationIfSet(
|
||||
function ($log) use ($response_includes_items_link, $response_includes_items, $xlvask_usage_logs, $user, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
|
||||
$automation = $automation_service->evaluateUsageLogRow($log, (int)$user->id, false);
|
||||
function ($log) use ($response_includes_items, $xlvask_usage_logs, $xlvask, $automation_service, &$linked_order_ids_by_wash_id) {
|
||||
// Remove the 'id' field from the log
|
||||
$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']);
|
||||
// Convert the 'WashItems' field from JSON to an array
|
||||
$log['WashItems'] = json_decode($log['WashItems'], true);
|
||||
@@ -98,6 +98,19 @@ class xlvaskUsageLogsRoute
|
||||
'ignored_at',
|
||||
'ignored_by',
|
||||
'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
|
||||
$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;
|
||||
// Define the result structure
|
||||
$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
|
||||
$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
|
||||
@@ -129,12 +132,24 @@ class xlvaskUsageLogsRoute
|
||||
$tmp_res['order']['xlvask_amount_cached'] = $amount_summary['cached'];
|
||||
// Clear memory
|
||||
unset($tmp);
|
||||
unset($log);
|
||||
// Return the result
|
||||
return [
|
||||
'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,
|
||||
'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)
|
||||
'usage_log_id' => $id,
|
||||
'linked_order_id' => $linked_order_id,
|
||||
@@ -143,7 +158,7 @@ class xlvaskUsageLogsRoute
|
||||
$xlvask_usage_logs->forceRestrictFilters(
|
||||
[
|
||||
// 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
|
||||
]
|
||||
)
|
||||
@@ -151,8 +166,6 @@ class xlvaskUsageLogsRoute
|
||||
// Return the response
|
||||
$response->success($result);
|
||||
} 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
|
||||
$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 () {
|
||||
global $db, $response;
|
||||
$this->requirePermission('ignore_xlvask_usage_order');
|
||||
@@ -176,8 +380,9 @@ class xlvaskUsageLogsRoute
|
||||
if ($id < 1) {
|
||||
$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 === ''
|
||||
? 'NULL'
|
||||
: "'" . $db->escape_string($reason) . "'";
|
||||
@@ -187,7 +392,9 @@ class xlvaskUsageLogsRoute
|
||||
"UPDATE xlvask_usage_logs
|
||||
SET ignored_at = NOW(),
|
||||
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}"
|
||||
);
|
||||
|
||||
@@ -218,10 +425,20 @@ class xlvaskUsageLogsRoute
|
||||
$dateFrom = $this->isParametersSet(['dateFrom']) ? (string)$this->getParameter('dateFrom') : null;
|
||||
$dateTo = $this->isParametersSet(['dateTo']) ? (string)$this->getParameter('dateTo') : null;
|
||||
$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(
|
||||
(new xlvask_automation_service())->runPending($dateFrom, $dateTo, $ids, $limit, (int)$user->id)
|
||||
);
|
||||
$response->success([
|
||||
'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',
|
||||
@@ -241,10 +458,15 @@ class xlvaskUsageLogsRoute
|
||||
if ($id < 1) {
|
||||
$response->error('Invalid XL Vask usage log id', 400);
|
||||
}
|
||||
self::requireUsageLogInHallScope($id, self::allowedHallIdsForUser($user));
|
||||
|
||||
$response->success(
|
||||
(new xlvask_automation_service())->evaluateUsageLogById($id, (int)$user->id, true)
|
||||
);
|
||||
$response->success([
|
||||
'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',
|
||||
@@ -264,13 +486,9 @@ class xlvaskUsageLogsRoute
|
||||
if ($id < 1) {
|
||||
$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;
|
||||
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
|
||||
|
||||
$response->success(
|
||||
(new xlvask_automation_service())->acceptUsageLogById($id, (int)$user->id, $suggestionId, $reason)
|
||||
);
|
||||
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
|
||||
},
|
||||
[
|
||||
'manage_xlvask_usage_automation' => 'Accept an XL Vask usage-log automation suggestion',
|
||||
@@ -290,13 +508,9 @@ class xlvaskUsageLogsRoute
|
||||
if ($id < 1) {
|
||||
$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;
|
||||
$reason = $this->isParametersSet(['reason']) ? trim((string)$this->getParameter('reason')) : null;
|
||||
|
||||
$response->success(
|
||||
(new xlvask_automation_service())->denyUsageLogById($id, (int)$user->id, $suggestionId, $reason)
|
||||
);
|
||||
$response->error('Use the server-generated automation decision preview and apply endpoints.', 409);
|
||||
},
|
||||
[
|
||||
'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 () {
|
||||
global $response;
|
||||
$this->requirePermission('list_xlvask_usage_orders_own');
|
||||
$user = (new authentication())->get_user();
|
||||
if (!$user) {
|
||||
$response->error('Invalid session', 400);
|
||||
}
|
||||
self::requireParameters([
|
||||
'fast_link_key', // Example: 'temporary_cache_6878cf0603d77'
|
||||
]);
|
||||
@@ -324,6 +543,10 @@ class xlvaskUsageLogsRoute
|
||||
$data = json_decode($cached_data, true);
|
||||
// Check if the data is valid
|
||||
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
|
||||
redis->delete($fast_link_key);
|
||||
// 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,
|
||||
`department_id` INT NOT NULL,
|
||||
`lane_id` INT NULL,
|
||||
`HallId` VARCHAR(191) NULL,
|
||||
`name` VARCHAR(255) NOT NULL,
|
||||
`notes` TEXT 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'));
|
||||
$definitions = $registry->definitions();
|
||||
|
||||
expect($definitions)->toHaveCount(23);
|
||||
expect($definitions)->toHaveCount(24);
|
||||
expect(array_keys($definitions))->toContain(
|
||||
'system.sync_logs',
|
||||
'backups.process_jobs',
|
||||
@@ -16,6 +16,7 @@ it('discovers module-owned cron task definitions', function (): void {
|
||||
'dynamicimages.pre_render',
|
||||
'weatherapi.preload_department_responses',
|
||||
'goals.progress_alerts',
|
||||
'xlvask.autopilot_queue',
|
||||
'account.process_deletion_requests',
|
||||
'selfserve.activate_opening_cleaner_relays'
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<?php
|
||||
|
||||
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_autopilot_service.php';
|
||||
require_once WD . '/objects/xlvask_usage_logs_o.php';
|
||||
|
||||
it('normalizes registrations for XL Vask automation signatures', function (): void {
|
||||
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`)');
|
||||
});
|
||||
|
||||
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 {
|
||||
$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.';");
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
expect($serviceContent)
|
||||
->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 {
|
||||
|
||||
@@ -23,9 +23,13 @@ it('does not execute XL Vask usage automation while listing usage order rows', f
|
||||
$route = (string)$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)->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 {
|
||||
@@ -36,7 +40,7 @@ it('returns cached amount summaries on XL Vask usage order rows without widening
|
||||
$route = (string)$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('$tmp->setProperties($usage_log_payload)')
|
||||
->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']");
|
||||
});
|
||||
|
||||
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');
|
||||
$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)
|
||||
->toContain("getParameter('dateFrom')")
|
||||
->toContain("getParameter('dateTo')")
|
||||
->toContain('$xlvask_usage_logs_o->importUsageLogs($dateFrom, $dateTo)')
|
||||
->toContain('runPending($dateFrom, $dateTo, [], 100, null)')
|
||||
->toContain("'replacement' => '/modules/xlvask/services/usage/autopilot-runs'")
|
||||
->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')");
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user