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.
@@ -0,0 +1,180 @@
1
+ """Turn a ChartPlan into requests, in the only order that works.
2
+
3
+ Renumbers run before creates: a new account links to its parent by the
4
+ parent's *document name*, and a renumber changes that name, so creating
5
+ first would link a child to a name about to stop existing.
6
+
7
+ Renumbers need their own order among themselves too -- measured live,
8
+ renumbering "Current Assets" to 1100 before "Cash In Hand" (still sitting on
9
+ 1100) vacated it died with a plain "number already used" error.
10
+ `icedcoffee_sync.apply.order.order_renames` works out that order, inserting a park
11
+ step onto a temporary number when two or more renames form a genuine cycle.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ from typing import Any, Protocol
18
+
19
+ from icedcoffee_sync.apply.diff import ChartPlan, RenameAccount
20
+ from icedcoffee_sync.apply.model import DesiredAccount, LiveAccount
21
+ from icedcoffee_sync.apply.order import ParkStep, order_creates, order_renames
22
+ from icedcoffee_sync.erpnext.accounts import document_name
23
+
24
+ RENUMBER_METHOD = "erpnext.accounts.doctype.account.account.update_account_number"
25
+
26
+
27
+ class PlanRefusedError(RuntimeError):
28
+ """The plan carried refusals; raised before the first request so nothing runs partially applied."""
29
+
30
+
31
+ class _Writer(Protocol):
32
+ def insert(self, doctype: str, doc: dict[str, Any], /) -> Any: ...
33
+ def update(self, doctype: str, name: str, doc: dict[str, Any], /) -> Any: ...
34
+ def call(self, dotted_path: str, params: dict[str, Any] | None = None, /) -> Any: ...
35
+
36
+
37
+ @dataclasses.dataclass
38
+ class ApplyReport:
39
+ """What actually happened, for printing back to the operator.
40
+
41
+ `parked` is every account `order_renames` moved out of the way to
42
+ break a renumbering cycle -- distinct from `renamed`, the moves the
43
+ chart wanted. A parked account's real renumber still lands in `renamed` too.
44
+ """
45
+
46
+ created: list[str] = dataclasses.field(default_factory=list)
47
+ renamed: list[tuple[str, str]] = dataclasses.field(default_factory=list)
48
+ parked: list[tuple[str, str, str]] = dataclasses.field(default_factory=list)
49
+ retyped: list[str] = dataclasses.field(default_factory=list)
50
+ enabled: list[str] = dataclasses.field(default_factory=list)
51
+
52
+
53
+ def _execute_renames(
54
+ client: _Writer,
55
+ renames: list[RenameAccount],
56
+ abbr: str,
57
+ report: ApplyReport,
58
+ chart_numbers: frozenset[str],
59
+ taken: frozenset[str],
60
+ ) -> dict[str, str]:
61
+ """Run every renumber, and any park step `order_renames` had to insert, in order.
62
+
63
+ A parked account's document name changes twice -- to its temporary
64
+ number, then its real one -- so `current_name` tracks each renamed
65
+ account's *current* document name (keyed by its original one) rather
66
+ than trusting `RenameAccount.live.document_name`, which only ever
67
+ holds the pre-run name.
68
+
69
+ Returns:
70
+ The same desired-number -> final-document-name map `apply_chart`
71
+ threads through creates and retypes.
72
+ """
73
+ resolved: dict[str, str] = {}
74
+ current_name: dict[str, str] = {}
75
+
76
+ def name_of(live: LiveAccount) -> str:
77
+ return current_name.get(live.document_name, live.document_name)
78
+
79
+ for step in order_renames(renames, chart_numbers, taken):
80
+ if isinstance(step, ParkStep):
81
+ new_name = client.call(
82
+ RENUMBER_METHOD,
83
+ {
84
+ "name": name_of(step.live),
85
+ "account_name": step.live.name,
86
+ "account_number": step.temporary_number,
87
+ },
88
+ )
89
+ new_name = (
90
+ str(new_name)
91
+ if new_name
92
+ else _swap_leading_number(step.live.document_name, step.live.number, step.temporary_number)
93
+ )
94
+ current_name[step.live.document_name] = new_name
95
+ report.parked.append((step.live.document_name, step.temporary_number, new_name))
96
+ continue
97
+
98
+ new_name = client.call(
99
+ RENUMBER_METHOD,
100
+ {
101
+ "name": name_of(step.live),
102
+ "account_name": step.desired.name,
103
+ "account_number": step.desired.number,
104
+ },
105
+ )
106
+ new_name = str(new_name) if new_name else document_name(step.desired.name, abbr, step.desired.number)
107
+ resolved[step.desired.number] = new_name
108
+ report.renamed.append((step.live.document_name, new_name))
109
+
110
+ return resolved
111
+
112
+
113
+ def _swap_leading_number(old_document_name: str, old_number: str, new_number: str) -> str:
114
+ """Fallback when the server returns no name: swap just the leading number.
115
+
116
+ A document name is `"{number} - {name} - {abbr}"`; only the number
117
+ changed, so replacing that leading prefix reproduces it without the
118
+ abbreviation as a separate input (mirrors `icedcoffee_sync.apply.park`'s
119
+ identical fallback).
120
+ """
121
+ return old_document_name.replace(f"{old_number} - ", f"{new_number} - ", 1)
122
+
123
+
124
+ def apply_chart(
125
+ client: _Writer,
126
+ plan: ChartPlan,
127
+ live: list[LiveAccount],
128
+ by_number: dict[str, DesiredAccount],
129
+ abbr: str,
130
+ company: str,
131
+ ) -> ApplyReport:
132
+ """Execute a plan. Raises PlanRefusedError before touching anything if it was refused.
133
+
134
+ `live` is the whole pre-diff live chart, not just the accounts `plan`
135
+ singles out: an account `plan` never mentions can still already hold a
136
+ number `order_renames` might otherwise pick to park something onto.
137
+ """
138
+ if plan.refusals:
139
+ reasons = ", ".join(sorted({r.reason.value for r in plan.refusals}))
140
+ raise PlanRefusedError(f"plan carries {len(plan.refusals)} refusal(s): {reasons}")
141
+
142
+ chart_numbers = frozenset(by_number)
143
+ taken = frozenset(a.number for a in live if a.number)
144
+ report = ApplyReport()
145
+ resolved = _execute_renames(client, plan.renames, abbr, report, chart_numbers, taken)
146
+
147
+ for change in order_creates(plan.creates, by_number):
148
+ want = change.desired
149
+ parent_name: str | None = None
150
+ if want.parent_number is not None:
151
+ parent = by_number[want.parent_number]
152
+ parent_name = resolved.get(want.parent_number, document_name(parent.name, abbr, parent.number))
153
+ doc: dict[str, Any] = {
154
+ "account_name": want.name,
155
+ "account_number": want.number,
156
+ "company": company,
157
+ "is_group": 1 if want.is_group else 0,
158
+ "root_type": want.root_type,
159
+ }
160
+ if parent_name is not None:
161
+ doc["parent_account"] = parent_name
162
+ if want.account_type:
163
+ doc["account_type"] = want.account_type
164
+ created = client.insert("Account", doc)
165
+ created_name = created.get("name") if isinstance(created, dict) else None
166
+ created_name = created_name or document_name(want.name, abbr, want.number)
167
+ resolved[want.number] = created_name
168
+ report.created.append(created_name)
169
+
170
+ for change in plan.retypes:
171
+ target = resolved.get(change.desired.number, change.live.document_name)
172
+ client.update("Account", target, {"account_type": change.desired.account_type or ""})
173
+ report.retyped.append(target)
174
+
175
+ for change in plan.enables:
176
+ target = resolved.get(change.desired.number, change.live.document_name)
177
+ client.update("Account", target, {"disabled": 0})
178
+ report.enabled.append(target)
179
+
180
+ return report
@@ -0,0 +1,45 @@
1
+ """What the chart wants, and what the site currently has.
2
+
3
+ Neither record carries a match key. Matching the two is a tree walk
4
+ (`icedcoffee_sync.apply.diff`), because an account has no usable intrinsic identity: a
5
+ name repeats across the chart ("Accounts Payable" is both a group and a leaf
6
+ inside it), and the number is the thing most likely to be absent or changing --
7
+ a site set up through ERPNext's wizard has no numbers at all. What identifies
8
+ an account is where it sits: which root it hangs under, then its account name
9
+ among that parent's actual children.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+
16
+ type MatchKey = tuple[str, str | None]
17
+ """How a refusal names the account it is about: (account name, parent name)."""
18
+
19
+
20
+ @dataclasses.dataclass(frozen=True)
21
+ class DesiredAccount:
22
+ """One account as the chart file describes it."""
23
+
24
+ number: str
25
+ name: str
26
+ parent_number: str | None
27
+ is_group: bool
28
+ root_type: str
29
+ account_type: str | None = None
30
+
31
+
32
+ @dataclasses.dataclass(frozen=True)
33
+ class LiveAccount:
34
+ """One account as the site currently has it."""
35
+
36
+ document_name: str
37
+ number: str
38
+ name: str
39
+ parent_document_name: str | None
40
+ is_group: bool
41
+ root_type: str
42
+ account_type: str | None = None
43
+ # Defaults False so every existing construction site -- test fixtures
44
+ # included -- keeps working unchanged; only icedcoffee_sync.apply.prune reads it.
45
+ disabled: bool = False
@@ -0,0 +1,129 @@
1
+ """Order changes so each one can actually be executed when its turn comes.
2
+
3
+ ERPNext links a new account to its parent's *document name*, so the parent
4
+ must exist first: creates run shallowest-first, and every renumber runs
5
+ before any create. Renumbers also need their own order among themselves --
6
+ measured live, "Current Assets" taking 1100 before "Cash In Hand" vacated it
7
+ died with "number already used", a collision `ChartPlan` deliberately leaves
8
+ for `order_renames` to resolve rather than refusing outright.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+
15
+ from icedcoffee_sync.apply.diff import CreateAccount, RenameAccount
16
+ from icedcoffee_sync.apply.model import DesiredAccount, LiveAccount
17
+ from icedcoffee_sync.apply.park import _next_candidate
18
+
19
+
20
+ class CyclicChartError(ValueError):
21
+ """A chart whose parent links form a loop."""
22
+
23
+
24
+ def depth(account: DesiredAccount, by_number: dict[str, DesiredAccount]) -> int:
25
+ """How many ancestors an account has. Raises on a cycle or dangling parent."""
26
+ seen: list[str] = [account.number]
27
+ steps = 0
28
+ current = account
29
+ while current.parent_number is not None:
30
+ if current.parent_number in seen:
31
+ raise CyclicChartError(f"parent cycle through {' -> '.join([*seen, current.parent_number])}")
32
+ seen.append(current.parent_number)
33
+ parent = by_number.get(current.parent_number)
34
+ if parent is None:
35
+ raise KeyError(f"account {current.number} names parent {current.parent_number}, which is not in the chart")
36
+ current = parent
37
+ steps += 1
38
+ return steps
39
+
40
+
41
+ def order_creates(creates: list[CreateAccount], by_number: dict[str, DesiredAccount]) -> list[CreateAccount]:
42
+ """Shallowest first, preserving chart order within a depth -- a stable sort, since depth is a valid tree order."""
43
+ return sorted(creates, key=lambda c: depth(c.desired, by_number))
44
+
45
+
46
+ @dataclasses.dataclass(frozen=True)
47
+ class ParkStep:
48
+ """A temporary renumber, not a chart member, that frees `live`'s number for whoever is waiting on it."""
49
+
50
+ live: LiveAccount
51
+ temporary_number: str
52
+
53
+
54
+ type RenameStep = RenameAccount | ParkStep
55
+
56
+
57
+ def order_renames(
58
+ renames: list[RenameAccount], chart_numbers: frozenset[str], taken: frozenset[str]
59
+ ) -> list[RenameStep]:
60
+ """Order renames so none tries to claim a number still occupied by another.
61
+
62
+ Renumber X depends on renumber Y when `X.desired.number ==
63
+ Y.live.number`; `ChartPlan` deliberately leaves that unrefused, so a
64
+ topological sort must resolve it, breaking a genuine cycle by parking
65
+ its lowest-indexed member onto a temporary number.
66
+
67
+ Args:
68
+ chart_numbers: Every account number the chart describes.
69
+ taken: Every number occupied on the live site, including accounts
70
+ this plan does not touch.
71
+
72
+ Returns:
73
+ `renames`, reordered, with a `ParkStep` wherever a cycle was broken.
74
+ """
75
+ n = len(renames)
76
+ dependents, remaining = _dependency_graph(renames)
77
+ avoid = taken | chart_numbers
78
+
79
+ done: set[int] = set()
80
+ vacated: set[int] = set()
81
+ parked_at: set[str] = set()
82
+ steps: list[RenameStep] = []
83
+
84
+ def _next_ready() -> int | None:
85
+ return next((i for i in range(n) if i not in done and remaining[i] == 0), None)
86
+
87
+ def _vacate(i: int) -> None:
88
+ vacated.add(i)
89
+ for dependent in dependents[i]:
90
+ remaining[dependent] -= 1
91
+
92
+ while len(done) < n:
93
+ ready = _next_ready()
94
+ if ready is not None:
95
+ steps.append(renames[ready])
96
+ done.add(ready)
97
+ _vacate(ready)
98
+ continue
99
+
100
+ # A cycle: park the lowest-indexed unvacated member to free its number.
101
+ to_park = next(i for i in range(n) if i not in vacated)
102
+ candidate = _unused_number(renames[to_park].live.number, avoid | parked_at)
103
+ parked_at.add(candidate)
104
+ steps.append(ParkStep(renames[to_park].live, candidate))
105
+ _vacate(to_park)
106
+
107
+ return steps
108
+
109
+
110
+ def _dependency_graph(renames: list[RenameAccount]) -> tuple[list[set[int]], list[int]]:
111
+ """Which renames must run before which, and how many each is still waiting on."""
112
+ n = len(renames)
113
+ depends_on = [
114
+ {j for j in range(n) if j != i and renames[i].desired.number == renames[j].live.number} for i in range(n)
115
+ ]
116
+ dependents: list[set[int]] = [set() for _ in range(n)]
117
+ for i, deps in enumerate(depends_on):
118
+ for j in deps:
119
+ dependents[j].add(i)
120
+ remaining = [len(deps) for deps in depends_on]
121
+ return dependents, remaining
122
+
123
+
124
+ def _unused_number(number: str, avoid: frozenset[str] | set[str]) -> str:
125
+ """`number`, escalated with `_next_candidate` until it names nothing in `avoid`."""
126
+ candidate = _next_candidate(number)
127
+ while candidate in avoid:
128
+ candidate = _next_candidate(candidate)
129
+ return candidate
@@ -0,0 +1,107 @@
1
+ """Move a surviving account's number out of the chart's way, without deleting it.
2
+
3
+ ERPNext refills four Company defaults (income, write-off, exchange gain/loss,
4
+ asset disposal) by account name on every Company save and refuses to retire
5
+ a default, so some pre-existing accounts cannot go before apply has pointed
6
+ the defaults elsewhere -- yet apply cannot create the chart's account on a
7
+ number they hold. Parking renumbers the survivor (9-prefixed) so apply can
8
+ run; the closing prune retires it afterwards.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import dataclasses
14
+ from typing import Any, Protocol
15
+
16
+ from icedcoffee_sync.apply.model import LiveAccount
17
+ from icedcoffee_sync.apply.prune import PruneReason, PruneRefusal, PruneReport
18
+
19
+ # The whitelisted rename apply itself uses; a field update does not move the document.
20
+ RENUMBER_METHOD = "erpnext.accounts.doctype.account.account.update_account_number"
21
+
22
+
23
+ @dataclasses.dataclass(frozen=True)
24
+ class ParkedNumber:
25
+ """One survivor renumbered out of the chart's way."""
26
+
27
+ old_document_name: str
28
+ number: str
29
+ parked_number: str
30
+ new_document_name: str
31
+
32
+
33
+ class _Client(Protocol):
34
+ def call(self, dotted_path: str, params: dict[str, Any] | None = None) -> Any: ...
35
+
36
+
37
+ def _next_candidate(number: str) -> str:
38
+ """One more `9` prepended -- the escalation `park_colliding_numbers` retries with."""
39
+ return f"9{number}"
40
+
41
+
42
+ def park_colliding_numbers(
43
+ client: _Client,
44
+ survivors: list[LiveAccount],
45
+ chart_numbers: frozenset[str],
46
+ taken: frozenset[str],
47
+ ) -> list[ParkedNumber]:
48
+ """Renumber every non-root survivor holding a chart number onto a free 9-prefixed one.
49
+
50
+ Roots are skipped: apply matches them by root type and renames them
51
+ itself. `taken` is every number occupied on the site; the chosen number
52
+ also avoids `chart_numbers` and earlier picks. Only the number moves.
53
+ """
54
+ parked: list[ParkedNumber] = []
55
+ chosen: set[str] = set()
56
+ for survivor in survivors:
57
+ if survivor.parent_document_name is None:
58
+ continue
59
+ if survivor.number not in chart_numbers:
60
+ continue
61
+
62
+ candidate = _next_candidate(survivor.number)
63
+ while candidate in taken or candidate in chart_numbers or candidate in chosen:
64
+ candidate = _next_candidate(candidate)
65
+ chosen.add(candidate)
66
+
67
+ new_name = client.call(
68
+ RENUMBER_METHOD,
69
+ {
70
+ "name": survivor.document_name,
71
+ "account_name": survivor.name,
72
+ "account_number": candidate,
73
+ },
74
+ )
75
+ # Document names are "{number} - {name} - {abbr}"; only the number changed.
76
+ new_name = (
77
+ str(new_name) if new_name else survivor.document_name.replace(f"{survivor.number} - ", f"{candidate} - ", 1)
78
+ )
79
+ parked.append(ParkedNumber(survivor.document_name, survivor.number, candidate, new_name))
80
+ return parked
81
+
82
+
83
+ def parking_candidates(
84
+ extras: list[LiveAccount],
85
+ report: PruneReport,
86
+ refusals: list[PruneRefusal],
87
+ ) -> tuple[list[LiveAccount], list[LiveAccount]]:
88
+ """Split prune's survivors into `(candidates, gl_excluded)`.
89
+
90
+ Candidates are the extras the run could not retire plus those refused
91
+ for any reason but GL entries -- renumbering never touches ledger
92
+ history, so a live child or root status is no obstacle. Extras refused
93
+ for GL entries are returned separately so the caller can still report
94
+ them as unresolved collisions.
95
+ """
96
+ gl_excluded = [refusal.live for refusal in refusals if refusal.reason is PruneReason.HAS_GL_ENTRIES]
97
+ survivor_names = set(report.disabled) | set(report.already_disabled) | {name for name, _ in report.failed}
98
+ survivor_names |= {
99
+ refusal.live.document_name for refusal in refusals if refusal.reason is not PruneReason.HAS_GL_ENTRIES
100
+ }
101
+ candidates = [extra for extra in extras if extra.document_name in survivor_names]
102
+ return candidates, gl_excluded
103
+
104
+
105
+ def unresolved_collisions(excluded: list[LiveAccount], chart_numbers: frozenset[str]) -> list[str]:
106
+ """Document names of the non-root `excluded` accounts still holding a chart number -- a failure to report."""
107
+ return [a.document_name for a in excluded if a.parent_document_name is not None and a.number in chart_numbers]
@@ -0,0 +1,215 @@
1
+ """Delete accounts the chart does not describe.
2
+
3
+ The only operation here that destroys data, against a real ledger. A
4
+ `PruneRefusal` is per-account, not plan-wide like `diff`'s `Refusal`: a
5
+ declined account is just an extra again next time and must not block its
6
+ siblings.
7
+
8
+ `plan_prune` refuses only what it knows locally, leaving referential
9
+ integrity to ERPNext's own `LinkExistsError`. Some accounts ERPNext names
10
+ disable as the only alternative; `execute_prune` falls back to disable on
11
+ that error, `ValidationError`, or `ChildExistsError` only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import enum
18
+ from typing import Any, Protocol
19
+
20
+ from icedcoffee_sync.apply.model import LiveAccount
21
+ from icedcoffee_sync.frappe.errors import ChildExistsError, FrappeError, LinkExistsError, ValidationError
22
+
23
+
24
+ class PruneReason(enum.StrEnum):
25
+ """Why an extra account was not put on the delete list."""
26
+
27
+ HAS_GL_ENTRIES = "has_gl_entries"
28
+ IS_ROOT = "is_root"
29
+ HAS_LIVE_CHILD = "has_live_child"
30
+
31
+
32
+ @dataclasses.dataclass(frozen=True)
33
+ class DeleteAccount:
34
+ """An extra account cleared to delete."""
35
+
36
+ live: LiveAccount
37
+
38
+
39
+ @dataclasses.dataclass(frozen=True)
40
+ class PruneRefusal:
41
+ """One extra account pruning will not touch, and why."""
42
+
43
+ live: LiveAccount
44
+ reason: PruneReason
45
+ detail: str
46
+
47
+
48
+ @dataclasses.dataclass(frozen=True)
49
+ class PrunePlan:
50
+ """What `plan_prune` concluded: deletes in execution order, plus refusals."""
51
+
52
+ deletes: list[DeleteAccount]
53
+ refusals: list[PruneRefusal]
54
+
55
+ @property
56
+ def is_empty(self) -> bool:
57
+ """True when there is nothing to prune and nothing to report."""
58
+ return not (self.deletes or self.refusals)
59
+
60
+
61
+ def plan_prune(
62
+ extras: list[LiveAccount],
63
+ live: list[LiveAccount],
64
+ accounts_with_entries: set[str],
65
+ ) -> PrunePlan:
66
+ """Decide which extras may be deleted, deepest first.
67
+
68
+ An extra already disabled is re-attempted, since whatever blocked it
69
+ might not still apply. Refused rather than deleted when it carries
70
+ ledger entries (per `accounts_with_entries`, a snapshot from plan time),
71
+ is a root (only ever matched, never created or removed), or still has a
72
+ live child -- checked against which children actually cleared, not mere
73
+ presence in `extras`, which is why extras are decided deepest first.
74
+ """
75
+ by_document_name = {account.document_name: account for account in live}
76
+
77
+ live_children: dict[str, list[LiveAccount]] = {}
78
+ for account in live:
79
+ if account.parent_document_name is not None:
80
+ live_children.setdefault(account.parent_document_name, []).append(account)
81
+
82
+ deletable: list[LiveAccount] = []
83
+ deletable_names: set[str] = set()
84
+ refusals: list[PruneRefusal] = []
85
+
86
+ # Deepest first: a parent's eligibility depends on its own extra children.
87
+ ordered_extras = sorted(extras, key=lambda account: _depth(account, by_document_name), reverse=True)
88
+
89
+ for account in ordered_extras:
90
+ if account.document_name in accounts_with_entries:
91
+ refusals.append(
92
+ PruneRefusal(
93
+ account,
94
+ PruneReason.HAS_GL_ENTRIES,
95
+ f"{account.document_name} carries ledger entries and cannot be deleted",
96
+ )
97
+ )
98
+ continue
99
+
100
+ if account.parent_document_name is None:
101
+ refusals.append(
102
+ PruneRefusal(
103
+ account,
104
+ PruneReason.IS_ROOT,
105
+ f"{account.document_name} is a root; ERPNext requires exactly one root per "
106
+ f"root_type and a root can only be matched, never created or deleted",
107
+ )
108
+ )
109
+ continue
110
+
111
+ # A child survives unless it already cleared to delete -- checked
112
+ # against `deletable_names`, not mere presence in `extras`.
113
+ surviving_children = [
114
+ child.document_name
115
+ for child in live_children.get(account.document_name, [])
116
+ if child.document_name not in deletable_names
117
+ ]
118
+ if surviving_children:
119
+ found = ", ".join(surviving_children)
120
+ refusals.append(
121
+ PruneRefusal(
122
+ account,
123
+ PruneReason.HAS_LIVE_CHILD,
124
+ f"{account.document_name} still has a live child not being deleted: {found}",
125
+ )
126
+ )
127
+ continue
128
+
129
+ deletable.append(account)
130
+ deletable_names.add(account.document_name)
131
+
132
+ ordered = sorted(deletable, key=lambda account: _depth(account, by_document_name), reverse=True)
133
+ return PrunePlan([DeleteAccount(account) for account in ordered], refusals)
134
+
135
+
136
+ def _depth(account: LiveAccount, by_document_name: dict[str, LiveAccount]) -> int:
137
+ """How many ancestors an account has. Gives up on a cycle rather than hanging."""
138
+ seen: set[str] = {account.document_name}
139
+ steps = 0
140
+ current = account
141
+ while current.parent_document_name is not None:
142
+ if current.parent_document_name in seen:
143
+ break
144
+ parent = by_document_name.get(current.parent_document_name)
145
+ if parent is None:
146
+ break
147
+ seen.add(parent.document_name)
148
+ current = parent
149
+ steps += 1
150
+ return steps
151
+
152
+
153
+ class _Client(Protocol):
154
+ """The slice of Client a prune execution needs."""
155
+
156
+ def delete(self, doctype: str, name: str, /) -> Any: ...
157
+ def update(self, doctype: str, name: str, doc: dict[str, Any], /) -> Any: ...
158
+
159
+
160
+ @dataclasses.dataclass
161
+ class PruneReport:
162
+ """What happened. `disabled` is a fresh fallback disable; `already_disabled` came in disabled and stayed refused."""
163
+
164
+ deleted: list[str] = dataclasses.field(default_factory=list)
165
+ disabled: list[str] = dataclasses.field(default_factory=list)
166
+ already_disabled: list[str] = dataclasses.field(default_factory=list)
167
+ failed: list[tuple[str, str]] = dataclasses.field(default_factory=list)
168
+
169
+
170
+ # FrappeError subclasses meaning ERPNext refused a delete on business
171
+ # grounds; anything else (RateLimitError, an unclassified 5xx) is
172
+ # infrastructure trouble that propagates instead.
173
+ _DISABLE_ELIGIBLE: tuple[type[FrappeError], ...] = (LinkExistsError, ValidationError, ChildExistsError)
174
+
175
+
176
+ def execute_prune(client: _Client, plan: PrunePlan) -> PruneReport:
177
+ """Delete each planned account in order, falling back to disable, continuing past a failure.
178
+
179
+ A delete refused with a `_DISABLE_ELIGIBLE` class falls back to
180
+ disabling instead; a disable failing the same way is recorded `failed`.
181
+ Neither ends the run. An account already disabled coming in is still
182
+ attempted, and if refused again is reported `already_disabled` with no
183
+ redundant disable.
184
+
185
+ Raises:
186
+ FrappeError: A delete or its disable fallback failed with something
187
+ outside `_DISABLE_ELIGIBLE`.
188
+ """
189
+ report = PruneReport()
190
+ for change in plan.deletes:
191
+ name = change.live.document_name
192
+ try:
193
+ client.delete("Account", name)
194
+ except _DISABLE_ELIGIBLE:
195
+ if change.live.disabled:
196
+ report.already_disabled.append(name)
197
+ else:
198
+ _fall_back_to_disable(client, name, report)
199
+ else:
200
+ report.deleted.append(name)
201
+ return report
202
+
203
+
204
+ def _fall_back_to_disable(client: _Client, name: str, report: PruneReport) -> None:
205
+ """Disable an account whose delete just failed, recording the outcome.
206
+
207
+ Catches only `_DISABLE_ELIGIBLE`, symmetric with the delete attempt;
208
+ anything else propagates uncaught the same way.
209
+ """
210
+ try:
211
+ client.update("Account", name, {"disabled": 1})
212
+ except _DISABLE_ELIGIBLE as error:
213
+ report.failed.append((name, str(error)))
214
+ else:
215
+ report.disabled.append(name)
@@ -0,0 +1 @@
1
+ """ERPNext doctype knowledge layered over the generic Frappe client."""