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,257 @@
|
|
|
1
|
+
"""Check our package's figures against ERPNext's own Trial Balance and AR/AP Summary reports.
|
|
2
|
+
|
|
3
|
+
Plan 1's `trial_balance`/`open_items` recompute a quarter's figures from the raw
|
|
4
|
+
GL Entries -- our own arithmetic, however carefully checked, never balancing
|
|
5
|
+
against ERPNext's own reports is a silent way for the close to hand out wrong
|
|
6
|
+
books. This module reads the three reports a person would otherwise open by
|
|
7
|
+
hand (Trial Balance, Accounts Receivable Summary, Accounts Payable Summary)
|
|
8
|
+
through `frappe.desk.query_report.run`, and compares them line by line against
|
|
9
|
+
`TrialBalance`/`OpenItem`: any disagreement is named, never averaged away or
|
|
10
|
+
silently accepted.
|
|
11
|
+
|
|
12
|
+
The comparison is account-by-account and party-by-party, not total-by-total: a
|
|
13
|
+
compensating pair of errors that leaves the grand total unchanged would pass a
|
|
14
|
+
total-only check and still be wrong books.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import datetime as dt
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from typing import Any, Literal, Protocol
|
|
24
|
+
|
|
25
|
+
from runningtab.period.balances import TrialBalance
|
|
26
|
+
from runningtab.period.csvfmt import money
|
|
27
|
+
from runningtab.period.ledger import AccountIndex
|
|
28
|
+
from runningtab.period.open_items import Kind, OpenItem
|
|
29
|
+
from runningtab.period.package import Package
|
|
30
|
+
from runningtab.period.quarter import FiscalYear, Quarter
|
|
31
|
+
|
|
32
|
+
TRIAL_BALANCE_REPORT = "Trial Balance"
|
|
33
|
+
# The Trial Balance report's own grand-total row names itself this, quoted by
|
|
34
|
+
# ERPNext itself -- see the committed tests/fixtures/period/trial-balance-report.json.
|
|
35
|
+
_TRIAL_BALANCE_TOTAL_ACCOUNT = "'Total'"
|
|
36
|
+
_PARTY_SUMMARY_REPORT: dict[Kind, str] = {
|
|
37
|
+
"Receivable": "Accounts Receivable Summary",
|
|
38
|
+
"Payable": "Accounts Payable Summary",
|
|
39
|
+
}
|
|
40
|
+
_ZERO = Decimal(0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Caller(Protocol):
|
|
44
|
+
def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_trial_balance_report(
|
|
48
|
+
client: _Caller, company: str, fiscal_year: FiscalYear, quarter: Quarter
|
|
49
|
+
) -> list[dict[str, Any]]:
|
|
50
|
+
"""ERPNext's own Trial Balance report for `quarter`, one row per account (postable or group) with activity, plus the report's own grand-total row.
|
|
51
|
+
|
|
52
|
+
Net presentation (`show_net_values: 1`) so each row's debit/credit columns
|
|
53
|
+
match `AccountBalance`'s sign convention directly, and every closing
|
|
54
|
+
figure Period Closing Vouchers would otherwise fold in is left out
|
|
55
|
+
(`with_period_closing_entry_for_*: 0`) since plan 1 does not handle those
|
|
56
|
+
either (`runningtab.period.balances._refuse_out_of_scope`).
|
|
57
|
+
|
|
58
|
+
Only a row that is not even a dict, or a dict with no `account` at all, is
|
|
59
|
+
dropped here. The report's own grand-total row -- `{"account": "'Total'",
|
|
60
|
+
"account_name": "'Total'", ...}`, quoted by ERPNext itself; see the
|
|
61
|
+
committed `tests/fixtures/period/trial-balance-report.json`, whose last
|
|
62
|
+
row is exactly this -- is a dict with an `account` like any other row, so
|
|
63
|
+
it passes through unfiltered. `trial_balance_mismatches` is what
|
|
64
|
+
recognizes and skips it, by the one thing that actually sets it apart: it
|
|
65
|
+
carries no `is_group_account` key, which every real account row does.
|
|
66
|
+
"""
|
|
67
|
+
result = client.call(
|
|
68
|
+
"frappe.desk.query_report.run",
|
|
69
|
+
{
|
|
70
|
+
"report_name": TRIAL_BALANCE_REPORT,
|
|
71
|
+
"filters": {
|
|
72
|
+
"company": company,
|
|
73
|
+
"fiscal_year": fiscal_year.name,
|
|
74
|
+
"from_date": quarter.start.isoformat(),
|
|
75
|
+
"to_date": quarter.end.isoformat(),
|
|
76
|
+
"show_net_values": 1,
|
|
77
|
+
"show_unclosed_fy_pl_balances": 0,
|
|
78
|
+
"with_period_closing_entry_for_opening": 0,
|
|
79
|
+
"with_period_closing_entry_for_current_period": 0,
|
|
80
|
+
"include_default_book_entries": 1,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
)
|
|
84
|
+
return [row for row in result["result"] if isinstance(row, dict) and row.get("account")]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def read_party_summary(
|
|
88
|
+
client: _Caller, company: str, as_of: dt.date, kind: Literal["Receivable", "Payable"]
|
|
89
|
+
) -> list[dict[str, Any]]:
|
|
90
|
+
"""ERPNext's own Accounts Receivable/Payable Summary report as of `as_of`, one row per party with an outstanding balance.
|
|
91
|
+
|
|
92
|
+
Like the Trial Balance report, the grand-total row is a bare list, not a
|
|
93
|
+
dict, and is dropped along with anything else missing a `party`.
|
|
94
|
+
"""
|
|
95
|
+
result = client.call(
|
|
96
|
+
"frappe.desk.query_report.run",
|
|
97
|
+
{
|
|
98
|
+
"report_name": _PARTY_SUMMARY_REPORT[kind],
|
|
99
|
+
"filters": {
|
|
100
|
+
"company": company,
|
|
101
|
+
"report_date": as_of.isoformat(),
|
|
102
|
+
"ageing_based_on": "Due Date",
|
|
103
|
+
"range": "30, 60, 90, 120",
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
return [row for row in result["result"] if isinstance(row, dict) and row.get("party")]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _decimal(row: dict[str, Any], field: str) -> Decimal:
|
|
111
|
+
return Decimal(str(row.get(field) or 0))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _trial_balance_figures(row: dict[str, Any]) -> tuple[Decimal, Decimal, Decimal, Decimal]:
|
|
115
|
+
return (
|
|
116
|
+
_decimal(row, "opening_debit") - _decimal(row, "opening_credit"),
|
|
117
|
+
_decimal(row, "debit"),
|
|
118
|
+
_decimal(row, "credit"),
|
|
119
|
+
_decimal(row, "closing_debit") - _decimal(row, "closing_credit"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def trial_balance_mismatches(ours: TrialBalance, theirs: list[dict[str, Any]], index: AccountIndex) -> list[str]:
|
|
124
|
+
"""Every postable account whose opening, period debit, period credit, or closing figure disagrees with `theirs`.
|
|
125
|
+
|
|
126
|
+
`theirs` rows are matched to `ours.lines` by account document name through
|
|
127
|
+
`index`, the same key `GLRow.account` uses, rather than by parsing the
|
|
128
|
+
report's human-readable account string. Four kinds of row in `theirs` are
|
|
129
|
+
not a postable account to compare against `ours.lines`, and each is
|
|
130
|
+
handled differently:
|
|
131
|
+
|
|
132
|
+
- The report's own grand-total row carries no `is_group_account` key at
|
|
133
|
+
all (see `read_trial_balance_report`'s docstring) *and* names itself
|
|
134
|
+
`account == "'Total'"` -- skipped outright, never reported. Both
|
|
135
|
+
conditions are checked, not just the missing key: the key's absence
|
|
136
|
+
alone is a structural accident of this one row on this ERPNext
|
|
137
|
+
version, not a guarantee, and treating it alone as "this is the total
|
|
138
|
+
row" would silently drop a real account row that happened to lack the
|
|
139
|
+
key for some other reason.
|
|
140
|
+
- Any other row missing `is_group_account` -- a shape this module does
|
|
141
|
+
not recognize, not the report's own total row -- is reported by name
|
|
142
|
+
rather than silently dropped: `"<account>: unrecognised row shape in
|
|
143
|
+
ERPNext's trial balance (no is_group_account)"`.
|
|
144
|
+
- A group account's row (`is_group_account` truthy) is a subtotal
|
|
145
|
+
`ours.lines` does not carry one of -- skipped outright, never reported.
|
|
146
|
+
- A row whose `account` is simply not in `index` is chart drift: ERPNext
|
|
147
|
+
has an account this run's `chart.coa` does not describe. An all-zero
|
|
148
|
+
row is skipped (there is nothing in it to disagree with, and the
|
|
149
|
+
export's own live-chart check already refuses chart drift elsewhere);
|
|
150
|
+
a row with any nonzero figure is reported by name, since it is real
|
|
151
|
+
money `ours` has nowhere to put and would otherwise vanish silently.
|
|
152
|
+
|
|
153
|
+
A postable account that `theirs` has no row for at all (no activity all
|
|
154
|
+
quarter, in ERPNext's own report) is treated as all-zero, not as a
|
|
155
|
+
missing-row mismatch: `ours.lines` always carries every postable account,
|
|
156
|
+
active or not, and a truly inactive one is expected to be absent from
|
|
157
|
+
ERPNext's report. `ours.prior_year_unclosed` has no line in ERPNext's own
|
|
158
|
+
report to compare against and is not checked here.
|
|
159
|
+
"""
|
|
160
|
+
by_number: dict[str, dict[str, Any]] = {}
|
|
161
|
+
mismatches: list[str] = []
|
|
162
|
+
for row in theirs:
|
|
163
|
+
if "is_group_account" not in row:
|
|
164
|
+
if row.get("account") == _TRIAL_BALANCE_TOTAL_ACCOUNT:
|
|
165
|
+
continue
|
|
166
|
+
mismatches.append(
|
|
167
|
+
f"{row.get('account')}: unrecognised row shape in ERPNext's trial balance (no is_group_account)"
|
|
168
|
+
)
|
|
169
|
+
continue
|
|
170
|
+
if row["is_group_account"]:
|
|
171
|
+
continue
|
|
172
|
+
account_name = row.get("account")
|
|
173
|
+
account = index.get(account_name) if account_name else None
|
|
174
|
+
if account is None:
|
|
175
|
+
opening, debit, credit, closing = _trial_balance_figures(row)
|
|
176
|
+
if any((opening, debit, credit, closing)):
|
|
177
|
+
mismatches.append(
|
|
178
|
+
f"{account_name}: in ERPNext's trial balance but not in chart.coa "
|
|
179
|
+
f"(opening {money(opening)}, period debit {money(debit)}, period credit {money(credit)}, "
|
|
180
|
+
f"closing {money(closing)})"
|
|
181
|
+
)
|
|
182
|
+
continue
|
|
183
|
+
by_number[account.number] = row
|
|
184
|
+
|
|
185
|
+
for line in ours.lines:
|
|
186
|
+
row = by_number.get(line.account.number)
|
|
187
|
+
label = f"{line.account.number} {line.account.name}"
|
|
188
|
+
their_opening, their_debit, their_credit, their_closing = (
|
|
189
|
+
_trial_balance_figures(row) if row else (_ZERO, _ZERO, _ZERO, _ZERO)
|
|
190
|
+
)
|
|
191
|
+
for our_value, their_value, figure in (
|
|
192
|
+
(line.opening, their_opening, "opening"),
|
|
193
|
+
(line.period_debit, their_debit, "period debit"),
|
|
194
|
+
(line.period_credit, their_credit, "period credit"),
|
|
195
|
+
(line.closing, their_closing, "closing"),
|
|
196
|
+
):
|
|
197
|
+
if our_value != their_value:
|
|
198
|
+
mismatches.append(f"{label} {figure}: ours {money(our_value)}, ERPNext {money(their_value)}")
|
|
199
|
+
return mismatches
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def open_item_mismatches(ours: Sequence[OpenItem], theirs: list[dict[str, Any]], kind: Kind) -> list[str]:
|
|
203
|
+
"""Every party whose total open amount disagrees with ERPNext's own AR/AP Summary `outstanding`.
|
|
204
|
+
|
|
205
|
+
Summed per party on our side (`OpenItem` is one row per document, the
|
|
206
|
+
report is one row per party) so the two are compared at the same grain.
|
|
207
|
+
ERPNext's `outstanding` is positive for money owed to us (Receivable) or
|
|
208
|
+
by us (Payable) -- the same sign `runningtab.period.open_items.open_items`
|
|
209
|
+
already gives `OpenItem.amount` -- so no sign flip is needed for either
|
|
210
|
+
kind.
|
|
211
|
+
"""
|
|
212
|
+
ours_by_party: dict[str, Decimal] = defaultdict(Decimal)
|
|
213
|
+
for item in ours:
|
|
214
|
+
ours_by_party[item.party] += item.amount
|
|
215
|
+
theirs_by_party: dict[str, Decimal] = defaultdict(Decimal)
|
|
216
|
+
for row in theirs:
|
|
217
|
+
theirs_by_party[str(row["party"])] += _decimal(row, "outstanding")
|
|
218
|
+
|
|
219
|
+
mismatches: list[str] = []
|
|
220
|
+
for party in sorted({*ours_by_party, *theirs_by_party}):
|
|
221
|
+
our_amount = ours_by_party.get(party, _ZERO)
|
|
222
|
+
their_amount = theirs_by_party.get(party, _ZERO)
|
|
223
|
+
if our_amount != their_amount:
|
|
224
|
+
mismatches.append(f"{party}: ours {money(our_amount)}, ERPNext {money(their_amount)}")
|
|
225
|
+
return mismatches
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def bind_crosscheck(
|
|
229
|
+
client: _Caller, company: str, index: AccountIndex
|
|
230
|
+
) -> Callable[[Package, TrialBalance, tuple[OpenItem, ...], tuple[OpenItem, ...], Quarter, FiscalYear], list[str]]:
|
|
231
|
+
"""Bind a live `client`, `company`, and chart `index` into `close.py`'s `CloseSteps.crosscheck` shape.
|
|
232
|
+
|
|
233
|
+
`package` is accepted (the close ceremony always passes it) but unused: it
|
|
234
|
+
carries only the rendered files, and every figure this module checks
|
|
235
|
+
already comes through `trial_balance`/`open_items` as `TrialBalance` and
|
|
236
|
+
`OpenItem`.
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def crosscheck(
|
|
240
|
+
package: Package,
|
|
241
|
+
trial_balance: TrialBalance,
|
|
242
|
+
receivables: tuple[OpenItem, ...],
|
|
243
|
+
payables: tuple[OpenItem, ...],
|
|
244
|
+
quarter: Quarter,
|
|
245
|
+
fiscal_year: FiscalYear,
|
|
246
|
+
) -> list[str]:
|
|
247
|
+
del package
|
|
248
|
+
theirs_tb = read_trial_balance_report(client, company, fiscal_year, quarter)
|
|
249
|
+
theirs_ar = read_party_summary(client, company, quarter.end, "Receivable")
|
|
250
|
+
theirs_ap = read_party_summary(client, company, quarter.end, "Payable")
|
|
251
|
+
return [
|
|
252
|
+
*trial_balance_mismatches(trial_balance, theirs_tb, index),
|
|
253
|
+
*open_item_mismatches(receivables, theirs_ar, "Receivable"),
|
|
254
|
+
*open_item_mismatches(payables, theirs_ap, "Payable"),
|
|
255
|
+
]
|
|
256
|
+
|
|
257
|
+
return crosscheck
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""How every file in the package writes money and rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
|
|
11
|
+
from runningtab.period import PeriodError
|
|
12
|
+
|
|
13
|
+
_CENT = Decimal("0.01")
|
|
14
|
+
|
|
15
|
+
# A cell starting with one of these opens as a formula, not text, the moment
|
|
16
|
+
# a spreadsheet opens the file -- and a bank counterparty's remarks are text
|
|
17
|
+
# from outside our control, not something we authored. A tab or carriage
|
|
18
|
+
# return can carry a formula character into a cell a naive check on the
|
|
19
|
+
# first character would miss (some readers treat leading whitespace as part
|
|
20
|
+
# of the formula prefix), so both lead the same way `=`, `+`, and `@` do.
|
|
21
|
+
_LEADING_FORMULA_CHARS = ("=", "+", "@", "\t", "\r")
|
|
22
|
+
|
|
23
|
+
# `-` alone is not a formula character in any spreadsheet, but Excel's own
|
|
24
|
+
# CSV import treats a `-` cell as one anyway unless what follows is a bare
|
|
25
|
+
# number -- so a plain negative amount like `-12.00` (money()'s own output)
|
|
26
|
+
# must stay untouched while anything else starting with `-` gets escaped.
|
|
27
|
+
_PLAIN_NEGATIVE_NUMBER = re.compile(r"^-\d+(\.\d+)?$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def money(value: Decimal) -> str:
|
|
31
|
+
"""Two places, no separator. Refuses a value that is not a whole number of cents.
|
|
32
|
+
|
|
33
|
+
The ledger stores cents; a third decimal place means an upstream parse
|
|
34
|
+
went wrong, and rounding it away would hide that.
|
|
35
|
+
"""
|
|
36
|
+
quantized = value.quantize(_CENT)
|
|
37
|
+
if quantized != value:
|
|
38
|
+
raise PeriodError(f"amount {value} is not a whole number of cents")
|
|
39
|
+
if quantized.is_zero():
|
|
40
|
+
quantized = abs(quantized)
|
|
41
|
+
return f"{quantized:f}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def is_formula_like(cell: str) -> bool:
|
|
45
|
+
"""Whether a spreadsheet would read `cell` as a formula instead of text.
|
|
46
|
+
|
|
47
|
+
Exported (not just used by `spreadsheet_safe_cell` below) so
|
|
48
|
+
`runningtab.period.qbo` can apply the identical rule to the QBO kit's Name
|
|
49
|
+
and Account Name columns -- which QuickBooks matches by exact text and
|
|
50
|
+
so cannot be escaped the way a human-facing cell can, only refused. One
|
|
51
|
+
predicate, so the two rules cannot drift apart.
|
|
52
|
+
"""
|
|
53
|
+
if cell.startswith(_LEADING_FORMULA_CHARS):
|
|
54
|
+
return True
|
|
55
|
+
return cell.startswith("-") and not _PLAIN_NEGATIVE_NUMBER.match(cell)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def spreadsheet_safe_cell(cell: str) -> str:
|
|
59
|
+
"""`cell`, prefixed with `'` if a spreadsheet would otherwise read it as a formula.
|
|
60
|
+
|
|
61
|
+
The apostrophe is Excel's and Sheets' own text-marker convention: it is
|
|
62
|
+
never itself shown, so a person reading the cell sees exactly the text
|
|
63
|
+
that was there, just no longer live. Exported for `runningtab.period.qbo`'s
|
|
64
|
+
Journal/Description column: free text, not a key QuickBooks matches on,
|
|
65
|
+
so it is escaped the same way the human-facing CSVs are rather than
|
|
66
|
+
refused.
|
|
67
|
+
"""
|
|
68
|
+
return f"'{cell}" if is_formula_like(cell) else cell
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def render_csv(header: Sequence[str], rows: Iterable[Sequence[str]], *, spreadsheet_safe: bool = True) -> bytes:
|
|
72
|
+
"""UTF-8 CSV with a header row and `\\n` line endings, identical for identical input.
|
|
73
|
+
|
|
74
|
+
`spreadsheet_safe` (the default) escapes any cell that would open as a
|
|
75
|
+
formula in a spreadsheet -- remarks carry bank counterparty text, which
|
|
76
|
+
is input from outside this system, not something we authored. Pass
|
|
77
|
+
`False` for the `qbo/` import files: QBO must receive exactly what was
|
|
78
|
+
computed, with nothing added.
|
|
79
|
+
"""
|
|
80
|
+
buffer = io.StringIO()
|
|
81
|
+
writer = csv.writer(buffer, lineterminator="\n", quoting=csv.QUOTE_MINIMAL)
|
|
82
|
+
writer.writerow(header)
|
|
83
|
+
if spreadsheet_safe:
|
|
84
|
+
rows = ([spreadsheet_safe_cell(cell) for cell in row] for row in rows)
|
|
85
|
+
writer.writerows(rows)
|
|
86
|
+
return buffer.getvalue().encode("utf-8")
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""The receipts and invoices behind the quarter's vouchers and bank lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import re
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from collections.abc import Iterable, Sequence
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
import httpx2
|
|
12
|
+
from icedcoffee_sync.frappe.client import FileDownloadError
|
|
13
|
+
|
|
14
|
+
from runningtab.health import AccountLedger
|
|
15
|
+
from runningtab.period import PeriodError
|
|
16
|
+
from runningtab.period.ledger import GLRow
|
|
17
|
+
from runningtab.period.quarter import Quarter
|
|
18
|
+
|
|
19
|
+
MAX_DOCUMENT_BYTES = 20 * 1024 * 1024
|
|
20
|
+
_UNSAFE = re.compile(r"[/\\\x00-\x1f]")
|
|
21
|
+
_CHUNK = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Site(Protocol):
|
|
25
|
+
def iter_all(self, doctype: str, **kwargs: Any) -> Any: ...
|
|
26
|
+
def download_file(self, file_url: str, *, max_bytes: int) -> bytes: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def safe_component(text: str) -> str:
|
|
30
|
+
cleaned = _UNSAFE.sub("_", text)
|
|
31
|
+
if cleaned in ("", ".", ".."):
|
|
32
|
+
raise PeriodError(f"{text!r} cannot name a file or directory")
|
|
33
|
+
return cleaned
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def quarter_vouchers(rows: Sequence[GLRow], quarter: Quarter) -> list[tuple[str, str]]:
|
|
37
|
+
return sorted({(r.voucher_type, r.voucher_no) for r in rows if quarter.start <= r.posting_date <= quarter.end})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def refuse_unreadable_doctypes(vouchers: Sequence[tuple[str, str]], readable: Iterable[str]) -> None:
|
|
41
|
+
"""Refuse, before listing Files, a voucher type the reader's credential cannot read documents of.
|
|
42
|
+
|
|
43
|
+
Frappe filters private Files attached to a document the credential
|
|
44
|
+
cannot read out of the query results rather than refusing the query, so
|
|
45
|
+
a voucher type the reader was never granted read on comes back with no
|
|
46
|
+
Files at all -- indistinguishable from a voucher with no attachments.
|
|
47
|
+
That is exactly the missed-attachment failure this catches before it
|
|
48
|
+
ships a package silently short a document nobody is told about.
|
|
49
|
+
"Bank Transaction" is required even though it never appears in
|
|
50
|
+
`vouchers`: `read_documents` always queries Files on it too.
|
|
51
|
+
"""
|
|
52
|
+
readable_set = frozenset(readable)
|
|
53
|
+
by_doctype: dict[str, list[str]] = defaultdict(list)
|
|
54
|
+
for doctype, name in vouchers:
|
|
55
|
+
by_doctype[doctype].append(name)
|
|
56
|
+
by_doctype.setdefault("Bank Transaction", [])
|
|
57
|
+
missing = sorted(doctype for doctype in by_doctype if doctype not in readable_set)
|
|
58
|
+
if not missing:
|
|
59
|
+
return
|
|
60
|
+
parts = [
|
|
61
|
+
f"{doctype} ({', '.join(sorted(by_doctype[doctype]))})" if by_doctype[doctype] else doctype
|
|
62
|
+
for doctype in missing
|
|
63
|
+
]
|
|
64
|
+
raise PeriodError(
|
|
65
|
+
"the reader cannot read Files on: "
|
|
66
|
+
+ "; ".join(parts)
|
|
67
|
+
+ "; Frappe silently omits attachments on a doctype it cannot read rather than refusing the query"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def refuse_unreadable_vouchers(client: _Site, vouchers: Sequence[tuple[str, str]]) -> None:
|
|
72
|
+
"""Refuse, before listing Files, a voucher the reader's credential cannot see the document of.
|
|
73
|
+
|
|
74
|
+
A doctype-level grant does not guarantee every document: Frappe also
|
|
75
|
+
honours per-document restrictions (a User Permission, say), and -- the
|
|
76
|
+
same silent omission `refuse_unreadable_doctypes` catches one level up --
|
|
77
|
+
filters a private File attached to a document the credential cannot read
|
|
78
|
+
out of the query results rather than refusing the query. `vouchers`'
|
|
79
|
+
names come from the general ledger, read under a different grant (`GL
|
|
80
|
+
Entry`), so nothing before this has proven the reader can see the
|
|
81
|
+
voucher documents themselves. Listing them back through the reader, in
|
|
82
|
+
chunks of at most `_CHUNK` names, is what proves it; not listed back
|
|
83
|
+
means not readable.
|
|
84
|
+
|
|
85
|
+
Bank Transactions need no matching check here: `read_bank_transactions`
|
|
86
|
+
already lists them through the reader's own credential, so whatever it
|
|
87
|
+
returns is by definition what the reader can see -- there is no
|
|
88
|
+
separately-sourced candidate list to compare it against, the way GL
|
|
89
|
+
Entry supplies one for vouchers. `refuse_missing_bank_transactions`
|
|
90
|
+
below is that check, sourced from the Mercury ledgers instead.
|
|
91
|
+
"""
|
|
92
|
+
by_doctype: dict[str, list[str]] = defaultdict(list)
|
|
93
|
+
for doctype, name in vouchers:
|
|
94
|
+
by_doctype[doctype].append(name)
|
|
95
|
+
missing: list[str] = []
|
|
96
|
+
for doctype, names in sorted(by_doctype.items()):
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
for start in range(0, len(names), _CHUNK):
|
|
99
|
+
rows = client.iter_all(
|
|
100
|
+
doctype,
|
|
101
|
+
fields=["name"],
|
|
102
|
+
filters=[["name", "in", names[start : start + _CHUNK]]],
|
|
103
|
+
order_by="name asc",
|
|
104
|
+
)
|
|
105
|
+
seen.update(str(r["name"]) for r in rows)
|
|
106
|
+
absent = sorted(set(names) - seen)
|
|
107
|
+
if absent:
|
|
108
|
+
missing.append(f"{doctype} ({', '.join(absent)})")
|
|
109
|
+
if missing:
|
|
110
|
+
raise PeriodError(
|
|
111
|
+
"the reader cannot read voucher document(s): "
|
|
112
|
+
+ "; ".join(missing)
|
|
113
|
+
+ "; Frappe silently omits attachments on a document it cannot read rather than refusing the query"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclasses.dataclass(frozen=True)
|
|
118
|
+
class BankTransaction:
|
|
119
|
+
"""One Bank Transaction, as much of it as `read_bank_transactions` needs."""
|
|
120
|
+
|
|
121
|
+
name: str
|
|
122
|
+
# The Mercury transaction this row was posted from -- `runningtab.mercury.posting`'s
|
|
123
|
+
# TRANSACTION_ID_FIELD -- or None for a row Mercury never posted (a hand
|
|
124
|
+
# import through Bank Statement Import, say).
|
|
125
|
+
mercury_transaction_id: str | None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def read_bank_transactions(client: _Site, company: str, quarter: Quarter) -> list[BankTransaction]:
|
|
129
|
+
"""Every Bank Transaction dated in `quarter`, draft or submitted -- cancelled excluded.
|
|
130
|
+
|
|
131
|
+
`docstatus != 2` rather than `= 1`: a draft row can carry an attachment
|
|
132
|
+
(a receipt matched to it before it was reconciled and submitted), and
|
|
133
|
+
excluding drafts here means `read_documents` never queries Files on that
|
|
134
|
+
row at all -- the attachment is silently missing from the package rather
|
|
135
|
+
than refused or reported. `order_by="name asc"`: `iter_all` pages by
|
|
136
|
+
offset over a listing with no unique sort key otherwise, and a page
|
|
137
|
+
boundary can then skip or repeat a row -- silently dropping an
|
|
138
|
+
attachment the same way an unhandled short read would.
|
|
139
|
+
|
|
140
|
+
`mercury_transaction_id` rides along on the same listing --
|
|
141
|
+
`refuse_missing_bank_transactions` needs it and a second read of the
|
|
142
|
+
same doctype would only duplicate this one.
|
|
143
|
+
"""
|
|
144
|
+
rows = client.iter_all(
|
|
145
|
+
"Bank Transaction",
|
|
146
|
+
fields=["name", "mercury_transaction_id"],
|
|
147
|
+
filters=[
|
|
148
|
+
["company", "=", company],
|
|
149
|
+
["docstatus", "!=", 2],
|
|
150
|
+
["date", ">=", quarter.start.isoformat()],
|
|
151
|
+
["date", "<=", quarter.end.isoformat()],
|
|
152
|
+
],
|
|
153
|
+
order_by="name asc",
|
|
154
|
+
)
|
|
155
|
+
transactions = [
|
|
156
|
+
BankTransaction(str(r["name"]), str(r["mercury_transaction_id"]) if r.get("mercury_transaction_id") else None)
|
|
157
|
+
for r in rows
|
|
158
|
+
]
|
|
159
|
+
return sorted(transactions, key=lambda b: b.name)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def refuse_missing_bank_transactions(
|
|
163
|
+
ledgers: Sequence[AccountLedger], bank_transactions: Sequence[BankTransaction], quarter: Quarter
|
|
164
|
+
) -> None:
|
|
165
|
+
"""Refuse, naming each, a settled Mercury transaction in `quarter` with no matching Bank Transaction.
|
|
166
|
+
|
|
167
|
+
`read_bank_transactions`'s listing is the only source `read_documents`
|
|
168
|
+
has for which Bank Transactions to look for Files on; nothing before
|
|
169
|
+
this proves that listing is complete. Two different failures produce
|
|
170
|
+
the identical symptom -- a settled transaction with no Bank Transaction
|
|
171
|
+
the reader can see, and its attachments (if any) silently absent from
|
|
172
|
+
the package:
|
|
173
|
+
|
|
174
|
+
- A per-document restriction hides a specific Bank Transaction from the
|
|
175
|
+
reader, the same class of silence `refuse_unreadable_vouchers` exists
|
|
176
|
+
to catch for vouchers -- but there `vouchers` is an independently
|
|
177
|
+
sourced candidate list (the general ledger); here, without the
|
|
178
|
+
Mercury ledgers as that independent source, a hidden row is simply
|
|
179
|
+
never queried at all.
|
|
180
|
+
- The Mercury ingest has not posted the quarter's transactions yet, so
|
|
181
|
+
the Bank Transaction does not exist to be hidden or found.
|
|
182
|
+
|
|
183
|
+
This refuses either way, without telling them apart: the fix for both
|
|
184
|
+
is the same (post or re-check the ingest, or re-provision the reader),
|
|
185
|
+
and a person reading a silently short package could never distinguish
|
|
186
|
+
them from the outside regardless.
|
|
187
|
+
"""
|
|
188
|
+
present = {bt.mercury_transaction_id for bt in bank_transactions if bt.mercury_transaction_id}
|
|
189
|
+
missing = sorted(
|
|
190
|
+
(t.id, ledger.label, t.accounting_date)
|
|
191
|
+
for ledger in ledgers
|
|
192
|
+
for t in ledger.transactions
|
|
193
|
+
if t.is_postable and quarter.start <= t.accounting_date <= quarter.end and t.id not in present
|
|
194
|
+
)
|
|
195
|
+
if missing:
|
|
196
|
+
named = "; ".join(f"{tid} ({label}, {date.isoformat()})" for tid, label, date in missing)
|
|
197
|
+
raise PeriodError(
|
|
198
|
+
"settled Mercury transaction(s) in the quarter have no matching Bank Transaction the reader can "
|
|
199
|
+
f"see: {named}; the Mercury ingest may not have posted them yet, or the reader cannot read them"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def read_documents(
|
|
204
|
+
client: _Site, vouchers: Sequence[tuple[str, str]], bank_transactions: Sequence[str]
|
|
205
|
+
) -> dict[str, bytes]:
|
|
206
|
+
"""Every File attached to `vouchers` or `bank_transactions`, keyed by the path it lands at in the package.
|
|
207
|
+
|
|
208
|
+
`order_by="name asc"` on the File listing: `iter_all` pages by offset
|
|
209
|
+
over a listing with no unique sort key otherwise, and a page boundary
|
|
210
|
+
can then skip or repeat a row -- silently dropping an attachment.
|
|
211
|
+
"""
|
|
212
|
+
by_doctype: dict[str, list[str]] = defaultdict(list)
|
|
213
|
+
for doctype, name in vouchers:
|
|
214
|
+
by_doctype[doctype].append(name)
|
|
215
|
+
by_doctype["Bank Transaction"].extend(bank_transactions)
|
|
216
|
+
documents: dict[str, bytes] = {}
|
|
217
|
+
sources: dict[str, str] = {}
|
|
218
|
+
problems: list[str] = []
|
|
219
|
+
for doctype, names in sorted(by_doctype.items()):
|
|
220
|
+
for start in range(0, len(names), _CHUNK):
|
|
221
|
+
files = client.iter_all(
|
|
222
|
+
"File",
|
|
223
|
+
fields=["name", "file_name", "file_url", "attached_to_doctype", "attached_to_name", "is_folder"],
|
|
224
|
+
filters=[
|
|
225
|
+
["attached_to_doctype", "=", doctype],
|
|
226
|
+
["attached_to_name", "in", names[start : start + _CHUNK]],
|
|
227
|
+
],
|
|
228
|
+
order_by="name asc",
|
|
229
|
+
)
|
|
230
|
+
for f in files:
|
|
231
|
+
if int(f.get("is_folder") or 0):
|
|
232
|
+
continue
|
|
233
|
+
path = "/".join([
|
|
234
|
+
"documents",
|
|
235
|
+
safe_component(doctype),
|
|
236
|
+
safe_component(str(f["attached_to_name"])),
|
|
237
|
+
safe_component(f"{f['name']}-{f['file_name']}"),
|
|
238
|
+
])
|
|
239
|
+
source = f"File {f['name']} ({f['file_name']}) on {doctype} {f['attached_to_name']}"
|
|
240
|
+
if path in sources:
|
|
241
|
+
# Two distinct Files landing on the same package path is a
|
|
242
|
+
# collision this function must never resolve by silently
|
|
243
|
+
# overwriting one with the other -- that would drop a
|
|
244
|
+
# document from the package without a trace.
|
|
245
|
+
raise PeriodError(
|
|
246
|
+
f"two Files would land on the same package path {path!r}: {sources[path]} and {source}"
|
|
247
|
+
)
|
|
248
|
+
sources[path] = source
|
|
249
|
+
try:
|
|
250
|
+
documents[path] = client.download_file(str(f["file_url"]), max_bytes=MAX_DOCUMENT_BYTES)
|
|
251
|
+
# A timeout or a connection error surfaces from httpx2 as
|
|
252
|
+
# httpx2.HTTPError, not FileDownloadError (that one covers a
|
|
253
|
+
# bad status or an over-cap read) -- collected the same way,
|
|
254
|
+
# naming the file and voucher, or it would escape this loop
|
|
255
|
+
# and crash the whole export instead of refusing it cleanly.
|
|
256
|
+
except (FileDownloadError, ValueError, httpx2.HTTPError) as exc:
|
|
257
|
+
problems.append(f"{doctype} {f['attached_to_name']}: could not fetch {f['file_name']}: {exc}")
|
|
258
|
+
if problems:
|
|
259
|
+
raise PeriodError("could not fetch all documents:\n " + "\n ".join(sorted(problems)))
|
|
260
|
+
return dict(sorted(documents.items()))
|