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.
2375 lines
103 KiB
PHP
2375 lines
103 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
|
|
|
use Exception;
|
|
use helpers\xlvask_usage_log;
|
|
use objects\orders_o;
|
|
use objects\xlvask_usage_logs_o;
|
|
|
|
class xlvask_automation_service
|
|
{
|
|
private const ACTION_ATTACH = 'attach_order';
|
|
private const ACTION_CREATE = 'create_order';
|
|
private const ACTION_NONE = 'none';
|
|
|
|
private const STATUS_SUGGESTED = 'suggested';
|
|
private const STATUS_AUTO_ACCEPTED = 'auto_accepted';
|
|
private const STATUS_ACCEPTED = 'accepted';
|
|
private const STATUS_DENIED = 'denied';
|
|
private const STATUS_FAILED = 'failed';
|
|
private const STATUS_NONE = 'none';
|
|
|
|
private const SOURCE_DETERMINISTIC = 'deterministic';
|
|
private const SOURCE_FUZZY = 'fuzzy';
|
|
private const SOURCE_HISTORY = 'history';
|
|
private const SOURCE_OPENAI = 'openai';
|
|
|
|
private const AUTOMATION_CASHIER_ID = 2285;
|
|
private const MIN_SUGGESTION_CONFIDENCE = 0.70;
|
|
private const AUTO_ATTACH_CONFIDENCE = 0.92;
|
|
private const AUTO_CREATE_CONFIDENCE = 0.97;
|
|
private const CREATE_MIN_AGE_HOURS = 6.0;
|
|
private const OPENAI_CACHE_VERSION = 1;
|
|
public const POLICY_VERSION = 'xlvask-autopilot-v1';
|
|
private const PLANNER_MODEL = 'gpt-5.6-sol';
|
|
private ?int $runId = null;
|
|
private bool $readOnlyEvaluation = false;
|
|
|
|
public function __construct()
|
|
{
|
|
// Read construction must not perform DDL. Mutation entry points bootstrap explicitly.
|
|
}
|
|
|
|
public function setRunContext(?int $runId): self
|
|
{
|
|
$this->runId = $runId !== null && $runId > 0 ? $runId : null;
|
|
return $this;
|
|
}
|
|
|
|
public function setReadOnlyEvaluation(bool $readOnly): self
|
|
{
|
|
$this->readOnlyEvaluation = $readOnly;
|
|
return $this;
|
|
}
|
|
|
|
public function evaluateUsageLogById(int $usageLogId, ?int $actorId = null, bool $allowExecute = true): array
|
|
{
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
$row = $this->loadUsageLogRow($usageLogId);
|
|
if ($row === null) {
|
|
return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.');
|
|
}
|
|
|
|
return $this->evaluateUsageLogRow($row, $actorId, $allowExecute);
|
|
}
|
|
|
|
public function evaluateUsageLogRow(array $row, ?int $actorId = null, bool $allowExecute = true): array
|
|
{
|
|
$usageLogId = (int)($row['id'] ?? 0);
|
|
if ($usageLogId < 1) {
|
|
return $this->emptyAutomation('XL Vask-vasken mangler et gyldigt id.');
|
|
}
|
|
if ((string)($row['import_state'] ?? '') === 'invalid') {
|
|
return [
|
|
...$this->emptyAutomation('XL Vask-kildedata kunne ikke valideres.'),
|
|
'status' => self::STATUS_FAILED,
|
|
'resolution_state' => 'failed',
|
|
];
|
|
}
|
|
|
|
try {
|
|
$log = $this->usageLogFromRow($row);
|
|
$guard = $this->guardReason($log, false);
|
|
if ($guard !== null) {
|
|
return $this->emptyAutomation($guard);
|
|
}
|
|
$linkedOrder = $this->existingLinkedOrder($log);
|
|
if ($linkedOrder !== null) {
|
|
if ((string)($row['import_state'] ?? '') === 'updated'
|
|
|| (string)($row['planned_action'] ?? '') === 'recheck') {
|
|
return [
|
|
...$this->emptyAutomation('Den tilknyttede ordre afventer revalidering mod den ændrede XL Vask-kilde.'),
|
|
'resolution_state' => 'needs_review',
|
|
'certainty' => 'uncertain',
|
|
'planned_action' => 'recheck',
|
|
'risk_flags' => ['linked_order_revision_mismatch'],
|
|
'matched_order_id' => (int)($linkedOrder->id ?? 0),
|
|
];
|
|
}
|
|
$linkedContext = $this->buildContext($usageLogId, $log, $row);
|
|
$linkedItems = (new orders_o())->getOrderItems((int)($linkedOrder->id ?? 0));
|
|
$linkedOrderData = $linkedOrder->asArray(true, false);
|
|
if (!self::linkedOrderMatchesForAutomation(
|
|
(array)$linkedContext['proposed_order'],
|
|
(array)$linkedContext['items'],
|
|
$linkedOrderData,
|
|
$linkedItems
|
|
)) {
|
|
return [
|
|
...$this->emptyAutomation('Den tilknyttede ordre matcher ikke den aktuelle XL Vask-revision.'),
|
|
'resolution_state' => 'needs_review',
|
|
'certainty' => 'uncertain',
|
|
'planned_action' => 'recheck',
|
|
'risk_flags' => ['linked_order_revision_mismatch'],
|
|
'matched_order_id' => (int)($linkedOrder->id ?? 0),
|
|
];
|
|
}
|
|
return [
|
|
...$this->emptyAutomation('XL Vask-vasken er allerede knyttet til en ordre.'),
|
|
'status' => self::STATUS_ACCEPTED,
|
|
'matched_order_id' => (int)($linkedOrder->id ?? 0),
|
|
'resolution_state' => 'already_linked',
|
|
'certainty' => 'certain',
|
|
'evidence' => [[
|
|
'type' => 'existing_link_semantically_revalidated',
|
|
'value' => true,
|
|
]],
|
|
];
|
|
}
|
|
|
|
$existing = $this->latestTerminalSuggestion($usageLogId);
|
|
if ($existing !== null) {
|
|
if ((string)$existing['status'] === self::STATUS_SUGGESTED) {
|
|
if (!$allowExecute) {
|
|
return $this->formatSuggestion($existing);
|
|
}
|
|
|
|
$context = $this->buildContext($usageLogId, $log, $row);
|
|
$contextGuard = $this->contextGuardReason($context);
|
|
if ($contextGuard !== null) {
|
|
return $this->emptyAutomation($contextGuard);
|
|
}
|
|
|
|
$freshSuggestion = $this->buildSuggestionForContext($context);
|
|
if ($freshSuggestion !== null && !$this->readOnlyEvaluation) {
|
|
$freshSuggestion = $this->decorateSuggestionWithEvidence($freshSuggestion, $context);
|
|
$suggestionId = $this->persistSuggestion($context, $freshSuggestion, $actorId);
|
|
$existing = $this->loadSuggestion($suggestionId) ?? $existing;
|
|
}
|
|
|
|
if ($allowExecute && $this->shouldAutoExecute($existing, $context)) {
|
|
return $this->executeSuggestion($existing, $context, $actorId, true);
|
|
}
|
|
}
|
|
|
|
return $this->formatSuggestion($existing);
|
|
}
|
|
|
|
$context = $this->buildContext($usageLogId, $log, $row);
|
|
$contextGuard = $this->contextGuardReason($context);
|
|
if ($contextGuard !== null) {
|
|
return $this->emptyAutomation($contextGuard);
|
|
}
|
|
|
|
if ($this->hasDeniedFeedback($context['signature_hash'], self::ACTION_ATTACH)
|
|
&& $this->hasDeniedFeedback($context['signature_hash'], self::ACTION_CREATE)) {
|
|
return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.');
|
|
}
|
|
|
|
$suggestion = $this->buildSuggestionForContext($context);
|
|
|
|
if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) {
|
|
return $this->emptyAutomation('Ingen sikker automatiseringshandling fundet.');
|
|
}
|
|
|
|
if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) {
|
|
return $this->emptyAutomation('Tidligere afvist for samme køretøjsmønster.');
|
|
}
|
|
|
|
$suggestion = $this->decorateSuggestionWithEvidence($suggestion, $context);
|
|
if ($this->readOnlyEvaluation) {
|
|
return $this->formatTransientSuggestion($usageLogId, $suggestion);
|
|
}
|
|
$suggestionId = $this->persistSuggestion($context, $suggestion, $actorId);
|
|
$suggestionRow = $this->loadSuggestion($suggestionId);
|
|
if ($suggestionRow === null) {
|
|
return $this->emptyAutomation('Forslaget kunne ikke gemmes.');
|
|
}
|
|
|
|
if ($allowExecute && $this->shouldAutoExecute($suggestionRow, $context)) {
|
|
return $this->executeSuggestion($suggestionRow, $context, $actorId, true);
|
|
}
|
|
|
|
return $this->formatSuggestion($suggestionRow);
|
|
} catch (Exception $e) {
|
|
return [
|
|
...$this->emptyAutomation('Automatiseringen kunne ikke evaluere vasken.'),
|
|
'status' => self::STATUS_FAILED,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
public function acceptUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array
|
|
{
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
$row = $this->loadUsageLogRow($usageLogId);
|
|
if ($row === null) {
|
|
return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.');
|
|
}
|
|
|
|
$log = $this->usageLogFromRow($row);
|
|
$guard = $this->guardReason($log);
|
|
if ($guard !== null) {
|
|
return $this->emptyAutomation($guard);
|
|
}
|
|
|
|
$context = $this->buildContext($usageLogId, $log, $row);
|
|
$contextGuard = $this->contextGuardReason($context);
|
|
if ($contextGuard !== null) {
|
|
return $this->emptyAutomation($contextGuard);
|
|
}
|
|
|
|
$suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId);
|
|
if ($suggestion === null) {
|
|
$this->evaluateUsageLogRow($row, $actorId, false);
|
|
$suggestion = $this->latestActionableSuggestion($usageLogId);
|
|
}
|
|
|
|
if ($suggestion === null) {
|
|
return $this->emptyAutomation('Der er intet forslag at acceptere.');
|
|
}
|
|
if ((int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) {
|
|
return $this->emptyAutomation('Forslaget tilhører en anden XL Vask-vask.');
|
|
}
|
|
|
|
$result = $this->executeSuggestion($suggestion, $context, $actorId, false);
|
|
$this->updateUsageLogAutomationState($usageLogId, $result, null);
|
|
if ((string)($result['status'] ?? '') === self::STATUS_ACCEPTED) {
|
|
$this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($result['matched_order_id'] ?? $result['created_order_id'] ?? 0), $actorId, $reason);
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
public function denyUsageLogById(int $usageLogId, ?int $actorId = null, ?int $suggestionId = null, ?string $reason = null): array
|
|
{
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
$row = $this->loadUsageLogRow($usageLogId);
|
|
if ($row === null) {
|
|
return $this->emptyAutomation('XL Vask-vasken blev ikke fundet.');
|
|
}
|
|
|
|
$log = $this->usageLogFromRow($row);
|
|
$context = $this->buildContext($usageLogId, $log, $row);
|
|
$suggestion = $suggestionId !== null ? $this->loadSuggestion($suggestionId) : $this->latestActionableSuggestion($usageLogId);
|
|
|
|
if ($suggestion === null) {
|
|
return $this->emptyAutomation('Der er intet forslag at afvise.');
|
|
}
|
|
if ((int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) {
|
|
return $this->emptyAutomation('Forslaget tilhører en anden XL Vask-vask.');
|
|
}
|
|
|
|
$this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId);
|
|
$this->persistFeedback($context, (string)$suggestion['action'], 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason);
|
|
|
|
$result = $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion);
|
|
$this->updateUsageLogAutomationState($usageLogId, $result, null);
|
|
global $db;
|
|
$db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}");
|
|
return $result;
|
|
}
|
|
|
|
/** Apply a preview-bound decision while the caller owns the transaction and row locks. */
|
|
public function applyBoundDecisionWithinTransaction(
|
|
int $usageLogId,
|
|
string $requestedAction,
|
|
?int $suggestionId,
|
|
?int $candidateOrderId,
|
|
int $expectedVersion,
|
|
string $sourceHash,
|
|
?int $actorId,
|
|
?string $reason = null
|
|
): array {
|
|
global $db;
|
|
$rowResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE");
|
|
$row = $rowResult !== false && $rowResult->num_rows > 0 ? $db->fetch_assoc($rowResult) : null;
|
|
if ($row === null
|
|
|| (int)($row['expected_version'] ?? 0) !== $expectedVersion
|
|
|| !hash_equals((string)($row['source_hash'] ?? ''), $sourceHash)) {
|
|
throw new Exception('XL Vask-vasken blev ændret efter forhåndsvisningen.');
|
|
}
|
|
|
|
$suggestionResult = $suggestionId === null ? false : $db->query(
|
|
"SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} FOR UPDATE"
|
|
);
|
|
$suggestion = $suggestionResult !== false && $suggestionResult->num_rows > 0
|
|
? $db->fetch_assoc($suggestionResult)
|
|
: null;
|
|
if ($suggestion === null || (int)($suggestion['usage_log_id'] ?? 0) !== $usageLogId) {
|
|
throw new Exception('Forslaget mangler eller tilhører en anden XL Vask-vask.');
|
|
}
|
|
if ((string)($suggestion['status'] ?? '') !== self::STATUS_SUGGESTED) {
|
|
throw new Exception('Forslaget er ikke længere aktivt.');
|
|
}
|
|
$suggestedAction = (string)$suggestion['action'];
|
|
if ($requestedAction === 'create_order' && $suggestedAction !== self::ACTION_CREATE) {
|
|
throw new Exception('Forslaget er ikke en ordreoprettelse.');
|
|
}
|
|
|
|
$context = $this->buildContext($usageLogId, $this->usageLogFromRow($row), $row);
|
|
if ($requestedAction === self::ACTION_ATTACH) {
|
|
$candidate = $this->candidateFromContext($context, (int)$candidateOrderId);
|
|
if ($candidate === null) {
|
|
throw new Exception('Den valgte ordre er ikke længere en tilladt kandidat.');
|
|
}
|
|
// Manual alternative selection is always uncertain and can never feed automatic certainty.
|
|
$suggestion['action'] = self::ACTION_ATTACH;
|
|
$suggestion['matched_order_id'] = (int)$candidate['id'];
|
|
$suggestion['candidate_order'] = $candidate;
|
|
$suggestion['candidate_order_json'] = json_encode($candidate, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$suggestion['certainty'] = 'uncertain';
|
|
}
|
|
if ($requestedAction === 'deny') {
|
|
$this->updateSuggestionStatus((int)$suggestion['id'], self::STATUS_DENIED, $actorId);
|
|
$this->persistFeedback($context, $suggestedAction, 'denied', (int)($suggestion['matched_order_id'] ?? 0), $actorId, $reason);
|
|
$this->syncUsageState($usageLogId, 'needs_review', 'none', 'none', $reason ?? 'Forslaget blev afvist.');
|
|
if ($db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}") === false
|
|
|| $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask usage-log version could not be advanced atomically.');
|
|
}
|
|
return $this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion);
|
|
}
|
|
|
|
$latest = $this->executeSuggestionWithinTransaction($suggestion, $context, $actorId, false);
|
|
$executedAction = $requestedAction === 'accept' ? $suggestedAction : $requestedAction;
|
|
$this->persistFeedback(
|
|
$context,
|
|
$executedAction,
|
|
'accepted',
|
|
(int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0),
|
|
$actorId,
|
|
$reason
|
|
);
|
|
return $this->formatSuggestion($latest);
|
|
}
|
|
|
|
/** Rebuild and validate a manual candidate against current server-side usage context. */
|
|
public function validateManualCandidate(int $usageLogId, int $orderId): array
|
|
{
|
|
$row = $this->loadUsageLogRow($usageLogId);
|
|
if ($row === null || $orderId < 1) {
|
|
throw new Exception('The selected XL Vask order candidate was not found.');
|
|
}
|
|
$context = $this->buildContext($usageLogId, $this->usageLogFromRow($row), $row);
|
|
$candidate = $this->candidateFromContext($context, $orderId);
|
|
if ($candidate === null) {
|
|
throw new Exception('The selected order is not a current server-derived candidate.');
|
|
}
|
|
return [
|
|
'id' => (int)$candidate['id'],
|
|
'customer_id' => (int)($candidate['customer_id'] ?? 0),
|
|
'department_id' => (int)($candidate['department_id'] ?? 0),
|
|
'created_at' => $candidate['created_at'] ?? null,
|
|
];
|
|
}
|
|
|
|
public function runPending(
|
|
?string $dateFrom = null,
|
|
?string $dateTo = null,
|
|
array $ids = [],
|
|
int $limit = 100,
|
|
?int $actorId = null,
|
|
bool $allowExecute = true,
|
|
?int $runId = null,
|
|
array $allowedHallIds = [],
|
|
bool $readOnlyEvaluation = false,
|
|
?callable $heartbeat = null
|
|
): array
|
|
{
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
if ($actorId !== null && $allowedHallIds === []) {
|
|
throw new Exception('No XL Vask hall scope is available for this user.');
|
|
}
|
|
$limit = max(1, min(500, $limit));
|
|
$rows = $ids !== []
|
|
? array_slice($this->loadUsageLogRowsByIds($ids, $allowedHallIds), 0, $limit)
|
|
: $this->loadPendingRows($dateFrom, $dateTo, $limit, $allowedHallIds);
|
|
$eligibleTotal = $ids !== [] ? count($rows) : $this->countPendingRows($dateFrom, $dateTo, $allowedHallIds);
|
|
$results = [];
|
|
$autoLinks = 0;
|
|
$autoCreates = 0;
|
|
$attemptedActions = 0;
|
|
$actionFailures = 0;
|
|
$consecutiveFailures = 0;
|
|
$circuitBreaker = null;
|
|
$this->setReadOnlyEvaluation($readOnlyEvaluation);
|
|
foreach ($rows as $row) {
|
|
if ($heartbeat !== null) {
|
|
$heartbeat();
|
|
}
|
|
$executionAllowed = !$readOnlyEvaluation && $allowExecute && $autoLinks < 500 && $autoCreates < 100;
|
|
$result = $this->evaluateUsageLogRow($row, $actorId, $executionAllowed);
|
|
if (!$readOnlyEvaluation && (string)($row['resolution_state'] ?? '') !== 'ignored') {
|
|
$this->updateUsageLogAutomationState((int)($row['id'] ?? 0), $result, $runId);
|
|
}
|
|
$results[] = ['usage_log_id' => (int)($row['id'] ?? 0), ...$result];
|
|
$status = (string)($result['status'] ?? self::STATUS_NONE);
|
|
$action = (string)($result['action'] ?? self::ACTION_NONE);
|
|
if ($status === self::STATUS_AUTO_ACCEPTED) {
|
|
$attemptedActions++;
|
|
$consecutiveFailures = 0;
|
|
if ($action === self::ACTION_CREATE) {
|
|
$autoCreates++;
|
|
} elseif ($action === self::ACTION_ATTACH) {
|
|
$autoLinks++;
|
|
}
|
|
} elseif ($status === self::STATUS_FAILED && $action !== self::ACTION_NONE) {
|
|
$attemptedActions++;
|
|
$actionFailures++;
|
|
$consecutiveFailures++;
|
|
$error = strtolower((string)($result['error'] ?? ''));
|
|
if (str_contains($error, 'allerede tilknyttet')
|
|
|| str_contains($error, 'ændret efter evalueringen')
|
|
|| str_contains($error, 'atomisk')
|
|
|| str_contains($error, 'unik wash_id')) {
|
|
$circuitBreaker = 'critical_execution_invariant';
|
|
}
|
|
} else {
|
|
$consecutiveFailures = 0;
|
|
}
|
|
|
|
if ($circuitBreaker !== null
|
|
|| $consecutiveFailures >= 3
|
|
|| ($attemptedActions >= 20 && ($actionFailures / $attemptedActions) > 0.02)) {
|
|
$circuitBreaker ??= $consecutiveFailures >= 3 ? 'three_consecutive_failures' : 'action_failure_rate';
|
|
break;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'selected' => count($rows),
|
|
'eligible_total' => $eligibleTotal,
|
|
'processed' => count($results),
|
|
'results' => $results,
|
|
'automatic_links' => $autoLinks,
|
|
'automatic_creations' => $autoCreates,
|
|
'circuit_breaker' => $circuitBreaker,
|
|
];
|
|
}
|
|
|
|
/** Read-only automation projection for list/detail GET endpoints. */
|
|
public function readAutomationStateByUsageLogId(int $usageLogId, array $usageRow = []): array
|
|
{
|
|
try {
|
|
$suggestion = $this->latestSuggestionWhere($usageLogId, [
|
|
self::STATUS_SUGGESTED,
|
|
self::STATUS_AUTO_ACCEPTED,
|
|
self::STATUS_ACCEPTED,
|
|
self::STATUS_DENIED,
|
|
self::STATUS_FAILED,
|
|
]);
|
|
$result = $suggestion === null ? $this->emptyAutomation((string)($usageRow['state_reason'] ?? '')) : $this->formatSuggestion($suggestion);
|
|
} catch (\Throwable) {
|
|
$result = $this->emptyAutomation((string)($usageRow['state_reason'] ?? ''));
|
|
}
|
|
|
|
return [
|
|
...$result,
|
|
'usage_log_id' => $usageLogId,
|
|
'import_state' => (string)($usageRow['import_state'] ?? 'unchanged'),
|
|
'resolution_state' => (string)($usageRow['resolution_state'] ?? ($result['status'] === self::STATUS_FAILED ? 'failed' : 'needs_review')),
|
|
'certainty' => (string)($usageRow['certainty'] ?? $result['certainty'] ?? 'none'),
|
|
'planned_action' => (string)($usageRow['planned_action'] ?? $result['action'] ?? self::ACTION_NONE),
|
|
'source_revision' => $usageRow['source_revision'] ?? null,
|
|
'source_hash' => $usageRow['source_hash'] ?? null,
|
|
'expected_version' => isset($usageRow['expected_version']) ? (int)$usageRow['expected_version'] : null,
|
|
'run_id' => isset($usageRow['last_run_id']) ? (int)$usageRow['last_run_id'] : ($result['run_id'] ?? null),
|
|
];
|
|
}
|
|
|
|
public static function classifyCertaintyForAutomation(
|
|
array $calibration,
|
|
bool $hardGuardsPass = true,
|
|
array $contradictions = []
|
|
): string {
|
|
if (!$hardGuardsPass || $contradictions !== []) {
|
|
return 'uncertain';
|
|
}
|
|
|
|
return (bool)($calibration['active'] ?? false)
|
|
&& (float)($calibration['precision_value'] ?? 0) >= 0.995
|
|
&& (float)($calibration['wilson_lower_bound'] ?? 0) >= 0.98
|
|
&& (int)($calibration['overall_examples'] ?? 0) >= 200
|
|
&& (int)($calibration['segment_examples'] ?? 0) >= 30
|
|
&& (int)($calibration['holdout_examples'] ?? 0) > 0
|
|
&& (int)($calibration['contradictions'] ?? 0) === 0
|
|
? 'certain'
|
|
: 'uncertain';
|
|
}
|
|
|
|
public static function normalizeRegistrationForAutomation(string $registration): string
|
|
{
|
|
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', $registration) ?? '');
|
|
}
|
|
|
|
public static function sourceIsStableForAutomatic(array $source, ?int $now = null): bool
|
|
{
|
|
$stableSince = strtotime((string)($source['source_stable_since'] ?? ''));
|
|
return (int)($source['source_observation_count'] ?? 0) >= 2
|
|
&& !empty($source['source_observed_at'])
|
|
&& $stableSince !== false
|
|
&& $stableSince <= (($now ?? time()) - (int)(self::CREATE_MIN_AGE_HOURS * 3600));
|
|
}
|
|
|
|
public static function itemSignaturePartsForAutomation(array $items): array
|
|
{
|
|
$parts = [];
|
|
foreach ($items as $item) {
|
|
if (!is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$parts[] = implode(':', [
|
|
(int)($item['product_id'] ?? 0),
|
|
(int)($item['quantity'] ?? 0),
|
|
(int)($item['price'] ?? 0),
|
|
]);
|
|
}
|
|
|
|
sort($parts, SORT_STRING);
|
|
return $parts;
|
|
}
|
|
|
|
public static function scoreItemMatchForAutomation(array $usageItems, array $orderItems): array
|
|
{
|
|
$usageSignature = self::itemSignaturePartsForAutomation($usageItems);
|
|
$orderSignature = self::itemSignaturePartsForAutomation($orderItems);
|
|
$usageTotal = self::itemsTotalForAutomation($usageItems);
|
|
$orderTotal = self::itemsTotalForAutomation($orderItems);
|
|
|
|
if ($usageSignature === $orderSignature && $usageTotal === $orderTotal) {
|
|
return [
|
|
'confidence' => 0.95,
|
|
'source' => self::SOURCE_DETERMINISTIC,
|
|
'reason' => 'Produkterne og prisen matcher en ordre fra samme dag.',
|
|
];
|
|
}
|
|
|
|
$usagePrimary = (int)($usageItems[0]['product_id'] ?? 0);
|
|
$orderPrimary = (int)($orderItems[0]['product_id'] ?? 0);
|
|
if ($usagePrimary < 1 || $usagePrimary !== $orderPrimary) {
|
|
return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => ''];
|
|
}
|
|
|
|
$overlap = self::productOverlapForAutomation($usageItems, $orderItems);
|
|
$totalDiff = abs($usageTotal - $orderTotal);
|
|
if ($overlap >= 0.70 && $totalDiff <= 50) {
|
|
return [
|
|
'confidence' => 0.93,
|
|
'source' => self::SOURCE_FUZZY,
|
|
'reason' => 'Samme primære produkt og relaterede tilføjelser matcher en ordre fra samme dag.',
|
|
];
|
|
}
|
|
|
|
if ($overlap >= 0.50 && $totalDiff <= 150) {
|
|
return [
|
|
'confidence' => 0.80,
|
|
'source' => self::SOURCE_FUZZY,
|
|
'reason' => 'Vasken ligner en ordre fra samme dag, men kræver manuel godkendelse.',
|
|
];
|
|
}
|
|
|
|
$matchableUsageItems = self::matchableUsageItemsForAutomation($usageItems);
|
|
$matchableOverlap = self::productOverlapForAutomation($matchableUsageItems, $orderItems);
|
|
if (
|
|
$matchableUsageItems !== []
|
|
&& $matchableOverlap >= 0.95
|
|
&& self::orderHasAdditionsBeyondUsage($matchableUsageItems, $orderItems)
|
|
) {
|
|
return [
|
|
'confidence' => 0.88,
|
|
'source' => self::SOURCE_FUZZY,
|
|
'reason' => 'Ordren indeholder XL Vask-produkterne samt ekstra ydelser fra samme dag.',
|
|
];
|
|
}
|
|
|
|
return ['confidence' => 0.0, 'source' => self::SOURCE_DETERMINISTIC, 'reason' => ''];
|
|
}
|
|
|
|
public static function itemsTotalForAutomation(array $items): int
|
|
{
|
|
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
|
|
}
|
|
|
|
public static function isExactItemMatchForAutomation(array $usageItems, array $orderItems): bool
|
|
{
|
|
return self::itemSignaturePartsForAutomation($usageItems) === self::itemSignaturePartsForAutomation($orderItems)
|
|
&& self::itemsTotalForAutomation($usageItems) === self::itemsTotalForAutomation($orderItems);
|
|
}
|
|
|
|
public static function linkedOrderMatchesForAutomation(
|
|
array $proposedOrder,
|
|
array $proposedItems,
|
|
array $linkedOrder,
|
|
array $linkedItems
|
|
): bool {
|
|
if (!self::isExactItemMatchForAutomation($proposedItems, $linkedItems)
|
|
|| (int)($proposedOrder['customer_id'] ?? 0) < 1
|
|
|| (int)($proposedOrder['customer_id'] ?? 0) !== (int)($linkedOrder['customer_id'] ?? 0)
|
|
|| (int)($proposedOrder['department_id'] ?? 0) < 1
|
|
|| (int)($proposedOrder['department_id'] ?? 0) !== (int)($linkedOrder['department_id'] ?? 0)) {
|
|
return false;
|
|
}
|
|
$proposedRegistrations = array_values(array_unique(array_filter(array_map(
|
|
[self::class, 'normalizeRegistrationForAutomation'],
|
|
[(string)($proposedOrder['reg_1'] ?? ''), (string)($proposedOrder['reg_2'] ?? ''), (string)($proposedOrder['reg_3'] ?? '')]
|
|
))));
|
|
$linkedRegistrations = array_values(array_unique(array_filter(array_map(
|
|
[self::class, 'normalizeRegistrationForAutomation'],
|
|
[(string)($linkedOrder['reg_1'] ?? ''), (string)($linkedOrder['reg_2'] ?? ''), (string)($linkedOrder['reg_3'] ?? '')]
|
|
))));
|
|
if ($proposedRegistrations === [] || array_diff($proposedRegistrations, $linkedRegistrations) !== []) {
|
|
return false;
|
|
}
|
|
$proposedLane = (int)($proposedOrder['lane'] ?? 0);
|
|
if ($proposedLane > 0 && $proposedLane !== (int)($linkedOrder['lane'] ?? 0)) {
|
|
return false;
|
|
}
|
|
$proposedDate = substr((string)($proposedOrder['created_at'] ?? ''), 0, 10);
|
|
$linkedDate = substr((string)($linkedOrder['created_at'] ?? ''), 0, 10);
|
|
return $proposedDate === '' || $linkedDate === '' || $proposedDate === $linkedDate;
|
|
}
|
|
|
|
public static function productOverlapForAutomation(array $usageItems, array $orderItems): float
|
|
{
|
|
$usageBag = self::productBagForAutomation($usageItems);
|
|
$orderBag = self::productBagForAutomation($orderItems);
|
|
$usageTotal = array_sum($usageBag);
|
|
if ($usageTotal <= 0) {
|
|
return 0.0;
|
|
}
|
|
|
|
$overlap = 0;
|
|
foreach ($usageBag as $productId => $quantity) {
|
|
$overlap += min($quantity, $orderBag[$productId] ?? 0);
|
|
}
|
|
|
|
return $overlap / $usageTotal;
|
|
}
|
|
|
|
public static function productBagForAutomation(array $items): array
|
|
{
|
|
$bag = [];
|
|
foreach ($items as $item) {
|
|
$productId = (int)($item['product_id'] ?? 0);
|
|
if ($productId < 1) {
|
|
continue;
|
|
}
|
|
$bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1));
|
|
}
|
|
|
|
return $bag;
|
|
}
|
|
|
|
private static function matchableUsageItemsForAutomation(array $items): array
|
|
{
|
|
$positiveItems = array_values(array_filter($items, static function (array $item): bool {
|
|
return (int)($item['product_id'] ?? 0) > 0
|
|
&& (int)($item['quantity'] ?? 0) > 0
|
|
&& (int)($item['price'] ?? 0) > 0;
|
|
}));
|
|
|
|
if ($positiveItems !== []) {
|
|
return $positiveItems;
|
|
}
|
|
|
|
return array_values(array_filter($items, static function (array $item): bool {
|
|
return (int)($item['product_id'] ?? 0) > 0
|
|
&& (int)($item['quantity'] ?? 0) > 0;
|
|
}));
|
|
}
|
|
|
|
private static function orderHasAdditionsBeyondUsage(array $usageItems, array $orderItems): bool
|
|
{
|
|
$usageBag = self::productBagForAutomation($usageItems);
|
|
foreach (self::productBagForAutomation($orderItems) as $productId => $quantity) {
|
|
if ($quantity > ($usageBag[$productId] ?? 0)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public static function normalizeUsageLogRowForAutomation(array $row): array
|
|
{
|
|
unset($row['id']);
|
|
|
|
$washItems = $row['WashItems'] ?? [];
|
|
if (is_string($washItems)) {
|
|
$decoded = json_decode($washItems, true);
|
|
$row['WashItems'] = is_array($decoded) ? $decoded : [];
|
|
} elseif (!is_array($washItems)) {
|
|
$row['WashItems'] = [];
|
|
}
|
|
|
|
return $row;
|
|
}
|
|
|
|
public static function openAiCacheKeyForAutomation(
|
|
string $schemaName,
|
|
string $prompt,
|
|
array $payload,
|
|
array $schema,
|
|
float $temperature
|
|
): string {
|
|
$input = [
|
|
'version' => self::OPENAI_CACHE_VERSION,
|
|
'schema_name' => $schemaName,
|
|
'prompt' => $prompt,
|
|
'payload' => $payload,
|
|
'schema' => $schema,
|
|
'temperature' => round($temperature, 4),
|
|
];
|
|
|
|
return hash('sha256', self::stableJsonForAutomation($input));
|
|
}
|
|
|
|
public static function stableJsonForAutomation(mixed $value): string
|
|
{
|
|
$encoded = json_encode(
|
|
self::normalizeForStableJson($value),
|
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION
|
|
);
|
|
|
|
if ($encoded === false) {
|
|
throw new Exception('Kunne ikke opbygge en stabil cache-nøgle for XL Vask-automatisering.');
|
|
}
|
|
|
|
return $encoded;
|
|
}
|
|
|
|
private static function normalizeForStableJson(mixed $value): mixed
|
|
{
|
|
if (!is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
$normalized = array_map(fn(mixed $item): mixed => self::normalizeForStableJson($item), $value);
|
|
$isList = $normalized === [] || array_keys($normalized) === range(0, count($normalized) - 1);
|
|
if (!$isList) {
|
|
ksort($normalized, SORT_STRING);
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
private function buildDeterministicSuggestion(array $context): ?array
|
|
{
|
|
$best = null;
|
|
foreach ($context['candidate_orders'] as $candidate) {
|
|
$score = $this->scoreOrderMatch($context['items'], $candidate['order_items']);
|
|
if ($score['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) {
|
|
continue;
|
|
}
|
|
|
|
$candidateSuggestion = [
|
|
'action' => self::ACTION_ATTACH,
|
|
'confidence' => $score['confidence'],
|
|
'source' => $score['source'],
|
|
'matched_order_id' => (int)$candidate['id'],
|
|
'created_order_id' => null,
|
|
'candidate_order' => $candidate,
|
|
'proposed_order' => $context['proposed_order'],
|
|
'reason' => $score['reason'] . ' Ordre #' . (int)$candidate['id'] . '.',
|
|
];
|
|
|
|
if ($best === null || $candidateSuggestion['confidence'] > $best['confidence']) {
|
|
$best = $candidateSuggestion;
|
|
}
|
|
}
|
|
|
|
if ($best !== null && $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_ATTACH)) {
|
|
$best['confidence'] = max($best['confidence'], 0.96);
|
|
$best['source'] = self::SOURCE_HISTORY;
|
|
$best['reason'] = 'Tidligere godkendt mønster for køretøjet matcher ordre #' . (int)$best['matched_order_id'] . '.';
|
|
}
|
|
|
|
if ($best !== null) {
|
|
return $best;
|
|
}
|
|
|
|
if ($context['age_hours'] >= self::CREATE_MIN_AGE_HOURS) {
|
|
$history = $this->findMatchingHistoricalOrder($context);
|
|
if ($history !== null || $this->hasAcceptedFeedback($context['signature_hash'], self::ACTION_CREATE)) {
|
|
return [
|
|
'action' => self::ACTION_CREATE,
|
|
'confidence' => 0.98,
|
|
'source' => self::SOURCE_HISTORY,
|
|
'matched_order_id' => null,
|
|
'created_order_id' => null,
|
|
'candidate_order' => $history,
|
|
'proposed_order' => $context['proposed_order'],
|
|
'reason' => 'Vasken er over 6 timer gammel og matcher et tidligere godkendt køretøjsmønster.',
|
|
];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function buildSuggestionForContext(array $context): ?array
|
|
{
|
|
$suggestion = $this->buildDeterministicSuggestion($context);
|
|
|
|
if (
|
|
($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE)
|
|
&& $this->isOpenAiEnabled()
|
|
) {
|
|
$suggestion = $this->buildOpenAiSuggestion($context) ?? $suggestion;
|
|
}
|
|
|
|
if ($suggestion === null || $suggestion['confidence'] < self::MIN_SUGGESTION_CONFIDENCE) {
|
|
return null;
|
|
}
|
|
|
|
if ($this->hasDeniedFeedback($context['signature_hash'], $suggestion['action'])) {
|
|
return null;
|
|
}
|
|
|
|
return $suggestion;
|
|
}
|
|
|
|
private function buildOpenAiSuggestion(array $context): ?array
|
|
{
|
|
try {
|
|
$schemaName = 'xlvask_automation';
|
|
$prompt = 'Vurder om en XL Vask-vask skal tilknyttes en eksisterende ordre, oprettes som ordre eller ikke behandles. Returner kun JSON efter skemaet.';
|
|
$temperature = 0.1;
|
|
$schema = [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'action' => ['type' => 'string', 'enum' => [self::ACTION_ATTACH, self::ACTION_CREATE, self::ACTION_NONE]],
|
|
'confidence' => ['type' => 'number'],
|
|
'reason_da' => ['type' => 'string'],
|
|
'candidate_order_id' => ['type' => ['integer', 'null']],
|
|
'proposed_order_items' => [
|
|
'type' => 'array',
|
|
'items' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'product_id' => ['type' => 'integer'],
|
|
'quantity' => ['type' => 'integer'],
|
|
'price' => ['type' => 'integer'],
|
|
],
|
|
'required' => ['product_id', 'quantity', 'price'],
|
|
'additionalProperties' => false,
|
|
],
|
|
],
|
|
'risk_flags' => ['type' => 'array', 'items' => ['type' => 'string']],
|
|
'evidence' => ['type' => 'array', 'items' => ['type' => 'string']],
|
|
'contradictions' => ['type' => 'array', 'items' => ['type' => 'string']],
|
|
'plan_steps' => [
|
|
'type' => 'array',
|
|
'items' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'step' => ['type' => 'string'],
|
|
'reason' => ['type' => 'string'],
|
|
],
|
|
'required' => ['step', 'reason'],
|
|
'additionalProperties' => false,
|
|
],
|
|
],
|
|
],
|
|
'required' => [
|
|
'action', 'confidence', 'reason_da', 'candidate_order_id', 'proposed_order_items',
|
|
'risk_flags', 'evidence', 'contradictions', 'plan_steps',
|
|
],
|
|
'additionalProperties' => false,
|
|
];
|
|
|
|
$creationAllowed = $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS;
|
|
$payload = [
|
|
'usage_log' => [
|
|
'opaque_context_id' => hash('sha256', (string)$context['signature_hash']),
|
|
'lane' => $context['signature']['lane'],
|
|
'created_at' => $context['proposed_order']['created_at'] ?? null,
|
|
'total_net_amount' => $context['total'],
|
|
'items' => $this->compactItems($context['items']),
|
|
'creation_allowed' => $creationAllowed,
|
|
'age_bucket' => $creationAllowed ? 'older_than_6_hours' : 'newer_than_6_hours',
|
|
],
|
|
'candidate_orders' => array_map(fn(array $candidate): array => [
|
|
'id' => (int)$candidate['id'],
|
|
'created_at' => $candidate['created_at'] ?? null,
|
|
'total_net_amount' => (int)($candidate['total_net_amount'] ?? 0),
|
|
'items' => $this->compactItems($candidate['order_items'] ?? []),
|
|
], $context['candidate_orders']),
|
|
];
|
|
|
|
$cacheKey = self::openAiCacheKeyForAutomation($schemaName, $prompt, $payload, $schema, $temperature);
|
|
$result = $this->loadOpenAiCacheResult($cacheKey);
|
|
if ($result === null) {
|
|
if ($this->readOnlyEvaluation) {
|
|
return null;
|
|
}
|
|
$openai = new openai();
|
|
$result = $openai->jsonTask($schemaName, $prompt, $payload, $schema, $temperature, self::PLANNER_MODEL);
|
|
$result = $this->sanitizeOpenAiResult($result, $context);
|
|
if (!$this->readOnlyEvaluation) {
|
|
$this->persistOpenAiCacheResult($cacheKey, $schemaName, $payload, $schema, $prompt, $temperature, $result);
|
|
}
|
|
}
|
|
$result = $this->sanitizeOpenAiResult($result, $context);
|
|
|
|
$action = (string)($result['action'] ?? self::ACTION_NONE);
|
|
$confidence = (float)($result['confidence'] ?? 0);
|
|
if (!in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) || $confidence < self::MIN_SUGGESTION_CONFIDENCE) {
|
|
return null;
|
|
}
|
|
|
|
if ($action === self::ACTION_CREATE && $context['age_hours'] < self::CREATE_MIN_AGE_HOURS) {
|
|
return null;
|
|
}
|
|
|
|
$candidate = null;
|
|
$candidateOrderId = (int)($result['candidate_order_id'] ?? 0);
|
|
if ($action === self::ACTION_ATTACH) {
|
|
foreach ($context['candidate_orders'] as $candidateOrder) {
|
|
if ((int)$candidateOrder['id'] === $candidateOrderId) {
|
|
$candidate = $candidateOrder;
|
|
break;
|
|
}
|
|
}
|
|
if ($candidate === null) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'action' => $action,
|
|
'confidence' => min(1.0, max(0.0, $confidence)),
|
|
'source' => self::SOURCE_OPENAI,
|
|
'matched_order_id' => $candidateOrderId > 0 ? $candidateOrderId : null,
|
|
'created_order_id' => null,
|
|
'candidate_order' => $candidate,
|
|
'proposed_order' => $context['proposed_order'],
|
|
'reason' => (string)($result['reason_da'] ?? 'OpenAI foreslår handlingen ud fra tilgængelige ordredata.'),
|
|
'risk_flags' => array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))),
|
|
'contradictions' => array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))),
|
|
'plan_steps' => array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')),
|
|
];
|
|
} catch (Exception) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function sanitizeOpenAiResult(array $result, array $context): array
|
|
{
|
|
$redactions = array_values(array_filter([
|
|
(string)($context['wash_id'] ?? ''),
|
|
(string)($context['signature']['registration'] ?? ''),
|
|
(string)($context['signature']['customer_number'] ?? ''),
|
|
], static fn(string $value): bool => strlen($value) >= 3));
|
|
$reason = mb_substr((string)($result['reason_da'] ?? ''), 0, 1000);
|
|
foreach ($redactions as $redaction) {
|
|
$reason = str_ireplace($redaction, '[redacted]', $reason);
|
|
}
|
|
|
|
return [
|
|
'action' => (string)($result['action'] ?? self::ACTION_NONE),
|
|
'confidence' => min(1.0, max(0.0, (float)($result['confidence'] ?? 0))),
|
|
'reason_da' => $reason,
|
|
'candidate_order_id' => isset($result['candidate_order_id']) ? (int)$result['candidate_order_id'] : null,
|
|
'proposed_order_items' => array_slice(array_values(array_filter((array)($result['proposed_order_items'] ?? []), 'is_array')), 0, 50),
|
|
'risk_flags' => array_slice(array_values(array_filter(array_map('strval', (array)($result['risk_flags'] ?? [])))), 0, 20),
|
|
'evidence' => array_slice(array_values(array_filter(array_map('strval', (array)($result['evidence'] ?? [])))), 0, 20),
|
|
'contradictions' => array_slice(array_values(array_filter(array_map('strval', (array)($result['contradictions'] ?? [])))), 0, 20),
|
|
'plan_steps' => array_slice(array_values(array_filter((array)($result['plan_steps'] ?? []), 'is_array')), 0, 20),
|
|
];
|
|
}
|
|
|
|
private function shouldAutoExecute(array $suggestion, array $context): bool
|
|
{
|
|
$confidence = (float)$suggestion['confidence'];
|
|
$action = (string)$suggestion['action'];
|
|
$xlvask = new xlvask();
|
|
|
|
if ((string)($suggestion['certainty'] ?? '') !== 'certain'
|
|
|| !$this->hardGuardsPassForCertainty($suggestion, $context)) {
|
|
return false;
|
|
}
|
|
|
|
if ($action === self::ACTION_ATTACH) {
|
|
return $xlvask->config->automatic_order_attachment_enabled->isTrue()
|
|
&& $confidence >= self::AUTO_ATTACH_CONFIDENCE
|
|
&& $this->isExactAttachSuggestionForContext($suggestion, $context);
|
|
}
|
|
|
|
if ($action === self::ACTION_CREATE) {
|
|
return $xlvask->config->automatic_order_creation_enabled->isTrue()
|
|
&& $confidence >= self::AUTO_CREATE_CONFIDENCE
|
|
&& $context['age_hours'] >= self::CREATE_MIN_AGE_HOURS
|
|
&& $context['candidate_orders'] === [];
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function hardGuardsPassForCertainty(array $suggestion, array $context): bool
|
|
{
|
|
if ((string)($suggestion['source'] ?? '') === self::SOURCE_OPENAI
|
|
|| !xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()
|
|
|| !self::sourceIsStableForAutomatic($context)) {
|
|
return false;
|
|
}
|
|
|
|
$action = (string)($suggestion['action'] ?? '');
|
|
if ($action === self::ACTION_ATTACH) {
|
|
return $this->isExactAttachSuggestionForContext($suggestion, $context);
|
|
}
|
|
if ($action === self::ACTION_CREATE) {
|
|
return (float)($context['age_hours'] ?? 0) >= self::CREATE_MIN_AGE_HOURS
|
|
&& (array)($context['candidate_orders'] ?? []) === []
|
|
&& (array)($context['items'] ?? []) !== [];
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private function decorateSuggestionWithEvidence(array $suggestion, array $context): array
|
|
{
|
|
$evidence = [[
|
|
'type' => 'source_revision',
|
|
'value' => (string)($context['source_revision'] ?? ''),
|
|
]];
|
|
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
|
|
$evidence[] = ['type' => 'exact_item_and_total_match', 'value' => true];
|
|
}
|
|
if ((string)$suggestion['source'] === self::SOURCE_HISTORY) {
|
|
$evidence[] = ['type' => 'audited_history_pattern', 'value' => true];
|
|
}
|
|
|
|
$contradictions = [];
|
|
if ((string)$suggestion['action'] === self::ACTION_ATTACH) {
|
|
$sameConfidenceCandidates = array_filter(
|
|
$context['candidate_orders'],
|
|
fn(array $candidate): bool => $this->scoreOrderMatch($context['items'], $candidate['order_items'])['confidence']
|
|
>= (float)$suggestion['confidence']
|
|
);
|
|
if (count($sameConfidenceCandidates) > 1) {
|
|
$contradictions[] = 'multiple_equally_credible_orders';
|
|
}
|
|
}
|
|
if ((string)$suggestion['source'] === self::SOURCE_OPENAI) {
|
|
$contradictions = array_values(array_unique([
|
|
...$contradictions,
|
|
...(array)($suggestion['contradictions'] ?? []),
|
|
]));
|
|
}
|
|
|
|
$segment = (string)$suggestion['source'] . ':' . (string)$suggestion['action'];
|
|
$calibration = $this->loadCalibration($segment);
|
|
$certainty = self::classifyCertaintyForAutomation(
|
|
$calibration ?? [],
|
|
$this->hardGuardsPassForCertainty($suggestion, $context),
|
|
$contradictions
|
|
);
|
|
$riskFlags = (array)($suggestion['risk_flags'] ?? []);
|
|
if ($calibration === null) {
|
|
$riskFlags[] = 'calibration_artifact_unavailable';
|
|
}
|
|
if ((string)$suggestion['source'] === self::SOURCE_OPENAI) {
|
|
$riskFlags[] = 'ai_advisory_only';
|
|
}
|
|
|
|
return [
|
|
...$suggestion,
|
|
'certainty' => $certainty,
|
|
'model' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? self::PLANNER_MODEL : null,
|
|
'model_confidence' => (string)$suggestion['source'] === self::SOURCE_OPENAI ? (float)$suggestion['confidence'] : null,
|
|
'calibrated_probability' => $calibration === null ? null : (float)$calibration['calibrated_probability'],
|
|
'evidence' => $evidence,
|
|
'contradictions' => $contradictions,
|
|
'risk_flags' => array_values(array_unique($riskFlags)),
|
|
'plan_steps' => (array)($suggestion['plan_steps'] ?? [
|
|
['step' => 'revalidate_source', 'status' => 'planned'],
|
|
['step' => (string)$suggestion['action'], 'status' => 'planned'],
|
|
['step' => 'verify_result', 'status' => 'planned'],
|
|
]),
|
|
];
|
|
}
|
|
|
|
private function loadCalibration(string $segmentKey): ?array
|
|
{
|
|
global $db;
|
|
$segmentKey = $db->escape_string($segmentKey);
|
|
$policy = $db->escape_string(self::POLICY_VERSION);
|
|
$result = $db->query(
|
|
"SELECT * FROM xlvask_automation_calibrations
|
|
WHERE policy_version = '{$policy}' AND segment_key = '{$segmentKey}' AND active = 1
|
|
ORDER BY id DESC LIMIT 1"
|
|
);
|
|
if ($result === false || $result->num_rows < 1) {
|
|
return null;
|
|
}
|
|
$row = $db->fetch_assoc($result);
|
|
$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) !== self::POLICY_VERSION
|
|
|| ($artifact['segment_key'] ?? null) !== $segmentKey
|
|
|| !hash_equals(
|
|
(string)($row['artifact_hash'] ?? ''),
|
|
hash('sha256', self::stableJsonForAutomation($artifact))
|
|
)) {
|
|
return null;
|
|
}
|
|
return [
|
|
...$artifact,
|
|
'id' => (int)$row['id'],
|
|
'active' => (bool)$row['active'],
|
|
'artifact_hash' => (string)$row['artifact_hash'],
|
|
];
|
|
}
|
|
|
|
private function isExactAttachSuggestionForContext(array $suggestion, array $context): bool
|
|
{
|
|
if ((string)($suggestion['action'] ?? '') !== self::ACTION_ATTACH) {
|
|
return false;
|
|
}
|
|
|
|
$matchedOrderId = (int)($suggestion['matched_order_id'] ?? 0);
|
|
$candidateOrder = $this->candidateOrderFromSuggestion($suggestion);
|
|
if ($matchedOrderId < 1 || !is_array($candidateOrder) || (int)($candidateOrder['id'] ?? 0) !== $matchedOrderId) {
|
|
return false;
|
|
}
|
|
|
|
$usageItems = $context['items'] ?? [];
|
|
$orderItems = $candidateOrder['order_items'] ?? [];
|
|
return is_array($usageItems)
|
|
&& is_array($orderItems)
|
|
&& self::isExactItemMatchForAutomation($usageItems, $orderItems);
|
|
}
|
|
|
|
private function candidateOrderFromSuggestion(array $suggestion): ?array
|
|
{
|
|
$candidateOrder = $suggestion['candidate_order'] ?? null;
|
|
if (is_array($candidateOrder)) {
|
|
return $candidateOrder;
|
|
}
|
|
|
|
$candidateOrderJson = $suggestion['candidate_order_json'] ?? null;
|
|
if (!is_string($candidateOrderJson) || trim($candidateOrderJson) === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($candidateOrderJson, true);
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
private function executeSuggestion(array $suggestion, array $context, ?int $actorId, bool $automatic): array
|
|
{
|
|
global $db;
|
|
$connection = $db->conn();
|
|
try {
|
|
$connection->begin_transaction();
|
|
$latest = $this->executeSuggestionWithinTransaction($suggestion, $context, $actorId, $automatic);
|
|
if ($automatic) {
|
|
$this->persistFeedback($context, (string)$suggestion['action'], 'accepted', (int)($latest['matched_order_id'] ?? $latest['created_order_id'] ?? 0), $actorId, $this->automaticFeedbackReason($suggestion, $context));
|
|
}
|
|
$connection->commit();
|
|
return $this->formatSuggestion($latest);
|
|
} catch (\Throwable $e) {
|
|
try {
|
|
$connection->rollback();
|
|
} catch (\Throwable) {
|
|
}
|
|
$this->updateSuggestionFailure((int)$suggestion['id'], $e->getMessage(), $actorId);
|
|
return [
|
|
...$this->formatSuggestion($this->loadSuggestion((int)$suggestion['id']) ?? $suggestion),
|
|
'status' => self::STATUS_FAILED,
|
|
'error' => $e->getMessage(),
|
|
];
|
|
}
|
|
}
|
|
|
|
private function executeSuggestionWithinTransaction(array $suggestion, array $context, ?int $actorId, bool $automatic): array
|
|
{
|
|
global $db;
|
|
if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) {
|
|
throw new Exception('Aktivering er blokeret, indtil unik wash_id-migrering er verificeret.');
|
|
}
|
|
|
|
$usageLogId = (int)$context['usage_log_id'];
|
|
$usageResult = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} FOR UPDATE");
|
|
$lockedUsage = $usageResult !== false && $usageResult->num_rows > 0 ? $db->fetch_assoc($usageResult) : null;
|
|
if ($lockedUsage === null) {
|
|
throw new Exception('XL Vask-vasken findes ikke længere.');
|
|
}
|
|
if ((int)($lockedUsage['expected_version'] ?? 0) !== (int)($context['expected_version'] ?? 0)
|
|
|| !hash_equals((string)($lockedUsage['source_hash'] ?? ''), (string)($context['source_hash'] ?? ''))) {
|
|
throw new Exception('XL Vask-kildedata blev ændret efter evalueringen.');
|
|
}
|
|
if (!empty($lockedUsage['ignored_at']) || (int)($lockedUsage['FinishStatus'] ?? 0) !== 1) {
|
|
throw new Exception('XL Vask-vasken er ikke længere behandlingsklar.');
|
|
}
|
|
if ($automatic && !self::sourceIsStableForAutomatic($lockedUsage)) {
|
|
throw new Exception('XL Vask-kildedata mangler to observationer eller stabilitetsvinduet.');
|
|
}
|
|
|
|
$washId = $db->escape_string((string)$context['wash_id']);
|
|
$duplicateResult = $db->query(
|
|
"SELECT id FROM orders WHERE LOWER(TRIM(wash_id)) = LOWER(TRIM('{$washId}')) FOR UPDATE"
|
|
);
|
|
if ($duplicateResult !== false && $duplicateResult->num_rows > 0) {
|
|
throw new Exception('Vasken er allerede tilknyttet en ordre.');
|
|
}
|
|
|
|
$action = (string)$suggestion['action'];
|
|
$matchedOrderId = null;
|
|
$createdOrderId = null;
|
|
if ($action === self::ACTION_ATTACH) {
|
|
$orderId = (int)$suggestion['matched_order_id'];
|
|
$allowedCandidateIds = array_map(static fn(array $candidate): int => (int)$candidate['id'], $context['candidate_orders']);
|
|
if ($orderId < 1 || !in_array($orderId, $allowedCandidateIds, true)) {
|
|
throw new Exception('Ordren er ikke længere en tilladt kandidat.');
|
|
}
|
|
$orderResult = $db->query("SELECT * FROM orders WHERE id = {$orderId} FOR UPDATE");
|
|
$orderRow = $orderResult !== false && $orderResult->num_rows > 0 ? $db->fetch_assoc($orderResult) : null;
|
|
if ($orderRow === null
|
|
|| !empty($orderRow['deleted_at'])
|
|
|| trim((string)($orderRow['wash_id'] ?? '')) !== ''
|
|
|| (int)($orderRow['invoice_collection_id'] ?? 0) > 0
|
|
|| (int)($orderRow['booking_id'] ?? 0) > 0
|
|
|| (int)$orderRow['customer_id'] !== (int)$context['proposed_order']['customer_id']
|
|
|| (int)$orderRow['department_id'] !== (int)$context['proposed_order']['department_id']) {
|
|
throw new Exception('Ordren er ændret eller økonomisk låst.');
|
|
}
|
|
$orderItems = (new orders_o())->getOrderItems($orderId);
|
|
if ($automatic && !self::isExactItemMatchForAutomation($context['items'], $orderItems)) {
|
|
throw new Exception('Ordrelinjer eller beløb matcher ikke længere præcist.');
|
|
}
|
|
if ($db->query("UPDATE orders SET wash_id = '{$washId}' WHERE id = {$orderId}") === false
|
|
|| $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('Ordretilknytningen kunne ikke gemmes entydigt.');
|
|
}
|
|
$matchedOrderId = $orderId;
|
|
} elseif ($action === self::ACTION_CREATE) {
|
|
if ($context['age_hours'] < self::CREATE_MIN_AGE_HOURS
|
|
|| empty($lockedUsage['source_stable_since'])
|
|
|| strtotime((string)$lockedUsage['source_stable_since']) > strtotime('-' . self::CREATE_MIN_AGE_HOURS . ' hours')
|
|
|| empty($lockedUsage['source_observed_at'])) {
|
|
throw new Exception('Vasken har ikke været stabil gennem observationsvinduet.');
|
|
}
|
|
if ($context['candidate_orders'] !== [] || $this->itemsTotal($context['items']) !== (int)$context['total']) {
|
|
throw new Exception('Ordreoprettelsen kan ikke afstemmes sikkert.');
|
|
}
|
|
$order = $this->createOrderFromContext($context);
|
|
$createdOrderId = (int)$order->id;
|
|
} else {
|
|
throw new Exception('Ukendt automatiseringshandling.');
|
|
}
|
|
|
|
$status = $automatic ? self::STATUS_AUTO_ACCEPTED : self::STATUS_ACCEPTED;
|
|
$this->updateSuggestionExecution(
|
|
(int)$suggestion['id'],
|
|
$status,
|
|
$actorId,
|
|
$matchedOrderId,
|
|
$createdOrderId,
|
|
$action,
|
|
(string)($suggestion['certainty'] ?? 'uncertain'),
|
|
isset($suggestion['candidate_order_json'])
|
|
? (string)$suggestion['candidate_order_json']
|
|
: json_encode($suggestion['candidate_order'] ?? null, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
|
);
|
|
$resolution = $action === self::ACTION_CREATE
|
|
? ($automatic ? 'auto_created' : 'already_linked')
|
|
: ($automatic ? 'auto_linked' : 'already_linked');
|
|
$this->syncUsageState($usageLogId, $resolution, (string)($suggestion['certainty'] ?? 'uncertain'), 'none', 'Ordrehandlingen blev verificeret og udført.');
|
|
if ($db->query("UPDATE xlvask_usage_logs SET expected_version = expected_version + 1 WHERE id = {$usageLogId}") === false
|
|
|| $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('XL Vask-versionen kunne ikke opdateres.');
|
|
}
|
|
$this->recordAudit($context, 'action_executed', $action, $suggestion, $actorId, [
|
|
'matched_order_id' => $matchedOrderId,
|
|
'created_order_id' => $createdOrderId,
|
|
'status' => $status,
|
|
]);
|
|
|
|
return $this->loadSuggestion((int)$suggestion['id']) ?? $suggestion;
|
|
}
|
|
|
|
private function automaticFeedbackReason(array $suggestion, array $context): string
|
|
{
|
|
if ($this->isExactAttachSuggestionForContext($suggestion, $context)) {
|
|
return 'Automatisk accepteret: Prisoverensstemmelse.';
|
|
}
|
|
|
|
return 'Automatisk accepteret.';
|
|
}
|
|
|
|
private function createOrderFromContext(array $context): orders_o
|
|
{
|
|
global $db;
|
|
$orderData = $context['proposed_order'];
|
|
$items = $context['items'];
|
|
$connection = $db->conn();
|
|
$orderStatement = $connection->prepare(
|
|
'INSERT INTO orders
|
|
(customer_id, cashier_id, reference, notes, department_id, reg_1, reg_2, reg_3, wash_id, lane, created_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
if ($orderStatement === false) {
|
|
throw new Exception('Kunne ikke forberede sikker ordreoprettelse.');
|
|
}
|
|
$customerId = (int)$orderData['customer_id'];
|
|
$cashierId = self::AUTOMATION_CASHIER_ID;
|
|
$reference = (string)($orderData['reference'] ?? '');
|
|
$notes = (string)($orderData['notes'] ?? '');
|
|
$departmentId = (int)$orderData['department_id'];
|
|
$reg1 = (string)($orderData['reg_1'] ?? '');
|
|
$reg2 = (string)($orderData['reg_2'] ?? '');
|
|
$reg3 = (string)($orderData['reg_3'] ?? '');
|
|
$washId = (string)$context['wash_id'];
|
|
$lane = (int)($orderData['lane'] ?? 0);
|
|
$createdAt = (string)($orderData['created_at'] ?? date('Y-m-d H:i:s'));
|
|
$orderStatement->bind_param(
|
|
'iississssis',
|
|
$customerId,
|
|
$cashierId,
|
|
$reference,
|
|
$notes,
|
|
$departmentId,
|
|
$reg1,
|
|
$reg2,
|
|
$reg3,
|
|
$washId,
|
|
$lane,
|
|
$createdAt
|
|
);
|
|
if (!$orderStatement->execute() || $orderStatement->affected_rows !== 1) {
|
|
throw new Exception('Ordren kunne ikke oprettes atomisk.');
|
|
}
|
|
$orderId = (int)$connection->insert_id;
|
|
$orderStatement->close();
|
|
|
|
$itemStatement = $connection->prepare(
|
|
'INSERT INTO order_items
|
|
(order_id, product_id, reference, notes, cashier_id, price, quantity, related_item_id)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
|
);
|
|
if ($itemStatement === false) {
|
|
throw new Exception('Kunne ikke forberede sikker ordrelinjeoprettelse.');
|
|
}
|
|
$firstItemId = null;
|
|
foreach ($items as $item) {
|
|
$productId = (int)$item['product_id'];
|
|
$itemReference = (string)($item['reference'] ?? '');
|
|
$itemNotes = (string)($item['notes'] ?? '');
|
|
$price = (int)$item['price'];
|
|
$quantity = (int)$item['quantity'];
|
|
$relatedItemId = $firstItemId;
|
|
$itemStatement->bind_param(
|
|
'iissiiii',
|
|
$orderId,
|
|
$productId,
|
|
$itemReference,
|
|
$itemNotes,
|
|
$cashierId,
|
|
$price,
|
|
$quantity,
|
|
$relatedItemId
|
|
);
|
|
if (!$itemStatement->execute() || $itemStatement->affected_rows !== 1) {
|
|
throw new Exception('En ordrelinje kunne ikke oprettes atomisk.');
|
|
}
|
|
if ($firstItemId === null) {
|
|
$firstItemId = (int)$connection->insert_id;
|
|
}
|
|
}
|
|
$itemStatement->close();
|
|
$verificationResult = $db->query(
|
|
"SELECT COUNT(*) item_count, COALESCE(SUM(price * quantity), 0) item_total
|
|
FROM order_items WHERE order_id = {$orderId} AND deleted_at IS NULL"
|
|
);
|
|
if ($verificationResult === false) {
|
|
throw new Exception('Ordren kunne ikke efterkontrolleres.');
|
|
}
|
|
$verification = $db->fetch_assoc($verificationResult);
|
|
if ((int)($verification['item_count'] ?? 0) !== count($items)
|
|
|| (int)($verification['item_total'] ?? 0) !== $this->itemsTotal($items)) {
|
|
throw new Exception('Den oprettede ordre kunne ikke afstemmes.');
|
|
}
|
|
|
|
$order = (new orders_o())->select($orderId);
|
|
$order->assignToInvoiceCollection(null, false);
|
|
$order->objectChanged();
|
|
return $order;
|
|
}
|
|
|
|
private function buildContext(int $usageLogId, xlvask_usage_log $log, array $row = []): array
|
|
{
|
|
$simulated = (new orders_o())->simulateOrderFromXLVask($log, true);
|
|
$proposedOrder = $simulated['order'] ?? [];
|
|
$items = $simulated['order_items'] ?? [];
|
|
$signature = $this->buildSignature($log, $proposedOrder, $items);
|
|
$signatureJson = json_encode($signature, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
if ($signatureJson === false) {
|
|
throw new Exception('Kunne ikke opbygge signatur for XL Vask-vasken.');
|
|
}
|
|
|
|
return [
|
|
'usage_log_id' => $usageLogId,
|
|
'wash_id' => (string)$log->WashId,
|
|
'log' => $log,
|
|
'proposed_order' => $proposedOrder,
|
|
'items' => $items,
|
|
'total' => $this->itemsTotal($items),
|
|
'signature' => $signature,
|
|
'signature_json' => $signatureJson,
|
|
'signature_hash' => hash('sha256', $signatureJson),
|
|
'source_hash' => (string)($row['source_hash'] ?? ''),
|
|
'source_revision' => (string)($row['source_revision'] ?? $log->Updated ?? ''),
|
|
'source_observed_at' => $row['source_observed_at'] ?? null,
|
|
'source_stable_since' => $row['source_stable_since'] ?? null,
|
|
'source_observation_count' => (int)($row['source_observation_count'] ?? 0),
|
|
'expected_version' => (int)($row['expected_version'] ?? 1),
|
|
'age_hours' => max(0.0, (time() - strtotime((string)$log->StartTime)) / 3600),
|
|
'candidate_orders' => $this->findSameDayCandidateOrders($log, $proposedOrder),
|
|
];
|
|
}
|
|
|
|
private function contextGuardReason(array $context): ?string
|
|
{
|
|
if ((int)($context['proposed_order']['customer_id'] ?? 0) < 1) {
|
|
return 'Vasken mangler en gyldig kundemapping.';
|
|
}
|
|
|
|
if ((int)($context['proposed_order']['department_id'] ?? 0) < 1) {
|
|
return 'Vasken mangler en gyldig afdelingsmapping.';
|
|
}
|
|
|
|
if (!is_array($context['items'] ?? null) || count($context['items']) < 1) {
|
|
return 'Vasken mangler gyldige produkter.';
|
|
}
|
|
|
|
foreach ($context['items'] as $item) {
|
|
if (!is_array($item) || (int)($item['product_id'] ?? 0) < 1) {
|
|
return 'Vasken mangler gyldige produkter.';
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function buildSignature(xlvask_usage_log $log, array $proposedOrder, array $items): array
|
|
{
|
|
return [
|
|
'registration' => $this->normalizeRegistration((string)$log->RegistrationNumber),
|
|
'customer_number' => (int)$log->CustomerId,
|
|
'department_id' => (int)($proposedOrder['department_id'] ?? 0),
|
|
'lane' => (int)($proposedOrder['lane'] ?? 0),
|
|
'primary_product_id' => (int)($items[0]['product_id'] ?? 0),
|
|
'items' => $this->itemSignatureParts($items),
|
|
'total_net_amount' => $this->itemsTotal($items),
|
|
];
|
|
}
|
|
|
|
private function scoreOrderMatch(array $usageItems, array $orderItems): array
|
|
{
|
|
return self::scoreItemMatchForAutomation($usageItems, $orderItems);
|
|
}
|
|
|
|
private function findSameDayCandidateOrders(xlvask_usage_log $log, array $proposedOrder): array
|
|
{
|
|
global $db;
|
|
|
|
$registration = $db->escape_string($this->normalizeRegistration((string)$log->RegistrationNumber));
|
|
$rawRegistration = $db->escape_string(trim((string)$log->RegistrationNumber));
|
|
$customerNumber = (int)$log->CustomerId;
|
|
$departmentId = (int)($proposedOrder['department_id'] ?? 0);
|
|
$date = date('Y-m-d', strtotime((string)$log->StartTime));
|
|
$from = $db->escape_string($date . ' 00:00:00');
|
|
$to = $db->escape_string($date . ' 23:59:59');
|
|
|
|
if ($registration === '' || $customerNumber < 1 || $departmentId < 1) {
|
|
return [];
|
|
}
|
|
|
|
$sql = "SELECT *
|
|
FROM orders
|
|
WHERE deleted_at IS NULL
|
|
AND customer_id = {$customerNumber}
|
|
AND department_id = {$departmentId}
|
|
AND cashier_id <> " . self::AUTOMATION_CASHIER_ID . "
|
|
AND created_at BETWEEN '{$from}' AND '{$to}'
|
|
AND (wash_id IS NULL OR wash_id = '')
|
|
AND COALESCE(invoice_collection_id, 0) = 0
|
|
AND COALESCE(booking_id, 0) = 0
|
|
AND (
|
|
REPLACE(UPPER(reg_1), ' ', '') IN ('{$registration}', '{$rawRegistration}')
|
|
OR REPLACE(UPPER(reg_2), ' ', '') IN ('{$registration}', '{$rawRegistration}')
|
|
OR REPLACE(UPPER(reg_3), ' ', '') IN ('{$registration}', '{$rawRegistration}')
|
|
)
|
|
ORDER BY ABS(TIMESTAMPDIFF(SECOND, created_at, '" . $db->escape_string(date('Y-m-d H:i:s', strtotime((string)$log->StartTime))) . "')) ASC
|
|
LIMIT 20";
|
|
|
|
$rows = $db->fetch_all($db->query($sql));
|
|
return array_map(function (array $row): array {
|
|
$orderItems = (new orders_o())->getOrderItems((int)$row['id']);
|
|
return [
|
|
...$row,
|
|
'id' => (int)$row['id'],
|
|
'total_net_amount' => (int)($row['total_net_amount'] ?? $this->itemsTotal($orderItems)),
|
|
'order_items' => $orderItems,
|
|
];
|
|
}, $rows);
|
|
}
|
|
|
|
private function candidateFromContext(array $context, int $orderId): ?array
|
|
{
|
|
foreach ((array)($context['candidate_orders'] ?? []) as $candidate) {
|
|
if (is_array($candidate) && (int)($candidate['id'] ?? 0) === $orderId) {
|
|
return $candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function findMatchingHistoricalOrder(array $context): ?array
|
|
{
|
|
global $db;
|
|
|
|
$signature = $context['signature'];
|
|
$registration = $db->escape_string((string)$signature['registration']);
|
|
$customerNumber = (int)$signature['customer_number'];
|
|
$departmentId = (int)$signature['department_id'];
|
|
$createdBefore = $db->escape_string((string)($context['proposed_order']['created_at'] ?? date('Y-m-d H:i:s')));
|
|
|
|
if ($registration === '' || $customerNumber < 1 || $departmentId < 1) {
|
|
return null;
|
|
}
|
|
|
|
$sql = "SELECT *
|
|
FROM orders
|
|
WHERE deleted_at IS NULL
|
|
AND customer_id = {$customerNumber}
|
|
AND department_id = {$departmentId}
|
|
AND created_at < '{$createdBefore}'
|
|
AND (
|
|
REPLACE(UPPER(reg_1), ' ', '') = '{$registration}'
|
|
OR REPLACE(UPPER(reg_2), ' ', '') = '{$registration}'
|
|
OR REPLACE(UPPER(reg_3), ' ', '') = '{$registration}'
|
|
)
|
|
ORDER BY created_at DESC
|
|
LIMIT 10";
|
|
|
|
foreach ($db->fetch_all($db->query($sql)) as $row) {
|
|
$orderItems = (new orders_o())->getOrderItems((int)$row['id']);
|
|
if ($this->itemSignatureParts($orderItems) === $signature['items']) {
|
|
return [
|
|
...$row,
|
|
'id' => (int)$row['id'],
|
|
'order_items' => $orderItems,
|
|
];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function existingLinkedOrder(xlvask_usage_log $log): ?orders_o
|
|
{
|
|
return (new orders_o())->selectByWashId($log->WashId);
|
|
}
|
|
|
|
private function guardReason(xlvask_usage_log $log, bool $checkExistingLink = true): ?string
|
|
{
|
|
if (!empty($log->ignored_at)) {
|
|
return 'Vasken er ignoreret.';
|
|
}
|
|
|
|
if (!$log->isCompleted()) {
|
|
return 'Vasken er ikke afsluttet.';
|
|
}
|
|
|
|
if (!$log->hasBillableCustomer()) {
|
|
return 'Vasken mangler en fakturerbar kunde.';
|
|
}
|
|
|
|
if ($checkExistingLink && $this->existingLinkedOrder($log) !== null) {
|
|
return 'Vasken er allerede tilknyttet en ordre.';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function persistSuggestion(array $context, array $suggestion, ?int $actorId): int
|
|
{
|
|
global $db;
|
|
|
|
$existing = $this->latestActionableSuggestion((int)$context['usage_log_id']);
|
|
if ($existing !== null) {
|
|
$this->updateSuggestionProposal((int)$existing['id'], $context, $suggestion, $actorId);
|
|
return (int)$existing['id'];
|
|
}
|
|
|
|
$fields = [
|
|
'usage_log_id' => (int)$context['usage_log_id'],
|
|
'run_id' => $this->runId,
|
|
'wash_id' => (string)$context['wash_id'],
|
|
'signature_hash' => (string)$context['signature_hash'],
|
|
'signature_json' => (string)$context['signature_json'],
|
|
'action' => (string)$suggestion['action'],
|
|
'status' => self::STATUS_SUGGESTED,
|
|
'confidence' => (float)$suggestion['confidence'],
|
|
'source' => (string)$suggestion['source'],
|
|
'policy_version' => self::POLICY_VERSION,
|
|
'model' => $suggestion['model'] ?? null,
|
|
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
|
'calibrated_probability' => $suggestion['calibrated_probability'] ?? null,
|
|
'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'),
|
|
'evidence_json' => json_encode($suggestion['evidence'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'contradictions_json' => json_encode($suggestion['contradictions'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'risk_flags_json' => json_encode($suggestion['risk_flags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'plan_steps_json' => json_encode($suggestion['plan_steps'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'expected_version' => (int)($context['expected_version'] ?? 1),
|
|
'input_hash' => (string)($context['source_hash'] ?? $context['signature_hash']),
|
|
'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'],
|
|
'created_order_id' => null,
|
|
'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'reason' => (string)$suggestion['reason'],
|
|
'created_by' => $actorId,
|
|
];
|
|
|
|
$columns = [];
|
|
$values = [];
|
|
foreach ($fields as $column => $value) {
|
|
$columns[] = "`{$column}`";
|
|
if ($value === null) {
|
|
$values[] = 'NULL';
|
|
} elseif (is_int($value) || is_float($value)) {
|
|
$values[] = (string)$value;
|
|
} else {
|
|
$values[] = "'" . $db->escape_string((string)$value) . "'";
|
|
}
|
|
}
|
|
|
|
$db->query('INSERT INTO xlvask_automation_suggestions (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $values) . ')');
|
|
$suggestionId = (int)$db->insert_id();
|
|
$this->syncUsageState(
|
|
(int)$context['usage_log_id'],
|
|
'needs_review',
|
|
(string)($suggestion['certainty'] ?? 'uncertain'),
|
|
(string)$suggestion['action'],
|
|
(string)$suggestion['reason']
|
|
);
|
|
return $suggestionId;
|
|
}
|
|
|
|
private function updateSuggestionProposal(int $suggestionId, array $context, array $suggestion, ?int $actorId): void
|
|
{
|
|
global $db;
|
|
|
|
$fields = [
|
|
'run_id' => $this->runId,
|
|
'signature_hash' => (string)$context['signature_hash'],
|
|
'signature_json' => (string)$context['signature_json'],
|
|
'action' => (string)$suggestion['action'],
|
|
'confidence' => (float)$suggestion['confidence'],
|
|
'source' => (string)$suggestion['source'],
|
|
'policy_version' => self::POLICY_VERSION,
|
|
'model' => $suggestion['model'] ?? null,
|
|
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
|
'calibrated_probability' => $suggestion['calibrated_probability'] ?? null,
|
|
'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'),
|
|
'evidence_json' => json_encode($suggestion['evidence'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'contradictions_json' => json_encode($suggestion['contradictions'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'risk_flags_json' => json_encode($suggestion['risk_flags'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'plan_steps_json' => json_encode($suggestion['plan_steps'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'expected_version' => (int)($context['expected_version'] ?? 1),
|
|
'input_hash' => (string)($context['source_hash'] ?? $context['signature_hash']),
|
|
'matched_order_id' => $suggestion['matched_order_id'] === null ? null : (int)$suggestion['matched_order_id'],
|
|
'created_order_id' => null,
|
|
'proposed_order_json' => json_encode($suggestion['proposed_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'candidate_order_json' => json_encode($suggestion['candidate_order'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
|
|
'reason' => (string)$suggestion['reason'],
|
|
];
|
|
|
|
if ($actorId !== null) {
|
|
$fields['created_by'] = $actorId;
|
|
}
|
|
|
|
$assignments = [];
|
|
foreach ($fields as $column => $value) {
|
|
if ($value === null) {
|
|
$sqlValue = 'NULL';
|
|
} elseif (is_int($value) || is_float($value)) {
|
|
$sqlValue = (string)$value;
|
|
} else {
|
|
$sqlValue = "'" . $db->escape_string((string)$value) . "'";
|
|
}
|
|
$assignments[] = "`{$column}` = {$sqlValue}";
|
|
}
|
|
|
|
$db->query(
|
|
'UPDATE xlvask_automation_suggestions SET ' . implode(', ', $assignments) .
|
|
" WHERE id = {$suggestionId} AND status = '" . self::STATUS_SUGGESTED . "'"
|
|
);
|
|
$this->syncUsageState(
|
|
(int)$context['usage_log_id'],
|
|
'needs_review',
|
|
(string)($suggestion['certainty'] ?? 'uncertain'),
|
|
(string)$suggestion['action'],
|
|
(string)$suggestion['reason']
|
|
);
|
|
}
|
|
|
|
private function persistFeedback(array $context, string $action, string $decision, int $orderId = 0, ?int $actorId = null, ?string $reason = null): void
|
|
{
|
|
global $db;
|
|
|
|
$values = [
|
|
'usage_log_id' => (int)$context['usage_log_id'],
|
|
'wash_id' => (string)$context['wash_id'],
|
|
'signature_hash' => (string)$context['signature_hash'],
|
|
'signature_json' => (string)$context['signature_json'],
|
|
'action' => $action,
|
|
'decision' => $decision,
|
|
'order_id' => $orderId > 0 ? $orderId : null,
|
|
'reason' => $reason,
|
|
'created_by' => $actorId,
|
|
];
|
|
|
|
$columns = [];
|
|
$sqlValues = [];
|
|
foreach ($values as $column => $value) {
|
|
$columns[] = "`{$column}`";
|
|
if ($value === null) {
|
|
$sqlValues[] = 'NULL';
|
|
} elseif (is_int($value)) {
|
|
$sqlValues[] = (string)$value;
|
|
} else {
|
|
$sqlValues[] = "'" . $db->escape_string((string)$value) . "'";
|
|
}
|
|
}
|
|
|
|
$db->query('INSERT INTO xlvask_automation_feedback (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $sqlValues) . ')');
|
|
}
|
|
|
|
private function loadOpenAiCacheResult(string $cacheKey): ?array
|
|
{
|
|
global $db;
|
|
|
|
$cacheKey = $db->escape_string($cacheKey);
|
|
$result = $db->query(
|
|
"SELECT result_json FROM xlvask_automation_openai_cache
|
|
WHERE cache_key = '{$cacheKey}'
|
|
LIMIT 1"
|
|
);
|
|
if ($result === false || $result->num_rows < 1) {
|
|
return null;
|
|
}
|
|
|
|
$row = $db->fetch_assoc($result);
|
|
$decoded = json_decode((string)($row['result_json'] ?? ''), true);
|
|
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
|
|
return null;
|
|
}
|
|
|
|
if (!$this->readOnlyEvaluation) {
|
|
$db->query(
|
|
"UPDATE xlvask_automation_openai_cache
|
|
SET hits = hits + 1, last_hit_at = NOW()
|
|
WHERE cache_key = '{$cacheKey}'"
|
|
);
|
|
}
|
|
|
|
return $decoded;
|
|
}
|
|
|
|
private function persistOpenAiCacheResult(
|
|
string $cacheKey,
|
|
string $schemaName,
|
|
array $payload,
|
|
array $schema,
|
|
string $prompt,
|
|
float $temperature,
|
|
array $result
|
|
): void {
|
|
global $db;
|
|
|
|
$input = [
|
|
'version' => self::OPENAI_CACHE_VERSION,
|
|
'schema_name' => $schemaName,
|
|
'prompt' => $prompt,
|
|
'payload' => $payload,
|
|
'schema' => $schema,
|
|
'temperature' => round($temperature, 4),
|
|
];
|
|
|
|
// Do not retain the personal operational prompt payload in the cache.
|
|
$inputJson = self::stableJsonForAutomation([
|
|
'version' => self::OPENAI_CACHE_VERSION,
|
|
'schema_name' => $schemaName,
|
|
'input_hash' => hash('sha256', self::stableJsonForAutomation($input)),
|
|
]);
|
|
$resultJson = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION);
|
|
if ($resultJson === false) {
|
|
return;
|
|
}
|
|
|
|
$cacheKey = $db->escape_string($cacheKey);
|
|
$schemaName = $db->escape_string($schemaName);
|
|
$inputJson = $db->escape_string($inputJson);
|
|
$resultJson = $db->escape_string($resultJson);
|
|
|
|
$db->query(
|
|
"INSERT INTO xlvask_automation_openai_cache
|
|
(cache_key, schema_name, input_json, result_json)
|
|
VALUES
|
|
('{$cacheKey}', '{$schemaName}', '{$inputJson}', '{$resultJson}')
|
|
ON DUPLICATE KEY UPDATE
|
|
result_json = VALUES(result_json),
|
|
input_json = VALUES(input_json),
|
|
updated_at = NOW()"
|
|
);
|
|
$db->query("DELETE FROM xlvask_automation_openai_cache WHERE updated_at < DATE_SUB(NOW(), INTERVAL 30 DAY)");
|
|
}
|
|
|
|
private function hasAcceptedFeedback(string $signatureHash, string $action): bool
|
|
{
|
|
return $this->hasFeedbackDecision($signatureHash, $action, 'accepted');
|
|
}
|
|
|
|
private function hasDeniedFeedback(string $signatureHash, string $action): bool
|
|
{
|
|
return $this->hasFeedbackDecision($signatureHash, $action, 'denied');
|
|
}
|
|
|
|
private function hasFeedbackDecision(string $signatureHash, string $action, string $decision): bool
|
|
{
|
|
global $db;
|
|
$signatureHash = $db->escape_string($signatureHash);
|
|
$action = $db->escape_string($action);
|
|
$decision = $db->escape_string($decision);
|
|
$result = $db->query(
|
|
"SELECT id FROM xlvask_automation_feedback
|
|
WHERE signature_hash = '{$signatureHash}' AND action = '{$action}' AND decision = '{$decision}'
|
|
ORDER BY id DESC LIMIT 1"
|
|
);
|
|
return $result !== false && $result->num_rows > 0;
|
|
}
|
|
|
|
private function latestTerminalSuggestion(int $usageLogId): ?array
|
|
{
|
|
return $this->latestSuggestionWhere($usageLogId, [
|
|
self::STATUS_SUGGESTED,
|
|
self::STATUS_AUTO_ACCEPTED,
|
|
self::STATUS_ACCEPTED,
|
|
self::STATUS_DENIED,
|
|
]);
|
|
}
|
|
|
|
private function latestActionableSuggestion(int $usageLogId): ?array
|
|
{
|
|
return $this->latestSuggestionWhere($usageLogId, [self::STATUS_SUGGESTED]);
|
|
}
|
|
|
|
private function latestSuggestionWhere(int $usageLogId, array $statuses): ?array
|
|
{
|
|
global $db;
|
|
$statusSql = implode(',', array_map(fn(string $status): string => "'" . $db->escape_string($status) . "'", $statuses));
|
|
$result = $db->query(
|
|
"SELECT * FROM xlvask_automation_suggestions
|
|
WHERE usage_log_id = {$usageLogId} AND status IN ({$statusSql})
|
|
ORDER BY id DESC LIMIT 1"
|
|
);
|
|
if ($result === false || $result->num_rows < 1) {
|
|
return null;
|
|
}
|
|
|
|
return $db->fetch_assoc($result);
|
|
}
|
|
|
|
private function loadSuggestion(int $suggestionId): ?array
|
|
{
|
|
global $db;
|
|
$result = $db->query("SELECT * FROM xlvask_automation_suggestions WHERE id = {$suggestionId} LIMIT 1");
|
|
if ($result === false || $result->num_rows < 1) {
|
|
return null;
|
|
}
|
|
return $db->fetch_assoc($result);
|
|
}
|
|
|
|
private function updateSuggestionStatus(int $suggestionId, string $status, ?int $actorId): void
|
|
{
|
|
global $db;
|
|
$status = $db->escape_string($status);
|
|
$actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId;
|
|
if ($db->query(
|
|
"UPDATE xlvask_automation_suggestions
|
|
SET status = '{$status}', decided_by = {$actorSql}, decided_at = NOW()
|
|
WHERE id = {$suggestionId}"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask suggestion execution state could not be updated atomically.');
|
|
}
|
|
}
|
|
|
|
private function updateSuggestionExecution(
|
|
int $suggestionId,
|
|
string $status,
|
|
?int $actorId,
|
|
?int $matchedOrderId,
|
|
?int $createdOrderId,
|
|
string $action,
|
|
string $certainty,
|
|
string|false|null $candidateOrderJson
|
|
): void
|
|
{
|
|
global $db;
|
|
$status = $db->escape_string($status);
|
|
$action = $db->escape_string($action);
|
|
$certainty = $db->escape_string($certainty);
|
|
$actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId;
|
|
$matchedSql = $matchedOrderId === null ? 'matched_order_id' : (string)(int)$matchedOrderId;
|
|
$createdSql = $createdOrderId === null ? 'created_order_id' : (string)(int)$createdOrderId;
|
|
$candidateSql = $candidateOrderJson === false || $candidateOrderJson === null
|
|
? 'candidate_order_json'
|
|
: "'" . $db->escape_string($candidateOrderJson) . "'";
|
|
if ($db->query(
|
|
"UPDATE xlvask_automation_suggestions
|
|
SET status = '{$status}',
|
|
decided_by = {$actorSql},
|
|
decided_at = NOW(),
|
|
executed_at = NOW(),
|
|
action = '{$action}',
|
|
certainty = '{$certainty}',
|
|
matched_order_id = {$matchedSql},
|
|
created_order_id = {$createdSql},
|
|
candidate_order_json = {$candidateSql}
|
|
WHERE id = {$suggestionId}"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask suggestion execution state could not be updated atomically.');
|
|
}
|
|
}
|
|
|
|
private function updateSuggestionFailure(int $suggestionId, string $message, ?int $actorId): void
|
|
{
|
|
global $db;
|
|
$actorSql = $actorId === null ? 'NULL' : (string)(int)$actorId;
|
|
$message = $db->escape_string($message);
|
|
$db->query(
|
|
"UPDATE xlvask_automation_suggestions
|
|
SET status = '" . self::STATUS_FAILED . "',
|
|
reason = CONCAT(COALESCE(reason, ''), ' Fejl: {$message}'),
|
|
decided_by = {$actorSql},
|
|
decided_at = NOW()
|
|
WHERE id = {$suggestionId}"
|
|
);
|
|
$suggestion = $this->loadSuggestion($suggestionId);
|
|
if ($suggestion !== null) {
|
|
$this->syncUsageState((int)$suggestion['usage_log_id'], 'failed', 'none', 'none', $message);
|
|
}
|
|
}
|
|
|
|
private function syncUsageState(
|
|
int $usageLogId,
|
|
string $resolutionState,
|
|
string $certainty,
|
|
string $plannedAction,
|
|
string $reason
|
|
): void {
|
|
global $db;
|
|
$resolutionState = $db->escape_string($resolutionState);
|
|
$certainty = $db->escape_string($certainty);
|
|
$plannedAction = $db->escape_string($plannedAction);
|
|
$reason = $db->escape_string($reason);
|
|
$runSql = $this->runId === null ? 'last_run_id' : (string)$this->runId;
|
|
if ($db->query(
|
|
"UPDATE xlvask_usage_logs SET resolution_state = '{$resolutionState}', certainty = '{$certainty}',
|
|
planned_action = '{$plannedAction}', state_reason = '{$reason}', last_run_id = {$runSql},
|
|
last_evaluated_at = NOW() WHERE id = {$usageLogId}"
|
|
) === false) {
|
|
throw new Exception('The XL Vask usage-log state could not be updated atomically.');
|
|
}
|
|
}
|
|
|
|
private function recordAudit(
|
|
array $context,
|
|
string $eventType,
|
|
?string $action,
|
|
array $before,
|
|
?int $actorId,
|
|
array $after = []
|
|
): void {
|
|
global $db;
|
|
$runSql = $this->runId === null ? 'NULL' : (string)$this->runId;
|
|
$actorSql = $actorId === null ? 'NULL' : (string)$actorId;
|
|
$safeBefore = $this->minimalAuditSuggestion($before);
|
|
$safeAfter = $this->minimalAuditSuggestion($after);
|
|
$beforeJson = $db->escape_string(json_encode($safeBefore, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
|
$afterJson = $db->escape_string(json_encode($safeAfter, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}');
|
|
$evidenceJson = $db->escape_string(json_encode($safeBefore['evidence_codes'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '[]');
|
|
if ($db->query(
|
|
"INSERT INTO xlvask_automation_audit
|
|
(run_id, usage_log_id, wash_id, event_type, action, policy_version, input_hash,
|
|
source_revision, expected_version, before_json, after_json, evidence_json, actor_id)
|
|
VALUES ({$runSql}, " . (int)$context['usage_log_id'] . ", '" . $db->escape_string((string)$context['wash_id']) . "',
|
|
'" . $db->escape_string($eventType) . "', " . ($action === null ? 'NULL' : "'" . $db->escape_string($action) . "'") . ",
|
|
'" . self::POLICY_VERSION . "', '" . $db->escape_string((string)($context['source_hash'] ?? $context['signature_hash'])) . "',
|
|
'" . $db->escape_string((string)($context['source_revision'] ?? '')) . "', " . (int)($context['expected_version'] ?? 1) . ",
|
|
'{$beforeJson}', '{$afterJson}', '{$evidenceJson}', {$actorSql})"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask automation audit record could not be stored atomically.');
|
|
}
|
|
}
|
|
|
|
private function minimalAuditSuggestion(array $value): array
|
|
{
|
|
$minimal = array_intersect_key($value, array_flip([
|
|
'id', 'status', 'action', 'certainty', 'source', 'matched_order_id', 'created_order_id',
|
|
'policy_version', 'input_hash', 'expected_version',
|
|
]));
|
|
$minimal['evidence_codes'] = array_values(array_filter(array_map(
|
|
static fn(mixed $evidence): string => is_array($evidence)
|
|
? mb_substr((string)($evidence['type'] ?? ''), 0, 64)
|
|
: mb_substr((string)$evidence, 0, 64),
|
|
(array)($value['evidence'] ?? [])
|
|
)));
|
|
return $minimal;
|
|
}
|
|
|
|
private function loadUsageLogRow(int $usageLogId): ?array
|
|
{
|
|
global $db;
|
|
(new xlvask_usage_logs_o())->structure();
|
|
$result = $db->query("SELECT * FROM xlvask_usage_logs WHERE id = {$usageLogId} LIMIT 1");
|
|
if ($result === false || $result->num_rows < 1) {
|
|
return null;
|
|
}
|
|
|
|
return $db->fetch_assoc($result);
|
|
}
|
|
|
|
private function loadUsageLogRowsByIds(array $ids, array $allowedHallIds = []): array
|
|
{
|
|
global $db;
|
|
$ids = array_values(array_filter(array_map('intval', $ids), fn(int $id): bool => $id > 0));
|
|
if ($ids === []) {
|
|
return [];
|
|
}
|
|
|
|
(new xlvask_usage_logs_o())->structure();
|
|
$hallSql = $this->hallScopeSql($allowedHallIds);
|
|
$result = $db->query('SELECT * FROM xlvask_usage_logs WHERE id IN (' . implode(',', $ids) . ')' . $hallSql);
|
|
return $db->fetch_all($result);
|
|
}
|
|
|
|
private function updateUsageLogAutomationState(int $usageLogId, array $automation, ?int $runId): void
|
|
{
|
|
global $db;
|
|
if ($usageLogId < 1) {
|
|
return;
|
|
}
|
|
|
|
$state = $this->stateForAutomationResult($automation);
|
|
$reason = $db->escape_string((string)($automation['reason'] ?? $automation['error'] ?? ''));
|
|
$runSql = $runId === null ? 'last_run_id' : (string)(int)$runId;
|
|
$db->query(
|
|
"UPDATE xlvask_usage_logs SET
|
|
resolution_state = '" . $db->escape_string($state['resolution_state']) . "',
|
|
certainty = '" . $db->escape_string($state['certainty']) . "',
|
|
planned_action = '" . $db->escape_string($state['planned_action']) . "',
|
|
state_reason = " . ($reason === '' ? 'NULL' : "'{$reason}'") . ",
|
|
last_run_id = {$runSql},
|
|
last_evaluated_at = NOW()
|
|
WHERE id = {$usageLogId}"
|
|
);
|
|
}
|
|
|
|
private function stateForAutomationResult(array $automation): array
|
|
{
|
|
$status = (string)($automation['status'] ?? self::STATUS_NONE);
|
|
$action = (string)($automation['action'] ?? self::ACTION_NONE);
|
|
$validatedCertainty = (string)($automation['certainty'] ?? 'none');
|
|
if (!in_array($validatedCertainty, ['certain', 'uncertain', 'none'], true)) {
|
|
$validatedCertainty = 'none';
|
|
}
|
|
if ((string)($automation['resolution_state'] ?? '') === 'needs_review') {
|
|
return ['resolution_state' => 'needs_review', 'certainty' => 'uncertain', 'planned_action' => 'recheck'];
|
|
}
|
|
|
|
if ($status === self::STATUS_AUTO_ACCEPTED) {
|
|
return [
|
|
'resolution_state' => $action === self::ACTION_CREATE ? 'auto_created' : 'auto_linked',
|
|
'certainty' => 'certain',
|
|
'planned_action' => $action,
|
|
];
|
|
}
|
|
if ($status === self::STATUS_ACCEPTED) {
|
|
return ['resolution_state' => 'already_linked', 'certainty' => $validatedCertainty, 'planned_action' => $action];
|
|
}
|
|
if ($status === self::STATUS_DENIED) {
|
|
return ['resolution_state' => 'needs_review', 'certainty' => 'uncertain', 'planned_action' => 'recheck'];
|
|
}
|
|
if ($status === self::STATUS_FAILED) {
|
|
return ['resolution_state' => 'failed', 'certainty' => 'none', 'planned_action' => 'recheck'];
|
|
}
|
|
if ($status === self::STATUS_SUGGESTED) {
|
|
return [
|
|
'resolution_state' => 'needs_review',
|
|
'certainty' => $validatedCertainty === 'certain' ? 'certain' : 'uncertain',
|
|
'planned_action' => in_array($action, [self::ACTION_ATTACH, self::ACTION_CREATE], true) ? $action : 'none',
|
|
];
|
|
}
|
|
|
|
return ['resolution_state' => 'needs_review', 'certainty' => 'none', 'planned_action' => 'none'];
|
|
}
|
|
|
|
private function loadPendingRows(?string $dateFrom, ?string $dateTo, int $limit, array $allowedHallIds = []): array
|
|
{
|
|
global $db;
|
|
(new xlvask_usage_logs_o())->structure();
|
|
$where = $this->pendingWhere($dateFrom, $dateTo, $allowedHallIds);
|
|
$limit = max(1, min(500, $limit));
|
|
$result = $db->query('SELECT * FROM xlvask_usage_logs WHERE ' . implode(' AND ', $where)
|
|
. " ORDER BY COALESCE(last_evaluated_at, '1970-01-01 00:00:00') ASC, id ASC LIMIT {$limit}");
|
|
return $db->fetch_all($result);
|
|
}
|
|
|
|
private function countPendingRows(?string $dateFrom, ?string $dateTo, array $allowedHallIds): int
|
|
{
|
|
global $db;
|
|
$row = $db->fetch_assoc($db->query(
|
|
'SELECT COUNT(*) total FROM xlvask_usage_logs WHERE '
|
|
. implode(' AND ', $this->pendingWhere($dateFrom, $dateTo, $allowedHallIds))
|
|
));
|
|
return (int)($row['total'] ?? 0);
|
|
}
|
|
|
|
private function pendingWhere(?string $dateFrom, ?string $dateTo, array $allowedHallIds): array
|
|
{
|
|
global $db;
|
|
$start = "STR_TO_DATE(REPLACE(SUBSTRING(StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s')";
|
|
$where = [
|
|
'FinishStatus = 1',
|
|
'(ignored_at IS NULL OR ignored_at = "")',
|
|
"resolution_state NOT IN ('already_linked', 'auto_linked', 'auto_created', 'ignored')",
|
|
];
|
|
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
|
|
if ($allowedHallIds !== []) {
|
|
$where[] = 'HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')';
|
|
}
|
|
$where[] = $dateFrom !== null && strtotime($dateFrom) !== false
|
|
? "{$start} >= '" . $db->escape_string(date('Y-m-d 00:00:00', strtotime($dateFrom))) . "'"
|
|
: "{$start} >= '" . $db->escape_string(date('Y-m-d H:i:s', strtotime('-7 days'))) . "'";
|
|
if ($dateTo !== null && strtotime($dateTo) !== false) {
|
|
$where[] = "{$start} <= '" . $db->escape_string(date('Y-m-d 23:59:59', strtotime($dateTo))) . "'";
|
|
}
|
|
return $where;
|
|
}
|
|
|
|
private function hallScopeSql(array $allowedHallIds): string
|
|
{
|
|
$allowedHallIds = $this->normalizeHallIds($allowedHallIds);
|
|
return $allowedHallIds === [] ? '' : ' AND HallId IN (' . $this->quotedHallIds($allowedHallIds) . ')';
|
|
}
|
|
|
|
private function normalizeHallIds(array $hallIds): array
|
|
{
|
|
return 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)));
|
|
}
|
|
|
|
private function quotedHallIds(array $hallIds): string
|
|
{
|
|
global $db;
|
|
return implode(',', array_map(
|
|
static fn(string $id): string => "'" . $db->escape_string($id) . "'",
|
|
$hallIds
|
|
));
|
|
}
|
|
|
|
private function usageLogFromRow(array $row): xlvask_usage_log
|
|
{
|
|
$row = self::normalizeUsageLogRowForAutomation($row);
|
|
|
|
$xlvask = new xlvask();
|
|
return $xlvask->new($xlvask->helpers->xlvask_usage_log)->setProperties($row);
|
|
}
|
|
|
|
private function formatSuggestion(array $row): array
|
|
{
|
|
$status = (string)($row['status'] ?? self::STATUS_NONE);
|
|
return [
|
|
'id' => isset($row['id']) ? (int)$row['id'] : null,
|
|
'status' => $status,
|
|
'action' => (string)($row['action'] ?? self::ACTION_NONE),
|
|
'confidence' => isset($row['confidence']) ? (float)$row['confidence'] : 0.0,
|
|
'source' => (string)($row['source'] ?? ''),
|
|
'certainty' => (string)($row['certainty'] ?? 'uncertain'),
|
|
'calibrated_probability' => isset($row['calibrated_probability']) && $row['calibrated_probability'] !== null
|
|
? (float)$row['calibrated_probability'] : null,
|
|
'model' => $row['model'] ?? null,
|
|
'model_confidence' => isset($row['model_confidence']) && $row['model_confidence'] !== null
|
|
? (float)$row['model_confidence'] : null,
|
|
'policy_version' => (string)($row['policy_version'] ?? self::POLICY_VERSION),
|
|
'evidence' => $this->decodeJsonField($row['evidence_json'] ?? null) ?? [],
|
|
'contradictions' => $this->decodeJsonField($row['contradictions_json'] ?? null) ?? [],
|
|
'risk_flags' => $this->decodeJsonField($row['risk_flags_json'] ?? null) ?? [],
|
|
'plan_steps' => $this->decodeJsonField($row['plan_steps_json'] ?? null) ?? [],
|
|
'expected_version' => isset($row['expected_version']) ? (int)$row['expected_version'] : null,
|
|
'run_id' => isset($row['run_id']) && $row['run_id'] !== null ? (int)$row['run_id'] : null,
|
|
'reason' => (string)($row['reason'] ?? ''),
|
|
'matched_order_id' => isset($row['matched_order_id']) && $row['matched_order_id'] !== null ? (int)$row['matched_order_id'] : null,
|
|
'created_order_id' => isset($row['created_order_id']) && $row['created_order_id'] !== null ? (int)$row['created_order_id'] : null,
|
|
'candidate_order' => $this->decodeJsonField($row['candidate_order_json'] ?? null),
|
|
'proposed_order' => $this->decodeJsonField($row['proposed_order_json'] ?? null),
|
|
'can_accept' => $status === self::STATUS_SUGGESTED,
|
|
'can_deny' => $status === self::STATUS_SUGGESTED,
|
|
];
|
|
}
|
|
|
|
private function formatTransientSuggestion(int $usageLogId, array $suggestion): array
|
|
{
|
|
return [
|
|
'usage_log_id' => $usageLogId,
|
|
'id' => null,
|
|
'status' => self::STATUS_SUGGESTED,
|
|
'action' => (string)($suggestion['action'] ?? self::ACTION_NONE),
|
|
'confidence' => (float)($suggestion['confidence'] ?? 0),
|
|
'source' => (string)($suggestion['source'] ?? ''),
|
|
'certainty' => (string)($suggestion['certainty'] ?? 'uncertain'),
|
|
'calibrated_probability' => $suggestion['calibrated_probability'] ?? null,
|
|
'model' => $suggestion['model'] ?? null,
|
|
'model_confidence' => $suggestion['model_confidence'] ?? null,
|
|
'policy_version' => self::POLICY_VERSION,
|
|
'reason' => (string)($suggestion['reason'] ?? ''),
|
|
'matched_order_id' => $suggestion['matched_order_id'] ?? null,
|
|
'created_order_id' => null,
|
|
'candidate_order' => $suggestion['candidate_order'] ?? null,
|
|
'proposed_order' => $suggestion['proposed_order'] ?? null,
|
|
'evidence' => $suggestion['evidence'] ?? [],
|
|
'contradictions' => $suggestion['contradictions'] ?? [],
|
|
'risk_flags' => $suggestion['risk_flags'] ?? [],
|
|
'plan_steps' => $suggestion['plan_steps'] ?? [],
|
|
'expected_version' => $suggestion['expected_version'] ?? null,
|
|
'run_id' => $this->runId,
|
|
'can_accept' => false,
|
|
'can_deny' => false,
|
|
];
|
|
}
|
|
|
|
private function emptyAutomation(string $reason = ''): array
|
|
{
|
|
return [
|
|
'id' => null,
|
|
'status' => self::STATUS_NONE,
|
|
'action' => self::ACTION_NONE,
|
|
'confidence' => 0.0,
|
|
'source' => '',
|
|
'certainty' => 'none',
|
|
'calibrated_probability' => null,
|
|
'model' => null,
|
|
'model_confidence' => null,
|
|
'policy_version' => self::POLICY_VERSION,
|
|
'evidence' => [],
|
|
'contradictions' => [],
|
|
'risk_flags' => [],
|
|
'plan_steps' => [],
|
|
'expected_version' => null,
|
|
'run_id' => null,
|
|
'reason' => $reason,
|
|
'matched_order_id' => null,
|
|
'created_order_id' => null,
|
|
'candidate_order' => null,
|
|
'proposed_order' => null,
|
|
'can_accept' => false,
|
|
'can_deny' => false,
|
|
];
|
|
}
|
|
|
|
private function decodeJsonField(?string $value): mixed
|
|
{
|
|
if ($value === null || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($value, true);
|
|
return json_last_error() === JSON_ERROR_NONE ? $decoded : null;
|
|
}
|
|
|
|
private function itemSignatureParts(array $items): array
|
|
{
|
|
return self::itemSignaturePartsForAutomation($items);
|
|
}
|
|
|
|
private function compactItems(array $items): array
|
|
{
|
|
return array_map(fn(array $item): array => [
|
|
'product_id' => (int)($item['product_id'] ?? 0),
|
|
'product_name' => (string)($item['product']['name'] ?? $item['product_name'] ?? ''),
|
|
'quantity' => (int)($item['quantity'] ?? 0),
|
|
'price' => (int)($item['price'] ?? 0),
|
|
], $items);
|
|
}
|
|
|
|
private function itemsTotal(array $items): int
|
|
{
|
|
return array_reduce($items, fn(int $total, array $item): int => $total + ((int)($item['price'] ?? 0) * (int)($item['quantity'] ?? 0)), 0);
|
|
}
|
|
|
|
private function productOverlap(array $usageItems, array $orderItems): float
|
|
{
|
|
$usageBag = $this->productBag($usageItems);
|
|
$orderBag = $this->productBag($orderItems);
|
|
$usageTotal = array_sum($usageBag);
|
|
if ($usageTotal <= 0) {
|
|
return 0.0;
|
|
}
|
|
|
|
$overlap = 0;
|
|
foreach ($usageBag as $productId => $quantity) {
|
|
$overlap += min($quantity, $orderBag[$productId] ?? 0);
|
|
}
|
|
|
|
return $overlap / $usageTotal;
|
|
}
|
|
|
|
private function productBag(array $items): array
|
|
{
|
|
$bag = [];
|
|
foreach ($items as $item) {
|
|
$productId = (int)($item['product_id'] ?? 0);
|
|
if ($productId < 1) {
|
|
continue;
|
|
}
|
|
$bag[$productId] = ($bag[$productId] ?? 0) + max(1, (int)($item['quantity'] ?? 1));
|
|
}
|
|
|
|
return $bag;
|
|
}
|
|
|
|
private function normalizeRegistration(string $registration): string
|
|
{
|
|
return self::normalizeRegistrationForAutomation($registration);
|
|
}
|
|
|
|
private function isOpenAiEnabled(): bool
|
|
{
|
|
try {
|
|
$xlvask = new xlvask();
|
|
if (!$xlvask->config->openai_integration_enabled->isTrue()) {
|
|
return false;
|
|
}
|
|
|
|
$openai = new openai();
|
|
return $openai->config->enabled->isTrue();
|
|
} catch (Exception) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|