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,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."""
|