lambda-erp 0.1.0__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.
- api/__init__.py +0 -0
- api/attachments.py +229 -0
- api/auth.py +511 -0
- api/bootstrap.py +498 -0
- api/chat.py +2764 -0
- api/demo_limits.py +400 -0
- api/deps.py +7 -0
- api/errors.py +56 -0
- api/main.py +182 -0
- api/pdf.py +151 -0
- api/providers.py +116 -0
- api/routers/__init__.py +0 -0
- api/routers/accounting.py +63 -0
- api/routers/admin.py +122 -0
- api/routers/analytics.py +1009 -0
- api/routers/bank_reconciliation.py +31 -0
- api/routers/documents.py +100 -0
- api/routers/masters.py +396 -0
- api/routers/reports.py +735 -0
- api/routers/setup.py +387 -0
- api/services.py +372 -0
- api/templates/document.html +197 -0
- lambda_erp/__init__.py +3 -0
- lambda_erp/accounting/__init__.py +0 -0
- lambda_erp/accounting/bank_transaction.py +76 -0
- lambda_erp/accounting/budget.py +117 -0
- lambda_erp/accounting/chart_of_accounts.py +183 -0
- lambda_erp/accounting/general_ledger.py +362 -0
- lambda_erp/accounting/journal_entry.py +235 -0
- lambda_erp/accounting/payment_entry.py +515 -0
- lambda_erp/accounting/pos_invoice.py +342 -0
- lambda_erp/accounting/purchase_invoice.py +504 -0
- lambda_erp/accounting/revaluation.py +172 -0
- lambda_erp/accounting/sales_invoice.py +523 -0
- lambda_erp/accounting/subscription.py +132 -0
- lambda_erp/buying/__init__.py +0 -0
- lambda_erp/buying/purchase_order.py +165 -0
- lambda_erp/controllers/__init__.py +0 -0
- lambda_erp/controllers/currency.py +52 -0
- lambda_erp/controllers/defaults.py +51 -0
- lambda_erp/controllers/pricing_rule.py +103 -0
- lambda_erp/controllers/taxes_and_totals.py +369 -0
- lambda_erp/database.py +1543 -0
- lambda_erp/exceptions.py +37 -0
- lambda_erp/hooks.py +37 -0
- lambda_erp/model.py +462 -0
- lambda_erp/selling/__init__.py +0 -0
- lambda_erp/selling/quotation.py +263 -0
- lambda_erp/selling/sales_order.py +214 -0
- lambda_erp/simulation.py +704 -0
- lambda_erp/stock/__init__.py +0 -0
- lambda_erp/stock/delivery_note.py +254 -0
- lambda_erp/stock/purchase_receipt.py +356 -0
- lambda_erp/stock/stock_entry.py +330 -0
- lambda_erp/stock/stock_ledger.py +337 -0
- lambda_erp/utils.py +167 -0
- lambda_erp-0.1.0.dist-info/METADATA +454 -0
- lambda_erp-0.1.0.dist-info/RECORD +60 -0
- lambda_erp-0.1.0.dist-info/WHEEL +4 -0
- lambda_erp-0.1.0.dist-info/licenses/LICENSE +21 -0
lambda_erp/exceptions.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Custom exceptions for Lambda ERP."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ValidationError(Exception):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MandatoryError(ValidationError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidCurrency(ValidationError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NegativeStockError(ValidationError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InvalidAccountError(ValidationError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ClosedAccountingPeriod(ValidationError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InsufficientFunds(ValidationError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DebitCreditNotEqual(ValidationError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DocumentStatusError(ValidationError):
|
|
37
|
+
pass
|
lambda_erp/hooks.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Lightweight lifecycle hook registry for the core+extension model.
|
|
2
|
+
|
|
3
|
+
A customer deployment (a separate repo depending on this core) registers
|
|
4
|
+
handlers for document lifecycle events without editing core files. Events are
|
|
5
|
+
named ``"<DocType>:<phase>"`` where phase is one of:
|
|
6
|
+
|
|
7
|
+
before_save / after_save
|
|
8
|
+
before_submit / after_submit
|
|
9
|
+
before_cancel / after_cancel
|
|
10
|
+
|
|
11
|
+
Semantics (see docs/core-extension-architecture.md):
|
|
12
|
+
- ``before_*`` fire inside the document's transaction — a raising handler
|
|
13
|
+
aborts and rolls back the operation (use for extra validation / guards).
|
|
14
|
+
- ``after_*`` fire after the change is committed and durable — use for
|
|
15
|
+
side-effects (notifications, external sync). A raising ``after_*`` handler
|
|
16
|
+
propagates but does NOT undo the committed voucher.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
|
|
21
|
+
_HOOKS: dict[str, list] = defaultdict(list)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_hook(event: str, fn) -> None:
|
|
25
|
+
"""Register a callable ``fn(doc, *args, **kwargs)`` for an event."""
|
|
26
|
+
_HOOKS[event].append(fn)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_hooks(event: str, *args, **kwargs) -> None:
|
|
30
|
+
"""Invoke every handler registered for ``event``, in registration order."""
|
|
31
|
+
for fn in _HOOKS.get(event, ()): # plain .get avoids creating empty lists
|
|
32
|
+
fn(*args, **kwargs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def clear_hooks() -> None:
|
|
36
|
+
"""Remove all registered handlers (used for test isolation)."""
|
|
37
|
+
_HOOKS.clear()
|
lambda_erp/model.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Document class replacing the framework's framework.model.document.Document.
|
|
3
|
+
|
|
4
|
+
In the reference implementation, every DocType instance is a Document with lifecycle hooks
|
|
5
|
+
(validate, before_submit, on_submit, on_cancel, etc.), child table support,
|
|
6
|
+
and automatic DB persistence. This module provides the same pattern.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import copy
|
|
10
|
+
from lambda_erp.utils import _dict, flt, new_name, now
|
|
11
|
+
from lambda_erp.database import get_db
|
|
12
|
+
from lambda_erp.exceptions import ValidationError, DocumentStatusError
|
|
13
|
+
from lambda_erp.hooks import run_hooks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Document status constants (mirrors the framework's docstatus)
|
|
17
|
+
DRAFT = 0
|
|
18
|
+
SUBMITTED = 1
|
|
19
|
+
CANCELLED = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Document:
|
|
23
|
+
"""Base class for all ERP documents.
|
|
24
|
+
|
|
25
|
+
Mirrors the framework's Document class with:
|
|
26
|
+
- Attribute-style field access
|
|
27
|
+
- Child table support (items, taxes, etc.)
|
|
28
|
+
- Lifecycle hooks (validate, on_submit, on_cancel)
|
|
29
|
+
- Automatic DB persistence
|
|
30
|
+
- Status tracking via docstatus
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
DOCTYPE = None # Override in subclasses, e.g. "Sales Invoice"
|
|
34
|
+
CHILD_TABLES = {} # {"items": ("Sales Invoice Item", SalesInvoiceItem), ...}
|
|
35
|
+
PREFIX = "DOC" # For auto-naming
|
|
36
|
+
|
|
37
|
+
# Master-reference validation — checked on every save(). Each entry is
|
|
38
|
+
# (field_name, master_doctype) at the parent level, or nested per child
|
|
39
|
+
# table key. A typo in e.g. supplier="SUPP-XX5" is caught here rather
|
|
40
|
+
# than silently creating an invoice against a phantom supplier.
|
|
41
|
+
LINK_FIELDS: dict = {} # {field_name: master_doctype}
|
|
42
|
+
CHILD_LINK_FIELDS: dict = {} # {child_key: {field_name: master_doctype}}
|
|
43
|
+
|
|
44
|
+
# Dynamic-link fields — target doctype is determined at runtime by
|
|
45
|
+
# reading a sibling "type" field. Example: Payment Entry's `party` field
|
|
46
|
+
# points at either Customer or Supplier depending on `party_type`.
|
|
47
|
+
# Shape: {field_name: (type_field, {type_value: master_doctype, ...})}
|
|
48
|
+
DYNAMIC_LINK_FIELDS: dict = {}
|
|
49
|
+
CHILD_DYNAMIC_LINK_FIELDS: dict = {} # same shape, nested by child table key
|
|
50
|
+
|
|
51
|
+
# Account-type direction constraints — check root_type / account_type on
|
|
52
|
+
# linked Account fields so a Sales Invoice can't accidentally post its
|
|
53
|
+
# Income to a random Expense account (GL still balances, P&L is junk).
|
|
54
|
+
# Shape: {field_name: {"root_type": str | list, "account_type": str | list}}
|
|
55
|
+
ACCOUNT_TYPE_CONSTRAINTS: dict = {}
|
|
56
|
+
CHILD_ACCOUNT_TYPE_CONSTRAINTS: dict = {}
|
|
57
|
+
|
|
58
|
+
def __init__(self, data=None, **kwargs):
|
|
59
|
+
self._data = _dict(data or {})
|
|
60
|
+
self._data.update(kwargs)
|
|
61
|
+
self._children = {} # field_name -> list of child dicts
|
|
62
|
+
|
|
63
|
+
if not self._data.get("name"):
|
|
64
|
+
self._data["name"] = new_name(self.PREFIX)
|
|
65
|
+
if not self._data.get("docstatus"):
|
|
66
|
+
self._data["docstatus"] = DRAFT
|
|
67
|
+
if not self._data.get("creation"):
|
|
68
|
+
self._data["creation"] = now()
|
|
69
|
+
|
|
70
|
+
# Initialize child tables
|
|
71
|
+
for field_name, (child_doctype, child_cls) in self.CHILD_TABLES.items():
|
|
72
|
+
children = self._data.pop(field_name, []) or []
|
|
73
|
+
self._children[field_name] = []
|
|
74
|
+
for i, child in enumerate(children):
|
|
75
|
+
if isinstance(child, dict):
|
|
76
|
+
child = _dict(child)
|
|
77
|
+
child["parent"] = self._data["name"]
|
|
78
|
+
child["idx"] = child.get("idx", i + 1)
|
|
79
|
+
if not child.get("name"):
|
|
80
|
+
child["name"] = new_name(f"{self.PREFIX}-ITEM")
|
|
81
|
+
self._children[field_name].append(child)
|
|
82
|
+
|
|
83
|
+
# --- Attribute access (mirrors the framework's Document) ---
|
|
84
|
+
|
|
85
|
+
def __getattr__(self, key):
|
|
86
|
+
if key.startswith("_"):
|
|
87
|
+
raise AttributeError(key)
|
|
88
|
+
# Check child tables first
|
|
89
|
+
if key in self.__dict__.get("_children", {}):
|
|
90
|
+
return self._children[key]
|
|
91
|
+
data = self.__dict__.get("_data", {})
|
|
92
|
+
return data.get(key)
|
|
93
|
+
|
|
94
|
+
def __setattr__(self, key, value):
|
|
95
|
+
if key.startswith("_") or key in ("DOCTYPE", "CHILD_TABLES", "PREFIX"):
|
|
96
|
+
super().__setattr__(key, value)
|
|
97
|
+
else:
|
|
98
|
+
self._data[key] = value
|
|
99
|
+
|
|
100
|
+
def __getitem__(self, key):
|
|
101
|
+
if key in self._children:
|
|
102
|
+
return self._children[key]
|
|
103
|
+
return self._data[key]
|
|
104
|
+
|
|
105
|
+
def __setitem__(self, key, value):
|
|
106
|
+
if key in self._children:
|
|
107
|
+
self._children[key] = value
|
|
108
|
+
else:
|
|
109
|
+
self._data[key] = value
|
|
110
|
+
|
|
111
|
+
def __contains__(self, key):
|
|
112
|
+
return key in self._data or key in self._children
|
|
113
|
+
|
|
114
|
+
def get(self, key, default=None):
|
|
115
|
+
if key in self._children:
|
|
116
|
+
return self._children[key]
|
|
117
|
+
return self._data.get(key, default)
|
|
118
|
+
|
|
119
|
+
def set(self, key, value):
|
|
120
|
+
if key in self._children:
|
|
121
|
+
self._children[key] = value
|
|
122
|
+
else:
|
|
123
|
+
self._data[key] = value
|
|
124
|
+
|
|
125
|
+
def update(self, d):
|
|
126
|
+
for key, value in d.items():
|
|
127
|
+
self.set(key, value)
|
|
128
|
+
|
|
129
|
+
def as_dict(self):
|
|
130
|
+
d = _dict(self._data.copy())
|
|
131
|
+
for field_name, children in self._children.items():
|
|
132
|
+
d[field_name] = [_dict(c) if isinstance(c, dict) else c for c in children]
|
|
133
|
+
return d
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def docstatus(self):
|
|
137
|
+
return self._data.get("docstatus", DRAFT)
|
|
138
|
+
|
|
139
|
+
# --- Child table helpers ---
|
|
140
|
+
|
|
141
|
+
def append(self, table_name, row=None):
|
|
142
|
+
"""Add a row to a child table. Mirrors doc.append('items', {...})."""
|
|
143
|
+
if table_name not in self._children:
|
|
144
|
+
self._children[table_name] = []
|
|
145
|
+
|
|
146
|
+
if row is None:
|
|
147
|
+
row = {}
|
|
148
|
+
if isinstance(row, dict):
|
|
149
|
+
row = _dict(row)
|
|
150
|
+
|
|
151
|
+
row["parent"] = self._data["name"]
|
|
152
|
+
row["idx"] = len(self._children[table_name]) + 1
|
|
153
|
+
if not row.get("name"):
|
|
154
|
+
row["name"] = new_name(f"{self.PREFIX}-ITEM")
|
|
155
|
+
|
|
156
|
+
self._children[table_name].append(row)
|
|
157
|
+
return row
|
|
158
|
+
|
|
159
|
+
# --- Lifecycle hooks (override in subclasses) ---
|
|
160
|
+
|
|
161
|
+
def validate(self):
|
|
162
|
+
"""Called before save/submit. Override to add validation logic."""
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
def before_save(self):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
def before_submit(self):
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def on_submit(self):
|
|
172
|
+
"""Called after submit. Override to post GL entries, update stock, etc."""
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
def on_cancel(self):
|
|
176
|
+
"""Called on cancellation. Override to reverse GL entries, etc."""
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
def _validate_links(self):
|
|
180
|
+
"""Check every declared LINK_FIELDS / CHILD_LINK_FIELDS reference
|
|
181
|
+
actually exists in its master table. Runs after the subclass's own
|
|
182
|
+
validate() so that default values populated by e.g. _set_missing_accounts
|
|
183
|
+
are already in place."""
|
|
184
|
+
db = get_db()
|
|
185
|
+
|
|
186
|
+
def _label(field: str) -> str:
|
|
187
|
+
return field.replace("_", " ").strip().title()
|
|
188
|
+
|
|
189
|
+
def _check(master: str, value, where: str, field: str):
|
|
190
|
+
if not value:
|
|
191
|
+
return
|
|
192
|
+
if not db.exists(master, value):
|
|
193
|
+
raise ValidationError(
|
|
194
|
+
f"{self.DOCTYPE}: {where}{_label(field)} '{value}' does not "
|
|
195
|
+
f"exist in {master}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
for field, master in self.LINK_FIELDS.items():
|
|
199
|
+
_check(master, self.get(field), "", field)
|
|
200
|
+
|
|
201
|
+
for child_key, fields in self.CHILD_LINK_FIELDS.items():
|
|
202
|
+
for idx, row in enumerate(self.get(child_key) or [], start=1):
|
|
203
|
+
row_get = row.get if isinstance(row, dict) else (lambda k: getattr(row, k, None))
|
|
204
|
+
for field, master in fields.items():
|
|
205
|
+
_check(master, row_get(field), f"row {idx} ", field)
|
|
206
|
+
|
|
207
|
+
# Dynamic-link fields: resolve the target master from a sibling
|
|
208
|
+
# type field. Empty values are allowed, but once a dynamic-link value
|
|
209
|
+
# is present its discriminator must resolve to a known master.
|
|
210
|
+
for field, (type_field, type_map) in self.DYNAMIC_LINK_FIELDS.items():
|
|
211
|
+
value = self.get(field)
|
|
212
|
+
if not value:
|
|
213
|
+
continue
|
|
214
|
+
type_value = self.get(type_field)
|
|
215
|
+
master = type_map.get(type_value)
|
|
216
|
+
if not master:
|
|
217
|
+
raise ValidationError(
|
|
218
|
+
f"{self.DOCTYPE}: {_label(type_field)} '{type_value}' is not valid for "
|
|
219
|
+
f"{_label(field)}"
|
|
220
|
+
)
|
|
221
|
+
_check(master, value, "", field)
|
|
222
|
+
|
|
223
|
+
for child_key, fields in self.CHILD_DYNAMIC_LINK_FIELDS.items():
|
|
224
|
+
for idx, row in enumerate(self.get(child_key) or [], start=1):
|
|
225
|
+
row_get = row.get if isinstance(row, dict) else (lambda k: getattr(row, k, None))
|
|
226
|
+
for field, (type_field, type_map) in fields.items():
|
|
227
|
+
value = row_get(field)
|
|
228
|
+
if not value:
|
|
229
|
+
continue
|
|
230
|
+
type_value = row_get(type_field)
|
|
231
|
+
master = type_map.get(type_value)
|
|
232
|
+
if not master:
|
|
233
|
+
raise ValidationError(
|
|
234
|
+
f"{self.DOCTYPE}: row {idx} {_label(type_field)} '{type_value}' "
|
|
235
|
+
f"is not valid for {_label(field)}"
|
|
236
|
+
)
|
|
237
|
+
_check(master, value, f"row {idx} ", field)
|
|
238
|
+
|
|
239
|
+
# Account-type direction constraints. Runs after the existence checks
|
|
240
|
+
# above, so we know the account row exists. If a constraint fails the
|
|
241
|
+
# underlying GL would still balance, but reports (P&L especially)
|
|
242
|
+
# would be nonsense — e.g. revenue posted to Administrative Expenses.
|
|
243
|
+
def _check_account(account: str, constraint: dict, where: str, field: str):
|
|
244
|
+
info = db.get_value("Account", account, ["root_type", "account_type"])
|
|
245
|
+
if not info:
|
|
246
|
+
return # link check already caught nonexistent account
|
|
247
|
+
for key, allowed in constraint.items():
|
|
248
|
+
actual = info.get(key)
|
|
249
|
+
allowed_set = {allowed} if isinstance(allowed, str) else set(allowed)
|
|
250
|
+
if actual not in allowed_set:
|
|
251
|
+
expected = " or ".join(sorted(allowed_set))
|
|
252
|
+
raise ValidationError(
|
|
253
|
+
f"{self.DOCTYPE}: {where}{_label(field)} '{account}' has "
|
|
254
|
+
f"{key}={actual!r}, expected {expected}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
for field, constraint in self.ACCOUNT_TYPE_CONSTRAINTS.items():
|
|
258
|
+
value = self.get(field)
|
|
259
|
+
if value:
|
|
260
|
+
_check_account(value, constraint, "", field)
|
|
261
|
+
|
|
262
|
+
for child_key, fields in self.CHILD_ACCOUNT_TYPE_CONSTRAINTS.items():
|
|
263
|
+
for idx, row in enumerate(self.get(child_key) or [], start=1):
|
|
264
|
+
row_get = row.get if isinstance(row, dict) else (lambda k: getattr(row, k, None))
|
|
265
|
+
for field, constraint in fields.items():
|
|
266
|
+
value = row_get(field)
|
|
267
|
+
if value:
|
|
268
|
+
_check_account(value, constraint, f"row {idx} ", field)
|
|
269
|
+
|
|
270
|
+
# --- Persistence ---
|
|
271
|
+
|
|
272
|
+
def save(self):
|
|
273
|
+
"""Validate and save to database.
|
|
274
|
+
|
|
275
|
+
Only drafts can be saved. Submitted documents are immutable by design
|
|
276
|
+
— re-running validate() on a submitted doc would recompute totals and
|
|
277
|
+
reset outstanding_amount, silently decoupling the subledger (aging,
|
|
278
|
+
outstanding) from the already-posted GL. Post-submit mutations that
|
|
279
|
+
are genuinely needed (outstanding_amount, billed_qty, modified) go
|
|
280
|
+
through db.set_value directly rather than round-tripping save().
|
|
281
|
+
"""
|
|
282
|
+
if self.docstatus != DRAFT:
|
|
283
|
+
raise DocumentStatusError(
|
|
284
|
+
f"Cannot save {self.DOCTYPE} {self.name}: document is "
|
|
285
|
+
f"{'submitted' if self.docstatus == SUBMITTED else 'cancelled'}. "
|
|
286
|
+
f"Submitted docs are immutable; cancel and create a new one to amend."
|
|
287
|
+
)
|
|
288
|
+
self._data["modified"] = now()
|
|
289
|
+
self.validate()
|
|
290
|
+
self._validate_links()
|
|
291
|
+
self.before_save()
|
|
292
|
+
run_hooks(f"{self.DOCTYPE}:before_save", self)
|
|
293
|
+
self._persist()
|
|
294
|
+
run_hooks(f"{self.DOCTYPE}:after_save", self)
|
|
295
|
+
return self
|
|
296
|
+
|
|
297
|
+
def submit(self):
|
|
298
|
+
"""Submit the document (docstatus 0 -> 1).
|
|
299
|
+
|
|
300
|
+
In the reference implementation, submitting a document is what actually posts GL entries,
|
|
301
|
+
creates stock ledger entries, etc. Draft documents have no effect
|
|
302
|
+
on the ledgers.
|
|
303
|
+
|
|
304
|
+
The entire operation (docstatus change + on_submit hooks like GL/stock
|
|
305
|
+
posting) is wrapped in a transaction. If on_submit() fails, the
|
|
306
|
+
docstatus change is rolled back.
|
|
307
|
+
"""
|
|
308
|
+
if self.docstatus != DRAFT:
|
|
309
|
+
raise DocumentStatusError(
|
|
310
|
+
f"Cannot submit {self.DOCTYPE} {self.name}: docstatus is {self.docstatus}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
db = get_db()
|
|
314
|
+
db._in_transaction = True
|
|
315
|
+
self._data["modified"] = now()
|
|
316
|
+
self.validate()
|
|
317
|
+
self.before_submit()
|
|
318
|
+
self._data["docstatus"] = SUBMITTED
|
|
319
|
+
self._data["status"] = "Submitted"
|
|
320
|
+
try:
|
|
321
|
+
# Inside the transaction: a raising before_submit hook aborts and
|
|
322
|
+
# rolls back the whole submit (use for guards / extra validation).
|
|
323
|
+
run_hooks(f"{self.DOCTYPE}:before_submit", self)
|
|
324
|
+
self._persist(commit=False)
|
|
325
|
+
self.on_submit()
|
|
326
|
+
db.commit()
|
|
327
|
+
except Exception:
|
|
328
|
+
db.conn.rollback()
|
|
329
|
+
# Restore in-memory state
|
|
330
|
+
self._data["docstatus"] = DRAFT
|
|
331
|
+
self._data["status"] = "Draft"
|
|
332
|
+
raise
|
|
333
|
+
finally:
|
|
334
|
+
db._in_transaction = False
|
|
335
|
+
# Post-commit: the voucher is durable. after_submit is for side-effects
|
|
336
|
+
# (notifications, external sync); a raise here does NOT undo the submit.
|
|
337
|
+
run_hooks(f"{self.DOCTYPE}:after_submit", self)
|
|
338
|
+
return self
|
|
339
|
+
|
|
340
|
+
def cancel(self):
|
|
341
|
+
"""Cancel a submitted document (docstatus 1 -> 2).
|
|
342
|
+
|
|
343
|
+
Wrapped in a transaction — if on_cancel() fails (e.g. reversing
|
|
344
|
+
GL entries), the docstatus change is rolled back.
|
|
345
|
+
"""
|
|
346
|
+
if self.docstatus != SUBMITTED:
|
|
347
|
+
raise DocumentStatusError(
|
|
348
|
+
f"Cannot cancel {self.DOCTYPE} {self.name}: docstatus is {self.docstatus}"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
db = get_db()
|
|
352
|
+
db._in_transaction = True
|
|
353
|
+
self._data["docstatus"] = CANCELLED
|
|
354
|
+
self._data["status"] = "Cancelled"
|
|
355
|
+
self._data["modified"] = now()
|
|
356
|
+
try:
|
|
357
|
+
run_hooks(f"{self.DOCTYPE}:before_cancel", self)
|
|
358
|
+
self._persist(commit=False)
|
|
359
|
+
self.on_cancel()
|
|
360
|
+
db.commit()
|
|
361
|
+
except Exception:
|
|
362
|
+
db.conn.rollback()
|
|
363
|
+
self._data["docstatus"] = SUBMITTED
|
|
364
|
+
self._data["status"] = "Submitted"
|
|
365
|
+
raise
|
|
366
|
+
finally:
|
|
367
|
+
db._in_transaction = False
|
|
368
|
+
run_hooks(f"{self.DOCTYPE}:after_cancel", self)
|
|
369
|
+
return self
|
|
370
|
+
|
|
371
|
+
def _persist(self, commit=True):
|
|
372
|
+
"""Save document and child tables to database."""
|
|
373
|
+
db = get_db()
|
|
374
|
+
doctype = self.DOCTYPE
|
|
375
|
+
|
|
376
|
+
# Build a clean dict with only the parent-level fields
|
|
377
|
+
parent_data = {}
|
|
378
|
+
for key, value in self._data.items():
|
|
379
|
+
if key not in self._children:
|
|
380
|
+
parent_data[key] = value
|
|
381
|
+
|
|
382
|
+
# Upsert parent (only persist columns that exist in the table)
|
|
383
|
+
valid_columns = db._get_table_columns(doctype)
|
|
384
|
+
filtered_data = {k: v for k, v in parent_data.items() if k in valid_columns}
|
|
385
|
+
|
|
386
|
+
if db.exists(doctype, self._data["name"]):
|
|
387
|
+
sets = ", ".join(f'"{k}" = ?' for k in filtered_data if k != "name")
|
|
388
|
+
params = [v for k, v in filtered_data.items() if k != "name"]
|
|
389
|
+
params.append(filtered_data["name"])
|
|
390
|
+
db.conn.execute(f'UPDATE "{doctype}" SET {sets} WHERE name = ?', params)
|
|
391
|
+
else:
|
|
392
|
+
db.insert(doctype, filtered_data)
|
|
393
|
+
|
|
394
|
+
# Persist child tables
|
|
395
|
+
for field_name, (child_doctype, child_cls) in self.CHILD_TABLES.items():
|
|
396
|
+
# Delete existing children and re-insert
|
|
397
|
+
db.delete(child_doctype, filters={"parent": self._data["name"]})
|
|
398
|
+
for child in self._children.get(field_name, []):
|
|
399
|
+
child_data = dict(child) if isinstance(child, dict) else child
|
|
400
|
+
db.insert(child_doctype, child_data)
|
|
401
|
+
|
|
402
|
+
if commit:
|
|
403
|
+
db.commit()
|
|
404
|
+
|
|
405
|
+
def reload(self):
|
|
406
|
+
"""Reload from database."""
|
|
407
|
+
db = get_db()
|
|
408
|
+
rows = db.get_all(self.DOCTYPE, filters={"name": self.name}, fields=["*"])
|
|
409
|
+
if rows:
|
|
410
|
+
self._data = rows[0]
|
|
411
|
+
for field_name, (child_doctype, child_cls) in self.CHILD_TABLES.items():
|
|
412
|
+
children = db.get_all(
|
|
413
|
+
child_doctype,
|
|
414
|
+
filters={"parent": self.name},
|
|
415
|
+
fields=["*"],
|
|
416
|
+
order_by="idx",
|
|
417
|
+
)
|
|
418
|
+
self._children[field_name] = children
|
|
419
|
+
|
|
420
|
+
@classmethod
|
|
421
|
+
def load(cls, name):
|
|
422
|
+
"""Load a document from the database by name."""
|
|
423
|
+
db = get_db()
|
|
424
|
+
rows = db.get_all(cls.DOCTYPE, filters={"name": name}, fields=["*"])
|
|
425
|
+
if not rows:
|
|
426
|
+
raise ValidationError(f"{cls.DOCTYPE} {name} not found")
|
|
427
|
+
|
|
428
|
+
doc = cls(rows[0])
|
|
429
|
+
for field_name, (child_doctype, child_cls) in cls.CHILD_TABLES.items():
|
|
430
|
+
children = db.get_all(
|
|
431
|
+
child_doctype,
|
|
432
|
+
filters={"parent": name},
|
|
433
|
+
fields=["*"],
|
|
434
|
+
order_by="idx",
|
|
435
|
+
)
|
|
436
|
+
doc._children[field_name] = children
|
|
437
|
+
return doc
|
|
438
|
+
|
|
439
|
+
def __repr__(self):
|
|
440
|
+
return f"<{self.DOCTYPE}: {self.name}>"
|
|
441
|
+
|
|
442
|
+
# --- Utility methods used by controllers ---
|
|
443
|
+
|
|
444
|
+
def precision(self, fieldname):
|
|
445
|
+
"""Return precision for a field. Default 2 for currency fields."""
|
|
446
|
+
# Simplified - the reference implementation pulls this from DocType metadata
|
|
447
|
+
return 2
|
|
448
|
+
|
|
449
|
+
def round_floats_in(self, doc=None, do_not_round_fields=None):
|
|
450
|
+
"""Round float values in the document."""
|
|
451
|
+
if doc is None:
|
|
452
|
+
doc = self._data
|
|
453
|
+
if isinstance(doc, dict):
|
|
454
|
+
target = doc
|
|
455
|
+
else:
|
|
456
|
+
target = doc._data if hasattr(doc, "_data") else doc
|
|
457
|
+
|
|
458
|
+
for key, value in list(target.items()):
|
|
459
|
+
if isinstance(value, float) and (
|
|
460
|
+
not do_not_round_fields or key not in do_not_round_fields
|
|
461
|
+
):
|
|
462
|
+
target[key] = flt(value, 2)
|
|
File without changes
|