Files
api/services/nginx/app/classes/xlvask_usage_logs_schema_bootstrap.php
T
Jeppe B 6d888a455d 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.
2026-08-03 15:33:55 +02:00

402 lines
20 KiB
PHP

<?php
namespace classes;
/**
* Ensures additive schema for XL Vask usage-log review state.
*/
class xlvask_usage_logs_schema_bootstrap
{
private static bool $initialized = false;
public static function ensureTables(): void
{
if (self::$initialized) {
return;
}
global $db;
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
return;
}
if (!self::tableExists($db, 'xlvask_usage_logs')) {
return;
}
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
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;
}
private static function ensureAutomationTables(object $db): void
{
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` (
`id` INT NOT NULL AUTO_INCREMENT,
`usage_log_id` INT NOT NULL,
`wash_id` VARCHAR(128) NOT NULL,
`signature_hash` CHAR(64) NOT NULL,
`signature_json` LONGTEXT NULL,
`action` VARCHAR(32) NOT NULL,
`status` VARCHAR(32) NOT NULL DEFAULT 'suggested',
`confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
`source` VARCHAR(32) NOT NULL DEFAULT 'deterministic',
`matched_order_id` INT NULL,
`created_order_id` INT NULL,
`proposed_order_json` LONGTEXT NULL,
`candidate_order_json` LONGTEXT NULL,
`reason` TEXT NULL,
`created_by` INT NULL,
`decided_by` INT NULL,
`decided_at` DATETIME NULL,
`executed_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_automation_usage` (`usage_log_id`),
KEY `idx_xlvask_automation_wash` (`wash_id`),
KEY `idx_xlvask_automation_signature` (`signature_hash`),
KEY `idx_xlvask_automation_status` (`status`)
) 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,
`usage_log_id` INT NULL,
`wash_id` VARCHAR(128) NULL,
`signature_hash` CHAR(64) NOT NULL,
`signature_json` LONGTEXT NULL,
`action` VARCHAR(32) NOT NULL,
`decision` VARCHAR(32) NOT NULL,
`order_id` INT NULL,
`reason` TEXT NULL,
`created_by` INT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`),
KEY `idx_xlvask_feedback_usage` (`usage_log_id`),
KEY `idx_xlvask_feedback_decision` (`decision`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
);
$db->query(
"CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` (
`id` INT NOT NULL AUTO_INCREMENT,
`cache_key` CHAR(64) NOT NULL,
`schema_name` VARCHAR(96) NOT NULL,
`input_json` LONGTEXT NOT NULL,
`result_json` LONGTEXT NOT NULL,
`hits` INT NOT NULL DEFAULT 0,
`last_hit_at` DATETIME 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_openai_cache_key` (`cache_key`),
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
{
if (!self::columnExists($db, $table, $column)) {
$db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}");
}
}
private static function tableExists(object $db, string $table): bool
{
$table = self::escapeIdentifier($table);
$result = $db->query("SHOW TABLES LIKE '{$table}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
return (int)$result->num_rows > 0;
}
private static function columnExists(object $db, string $table, string $column): bool
{
$table = self::escapeIdentifier($table);
$column = self::escapeIdentifier($column);
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
return false;
}
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);
}
}