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.
Files changed (60) hide show
  1. api/__init__.py +0 -0
  2. api/attachments.py +229 -0
  3. api/auth.py +511 -0
  4. api/bootstrap.py +498 -0
  5. api/chat.py +2764 -0
  6. api/demo_limits.py +400 -0
  7. api/deps.py +7 -0
  8. api/errors.py +56 -0
  9. api/main.py +182 -0
  10. api/pdf.py +151 -0
  11. api/providers.py +116 -0
  12. api/routers/__init__.py +0 -0
  13. api/routers/accounting.py +63 -0
  14. api/routers/admin.py +122 -0
  15. api/routers/analytics.py +1009 -0
  16. api/routers/bank_reconciliation.py +31 -0
  17. api/routers/documents.py +100 -0
  18. api/routers/masters.py +396 -0
  19. api/routers/reports.py +735 -0
  20. api/routers/setup.py +387 -0
  21. api/services.py +372 -0
  22. api/templates/document.html +197 -0
  23. lambda_erp/__init__.py +3 -0
  24. lambda_erp/accounting/__init__.py +0 -0
  25. lambda_erp/accounting/bank_transaction.py +76 -0
  26. lambda_erp/accounting/budget.py +117 -0
  27. lambda_erp/accounting/chart_of_accounts.py +183 -0
  28. lambda_erp/accounting/general_ledger.py +362 -0
  29. lambda_erp/accounting/journal_entry.py +235 -0
  30. lambda_erp/accounting/payment_entry.py +515 -0
  31. lambda_erp/accounting/pos_invoice.py +342 -0
  32. lambda_erp/accounting/purchase_invoice.py +504 -0
  33. lambda_erp/accounting/revaluation.py +172 -0
  34. lambda_erp/accounting/sales_invoice.py +523 -0
  35. lambda_erp/accounting/subscription.py +132 -0
  36. lambda_erp/buying/__init__.py +0 -0
  37. lambda_erp/buying/purchase_order.py +165 -0
  38. lambda_erp/controllers/__init__.py +0 -0
  39. lambda_erp/controllers/currency.py +52 -0
  40. lambda_erp/controllers/defaults.py +51 -0
  41. lambda_erp/controllers/pricing_rule.py +103 -0
  42. lambda_erp/controllers/taxes_and_totals.py +369 -0
  43. lambda_erp/database.py +1543 -0
  44. lambda_erp/exceptions.py +37 -0
  45. lambda_erp/hooks.py +37 -0
  46. lambda_erp/model.py +462 -0
  47. lambda_erp/selling/__init__.py +0 -0
  48. lambda_erp/selling/quotation.py +263 -0
  49. lambda_erp/selling/sales_order.py +214 -0
  50. lambda_erp/simulation.py +704 -0
  51. lambda_erp/stock/__init__.py +0 -0
  52. lambda_erp/stock/delivery_note.py +254 -0
  53. lambda_erp/stock/purchase_receipt.py +356 -0
  54. lambda_erp/stock/stock_entry.py +330 -0
  55. lambda_erp/stock/stock_ledger.py +337 -0
  56. lambda_erp/utils.py +167 -0
  57. lambda_erp-0.1.0.dist-info/METADATA +454 -0
  58. lambda_erp-0.1.0.dist-info/RECORD +60 -0
  59. lambda_erp-0.1.0.dist-info/WHEEL +4 -0
  60. 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 }} &middot; Generated from Lambda ERP
194
+ </div>
195
+
196
+ </body>
197
+ </html>
lambda_erp/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Lambda ERP - Core ERP logic packaged as a standalone library."""
2
+
3
+ __version__ = "0.1.0"
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()