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,312 @@
1
+ """`runningtab period status`: whether every closed quarter's stored package still matches the ledger, and whether
2
+ the lock itself is still intact.
3
+
4
+ There are three ways a closed quarter can go bad, and this is the one
5
+ place that looks for all three: Books Closed Through moved backward without a
6
+ matching close (a reopen, done directly in the ERPNext UI -- there is no
7
+ `period reopen`); `role_allowed_for_frozen_entries` left set, which would let
8
+ someone post into a period nominally still closed; and a stored package whose
9
+ files no longer match what the ledger now says, because something in ERPNext
10
+ changed after the close (a correcting Journal Entry against a document a
11
+ closed quarter's trial balance already used, say). It also reports the
12
+ spec's "Failure behaviour" case: Books Closed Through moved past every
13
+ stored quarter, so a quarter is locked with no final package.
14
+
15
+ `period_status` itself is pure -- everything it needs from ERPNext and the
16
+ record bucket arrives as already-read values (`lock`, `history`,
17
+ `stored_latest`) or as callables (`sums`, `rebuild`) the caller closes over
18
+ its own credential and bucket with. `runningtab.cli` is the one place that reads
19
+ the reader's credential and the record bucket for real; the CLI's `rebuild`
20
+ closure runs each stored quarter through the exact same read-and-build path
21
+ `period export` uses, so a mismatch this reports is never an artefact of a
22
+ different code path disagreeing with itself.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import datetime as dt
29
+ import hashlib
30
+ from collections.abc import Callable, Iterable
31
+
32
+ from runningtab import health
33
+ from runningtab.period.close import reclose_order, reclose_steps, reopen_step
34
+ from runningtab.period.lock import LockChange, LockState
35
+ from runningtab.period.package import Package
36
+ from runningtab.period.quarter import Quarter
37
+ from runningtab.period.record import StoredPackage
38
+
39
+
40
+ @dataclasses.dataclass(frozen=True)
41
+ class QuarterStatus:
42
+ """One stored quarter, and every path (if any) where the rebuild disagrees with what is stored."""
43
+
44
+ quarter: Quarter
45
+ revision: int
46
+ changed: list[str]
47
+
48
+
49
+ @dataclasses.dataclass(frozen=True)
50
+ class StatusReport:
51
+ """Books Closed Through, its history, every stored quarter's status, and every problem found by name."""
52
+
53
+ lock: LockState
54
+ history: list[LockChange]
55
+ quarters: list[QuarterStatus]
56
+ problems: list[str]
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ """Whether anything named in `problems` needs a person -- an empty `quarters` list is not itself a problem."""
61
+ return not self.problems
62
+
63
+
64
+ def _newest_regression(history: list[LockChange]) -> LockChange | None:
65
+ """The newest Version (in `history`'s newest-first order) that moved `accounts_frozen_till_date` backward.
66
+
67
+ Not simply `history[0]`: a reopen can be followed by a partial, still
68
+ incomplete recovery -- a later Version that moves the lock forward again
69
+ but not yet back to the latest stored quarter's end. That later Version
70
+ is not itself a regression, and reporting it would name the wrong edit as
71
+ "who reopened this". Walking newest-first and returning the first actual
72
+ decrease finds the regression whichever Version is newest overall.
73
+
74
+ A regression that *clears* the field entirely (`new` is None) counts too
75
+ -- the obvious way to "unlock" in the ERPNext UI is to empty
76
+ `accounts_frozen_till_date`, not type in an earlier date. Treating that
77
+ as "no regression found" (since `None < old` can't be compared) would
78
+ have `_reopen_problem` fall back to "no Company Version records who
79
+ moved it" even though this Version is exactly that record.
80
+ """
81
+ for change in history:
82
+ if change.old is None:
83
+ continue
84
+ if change.new is None or change.new < change.old:
85
+ return change
86
+ return None
87
+
88
+
89
+ def _reopen_problem(lock: LockState, history: list[LockChange], latest_end: dt.date) -> str:
90
+ shown = lock.closed_through.isoformat() if lock.closed_through else "unset"
91
+ change = _newest_regression(history)
92
+ if change is None:
93
+ return (
94
+ f"Books Closed Through is {shown}, earlier than the latest stored quarter's end "
95
+ f"({latest_end.isoformat()}) -- a reopen, but no Company Version records who moved it"
96
+ )
97
+ old = change.old.isoformat() if change.old else "unset"
98
+ new = change.new.isoformat() if change.new else "cleared"
99
+ return (
100
+ f"Books Closed Through is {shown}, earlier than the latest stored quarter's end "
101
+ f"({latest_end.isoformat()}) -- reopened by {change.by} on {change.at.isoformat()} "
102
+ f"(Company Version {change.version} moved it {old} -> {new})"
103
+ )
104
+
105
+
106
+ def _reclose_next_step(lock: LockState, stored: Iterable[Quarter]) -> str:
107
+ """What gets the books closed again after a reopen: the quarters to re-close, in order, and the lock date
108
+ `close` needs first when the lock sits somewhere no re-close accepts.
109
+ """
110
+ quarters = set(stored)
111
+ order = reclose_order(lock.closed_through, quarters)
112
+ previous = order[0].previous
113
+ if previous in quarters and lock.closed_through != previous.end:
114
+ return (
115
+ f"to close them again, set Books Closed Through to {previous.end.isoformat()} in ERPNext, then "
116
+ f"{reclose_steps(order)}"
117
+ )
118
+ return f"to close them again, {reclose_steps(order)}"
119
+
120
+
121
+ def _unstored_lock_problem(closed_through: dt.date) -> str:
122
+ """Books Closed Through sits in a quarter with no stored package: frozen in ERPNext, absent from the record.
123
+
124
+ What a close that locked and then failed to store leaves behind -- a
125
+ rebuild mismatch, a bucket error, a killed process. Re-running `close`
126
+ for that quarter is its recovery. A date that is not a quarter's end
127
+ was never set by `close` at all, so it is named as such.
128
+ """
129
+ quarter = Quarter.containing(closed_through)
130
+ command = f"`runningtab period close --quarter {quarter.label}`"
131
+ if closed_through == quarter.end:
132
+ return (
133
+ f"Books Closed Through is {closed_through.isoformat()} but {quarter.label} has no stored package; "
134
+ f"run {command} to store it"
135
+ )
136
+ return (
137
+ f"Books Closed Through is {closed_through.isoformat()}, inside {quarter.label}, which has no stored "
138
+ "package -- not a quarter's end, so no close set it; find who did in the Company's Version history, "
139
+ f"then run {command}"
140
+ )
141
+
142
+
143
+ _BANK_RECONCILIATION = "bank-reconciliation.csv"
144
+
145
+
146
+ def _late_bank_activity_next_step(quarter: Quarter, stored: Iterable[Quarter]) -> str:
147
+ """What clears a closed quarter whose bank reconciliation moved: reopen it, book the row there, re-close.
148
+
149
+ Usually bank activity dated in `quarter` that reached Mercury after the
150
+ close (the ingest names each such row). Booking it in the open quarter
151
+ instead would leave this quarter's rebuild different from its stored
152
+ package for good. Re-closing writes a new revision, and every stored
153
+ quarter after it is re-closed after it, in order.
154
+ """
155
+ order = [quarter, *sorted(q for q in stored if q > quarter)]
156
+ return (
157
+ f"if bank activity dated in {quarter.label} arrived after it closed, reopen {quarter.label} in ERPNext "
158
+ f"(move Books Closed Through back to {quarter.previous.end.isoformat()}), book it there, then "
159
+ f"{reclose_steps(order)}"
160
+ )
161
+
162
+
163
+ def _unfinished_reclose_problem(quarter: Quarter, stored: StoredPackage, setter: LockChange, recorded: str) -> str:
164
+ """Books Closed Through is at `quarter`'s end, but a newer Version put it there than the one its stored close
165
+ records: a re-close locked the quarter and never stored its revision.
166
+
167
+ The earlier revision can still match the ledger, so no file differs --
168
+ this is the only sign that the lock and the record now disagree about
169
+ which close froze the quarter.
170
+ """
171
+ return (
172
+ f"Books Closed Through was set to {quarter.end.isoformat()} by Company Version {setter.version} "
173
+ f"({setter.by}, {setter.at.isoformat()}), but {quarter.label} rev{stored.revision} records Company Version "
174
+ f"{recorded}: a re-close locked {quarter.label} and stored no new revision; {reopen_step(quarter)} and "
175
+ f"run `runningtab period close --quarter {quarter.label}` again"
176
+ )
177
+
178
+
179
+ def _closing_change(history: list[LockChange], end: dt.date) -> LockChange | None:
180
+ """The newest Version that moved Books Closed Through *forward* onto `end` -- the one that closed that quarter.
181
+
182
+ Only forward moves count. A reopen that moves the lock back onto an
183
+ earlier quarter's end (reopening 2026Q3 lands on 2026Q2's end) also sets
184
+ `new == end`, but it closed nothing, and taking it for 2026Q2's close
185
+ would report a re-close of 2026Q2 that never happened.
186
+ """
187
+ return next((change for change in history if change.new == end and (change.old is None or change.old < end)), None)
188
+
189
+
190
+ def _lock_record_problems(
191
+ lock: LockState,
192
+ history: list[LockChange],
193
+ stored_latest: dict[Quarter, StoredPackage],
194
+ lock_record: Callable[[StoredPackage], str],
195
+ ) -> list[str]:
196
+ """For every stored quarter Books Closed Through covers: does its latest revision record the close that
197
+ actually froze it?
198
+
199
+ Every covered quarter, not only the one the lock ends in: a re-close of
200
+ 2026Q2 that locked and failed to store, followed by a re-close of
201
+ 2026Q3, leaves the lock on 2026Q3 and 2026Q2 still on its old revision.
202
+ The stored manifest is read either way, and a covered quarter with no
203
+ forward Version onto its end is reported as unconfirmed -- every close
204
+ writes one, so its absence is itself something a person must look at,
205
+ not a pass.
206
+ """
207
+ if lock.closed_through is None:
208
+ return []
209
+ problems: list[str] = []
210
+ for quarter in sorted(q for q in stored_latest if q.end <= lock.closed_through):
211
+ stored = stored_latest[quarter]
212
+ try:
213
+ recorded = lock_record(stored)
214
+ except Exception as exc:
215
+ problems.append(f"{quarter.label} rev{stored.revision}: could not read which lock it records: {exc}")
216
+ continue
217
+ setter = _closing_change(history, quarter.end)
218
+ if setter is None:
219
+ problems.append(
220
+ f"{quarter.label} rev{stored.revision} records Company Version {recorded}, but no Company Version "
221
+ f"moves Books Closed Through forward to {quarter.end.isoformat()}, so its close cannot be "
222
+ "confirmed; check the Company's Version history in ERPNext"
223
+ )
224
+ elif setter.version != recorded:
225
+ problems.append(_unfinished_reclose_problem(quarter, stored, setter, recorded))
226
+ return problems
227
+
228
+
229
+ def _file_hashes(package: Package) -> dict[str, str]:
230
+ return {path: hashlib.sha256(data).hexdigest() for path, data in package.files.items()}
231
+
232
+
233
+ def period_status(
234
+ lock: LockState,
235
+ history: list[LockChange],
236
+ stored_latest: dict[Quarter, StoredPackage],
237
+ sums: Callable[[StoredPackage], dict[str, str]],
238
+ rebuild: Callable[[Quarter], Package],
239
+ lock_record: Callable[[StoredPackage], str],
240
+ ) -> StatusReport:
241
+ """Check the lock and every stored quarter's latest revision, and report every problem found by name.
242
+
243
+ `rebuild` is never called for a quarter this function has no stored
244
+ package for, and nothing here writes anything anywhere. An unstored
245
+ quarter matters here only when Books Closed Through sits in it -- the
246
+ quarter is frozen with nothing in the record -- and then only that one
247
+ quarter is named, never every quarter before the first close.
248
+ """
249
+ problems: list[str] = []
250
+
251
+ if lock.bypass_role:
252
+ problems.append(f"role_allowed_for_frozen_entries is {lock.bypass_role!r}")
253
+
254
+ latest_end = max((stored.quarter.end for stored in stored_latest.values()), default=None)
255
+ if lock.closed_through is not None and (latest_end is None or lock.closed_through > latest_end):
256
+ problems.append(_unstored_lock_problem(lock.closed_through))
257
+ if latest_end is not None and (lock.closed_through is None or lock.closed_through < latest_end):
258
+ problems.append(f"{_reopen_problem(lock, history, latest_end)}; {_reclose_next_step(lock, stored_latest)}")
259
+ problems.extend(_lock_record_problems(lock, history, stored_latest, lock_record))
260
+
261
+ quarters: list[QuarterStatus] = []
262
+ for quarter in sorted(stored_latest):
263
+ stored = stored_latest[quarter]
264
+ try:
265
+ stored_sums = sums(stored)
266
+ rebuilt_sums = _file_hashes(rebuild(quarter))
267
+ except Exception as exc:
268
+ # `sums`/`rebuild` are the caller's read of the outside world
269
+ # (the record bucket, ERPNext, the chart) -- a refusal there
270
+ # (chart drift between a `chart.coa` merge and its apply, an
271
+ # ERPNext read that failed) must not discard the bypass-role and
272
+ # reopen problems already found above, nor stop the remaining
273
+ # quarters from being checked. Named here rather than silently
274
+ # skipped: the exit code already goes non-zero either way, but a
275
+ # person reading only the top line ("refused: chart drift...")
276
+ # would otherwise never learn the bypass role was also set.
277
+ problems.append(f"{quarter.label} rev{stored.revision}: could not verify: {exc}")
278
+ continue
279
+ changed = sorted(
280
+ path for path in {*stored_sums, *rebuilt_sums} if stored_sums.get(path) != rebuilt_sums.get(path)
281
+ )
282
+ quarters.append(QuarterStatus(quarter=quarter, revision=stored.revision, changed=changed))
283
+ if changed:
284
+ problem = (
285
+ f"{quarter.label} rev{stored.revision}: rebuilt files differ from the stored package: "
286
+ + ", ".join(changed)
287
+ )
288
+ if _BANK_RECONCILIATION in changed:
289
+ problem += f"; {_late_bank_activity_next_step(quarter, stored_latest)}"
290
+ problems.append(problem)
291
+
292
+ return StatusReport(lock=lock, history=history, quarters=quarters, problems=problems)
293
+
294
+
295
+ def closed_periods_check(report: StatusReport) -> health.Check:
296
+ """`runningtab health`'s sixth section: the same report `period status` prints, as one named `Check`."""
297
+ key, title = "closed-periods", "closed periods"
298
+ if not report.problems:
299
+ summary = (
300
+ "nothing closed yet" if not report.quarters else f"{len(report.quarters)} quarter(s) closed, all match"
301
+ )
302
+ return health.Check(key=key, title=title, outcome=health.Outcome.PASS, summary=summary)
303
+ detail: list[str] = []
304
+ for problem in report.problems:
305
+ detail.extend(health.wrapped(problem))
306
+ return health.Check(
307
+ key=key,
308
+ title=title,
309
+ outcome=health.Outcome.FAIL,
310
+ summary=f"{len(report.problems)} problem(s)",
311
+ detail=tuple(detail),
312
+ )