runningtab 0.1.0.dev3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. runningtab/__init__.py +3 -0
  2. runningtab/access.py +419 -0
  3. runningtab/books.py +225 -0
  4. runningtab/chart.py +408 -0
  5. runningtab/cli.py +2216 -0
  6. runningtab/coa.py +212 -0
  7. runningtab/doc.py +185 -0
  8. runningtab/forecast/__init__.py +1 -0
  9. runningtab/forecast/compute.py +224 -0
  10. runningtab/forecast/doctype.py +228 -0
  11. runningtab/forecast/model.py +251 -0
  12. runningtab/forecast/names.py +8 -0
  13. runningtab/forecast/sources.py +85 -0
  14. runningtab/gl_codes.py +50 -0
  15. runningtab/health.py +661 -0
  16. runningtab/mercury/__init__.py +7 -0
  17. runningtab/mercury/categories.py +500 -0
  18. runningtab/mercury/classify.py +101 -0
  19. runningtab/mercury/client.py +360 -0
  20. runningtab/mercury/ingest.py +733 -0
  21. runningtab/mercury/mapping.py +130 -0
  22. runningtab/mercury/models.py +544 -0
  23. runningtab/mercury/posting.py +757 -0
  24. runningtab/mercury/receipts.py +304 -0
  25. runningtab/mercury/session.py +65 -0
  26. runningtab/mercury/state.py +285 -0
  27. runningtab/mercury/vouchers.py +951 -0
  28. runningtab/period/__init__.py +9 -0
  29. runningtab/period/balances.py +283 -0
  30. runningtab/period/bank.py +154 -0
  31. runningtab/period/close.py +510 -0
  32. runningtab/period/crosscheck.py +257 -0
  33. runningtab/period/csvfmt.py +86 -0
  34. runningtab/period/documents.py +260 -0
  35. runningtab/period/fence.py +277 -0
  36. runningtab/period/ledger.py +205 -0
  37. runningtab/period/lock.py +294 -0
  38. runningtab/period/manifest.py +259 -0
  39. runningtab/period/open_items.py +84 -0
  40. runningtab/period/package.py +140 -0
  41. runningtab/period/qbo.py +521 -0
  42. runningtab/period/qbo_map.py +174 -0
  43. runningtab/period/quarter.py +90 -0
  44. runningtab/period/readiness.py +146 -0
  45. runningtab/period/record.py +247 -0
  46. runningtab/period/status.py +312 -0
  47. runningtab/site.py +409 -0
  48. runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
  49. runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
  50. runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
  51. runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
  52. runningtab-0.1.0.dev3.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,510 @@
