fix(finance): release budget held by prints that ended

Before a print without a user (authentication off, or its user deleted while
it printed) was charged to its cost centre (b2ed37b6c), billing stopped
before using up or releasing the print's budget reservation, so it held that
much of the cost centre's budget for good.

A start-up repair releases active print reservations whose queue entry has
finished or is gone: nothing can charge them any more. Waiting, printing and
skipped entries keep theirs, and the ledger is not touched. Not gated: it
only matches dead reservations, so a second start changes nothing.
This commit is contained in:
maziggy
2026-10-15 16:26:02 +02:00
parent 176161f0d2
commit 7ece330c53
3 changed files with 98 additions and 0 deletions
+1
View File
@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
- **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
### Fixed
- **Budget held by prints that ended is released** — Before a print without a user (authentication off, or its user deleted while it printed) was charged to its cost centre, it kept its budget reservation for good, so the cost centre showed that much less budget left. At the next start, reservations of prints that have finished or were removed from the queue are released. Only the reserved amount is freed; charges already booked don't change.
- **A pipeline run remembers how each copy ended** — A copy a slicer pipeline sent to the queue only showed its outcome while its queue entry existed. Once the entry was deleted (from the queue page, with its archive, or with its user) the run could no longer tell a printed copy from one that never ran: it stayed **dispatching** forever, and **Retry failed** could not tell what to retry. Each copy now keeps its final state (completed, failed or cancelled) when its queue entry ends, and removing a copy that hadn't printed yet counts as cancelled. Copies whose entry was already gone before this update show as **unknown**: never retried, and counted as finished, so the run completes.
- **Deleting a spool keeps its usage history, and a new spool no longer takes over a deleted one's** — On PostgreSQL, deleting a spool deleted its usage history, so **Recalculate costs** replaced the real spool cost of its prints with an estimate. On SQLite the history stayed, but the next spool created could get the deleted spool's number and then showed its prints and consumption as its own. The usage history now stays on both databases without a spool, the prints keep their spool cost, and spool numbers are never given out again on SQLite. A Git backup restores such history as well. Supplier and material statistics leave out usage of deleted spools, as there is no spool left to count it for.
- **Archives a spool was used for can be deleted on PostgreSQL** — With PostgreSQL, deleting an archive for good (deleting it with **purge from statistics**, the automatic archive purge with that option, or deleting a printer together with its archives) failed with a server error when a spool from the inventory had been used for that print. The spool's usage record now stays and only loses its link to the deleted archive, on both databases; on SQLite, usage records still pointing at an archive deleted earlier are cleared of that link at the next start. **Recalculate costs** no longer charges usage records without an archive link to archives by their print name: that added the spool cost of a deleted print, of a print made with archiving off, or of every earlier run to each archive with the same name. Archives without linked usage get the estimate from the filament price instead, as they already did when no such record existed (usage recorded before 0.2.1, or with archiving off).
+31
View File
@@ -5806,6 +5806,7 @@ async def _run_migrations(conn):
await _migrate_spool_usage_outlives_its_archive(conn)
await _migrate_spool_usage_outlives_its_spool(conn)
await _backfill_pipeline_job_final_status(conn)
await _release_stuck_budget_reservations(conn)
await _migrate_pg_column_types(conn)
await _migrate_pg_columns_to_model(conn)
@@ -6142,6 +6143,36 @@ async def _backfill_pipeline_job_final_status(conn) -> None:
logger.info("Stored the final status of %d pipeline job(s) from their queue entry", result.rowcount)
async def _release_stuck_budget_reservations(conn) -> None:
"""Release print reservations whose queue entry has finished or is gone.
Billing uses up or releases a print's reservation when the print ends.
Before a print without a user (authentication off, or its user deleted
while it printed) was billed to its cost centre, billing stopped before
either, and the reservation held that much of the budget for good. Such a
reservation can no longer be charged: the print was billed or never will
be. Only the reservation changes; the ledger does not. Not gated: it only
matches reservations nothing can use, so a second run changes nothing.
A skipped entry can return to pending, so it keeps its reservation.
"""
from sqlalchemy import text
from backend.app.utils.local_time import utcnow_naive
async with conn.begin_nested():
result = await conn.execute(
text(
"UPDATE budget_reservations SET status = 'released', released_at = :now "
"WHERE status = 'active' AND source_type = 'print_queue' "
"AND NOT EXISTS (SELECT 1 FROM print_queue q WHERE q.id = budget_reservations.source_id "
"AND q.status NOT IN ('completed', 'failed', 'cancelled', 'aborted'))"
),
{"now": utcnow_naive()},
)
if result.rowcount:
logger.info("Released %d budget reservation(s) of prints that finished or were removed", result.rowcount)
_PG_ON_DELETE_CODES = {"CASCADE": "c", "SET NULL": "n"}
@@ -0,0 +1,66 @@
"""Reservations no print can use any more are released at start.
Before b2ed37b6c, billing a print without a user (authentication off, or a
user deleted while it printed) stopped before releasing the print's budget
reservation, so it held that much of the cost centre's budget for good. A
reservation whose queue entry has finished or is gone can never be charged.
"""
from __future__ import annotations
import pytest
from sqlalchemy import select
from backend.app.core.database import _release_stuck_budget_reservations
from backend.app.models.finance import BudgetReservation, CostCenter
from backend.app.models.print_queue import PrintQueueItem
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
CASES = {
# queue entry status (None: entry deleted) -> reservation released?
"completed": True,
"failed": True,
"cancelled": True,
None: True,
"printing": False,
"pending": False,
"skipped": False, # can go back to pending
}
async def test_reservations_of_finished_or_deleted_entries_are_released(db_session, printer_factory, archive_factory):
printer = await printer_factory()
archive = await archive_factory(printer.id)
center = CostCenter(name="Lab", total_budget=100)
db_session.add(center)
await db_session.flush()
reservation_ids = {}
for status in CASES:
if status is None:
source_id = 999_999
else:
item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status=status)
db_session.add(item)
await db_session.flush()
source_id = item.id
reservation = BudgetReservation(
cost_center_id=center.id, amount=5, status="active", source_type="print_queue", source_id=source_id
)
db_session.add(reservation)
await db_session.flush()
reservation_ids[status] = reservation.id
other = BudgetReservation(cost_center_id=center.id, amount=5, status="active", source_type="manual", source_id=1)
db_session.add(other)
await db_session.commit()
other_id = other.id
await _release_stuck_budget_reservations(await db_session.connection())
await _release_stuck_budget_reservations(await db_session.connection()) # a restart changes nothing
await db_session.commit()
db_session.expire_all()
rows = dict((await db_session.execute(select(BudgetReservation.id, BudgetReservation.status))).all())
released = {status: rows[rid] == "released" for status, rid in reservation_ids.items()}
assert released == CASES
assert rows[other_id] == "active"