test_recalculate_reprices_usage_3414.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. """Recalculate Costs prices recorded usage at the spools' current prices (#3414).
  2. A usage row stores what its grams cost at completion, and Recalculate added
  3. those up, so a spool price corrected afterwards changed nothing: "0 archives
  4. were updated". The rows now take the spool's current price first, and the run
  5. each row was charged in moves by the same difference, as statistics read it.
  6. """
  7. from datetime import datetime, timedelta
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from httpx import AsyncClient
  11. from sqlalchemy import select
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.print_log import PrintLogEntry
  14. from backend.app.models.settings import Settings
  15. from backend.app.models.spool import Spool
  16. from backend.app.models.spool_usage_history import SpoolUsageHistory
  17. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  18. START = datetime(2026, 10, 28, 10, 0, 0)
  19. async def _spool(db, cost_per_kg):
  20. spool = Spool(material="PLA", label_weight=1000, core_weight=250, weight_used=0, cost_per_kg=cost_per_kg)
  21. db.add(spool)
  22. await db.commit()
  23. await db.refresh(spool)
  24. return spool
  25. async def _print(archive_factory, printer, *, grams, cost, started=START):
  26. return await archive_factory(
  27. printer.id,
  28. status="completed",
  29. cost=cost,
  30. filament_used_grams=grams,
  31. started_at=started,
  32. completed_at=started + timedelta(hours=1),
  33. )
  34. def _usage(archive, printer, *, grams, cost, spool_id=None, spoolman_spool_id=None, at=START, status="completed"):
  35. return SpoolUsageHistory(
  36. spool_id=spool_id,
  37. spoolman_spool_id=spoolman_spool_id,
  38. printer_id=printer.id,
  39. archive_id=archive.id if archive is not None else None,
  40. weight_used=grams,
  41. percent_used=5,
  42. status=status,
  43. cost=cost,
  44. created_at=at + timedelta(hours=1, seconds=5),
  45. )
  46. async def _recalculate(client: AsyncClient, **kwargs):
  47. response = await client.post("/api/v1/archives/recalculate-costs", **kwargs)
  48. assert response.status_code == 200, response.text
  49. return response.json()
  50. async def _fresh(db, model, row_id):
  51. return (
  52. await db.execute(select(model).where(model.id == row_id).execution_options(populate_existing=True))
  53. ).scalar_one()
  54. async def _runs(db, archive_id):
  55. query = select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).order_by(PrintLogEntry.id)
  56. return list((await db.execute(query.execution_options(populate_existing=True))).scalars().all())
  57. async def test_a_spools_new_price_reaches_its_usage_the_archive_and_the_run(
  58. async_client: AsyncClient, db_session, printer_factory, archive_factory
  59. ):
  60. """The reported case: a print off a priced spool, the spool's price raised
  61. tenfold afterwards."""
  62. db_session.add(Settings(key="default_filament_cost", value="1400"))
  63. printer = await printer_factory()
  64. spool = await _spool(db_session, 1400.0)
  65. archive = await _print(archive_factory, printer, grams=45.6, cost=63.84)
  66. usage = _usage(archive, printer, grams=45.6, cost=63.84, spool_id=spool.id)
  67. db_session.add(usage)
  68. spool.cost_per_kg = 14000.0
  69. await db_session.commit()
  70. body = await _recalculate(async_client)
  71. assert body["updated"] == 1
  72. assert body["usage_repriced"] == 1
  73. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 638.4
  74. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 638.4
  75. assert [run.cost for run in await _runs(db_session, archive.id)] == [638.4]
  76. async def test_an_unchanged_price_changes_nothing_and_a_second_run_neither(
  77. async_client: AsyncClient, db_session, printer_factory, archive_factory
  78. ):
  79. db_session.add(Settings(key="default_filament_cost", value="1400"))
  80. printer = await printer_factory()
  81. spool = await _spool(db_session, 1400.0)
  82. archive = await _print(archive_factory, printer, grams=45.6, cost=63.84)
  83. usage = _usage(archive, printer, grams=45.6, cost=63.84, spool_id=spool.id)
  84. db_session.add(usage)
  85. await db_session.commit()
  86. first = await _recalculate(async_client)
  87. second = await _recalculate(async_client)
  88. assert (first["usage_repriced"], first["updated"]) == (0, 0)
  89. assert (second["usage_repriced"], second["updated"]) == (0, 0)
  90. assert [run.cost for run in await _runs(db_session, archive.id)] == [63.84]
  91. async def test_a_small_prints_price_change_of_a_cent_is_taken(
  92. async_client: AsyncClient, db_session, printer_factory, archive_factory
  93. ):
  94. db_session.add(Settings(key="default_filament_cost", value="20"))
  95. printer = await printer_factory()
  96. spool = await _spool(db_session, 20.0)
  97. archive = await _print(archive_factory, printer, grams=5.0, cost=0.1)
  98. usage = _usage(archive, printer, grams=5.0, cost=0.1, spool_id=spool.id)
  99. db_session.add(usage)
  100. spool.cost_per_kg = 22.0
  101. await db_session.commit()
  102. await _recalculate(async_client)
  103. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 0.11
  104. assert [run.cost for run in await _runs(db_session, archive.id)] == [0.11]
  105. async def test_a_reprint_off_an_unchanged_spool_keeps_its_cost(
  106. async_client: AsyncClient, db_session, printer_factory, archive_factory
  107. ):
  108. """The archive's new cost averages both prints; the reprint's own spool
  109. priced it and did not change."""
  110. db_session.add(Settings(key="default_filament_cost", value="20"))
  111. printer = await printer_factory()
  112. spool_a = await _spool(db_session, 20.0)
  113. spool_b = await _spool(db_session, 20.0)
  114. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  115. later = START + timedelta(days=1)
  116. db_session.add(
  117. PrintLogEntry(
  118. archive_id=archive.id,
  119. printer_id=printer.id,
  120. status="completed",
  121. started_at=later,
  122. completed_at=later + timedelta(hours=1),
  123. filament_used_grams=100.0,
  124. cost=2.0,
  125. )
  126. )
  127. db_session.add(_usage(archive, printer, grams=100.0, cost=2.0, spool_id=spool_a.id))
  128. db_session.add(_usage(archive, printer, grams=100.0, cost=2.0, spool_id=spool_b.id, at=later))
  129. spool_a.cost_per_kg = 40.0
  130. await db_session.commit()
  131. await _recalculate(async_client)
  132. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
  133. assert [run.cost for run in await _runs(db_session, archive.id)] == [4.0, 2.0]
  134. async def test_a_spoolman_print_takes_a_new_default_and_spool_price_together(
  135. async_client: AsyncClient, db_session, printer_factory, archive_factory
  136. ):
  137. """80 g off the spool, 20 g unpriced at the default: completion gave the
  138. run the archive's whole figure, and it follows the archive's new one."""
  139. db_session.add(Settings(key="spoolman_enabled", value="true"))
  140. db_session.add(Settings(key="default_filament_cost", value="50"))
  141. printer = await printer_factory()
  142. archive = await _print(archive_factory, printer, grams=100.0, cost=2.1)
  143. usage = _usage(archive, printer, grams=80.0, cost=1.6, spoolman_spool_id=41)
  144. db_session.add(usage)
  145. await db_session.commit()
  146. with _spoolman([{"id": 41, "price": 30.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
  147. await _recalculate(async_client)
  148. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.4
  149. assert [run.cost for run in await _runs(db_session, archive.id)] == [3.4]
  150. async def test_spoolman_spool_given_a_price_reaches_every_run(
  151. async_client: AsyncClient, db_session, printer_factory, archive_factory
  152. ):
  153. """Unpriced, completion covered each run's grams at the default rate."""
  154. db_session.add(Settings(key="spoolman_enabled", value="true"))
  155. db_session.add(Settings(key="default_filament_cost", value="25"))
  156. printer = await printer_factory()
  157. archive = await _print(archive_factory, printer, grams=100.0, cost=2.5)
  158. later = START + timedelta(days=1)
  159. db_session.add(
  160. PrintLogEntry(
  161. archive_id=archive.id,
  162. printer_id=printer.id,
  163. status="failed",
  164. started_at=later,
  165. completed_at=later + timedelta(hours=1),
  166. cost=1.0,
  167. )
  168. )
  169. db_session.add(_usage(archive, printer, grams=100.0, cost=None, spoolman_spool_id=41))
  170. db_session.add(_usage(archive, printer, grams=40.0, cost=None, spoolman_spool_id=41, at=later, status="failed"))
  171. await db_session.commit()
  172. with _spoolman([{"id": 41, "price": 50.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
  173. await _recalculate(async_client)
  174. assert [run.cost for run in await _runs(db_session, archive.id)] == [5.0, 2.0]
  175. async def test_usage_recorded_outside_any_run_moves_none(
  176. async_client: AsyncClient, db_session, printer_factory, archive_factory
  177. ):
  178. """A row stamped two hours late (local time on an older install) must not
  179. move the reprint that started after the print it belongs to."""
  180. db_session.add(Settings(key="default_filament_cost", value="20"))
  181. printer = await printer_factory()
  182. spool = await _spool(db_session, 20.0)
  183. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  184. later = START + timedelta(hours=1, minutes=10)
  185. db_session.add(
  186. PrintLogEntry(
  187. archive_id=archive.id,
  188. printer_id=printer.id,
  189. status="completed",
  190. started_at=later,
  191. completed_at=later + timedelta(minutes=30),
  192. cost=1.9,
  193. )
  194. )
  195. usage = _usage(archive, printer, grams=100.0, cost=2.0, spool_id=spool.id)
  196. usage.created_at = START + timedelta(hours=3)
  197. db_session.add(usage)
  198. spool.cost_per_kg = 40.0
  199. await db_session.commit()
  200. await _recalculate(async_client)
  201. assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0, 1.9]
  202. async def test_a_run_without_an_end_holds_usage_until_the_next_run(
  203. async_client: AsyncClient, db_session, printer_factory, archive_factory
  204. ):
  205. db_session.add(Settings(key="default_filament_cost", value="20"))
  206. printer = await printer_factory()
  207. spool = await _spool(db_session, 20.0)
  208. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  209. (first,) = await _runs(db_session, archive.id)
  210. first.completed_at = None
  211. first.cost = 1.0
  212. later = START + timedelta(hours=2)
  213. db_session.add(
  214. PrintLogEntry(
  215. archive_id=archive.id,
  216. printer_id=printer.id,
  217. status="completed",
  218. started_at=later,
  219. completed_at=later + timedelta(hours=1),
  220. cost=1.9,
  221. )
  222. )
  223. db_session.add(_usage(archive, printer, grams=50.0, cost=1.0, spool_id=spool.id))
  224. spool.cost_per_kg = 40.0
  225. await db_session.commit()
  226. await _recalculate(async_client)
  227. assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0, 1.9]
  228. async def test_the_run_of_a_deleted_archive_follows_its_usage(async_client: AsyncClient, db_session, printer_factory):
  229. """A printer deleted with its archives leaves both without an archive."""
  230. db_session.add(Settings(key="default_filament_cost", value="20"))
  231. printer = await printer_factory()
  232. spool = await _spool(db_session, 20.0)
  233. run = PrintLogEntry(
  234. printer_id=printer.id, status="completed", started_at=START, completed_at=START + timedelta(hours=1), cost=2.0
  235. )
  236. db_session.add(run)
  237. db_session.add(_usage(None, printer, grams=100.0, cost=2.0, spool_id=spool.id))
  238. spool.cost_per_kg = 40.0
  239. await db_session.commit()
  240. await _recalculate(async_client)
  241. assert (await _fresh(db_session, PrintLogEntry, run.id)).cost == 4.0
  242. async def test_a_spool_without_a_price_follows_the_default_cost(
  243. async_client: AsyncClient, db_session, printer_factory, archive_factory
  244. ):
  245. db_session.add(Settings(key="default_filament_cost", value="50"))
  246. printer = await printer_factory()
  247. spool = await _spool(db_session, None)
  248. archive = await _print(archive_factory, printer, grams=100.0, cost=2.5)
  249. usage = _usage(archive, printer, grams=100.0, cost=2.5, spool_id=spool.id)
  250. db_session.add(usage)
  251. await db_session.commit()
  252. await _recalculate(async_client)
  253. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 5.0
  254. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 5.0
  255. assert [run.cost for run in await _runs(db_session, archive.id)] == [5.0]
  256. async def test_a_deleted_spools_usage_keeps_what_it_cost(
  257. async_client: AsyncClient, db_session, printer_factory, archive_factory
  258. ):
  259. db_session.add(Settings(key="default_filament_cost", value="50"))
  260. printer = await printer_factory()
  261. archive = await _print(archive_factory, printer, grams=100.0, cost=3.0)
  262. usage = _usage(archive, printer, grams=100.0, cost=3.0)
  263. db_session.add(usage)
  264. await db_session.commit()
  265. await _recalculate(async_client)
  266. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 3.0
  267. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
  268. async def test_a_deleted_spool_still_named_by_its_usage_keeps_what_it_cost(
  269. async_client: AsyncClient, db_session, printer_factory, archive_factory
  270. ):
  271. """Usage left pointing at a spool id that no longer exists, which only
  272. SQLite allows: PostgreSQL's foreign key clears it with the spool."""
  273. if db_session.bind.dialect.name != "sqlite":
  274. pytest.skip("PostgreSQL refuses a usage row of a missing spool")
  275. db_session.add(Settings(key="default_filament_cost", value="50"))
  276. printer = await printer_factory()
  277. archive = await _print(archive_factory, printer, grams=100.0, cost=3.0)
  278. usage = _usage(archive, printer, grams=100.0, cost=3.0, spool_id=987654)
  279. db_session.add(usage)
  280. await db_session.commit()
  281. await _recalculate(async_client)
  282. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 3.0
  283. async def test_a_run_its_usage_moved_is_not_given_the_archives_cost(
  284. async_client: AsyncClient, db_session, printer_factory, archive_factory
  285. ):
  286. """The run had only its spool's 50 g (1.00), the archive 50 g more at the
  287. default (2.00). Repriced, the run is 2.00 -- the archive's old figure, which
  288. must not hand it the archive's new 3.00."""
  289. db_session.add(Settings(key="default_filament_cost", value="20"))
  290. printer = await printer_factory()
  291. spool = await _spool(db_session, 20.0)
  292. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  293. (run,) = await _runs(db_session, archive.id)
  294. run.cost = 1.0
  295. db_session.add(_usage(archive, printer, grams=50.0, cost=1.0, spool_id=spool.id))
  296. spool.cost_per_kg = 40.0
  297. await db_session.commit()
  298. await _recalculate(async_client)
  299. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
  300. assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0]
  301. async def test_each_run_moves_by_its_own_usage(async_client: AsyncClient, db_session, printer_factory, archive_factory):
  302. """A reprint that failed halfway: each run gets its own row's difference,
  303. the default-rate grams of the full run stay as they were."""
  304. db_session.add(Settings(key="default_filament_cost", value="20"))
  305. printer = await printer_factory()
  306. spool = await _spool(db_session, 20.0)
  307. # 100 g: 80 g off the spool at 20/kg (1.60) + 20 g at the default (0.40).
  308. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  309. later = START + timedelta(days=2)
  310. db_session.add(
  311. PrintLogEntry(
  312. archive_id=archive.id,
  313. printer_id=printer.id,
  314. status="failed",
  315. started_at=later,
  316. completed_at=later + timedelta(hours=1),
  317. cost=0.8,
  318. )
  319. )
  320. first = _usage(archive, printer, grams=80.0, cost=1.6, spool_id=spool.id)
  321. second = _usage(archive, printer, grams=40.0, cost=0.8, spool_id=spool.id, at=later, status="failed")
  322. db_session.add_all([first, second])
  323. spool.cost_per_kg = 40.0
  324. await db_session.commit()
  325. await _recalculate(async_client)
  326. assert [run.cost for run in await _runs(db_session, archive.id)] == [3.6, 1.6]
  327. assert (await _fresh(db_session, SpoolUsageHistory, first.id)).cost == 3.2
  328. assert (await _fresh(db_session, SpoolUsageHistory, second.id)).cost == 1.6
  329. async def test_a_run_on_another_printer_is_not_charged_this_ones_usage(
  330. async_client: AsyncClient, db_session, printer_factory, archive_factory
  331. ):
  332. db_session.add(Settings(key="default_filament_cost", value="20"))
  333. here = await printer_factory(name="Here")
  334. there = await printer_factory(name="There")
  335. spool = await _spool(db_session, 20.0)
  336. archive = await _print(archive_factory, here, grams=100.0, cost=2.0)
  337. db_session.add(
  338. PrintLogEntry(
  339. archive_id=archive.id,
  340. printer_id=there.id,
  341. status="completed",
  342. # Started after this printer's run, before its usage was recorded.
  343. started_at=START + timedelta(minutes=1),
  344. cost=1.9,
  345. )
  346. )
  347. db_session.add(_usage(archive, here, grams=100.0, cost=2.0, spool_id=spool.id))
  348. spool.cost_per_kg = 30.0
  349. await db_session.commit()
  350. await _recalculate(async_client)
  351. assert sorted((run.printer_id, run.cost) for run in await _runs(db_session, archive.id)) == sorted(
  352. [(here.id, 3.0), (there.id, 1.9)]
  353. )
  354. def _spoolman(spools):
  355. client = MagicMock()
  356. client.get_all_spools = AsyncMock(return_value=spools)
  357. return patch("backend.app.services.usage_repricing._spoolman_client", AsyncMock(return_value=client))
  358. async def test_a_spoolman_spools_new_price_reaches_its_usage(
  359. async_client: AsyncClient, db_session, printer_factory, archive_factory
  360. ):
  361. """Feature parity: a Spoolman charge is repriced from the spool's price in
  362. Spoolman, as a built-in one from the inventory's."""
  363. db_session.add(Settings(key="spoolman_enabled", value="true"))
  364. db_session.add(Settings(key="default_filament_cost", value="25"))
  365. printer = await printer_factory()
  366. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  367. usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
  368. db_session.add(usage)
  369. await db_session.commit()
  370. with _spoolman([{"id": 41, "price": 30.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
  371. body = await _recalculate(async_client)
  372. assert body["usage_repriced"] == 1
  373. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 3.0
  374. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 3.0
  375. assert [run.cost for run in await _runs(db_session, archive.id)] == [3.0]
  376. async def test_unreachable_spoolman_keeps_its_usage_and_says_so(
  377. async_client: AsyncClient, db_session, printer_factory, archive_factory
  378. ):
  379. db_session.add(Settings(key="spoolman_enabled", value="true"))
  380. printer = await printer_factory()
  381. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  382. usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
  383. db_session.add(usage)
  384. await db_session.commit()
  385. with patch("backend.app.services.usage_repricing._spoolman_client", AsyncMock(return_value=None)):
  386. body = await _recalculate(async_client)
  387. assert body["spoolman_unreachable"] == 1
  388. assert "Spoolman not reachable" in body["message"]
  389. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 2.0
  390. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 2.0
  391. async def test_a_spool_gone_from_spoolman_keeps_its_usage(
  392. async_client: AsyncClient, db_session, printer_factory, archive_factory
  393. ):
  394. db_session.add(Settings(key="spoolman_enabled", value="true"))
  395. printer = await printer_factory()
  396. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  397. usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
  398. db_session.add(usage)
  399. await db_session.commit()
  400. with _spoolman([{"id": 7, "price": 30.0, "initial_weight": 1000}]):
  401. body = await _recalculate(async_client)
  402. assert body["spoolman_unreachable"] == 0
  403. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 2.0
  404. async def test_a_caller_limited_to_some_printers_reprices_only_their_usage(
  405. async_client: AsyncClient, db_session, printer_factory, archive_factory
  406. ):
  407. from backend.tests.integration.test_bulk_archive_routes_scope import _member
  408. db_session.add(Settings(key="default_filament_cost", value="20"))
  409. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  410. spool = await _spool(db_session, 20.0)
  411. on_a = await _print(archive_factory, a, grams=100.0, cost=2.0)
  412. on_b = await _print(archive_factory, b, grams=100.0, cost=2.0)
  413. usage_a = _usage(on_a, a, grams=100.0, cost=2.0, spool_id=spool.id)
  414. usage_b = _usage(on_b, b, grams=100.0, cost=2.0, spool_id=spool.id)
  415. unlinked = _usage(None, b, grams=100.0, cost=2.0, spool_id=spool.id)
  416. db_session.add_all([usage_a, usage_b, unlinked])
  417. spool.cost_per_kg = 30.0
  418. await db_session.commit()
  419. member = await _member(async_client, [a.id])
  420. await _recalculate(async_client, headers=member)
  421. assert (await _fresh(db_session, SpoolUsageHistory, usage_a.id)).cost == 3.0
  422. assert (await _fresh(db_session, SpoolUsageHistory, usage_b.id)).cost == 2.0
  423. assert (await _fresh(db_session, SpoolUsageHistory, unlinked.id)).cost == 2.0
  424. async def test_usage_of_no_archive_is_repriced_for_an_unlimited_caller(
  425. async_client: AsyncClient, db_session, printer_factory
  426. ):
  427. """It shows on the spool's usage list."""
  428. db_session.add(Settings(key="default_filament_cost", value="20"))
  429. printer = await printer_factory()
  430. spool = await _spool(db_session, 20.0)
  431. unlinked = _usage(None, printer, grams=100.0, cost=2.0, spool_id=spool.id)
  432. db_session.add(unlinked)
  433. spool.cost_per_kg = 30.0
  434. await db_session.commit()
  435. await _recalculate(async_client)
  436. assert (await _fresh(db_session, SpoolUsageHistory, unlinked.id)).cost == 3.0
  437. async def test_a_spoolman_spool_given_a_price_prices_its_print(
  438. async_client: AsyncClient, db_session, printer_factory, archive_factory
  439. ):
  440. """Unpriced at completion, the print took the archive's estimate; with the
  441. spool priced now, the run follows the archive's new cost."""
  442. db_session.add(Settings(key="spoolman_enabled", value="true"))
  443. db_session.add(Settings(key="default_filament_cost", value="25"))
  444. printer = await printer_factory()
  445. archive = await _print(archive_factory, printer, grams=100.0, cost=3.0)
  446. usage = _usage(archive, printer, grams=100.0, cost=None, spoolman_spool_id=41)
  447. db_session.add(usage)
  448. await db_session.commit()
  449. with _spoolman([{"id": 41, "price": 40.0, "initial_weight": 1000, "filament": {"weight": 1000}}]):
  450. await _recalculate(async_client)
  451. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost == 4.0
  452. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 4.0
  453. assert [run.cost for run in await _runs(db_session, archive.id)] == [4.0]
  454. async def test_a_spoolman_spool_losing_its_price_keeps_its_print_and_run_together(
  455. async_client: AsyncClient, db_session, printer_factory, archive_factory
  456. ):
  457. """The archive keeps a Spoolman-priced cost it can't rebuild (#2591); its
  458. run must not be moved away from it."""
  459. db_session.add(Settings(key="spoolman_enabled", value="true"))
  460. db_session.add(Settings(key="default_filament_cost", value="25"))
  461. printer = await printer_factory()
  462. archive = await _print(archive_factory, printer, grams=100.0, cost=2.0)
  463. usage = _usage(archive, printer, grams=100.0, cost=2.0, spoolman_spool_id=41)
  464. db_session.add(usage)
  465. await db_session.commit()
  466. with _spoolman([{"id": 41, "initial_weight": 1000, "filament": {"weight": 1000}}]):
  467. await _recalculate(async_client)
  468. assert (await _fresh(db_session, SpoolUsageHistory, usage.id)).cost is None
  469. assert (await _fresh(db_session, PrintArchive, archive.id)).cost == 2.0
  470. assert [run.cost for run in await _runs(db_session, archive.id)] == [2.0]