1
+ """`close_quarter`: the close ceremony's exact order, driven through injected callables.
2
+
3
+ The close runs in six stages: preconditions, readiness,
4
+ build-and-cross-check, lock, rebuild-and-compare, store. Every stage but the
5
+ last two guards the next: a failure in an earlier stage means nothing later
6
+ ever runs, nothing changes anywhere, and the refusal names every offending
7
+ item in that stage at once, not just the first.
8
+
9
+ `CloseSteps` is that ceremony's whole outside world -- ERPNext, the record
10
+ bucket, the clock -- as one dataclass of callables, so `close_quarter` runs
11
+ against fakes with no site and no bucket. The brief names eight of its
12
+ fields verbatim (`read_lock`, `read_blockers`, `build`, `crosscheck`,
13
+ `set_closed_through`, `stored_packages`, `upload`, `today`) and leaves each
14
+ one's exact parameters to be chosen here; this module also adds a ninth
15
+ and a tenth, `lock_history` and `locked`, and explains why below.
16
+
17
+ Two fields' shapes need a word up front:
18
+
19
+ - `build` returns `Built`, not a bare `Package`: `crosscheck` has to compare
20
+ against ERPNext's own Trial Balance and AR/AP reports account-by-account
21
+ and party-by-party, which needs the `TrialBalance` and `OpenItem` lists
22
+ `build_package` computes on its way to `Package` -- and `Package` itself
23
+ (`runningtab.period.package.Package`) carries only the rendered files, bank
24
+ reconciliation and split-voucher list, on purpose: plan 1 already shipped
25
+ that shape and this task does not reopen it. `Built` is the richer bundle
26
+ `build` hands back instead, carrying a `ManifestFacts` alongside so the
27
+ Store stage never has to re-derive company/site/chart/qmap facts that
28
+ `build` already had to hand.
29
+ - `upload` bundles "pick the next storage prefix," "render the final
30
+ manifest naming that prefix's revision," and "write it" into one call,
31
+ rather than three separate `CloseSteps` fields. `next_prefix` (Task 5,
32
+ `runningtab.period.record`) must probe the real bucket's object listing, not
33
+ the `stored_packages` snapshot this module already holds from the
34
+ precondition stage, to land on a free revision even after a half-failed
35
+ prior upload (see `next_prefix`'s own docstring) -- so only the "upload"
36
+ step's own implementation, which alone touches the real `Record`, can
37
+ correctly choose the revision. But `MANIFEST.md`'s `revision` field must
38
+ name that exact same number, so `close_quarter` cannot render the
39
+ manifest before the revision is chosen. `upload` resolves that by taking
40
+ a `build_manifest(revision) -> bytes` callback: its real implementation
41
+ calls `next_prefix`, invokes the callback with the revision it picked,
42
+ assembles `SHA256SUMS` and the manifest around the content files, calls
43
+ `upload_package`, and returns the prefix used -- one code path decides the
44
+ revision and feeds it straight to the manifest, so the two can never
45
+ disagree.
46
+
47
+ Why `lock_history` (a ninth field, not in the brief's literal list): in the
48
+ recovery case -- `closed_through` already equals `quarter.end` -- step 4
49
+ must not call `set_closed_through` a second time (its own Version audit
50
+ trail would otherwise carry a redundant no-op edit). But `CloseOutcome.lock`
51
+ is a `LockChange`, not an optional one, in every case including recovery.
52
+ `read_lock` (Task 4) returns only a `LockState` -- closed_through and
53
+ bypass_role, no version/who/when -- so it cannot supply one. `lock_history`
54
+ (already written in `runningtab.period.lock` for exactly this "who moved the
55
+ lock and when" question) is the one honest source for the `LockChange` that
56
+ already put `quarter.end` in place; fabricating one from `LockState` alone
57
+ would be a confident wrong answer about who closed the quarter and when.
58
+
59
+ Why `locked` (a tenth field): once stage 4 has run, the quarter is frozen
60
+ in ERPNext whatever happens next, and the caller must be able to say so if
61
+ stage 5 or 6 fails -- on a fresh lock and on recovery alike, where this run
62
+ moved nothing. `close_quarter` calls `locked` with a `Held` -- the
63
+ `LockChange` that froze the quarter, and whether the quarter already had a
64
+ stored package -- before anything after the lock runs. The second half
65
+ decides the next step: a first close's store can simply be retried, but a
66
+ re-close of a stored quarter cannot (the re-run is refused as "already has a
67
+ stored package"), so the person reopens it and closes it again.
68
+
69
+ The spec's stage 5 says a rebuild mismatch "reports the GL entries that
70
+ differ". `HeldForReview` names every differing file, and for
71
+ `general-ledger.csv` the vouchers (type and name) whose rows differ, read
72
+ straight from that file's own `voucher_type`/`voucher_no` columns. A change
73
+ that reaches only other files -- a backdated entry moving an opening
74
+ balance in `trial-balance.csv`, say -- is named by file alone, since no
75
+ row in this quarter's general ledger carries it.
76
+ """
77
+
78
+ from __future__ import annotations
79
+
80
+ import csv
81
+ import dataclasses
82
+ import datetime as dt
83
+ import io
84
+ from collections.abc import Callable, Iterable
85
+ from typing import NoReturn
86
+
87
+ from runningtab.period import PeriodError
88
+ from runningtab.period.balances import TrialBalance
89
+ from runningtab.period.csvfmt import money
90
+ from runningtab.period.lock import LockChange, LockState
91
+ from runningtab.period.manifest import ManifestFacts, render_manifest
92
+ from runningtab.period.open_items import OpenItem
93
+ from runningtab.period.package import Package
94
+ from runningtab.period.quarter import FiscalYear, Quarter
95
+ from runningtab.period.readiness import Blocker
96
+ from runningtab.period.record import StoredPackage
97
+
98
+
99
+ @dataclasses.dataclass(frozen=True)
100
+ class Built:
101
+ """One `build` call's output: the rendered `Package` plus what `crosscheck` and the manifest need.
102
+
103
+ `manifest_facts` is the build-time `ManifestFacts` -- quarter, fiscal
104
+ year, company, site, chart, qmap, bank, split vouchers, and so on --
105
+ with `closed_by`, `lock_version` and `revision` still at their defaults;
106
+ the Store stage fills those three in with `dataclasses.replace` once the
107
+ lock and the chosen revision are known, rather than `close_quarter`
108
+ re-deriving the rest from scratch.
109
+ """
110
+
111
+ package: Package
112
+ trial_balance: TrialBalance
113
+ receivables: tuple[OpenItem, ...]
114
+ payables: tuple[OpenItem, ...]
115
+ manifest_facts: ManifestFacts
116
+
117
+
118
+ @dataclasses.dataclass(frozen=True)
119
+ class CloseSteps:
120
+ """The close ceremony's outside world, as callables, so `close_quarter` runs with no site and no bucket.
121
+
122
+ Every callable here is a zero-or-few-argument closure the CLI builds
123
+ already scoped to one company, one quarter and one bucket -- the same
124
+ style Task 4's `set_closed_through(admin, company, date)` uses, closed
125
+ over `admin`/`company` by the CLI and given only the date each call.
126
+ `crosscheck` and `upload`'s exact parameters are chosen here (see the
127
+ module docstring); the rest take nothing and return the one fact their
128
+ name promises.
129
+ """
130
+
131
+ read_lock: Callable[[], LockState]
132
+ read_blockers: Callable[[], list[Blocker]]
133
+ build: Callable[[], Built]
134
+ crosscheck: Callable[
135
+ [Package, TrialBalance, tuple[OpenItem, ...], tuple[OpenItem, ...], Quarter, FiscalYear], list[str]
136
+ ]
137
+ set_closed_through: Callable[[dt.date], LockChange]
138
+ lock_history: Callable[[], list[LockChange]]
139
+ locked: Callable[[Held], None]
140
+ stored_packages: Callable[[], list[StoredPackage]]
141
+ upload: Callable[[dict[str, bytes], Callable[[int], bytes]], str]
142
+ today: Callable[[], dt.date]
143
+
144
+
145
+ @dataclasses.dataclass(frozen=True)
146
+ class Held:
147
+ """The quarter is now locked: the change that locked it, whether this is a re-close of a stored quarter,
148
+ and the stored quarters after it, which a reopen of this one leaves to be re-closed in order."""
149
+
150
+ change: LockChange
151
+ reclosing: bool
152
+ later: tuple[Quarter, ...] = ()
153
+
154
+
155
+ @dataclasses.dataclass(frozen=True)
156
+ class CloseOutcome:
157
+ """What a successful close produced: where the package landed, the lock change that closed it, the package itself."""
158
+
159
+ prefix: str
160
+ lock: LockChange
161
+ package: Package
162
+
163
+
164
+ class HeldForReview(PeriodError):
165
+ """The quarter stays locked through the close date with no stored package, because the ledger moved.
166
+
167
+ Raised when the rebuild-and-compare stage (5) finds the ledger changed
168
+ between the check and the lock. The lock is not rolled back (there is
169
+ no rollback). On a first close, re-running `close` for the quarter is
170
+ the recovery: it lands in the recovery branch, re-runs readiness and the
171
+ cross-check against the ledger as it now is (locked, so no longer
172
+ moving), rebuilds, and stores that. On a re-close of a stored quarter a
173
+ re-run is refused as "already has a stored package", so the recovery is
174
+ to reopen the quarter and close it again. If what posted does not belong
175
+ in the quarter, the person reopens it either way. The message says which,
176
+ so a caller adds no retry advice of its own beside it.
177
+ """
178
+
179
+
180
+ def reclose_order(closed_through: dt.date | None, stored: Iterable[Quarter]) -> list[Quarter]:
181
+ """Every stored quarter Books Closed Through no longer covers, oldest first: the order to re-close them in.
182
+
183
+ Quarters close in order, so a reopened quarter is re-closed before any
184
+ stored quarter after it -- each of those re-closes then finds the lock
185
+ exactly at its own previous quarter's end, which is what lets it through.
186
+ """
187
+ return sorted({q for q in stored if closed_through is None or q.end > closed_through})
188
+
189
+
190
+ def reclose_steps(order: list[Quarter], then: Quarter | None = None) -> str:
191
+ """`order` (from `reclose_order`) as a person's next steps: "re-close 2026Q2, then 2026Q3".
192
+
193
+ `then` names a quarter to close after them that was never stored -- the
194
+ one someone just tried to close, when the lock says others come first.
195
+ """
196
+ text = "re-close " + ", then ".join(q.label for q in order)
197
+ if then is not None and then not in order:
198
+ text += f", then close {then.label}"
199
+ return text
200
+
201
+
202
+ def _refuse(abbr: str, quarter: Quarter, items: list[str]) -> NoReturn:
203
+ raise PeriodError(f"cannot close {abbr} {quarter.label}: {'; '.join(items)}")
204
+
205
+
206
+ def _stored_entry(stored: list[StoredPackage], quarter: Quarter) -> StoredPackage | None:
207
+ return next((p for p in stored if p.quarter == quarter), None)
208
+
209
+
210
+ def _already_stored_message(quarter: Quarter, existing: StoredPackage) -> str:
211
+ """A finished close and an interrupted one look identical here -- lock at `quarter.end`, a stored package --
212
+ so this refuses either way, but the interrupted case is a person's mistake to fix, not a bug to report, and
213
+ the message says exactly what to do about it.
214
+ """
215
+ return (
216
+ f"{quarter.label} already has a stored package at {existing.prefix!r}; `runningtab period status` checks it "
217
+ f"against the ledger, `close` does not re-run it. If a close of {quarter.label} was interrupted after "
218
+ "locking, run `runningtab period status`: if it reports changes against the stored package, reopen the "
219
+ "quarter in ERPNext and close it again."
220
+ )
221
+
222
+
223
+ def _already_closed_message(quarter: Quarter, existing: StoredPackage, closed_through: dt.date) -> str:
224
+ return (
225
+ f"{quarter.label} is already closed: stored at {existing.prefix!r}, and Books Closed Through "
226
+ f"({closed_through.isoformat()}) is past its end. To change it, reopen {quarter.label} in ERPNext by moving "
227
+ f"Books Closed Through back to {quarter.previous.end.isoformat()}, then re-close it and every stored "
228
+ "quarter after it, in order"
229
+ )
230
+
231
+
232
+ def _never_closed_message(abbr: str, quarter: Quarter, nearest: Quarter, order: list[Quarter]) -> str:
233
+ """`quarter` was never stored, but a later quarter was: it can never be closed now, so name what can be done.
234
+
235
+ Nothing reaches this through `close` itself -- the previous-quarter rule
236
+ stops a gap from forming -- so `quarter` is before the first quarter this
237
+ company ever closed. The next step is whatever the lock says is reopened,
238
+ or, if nothing is, the next quarter after the latest stored one.
239
+ """
240
+ text = (
241
+ f"{abbr}: {quarter.label} was never closed and {nearest.label}, after it, is already stored; quarters "
242
+ f"close in order, so {quarter.label} can no longer be closed. "
243
+ )
244
+ if order:
245
+ return text + f"Books Closed Through leaves stored quarters open: {reclose_steps(order)}"
246
+ return text + "Nothing stored is reopened; `runningtab period status` shows where the books stand"
247
+
248
+
249
+ def _lock_rule_failure(
250
+ quarter: Quarter,
251
+ stored: list[StoredPackage],
252
+ lock: LockState,
253
+ own: StoredPackage | None,
254
+ order: list[Quarter],
255
+ ) -> str | None:
256
+ """The one lock-versus-record rule `quarter` breaks, if any -- see `_precondition_failures`."""
257
+ before = [p for p in stored if p.quarter < quarter]
258
+ previous = quarter.previous
259
+ previous_entry = _stored_entry(before, previous) if before else None
260
+
261
+ if before and previous_entry is None:
262
+ return f"the previous quarter, {previous.label}, has no stored package; quarters close in order"
263
+ if lock.closed_through == quarter.end:
264
+ return _already_stored_message(quarter, own) if own is not None else None
265
+ if own is not None and lock.closed_through is not None and lock.closed_through > quarter.end:
266
+ return _already_closed_message(quarter, own, lock.closed_through)
267
+ if previous_entry is not None:
268
+ required = previous_entry.quarter.end
269
+ if lock.closed_through == required:
270
+ return None
271
+ shown = lock.closed_through.isoformat() if lock.closed_through else "unset"
272
+ text = (
273
+ f"Books Closed Through is {shown}, but {previous.label} is stored through {required.isoformat()}; "
274
+ f"it must be {required.isoformat()} or {quarter.end.isoformat()}"
275
+ )
276
+ if order:
277
+ text += f" -- a reopened quarter must be re-closed first: {reclose_steps(order, then=quarter)}"
278
+ return text
279
+ if lock.closed_through is not None and lock.closed_through > quarter.end:
280
+ return (
281
+ f"Books Closed Through is {lock.closed_through.isoformat()}, already past "
282
+ f"{quarter.label}'s end ({quarter.end.isoformat()})"
283
+ )
284
+ return None
285
+
286
+
287
+ def _precondition_failures(
288
+ quarter: Quarter, today: dt.date, stored: list[StoredPackage], lock: LockState, abbr: str
289
+ ) -> list[str]:
290
+ """Every precondition `quarter` fails right now, checked against `lock` and what is actually stored.
291
+
292
+ A later quarter already being stored only refuses a quarter that was
293
+ itself never stored: quarters close in order, so an unstored quarter
294
+ before a stored one was skipped (in practice, it predates the first
295
+ close ever) and can no longer be closed. A quarter that *was* stored
296
+ before and is being closed again is a reopen, and a reopen of any
297
+ quarter -- not only the latest -- goes ahead under the lock rules below,
298
+ after which every stored quarter after it is re-closed in order (each
299
+ finds the lock at its own previous quarter's end). The never-stored
300
+ check can fire alongside the "already past" check below (a cleared or
301
+ rolled-back lock can be both behind where it should be *and* trailing a
302
+ quarter that is already stored) or entirely on its own.
303
+
304
+ "What is stored" for the previous-quarter rule below means stored
305
+ *strictly before* `quarter` -- never `quarter`'s own packages. A
306
+ quarter's own prior close (from before a reopen, or from an
307
+ interrupted store) says nothing about whether some earlier quarter was
308
+ ever closed; counting it would make closing the very first quarter
309
+ ever a second time (after a legitimate reopen) fail with "the previous
310
+ quarter has no stored package", which is true of every quarter and
311
+ tells the person nothing they can act on.
312
+
313
+ When nothing is stored before this quarter (the very first close
314
+ ever, including a reopened-and-reclosed first quarter), the lock only
315
+ has to not already be past this quarter -- the old, loose rule. Once
316
+ something is stored before it, the rule tightens: `closed_through`
317
+ must be exactly the previous quarter's own close date (proving nothing
318
+ was reopened since) or exactly this quarter's end (recovery from a
319
+ lock that succeeded but whose store did not). Anything else -- unset,
320
+ or pointing at some other date entirely -- means a quarter somewhere
321
+ was reopened and never re-closed, and the refusal names the order to
322
+ re-close in.
323
+
324
+ Either way, a lock at or past `quarter.end` is further refused if
325
+ `quarter` already has a complete, stored package of its own: at
326
+ `quarter.end` that is a finished close (or an interrupted re-close),
327
+ not an interrupted upload to retry, and `runningtab period status` is what
328
+ checks a finished close still matches the ledger; past it, the quarter
329
+ is simply closed and has to be reopened first.
330
+ """
331
+ failures: list[str] = []
332
+ if not quarter.has_ended(today):
333
+ failures.append(
334
+ f"{quarter.label} has not ended: today is {today.isoformat()}, it ends {quarter.end.isoformat()}"
335
+ )
336
+
337
+ own = _stored_entry(stored, quarter)
338
+ order = reclose_order(lock.closed_through, (p.quarter for p in stored))
339
+ later = sorted(p.quarter for p in stored if p.quarter > quarter)
340
+ if later and own is None:
341
+ failures.append(_never_closed_message(abbr, quarter, later[0], order))
342
+
343
+ lock_failure = _lock_rule_failure(quarter, stored, lock, own, order)
344
+ if lock_failure is not None:
345
+ failures.append(lock_failure)
346
+
347
+ if lock.bypass_role:
348
+ failures.append(f"role_allowed_for_frozen_entries is {lock.bypass_role!r}; it must be empty before closing")
349
+ return failures
350
+
351
+
352
+ def _readiness_failures(blockers: list[Blocker], package: Package) -> list[str]:
353
+ failures = [f"{b.kind} {b.doctype} {b.name}: {b.detail}" for b in blockers]
354
+ failures.extend(
355
+ f"bank difference on {line.label} ({line.number}): {money(line.difference)}"
356
+ for line in package.bank
357
+ if line.difference != 0
358
+ )
359
+ return failures
360
+
361
+
362
+ def _differing_paths(before: dict[str, bytes], after: dict[str, bytes]) -> list[str]:
363
+ return sorted(path for path in {*before, *after} if before.get(path) != after.get(path))
364
+
365
+
366
+ _GENERAL_LEDGER = "general-ledger.csv"
367
+
368
+
369
+ def _csv_rows(data: bytes) -> set[tuple[str, ...]]:
370
+ return {tuple(row) for row in csv.reader(io.StringIO(data.decode("utf-8")))}
371
+
372
+
373
+ def _differing_vouchers(before: dict[str, bytes], after: dict[str, bytes]) -> list[str]:
374
+ """Every voucher ("Journal Entry ACC-JV-2026-00012") with a row in one build's general ledger and not the other's.
375
+
376
+ Read from `general-ledger.csv`'s own `voucher_type` and `voucher_no`
377
+ columns; a row too short to carry them names no voucher (the file is
378
+ still named as differing).
379
+ """
380
+ old, new = before.get(_GENERAL_LEDGER, b""), after.get(_GENERAL_LEDGER, b"")
381
+ if old == new:
382
+ return []
383
+ header = next(csv.reader(io.StringIO((new or old).decode("utf-8"))), [])
384
+ if "voucher_type" not in header or "voucher_no" not in header:
385
+ return []
386
+ kind, name = header.index("voucher_type"), header.index("voucher_no")
387
+ changed = _csv_rows(old) ^ _csv_rows(new)
388
+ return sorted({f"{row[kind]} {row[name]}" for row in changed if len(row) > max(kind, name)})
389
+
390
+
391
+ def reopen_step(quarter: Quarter) -> str:
392
+ """How a person reopens `quarter`: the one ERPNext edit that does it."""
393
+ return f"reopen {quarter.label} (move Books Closed Through back to {quarter.previous.end.isoformat()} in ERPNext)"
394
+
395
+
396
+ def _held_for_review_message(
397
+ abbr: str, quarter: Quarter, differing: list[str], vouchers: list[str], later: list[Quarter], *, reclosing: bool
398
+ ) -> str:
399
+ text = (
400
+ f"{abbr} {quarter.label} stays locked through {quarter.end.isoformat()} with no new stored package: the "
401
+ "ledger changed between the check and the lock, in " + ", ".join(differing)
402
+ )
403
+ if vouchers:
404
+ text += f" (GL entries of {', '.join(vouchers)})"
405
+ command = f"`runningtab period close --quarter {quarter.label}`"
406
+ if reclosing:
407
+ # A re-run with the lock at quarter.end is refused as "already has a
408
+ # stored package" (the earlier revision), so there is no retry here.
409
+ # The quarter is locked, so nothing can be reversed in it until it is
410
+ # reopened: reopen comes first.
411
+ text += (
412
+ f". Check what posted; then {reopen_step(quarter)}, reverse anything that should not be in "
413
+ f"{quarter.label}, and run {command} again"
414
+ )
415
+ else:
416
+ text += (
417
+ f". Check what posted. If it belongs in {quarter.label}, run {command} again to re-check and store "
418
+ f"what is now locked. If it should not be in {quarter.label}, {reopen_step(quarter)}, reverse it, "
419
+ f"and run {command} again"
420
+ )
421
+ if later:
422
+ text += f"; then {reclose_steps(later)}"
423
+ return text
424
+
425
+
426
+ def close_quarter(steps: CloseSteps, quarter: Quarter, abbr: str) -> CloseOutcome:
427
+ """Run the close ceremony for `quarter`, in the spec's exact order, and return where it landed.
428
+
429
+ Every stage before the lock leaves nothing changed and names every
430
+ failing item in that stage, not just the first; a failure at or after
431
+ the lock leaves the lock in place -- there is no rollback -- and says so
432
+ by name. `abbr` is not used to look anything up (every `steps` callable
433
+ is already scoped to one company/bucket by the CLI); every refusal
434
+ names it, so a person reading a raised message never has to guess which
435
+ company's close failed.
436
+ """
437
+ # 1. Preconditions.
438
+ today = steps.today()
439
+ stored = steps.stored_packages()
440
+ lock = steps.read_lock()
441
+ failures = _precondition_failures(quarter, today, stored, lock, abbr)
442
+ if failures:
443
+ _refuse(abbr, quarter, failures)
444
+
445
+ # 2. Readiness, and 3. Build (the same read the bank-difference check
446
+ # in readiness needs -- see the module docstring on why `build` runs
447
+ # once here rather than being deferred to the cross-check stage).
448
+ # Blockers are read first so a `build` refusal (an unbalanced trial
449
+ # balance, say) is reported together with them in one refusal, rather
450
+ # than hiding whatever else was already wrong.
451
+ blockers = steps.read_blockers()
452
+ try:
453
+ built = steps.build()
454
+ except PeriodError as exc:
455
+ _refuse(abbr, quarter, [f"{b.kind} {b.doctype} {b.name}: {b.detail}" for b in blockers] + [str(exc)])
456
+ failures = _readiness_failures(blockers, built.package)
457
+ if failures:
458
+ _refuse(abbr, quarter, failures)
459
+
460
+ # 3. Cross-check.
461
+ mismatches = steps.crosscheck(
462
+ built.package, built.trial_balance, built.receivables, built.payables, quarter, built.manifest_facts.fiscal_year
463
+ )
464
+ if mismatches:
465
+ _refuse(abbr, quarter, mismatches)
466
+
467
+ # 4. Lock.
468
+ if lock.closed_through == quarter.end:
469
+ # Recovery: the lock already succeeded, only the store did not.
470
+ # The newest Version is the only one that can confirm that --
471
+ # anything else (nothing recorded, or the newest Version disagreeing
472
+ # with the live field) means the field and its own audit trail have
473
+ # drifted apart, and this refuses rather than trust either alone.
474
+ history = steps.lock_history()
475
+ if not history or history[0].new != quarter.end:
476
+ newest = history[0].new.isoformat() if history and history[0].new else "unset"
477
+ raise PeriodError(
478
+ f"{abbr} {quarter.label}: Books Closed Through is already {quarter.end.isoformat()} but the "
479
+ f"newest Version does not confirm it (newest recorded value: {newest})"
480
+ )
481
+ change = history[0]
482
+ else:
483
+ change = steps.set_closed_through(quarter.end)
484
+ reclosing = _stored_entry(stored, quarter) is not None
485
+ later = tuple(sorted({p.quarter for p in stored if p.quarter > quarter}))
486
+ steps.locked(Held(change, reclosing, later))
487
+
488
+ # 5. Rebuild and compare.
489
+ rebuilt = steps.build()
490
+ if rebuilt.package.files != built.package.files:
491
+ raise HeldForReview(
492
+ _held_for_review_message(
493
+ abbr,
494
+ quarter,
495
+ _differing_paths(built.package.files, rebuilt.package.files),
496
+ _differing_vouchers(built.package.files, rebuilt.package.files),
497
+ list(later),
498
+ reclosing=reclosing,
499
+ )
500
+ )
501
+
502
+ # 6. Store.
503
+ def build_manifest(revision: int) -> bytes:
504
+ facts = dataclasses.replace(
505
+ rebuilt.manifest_facts, draft=False, closed_by=change.by, lock_version=change.version, revision=revision
506
+ )
507
+ return render_manifest(facts, rebuilt.package.files)
508
+
509
+ prefix = steps.upload(rebuilt.package.files, build_manifest)
510
+ return CloseOutcome(prefix=prefix, lock=change, package=rebuilt.package)