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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user