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,174 @@
|
|
|
1
|
+
"""What QuickBooks Online needs to know about our chart, from `qbo.toml`.
|
|
2
|
+
|
|
3
|
+
Explicit per account rather than a rule on root and account type: 62 lines a
|
|
4
|
+
person can read beat an engine whose output they have to predict.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import pathlib
|
|
11
|
+
import tomllib
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from icedcoffee_sync.apply.model import DesiredAccount
|
|
16
|
+
|
|
17
|
+
from runningtab.period import PeriodError
|
|
18
|
+
|
|
19
|
+
CLEARING_ACCOUNT_NAME = "QBO Import Clearing"
|
|
20
|
+
|
|
21
|
+
# What a [builtin] entry's `role` may say, and the ERPNext fact on the chart
|
|
22
|
+
# account it names that must then be true -- the cross-check
|
|
23
|
+
# `validate_qbo_map` runs so that an entry naming the wrong QBO built-in (the
|
|
24
|
+
# two swapped, say) is refused instead of quietly posting balances to the
|
|
25
|
+
# wrong QuickBooks account.
|
|
26
|
+
BUILTIN_ROLES = frozenset({"receivable", "payable", "retained_earnings"})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclasses.dataclass(frozen=True)
|
|
30
|
+
class BuiltinAccount:
|
|
31
|
+
"""A chart account QBO already carries by default: its QBO name, and which of the three builtins it is."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
role: str # one of BUILTIN_ROLES
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclasses.dataclass(frozen=True)
|
|
38
|
+
class QboMap:
|
|
39
|
+
types: dict[str, frozenset[str]]
|
|
40
|
+
accounts: dict[str, tuple[str, str]]
|
|
41
|
+
builtin: dict[str, BuiltinAccount]
|
|
42
|
+
clearing: tuple[str, str]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
|
|
46
|
+
value = data.get(key)
|
|
47
|
+
if not isinstance(value, dict):
|
|
48
|
+
raise PeriodError(f"qbo.toml has no [{key}] table")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _expect_list_of_str(value: Any, where: str, problems: list[str]) -> list[str] | None:
|
|
53
|
+
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
54
|
+
return value
|
|
55
|
+
problems.append(f"{where}: expected a list of strings, got {value!r}")
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _expect_str(value: Any, where: str, problems: list[str]) -> str:
|
|
60
|
+
"""Return `value` if it is a string, else record the problem and return a placeholder.
|
|
61
|
+
|
|
62
|
+
The placeholder is never read: every caller only uses the return value
|
|
63
|
+
after `parse_qbo_map` has raised on a non-empty `problems`.
|
|
64
|
+
"""
|
|
65
|
+
if isinstance(value, str):
|
|
66
|
+
return value
|
|
67
|
+
problems.append(f"{where}: expected a string, got {value!r}")
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_qbo_map(text: str) -> QboMap:
|
|
72
|
+
"""Parse `qbo.toml`, or raise `PeriodError` naming every malformed entry at once.
|
|
73
|
+
|
|
74
|
+
A hand edit that gets a shape wrong -- a bare string where `[Type, Detail
|
|
75
|
+
Type]` belongs, a one-element list, a number -- must be refused by name,
|
|
76
|
+
not silently misread or left to crash on a raw Python `IndexError` or
|
|
77
|
+
`TypeError` a person editing the file would never connect to their typo.
|
|
78
|
+
"""
|
|
79
|
+
data: dict[str, Any] = tomllib.loads(text)
|
|
80
|
+
problems: list[str] = []
|
|
81
|
+
|
|
82
|
+
types: dict[str, frozenset[str]] = {}
|
|
83
|
+
for key, value in _table(data, "types").items():
|
|
84
|
+
detail_types = _expect_list_of_str(value, f"[types] {key!r}", problems)
|
|
85
|
+
if detail_types is not None:
|
|
86
|
+
types[str(key)] = frozenset(detail_types)
|
|
87
|
+
|
|
88
|
+
accounts: dict[str, tuple[str, str]] = {}
|
|
89
|
+
for key, value in _table(data, "accounts").items():
|
|
90
|
+
if isinstance(value, list) and len(value) == 2 and all(isinstance(item, str) for item in value):
|
|
91
|
+
accounts[str(key)] = (value[0], value[1])
|
|
92
|
+
else:
|
|
93
|
+
problems.append(f"[accounts] {key!r}: expected [Type, Detail Type], got {value!r}")
|
|
94
|
+
|
|
95
|
+
builtin: dict[str, BuiltinAccount] = {}
|
|
96
|
+
for key, value in _table(data, "builtin").items():
|
|
97
|
+
if not isinstance(value, dict):
|
|
98
|
+
problems.append(f"[builtin] {key!r}: expected {{name = ..., role = ...}}, got {value!r}")
|
|
99
|
+
continue
|
|
100
|
+
name = _expect_str(value.get("name"), f"[builtin] {key!r} 'name'", problems)
|
|
101
|
+
role = _expect_str(value.get("role"), f"[builtin] {key!r} 'role'", problems)
|
|
102
|
+
if role and role not in BUILTIN_ROLES:
|
|
103
|
+
problems.append(f"[builtin] {key!r} 'role': {role!r} is not one of {sorted(BUILTIN_ROLES)}")
|
|
104
|
+
builtin[str(key)] = BuiltinAccount(name, role)
|
|
105
|
+
|
|
106
|
+
clearing_raw = _table(data, "clearing")
|
|
107
|
+
clearing_type = _expect_str(clearing_raw.get("type"), "[clearing] 'type'", problems)
|
|
108
|
+
clearing_detail = _expect_str(clearing_raw.get("detail"), "[clearing] 'detail'", problems)
|
|
109
|
+
|
|
110
|
+
if problems:
|
|
111
|
+
raise PeriodError("qbo.toml is malformed:\n " + "\n ".join(problems))
|
|
112
|
+
|
|
113
|
+
return QboMap(types, accounts, builtin, (clearing_type, clearing_detail))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def load_qbo_map(path: pathlib.Path) -> QboMap:
|
|
117
|
+
return parse_qbo_map(path.read_text(encoding="utf-8"))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_ROLE_MATCHES: dict[str, Any] = {
|
|
121
|
+
"receivable": lambda a: a.account_type == "Receivable",
|
|
122
|
+
"payable": lambda a: a.account_type == "Payable",
|
|
123
|
+
"retained_earnings": lambda a: a.root_type == "Equity",
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _builtin_role_problems(qmap: QboMap, by_number: dict[str, DesiredAccount]) -> list[str]:
|
|
128
|
+
"""A problem per [builtin] entry whose chart account isn't actually its `role`'s ERPNext type.
|
|
129
|
+
|
|
130
|
+
`role` names which of QBO's three built-in accounts a chart account
|
|
131
|
+
stands in for; independently of what its `name` says, the account must
|
|
132
|
+
actually be that role's type -- otherwise the two could have been
|
|
133
|
+
swapped (Receivable given Payable's role, say) with nothing else in this
|
|
134
|
+
file catching it, and balances would post to the wrong QuickBooks
|
|
135
|
+
account despite `check_replay` passing (it only checks the kit
|
|
136
|
+
reproduces itself, not that qbo.toml named the right accounts to begin
|
|
137
|
+
with).
|
|
138
|
+
"""
|
|
139
|
+
problems: list[str] = []
|
|
140
|
+
for number, entry in sorted(qmap.builtin.items()):
|
|
141
|
+
account = by_number.get(number)
|
|
142
|
+
matches = _ROLE_MATCHES.get(entry.role)
|
|
143
|
+
if account is None or matches is None:
|
|
144
|
+
continue # already reported by the "not in chart.coa" or role-enum check
|
|
145
|
+
if not matches(account):
|
|
146
|
+
actual = account.account_type or account.root_type
|
|
147
|
+
problems.append(f"{number} ({actual!r}): [builtin] role {entry.role!r} does not match")
|
|
148
|
+
return problems
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def validate_qbo_map(qmap: QboMap, chart: Sequence[DesiredAccount]) -> None:
|
|
152
|
+
"""Refuse, naming every problem, a map the import kit could not be built from."""
|
|
153
|
+
by_number = {a.number: a for a in chart}
|
|
154
|
+
postable = {a.number for a in chart if not a.is_group}
|
|
155
|
+
problems: list[str] = []
|
|
156
|
+
for number in sorted(postable - qmap.accounts.keys() - qmap.builtin.keys()):
|
|
157
|
+
problems.append(f"{number} {by_number[number].name}: no entry in [accounts] or [builtin]")
|
|
158
|
+
for number in sorted(qmap.accounts.keys() & qmap.builtin.keys()):
|
|
159
|
+
problems.append(f"{number}: in both [accounts] and [builtin]")
|
|
160
|
+
for number in sorted((qmap.accounts.keys() | qmap.builtin.keys()) - postable):
|
|
161
|
+
problems.append(f"{number}: not a postable account in chart.coa")
|
|
162
|
+
for number, (qtype, detail) in sorted([*qmap.accounts.items(), ("clearing", qmap.clearing)]):
|
|
163
|
+
if detail not in qmap.types.get(qtype, frozenset()):
|
|
164
|
+
problems.append(f"{number}: {qtype!r} / {detail!r} is not a Type and Detail Type listed in [types]")
|
|
165
|
+
problems.extend(_builtin_role_problems(qmap, by_number))
|
|
166
|
+
names = (
|
|
167
|
+
[by_number[n].name for n in qmap.accounts if n in by_number]
|
|
168
|
+
+ [entry.name for entry in qmap.builtin.values()]
|
|
169
|
+
+ [CLEARING_ACCOUNT_NAME]
|
|
170
|
+
)
|
|
171
|
+
for name in sorted({n for n in names if names.count(n) > 1}):
|
|
172
|
+
problems.append(f"{name!r}: more than one account would import under this name")
|
|
173
|
+
if problems:
|
|
174
|
+
raise PeriodError("qbo.toml cannot build the import kit:\n " + "\n ".join(problems))
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Calendar quarters, and the ERPNext Fiscal Year each must sit inside."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import datetime as dt
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
from runningtab.period import PeriodError
|
|
11
|
+
|
|
12
|
+
_QUARTER = re.compile(r"(?P<year>\d{4})Q(?P<number>[1-4])")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclasses.dataclass(frozen=True, order=True)
|
|
16
|
+
class Quarter:
|
|
17
|
+
"""One calendar quarter, named the way the command line takes it: `2026Q3`."""
|
|
18
|
+
|
|
19
|
+
year: int
|
|
20
|
+
number: int
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def parse(cls, text: str) -> Quarter:
|
|
24
|
+
# `\d{4}` matches "0000"; `dt.date`'s minimum year is 1, so "0000Q1"
|
|
25
|
+
# parsed clean and only failed later, with a bare ValueError, the
|
|
26
|
+
# first time something touched `.start` or `.end`.
|
|
27
|
+
match = _QUARTER.fullmatch(text)
|
|
28
|
+
if match is None or int(match["year"]) < 1:
|
|
29
|
+
raise PeriodError(f"{text!r} is not a quarter; write it as YYYYQn, e.g. 2026Q3")
|
|
30
|
+
return cls(int(match["year"]), int(match["number"]))
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def containing(cls, date: dt.date) -> Quarter:
|
|
34
|
+
"""The quarter `date` falls in."""
|
|
35
|
+
return cls(date.year, (date.month - 1) // 3 + 1)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def previous(self) -> Quarter:
|
|
39
|
+
"""The quarter just before this one."""
|
|
40
|
+
if self.number == 1:
|
|
41
|
+
return Quarter(self.year - 1, 4)
|
|
42
|
+
return Quarter(self.year, self.number - 1)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def label(self) -> str:
|
|
46
|
+
return f"{self.year}Q{self.number}"
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def start(self) -> dt.date:
|
|
50
|
+
return dt.date(self.year, 3 * self.number - 2, 1)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def end(self) -> dt.date:
|
|
54
|
+
if self.number == 4:
|
|
55
|
+
return dt.date(self.year, 12, 31)
|
|
56
|
+
return dt.date(self.year, 3 * self.number + 1, 1) - dt.timedelta(days=1)
|
|
57
|
+
|
|
58
|
+
def has_ended(self, today: dt.date) -> bool:
|
|
59
|
+
return today > self.end
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclasses.dataclass(frozen=True)
|
|
63
|
+
class FiscalYear:
|
|
64
|
+
"""An ERPNext Fiscal Year: P&L accounts reset at its start."""
|
|
65
|
+
|
|
66
|
+
name: str
|
|
67
|
+
start: dt.date
|
|
68
|
+
end: dt.date
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fiscal_year_for(quarter: Quarter, years: Sequence[FiscalYear]) -> FiscalYear:
|
|
72
|
+
"""The one Fiscal Year that wholly contains `quarter`.
|
|
73
|
+
|
|
74
|
+
Refuses a quarter no year holds, one split across two, and more than one
|
|
75
|
+
candidate -- the opening balance of every P&L account depends on the
|
|
76
|
+
answer, so it is never guessed.
|
|
77
|
+
"""
|
|
78
|
+
holding = [y for y in years if y.start <= quarter.start and quarter.end <= y.end]
|
|
79
|
+
if len(holding) == 1:
|
|
80
|
+
return holding[0]
|
|
81
|
+
if len(holding) > 1:
|
|
82
|
+
names = ", ".join(sorted(y.name for y in holding))
|
|
83
|
+
raise PeriodError(f"{quarter.label} sits in more than one ERPNext Fiscal Year: {names}")
|
|
84
|
+
touching = [y for y in years if y.start <= quarter.end and quarter.start <= y.end]
|
|
85
|
+
if touching:
|
|
86
|
+
raise PeriodError(
|
|
87
|
+
f"{quarter.label} does not fall within a single Fiscal Year; it spans "
|
|
88
|
+
f"{', '.join(sorted(y.name for y in touching))}"
|
|
89
|
+
)
|
|
90
|
+
raise PeriodError(f"{quarter.label}: no ERPNext Fiscal Year covers it; create one first")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""What blocks a quarter's close.
|
|
2
|
+
|
|
3
|
+
A hard stop is a fact ERPNext already holds: an entry parked in suspense, a
|
|
4
|
+
Bank Transaction the reconciliation report has not zeroed out, or a draft
|
|
5
|
+
Journal Entry or Purchase Invoice dated in the quarter that can never be
|
|
6
|
+
submitted once the quarter closes. `runningtab health` uses the same readers; this
|
|
7
|
+
just asks them one quarter at a time and names each finding as a `Blocker` the
|
|
8
|
+
close command can print or refuse on.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import dataclasses
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from decimal import ROUND_HALF_UP, Decimal
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
|
|
18
|
+
from runningtab.period.csvfmt import money
|
|
19
|
+
from runningtab.period.ledger import GLRow
|
|
20
|
+
from runningtab.period.quarter import Quarter
|
|
21
|
+
|
|
22
|
+
_DRAFT_DOCTYPES = ("Journal Entry", "Purchase Invoice")
|
|
23
|
+
_CENT = Decimal("0.01")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Reader(Protocol):
|
|
27
|
+
def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclasses.dataclass(frozen=True)
|
|
31
|
+
class Blocker:
|
|
32
|
+
"""One thing that must be resolved in ERPNext before the quarter can close."""
|
|
33
|
+
|
|
34
|
+
kind: str
|
|
35
|
+
doctype: str
|
|
36
|
+
name: str
|
|
37
|
+
detail: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _order(blocker: Blocker) -> tuple[str, str]:
|
|
41
|
+
return (blocker.kind, blocker.name)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _amount(value: object) -> Decimal:
|
|
45
|
+
"""A currency field from a raw ERPNext row, safe for `runningtab.period.csvfmt.money`.
|
|
46
|
+
|
|
47
|
+
The client parses JSON with the standard library, so this arrives as a
|
|
48
|
+
float; going through `Decimal(str(value))` rather than `Decimal(value)`
|
|
49
|
+
keeps the float's own binary repr noise out of the arithmetic -- the same
|
|
50
|
+
guard `ledger._money` uses -- and quantizing to the cent ERPNext actually
|
|
51
|
+
stores money at is what lets `money`, which refuses anything not already
|
|
52
|
+
a whole number of cents, accept the result.
|
|
53
|
+
"""
|
|
54
|
+
return Decimal(str(value)).quantize(_CENT, rounding=ROUND_HALF_UP)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _suspense_blockers(rows: Sequence[GLRow], suspense_account: str, quarter: Quarter) -> list[Blocker]:
|
|
58
|
+
"""Every row from the same ledger read the package builds from that landed on suspense, dated in the quarter.
|
|
59
|
+
|
|
60
|
+
Reusing `rows` rather than a second, separately-filtered GL Entry query
|
|
61
|
+
means this can never disagree with what the package itself will contain.
|
|
62
|
+
"""
|
|
63
|
+
return [
|
|
64
|
+
Blocker(
|
|
65
|
+
kind="suspense",
|
|
66
|
+
doctype="GL Entry",
|
|
67
|
+
name=row.name,
|
|
68
|
+
detail=(
|
|
69
|
+
f"{row.voucher_type} {row.voucher_no} posted {money(row.net)} to suspense "
|
|
70
|
+
f"on {row.posting_date.isoformat()}"
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
for row in rows
|
|
74
|
+
if row.account == suspense_account and quarter.start <= row.posting_date <= quarter.end
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unreconciled_blockers(client: _Reader, company: str, quarter: Quarter) -> list[Blocker]:
|
|
79
|
+
"""Submitted Bank Transactions dated in the quarter ERPNext has not fully allocated.
|
|
80
|
+
|
|
81
|
+
ERPNext marks a Bank Transaction "Reconciled" only once it is submitted
|
|
82
|
+
(`docstatus = 1`) and its `unallocated_amount` reaches zero or below
|
|
83
|
+
(erpnext/accounts/doctype/bank_transaction/bank_transaction.py:85-91);
|
|
84
|
+
this filters on exactly that.
|
|
85
|
+
"""
|
|
86
|
+
rows = client.iter_all(
|
|
87
|
+
"Bank Transaction",
|
|
88
|
+
fields=["name", "date", "unallocated_amount"],
|
|
89
|
+
filters=[
|
|
90
|
+
["company", "=", company],
|
|
91
|
+
["docstatus", "=", 1],
|
|
92
|
+
["date", ">=", quarter.start.isoformat()],
|
|
93
|
+
["date", "<=", quarter.end.isoformat()],
|
|
94
|
+
["unallocated_amount", ">", 0],
|
|
95
|
+
],
|
|
96
|
+
order_by="name asc",
|
|
97
|
+
)
|
|
98
|
+
return [
|
|
99
|
+
Blocker(
|
|
100
|
+
kind="unreconciled",
|
|
101
|
+
doctype="Bank Transaction",
|
|
102
|
+
name=str(row["name"]),
|
|
103
|
+
detail=f"{money(_amount(row['unallocated_amount']))} unallocated on {row['date']}",
|
|
104
|
+
)
|
|
105
|
+
for row in rows
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _draft_blockers(client: _Reader, company: str, quarter: Quarter, doctype: str) -> list[Blocker]:
|
|
110
|
+
"""Draft (`docstatus = 0`) documents of `doctype` dated in the quarter.
|
|
111
|
+
|
|
112
|
+
A draft dated in a quarter that has closed can never be submitted, so
|
|
113
|
+
leaving one there strands whatever work it represents.
|
|
114
|
+
"""
|
|
115
|
+
rows = client.iter_all(
|
|
116
|
+
doctype,
|
|
117
|
+
fields=["name", "posting_date"],
|
|
118
|
+
filters=[
|
|
119
|
+
["company", "=", company],
|
|
120
|
+
["docstatus", "=", 0],
|
|
121
|
+
["posting_date", ">=", quarter.start.isoformat()],
|
|
122
|
+
["posting_date", "<=", quarter.end.isoformat()],
|
|
123
|
+
],
|
|
124
|
+
order_by="name asc",
|
|
125
|
+
)
|
|
126
|
+
return [
|
|
127
|
+
Blocker(
|
|
128
|
+
kind="draft",
|
|
129
|
+
doctype=doctype,
|
|
130
|
+
name=str(row["name"]),
|
|
131
|
+
detail=f"draft {doctype} dated {row['posting_date']}",
|
|
132
|
+
)
|
|
133
|
+
for row in rows
|
|
134
|
+
]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def read_blockers(
|
|
138
|
+
client: _Reader, company: str, quarter: Quarter, suspense_account: str, rows: Sequence[GLRow]
|
|
139
|
+
) -> list[Blocker]:
|
|
140
|
+
"""Everything that must be resolved in ERPNext before `quarter` can close, sorted by (kind, name)."""
|
|
141
|
+
blockers = [
|
|
142
|
+
*_suspense_blockers(rows, suspense_account, quarter),
|
|
143
|
+
*_unreconciled_blockers(client, company, quarter),
|
|
144
|
+
*[blocker for doctype in _DRAFT_DOCTYPES for blocker in _draft_blockers(client, company, quarter, doctype)],
|
|
145
|
+
]
|
|
146
|
+
return sorted(blockers, key=_order)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""The record bucket: where a closed quarter's package lives once `period close` hands it off.
|
|
2
|
+
|
|
3
|
+
Storage under a retention policy (each books directory names its buckets in `books.toml`)
|
|
4
|
+
means an object, once written, cannot be overwritten or deleted until it ages
|
|
5
|
+
out. `Record` is the narrow surface that fact needs: list what is there, read
|
|
6
|
+
an object, create one -- and only ever create, never update. `GcsRecord` is
|
|
7
|
+
the real bucket; tests exercise the same functions against a plain dict, so
|
|
8
|
+
the revision numbering and read-back logic run with no credentials and no
|
|
9
|
+
network.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
import re
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
from runningtab.period import PeriodError
|
|
19
|
+
from runningtab.period.manifest import lock_version_of
|
|
20
|
+
from runningtab.period.quarter import Quarter
|
|
21
|
+
|
|
22
|
+
# `<abbr>/<quarter>/SHA256SUMS` is revision 1; `<abbr>/<quarter>/revN/SHA256SUMS`
|
|
23
|
+
# is revision N. The abbr is stripped by the caller before matching, since
|
|
24
|
+
# `stored_packages` already scoped the listing to it.
|
|
25
|
+
_STORED_SUMS = re.compile(r"^(?P<quarter>\d{4}Q[1-4])/(?:rev(?P<rev>\d+)/)?SHA256SUMS$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclasses.dataclass(frozen=True)
|
|
29
|
+
class StoredPackage:
|
|
30
|
+
"""One complete package found in the bucket: which quarter, which revision, and where its objects sit."""
|
|
31
|
+
|
|
32
|
+
quarter: Quarter
|
|
33
|
+
revision: int
|
|
34
|
+
prefix: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SuspectPackage(PeriodError):
|
|
38
|
+
"""`SHA256SUMS` was written but did not read back intact, so a package that may be bad is listed as complete.
|
|
39
|
+
|
|
40
|
+
Every content file had already read back intact, but `stored_packages`
|
|
41
|
+
now lists this prefix, and nothing here can delete it. A retry would
|
|
42
|
+
only be refused as "already has a stored package", so the message names
|
|
43
|
+
the prefix as suspect and sends the person to `runningtab period status`,
|
|
44
|
+
which compares what is stored against the ledger.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Record(Protocol):
|
|
49
|
+
"""What the record bucket needs to offer. `GcsRecord` is the real one; tests use a plain dict."""
|
|
50
|
+
|
|
51
|
+
def list_names(self, prefix: str) -> list[str]:
|
|
52
|
+
"""Every object name starting with `prefix`."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
def read(self, name: str) -> bytes:
|
|
56
|
+
"""The object's whole content."""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def create(self, name: str, data: bytes) -> None:
|
|
60
|
+
"""Write a new object. Raises `PeriodError` if `name` already exists -- this never overwrites."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GcsRecord:
|
|
65
|
+
"""`Record` against a real GCS bucket, under a retention policy or not.
|
|
66
|
+
|
|
67
|
+
`google.cloud.storage` is imported here, inside the class, and nowhere
|
|
68
|
+
else in this module -- so the unit suite, which only ever exercises a
|
|
69
|
+
dict-backed `Record`, never needs credentials or reaches the network.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, bucket_name: str) -> None:
|
|
73
|
+
from google.cloud import storage
|
|
74
|
+
|
|
75
|
+
self.bucket_name = bucket_name
|
|
76
|
+
self._client = storage.Client()
|
|
77
|
+
self._bucket = self._client.bucket(bucket_name)
|
|
78
|
+
|
|
79
|
+
def list_names(self, prefix: str) -> list[str]:
|
|
80
|
+
return [blob.name for blob in self._client.list_blobs(self._bucket, prefix=prefix)]
|
|
81
|
+
|
|
82
|
+
def read(self, name: str) -> bytes:
|
|
83
|
+
return self._bucket.blob(name).download_as_bytes()
|
|
84
|
+
|
|
85
|
+
def create(self, name: str, data: bytes) -> None:
|
|
86
|
+
# `if_generation_match=0` -- generation 0 means "no live object of
|
|
87
|
+
# this name" -- is the server-side precondition that makes this
|
|
88
|
+
# never overwrite, even under a race with another writer.
|
|
89
|
+
from google.api_core.exceptions import PreconditionFailed
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
self._bucket.blob(name).upload_from_string(data, if_generation_match=0)
|
|
93
|
+
except PreconditionFailed as exc:
|
|
94
|
+
raise PeriodError(f"{name!r} already exists in {self.bucket_name!r}; refusing to overwrite") from exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def stored_packages(record: Record, abbr: str) -> list[StoredPackage]:
|
|
98
|
+
"""Every complete package stored for `abbr`, found by locating each `SHA256SUMS` object.
|
|
99
|
+
|
|
100
|
+
`SHA256SUMS` is the last object `upload_package` writes, so a package
|
|
101
|
+
still being written -- or one that failed partway -- has no `SHA256SUMS`
|
|
102
|
+
yet and is invisible here, exactly as it should be: nothing lists it as
|
|
103
|
+
available until it is actually complete.
|
|
104
|
+
"""
|
|
105
|
+
packages = []
|
|
106
|
+
for name in record.list_names(f"{abbr}/"):
|
|
107
|
+
match = _STORED_SUMS.fullmatch(name.removeprefix(f"{abbr}/"))
|
|
108
|
+
if match is None:
|
|
109
|
+
continue
|
|
110
|
+
quarter = Quarter.parse(match["quarter"])
|
|
111
|
+
revision = int(match["rev"]) if match["rev"] else 1
|
|
112
|
+
prefix = f"{abbr}/{quarter.label}/" if revision == 1 else f"{abbr}/{quarter.label}/rev{revision}/"
|
|
113
|
+
packages.append(StoredPackage(quarter, revision, prefix))
|
|
114
|
+
return sorted(packages, key=lambda p: (p.quarter, p.revision))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def latest(stored: list[StoredPackage]) -> dict[Quarter, StoredPackage]:
|
|
118
|
+
"""The highest-revision `StoredPackage` for each quarter present in `stored`."""
|
|
119
|
+
result: dict[Quarter, StoredPackage] = {}
|
|
120
|
+
for package in stored:
|
|
121
|
+
current = result.get(package.quarter)
|
|
122
|
+
if current is None or package.revision > current.revision:
|
|
123
|
+
result[package.quarter] = package
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _prefix_for(abbr: str, quarter: Quarter, revision: int) -> str:
|
|
128
|
+
"""`<abbr>/<quarter>/` for revision 1, `<abbr>/<quarter>/revN/` for any other -- the one place this format
|
|
129
|
+
is spelled. `next_prefix` and `revision_of` are its only two callers, one building it and one reading it
|
|
130
|
+
back, so the two can never drift apart the way a second, independent parse of the same shape could.
|
|
131
|
+
"""
|
|
132
|
+
return f"{abbr}/{quarter.label}/" if revision == 1 else f"{abbr}/{quarter.label}/rev{revision}/"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def next_prefix(record: Record, abbr: str, quarter: Quarter) -> str:
|
|
136
|
+
"""Where the next revision of `quarter`'s package belongs.
|
|
137
|
+
|
|
138
|
+
Probes each candidate prefix in turn -- `<abbr>/<quarter>/`, then
|
|
139
|
+
`rev2/`, `rev3/`, ... -- and returns the first one holding no object at
|
|
140
|
+
all. `stored_packages` calls a prefix with no `SHA256SUMS` "not there,"
|
|
141
|
+
but a half-failed `upload_package` can still have left content objects
|
|
142
|
+
under it, and this function must not repeat that prefix: the bucket is
|
|
143
|
+
under a retention policy, those objects cannot be deleted until they age out, and a retry that
|
|
144
|
+
recomputed the same prefix would have every `create` refuse against them
|
|
145
|
+
forever. Checking `record.list_names` directly, rather than trusting
|
|
146
|
+
`stored_packages`, is what makes a retry land on the next free revision
|
|
147
|
+
instead.
|
|
148
|
+
"""
|
|
149
|
+
revision = 1
|
|
150
|
+
while True:
|
|
151
|
+
prefix = _prefix_for(abbr, quarter, revision)
|
|
152
|
+
if not record.list_names(prefix):
|
|
153
|
+
return prefix
|
|
154
|
+
revision += 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_REVISION_SUFFIX = re.compile(r"rev(\d+)/$")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def revision_of(prefix: str, abbr: str, quarter: Quarter) -> int:
|
|
161
|
+
"""The revision `next_prefix(record, abbr, quarter)` encoded into a `prefix` it returned.
|
|
162
|
+
|
|
163
|
+
A caller that renders `MANIFEST.md`'s revision field needs the exact
|
|
164
|
+
number the prefix it is about to write under names -- this is the one
|
|
165
|
+
place that reads the layout `next_prefix` writes, so the two can never
|
|
166
|
+
silently disagree about what a bare prefix versus a `revN/` one means.
|
|
167
|
+
"""
|
|
168
|
+
if prefix == _prefix_for(abbr, quarter, 1):
|
|
169
|
+
return 1
|
|
170
|
+
match = _REVISION_SUFFIX.search(prefix)
|
|
171
|
+
if match is None or prefix != _prefix_for(abbr, quarter, int(match[1])):
|
|
172
|
+
raise PeriodError(f"{prefix!r} is not a {abbr!r}/{quarter.label} revision prefix")
|
|
173
|
+
return int(match[1])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def upload_package(record: Record, prefix: str, files: dict[str, bytes]) -> None:
|
|
177
|
+
"""Write every object in `files` under `prefix`, `SHA256SUMS` last, proving each one landed intact first.
|
|
178
|
+
|
|
179
|
+
Every content file is created, then read back and checked before
|
|
180
|
+
`SHA256SUMS` is ever written: a content file that fails its read-back
|
|
181
|
+
raises immediately, with `SHA256SUMS` still unwritten, so a corrupt
|
|
182
|
+
package never becomes visible as complete to `stored_packages`.
|
|
183
|
+
`SHA256SUMS` itself is then created and read back the same way; a
|
|
184
|
+
mismatch there raises `SuspectPackage`, since by then the package is
|
|
185
|
+
already listed as complete. The
|
|
186
|
+
read-back is not paranoia: a write GCS acknowledged is still only a
|
|
187
|
+
claim about what is in the bucket, and a package an accountant trusts on
|
|
188
|
+
a wrong claim is worse than one that fails loudly here instead.
|
|
189
|
+
"""
|
|
190
|
+
content = {path: data for path, data in files.items() if path != "SHA256SUMS"}
|
|
191
|
+
for path, data in content.items():
|
|
192
|
+
record.create(f"{prefix}{path}", data)
|
|
193
|
+
mismatched = [f"{prefix}{path}" for path, data in content.items() if record.read(f"{prefix}{path}") != data]
|
|
194
|
+
if mismatched:
|
|
195
|
+
raise PeriodError(f"uploaded but did not read back intact: {', '.join(sorted(mismatched))}")
|
|
196
|
+
if "SHA256SUMS" in files:
|
|
197
|
+
sums_name = f"{prefix}SHA256SUMS"
|
|
198
|
+
record.create(sums_name, files["SHA256SUMS"])
|
|
199
|
+
if record.read(sums_name) != files["SHA256SUMS"]:
|
|
200
|
+
raise SuspectPackage(
|
|
201
|
+
f"{sums_name} was written but did not read back intact, so {prefix!r} is now listed as a stored "
|
|
202
|
+
f"package that may not be what was sent; treat {prefix!r} as suspect and run `runningtab period status` "
|
|
203
|
+
"to compare it with the ledger"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def read_lock_version(record: Record, stored: StoredPackage) -> str:
|
|
208
|
+
"""The Company Version a stored package's `MANIFEST.md` says locked its quarter.
|
|
209
|
+
|
|
210
|
+
A stored package is always a final close, so a manifest with no lock
|
|
211
|
+
record is refused by name rather than read as "no lock".
|
|
212
|
+
"""
|
|
213
|
+
name = f"{stored.prefix}MANIFEST.md"
|
|
214
|
+
try:
|
|
215
|
+
text = record.read(name).decode()
|
|
216
|
+
except UnicodeDecodeError as exc:
|
|
217
|
+
raise PeriodError(f"{name} is not valid UTF-8: {exc}") from exc
|
|
218
|
+
version = lock_version_of(text)
|
|
219
|
+
if version is None:
|
|
220
|
+
raise PeriodError(f"{name} records no lock (no Company Version line)")
|
|
221
|
+
return version
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def read_sums(record: Record, stored: StoredPackage) -> dict[str, str]:
|
|
225
|
+
"""`path -> hex digest`, parsed from the stored package's `SHA256SUMS` (the standard `sha256sum` two-space format).
|
|
226
|
+
|
|
227
|
+
The bucket is under a retention policy and `upload_package` writes this object
|
|
228
|
+
itself, so a malformed read is unlikely -- but unlikely is not never,
|
|
229
|
+
and the ruling is that every read failure is a named refusal, not a
|
|
230
|
+
traceback that loses whatever else `period status`/`health` already
|
|
231
|
+
found.
|
|
232
|
+
"""
|
|
233
|
+
name = f"{stored.prefix}SHA256SUMS"
|
|
234
|
+
try:
|
|
235
|
+
text = record.read(name).decode()
|
|
236
|
+
except UnicodeDecodeError as exc:
|
|
237
|
+
raise PeriodError(f"{name} is not valid UTF-8: {exc}") from exc
|
|
238
|
+
result = {}
|
|
239
|
+
for lineno, line in enumerate(text.splitlines(), start=1):
|
|
240
|
+
if not line:
|
|
241
|
+
continue
|
|
242
|
+
try:
|
|
243
|
+
digest, path = line.split(" ", 1)
|
|
244
|
+
except ValueError:
|
|
245
|
+
raise PeriodError(f"{name} line {lineno} is not `<sha256> <path>`: {line!r}") from None
|
|
246
|
+
result[path] = digest
|
|
247
|
+
return result
|