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,277 @@
|
|
|
1
|
+
"""Fence Company's two close-lock fields at permission level 1, and prove the bot cannot move them.
|
|
2
|
+
|
|
3
|
+
`accounts_frozen_till_date` and `role_allowed_for_frozen_entries` are the two
|
|
4
|
+
fields that make a quarter's close mean something: once set, they are supposed
|
|
5
|
+
to stop *anyone* below the fence from post-dating an entry into a closed
|
|
6
|
+
period. Company's standard permissions are all permlevel 0
|
|
7
|
+
(`erpnext/setup/doctype/company/company.json`), so the bot's ordinary
|
|
8
|
+
`Company: read, write` grant (`runningtab.access.GRANTS`) would otherwise reach
|
|
9
|
+
them like any other field.
|
|
10
|
+
|
|
11
|
+
Frappe does not refuse a write to a field above the writer's permlevel -- it
|
|
12
|
+
silently resets it to what is already stored (`validate_higher_perm_levels` in
|
|
13
|
+
`frappe/model/document.py`) -- so a doctype-level permission check proves
|
|
14
|
+
nothing here. The only proof is a real write attempted with the bot's key,
|
|
15
|
+
read back by an administrator: `prove_fence`. Its probe only ever pushes
|
|
16
|
+
`accounts_frozen_till_date` later than whatever is there (never earlier), and
|
|
17
|
+
restores whatever it finds changed -- verifying the restore actually took --
|
|
18
|
+
so a freeze a person actually set stays in force whether or not this proof
|
|
19
|
+
runs.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import dataclasses
|
|
25
|
+
import datetime as dt
|
|
26
|
+
from typing import Any, Literal, Protocol
|
|
27
|
+
|
|
28
|
+
from icedcoffee_sync.frappe.access import PermissionGrant, ensure_role, grant
|
|
29
|
+
from icedcoffee_sync.frappe.errors import PermissionError_
|
|
30
|
+
|
|
31
|
+
from runningtab.period import PeriodError
|
|
32
|
+
|
|
33
|
+
LOCK_FIELDS: tuple[str, str] = ("accounts_frozen_till_date", "role_allowed_for_frozen_entries")
|
|
34
|
+
|
|
35
|
+
COMPANY = "Company"
|
|
36
|
+
FENCE_ROLE = "System Manager"
|
|
37
|
+
FENCE_PERMLEVEL = 1
|
|
38
|
+
|
|
39
|
+
# The probe role -- the books directory's `fence.probe_role`, passed in as
|
|
40
|
+
# `probe_role` -- is what the probe writes into
|
|
41
|
+
# `role_allowed_for_frozen_entries`. It must be a role held by nobody -- specifically, it must NOT be "Guest" or "All".
|
|
42
|
+
# Frappe's `get_roles()` (frappe/permissions.py) adds both to every logged-in
|
|
43
|
+
# non-Administrator user's role list unconditionally (`roles +=
|
|
44
|
+
# [ALL_USER_ROLE, GUEST_ROLE]`), and ERPNext's `check_freezing_date`
|
|
45
|
+
# (erpnext/accounts/services/gl_validator.py) lets a posting through once the
|
|
46
|
+
# configured role is in `frappe.get_roles()`. A probe that ever landed
|
|
47
|
+
# "Guest" as the bypass role would not just fail to prove the fence -- it
|
|
48
|
+
# would open the closed quarter to every user on the site. This role exists
|
|
49
|
+
# for exactly one purpose (a value to write that nobody can ever satisfy) and
|
|
50
|
+
# `install_fence` creates it with desk access off; nothing ever grants it to
|
|
51
|
+
# a user. `runningtab.books` refuses a probe role of "Guest", "All", or the
|
|
52
|
+
# bot's or reader's own role.
|
|
53
|
+
|
|
54
|
+
_PLACEHOLDER_DATE = "1900-01-01"
|
|
55
|
+
|
|
56
|
+
HowMoved = Literal["raised", "reset", "took"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def property_setter_documents() -> list[dict[str, Any]]:
|
|
60
|
+
"""The two Property Setters that push `LOCK_FIELDS` to permission level 1 on Company."""
|
|
61
|
+
return [
|
|
62
|
+
{
|
|
63
|
+
"doctype": "Property Setter",
|
|
64
|
+
"doctype_or_field": "DocField",
|
|
65
|
+
"doc_type": COMPANY,
|
|
66
|
+
"field_name": field,
|
|
67
|
+
"property": "permlevel",
|
|
68
|
+
"value": "1",
|
|
69
|
+
"property_type": "Int",
|
|
70
|
+
}
|
|
71
|
+
for field in LOCK_FIELDS
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _AdminClient(Protocol):
|
|
76
|
+
"""The administrator's slice: create/repair the fence, and read/restore Company."""
|
|
77
|
+
|
|
78
|
+
def list(self, doctype: str, **kwargs: Any) -> Any: ...
|
|
79
|
+
def insert(self, doctype: str, doc: dict[str, Any]) -> Any: ...
|
|
80
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
|
|
81
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
82
|
+
def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _BotClient(Protocol):
|
|
86
|
+
"""The bot's slice: the one write `prove_fence` attempts."""
|
|
87
|
+
|
|
88
|
+
def update(self, doctype: str, name: str, doc: dict[str, Any]) -> Any: ...
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def install_fence(admin: _AdminClient, probe_role: str) -> list[tuple[str, str]]:
|
|
92
|
+
"""Create or repair the two Property Setters, grant System Manager permlevel-1 Company, and ensure the probe role.
|
|
93
|
+
|
|
94
|
+
Follows `runningtab.mercury.posting.install_custom_fields`: list each Property
|
|
95
|
+
Setter by its `(doc_type, field_name, property)`, insert when absent,
|
|
96
|
+
update `value` when it differs. Needs an administrator -- the books bot's
|
|
97
|
+
role carries no Property Setter permission, deliberately, the same reason
|
|
98
|
+
it carries no Custom Field permission.
|
|
99
|
+
|
|
100
|
+
Does **not** call `revoke_grants_except` for System Manager: that is a
|
|
101
|
+
standard role this code does not own, and reconciling it down to exactly
|
|
102
|
+
one doctype's permissions would strip whatever else a person granted it.
|
|
103
|
+
|
|
104
|
+
Also ensures `probe_role` exists (desk access off), so `prove_fence` has
|
|
105
|
+
a role to probe with that is guaranteed to be held by nobody. Nothing
|
|
106
|
+
here, or anywhere else in this codebase, ever grants it to a user.
|
|
107
|
+
"""
|
|
108
|
+
results: list[tuple[str, str]] = []
|
|
109
|
+
for doc in property_setter_documents():
|
|
110
|
+
field = doc["field_name"]
|
|
111
|
+
label = f"{COMPANY}.{field}"
|
|
112
|
+
body = {k: v for k, v in doc.items() if k != "doctype"}
|
|
113
|
+
existing = admin.list(
|
|
114
|
+
"Property Setter",
|
|
115
|
+
fields=["name", "value"],
|
|
116
|
+
filters=[["doc_type", "=", COMPANY], ["field_name", "=", field], ["property", "=", "permlevel"]],
|
|
117
|
+
limit=1,
|
|
118
|
+
)
|
|
119
|
+
if not existing:
|
|
120
|
+
admin.insert("Property Setter", body)
|
|
121
|
+
results.append((label, "created"))
|
|
122
|
+
continue
|
|
123
|
+
row = existing[0]
|
|
124
|
+
if row.get("value") != body["value"]:
|
|
125
|
+
admin.update("Property Setter", str(row["name"]), {"value": body["value"]})
|
|
126
|
+
results.append((label, "updated"))
|
|
127
|
+
else:
|
|
128
|
+
results.append((label, "unchanged"))
|
|
129
|
+
|
|
130
|
+
grant(admin, FENCE_ROLE, PermissionGrant(COMPANY, frozenset({"read", "write"}), permlevel=FENCE_PERMLEVEL))
|
|
131
|
+
|
|
132
|
+
role_change = ensure_role(admin, probe_role)
|
|
133
|
+
if role_change.created:
|
|
134
|
+
results.append((probe_role, "created"))
|
|
135
|
+
elif role_change.desk_access_repaired or role_change.disabled_repaired:
|
|
136
|
+
results.append((probe_role, "updated"))
|
|
137
|
+
else:
|
|
138
|
+
results.append((probe_role, "unchanged"))
|
|
139
|
+
return results
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclasses.dataclass(frozen=True)
|
|
143
|
+
class FenceProof:
|
|
144
|
+
"""Whether the server actually refused the bot's write to each lock field, and how."""
|
|
145
|
+
|
|
146
|
+
date_refused: bool
|
|
147
|
+
role_refused: bool
|
|
148
|
+
# "raised": the bot's write itself raised PermissionError_.
|
|
149
|
+
# "reset": the write reached the server but the stored value never moved
|
|
150
|
+
# (Frappe's own `validate_higher_perm_levels`).
|
|
151
|
+
# "took": the value actually moved, and was restored (verified).
|
|
152
|
+
date_how: HowMoved
|
|
153
|
+
role_how: HowMoved
|
|
154
|
+
restored: tuple[str, ...]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _probe_date(current: Any) -> str:
|
|
158
|
+
"""A later `accounts_frozen_till_date` than whatever is stored -- freezes more, never less."""
|
|
159
|
+
if not current:
|
|
160
|
+
return _PLACEHOLDER_DATE
|
|
161
|
+
parsed = dt.date.fromisoformat(str(current))
|
|
162
|
+
return (parsed + dt.timedelta(days=1)).isoformat()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _probe_value(field: str, current: Any, probe_role: str) -> Any:
|
|
166
|
+
if field == LOCK_FIELDS[0]:
|
|
167
|
+
return _probe_date(current)
|
|
168
|
+
return probe_role
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _probe_field(
|
|
172
|
+
admin: _AdminClient, bot: _BotClient, company: str, field: str, original: Any, probe_role: str
|
|
173
|
+
) -> tuple[bool, bool, HowMoved]:
|
|
174
|
+
"""Attempt the bot's write, read it back as admin, and verify the restore actually took.
|
|
175
|
+
|
|
176
|
+
The bot's write sits inside the same `try` the restore's `finally`
|
|
177
|
+
guards: if it raises `PermissionError_`, nothing landed and the probe is
|
|
178
|
+
refused outright ("raised"); if it raises anything else, the write's
|
|
179
|
+
effect is unknown, so the restore below still runs -- a restore after a
|
|
180
|
+
write that never landed is a harmless no-op. Whatever happens, the
|
|
181
|
+
restore is attempted and then verified by reading Company back a second
|
|
182
|
+
time: a restore that does not read back as `original` raises
|
|
183
|
+
`PeriodError` naming the field and both values, because a probe that
|
|
184
|
+
cannot put a lock field back is not something to let return quietly.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
try:
|
|
188
|
+
bot.update(COMPANY, company, {field: _probe_value(field, original, probe_role)})
|
|
189
|
+
except PermissionError_:
|
|
190
|
+
return True, False, "raised"
|
|
191
|
+
|
|
192
|
+
after = admin.get(COMPANY, company)
|
|
193
|
+
changed = after.get(field) != original
|
|
194
|
+
return not changed, changed, ("took" if changed else "reset")
|
|
195
|
+
finally:
|
|
196
|
+
admin.update(COMPANY, company, {field: original})
|
|
197
|
+
confirmed = admin.get(COMPANY, company)
|
|
198
|
+
if confirmed.get(field) != original:
|
|
199
|
+
raise PeriodError(
|
|
200
|
+
f"the fence probe could not restore {field}: it is stuck at {confirmed.get(field)!r}, "
|
|
201
|
+
f"not its original {original!r}. Set it back in ERPNext now."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def probe_role_holders(admin: _AdminClient, probe_role: str) -> list[str]:
|
|
206
|
+
"""Every user who holds `probe_role`. Nothing in this codebase grants it, so this should always be empty."""
|
|
207
|
+
rows = admin.list("User", fields=["name"], filters=[["Has Role", "role", "=", probe_role]], limit=50)
|
|
208
|
+
return sorted(str(row["name"]) for row in rows)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _refuse_an_unprovable_probe(admin: _AdminClient, original: dict[str, Any], probe_role: str) -> None:
|
|
212
|
+
"""Refuse, before the bot writes anything, a probe whose result could not mean what it says.
|
|
213
|
+
|
|
214
|
+
A probe value equal to what is stored reads back unchanged whether or
|
|
215
|
+
not the bot could write it, so it would report a fence that was never
|
|
216
|
+
tested. And while a failing fence lets the probe role sit in
|
|
217
|
+
`role_allowed_for_frozen_entries`, anyone holding that role could post
|
|
218
|
+
into a closed quarter -- so the role being held by nobody is checked
|
|
219
|
+
here, not assumed.
|
|
220
|
+
"""
|
|
221
|
+
for field in LOCK_FIELDS:
|
|
222
|
+
current = original.get(field)
|
|
223
|
+
if _probe_value(field, current, probe_role) == current:
|
|
224
|
+
raise PeriodError(
|
|
225
|
+
f"{field} is already {current!r}, the fence probe's own value, so the probe could not tell a "
|
|
226
|
+
"refused write from one that landed; clear it in ERPNext and run provision again"
|
|
227
|
+
)
|
|
228
|
+
holders = probe_role_holders(admin, probe_role)
|
|
229
|
+
if holders:
|
|
230
|
+
raise PeriodError(
|
|
231
|
+
f"{probe_role!r} is held by {', '.join(holders)}; it must be held by nobody, because the fence probe "
|
|
232
|
+
"writes it into role_allowed_for_frozen_entries and a holder could post into a closed quarter while "
|
|
233
|
+
"it sits there. Remove the role from those users and run provision again"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def check_fence_probe(admin: _AdminClient, company: str, probe_role: str) -> None:
|
|
238
|
+
"""Refuse now, as the administrator, a fence probe that could not prove anything -- before any key is minted.
|
|
239
|
+
|
|
240
|
+
`access provision` rotates the bot's key before it can run `prove_fence`,
|
|
241
|
+
so a refusal found only inside the proof would leave the stored key
|
|
242
|
+
already dead. Running the same checks here first keeps that refusal an
|
|
243
|
+
ordinary one; `prove_fence` still repeats them immediately before it
|
|
244
|
+
writes, since the site can change between the two.
|
|
245
|
+
"""
|
|
246
|
+
_refuse_an_unprovable_probe(admin, admin.get(COMPANY, company), probe_role)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def prove_fence(admin: _AdminClient, bot: _BotClient, company: str, probe_role: str) -> FenceProof:
|
|
250
|
+
"""Prove the bot's key cannot move either lock field, restoring (and verifying) anything that did move.
|
|
251
|
+
|
|
252
|
+
Reads the Company record as `admin` first, refuses a probe that could
|
|
253
|
+
not prove anything (`_refuse_an_unprovable_probe`), then attempts one
|
|
254
|
+
write per field with `bot`'s key. A field whose stored value did not
|
|
255
|
+
change is refused; the bot's update raising `PermissionError_` counts as
|
|
256
|
+
refused too. Anything that did change is restored by `admin` to its
|
|
257
|
+
original value, the restore is verified, and only then is the field
|
|
258
|
+
named in `restored`.
|
|
259
|
+
"""
|
|
260
|
+
original = admin.get(COMPANY, company)
|
|
261
|
+
_refuse_an_unprovable_probe(admin, original, probe_role)
|
|
262
|
+
date_refused, date_changed, date_how = _probe_field(
|
|
263
|
+
admin, bot, company, LOCK_FIELDS[0], original.get(LOCK_FIELDS[0]), probe_role
|
|
264
|
+
)
|
|
265
|
+
role_refused, role_changed, role_how = _probe_field(
|
|
266
|
+
admin, bot, company, LOCK_FIELDS[1], original.get(LOCK_FIELDS[1]), probe_role
|
|
267
|
+
)
|
|
268
|
+
restored = tuple(
|
|
269
|
+
field for field, changed in ((LOCK_FIELDS[0], date_changed), (LOCK_FIELDS[1], role_changed)) if changed
|
|
270
|
+
)
|
|
271
|
+
return FenceProof(
|
|
272
|
+
date_refused=date_refused,
|
|
273
|
+
role_refused=role_refused,
|
|
274
|
+
date_how=date_how,
|
|
275
|
+
role_how=role_how,
|
|
276
|
+
restored=restored,
|
|
277
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""One read of the ledger, parsed exactly.
|
|
2
|
+
|
|
3
|
+
Every submitted, non-cancelled GL Entry for the company through the quarter's
|
|
4
|
+
end. Paged with `iter_all`, never a capped `list`: a short read would build a
|
|
5
|
+
package that balances and is wrong.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import datetime as dt
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
|
|
16
|
+
from icedcoffee_sync.apply.model import DesiredAccount
|
|
17
|
+
from icedcoffee_sync.erpnext.accounts import document_name
|
|
18
|
+
|
|
19
|
+
from runningtab.period import PeriodError
|
|
20
|
+
from runningtab.period.quarter import FiscalYear
|
|
21
|
+
|
|
22
|
+
GL_FIELDS = [
|
|
23
|
+
"name",
|
|
24
|
+
"posting_date",
|
|
25
|
+
"account",
|
|
26
|
+
"party_type",
|
|
27
|
+
"party",
|
|
28
|
+
"debit",
|
|
29
|
+
"credit",
|
|
30
|
+
"voucher_type",
|
|
31
|
+
"voucher_no",
|
|
32
|
+
"against_voucher_type",
|
|
33
|
+
"against_voucher",
|
|
34
|
+
"due_date",
|
|
35
|
+
"remarks",
|
|
36
|
+
"is_opening",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
AccountIndex = dict[str, DesiredAccount]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _Reader(Protocol):
|
|
43
|
+
def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
|
|
44
|
+
def get(self, doctype: str, name: str) -> Any: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass(frozen=True)
|
|
48
|
+
class GLRow:
|
|
49
|
+
"""One GL Entry, with money as exact decimals."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
posting_date: dt.date
|
|
53
|
+
account: str
|
|
54
|
+
party_type: str | None
|
|
55
|
+
party: str | None
|
|
56
|
+
debit: Decimal
|
|
57
|
+
credit: Decimal
|
|
58
|
+
voucher_type: str
|
|
59
|
+
voucher_no: str
|
|
60
|
+
against_voucher_type: str | None
|
|
61
|
+
against_voucher: str | None
|
|
62
|
+
due_date: dt.date | None
|
|
63
|
+
remarks: str
|
|
64
|
+
is_opening: bool
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def net(self) -> Decimal:
|
|
68
|
+
return self.debit - self.credit
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _money(value: object) -> Decimal:
|
|
72
|
+
# The client parses JSON with the standard library, so amounts arrive as
|
|
73
|
+
# floats; `str()` first so the binary tail never reaches the arithmetic.
|
|
74
|
+
if value in (None, ""):
|
|
75
|
+
return Decimal(0)
|
|
76
|
+
return Decimal(str(value))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _text(value: object) -> str | None:
|
|
80
|
+
return str(value) if value not in (None, "") else None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _date(value: object) -> dt.date | None:
|
|
84
|
+
return dt.date.fromisoformat(str(value)[:10]) if value not in (None, "") else None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_gl_row(row: dict[str, Any]) -> GLRow:
|
|
88
|
+
posting = _date(row["posting_date"])
|
|
89
|
+
if posting is None:
|
|
90
|
+
raise PeriodError(f"GL Entry {row.get('name')} has no posting date")
|
|
91
|
+
return GLRow(
|
|
92
|
+
name=str(row["name"]),
|
|
93
|
+
posting_date=posting,
|
|
94
|
+
account=str(row["account"]),
|
|
95
|
+
party_type=_text(row.get("party_type")),
|
|
96
|
+
party=_text(row.get("party")),
|
|
97
|
+
debit=_money(row.get("debit")),
|
|
98
|
+
credit=_money(row.get("credit")),
|
|
99
|
+
voucher_type=str(row.get("voucher_type") or ""),
|
|
100
|
+
voucher_no=str(row.get("voucher_no") or ""),
|
|
101
|
+
against_voucher_type=_text(row.get("against_voucher_type")),
|
|
102
|
+
against_voucher=_text(row.get("against_voucher")),
|
|
103
|
+
due_date=_date(row.get("due_date")),
|
|
104
|
+
remarks=str(row.get("remarks") or ""),
|
|
105
|
+
is_opening=str(row.get("is_opening") or "No") == "Yes",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _order(row: GLRow) -> tuple[dt.date, str, str, str]:
|
|
110
|
+
return (row.posting_date, row.voucher_type, row.voucher_no, row.name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _refuse_malformed_rows(rows: Sequence[GLRow]) -> None:
|
|
114
|
+
"""Refuse what offset paging over a non-unique sort key could still hand back.
|
|
115
|
+
|
|
116
|
+
`read_gl` pages on `posting_date asc, name asc`, a unique order, but a
|
|
117
|
+
reader depending on that guarantee should not trust it silently: refuse a
|
|
118
|
+
duplicate GL Entry `name` (paging that returned the same row twice, or
|
|
119
|
+
skipped one and duplicated another across a page boundary) and any row
|
|
120
|
+
with no `voucher_type` or `voucher_no`, which nothing downstream can
|
|
121
|
+
group into a voucher.
|
|
122
|
+
"""
|
|
123
|
+
seen: dict[str, int] = {}
|
|
124
|
+
for row in rows:
|
|
125
|
+
seen[row.name] = seen.get(row.name, 0) + 1
|
|
126
|
+
duplicates = sorted(name for name, count in seen.items() if count > 1)
|
|
127
|
+
if duplicates:
|
|
128
|
+
raise PeriodError(f"the GL Entry read returned duplicate rows: {', '.join(duplicates)}")
|
|
129
|
+
unvouchered = sorted(row.name for row in rows if not row.voucher_type or not row.voucher_no)
|
|
130
|
+
if unvouchered:
|
|
131
|
+
raise PeriodError(
|
|
132
|
+
f"GL Entries with no voucher_type or voucher_no cannot be grouped into a voucher: {', '.join(unvouchered)}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_gl(client: _Reader, company: str, through: dt.date) -> list[GLRow]:
|
|
137
|
+
"""Every live GL Entry for `company` dated on or before `through`, in package order.
|
|
138
|
+
|
|
139
|
+
Pages on `order_by="posting_date asc, name asc"`: `name` breaks ties on a
|
|
140
|
+
date, since offset paging over `posting_date asc` alone is a non-unique
|
|
141
|
+
sort key that can silently duplicate or skip a row across a page
|
|
142
|
+
boundary. `_refuse_malformed_rows` is the backstop for what a bad page
|
|
143
|
+
could still let through.
|
|
144
|
+
"""
|
|
145
|
+
rows = client.iter_all(
|
|
146
|
+
"GL Entry",
|
|
147
|
+
fields=GL_FIELDS,
|
|
148
|
+
filters=[["company", "=", company], ["is_cancelled", "=", 0], ["posting_date", "<=", through.isoformat()]],
|
|
149
|
+
order_by="posting_date asc, name asc",
|
|
150
|
+
)
|
|
151
|
+
parsed = sorted((parse_gl_row(row) for row in rows), key=_order)
|
|
152
|
+
_refuse_malformed_rows(parsed)
|
|
153
|
+
return parsed
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_fiscal_year(row: dict[str, Any]) -> FiscalYear:
|
|
157
|
+
return FiscalYear(
|
|
158
|
+
name=str(row["name"]),
|
|
159
|
+
start=dt.date.fromisoformat(str(row["year_start_date"])[:10]),
|
|
160
|
+
end=dt.date.fromisoformat(str(row["year_end_date"])[:10]),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def read_fiscal_years(client: _Reader, company: str) -> list[FiscalYear]:
|
|
165
|
+
"""Every enabled Fiscal Year that applies to `company`.
|
|
166
|
+
|
|
167
|
+
Fiscal Year's `companies` child table (doctype `Fiscal Year Company`,
|
|
168
|
+
field `company`) restricts which companies a year applies to -- an
|
|
169
|
+
empty table means every company, so a year with rows naming other
|
|
170
|
+
companies but not this one is excluded, and a year with no rows at all
|
|
171
|
+
is kept. `iter_all`'s listing does not expand child tables, so each
|
|
172
|
+
year is read individually with `client.get`; there are few Fiscal
|
|
173
|
+
Years, so this stays cheap.
|
|
174
|
+
"""
|
|
175
|
+
rows = client.iter_all(
|
|
176
|
+
"Fiscal Year", fields=["name", "year_start_date", "year_end_date"], filters=[["disabled", "=", 0]]
|
|
177
|
+
)
|
|
178
|
+
years = []
|
|
179
|
+
for row in rows:
|
|
180
|
+
doc = client.get("Fiscal Year", str(row["name"]))
|
|
181
|
+
companies = [str(c["company"]) for c in (doc.get("companies") or [])]
|
|
182
|
+
if companies and company not in companies:
|
|
183
|
+
continue
|
|
184
|
+
years.append(parse_fiscal_year(row))
|
|
185
|
+
return years
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def index_accounts(chart: Sequence[DesiredAccount], abbr: str) -> AccountIndex:
|
|
189
|
+
"""Chart accounts by the document name ERPNext gives them on this company."""
|
|
190
|
+
return {document_name(a.name, abbr, a.number): a for a in chart}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def require_known_accounts(rows: Sequence[GLRow], index: AccountIndex) -> None:
|
|
194
|
+
"""Refuse when any GL Entry sits on an account `chart.coa` does not describe.
|
|
195
|
+
|
|
196
|
+
Such rows would otherwise have nowhere to go in the package, and dropping
|
|
197
|
+
them would hand over books that balance and are wrong.
|
|
198
|
+
"""
|
|
199
|
+
unknown = sorted({r.account for r in rows if r.account not in index})
|
|
200
|
+
if unknown:
|
|
201
|
+
raise PeriodError(
|
|
202
|
+
"GL Entries sit on accounts chart.coa does not describe: "
|
|
203
|
+
+ "; ".join(unknown)
|
|
204
|
+
+ ". Add them to the chart (and apply it) or move the entries."
|
|
205
|
+
)
|