icedcoffee-sync 0.1.0.dev5__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.
- icedcoffee_sync/__init__.py +3 -0
- icedcoffee_sync/apply/__init__.py +6 -0
- icedcoffee_sync/apply/diff.py +522 -0
- icedcoffee_sync/apply/execute.py +180 -0
- icedcoffee_sync/apply/model.py +45 -0
- icedcoffee_sync/apply/order.py +129 -0
- icedcoffee_sync/apply/park.py +107 -0
- icedcoffee_sync/apply/prune.py +215 -0
- icedcoffee_sync/erpnext/__init__.py +1 -0
- icedcoffee_sync/erpnext/accounts.py +183 -0
- icedcoffee_sync/erpnext/comment.py +62 -0
- icedcoffee_sync/erpnext/company.py +102 -0
- icedcoffee_sync/erpnext/documents.py +179 -0
- icedcoffee_sync/erpnext/ensure.py +34 -0
- icedcoffee_sync/erpnext/purchase.py +260 -0
- icedcoffee_sync/erpnext/webhook.py +137 -0
- icedcoffee_sync/frappe/__init__.py +0 -0
- icedcoffee_sync/frappe/access.py +379 -0
- icedcoffee_sync/frappe/client.py +539 -0
- icedcoffee_sync/frappe/errors.py +201 -0
- icedcoffee_sync/frappe/lifecycle.py +30 -0
- icedcoffee_sync/frappe/request.py +145 -0
- icedcoffee_sync/frappe/response.py +76 -0
- icedcoffee_sync/py.typed +0 -0
- icedcoffee_sync/site.py +65 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/METADATA +9 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/RECORD +29 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/WHEEL +4 -0
- icedcoffee_sync-0.1.0.dev5.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Declarative apply: desired state, diff, ordered execution.
|
|
2
|
+
|
|
3
|
+
Generic by construction. Nothing here knows what a chart of accounts contains,
|
|
4
|
+
only how one is shaped. Dependency direction: `icedcoffee_sync.erpnext` may import from
|
|
5
|
+
this module, never the reverse.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"""Compare a desired chart against a live one and say what would change.
|
|
2
|
+
|
|
3
|
+
Accounts have no intrinsic key: identity comes from position, walked
|
|
4
|
+
top-down. A root is matched by root_type, since ERPNext rejects an Account
|
|
5
|
+
with no `parent_account` and so roots are never created. A child is matched
|
|
6
|
+
against its parent's already-resolved *live* identity, not a pre-apply
|
|
7
|
+
index, so a renamed parent's children still match.
|
|
8
|
+
|
|
9
|
+
Extras (accounts the chart doesn't describe) are reported and left alone --
|
|
10
|
+
deletion is opt-in. A single refusal makes the whole plan unapplyable, since
|
|
11
|
+
a partially applied chart leaves a tree shape nobody chose to diff against.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import enum
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
|
|
20
|
+
from icedcoffee_sync.apply.model import DesiredAccount, LiveAccount, MatchKey
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RefusalReason(enum.StrEnum):
|
|
24
|
+
"""Why the diff declined to decide."""
|
|
25
|
+
|
|
26
|
+
HAS_GL_ENTRIES = "has_gl_entries"
|
|
27
|
+
AMBIGUOUS_MATCH = "ambiguous_match"
|
|
28
|
+
GROUP_FLAG_CHANGED = "group_flag_changed"
|
|
29
|
+
ROOT_TYPE_CHANGED = "root_type_changed"
|
|
30
|
+
ROOT_NOT_UNIQUE = "root_not_unique"
|
|
31
|
+
NUMBER_COLLISION = "number_collision"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclasses.dataclass(frozen=True)
|
|
35
|
+
class CreateAccount:
|
|
36
|
+
"""An account in the chart with no counterpart on the site."""
|
|
37
|
+
|
|
38
|
+
desired: DesiredAccount
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclasses.dataclass(frozen=True)
|
|
42
|
+
class RenameAccount:
|
|
43
|
+
"""A matched account whose number or name differs.
|
|
44
|
+
|
|
45
|
+
One bucket for both because ERPNext names a document
|
|
46
|
+
`{number} - {name} - {abbr}`: changing either field is the same single
|
|
47
|
+
rename call, and ordering them is the same problem either way.
|
|
48
|
+
|
|
49
|
+
They are not the same thing to a person, though, so `changes_number` and
|
|
50
|
+
`changes_name` tell them apart. A renumber moves the GL code the bank
|
|
51
|
+
shows against every transaction; a rename only changes the label on it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
live: LiveAccount
|
|
55
|
+
desired: DesiredAccount
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def changes_number(self) -> bool:
|
|
59
|
+
"""True when the account's number moves."""
|
|
60
|
+
return self.live.number != self.desired.number
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def changes_name(self) -> bool:
|
|
64
|
+
"""True when the account's name changes."""
|
|
65
|
+
return self.live.name != self.desired.name
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclasses.dataclass(frozen=True)
|
|
69
|
+
class RetypeAccount:
|
|
70
|
+
"""A matched account whose account_type differs."""
|
|
71
|
+
|
|
72
|
+
live: LiveAccount
|
|
73
|
+
desired: DesiredAccount
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclasses.dataclass(frozen=True)
|
|
77
|
+
class ExtraAccount:
|
|
78
|
+
"""An account on the site that the chart does not describe."""
|
|
79
|
+
|
|
80
|
+
live: LiveAccount
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclasses.dataclass(frozen=True)
|
|
84
|
+
class EnableAccount:
|
|
85
|
+
"""A matched account the chart wants enabled, but the site has disabled.
|
|
86
|
+
|
|
87
|
+
The chart format has no field for "disabled", so any matched account is
|
|
88
|
+
implicitly wanted enabled -- `disabled=True` is always drift. Never
|
|
89
|
+
checked against `with_entries`: re-enabling touches neither GL entries
|
|
90
|
+
nor the document's identity.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
live: LiveAccount
|
|
94
|
+
desired: DesiredAccount
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclasses.dataclass(frozen=True)
|
|
98
|
+
class Refusal:
|
|
99
|
+
"""A situation the diff will not resolve on its own.
|
|
100
|
+
|
|
101
|
+
`live_document_names` holds the live accounts the refusal is about -- one
|
|
102
|
+
for a mismatch, several for an ambiguous match, none when the chart wants
|
|
103
|
+
an account the site doesn't have.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
key: MatchKey
|
|
107
|
+
reason: RefusalReason
|
|
108
|
+
detail: str
|
|
109
|
+
live_document_names: tuple[str, ...] = ()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@dataclasses.dataclass(frozen=True)
|
|
113
|
+
class ChartPlan:
|
|
114
|
+
"""Everything the diff concluded.
|
|
115
|
+
|
|
116
|
+
`enables` defaults to empty and sits last so positional `ChartPlan(...)`
|
|
117
|
+
construction (test fixtures, especially) needs no updating.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
creates: list[CreateAccount]
|
|
121
|
+
renames: list[RenameAccount]
|
|
122
|
+
retypes: list[RetypeAccount]
|
|
123
|
+
extras: list[ExtraAccount]
|
|
124
|
+
refusals: list[Refusal]
|
|
125
|
+
enables: list[EnableAccount] = dataclasses.field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_applyable(self) -> bool:
|
|
129
|
+
"""True when nothing was refused. Extras do not block."""
|
|
130
|
+
return not self.refusals
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def is_empty(self) -> bool:
|
|
134
|
+
"""True when the site already matches the chart."""
|
|
135
|
+
return not (self.creates or self.renames or self.retypes or self.enables)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def diff(
|
|
139
|
+
desired: list[DesiredAccount],
|
|
140
|
+
live: list[LiveAccount],
|
|
141
|
+
accounts_with_entries: set[str],
|
|
142
|
+
) -> ChartPlan:
|
|
143
|
+
"""Diff a desired chart against a live one.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
desired: The chart as the file describes it.
|
|
147
|
+
live: Every account the site has for the company.
|
|
148
|
+
accounts_with_entries: Live document names carrying ledger entries.
|
|
149
|
+
Only accounts this diff would otherwise *change* are checked
|
|
150
|
+
against it.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Everything the walk concluded, refusals included.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
KeyError: A desired or live account names a parent outside its set.
|
|
157
|
+
ValueError: The chart declares two roots of the same root_type.
|
|
158
|
+
"""
|
|
159
|
+
walk = _Walk(
|
|
160
|
+
with_entries=accounts_with_entries,
|
|
161
|
+
keys=_keys(desired),
|
|
162
|
+
desired_children=_desired_children(desired),
|
|
163
|
+
live_children=_live_children(live),
|
|
164
|
+
)
|
|
165
|
+
walk.roots()
|
|
166
|
+
walk.number_collisions(live)
|
|
167
|
+
extras = [ExtraAccount(a) for a in live if a.document_name not in walk.matched]
|
|
168
|
+
return ChartPlan(walk.creates, walk.renames, walk.retypes, extras, walk.refusals, walk.enables)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclasses.dataclass
|
|
172
|
+
class _Walk:
|
|
173
|
+
"""One diff in progress: both trees, and what the walk has concluded so far."""
|
|
174
|
+
|
|
175
|
+
with_entries: set[str]
|
|
176
|
+
keys: dict[str, MatchKey]
|
|
177
|
+
desired_children: dict[str | None, list[DesiredAccount]]
|
|
178
|
+
live_children: dict[str | None, list[LiveAccount]]
|
|
179
|
+
creates: list[CreateAccount] = dataclasses.field(default_factory=list)
|
|
180
|
+
renames: list[RenameAccount] = dataclasses.field(default_factory=list)
|
|
181
|
+
retypes: list[RetypeAccount] = dataclasses.field(default_factory=list)
|
|
182
|
+
enables: list[EnableAccount] = dataclasses.field(default_factory=list)
|
|
183
|
+
refusals: list[Refusal] = dataclasses.field(default_factory=list)
|
|
184
|
+
matched: set[str] = dataclasses.field(default_factory=set)
|
|
185
|
+
|
|
186
|
+
def roots(self) -> None:
|
|
187
|
+
"""Match each chart root to the live root of the same root_type."""
|
|
188
|
+
live_roots: dict[str, list[LiveAccount]] = {}
|
|
189
|
+
for root in self.live_children.get(None, []):
|
|
190
|
+
live_roots.setdefault(root.root_type, []).append(root)
|
|
191
|
+
|
|
192
|
+
declared: dict[str, DesiredAccount] = {}
|
|
193
|
+
for want in self.desired_children.get(None, []):
|
|
194
|
+
if want.root_type in declared:
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"chart roots {declared[want.root_type].number} and {want.number} are both "
|
|
197
|
+
f"root_type {want.root_type!r}; a root is matched by root_type alone, so two "
|
|
198
|
+
f"of a kind cannot be told apart"
|
|
199
|
+
)
|
|
200
|
+
declared[want.root_type] = want
|
|
201
|
+
|
|
202
|
+
candidates = live_roots.get(want.root_type, [])
|
|
203
|
+
if len(candidates) == 1:
|
|
204
|
+
self.pair(want, candidates[0])
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
found = ", ".join(c.document_name for c in candidates) or "none"
|
|
208
|
+
self.refuse(
|
|
209
|
+
want,
|
|
210
|
+
RefusalReason.ROOT_NOT_UNIQUE,
|
|
211
|
+
f"chart root {want.number} {want.name!r} needs exactly one live root of root_type "
|
|
212
|
+
f"{want.root_type!r}; the site has {len(candidates)} ({found}). A root cannot be "
|
|
213
|
+
f"created: ERPNext rejects an Account with no parent_account",
|
|
214
|
+
candidates,
|
|
215
|
+
)
|
|
216
|
+
self.consume(candidates)
|
|
217
|
+
|
|
218
|
+
def pair(self, want: DesiredAccount, have: LiveAccount) -> None:
|
|
219
|
+
"""Reconcile one matched pair, then match their children.
|
|
220
|
+
|
|
221
|
+
Children are walked even for a refused pair: nothing runs from a
|
|
222
|
+
refused plan, so a create proposed under a disputed parent is just
|
|
223
|
+
information.
|
|
224
|
+
"""
|
|
225
|
+
self.matched.add(have.document_name)
|
|
226
|
+
self.reconcile(want, have)
|
|
227
|
+
self.children(want, have)
|
|
228
|
+
|
|
229
|
+
def reconcile(self, want: DesiredAccount, have: LiveAccount) -> None:
|
|
230
|
+
"""Decide what changes on a matched pair, or refuse to."""
|
|
231
|
+
if have.is_group != want.is_group:
|
|
232
|
+
self.refuse(
|
|
233
|
+
want,
|
|
234
|
+
RefusalReason.GROUP_FLAG_CHANGED,
|
|
235
|
+
f"{have.document_name} is_group={have.is_group}, chart says {want.is_group}",
|
|
236
|
+
[have],
|
|
237
|
+
)
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
if have.root_type != want.root_type:
|
|
241
|
+
self.refuse(
|
|
242
|
+
want,
|
|
243
|
+
RefusalReason.ROOT_TYPE_CHANGED,
|
|
244
|
+
f"{have.document_name} root_type={have.root_type!r}, chart says {want.root_type!r}",
|
|
245
|
+
[have],
|
|
246
|
+
)
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
wants_rename = have.number != want.number or have.name != want.name
|
|
250
|
+
wants_retype = (have.account_type or None) != (want.account_type or None)
|
|
251
|
+
|
|
252
|
+
if (wants_rename or wants_retype) and have.document_name in self.with_entries:
|
|
253
|
+
self.refuse(
|
|
254
|
+
want,
|
|
255
|
+
RefusalReason.HAS_GL_ENTRIES,
|
|
256
|
+
f"{have.document_name} carries ledger entries and cannot be modified",
|
|
257
|
+
[have],
|
|
258
|
+
)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if wants_rename:
|
|
262
|
+
self.renames.append(RenameAccount(have, want))
|
|
263
|
+
if wants_retype:
|
|
264
|
+
self.retypes.append(RetypeAccount(have, want))
|
|
265
|
+
if have.disabled:
|
|
266
|
+
self.enables.append(EnableAccount(have, want))
|
|
267
|
+
|
|
268
|
+
def children(self, want: DesiredAccount, have: LiveAccount) -> None:
|
|
269
|
+
"""Match a matched pair's children to each other, by number and then by name.
|
|
270
|
+
|
|
271
|
+
Only this pair's own children are candidates -- keeps a renamed
|
|
272
|
+
parent from stranding its subtree, and makes ambiguity a
|
|
273
|
+
sibling-only concern rather than a chart-wide one.
|
|
274
|
+
|
|
275
|
+
**Number first.** In a chart of accounts the number is the account's
|
|
276
|
+
identity and the name is a label on it: the chart file is keyed on
|
|
277
|
+
number, ERPNext enforces one account per number per company, and the
|
|
278
|
+
GL codes our bank shows are those numbers. So an account whose name
|
|
279
|
+
the chart rewrites is the same account, and matching it by name would
|
|
280
|
+
make it a create beside the original -- which then collides with the
|
|
281
|
+
number the original still holds, and the plan refuses the very change
|
|
282
|
+
it was asked to make.
|
|
283
|
+
|
|
284
|
+
**Name second**, for the live accounts a number cannot reach: the
|
|
285
|
+
stock chart a wizard site starts with carries no numbers at all, so
|
|
286
|
+
number-only matching would strand every one of them and re-create the
|
|
287
|
+
chart alongside itself.
|
|
288
|
+
|
|
289
|
+
Two passes rather than one loop, so a name cannot claim a sibling
|
|
290
|
+
that some later chart entry owns by number. A number match is never
|
|
291
|
+
ambiguous the way a name match is, which is also why a number match
|
|
292
|
+
settles what would otherwise be two same-named siblings.
|
|
293
|
+
"""
|
|
294
|
+
siblings = self.live_children.get(have.document_name, [])
|
|
295
|
+
kids = self.desired_children.get(want.number, [])
|
|
296
|
+
|
|
297
|
+
counterparts: list[LiveAccount | None] = [None] * len(kids)
|
|
298
|
+
taken: set[str] = set()
|
|
299
|
+
self._claim_by_number(kids, siblings, counterparts, taken)
|
|
300
|
+
refused = self._refuse_contested_claims(kids, siblings, counterparts, have)
|
|
301
|
+
refused |= self._claim_by_name(kids, siblings, counterparts, taken, have, refused)
|
|
302
|
+
|
|
303
|
+
for index, kid in enumerate(kids):
|
|
304
|
+
if index in refused:
|
|
305
|
+
continue
|
|
306
|
+
counterpart = counterparts[index]
|
|
307
|
+
if counterpart is None:
|
|
308
|
+
self.create(kid)
|
|
309
|
+
else:
|
|
310
|
+
self.pair(kid, counterpart)
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _claim_by_number(
|
|
314
|
+
kids: list[DesiredAccount],
|
|
315
|
+
siblings: list[LiveAccount],
|
|
316
|
+
counterparts: list[LiveAccount | None],
|
|
317
|
+
taken: set[str],
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Pair each chart entry with the sibling already holding its number."""
|
|
320
|
+
by_number = {child.number: child for child in siblings if child.number}
|
|
321
|
+
for index, kid in enumerate(kids):
|
|
322
|
+
match = by_number.get(kid.number)
|
|
323
|
+
if match is not None:
|
|
324
|
+
counterparts[index] = match
|
|
325
|
+
taken.add(match.document_name)
|
|
326
|
+
|
|
327
|
+
def _refuse_contested_claims(
|
|
328
|
+
self,
|
|
329
|
+
kids: list[DesiredAccount],
|
|
330
|
+
siblings: list[LiveAccount],
|
|
331
|
+
counterparts: list[LiveAccount | None],
|
|
332
|
+
have: LiveAccount,
|
|
333
|
+
) -> set[int]:
|
|
334
|
+
"""Refuse where one chart entry owns a live account by number and another names it.
|
|
335
|
+
|
|
336
|
+
The signature of an identity swap: the chart moves an account to a new
|
|
337
|
+
number while a different entry takes the number it left, and both point
|
|
338
|
+
at the same live document. Preferring the number silently repurposes
|
|
339
|
+
it -- and while the GL-entry guard stops that for an account with
|
|
340
|
+
ledger history, an *empty* account can still be linked from Bank
|
|
341
|
+
Account records, tax templates or the Company defaults, and every one
|
|
342
|
+
of those links follows the rename to the wrong account.
|
|
343
|
+
|
|
344
|
+
Which of the two readings the author meant is genuinely unknowable
|
|
345
|
+
here, so this refuses instead of guessing. Splitting the change across
|
|
346
|
+
two applies resolves it.
|
|
347
|
+
|
|
348
|
+
A swap of names between two accounts that each keep their number is
|
|
349
|
+
not this: there the contested account is claimed by number too, and
|
|
350
|
+
both entries end up where they belong.
|
|
351
|
+
"""
|
|
352
|
+
claimed_by_number = {
|
|
353
|
+
counterpart.document_name: index for index, counterpart in enumerate(counterparts) if counterpart
|
|
354
|
+
}
|
|
355
|
+
refused: set[int] = set()
|
|
356
|
+
for index, kid in enumerate(kids):
|
|
357
|
+
if counterparts[index] is not None:
|
|
358
|
+
continue
|
|
359
|
+
for sibling in siblings:
|
|
360
|
+
if sibling.name != kid.name:
|
|
361
|
+
continue
|
|
362
|
+
owner = claimed_by_number.get(sibling.document_name)
|
|
363
|
+
if owner is None or owner == index:
|
|
364
|
+
continue
|
|
365
|
+
self.refuse(
|
|
366
|
+
kid,
|
|
367
|
+
RefusalReason.AMBIGUOUS_MATCH,
|
|
368
|
+
f"chart {kid.number} {kid.name!r} matches {sibling.document_name} by name, but chart "
|
|
369
|
+
f"{kids[owner].number} already claims it by number; apply the renumber and the "
|
|
370
|
+
f"reuse of its old number as two separate changes",
|
|
371
|
+
[sibling],
|
|
372
|
+
)
|
|
373
|
+
self.consume([sibling])
|
|
374
|
+
refused.add(index)
|
|
375
|
+
break
|
|
376
|
+
return refused
|
|
377
|
+
|
|
378
|
+
def _claim_by_name(
|
|
379
|
+
self,
|
|
380
|
+
kids: list[DesiredAccount],
|
|
381
|
+
siblings: list[LiveAccount],
|
|
382
|
+
counterparts: list[LiveAccount | None],
|
|
383
|
+
taken: set[str],
|
|
384
|
+
have: LiveAccount,
|
|
385
|
+
already_refused: set[int],
|
|
386
|
+
) -> set[int]:
|
|
387
|
+
"""Pair what the numbers left over, by name. Returns the indices refused as ambiguous."""
|
|
388
|
+
by_name: dict[str, list[LiveAccount]] = {}
|
|
389
|
+
for child in siblings:
|
|
390
|
+
by_name.setdefault(child.name, []).append(child)
|
|
391
|
+
|
|
392
|
+
refused: set[int] = set()
|
|
393
|
+
for index, kid in enumerate(kids):
|
|
394
|
+
if counterparts[index] is not None or index in already_refused:
|
|
395
|
+
continue
|
|
396
|
+
candidates = [c for c in by_name.get(kid.name, []) if c.document_name not in taken]
|
|
397
|
+
if len(candidates) > 1:
|
|
398
|
+
found = ", ".join(c.document_name for c in candidates)
|
|
399
|
+
self.refuse(
|
|
400
|
+
kid,
|
|
401
|
+
RefusalReason.AMBIGUOUS_MATCH,
|
|
402
|
+
f"{len(candidates)} children of {have.document_name} are named {kid.name!r}: {found}",
|
|
403
|
+
candidates,
|
|
404
|
+
)
|
|
405
|
+
self.consume(candidates)
|
|
406
|
+
taken.update(c.document_name for c in candidates)
|
|
407
|
+
refused.add(index)
|
|
408
|
+
elif candidates:
|
|
409
|
+
counterparts[index] = candidates[0]
|
|
410
|
+
taken.add(candidates[0].document_name)
|
|
411
|
+
return refused
|
|
412
|
+
|
|
413
|
+
def number_collisions(self, live: list[LiveAccount]) -> None:
|
|
414
|
+
"""Refuse any create or renumber that would land on a number a live account still holds.
|
|
415
|
+
|
|
416
|
+
Not local to a matched pair, unlike `reconcile`'s checks: the
|
|
417
|
+
contested account can be anywhere in the tree. Checked against the
|
|
418
|
+
state *after* this plan's own renames land -- `apply_chart` runs
|
|
419
|
+
renames before creates, so a number this plan itself vacates is
|
|
420
|
+
not a collision, or a valid renumbering plan would refuse itself
|
|
421
|
+
forever.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
live: Every account the site has for the company -- the
|
|
425
|
+
contested account may be one this walk never visited.
|
|
426
|
+
"""
|
|
427
|
+
live_by_number = {a.number: a for a in live if a.number}
|
|
428
|
+
vacated = {r.live.number for r in self.renames if r.live.number and r.live.number != r.desired.number}
|
|
429
|
+
|
|
430
|
+
def check(want: DesiredAccount, counterpart: LiveAccount | None) -> None:
|
|
431
|
+
if not want.number or want.number in vacated:
|
|
432
|
+
return
|
|
433
|
+
contested = live_by_number.get(want.number)
|
|
434
|
+
if contested is None:
|
|
435
|
+
return
|
|
436
|
+
if counterpart is not None and contested.document_name == counterpart.document_name:
|
|
437
|
+
return
|
|
438
|
+
self.refuse(
|
|
439
|
+
want,
|
|
440
|
+
RefusalReason.NUMBER_COLLISION,
|
|
441
|
+
f"chart assigns {want.number} to {want.name!r} but {want.number} is held by "
|
|
442
|
+
f"{contested.document_name!r}",
|
|
443
|
+
[contested],
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
for change in self.creates:
|
|
447
|
+
check(change.desired, None)
|
|
448
|
+
for change in self.renames:
|
|
449
|
+
check(change.desired, change.live)
|
|
450
|
+
|
|
451
|
+
def create(self, want: DesiredAccount) -> None:
|
|
452
|
+
"""Create an unmatched account and, with it, everything beneath it."""
|
|
453
|
+
self.creates.append(CreateAccount(want))
|
|
454
|
+
for child in self.desired_children.get(want.number, []):
|
|
455
|
+
self.create(child)
|
|
456
|
+
|
|
457
|
+
def consume(self, accounts: list[LiveAccount]) -> None:
|
|
458
|
+
"""Mark live subtrees as accounted for without matching them.
|
|
459
|
+
|
|
460
|
+
A refused account must not also surface as an extra -- refused and
|
|
461
|
+
unmentioned at once makes no sense -- and neither must its descendants,
|
|
462
|
+
which the walk cannot reach now that it has stopped at their ancestor.
|
|
463
|
+
"""
|
|
464
|
+
for account in accounts:
|
|
465
|
+
for node in self._subtree(account):
|
|
466
|
+
self.matched.add(node.document_name)
|
|
467
|
+
|
|
468
|
+
def refuse(
|
|
469
|
+
self,
|
|
470
|
+
want: DesiredAccount,
|
|
471
|
+
reason: RefusalReason,
|
|
472
|
+
detail: str,
|
|
473
|
+
accounts: list[LiveAccount],
|
|
474
|
+
) -> None:
|
|
475
|
+
"""Record a refusal against a desired account and the live accounts involved."""
|
|
476
|
+
names = tuple(a.document_name for a in accounts)
|
|
477
|
+
self.refusals.append(Refusal(self.keys[want.number], reason, detail, names))
|
|
478
|
+
|
|
479
|
+
def _subtree(self, account: LiveAccount) -> Iterator[LiveAccount]:
|
|
480
|
+
yield account
|
|
481
|
+
for child in self.live_children.get(account.document_name, []):
|
|
482
|
+
yield from self._subtree(child)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _keys(desired: list[DesiredAccount]) -> dict[str, MatchKey]:
|
|
486
|
+
"""Label every desired account as (its name, its parent's name) for reporting."""
|
|
487
|
+
by_number = {a.number: a for a in desired}
|
|
488
|
+
keys: dict[str, MatchKey] = {}
|
|
489
|
+
for account in desired:
|
|
490
|
+
if account.parent_number is None:
|
|
491
|
+
keys[account.number] = (account.name, None)
|
|
492
|
+
continue
|
|
493
|
+
parent = by_number.get(account.parent_number)
|
|
494
|
+
if parent is None:
|
|
495
|
+
raise KeyError(f"account {account.number} names parent {account.parent_number}, which is not in the chart")
|
|
496
|
+
keys[account.number] = (account.name, parent.name)
|
|
497
|
+
return keys
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _desired_children(desired: list[DesiredAccount]) -> dict[str | None, list[DesiredAccount]]:
|
|
501
|
+
"""Group desired accounts under their parent's number; roots under None."""
|
|
502
|
+
children: dict[str | None, list[DesiredAccount]] = {}
|
|
503
|
+
for account in desired:
|
|
504
|
+
children.setdefault(account.parent_number, []).append(account)
|
|
505
|
+
return children
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _live_children(live: list[LiveAccount]) -> dict[str | None, list[LiveAccount]]:
|
|
509
|
+
"""Group live accounts under their parent's document name; roots under None.
|
|
510
|
+
|
|
511
|
+
Raises `KeyError` if a parent names a document outside the given set:
|
|
512
|
+
the walk descends only from roots, so such an account would otherwise be
|
|
513
|
+
silently misreported as an extra rather than one nobody looked at.
|
|
514
|
+
"""
|
|
515
|
+
by_document_name = {a.document_name: a for a in live}
|
|
516
|
+
children: dict[str | None, list[LiveAccount]] = {}
|
|
517
|
+
for account in live:
|
|
518
|
+
parent = account.parent_document_name
|
|
519
|
+
if parent is not None and parent not in by_document_name:
|
|
520
|
+
raise KeyError(f"account {account.document_name!r} names parent {parent!r}, which is not in the given set")
|
|
521
|
+
children.setdefault(parent, []).append(account)
|
|
522
|
+
return children
|