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,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)
|