usage_repricing.py 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. """Reprice recorded spool usage at the spools' current prices (#3414).
  2. A usage row stores what its grams cost when the print completed. Recalculate
  3. Costs adds those up, so a spool price corrected afterwards changed nothing:
  4. the old price was read back from the rows. This puts the spool's current
  5. price on its rows first, and moves the cost of the run each row was charged
  6. in by the same difference, as statistics read a run's own cost.
  7. A row whose spool is gone keeps what it cost: there is no price left to apply.
  8. """
  9. from __future__ import annotations
  10. import logging
  11. from collections import defaultdict
  12. from dataclasses import dataclass, field
  13. from datetime import datetime, timedelta
  14. from sqlalchemy import Select, select
  15. from sqlalchemy.ext.asyncio import AsyncSession
  16. from backend.app.models.archive import PrintArchive
  17. from backend.app.models.print_log import PrintLogEntry
  18. from backend.app.models.spool import Spool
  19. from backend.app.models.spool_usage_history import SpoolUsageHistory
  20. logger = logging.getLogger(__name__)
  21. # A row is recorded as its run completes. Its run is the one whose start and
  22. # end (plus this much for the completion to be processed) hold the row's
  23. # time; a row that falls in none -- times that were stored in local time on
  24. # older installs -- moves no run rather than the wrong one.
  25. _COMPLETION_SLACK = timedelta(minutes=30)
  26. @dataclass
  27. class RepriceResult:
  28. repriced: int = 0
  29. # Spoolman rows left as they were because Spoolman could not be read.
  30. spoolman_unreachable: int = 0
  31. # Runs whose cost their own usage set: the archive's new cost is not
  32. # theirs to take (``mirror_archive_cost``).
  33. priced_runs: set[int] = field(default_factory=set)
  34. async def _spoolman_client(db: AsyncSession):
  35. """The Spoolman client, as usage reporting gets it, off this session's
  36. settings; None when it can't be reached."""
  37. from backend.app.api.routes.settings import get_setting
  38. from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client
  39. client = await get_spoolman_client()
  40. if client is None:
  41. url = await get_setting(db, "spoolman_url")
  42. if not url:
  43. return None
  44. try:
  45. client = await init_spoolman_client(url)
  46. except ValueError as exc:
  47. logger.warning("[COST] Spoolman URL rejected: %s", exc)
  48. return None
  49. if client is None or not await client.health_check():
  50. return None
  51. return client
  52. async def _spoolman_rates(db: AsyncSession, spool_ids: set[int]) -> dict[int, float | None] | None:
  53. """Per-gram price of each Spoolman spool still in Spoolman (None for an
  54. unpriced one); None when Spoolman can't be read."""
  55. from backend.app.services.spoolman_tracking import _spool_cost_per_gram
  56. client = await _spoolman_client(db)
  57. if client is None:
  58. return None
  59. try:
  60. spools = await client.get_all_spools(allow_archived=True)
  61. except Exception as exc: # noqa: BLE001 -- the rows are kept, the recalculation goes on
  62. logger.warning("[COST] Spoolman spools could not be read to reprice their usage: %s", exc)
  63. return None
  64. return {
  65. spool["id"]: _spool_cost_per_gram(spool)
  66. for spool in spools
  67. if isinstance(spool, dict) and spool.get("id") in spool_ids
  68. }
  69. def _run_of(row: SpoolUsageHistory, runs: list[PrintLogEntry]) -> PrintLogEntry | None:
  70. """The run the row was charged in, of the ``runs`` of its archive (or of
  71. no archive, the archive deleted): on its printer, and running when the
  72. row was recorded at its end. A run without an end time holds the rows
  73. until the next run on the printer starts."""
  74. if row.created_at is None:
  75. return None
  76. if row.printer_id is None and row.archive_id is None:
  77. return None
  78. candidates = sorted(
  79. (
  80. run
  81. for run in runs
  82. if run.started_at is not None and (row.printer_id is None or run.printer_id == row.printer_id)
  83. ),
  84. key=lambda run: run.started_at or datetime.min,
  85. )
  86. match = None
  87. for index, run in enumerate(candidates):
  88. started = run.started_at
  89. if started is None or started > row.created_at:
  90. break
  91. if run.completed_at is not None:
  92. end = run.completed_at + _COMPLETION_SLACK
  93. elif index + 1 < len(candidates):
  94. end = candidates[index + 1].started_at
  95. else:
  96. end = None
  97. match = run if end is None or row.created_at <= end else None
  98. return match
  99. def _in_run(cost: float | None, grams: float, spoolman: bool, default_cost_per_kg: float) -> float:
  100. """What a row's cost put into its run's cost. A Spoolman charge without a
  101. price was covered at the default rate (``spoolman_run_cost``, or the
  102. archive's figure); a built-in one without a price had none to add."""
  103. if cost is not None:
  104. return cost
  105. return round(grams / 1000.0 * default_cost_per_kg, 2) if spoolman else 0.0
  106. async def reprice_usage(
  107. db: AsyncSession,
  108. archive_ids: Select | None,
  109. *,
  110. default_cost_per_kg: float,
  111. spoolman_enabled: bool,
  112. ) -> RepriceResult:
  113. """Reprice the usage rows of the archives ``archive_ids`` selects (every
  114. row, those of no archive too, when it is None) and the runs they were
  115. charged in. Does not commit.
  116. A run is moved by the change of its own rows. The one exception is an
  117. archive whose rows all belong to its latest run, still at the archive's
  118. cost: that run is the archive's figure and follows the archive's new cost
  119. as a whole (``mirror_archive_cost``), default-rate grams included.
  120. """
  121. result = RepriceResult()
  122. query = select(SpoolUsageHistory)
  123. runs_query = select(PrintLogEntry)
  124. if archive_ids is not None:
  125. query = query.where(SpoolUsageHistory.archive_id.in_(archive_ids))
  126. runs_query = runs_query.where(PrintLogEntry.archive_id.in_(archive_ids))
  127. rows = list((await db.scalars(query)).all())
  128. if not rows:
  129. return result
  130. spool_ids = {row.spool_id for row in rows if row.spool_id is not None}
  131. spool_rates = dict(
  132. (await db.execute(select(Spool.id, Spool.cost_per_kg).where(Spool.id.in_(spool_ids)))).tuples().all()
  133. )
  134. spoolman_ids = {row.spoolman_spool_id for row in rows if row.spoolman_spool_id is not None}
  135. spoolman_rates = await _spoolman_rates(db, spoolman_ids) if spoolman_ids and spoolman_enabled else {}
  136. runs_by_archive: dict[int | None, list[PrintLogEntry]] = defaultdict(list)
  137. for run in (await db.scalars(runs_query)).all():
  138. runs_by_archive[run.archive_id].append(run)
  139. owner = {row.id: _run_of(row, runs_by_archive.get(row.archive_id, [])) for row in rows}
  140. archive_ids_seen = {row.archive_id for row in rows if row.archive_id is not None}
  141. archive_costs = dict(
  142. (await db.execute(select(PrintArchive.id, PrintArchive.cost).where(PrintArchive.id.in_(archive_ids_seen))))
  143. .tuples()
  144. .all()
  145. )
  146. follows_archive: set[int] = set()
  147. rows_by_archive: dict[int, list[SpoolUsageHistory]] = defaultdict(list)
  148. for row in rows:
  149. if row.archive_id is not None:
  150. rows_by_archive[row.archive_id].append(row)
  151. for archive_id, archive_rows in rows_by_archive.items():
  152. runs = runs_by_archive.get(archive_id, [])
  153. if not runs or archive_id not in archive_costs:
  154. continue
  155. latest = max(runs, key=lambda run: run.id)
  156. if (
  157. latest.status == "completed"
  158. and latest.cost in (None, archive_costs[archive_id])
  159. and all(owner[row.id] is latest for row in archive_rows)
  160. ):
  161. follows_archive.add(latest.id)
  162. for row in rows:
  163. grams = row.weight_used or 0.0
  164. run = owner[row.id]
  165. if run is not None and run.id not in follows_archive:
  166. result.priced_runs.add(run.id)
  167. if row.spool_id is not None:
  168. if row.spool_id not in spool_rates:
  169. continue # the spool was deleted
  170. cost_per_kg = spool_rates[row.spool_id]
  171. if cost_per_kg is None:
  172. cost_per_kg = default_cost_per_kg
  173. # As the usage tracker prices it at completion.
  174. new_cost = round(grams / 1000.0 * cost_per_kg, 2) if cost_per_kg > 0 else None
  175. elif row.spoolman_spool_id is not None:
  176. if spoolman_rates is None:
  177. result.spoolman_unreachable += 1
  178. continue
  179. if row.spoolman_spool_id not in spoolman_rates:
  180. continue # Spoolman is off, or the spool is gone from it
  181. rate = spoolman_rates[row.spoolman_spool_id]
  182. # Unpriced, as completion records it: the archive covers those
  183. # grams at the default rate.
  184. new_cost = round(grams * rate, 2) if rate is not None else None
  185. else:
  186. continue # the spool was deleted
  187. if new_cost == row.cost:
  188. continue
  189. spoolman = row.spool_id is None
  190. delta = _in_run(new_cost, grams, spoolman, default_cost_per_kg) - _in_run(
  191. row.cost, grams, spoolman, default_cost_per_kg
  192. )
  193. row.cost = new_cost
  194. result.repriced += 1
  195. if run is not None and run.id not in follows_archive and delta:
  196. run.cost = round(max(0.0, (run.cost or 0.0) + delta), 2)
  197. if spoolman_rates is None and result.spoolman_unreachable:
  198. logger.warning(
  199. "[COST] Spoolman not reachable: %d usage record(s) kept their price", result.spoolman_unreachable
  200. )
  201. return result