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.
Files changed (52) hide show
  1. runningtab/__init__.py +3 -0
  2. runningtab/access.py +419 -0
  3. runningtab/books.py +225 -0
  4. runningtab/chart.py +408 -0
  5. runningtab/cli.py +2216 -0
  6. runningtab/coa.py +212 -0
  7. runningtab/doc.py +185 -0
  8. runningtab/forecast/__init__.py +1 -0
  9. runningtab/forecast/compute.py +224 -0
  10. runningtab/forecast/doctype.py +228 -0
  11. runningtab/forecast/model.py +251 -0
  12. runningtab/forecast/names.py +8 -0
  13. runningtab/forecast/sources.py +85 -0
  14. runningtab/gl_codes.py +50 -0
  15. runningtab/health.py +661 -0
  16. runningtab/mercury/__init__.py +7 -0
  17. runningtab/mercury/categories.py +500 -0
  18. runningtab/mercury/classify.py +101 -0
  19. runningtab/mercury/client.py +360 -0
  20. runningtab/mercury/ingest.py +733 -0
  21. runningtab/mercury/mapping.py +130 -0
  22. runningtab/mercury/models.py +544 -0
  23. runningtab/mercury/posting.py +757 -0
  24. runningtab/mercury/receipts.py +304 -0
  25. runningtab/mercury/session.py +65 -0
  26. runningtab/mercury/state.py +285 -0
  27. runningtab/mercury/vouchers.py +951 -0
  28. runningtab/period/__init__.py +9 -0
  29. runningtab/period/balances.py +283 -0
  30. runningtab/period/bank.py +154 -0
  31. runningtab/period/close.py +510 -0
  32. runningtab/period/crosscheck.py +257 -0
  33. runningtab/period/csvfmt.py +86 -0
  34. runningtab/period/documents.py +260 -0
  35. runningtab/period/fence.py +277 -0
  36. runningtab/period/ledger.py +205 -0
  37. runningtab/period/lock.py +294 -0
  38. runningtab/period/manifest.py +259 -0
  39. runningtab/period/open_items.py +84 -0
  40. runningtab/period/package.py +140 -0
  41. runningtab/period/qbo.py +521 -0
  42. runningtab/period/qbo_map.py +174 -0
  43. runningtab/period/quarter.py +90 -0
  44. runningtab/period/readiness.py +146 -0
  45. runningtab/period/record.py +247 -0
  46. runningtab/period/status.py +312 -0
  47. runningtab/site.py +409 -0
  48. runningtab-0.1.0.dev3.dist-info/METADATA +726 -0
  49. runningtab-0.1.0.dev3.dist-info/RECORD +52 -0
  50. runningtab-0.1.0.dev3.dist-info/WHEEL +4 -0
  51. runningtab-0.1.0.dev3.dist-info/entry_points.txt +2 -0
  52. 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()))