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.
- runningtab/__init__.py +3 -0
- runningtab/access.py +419 -0
- runningtab/books.py +225 -0
- runningtab/chart.py +408 -0
- runningtab/cli.py +2216 -0
- runningtab/coa.py +212 -0
- runningtab/doc.py +185 -0
- runningtab/forecast/__init__.py +1 -0
- runningtab/forecast/compute.py +224 -0
- runningtab/forecast/doctype.py +228 -0
- runningtab/forecast/model.py +251 -0
- runningtab/forecast/names.py +8 -0
- runningtab/forecast/sources.py +85 -0
- runningtab/gl_codes.py +50 -0
- runningtab/health.py +661 -0
- runningtab/mercury/__init__.py +7 -0
- runningtab/mercury/categories.py +500 -0
- runningtab/mercury/classify.py +101 -0
- runningtab/mercury/client.py +360 -0
- runningtab/mercury/ingest.py +733 -0
- runningtab/mercury/mapping.py +130 -0
- runningtab/mercury/models.py +544 -0
- runningtab/mercury/posting.py +757 -0
- runningtab/mercury/receipts.py +304 -0
- runningtab/mercury/session.py +65 -0
- runningtab/mercury/state.py +285 -0
- runningtab/mercury/vouchers.py +951 -0
- runningtab/period/__init__.py +9 -0
- runningtab/period/balances.py +283 -0
- runningtab/period/bank.py +154 -0
- runningtab/period/close.py +510 -0
- runningtab/period/crosscheck.py +257 -0
- runningtab/period/csvfmt.py +86 -0
- runningtab/period/documents.py +260 -0
- runningtab/period/fence.py +277 -0
- runningtab/period/ledger.py +205 -0
- runningtab/period/lock.py +294 -0
- runningtab/period/manifest.py +259 -0
- runningtab/period/open_items.py +84 -0
- runningtab/period/package.py +140 -0
- runningtab/period/qbo.py +521 -0
- runningtab/period/qbo_map.py +174 -0
- runningtab/period/quarter.py +90 -0
- runningtab/period/readiness.py +146 -0
- runningtab/period/record.py +247 -0
- runningtab/period/status.py +312 -0
- runningtab/site.py +409 -0
- runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
- runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
- runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
- runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|