closeActiveFixedPricingVersion( $customer_number, $effective_from, $source, $confidence, $inferred, $metadata ); } return $this->upsertVersion( 'customer_fixed_pricing_versions', [ 'customer_number' => $customer_number, ], [ 'price' => (int)$price, 'description' => $description ?? '', ], $this->normalizeDatetime($effective_from), $source, $confidence, $inferred, $metadata ); } public function closeActiveFixedPricingVersion( int $customer_number, ?string $effective_to = null, string $source = 'live.fixed_pricing', float $confidence = 1.0, bool $inferred = false, array $metadata = [] ): array { return $this->closeActiveVersion( 'customer_fixed_pricing_versions', [ 'customer_number' => $customer_number, ], $this->normalizeDatetime($effective_to), $source, $confidence, $inferred, $metadata ); } public function recordVehicleSubscriptionVersion( array $state, ?string $effective_from = null, string $source = 'live.vehicle', float $confidence = 1.0, bool $inferred = false, array $metadata = [] ): array { if (!isset($state['customer_number'], $state['reg'], $state['vehicle_type'], $state['wash_subscription'])) { throw new Exception('Missing required vehicle version state keys'); } return $this->upsertVersion( 'customer_vehicle_subscription_versions', [ 'customer_number' => (int)$state['customer_number'], 'reg' => (string)$state['reg'], ], [ 'vehicle_id' => isset($state['vehicle_id']) ? (int)$state['vehicle_id'] : null, 'vehicle_type' => (int)$state['vehicle_type'], 'wash_subscription' => (int)((bool)$state['wash_subscription']), ], $this->normalizeDatetime($effective_from), $source, $confidence, $inferred, $metadata ); } public function closeActiveVehicleSubscriptionVersion( int $customer_number, string $reg, ?string $effective_to = null, string $source = 'live.vehicle', float $confidence = 1.0, bool $inferred = false, array $metadata = [] ): array { return $this->closeActiveVersion( 'customer_vehicle_subscription_versions', [ 'customer_number' => $customer_number, 'reg' => $reg, ], $this->normalizeDatetime($effective_to), $source, $confidence, $inferred, $metadata ); } public function recordDiscountOverrideVersion( int $user_id, int $customer_number, bool $is_category, int|string $object_id, ?int $discount, ?string $effective_from = null, string $source = 'live.discount_override', float $confidence = 1.0, bool $inferred = false, array $metadata = [], ?int $fixed_price = null, ?int $department_id = null ): array { $identity = [ 'user_id' => $user_id, 'customer_number' => $customer_number, 'is_category' => (int)$is_category, 'object_id' => (string)$object_id, ]; if ($department_id !== null) { $identity['department_id'] = (int)$department_id; } if (($discount === null || (int)$discount === 0) && $fixed_price === null) { return $this->closeActiveVersion( 'customer_discount_override_versions', $identity, $this->normalizeDatetime($effective_from), $source, $confidence, $inferred, $metadata ); } return $this->upsertVersion( 'customer_discount_override_versions', $identity, [ 'discount' => (int)$discount, 'fixed_price' => $is_category ? null : $fixed_price, ], $this->normalizeDatetime($effective_from), $source, $confidence, $inferred, $metadata ); } public function listFixedPricingVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array { return $this->listVersions( 'customer_fixed_pricing_versions', ['customer_number' => $customer_number], $date_from, $date_to ); } public function listVehicleSubscriptionVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array { return $this->listVersions( 'customer_vehicle_subscription_versions', ['customer_number' => $customer_number], $date_from, $date_to ); } public function listDiscountOverrideVersions(int $customer_number, ?string $date_from = null, ?string $date_to = null): array { return $this->listVersions( 'customer_discount_override_versions', ['customer_number' => $customer_number], $date_from, $date_to ); } public function resolveFixedPricingVersionAt(int $customer_number, string $timestamp): ?array { $rows = $this->resolveActiveVersions( 'customer_fixed_pricing_versions', ['customer_number' => $customer_number], $timestamp, 'effective_from DESC, id DESC', 1 ); return $rows[0] ?? null; } public function resolveVehicleSubscriptionVersionsAt(int $customer_number, string $timestamp): array { $rows = $this->resolveActiveVersions( 'customer_vehicle_subscription_versions', [ 'customer_number' => $customer_number, 'wash_subscription' => 1, ], $timestamp, 'reg ASC, effective_from DESC, id DESC' ); $unique = []; foreach ($rows as $row) { $reg = (string)$row['reg']; if (!isset($unique[$reg])) { $unique[$reg] = $row; } } return array_values($unique); } public function resolveDiscountOverrideAt( int $customer_number, bool $is_category, int|string $object_id, string $timestamp, ?int $department_id = null ): ?array { $identity = [ 'customer_number' => $customer_number, 'is_category' => (int)$is_category, 'object_id' => (string)$object_id, ]; if ($department_id !== null) { $identity['department_id'] = (int)$department_id; } $rows = $this->resolveActiveVersions( 'customer_discount_override_versions', $identity, $timestamp, 'effective_from DESC, id DESC', 1 ); return $rows[0] ?? null; } public function runBestEffortBackfill(): array { global $db; economic_v2_schema_bootstrap::ensureTables(); $report = [ 'fixed_pricing' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], 'vehicle_subscriptions' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], 'discount_overrides' => ['inserted' => 0, 'updated' => 0, 'closed' => 0, 'noop' => 0], 'inferred' => ['fixed_pricing' => 0, 'vehicle_subscriptions' => 0], 'warnings' => [], ]; // Fixed pricing current state. $has_fixed_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_fixed_pricing', 'created_at'); $fixed_cols = $has_fixed_created_at ? 'customer_number, price, description, created_at' : 'customer_number, price, description'; $fixed_rows = $this->fetchAll("SELECT $fixed_cols FROM customer_fixed_pricing"); foreach ($fixed_rows as $row) { $effective_from = $has_fixed_created_at ? $this->normalizeDatetime((string)$row['created_at']) : $this->normalizeDatetime(null); $confidence = $has_fixed_created_at ? 0.8 : 0.6; $result = $this->recordFixedPricingVersion( (int)$row['customer_number'], (int)$row['price'], (string)($row['description'] ?? ''), $effective_from, 'backfill.current_fixed_pricing', $confidence, true, ['table' => 'customer_fixed_pricing'] ); $this->incrementReportAction($report['fixed_pricing'], $result['action'] ?? 'noop'); } // Infer fixed pricing start from synthetic fixed-price orders when no timeline exists. $fixed_inferred = $this->fetchAll( "SELECT o.customer_id AS customer_number, MIN(o.created_at) AS first_seen, MAX(oi.price) AS inferred_price FROM orders o JOIN order_items oi ON oi.order_id = o.id WHERE o.deleted_at IS NULL AND oi.deleted_at IS NULL AND o.reference = 'Fast pris aftale' AND oi.product_id = 61 GROUP BY o.customer_id" ); foreach ($fixed_inferred as $row) { $customer_number = (int)$row['customer_number']; if ($this->resolveFixedPricingVersionAt($customer_number, (string)$row['first_seen']) !== null) { continue; } $price = (int)($row['inferred_price'] ?? 0); if ($price <= 0) { continue; } $this->recordFixedPricingVersion( $customer_number, $price, 'Inferred from fixed-pricing invoice order', $this->normalizeDatetime((string)$row['first_seen']), 'backfill.inferred_fixed_pricing_order', 0.55, true, ['reference' => 'Fast pris aftale', 'product_id' => 61] ); $report['inferred']['fixed_pricing']++; } // Vehicle subscriptions current state. $has_vehicle_created_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'created_at'); $has_vehicle_deleted_at = economic_v2_schema_bootstrap::tableHasColumn('customer_vehicles', 'deleted_at'); $vehicle_cols = 'id, customer_id, reg, type, wash_subscription' . ($has_vehicle_created_at ? ', created_at' : '') . ($has_vehicle_deleted_at ? ', deleted_at' : ''); $vehicle_rows = $this->fetchAll("SELECT $vehicle_cols FROM customer_vehicles"); foreach ($vehicle_rows as $row) { $effective_from = $has_vehicle_created_at ? $this->normalizeDatetime((string)$row['created_at']) : $this->normalizeDatetime(null); $confidence = $has_vehicle_created_at ? 0.75 : 0.55; $result = $this->recordVehicleSubscriptionVersion( [ 'vehicle_id' => (int)$row['id'], 'customer_number' => (int)$row['customer_id'], 'reg' => (string)$row['reg'], 'vehicle_type' => (int)$row['type'], 'wash_subscription' => (bool)$row['wash_subscription'], ], $effective_from, 'backfill.current_vehicle', $confidence, true, ['table' => 'customer_vehicles'] ); $this->incrementReportAction($report['vehicle_subscriptions'], $result['action'] ?? 'noop'); if ($has_vehicle_deleted_at && !empty($row['deleted_at'])) { $close_result = $this->closeActiveVehicleSubscriptionVersion( (int)$row['customer_id'], (string)$row['reg'], $this->normalizeDatetime((string)$row['deleted_at']), 'backfill.current_vehicle_deleted', 0.9, true, ['table' => 'customer_vehicles'] ); $this->incrementReportAction($report['vehicle_subscriptions'], $close_result['action'] ?? 'noop'); } } // Infer subscriptions from synthetic subscription orders. $subscription_inferred = $this->fetchAll( "SELECT o.customer_id AS customer_number, oi.reference AS reg, oi.product_id AS vehicle_type, MIN(o.created_at) AS first_seen FROM orders o JOIN order_items oi ON oi.order_id = o.id WHERE o.deleted_at IS NULL AND oi.deleted_at IS NULL AND o.reference = 'Vaskeabonnementer' AND oi.reference <> '' AND oi.quantity > 0 GROUP BY o.customer_id, oi.reference, oi.product_id" ); foreach ($subscription_inferred as $row) { $resolved = $this->resolveVehicleSubscriptionVersionsAt((int)$row['customer_number'], (string)$row['first_seen']); $already = false; foreach ($resolved as $active) { if ((string)$active['reg'] === (string)$row['reg']) { $already = true; break; } } if ($already) { continue; } $this->recordVehicleSubscriptionVersion( [ 'vehicle_id' => null, 'customer_number' => (int)$row['customer_number'], 'reg' => (string)$row['reg'], 'vehicle_type' => (int)$row['vehicle_type'], 'wash_subscription' => true, ], $this->normalizeDatetime((string)$row['first_seen']), 'backfill.inferred_subscription_order', 0.5, true, ['reference' => 'Vaskeabonnementer'] ); $report['inferred']['vehicle_subscriptions']++; } // Discount overrides current state. price_overrides_schema_bootstrap::ensureColumns(); $has_override_created_at = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'created_at'); $has_override_fixed_price = economic_v2_schema_bootstrap::tableHasColumn('price_overrides', 'fixed_price'); $discount_cols = 'po.user_id, u.customer_number, po.is_category, po.product_or_category_id, po.percentage' . ($has_override_fixed_price ? ', po.fixed_price' : '') . ($has_override_created_at ? ', po.created_at' : ''); $discount_rows = $this->fetchAll( "SELECT $discount_cols FROM price_overrides po JOIN users u ON u.id = po.user_id" ); foreach ($discount_rows as $row) { $effective_from = $has_override_created_at ? $this->normalizeDatetime((string)$row['created_at']) : $this->normalizeDatetime(null); $confidence = $has_override_created_at ? 0.85 : 0.6; $result = $this->recordDiscountOverrideVersion( (int)$row['user_id'], (int)$row['customer_number'], (bool)$row['is_category'], (string)$row['product_or_category_id'], (int)$row['percentage'], $effective_from, 'backfill.current_discount_override', $confidence, true, ['table' => 'price_overrides'], $has_override_fixed_price && $row['fixed_price'] !== null ? (int)$row['fixed_price'] : null ); $this->incrementReportAction($report['discount_overrides'], $result['action'] ?? 'noop'); } return $report; } private function upsertVersion( string $table, array $identity, array $values, string $effective_from, string $source, float $confidence, bool $inferred, array $metadata ): array { global $db; $confidence = $this->normalizeConfidence($confidence); $metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $source = $db->escape_string($source); $effective_from = $db->escape_string($effective_from); // Close the previous active interval when a new one starts. $close_to = $db->escape_string($this->minusOneSecond($effective_from)); $identity_where = $this->buildWhereClause($identity); $db->query( "UPDATE $table SET effective_to = '$close_to' WHERE $identity_where AND effective_from < '$effective_from' AND (effective_to IS NULL OR effective_to >= '$effective_from')" ); $existing = $this->fetchOne( "SELECT id FROM $table WHERE $identity_where AND effective_from = '$effective_from' ORDER BY id DESC LIMIT 1" ); if ($existing !== null) { $id = (int)$existing['id']; $set_parts = []; foreach ($values as $k => $v) { $set_parts[] = $this->buildSetFragment($k, $v); } $set_parts[] = "source = '$source'"; $set_parts[] = "confidence = $confidence"; $set_parts[] = "inferred = " . ((int)$inferred); $set_parts[] = "metadata_json = '$metadata_json'"; $db->query("UPDATE $table SET " . implode(', ', $set_parts) . " WHERE id = $id"); return [ 'action' => 'updated', 'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"), ]; } $next_start = $this->fetchOne( "SELECT effective_from FROM $table WHERE $identity_where AND effective_from > '$effective_from' ORDER BY effective_from ASC LIMIT 1" ); $effective_to_value = null; if ($next_start !== null && !empty($next_start['effective_from'])) { $effective_to_value = $this->minusOneSecond((string)$next_start['effective_from']); } $insert_data = [ ...$identity, ...$values, 'effective_from' => $effective_from, 'effective_to' => $effective_to_value, 'source' => $source, 'confidence' => $confidence, 'inferred' => (int)$inferred, 'metadata_json' => $metadata_json, ]; $columns = []; $values_sql = []; foreach ($insert_data as $k => $v) { $columns[] = $k; $values_sql[] = $this->buildValueFragment($v); } $db->query( "INSERT INTO $table (" . implode(', ', $columns) . ") VALUES (" . implode(', ', $values_sql) . ")" ); $id = (int)$db->insert_id(); return [ 'action' => 'inserted', 'row' => $this->fetchOne("SELECT * FROM $table WHERE id = $id"), ]; } private function closeActiveVersion( string $table, array $identity, string $effective_to, string $source, float $confidence, bool $inferred, array $metadata ): array { global $db; $confidence = $this->normalizeConfidence($confidence); $source = $db->escape_string($source); $metadata_json = $db->escape_string(json_encode($metadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); $effective_to = $db->escape_string($effective_to); $identity_where = $this->buildWhereClause($identity); $result = $db->query( "UPDATE $table SET effective_to = '$effective_to', source = '$source', confidence = $confidence, inferred = " . ((int)$inferred) . ", metadata_json = '$metadata_json' WHERE $identity_where AND effective_from <= '$effective_to' AND (effective_to IS NULL OR effective_to > '$effective_to')" ); if ($result && $db->conn()->affected_rows > 0) { return ['action' => 'closed']; } return ['action' => 'noop']; } private function listVersions(string $table, array $identity, ?string $date_from, ?string $date_to): array { $where = $this->buildWhereClause($identity); if ($date_from !== null) { $date_from = $this->normalizeDatetime($date_from); $where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($date_from) . "')"; } if ($date_to !== null) { $date_to = $this->normalizeDatetime($date_to); $where .= " AND effective_from <= '" . $this->escape($date_to) . "'"; } return $this->fetchAll("SELECT * FROM $table WHERE $where ORDER BY effective_from ASC, id ASC"); } private function resolveActiveVersions( string $table, array $identity, string $timestamp, string $order_by, ?int $limit = null ): array { $timestamp = $this->normalizeDatetime($timestamp); $where = $this->buildWhereClause($identity); $where .= " AND effective_from <= '" . $this->escape($timestamp) . "'"; $where .= " AND (effective_to IS NULL OR effective_to >= '" . $this->escape($timestamp) . "')"; $sql = "SELECT * FROM $table WHERE $where ORDER BY $order_by"; if ($limit !== null) { $sql .= " LIMIT " . ((int)$limit); } return $this->fetchAll($sql); } private function buildWhereClause(array $identity): string { $parts = []; foreach ($identity as $k => $v) { if ($v === null) { $parts[] = "$k IS NULL"; continue; } if (is_bool($v)) { $parts[] = "$k = " . ((int)$v); continue; } if (is_int($v) || is_float($v)) { $parts[] = "$k = $v"; continue; } $parts[] = "$k = '" . $this->escape((string)$v) . "'"; } return implode(' AND ', $parts); } private function buildSetFragment(string $key, mixed $value): string { return "$key = " . $this->buildValueFragment($value); } private function buildValueFragment(mixed $value): string { if ($value === null) { return 'NULL'; } if (is_bool($value)) { return (string)((int)$value); } if (is_int($value) || is_float($value)) { return (string)$value; } return "'" . $this->escape((string)$value) . "'"; } private function normalizeDatetime(?string $value): string { if ($value === null || trim($value) === '') { return date('Y-m-d H:i:s'); } $dt = new DateTime($value); return $dt->format('Y-m-d H:i:s'); } private function minusOneSecond(string $datetime): string { $dt = new DateTime($datetime); $dt->modify('-1 second'); return $dt->format('Y-m-d H:i:s'); } private function normalizeConfidence(float $confidence): float { if ($confidence < 0) { return 0.0; } if ($confidence > 1) { return 1.0; } return round($confidence, 5); } private function escape(string $value): string { global $db; return $db->escape_string($value); } private function fetchAll(string $sql): array { global $db; $result = $db->query($sql); if (!$result) { return []; } return $db->fetch_all($result); } private function fetchOne(string $sql): ?array { $rows = $this->fetchAll($sql); if (empty($rows)) { return null; } return $rows[0]; } private function incrementReportAction(array &$bucket, string $action): void { if (!isset($bucket[$action])) { $bucket[$action] = 0; } $bucket[$action]++; } }