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
runningtab/chart.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""Load and validate a chart of accounts from a books directory's `chart.coa`.
|
|
2
|
+
|
|
3
|
+
The file is an indented outline: one account per line (`<number> <name>
|
|
4
|
+
[tags]`), two spaces per level, `>` lines carrying the account's note, and a
|
|
5
|
+
trailing `[defaults]` block. A group is any account with children -- an
|
|
6
|
+
empty group cannot be expressed. Every check here fails at load time so
|
|
7
|
+
that nothing fails halfway through an apply.
|
|
8
|
+
|
|
9
|
+
A tag block holds at most one `root:` tag, at most one ERPNext account type,
|
|
10
|
+
and the reserved word `no-bank-line`, which says the account only ever
|
|
11
|
+
receives a period-end or adjustment journal. The chart is the one place that
|
|
12
|
+
fact is written down; `runningtab.mercury.categories` reads it rather than keeping
|
|
13
|
+
a list of numbers beside the chart, because such a list goes stale invisibly.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import dataclasses
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
from icedcoffee_sync.apply.model import DesiredAccount
|
|
23
|
+
from icedcoffee_sync.erpnext.accounts import ROOT_TYPES, UnknownAccountTypeError, validate_account_type
|
|
24
|
+
|
|
25
|
+
# Every Company default field the chart must declare. Kept outside the file
|
|
26
|
+
# so a truncated [defaults] block is a load error, not a silently
|
|
27
|
+
# half-configured ledger; change it and chart.coa together.
|
|
28
|
+
REQUIRED_DEFAULT_FIELDS: frozenset[str] = frozenset({
|
|
29
|
+
"default_bank_account",
|
|
30
|
+
"default_cash_account",
|
|
31
|
+
"default_receivable_account",
|
|
32
|
+
"default_payable_account",
|
|
33
|
+
"default_expense_account",
|
|
34
|
+
"default_income_account",
|
|
35
|
+
"round_off_account",
|
|
36
|
+
"exchange_gain_loss_account",
|
|
37
|
+
"write_off_account",
|
|
38
|
+
"default_deferred_revenue_account",
|
|
39
|
+
"default_deferred_expense_account",
|
|
40
|
+
"accumulated_depreciation_account",
|
|
41
|
+
"depreciation_expense_account",
|
|
42
|
+
"disposal_account",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
# Where the ingest puts a transaction it could not classify, and what
|
|
46
|
+
# `runningtab health` ages. Named here rather than only in `chart.coa` so that
|
|
47
|
+
# deleting it from the chart is a load error: the alternative is a health
|
|
48
|
+
# report that quietly stops looking at the one account nobody watches.
|
|
49
|
+
SUSPENSE_ACCOUNT_NUMBER = "5299"
|
|
50
|
+
|
|
51
|
+
_ACCOUNT_LINE = re.compile(r"^(?P<number>\d+)(?:\s+(?P<rest>.*))?$")
|
|
52
|
+
_ROOT_TAG_PREFIX = "root:"
|
|
53
|
+
# A reserved tag word, recognised before the account-type slot so it never
|
|
54
|
+
# reaches `validate_account_type`. It marks an account that only ever receives
|
|
55
|
+
# a period-end or adjustment journal -- depreciation, rounding, FX, write-offs,
|
|
56
|
+
# a disposal gain -- and so can never be what somebody picks against a bank
|
|
57
|
+
# line. `runningtab.mercury.categories` reads it and offers no category for those.
|
|
58
|
+
NO_BANK_LINE_TAG = "no-bank-line"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ChartError(ValueError):
|
|
62
|
+
"""The chart file is not a chart we can apply."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclasses.dataclass(frozen=True)
|
|
66
|
+
class ChartEntry:
|
|
67
|
+
"""One account as the outline describes it, plus its note."""
|
|
68
|
+
|
|
69
|
+
account: DesiredAccount
|
|
70
|
+
note: str
|
|
71
|
+
# Whether the line carried `no-bank-line`. Lives here and not on
|
|
72
|
+
# `DesiredAccount` because it says nothing to ERPNext: the account is
|
|
73
|
+
# ordinary there, and only the bank-facing pickers care.
|
|
74
|
+
no_bank_line: bool = False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclasses.dataclass(frozen=True)
|
|
78
|
+
class ChartDocument:
|
|
79
|
+
"""The parsed `chart.coa` file: accounts in file order, plus defaults."""
|
|
80
|
+
|
|
81
|
+
entries: list[ChartEntry]
|
|
82
|
+
defaults: dict[str, str]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _split_name_and_tags(rest: str, lineno: int) -> tuple[str, list[str]]:
|
|
86
|
+
"""Split `<name> [tag, tag]` into the name and the tag list.
|
|
87
|
+
|
|
88
|
+
Names may contain `&`, `/`, `(`, `)`, en dashes, `$`, and `,` -- only `[`
|
|
89
|
+
is reserved -- so the first `[` unambiguously starts the tag block.
|
|
90
|
+
"""
|
|
91
|
+
if "[" not in rest:
|
|
92
|
+
name = rest.rstrip()
|
|
93
|
+
return name, []
|
|
94
|
+
name_part, _, tag_part = rest.partition("[")
|
|
95
|
+
name = name_part.rstrip()
|
|
96
|
+
if not tag_part.endswith("]"):
|
|
97
|
+
raise ChartError(f"line {lineno}: tag block is missing its closing ']'")
|
|
98
|
+
tag_content = tag_part[:-1].strip()
|
|
99
|
+
tags = [t.strip() for t in tag_content.split(",") if t.strip()]
|
|
100
|
+
if not tags:
|
|
101
|
+
raise ChartError(f"line {lineno}: empty tag block '[]'")
|
|
102
|
+
return name, tags
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_tags(tags: list[str], lineno: int) -> tuple[str | None, str | None, bool]:
|
|
106
|
+
"""Split tags into (root type or None, account type or None, no-bank-line)."""
|
|
107
|
+
root_declared: str | None = None
|
|
108
|
+
account_type: str | None = None
|
|
109
|
+
no_bank_line = False
|
|
110
|
+
for tag in tags:
|
|
111
|
+
if tag.startswith(_ROOT_TAG_PREFIX):
|
|
112
|
+
if root_declared is not None:
|
|
113
|
+
raise ChartError(f"line {lineno}: more than one 'root:' tag")
|
|
114
|
+
root_declared = tag[len(_ROOT_TAG_PREFIX) :].strip()
|
|
115
|
+
elif tag == NO_BANK_LINE_TAG:
|
|
116
|
+
# Matched before the account-type slot: it is a word of our own,
|
|
117
|
+
# not one of ERPNext's account types, and it shares the tag block
|
|
118
|
+
# with one (`[Depreciation, no-bank-line]`).
|
|
119
|
+
if no_bank_line:
|
|
120
|
+
raise ChartError(f"line {lineno}: more than one {NO_BANK_LINE_TAG!r} tag")
|
|
121
|
+
no_bank_line = True
|
|
122
|
+
else:
|
|
123
|
+
if account_type is not None:
|
|
124
|
+
raise ChartError(f"line {lineno}: more than one account-type tag ({account_type!r}, {tag!r})")
|
|
125
|
+
account_type = tag
|
|
126
|
+
return root_declared, account_type, no_bank_line
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclasses.dataclass
|
|
130
|
+
class _ParseState:
|
|
131
|
+
"""Mutable state threaded through one pass over the file."""
|
|
132
|
+
|
|
133
|
+
entries: list[ChartEntry] = dataclasses.field(default_factory=list)
|
|
134
|
+
root_types_by_number: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
135
|
+
numbers_seen: set[str] = dataclasses.field(default_factory=set)
|
|
136
|
+
ancestors: list[str] = dataclasses.field(default_factory=list) # ancestors[level] = number at that level
|
|
137
|
+
last_entry_index: int | None = None
|
|
138
|
+
defaults: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _parse_default_line(stripped: str, lineno: int, defaults: dict[str, str]) -> None:
|
|
142
|
+
if "=" not in stripped:
|
|
143
|
+
raise ChartError(f"line {lineno}: expected 'key = number' inside [defaults]")
|
|
144
|
+
key, _, value = stripped.partition("=")
|
|
145
|
+
key, value = key.strip(), value.strip()
|
|
146
|
+
if not value.isdigit():
|
|
147
|
+
raise ChartError(f"line {lineno}: default {key!r} must be an account number, got {value!r}")
|
|
148
|
+
if key in defaults:
|
|
149
|
+
raise ChartError(f"line {lineno}: duplicate default {key!r}")
|
|
150
|
+
defaults[key] = value
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _append_note(state: _ParseState, stripped: str, lineno: int) -> None:
|
|
154
|
+
if state.last_entry_index is None:
|
|
155
|
+
raise ChartError(f"line {lineno}: note has no preceding account")
|
|
156
|
+
note_text = stripped[1:].strip()
|
|
157
|
+
previous = state.entries[state.last_entry_index]
|
|
158
|
+
state.entries[state.last_entry_index] = dataclasses.replace(previous, note=f"{previous.note} {note_text}".strip())
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _account_level(raw: str, lineno: int) -> tuple[int, str]:
|
|
162
|
+
indent = len(raw) - len(raw.lstrip(" "))
|
|
163
|
+
if indent % 2 != 0:
|
|
164
|
+
raise ChartError(f"line {lineno}: indent must be a whole number of two-space levels, got {indent} spaces")
|
|
165
|
+
return indent // 2, raw[indent:]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _account_root_type(
|
|
169
|
+
level: int, parent_number: str | None, root_declared: str | None, number: str, lineno: int, state: _ParseState
|
|
170
|
+
) -> str:
|
|
171
|
+
if level > 0:
|
|
172
|
+
if root_declared is not None:
|
|
173
|
+
raise ChartError(f"line {lineno}: account {number} is not a root; it must not carry 'root:'")
|
|
174
|
+
if parent_number is None:
|
|
175
|
+
# Unreachable in practice: _parse_account_line only calls this at
|
|
176
|
+
# level > 0 once it has confirmed a parent slot exists. Guarded
|
|
177
|
+
# explicitly anyway, so a future refactor that breaks that
|
|
178
|
+
# invariant fails loudly instead of crashing on a bad dict key.
|
|
179
|
+
raise ChartError(f"line {lineno}: account {number} has no parent to inherit a root type from")
|
|
180
|
+
return state.root_types_by_number[parent_number]
|
|
181
|
+
if root_declared is None:
|
|
182
|
+
raise ChartError(f"line {lineno}: root account {number} must carry a 'root: <RootType>' tag")
|
|
183
|
+
if root_declared not in ROOT_TYPES:
|
|
184
|
+
raise ChartError(
|
|
185
|
+
f"line {lineno}: root account {number} has root_type {root_declared!r}; expected one of {sorted(ROOT_TYPES)}"
|
|
186
|
+
)
|
|
187
|
+
return root_declared
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _parse_account_line(raw: str, lineno: int, state: _ParseState) -> None:
|
|
191
|
+
level, body = _account_level(raw, lineno)
|
|
192
|
+
|
|
193
|
+
match = _ACCOUNT_LINE.match(body)
|
|
194
|
+
if not match:
|
|
195
|
+
raise ChartError(f"line {lineno}: not a valid account line: {raw!r}")
|
|
196
|
+
number = match.group("number")
|
|
197
|
+
name, tags = _split_name_and_tags(match.group("rest") or "", lineno)
|
|
198
|
+
if not name:
|
|
199
|
+
raise ChartError(f"line {lineno}: account {number} has no name")
|
|
200
|
+
|
|
201
|
+
if number in state.numbers_seen:
|
|
202
|
+
raise ChartError(f"line {lineno}: duplicate account number {number}")
|
|
203
|
+
state.numbers_seen.add(number)
|
|
204
|
+
|
|
205
|
+
if level > len(state.ancestors):
|
|
206
|
+
raise ChartError(f"line {lineno}: account {number} is indented more than one level past the previous account")
|
|
207
|
+
del state.ancestors[level:]
|
|
208
|
+
parent_number = state.ancestors[-1] if state.ancestors else None
|
|
209
|
+
|
|
210
|
+
root_declared, account_type, no_bank_line = _parse_tags(tags, lineno)
|
|
211
|
+
root_type = _account_root_type(level, parent_number, root_declared, number, lineno, state)
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
validate_account_type(account_type)
|
|
215
|
+
except UnknownAccountTypeError as exc:
|
|
216
|
+
raise ChartError(f"line {lineno}: account {number}: {exc}") from exc
|
|
217
|
+
|
|
218
|
+
state.ancestors.append(number)
|
|
219
|
+
state.root_types_by_number[number] = root_type
|
|
220
|
+
state.entries.append(
|
|
221
|
+
ChartEntry(
|
|
222
|
+
account=DesiredAccount(
|
|
223
|
+
number=number,
|
|
224
|
+
name=name,
|
|
225
|
+
parent_number=parent_number,
|
|
226
|
+
is_group=False, # derived below, once every child is known
|
|
227
|
+
root_type=root_type,
|
|
228
|
+
account_type=account_type,
|
|
229
|
+
),
|
|
230
|
+
note="",
|
|
231
|
+
no_bank_line=no_bank_line,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
state.last_entry_index = len(state.entries) - 1
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _derive_is_group(entries: list[ChartEntry]) -> list[ChartEntry]:
|
|
238
|
+
parents = {entry.account.parent_number for entry in entries if entry.account.parent_number}
|
|
239
|
+
return [
|
|
240
|
+
dataclasses.replace(entry, account=dataclasses.replace(entry.account, is_group=entry.account.number in parents))
|
|
241
|
+
for entry in entries
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse(text: str) -> ChartDocument:
|
|
246
|
+
state = _ParseState()
|
|
247
|
+
in_defaults = False
|
|
248
|
+
|
|
249
|
+
for lineno, raw in enumerate(text.splitlines(), start=1):
|
|
250
|
+
stripped = raw.strip()
|
|
251
|
+
if not stripped or stripped.startswith("#"):
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
if stripped == "[defaults]":
|
|
255
|
+
if in_defaults:
|
|
256
|
+
raise ChartError(f"line {lineno}: a second '[defaults]' section; it must appear once, last")
|
|
257
|
+
in_defaults = True
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
if in_defaults:
|
|
261
|
+
_parse_default_line(stripped, lineno, state.defaults)
|
|
262
|
+
elif stripped.startswith(">"):
|
|
263
|
+
_append_note(state, stripped, lineno)
|
|
264
|
+
else:
|
|
265
|
+
_parse_account_line(raw, lineno, state)
|
|
266
|
+
|
|
267
|
+
return ChartDocument(entries=_derive_is_group(state.entries), defaults=state.defaults)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def load_chart_document(path: pathlib.Path) -> ChartDocument:
|
|
271
|
+
"""Parse `chart.coa`, or raise `ChartError` naming the offending line."""
|
|
272
|
+
return _parse(path.read_text(encoding="utf-8"))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def parse_chart_entries(text: str) -> list[ChartEntry]:
|
|
276
|
+
"""`load_chart_entries`, from `chart.coa` text already in hand rather than read from `path` here.
|
|
277
|
+
|
|
278
|
+
For a caller that must use one single read of the file across more than
|
|
279
|
+
one step -- `period export` validates the live chart against it, builds
|
|
280
|
+
the package from it, and hashes it for the manifest -- so that an edit
|
|
281
|
+
landing between those reads cannot make one step see different content
|
|
282
|
+
than another.
|
|
283
|
+
"""
|
|
284
|
+
document = _parse(text)
|
|
285
|
+
accounts = [entry.account for entry in document.entries]
|
|
286
|
+
_check_match_keys(accounts)
|
|
287
|
+
_check_roots(accounts)
|
|
288
|
+
_check_defaults(accounts, document.defaults)
|
|
289
|
+
_check_suspense(accounts)
|
|
290
|
+
return document.entries
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def load_chart_entries(path: pathlib.Path) -> list[ChartEntry]:
|
|
294
|
+
"""Read the chart and run every check, keeping each account's note and tags.
|
|
295
|
+
|
|
296
|
+
`load_chart` is this with the notes and tags dropped. Anything that needs
|
|
297
|
+
`no_bank_line` -- Mercury's category selection -- comes through here, so
|
|
298
|
+
the flag reaches its reader from the same parse that validated the file.
|
|
299
|
+
"""
|
|
300
|
+
return parse_chart_entries(path.read_text(encoding="utf-8"))
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def parse_chart(text: str) -> list[DesiredAccount]:
|
|
304
|
+
"""`load_chart`, from `chart.coa` text already in hand -- see `parse_chart_entries`."""
|
|
305
|
+
return [entry.account for entry in parse_chart_entries(text)]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def load_chart(path: pathlib.Path) -> list[DesiredAccount]:
|
|
309
|
+
"""Read the chart, or raise ChartError describing exactly what is wrong."""
|
|
310
|
+
return [entry.account for entry in load_chart_entries(path)]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def suspense_account(accounts: list[DesiredAccount]) -> DesiredAccount:
|
|
314
|
+
"""The account a person parks a statement line in when they cannot yet say where it belongs.
|
|
315
|
+
|
|
316
|
+
Present because `load_chart` insists: `runningtab health` ages what sits here.
|
|
317
|
+
"""
|
|
318
|
+
return next(a for a in accounts if a.number == SUSPENSE_ACCOUNT_NUMBER)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _check_suspense(accounts: list[DesiredAccount]) -> None:
|
|
322
|
+
"""The suspense account exists, is postable, and is an expense."""
|
|
323
|
+
account = next((a for a in accounts if a.number == SUSPENSE_ACCOUNT_NUMBER), None)
|
|
324
|
+
if account is None:
|
|
325
|
+
raise ChartError(
|
|
326
|
+
f"chart has no suspense account {SUSPENSE_ACCOUNT_NUMBER}; a person reconciling a statement line "
|
|
327
|
+
f"they cannot yet code has nowhere to park it, and `runningtab health` has nothing to age"
|
|
328
|
+
)
|
|
329
|
+
if account.is_group:
|
|
330
|
+
raise ChartError(f"suspense account {SUSPENSE_ACCOUNT_NUMBER} is a group and cannot be posted to")
|
|
331
|
+
if account.root_type != "Expense":
|
|
332
|
+
raise ChartError(
|
|
333
|
+
f"suspense account {SUSPENSE_ACCOUNT_NUMBER} has root_type {account.root_type!r}; expected 'Expense'"
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _check_roots(accounts: list[DesiredAccount]) -> None:
|
|
338
|
+
"""Exactly one root per ERPNext root type.
|
|
339
|
+
|
|
340
|
+
A missing root type would make the site's whole subtree for it extras,
|
|
341
|
+
which prune would delete after nothing but the usual prompt.
|
|
342
|
+
"""
|
|
343
|
+
root_types = [a.root_type for a in accounts if a.parent_number is None]
|
|
344
|
+
missing = sorted(ROOT_TYPES - set(root_types))
|
|
345
|
+
if missing:
|
|
346
|
+
raise ChartError(f"chart has no root account for root_type(s): {', '.join(missing)}")
|
|
347
|
+
duplicated = sorted({rt for rt in root_types if root_types.count(rt) > 1})
|
|
348
|
+
if duplicated:
|
|
349
|
+
raise ChartError(f"chart declares more than one root for root_type(s): {', '.join(duplicated)}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _check_match_keys(accounts: list[DesiredAccount]) -> None:
|
|
353
|
+
"""Refuse two accounts `diff` cannot tell apart: same name under the same parent.
|
|
354
|
+
|
|
355
|
+
Keyed on the parent's number, not its name, because the matching walk
|
|
356
|
+
descends by parent identity; roots are identified by root type instead.
|
|
357
|
+
"""
|
|
358
|
+
seen: dict[tuple[str, str], str] = {}
|
|
359
|
+
for account in accounts:
|
|
360
|
+
if account.parent_number is None:
|
|
361
|
+
continue
|
|
362
|
+
key = (account.name, account.parent_number)
|
|
363
|
+
if key in seen:
|
|
364
|
+
raise ChartError(
|
|
365
|
+
f"accounts {seen[key]} and {account.number} share name {account.name!r} "
|
|
366
|
+
f"under the same parent; the diff cannot tell them apart"
|
|
367
|
+
)
|
|
368
|
+
seen[key] = account.number
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _check_defaults(accounts: list[DesiredAccount], defaults: dict[str, str]) -> None:
|
|
372
|
+
"""Every default names a postable chart account, and the set is exactly `REQUIRED_DEFAULT_FIELDS`.
|
|
373
|
+
|
|
374
|
+
Values are checked before the key set so a bad value is reported as
|
|
375
|
+
itself rather than as a completeness complaint. ERPNext cannot post to
|
|
376
|
+
a group, so a default naming one is refused.
|
|
377
|
+
"""
|
|
378
|
+
by_number = {a.number: a for a in accounts}
|
|
379
|
+
for field, number in defaults.items():
|
|
380
|
+
account = by_number.get(number)
|
|
381
|
+
if account is None:
|
|
382
|
+
raise ChartError(f"default {field!r} names account {number}, which is not in the chart")
|
|
383
|
+
if account.is_group:
|
|
384
|
+
raise ChartError(
|
|
385
|
+
f"default {field!r} names account {number} ({account.name!r}), which is a group and cannot be posted to"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
missing_fields = sorted(REQUIRED_DEFAULT_FIELDS - defaults.keys())
|
|
389
|
+
if missing_fields:
|
|
390
|
+
raise ChartError(f"chart's [defaults] section is missing required field(s): {', '.join(missing_fields)}")
|
|
391
|
+
unexpected_fields = sorted(defaults.keys() - REQUIRED_DEFAULT_FIELDS)
|
|
392
|
+
if unexpected_fields:
|
|
393
|
+
raise ChartError(
|
|
394
|
+
f"chart's [defaults] section names field(s) not in REQUIRED_DEFAULT_FIELDS: {', '.join(unexpected_fields)}"
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def parse_defaults(text: str) -> dict[str, str]:
|
|
399
|
+
"""`load_defaults`, from `chart.coa` text already in hand -- see `parse_chart_entries`."""
|
|
400
|
+
document = _parse(text)
|
|
401
|
+
accounts = [entry.account for entry in document.entries]
|
|
402
|
+
_check_defaults(accounts, document.defaults)
|
|
403
|
+
return dict(document.defaults)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def load_defaults(path: pathlib.Path) -> dict[str, str]:
|
|
407
|
+
"""Company default-account fields, as account numbers."""
|
|
408
|
+
return parse_defaults(path.read_text(encoding="utf-8"))
|