Files
api/services/nginx/app/classes/economic_v2_versioning_service.php
T

726 lines
25 KiB
PHP

<?php
namespace classes;
use DateTime;
use Exception;
class economic_v2_versioning_service
{
public function __construct()
{
economic_v2_schema_bootstrap::ensureTables();
}
public function recordFixedPricingVersion(
int $customer_number,
?int $price,
?string $description,
?string $effective_from = null,
string $source = 'live.fixed_pricing',
float $confidence = 1.0,
bool $inferred = false,
array $metadata = []
): array {
if ($price === null) {
return $this->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]++;
}
}