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:
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user