726 lines
25 KiB
PHP
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]++;
|
|
}
|
|
}
|