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
api/services.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Bridge between FastAPI request data and lambda_erp Document classes."""
|
|
2
|
+
|
|
3
|
+
from lambda_erp.utils import _dict
|
|
4
|
+
from lambda_erp.database import get_db
|
|
5
|
+
|
|
6
|
+
from lambda_erp.selling.quotation import (
|
|
7
|
+
Quotation, make_sales_order,
|
|
8
|
+
make_sales_invoice_from_quotation, make_delivery_note_from_quotation,
|
|
9
|
+
)
|
|
10
|
+
from lambda_erp.selling.sales_order import SalesOrder, make_sales_invoice
|
|
11
|
+
from lambda_erp.buying.purchase_order import PurchaseOrder, make_purchase_invoice
|
|
12
|
+
from lambda_erp.accounting.sales_invoice import SalesInvoice, make_sales_return
|
|
13
|
+
from lambda_erp.accounting.purchase_invoice import PurchaseInvoice, make_purchase_return
|
|
14
|
+
from lambda_erp.accounting.payment_entry import PaymentEntry
|
|
15
|
+
from lambda_erp.accounting.journal_entry import JournalEntry
|
|
16
|
+
from lambda_erp.stock.stock_entry import StockEntry
|
|
17
|
+
from lambda_erp.stock.delivery_note import DeliveryNote, make_delivery_note, make_delivery_return
|
|
18
|
+
from lambda_erp.stock.purchase_receipt import PurchaseReceipt, make_purchase_receipt, make_purchase_receipt_return
|
|
19
|
+
from lambda_erp.accounting.pos_invoice import POSInvoice
|
|
20
|
+
from lambda_erp.controllers.pricing_rule import PricingRule
|
|
21
|
+
from lambda_erp.accounting.budget import Budget
|
|
22
|
+
from lambda_erp.accounting.subscription import Subscription
|
|
23
|
+
from lambda_erp.accounting.bank_transaction import BankTransaction
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --- Doctype registries ---
|
|
27
|
+
|
|
28
|
+
DOCUMENT_CLASSES = {
|
|
29
|
+
"Quotation": Quotation,
|
|
30
|
+
"Sales Order": SalesOrder,
|
|
31
|
+
"Sales Invoice": SalesInvoice,
|
|
32
|
+
"Purchase Order": PurchaseOrder,
|
|
33
|
+
"Purchase Invoice": PurchaseInvoice,
|
|
34
|
+
"Payment Entry": PaymentEntry,
|
|
35
|
+
"Journal Entry": JournalEntry,
|
|
36
|
+
"Stock Entry": StockEntry,
|
|
37
|
+
"Delivery Note": DeliveryNote,
|
|
38
|
+
"Purchase Receipt": PurchaseReceipt,
|
|
39
|
+
"POS Invoice": POSInvoice,
|
|
40
|
+
"Pricing Rule": PricingRule,
|
|
41
|
+
"Budget": Budget,
|
|
42
|
+
"Subscription": Subscription,
|
|
43
|
+
"Bank Transaction": BankTransaction,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
CONVERTERS = {
|
|
47
|
+
("Quotation", "Sales Order"): make_sales_order,
|
|
48
|
+
("Quotation", "Sales Invoice"): make_sales_invoice_from_quotation,
|
|
49
|
+
("Quotation", "Delivery Note"): make_delivery_note_from_quotation,
|
|
50
|
+
("Sales Order", "Sales Invoice"): make_sales_invoice,
|
|
51
|
+
("Sales Order", "Delivery Note"): make_delivery_note,
|
|
52
|
+
("Purchase Order", "Purchase Invoice"): make_purchase_invoice,
|
|
53
|
+
("Purchase Order", "Purchase Receipt"): make_purchase_receipt,
|
|
54
|
+
# Returns (same-to-same conversion creates a return document)
|
|
55
|
+
("Sales Invoice", "Sales Invoice"): make_sales_return,
|
|
56
|
+
("Purchase Invoice", "Purchase Invoice"): make_purchase_return,
|
|
57
|
+
("Delivery Note", "Delivery Note"): make_delivery_return,
|
|
58
|
+
("Purchase Receipt", "Purchase Receipt"): make_purchase_receipt_return,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
MASTER_TABLES = {
|
|
62
|
+
"customer": ("Customer", "customer_name"),
|
|
63
|
+
"supplier": ("Supplier", "supplier_name"),
|
|
64
|
+
"item": ("Item", "item_name"),
|
|
65
|
+
"warehouse": ("Warehouse", "warehouse_name"),
|
|
66
|
+
"account": ("Account", "account_name"),
|
|
67
|
+
"company": ("Company", "company_name"),
|
|
68
|
+
"cost-center": ("Cost Center", "cost_center_name"),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Slug <-> doctype name mapping
|
|
72
|
+
SLUG_TO_DOCTYPE = {}
|
|
73
|
+
DOCTYPE_TO_SLUG = {}
|
|
74
|
+
for dt in DOCUMENT_CLASSES:
|
|
75
|
+
slug = dt.lower().replace(" ", "-")
|
|
76
|
+
SLUG_TO_DOCTYPE[slug] = dt
|
|
77
|
+
DOCTYPE_TO_SLUG[dt] = slug
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def register_doctype(doctype: str, cls, slug: str | None = None) -> None:
|
|
81
|
+
"""Register (or override) the class used for a doctype.
|
|
82
|
+
|
|
83
|
+
Extension point for customer deployments (see
|
|
84
|
+
docs/core-extension-architecture.md): a plugin subclasses a core document
|
|
85
|
+
class and registers it here at startup, so every loader path
|
|
86
|
+
(create/load/update/submit/cancel_document) resolves the subclass.
|
|
87
|
+
`get_document_class` reads `DOCUMENT_CLASSES` live, so no other change is
|
|
88
|
+
needed.
|
|
89
|
+
"""
|
|
90
|
+
DOCUMENT_CLASSES[doctype] = cls
|
|
91
|
+
slug = slug or doctype.lower().replace(" ", "-")
|
|
92
|
+
SLUG_TO_DOCTYPE[slug] = doctype
|
|
93
|
+
DOCTYPE_TO_SLUG[doctype] = slug
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_document_class(doctype_slug: str):
|
|
97
|
+
"""Get document class from URL slug."""
|
|
98
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
99
|
+
if not doctype:
|
|
100
|
+
return None, None
|
|
101
|
+
return doctype, DOCUMENT_CLASSES[doctype]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def create_document(doctype_slug: str, data: dict) -> dict:
|
|
105
|
+
doctype, cls = get_document_class(doctype_slug)
|
|
106
|
+
if not cls:
|
|
107
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
108
|
+
doc = cls(data)
|
|
109
|
+
doc.save()
|
|
110
|
+
return doc.as_dict()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_document(doctype_slug: str, name: str) -> dict:
|
|
114
|
+
doctype, cls = get_document_class(doctype_slug)
|
|
115
|
+
if not cls:
|
|
116
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
117
|
+
doc = cls.load(name)
|
|
118
|
+
return doc.as_dict()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def update_document(doctype_slug: str, name: str, data: dict) -> dict:
|
|
122
|
+
doctype, cls = get_document_class(doctype_slug)
|
|
123
|
+
if not cls:
|
|
124
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
125
|
+
doc = cls.load(name)
|
|
126
|
+
# Update parent fields
|
|
127
|
+
for key, value in data.items():
|
|
128
|
+
if key not in ("name", "docstatus", "creation") and key not in doc.CHILD_TABLES:
|
|
129
|
+
doc._data[key] = value
|
|
130
|
+
# Update child tables if provided
|
|
131
|
+
for table_name in doc.CHILD_TABLES:
|
|
132
|
+
if table_name in data:
|
|
133
|
+
doc._children[table_name] = []
|
|
134
|
+
for row in data[table_name]:
|
|
135
|
+
doc.append(table_name, _dict(row))
|
|
136
|
+
doc.save()
|
|
137
|
+
return doc.as_dict()
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def submit_document(doctype_slug: str, name: str) -> dict:
|
|
141
|
+
doctype, cls = get_document_class(doctype_slug)
|
|
142
|
+
if not cls:
|
|
143
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
144
|
+
doc = cls.load(name)
|
|
145
|
+
doc.submit()
|
|
146
|
+
return doc.as_dict()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cancel_document(doctype_slug: str, name: str) -> dict:
|
|
150
|
+
doctype, cls = get_document_class(doctype_slug)
|
|
151
|
+
if not cls:
|
|
152
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
153
|
+
doc = cls.load(name)
|
|
154
|
+
doc.cancel()
|
|
155
|
+
return doc.as_dict()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register_converter(source_doctype: str, target_doctype: str, fn) -> None:
|
|
159
|
+
"""Register (or override) the converter for a (source, target) pair.
|
|
160
|
+
|
|
161
|
+
Extension point for customer deployments that need different conversion
|
|
162
|
+
logic (see docs/core-extension-architecture.md). To merely have the
|
|
163
|
+
converted document use an overridden class, you don't need this — register
|
|
164
|
+
the class with `register_doctype` and `convert_document` upgrades the
|
|
165
|
+
produced instance automatically.
|
|
166
|
+
"""
|
|
167
|
+
CONVERTERS[(source_doctype, target_doctype)] = fn
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def convert_document(doctype_slug: str, name: str, target_doctype: str) -> dict:
|
|
171
|
+
source_doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
172
|
+
if not source_doctype:
|
|
173
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
174
|
+
|
|
175
|
+
converter = CONVERTERS.get((source_doctype, target_doctype))
|
|
176
|
+
if not converter:
|
|
177
|
+
raise ValueError(f"Cannot convert {source_doctype} to {target_doctype}")
|
|
178
|
+
|
|
179
|
+
new_doc = converter(name)
|
|
180
|
+
# Honor a registered class override for the produced doctype. The core
|
|
181
|
+
# converter builds a base-class instance; if a plugin registered a subclass
|
|
182
|
+
# for the target, upgrade the instance in place so save()/on_submit() use
|
|
183
|
+
# the override. Safe because overrides are subclasses (same layout, no
|
|
184
|
+
# __slots__); guarded so a non-subclass registration is ignored.
|
|
185
|
+
override = DOCUMENT_CLASSES.get(target_doctype)
|
|
186
|
+
if override is not None and type(new_doc) is not override and issubclass(override, type(new_doc)):
|
|
187
|
+
new_doc.__class__ = override
|
|
188
|
+
|
|
189
|
+
new_doc.save()
|
|
190
|
+
return new_doc.as_dict()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
DATE_FIELDS = {
|
|
194
|
+
"Quotation": "transaction_date",
|
|
195
|
+
"Sales Order": "transaction_date",
|
|
196
|
+
"Purchase Order": "transaction_date",
|
|
197
|
+
"Sales Invoice": "posting_date",
|
|
198
|
+
"Purchase Invoice": "posting_date",
|
|
199
|
+
"Payment Entry": "posting_date",
|
|
200
|
+
"Journal Entry": "posting_date",
|
|
201
|
+
"Stock Entry": "posting_date",
|
|
202
|
+
"Delivery Note": "posting_date",
|
|
203
|
+
"Purchase Receipt": "posting_date",
|
|
204
|
+
"POS Invoice": "posting_date",
|
|
205
|
+
"Bank Transaction": "posting_date",
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def list_documents(doctype_slug: str, filters: dict = None, limit: int = 50, offset: int = 0) -> list:
|
|
210
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
211
|
+
if not doctype:
|
|
212
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
213
|
+
|
|
214
|
+
db = get_db()
|
|
215
|
+
db_filters = {}
|
|
216
|
+
from_date = None
|
|
217
|
+
to_date = None
|
|
218
|
+
if filters:
|
|
219
|
+
for key, value in filters.items():
|
|
220
|
+
if value is None or value == "":
|
|
221
|
+
continue
|
|
222
|
+
if key == "from_date":
|
|
223
|
+
from_date = value
|
|
224
|
+
elif key == "to_date":
|
|
225
|
+
to_date = value
|
|
226
|
+
else:
|
|
227
|
+
db_filters[key] = value
|
|
228
|
+
|
|
229
|
+
# Date range filtering via the doctype's primary date field
|
|
230
|
+
date_field = DATE_FIELDS.get(doctype)
|
|
231
|
+
if date_field:
|
|
232
|
+
if from_date:
|
|
233
|
+
db_filters[date_field] = (">=", from_date)
|
|
234
|
+
if to_date:
|
|
235
|
+
# If we already set from_date, we need a second condition on the same field
|
|
236
|
+
if from_date:
|
|
237
|
+
# Use raw SQL fallback below
|
|
238
|
+
pass
|
|
239
|
+
else:
|
|
240
|
+
db_filters[date_field] = ("<=", to_date)
|
|
241
|
+
|
|
242
|
+
# If both from and to are set, the dict can only hold one constraint per key
|
|
243
|
+
# Fall through to a raw SQL query for that case
|
|
244
|
+
if date_field and from_date and to_date:
|
|
245
|
+
return _list_with_date_range(
|
|
246
|
+
db, doctype, doctype_slug, db_filters, date_field, from_date, to_date, limit, offset
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# get_all doesn't support offset, so use raw SQL when needed
|
|
250
|
+
if offset:
|
|
251
|
+
return _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset)
|
|
252
|
+
|
|
253
|
+
rows = db.get_all(
|
|
254
|
+
doctype,
|
|
255
|
+
filters=db_filters if db_filters else None,
|
|
256
|
+
fields=["*"],
|
|
257
|
+
order_by="creation DESC",
|
|
258
|
+
limit=limit,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return _attach_children(db, doctype_slug, rows)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def count_documents(doctype_slug: str, filters: dict = None) -> int:
|
|
265
|
+
"""Count documents matching the filters (ignores limit/offset)."""
|
|
266
|
+
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
267
|
+
if not doctype:
|
|
268
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
269
|
+
|
|
270
|
+
db = get_db()
|
|
271
|
+
db_filters = {}
|
|
272
|
+
from_date = None
|
|
273
|
+
to_date = None
|
|
274
|
+
if filters:
|
|
275
|
+
for key, value in filters.items():
|
|
276
|
+
if value is None or value == "":
|
|
277
|
+
continue
|
|
278
|
+
if key == "from_date":
|
|
279
|
+
from_date = value
|
|
280
|
+
elif key == "to_date":
|
|
281
|
+
to_date = value
|
|
282
|
+
else:
|
|
283
|
+
db_filters[key] = value
|
|
284
|
+
|
|
285
|
+
date_field = DATE_FIELDS.get(doctype)
|
|
286
|
+
where_parts = []
|
|
287
|
+
params = []
|
|
288
|
+
if date_field and from_date:
|
|
289
|
+
where_parts.append(f'"{date_field}" >= ?')
|
|
290
|
+
params.append(from_date)
|
|
291
|
+
if date_field and to_date:
|
|
292
|
+
where_parts.append(f'"{date_field}" <= ?')
|
|
293
|
+
params.append(to_date)
|
|
294
|
+
for k, v in db_filters.items():
|
|
295
|
+
if isinstance(v, (list, tuple)) and len(v) == 2:
|
|
296
|
+
op, val = v
|
|
297
|
+
where_parts.append(f'"{k}" {op} ?')
|
|
298
|
+
params.append(val)
|
|
299
|
+
else:
|
|
300
|
+
where_parts.append(f'"{k}" = ?')
|
|
301
|
+
params.append(v)
|
|
302
|
+
|
|
303
|
+
query = f'SELECT COUNT(*) as c FROM "{doctype}"'
|
|
304
|
+
if where_parts:
|
|
305
|
+
query += " WHERE " + " AND ".join(where_parts)
|
|
306
|
+
rows = db.sql(query, params)
|
|
307
|
+
return int(rows[0]["c"]) if rows else 0
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _list_with_offset(db, doctype, doctype_slug, db_filters, limit, offset):
|
|
311
|
+
where_parts = []
|
|
312
|
+
params = []
|
|
313
|
+
for k, v in db_filters.items():
|
|
314
|
+
if isinstance(v, (list, tuple)) and len(v) == 2:
|
|
315
|
+
op, val = v
|
|
316
|
+
where_parts.append(f'"{k}" {op} ?')
|
|
317
|
+
params.append(val)
|
|
318
|
+
else:
|
|
319
|
+
where_parts.append(f'"{k}" = ?')
|
|
320
|
+
params.append(v)
|
|
321
|
+
query = f'SELECT * FROM "{doctype}"'
|
|
322
|
+
if where_parts:
|
|
323
|
+
query += " WHERE " + " AND ".join(where_parts)
|
|
324
|
+
query += " ORDER BY creation DESC"
|
|
325
|
+
if limit:
|
|
326
|
+
query += f" LIMIT {int(limit)}"
|
|
327
|
+
if offset:
|
|
328
|
+
query += f" OFFSET {int(offset)}"
|
|
329
|
+
rows = db.sql(query, params)
|
|
330
|
+
return _attach_children(db, doctype_slug, rows)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _attach_children(db, doctype_slug: str, rows: list) -> list:
|
|
334
|
+
_, cls = get_document_class(doctype_slug)
|
|
335
|
+
result = []
|
|
336
|
+
for row in rows:
|
|
337
|
+
doc_dict = dict(row)
|
|
338
|
+
if cls and cls.CHILD_TABLES:
|
|
339
|
+
for table_name, (child_doctype, _) in cls.CHILD_TABLES.items():
|
|
340
|
+
children = db.get_all(
|
|
341
|
+
child_doctype,
|
|
342
|
+
filters={"parent": row["name"]},
|
|
343
|
+
fields=["*"],
|
|
344
|
+
order_by="idx",
|
|
345
|
+
)
|
|
346
|
+
doc_dict[table_name] = [dict(c) for c in children]
|
|
347
|
+
result.append(doc_dict)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _list_with_date_range(db, doctype, doctype_slug, extra_filters, date_field, from_date, to_date, limit, offset=0):
|
|
352
|
+
"""List documents when both from_date and to_date are set."""
|
|
353
|
+
where_parts = [f'"{date_field}" >= ?', f'"{date_field}" <= ?']
|
|
354
|
+
params = [from_date, to_date]
|
|
355
|
+
for k, v in extra_filters.items():
|
|
356
|
+
if isinstance(v, (list, tuple)) and len(v) == 2:
|
|
357
|
+
op, val = v
|
|
358
|
+
where_parts.append(f'"{k}" {op} ?')
|
|
359
|
+
params.append(val)
|
|
360
|
+
else:
|
|
361
|
+
where_parts.append(f'"{k}" = ?')
|
|
362
|
+
params.append(v)
|
|
363
|
+
query = (
|
|
364
|
+
f'SELECT * FROM "{doctype}" WHERE ' + " AND ".join(where_parts)
|
|
365
|
+
+ " ORDER BY creation DESC"
|
|
366
|
+
)
|
|
367
|
+
if limit:
|
|
368
|
+
query += f" LIMIT {int(limit)}"
|
|
369
|
+
if offset:
|
|
370
|
+
query += f" OFFSET {int(offset)}"
|
|
371
|
+
rows = db.sql(query, params)
|
|
372
|
+
return _attach_children(db, doctype_slug, rows)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
@page { size: {{ page_size }}; margin: 20mm 15mm; }
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
body { font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #1a1a1a; line-height: 1.4; }
|
|
9
|
+
|
|
10
|
+
.header { display: flex; justify-content: space-between; margin-bottom: 20px; }
|
|
11
|
+
.company { font-size: 14pt; font-weight: bold; color: #2563eb; }
|
|
12
|
+
.doc-title { text-align: right; }
|
|
13
|
+
.doc-title h1 { font-size: 16pt; color: #1a1a1a; margin: 0; }
|
|
14
|
+
.doc-title .doc-name { font-size: 10pt; color: #666; }
|
|
15
|
+
.doc-title .doc-date { font-size: 9pt; color: #888; margin-top: 2px; }
|
|
16
|
+
|
|
17
|
+
.parties { display: flex; justify-content: space-between; margin-bottom: 20px; padding: 12px; background: #f8fafc; border-radius: 4px; }
|
|
18
|
+
.party-block h3 { font-size: 8pt; text-transform: uppercase; letter-spacing: 0.5px; color: #888; margin-bottom: 4px; }
|
|
19
|
+
.party-block .name { font-weight: bold; font-size: 10pt; }
|
|
20
|
+
.party-block .detail { font-size: 8pt; color: #555; }
|
|
21
|
+
|
|
22
|
+
.meta-row { display: flex; gap: 30px; margin-bottom: 15px; font-size: 8.5pt; }
|
|
23
|
+
.meta-item { }
|
|
24
|
+
.meta-item .label { color: #888; text-transform: uppercase; letter-spacing: 0.3px; font-size: 7pt; }
|
|
25
|
+
.meta-item .value { font-weight: 600; }
|
|
26
|
+
|
|
27
|
+
table.items { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
|
|
28
|
+
table.items th { background: #f1f5f9; padding: 6px 8px; text-align: left; font-size: 8pt; font-weight: 600; color: #555; border-bottom: 2px solid #e2e8f0; }
|
|
29
|
+
table.items td { padding: 6px 8px; border-bottom: 1px solid #f1f5f9; }
|
|
30
|
+
table.items .num { text-align: right; }
|
|
31
|
+
table.items tfoot td { border-top: 2px solid #e2e8f0; font-weight: bold; }
|
|
32
|
+
|
|
33
|
+
.totals { margin-left: auto; width: 250px; margin-bottom: 20px; }
|
|
34
|
+
.totals table { width: 100%; }
|
|
35
|
+
.totals td { padding: 4px 0; font-size: 9pt; }
|
|
36
|
+
.totals td.label { color: #555; }
|
|
37
|
+
.totals td.amount { text-align: right; font-weight: 600; }
|
|
38
|
+
.totals .grand { font-size: 11pt; border-top: 2px solid #1a1a1a; padding-top: 6px; }
|
|
39
|
+
|
|
40
|
+
.taxes { margin-bottom: 10px; }
|
|
41
|
+
.taxes h3 { font-size: 8pt; color: #888; text-transform: uppercase; margin-bottom: 4px; }
|
|
42
|
+
|
|
43
|
+
.status-badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 8pt; font-weight: bold; }
|
|
44
|
+
.status-submitted { background: #dbeafe; color: #1d4ed8; }
|
|
45
|
+
.status-cancelled { background: #fee2e2; color: #dc2626; }
|
|
46
|
+
.status-draft { background: #f3f4f6; color: #6b7280; }
|
|
47
|
+
.status-return { background: #fef3c7; color: #d97706; }
|
|
48
|
+
|
|
49
|
+
.footer { margin-top: 30px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 7.5pt; color: #999; text-align: center; }
|
|
50
|
+
.remarks { margin-top: 15px; padding: 8px; background: #fefce8; border-radius: 4px; font-size: 8pt; color: #713f12; }
|
|
51
|
+
</style>
|
|
52
|
+
</head>
|
|
53
|
+
<body>
|
|
54
|
+
|
|
55
|
+
{# ---- Header ---- #}
|
|
56
|
+
<div class="header">
|
|
57
|
+
<div>
|
|
58
|
+
<div class="company">{{ company_name or doc.company or "" }}</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="doc-title">
|
|
61
|
+
<h1>{{ title }}{% if doc.is_return %} <span class="status-badge status-return">RETURN</span>{% endif %}</h1>
|
|
62
|
+
<div class="doc-name">{{ doc.name }}</div>
|
|
63
|
+
<div class="doc-date">{{ doc.posting_date or doc.transaction_date or "" }}</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{# ---- Parties ---- #}
|
|
68
|
+
<div class="parties">
|
|
69
|
+
<div class="party-block">
|
|
70
|
+
<h3>From</h3>
|
|
71
|
+
<div class="name">{{ company_name or doc.company or "" }}</div>
|
|
72
|
+
{% if company_info %}
|
|
73
|
+
{% if company_info.get("address") %}<div class="detail">{{ company_info.address }}</div>{% endif %}
|
|
74
|
+
{% if company_info.get("city") or company_info.get("zip_code") or company_info.get("country") %}<div class="detail">{{ company_info.get("city", "") }}{% if company_info.get("zip_code") %} {{ company_info.zip_code }}{% endif %}{% if company_info.get("country") %}, {{ company_info.country }}{% endif %}</div>{% endif %}
|
|
75
|
+
{% if company_info.get("email") %}<div class="detail">{{ company_info.email }}</div>{% endif %}
|
|
76
|
+
{% if company_info.get("phone") %}<div class="detail">{{ company_info.phone }}</div>{% endif %}
|
|
77
|
+
{% if company_info.get("tax_id") %}<div class="detail">Tax ID: {{ company_info.tax_id }}</div>{% endif %}
|
|
78
|
+
{% endif %}
|
|
79
|
+
</div>
|
|
80
|
+
<div class="party-block" style="text-align: right;">
|
|
81
|
+
<h3>{{ party_label }}</h3>
|
|
82
|
+
<div class="name">{{ party_name }}</div>
|
|
83
|
+
{% if party_info %}
|
|
84
|
+
{% if party_info.get("address") %}<div class="detail">{{ party_info.address }}</div>{% endif %}
|
|
85
|
+
{% if party_info.get("city") or party_info.get("zip_code") or party_info.get("country") %}<div class="detail">{{ party_info.get("city", "") }}{% if party_info.get("zip_code") %} {{ party_info.zip_code }}{% endif %}{% if party_info.get("country") %}, {{ party_info.country }}{% endif %}</div>{% endif %}
|
|
86
|
+
{% if party_info.get("email") %}<div class="detail">{{ party_info.email }}</div>{% endif %}
|
|
87
|
+
{% if party_info.get("phone") %}<div class="detail">{{ party_info.phone }}</div>{% endif %}
|
|
88
|
+
{% if party_info.get("tax_id") %}<div class="detail">Tax ID: {{ party_info.tax_id }}</div>{% endif %}
|
|
89
|
+
{% endif %}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{# ---- Meta fields ---- #}
|
|
94
|
+
{% if meta_fields %}
|
|
95
|
+
<div class="meta-row">
|
|
96
|
+
{% for m in meta_fields %}
|
|
97
|
+
<div class="meta-item">
|
|
98
|
+
<div class="label">{{ m.label }}</div>
|
|
99
|
+
<div class="value">{{ m.value }}</div>
|
|
100
|
+
</div>
|
|
101
|
+
{% endfor %}
|
|
102
|
+
</div>
|
|
103
|
+
{% endif %}
|
|
104
|
+
|
|
105
|
+
{# ---- Items table ---- #}
|
|
106
|
+
<table class="items">
|
|
107
|
+
<thead>
|
|
108
|
+
<tr>
|
|
109
|
+
<th style="width: 30px">#</th>
|
|
110
|
+
<th>Item</th>
|
|
111
|
+
<th>Description</th>
|
|
112
|
+
{% if show_warehouse %}<th>Warehouse</th>{% endif %}
|
|
113
|
+
<th class="num">Qty</th>
|
|
114
|
+
<th class="num">Rate</th>
|
|
115
|
+
<th class="num">Amount</th>
|
|
116
|
+
</tr>
|
|
117
|
+
</thead>
|
|
118
|
+
<tbody>
|
|
119
|
+
{% for item in items %}
|
|
120
|
+
<tr>
|
|
121
|
+
<td>{{ loop.index }}</td>
|
|
122
|
+
<td style="font-weight: 600">{{ item.item_code or "" }}</td>
|
|
123
|
+
<td>{{ item.item_name or item.description or "" }}</td>
|
|
124
|
+
{% if show_warehouse %}<td>{{ item.warehouse or "" }}</td>{% endif %}
|
|
125
|
+
<td class="num">{{ item.qty }}</td>
|
|
126
|
+
<td class="num">{{ "%.2f"|format(item.rate or 0) }}</td>
|
|
127
|
+
<td class="num">{{ "%.2f"|format(item.amount or item.net_amount or (item.qty or 0) * (item.rate or 0)) }}</td>
|
|
128
|
+
</tr>
|
|
129
|
+
{% endfor %}
|
|
130
|
+
</tbody>
|
|
131
|
+
</table>
|
|
132
|
+
|
|
133
|
+
{# ---- Taxes ---- #}
|
|
134
|
+
{% if taxes %}
|
|
135
|
+
<div class="taxes">
|
|
136
|
+
<h3>Taxes & Charges</h3>
|
|
137
|
+
<table class="items">
|
|
138
|
+
<thead>
|
|
139
|
+
<tr>
|
|
140
|
+
<th>Description</th>
|
|
141
|
+
<th class="num">Rate</th>
|
|
142
|
+
<th class="num">Amount</th>
|
|
143
|
+
</tr>
|
|
144
|
+
</thead>
|
|
145
|
+
<tbody>
|
|
146
|
+
{% for tax in taxes %}
|
|
147
|
+
<tr>
|
|
148
|
+
<td>{{ tax.description or tax.account_head or "" }}</td>
|
|
149
|
+
<td class="num">{{ "%.1f"|format(tax.rate or 0) }}%</td>
|
|
150
|
+
<td class="num">{{ "%.2f"|format(tax.tax_amount or 0) }}</td>
|
|
151
|
+
</tr>
|
|
152
|
+
{% endfor %}
|
|
153
|
+
</tbody>
|
|
154
|
+
</table>
|
|
155
|
+
</div>
|
|
156
|
+
{% endif %}
|
|
157
|
+
|
|
158
|
+
{# ---- Totals ---- #}
|
|
159
|
+
<div class="totals">
|
|
160
|
+
<table>
|
|
161
|
+
<tr>
|
|
162
|
+
<td class="label">Net Total</td>
|
|
163
|
+
<td class="amount">{{ currency }} {{ "%.2f"|format(doc.net_total or 0) }}</td>
|
|
164
|
+
</tr>
|
|
165
|
+
{% if doc.total_taxes_and_charges %}
|
|
166
|
+
<tr>
|
|
167
|
+
<td class="label">Tax</td>
|
|
168
|
+
<td class="amount">{{ currency }} {{ "%.2f"|format(doc.total_taxes_and_charges or 0) }}</td>
|
|
169
|
+
</tr>
|
|
170
|
+
{% endif %}
|
|
171
|
+
<tr class="grand">
|
|
172
|
+
<td class="label">Grand Total</td>
|
|
173
|
+
<td class="amount">{{ currency }} {{ "%.2f"|format(doc.grand_total or 0) }}</td>
|
|
174
|
+
</tr>
|
|
175
|
+
{% if doc.outstanding_amount is defined and doc.outstanding_amount is not none %}
|
|
176
|
+
<tr>
|
|
177
|
+
<td class="label">Outstanding</td>
|
|
178
|
+
<td class="amount">{{ currency }} {{ "%.2f"|format(doc.outstanding_amount or 0) }}</td>
|
|
179
|
+
</tr>
|
|
180
|
+
{% endif %}
|
|
181
|
+
</table>
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{# ---- Remarks ---- #}
|
|
185
|
+
{% if doc.remarks %}
|
|
186
|
+
<div class="remarks">
|
|
187
|
+
<strong>Remarks:</strong> {{ doc.remarks }}
|
|
188
|
+
</div>
|
|
189
|
+
{% endif %}
|
|
190
|
+
|
|
191
|
+
{# ---- Footer ---- #}
|
|
192
|
+
<div class="footer">
|
|
193
|
+
{{ doc.name }} · Generated from Lambda ERP
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|
lambda_erp/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bank Transaction.
|
|
3
|
+
|
|
4
|
+
Bank Transaction represents a single entry from a bank statement.
|
|
5
|
+
It can be matched to Payment Entries or Invoices for reconciliation.
|
|
6
|
+
When matched, it sets clearance_date on the referenced document.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from lambda_erp.model import Document
|
|
10
|
+
from lambda_erp.utils import _dict, flt, nowdate
|
|
11
|
+
from lambda_erp.database import get_db
|
|
12
|
+
from lambda_erp.exceptions import ValidationError
|
|
13
|
+
|
|
14
|
+
class BankTransaction(Document):
|
|
15
|
+
DOCTYPE = "Bank Transaction"
|
|
16
|
+
CHILD_TABLES = {}
|
|
17
|
+
PREFIX = "BT"
|
|
18
|
+
|
|
19
|
+
def validate(self):
|
|
20
|
+
deposit = flt(self.deposit)
|
|
21
|
+
withdrawal = flt(self.withdrawal)
|
|
22
|
+
if not deposit and not withdrawal:
|
|
23
|
+
raise ValidationError("Either Deposit or Withdrawal amount is required")
|
|
24
|
+
if deposit and withdrawal:
|
|
25
|
+
raise ValidationError("Cannot have both Deposit and Withdrawal")
|
|
26
|
+
|
|
27
|
+
if not self.posting_date:
|
|
28
|
+
self._data["posting_date"] = nowdate()
|
|
29
|
+
|
|
30
|
+
self._calculate_unallocated()
|
|
31
|
+
self._set_status()
|
|
32
|
+
|
|
33
|
+
def _calculate_unallocated(self):
|
|
34
|
+
total = flt(self.deposit) or flt(self.withdrawal)
|
|
35
|
+
allocated = flt(self.allocated_amount)
|
|
36
|
+
self._data["unallocated_amount"] = flt(total - allocated, 2)
|
|
37
|
+
|
|
38
|
+
def _set_status(self):
|
|
39
|
+
unallocated = flt(self._data.get("unallocated_amount", 0))
|
|
40
|
+
total = flt(self.deposit) or flt(self.withdrawal)
|
|
41
|
+
if unallocated <= 0 and total > 0:
|
|
42
|
+
self._data["status"] = "Reconciled"
|
|
43
|
+
elif flt(self.allocated_amount) > 0:
|
|
44
|
+
self._data["status"] = "Partially Reconciled"
|
|
45
|
+
else:
|
|
46
|
+
self._data["status"] = "Unreconciled"
|
|
47
|
+
|
|
48
|
+
def reconcile_bank_transaction(bank_transaction_name, reference_doctype, reference_name):
|
|
49
|
+
"""Match a bank transaction to a payment entry or invoice.
|
|
50
|
+
|
|
51
|
+
Sets clearance_date on the referenced document and updates the
|
|
52
|
+
bank transaction's allocated amount and status.
|
|
53
|
+
"""
|
|
54
|
+
db = get_db()
|
|
55
|
+
|
|
56
|
+
bt = BankTransaction.load(bank_transaction_name)
|
|
57
|
+
total = flt(bt.deposit) or flt(bt.withdrawal)
|
|
58
|
+
unallocated = flt(bt._data.get("unallocated_amount", total))
|
|
59
|
+
|
|
60
|
+
if unallocated <= 0:
|
|
61
|
+
raise ValidationError("Bank Transaction is already fully reconciled")
|
|
62
|
+
|
|
63
|
+
# Set reference on bank transaction
|
|
64
|
+
bt._data["reference_doctype"] = reference_doctype
|
|
65
|
+
bt._data["reference_name"] = reference_name
|
|
66
|
+
bt._data["allocated_amount"] = total
|
|
67
|
+
bt._calculate_unallocated()
|
|
68
|
+
bt._set_status()
|
|
69
|
+
bt._persist()
|
|
70
|
+
|
|
71
|
+
# Set clearance_date on the referenced document
|
|
72
|
+
if db.exists(reference_doctype, reference_name):
|
|
73
|
+
db.set_value(reference_doctype, reference_name, "clearance_date", bt.posting_date)
|
|
74
|
+
db.commit()
|
|
75
|
+
|
|
76
|
+
return bt.as_dict()
|