Complete selected-customer invoice period tree (#338)

Add the authoritative revision-bound invoice collection tree and guarded cleanup, merge, price-reset, and transfer operations.
This commit is contained in:
Jeppe B
2026-08-03 12:02:34 +02:00
committed by GitHub
parent f37feef1e6
commit 068f9e254f
12 changed files with 2413 additions and 67 deletions
@@ -26,6 +26,9 @@ class collected_order_invoices_o extends db
{
use db_object_t;
private const SUPERSESSION_MARKER_PATTERN = '/\n?\[\[invoice_collection_superseded:(\{.*?\})\]\]/';
private static array $knownColumns = [];
public object_property $customer_number;
public object_property $name;
public object_property $notes;
@@ -78,6 +81,9 @@ class collected_order_invoices_o extends db
$this->id = $collection['id'];
self::getObjectProperties();
self::requireSelected();
if ($this->getSupersessionMetadata() !== null) {
continue;
}
$result[] = self::asArray();
}
return $result;
@@ -98,6 +104,27 @@ class collected_order_invoices_o extends db
$this->error_message = new object_property($this->table, $this->id, 'error_message', 'string', false);
}
private static function hasColumn(string $column): bool
{
global $db;
if (array_key_exists($column, self::$knownColumns)) {
return self::$knownColumns[$column];
}
$safeColumn = $db->escape_string($column);
$result = $db->query("SHOW COLUMNS FROM collected_order_invoices LIKE '{$safeColumn}'");
self::$knownColumns[$column] = $result && $result->num_rows > 0;
return self::$knownColumns[$column];
}
public static function additiveSchemaDefinitions(): array
{
return [
'superseded_by_collection_id' => 'INT NULL',
'superseded_at' => 'DATETIME NULL',
'superseded_by_user_id' => 'INT NULL',
];
}
/**
* Get the invoice collection as an array
* @throws ApiErrorException If the payment method is Stripe and the request fails
@@ -242,6 +269,123 @@ class collected_order_invoices_o extends db
return $fields;
}
/**
* Mark an emptied merge source as superseded without requiring a schema migration.
* Existing collection metadata is preserved; the target collection remains authoritative.
*
* @throws Exception
*/
public function markSupersededBy(int $targetInvoiceCollectionId, int $actorUserId): void
{
global $db;
self::requireSelected();
if ($this->getSupersessionMetadata() !== null) {
throw new Exception('The source invoice collection has already been superseded.');
}
if ($targetInvoiceCollectionId < 1 || $targetInvoiceCollectionId === (int)$this->id) {
throw new Exception('A different merge target invoice collection is required.');
}
if ($actorUserId < 1) {
throw new Exception('A valid actor is required when marking an invoice collection as superseded.');
}
$target = (new self())->select($targetInvoiceCollectionId);
$target->requireSelected();
if ($target->getSupersessionMetadata() !== null) {
throw new Exception('The target invoice collection has already been superseded.');
}
if ((int)$target->customer_number->value() !== (int)$this->customer_number->value()) {
throw new Exception('A superseding invoice collection must belong to the same customer.');
}
$sourceId = (int)$this->id;
$remaining = $db->query(
"SELECT id FROM orders
WHERE invoice_collection_id = {$sourceId}
AND deleted_at IS NULL
LIMIT 1"
);
if ($remaining && $remaining->num_rows > 0) {
throw new Exception('The source invoice collection still contains active orders.');
}
$metadata = [
'target_invoice_collection_id' => $targetInvoiceCollectionId,
'superseded_by_user_id' => $actorUserId,
'superseded_at' => date('c'),
];
if (self::hasColumn('superseded_by_collection_id')) {
$assignments = ['superseded_by_collection_id = ' . $targetInvoiceCollectionId];
if (self::hasColumn('superseded_at')) {
$assignments[] = "superseded_at = '" . $db->escape_string(date('Y-m-d H:i:s')) . "'";
}
if (self::hasColumn('superseded_by_user_id')) {
$assignments[] = 'superseded_by_user_id = ' . $actorUserId;
}
$db->query(
'UPDATE collected_order_invoices SET ' . implode(', ', $assignments)
. ' WHERE id = ' . $sourceId
);
$notes = preg_replace(self::SUPERSESSION_MARKER_PATTERN, '', (string)$this->notes->value());
if ($notes !== (string)$this->notes->value()) {
$this->notes->set(rtrim((string)$notes));
}
} else {
$marker = '[[invoice_collection_superseded:' . json_encode(
$metadata,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
) . ']]';
$notes = preg_replace(self::SUPERSESSION_MARKER_PATTERN, '', (string)$this->notes->value());
$notes = rtrim((string)$notes);
$this->notes->set(($notes === '' ? '' : $notes . "\n") . $marker);
}
if (empty($this->closed_at->value())) {
$this->closed_at->set(date('Y-m-d H:i:s'));
}
self::objectChanged();
}
/** @return array{target_invoice_collection_id:int,superseded_by_user_id:int,superseded_at:?string}|null */
public function getSupersessionMetadata(): ?array
{
global $db;
self::requireSelected();
if (self::hasColumn('superseded_by_collection_id')) {
$fields = ['superseded_by_collection_id'];
if (self::hasColumn('superseded_by_user_id')) {
$fields[] = 'superseded_by_user_id';
}
if (self::hasColumn('superseded_at')) {
$fields[] = 'superseded_at';
}
$result = $db->query(
'SELECT ' . implode(', ', $fields)
. ' FROM collected_order_invoices WHERE id = ' . (int)$this->id . ' LIMIT 1'
);
$row = $result ? $result->fetch_assoc() : null;
if (is_array($row) && (int)($row['superseded_by_collection_id'] ?? 0) > 0) {
return [
'target_invoice_collection_id' => (int)$row['superseded_by_collection_id'],
'superseded_by_user_id' => (int)($row['superseded_by_user_id'] ?? 0),
'superseded_at' => isset($row['superseded_at']) ? (string)$row['superseded_at'] : null,
];
}
return null;
}
if (!preg_match(self::SUPERSESSION_MARKER_PATTERN, (string)$this->notes->value(), $matches)) {
return null;
}
$metadata = json_decode($matches[1] ?? '', true);
if (!is_array($metadata) || (int)($metadata['target_invoice_collection_id'] ?? 0) < 1) {
return null;
}
return [
'target_invoice_collection_id' => (int)$metadata['target_invoice_collection_id'],
'superseded_by_user_id' => (int)($metadata['superseded_by_user_id'] ?? 0),
'superseded_at' => isset($metadata['superseded_at']) ? (string)$metadata['superseded_at'] : null,
];
}
/**
* Check if the invoice draft is existing in E-conomic
* @returns bool If the invoice draft is existing
+1 -1
View File
@@ -1340,7 +1340,7 @@ class orders_o extends db
WHERE customer_number IS NOT NULL AND customer_number <> 0
GROUP BY customer_number
) customer_user ON customer_user.customer_number = o.customer_id
LEFT JOIN order_items oi ON oi.order_id = o.id AND (oi.deleted_at IS NULL OR oi.deleted_at = '')
LEFT JOIN order_items oi ON oi.order_id = o.id AND oi.deleted_at IS NULL
LEFT JOIN collected_order_invoices coi ON coi.id = o.invoice_collection_id
LEFT JOIN economic_module_orders emo ON emo.id = o.id
LEFT JOIN (