lambda-erp 0.1.44__tar.gz → 0.1.46__tar.gz

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 (71) hide show
  1. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/chat.py +58 -3
  3. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/masters.py +16 -3
  4. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/reports.py +106 -0
  5. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/frontend/src/api/client.ts +5 -0
  6. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/pyproject.toml +1 -1
  7. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/.gitignore +0 -0
  8. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/LICENSE +0 -0
  9. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/README.md +0 -0
  10. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/__init__.py +0 -0
  11. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/attachments.py +0 -0
  12. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/auth.py +0 -0
  13. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/bootstrap.py +0 -0
  14. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/demo_limits.py +0 -0
  15. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/deps.py +0 -0
  16. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/errors.py +0 -0
  17. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/main.py +0 -0
  18. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/oauth.py +0 -0
  19. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/pdf.py +0 -0
  20. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/providers.py +0 -0
  21. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/remarks_md.py +0 -0
  22. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/__init__.py +0 -0
  23. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/accounting.py +0 -0
  24. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/admin.py +0 -0
  25. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/analytics.py +0 -0
  26. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/bank_reconciliation.py +0 -0
  27. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/chat_api.py +0 -0
  28. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/documents.py +0 -0
  29. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/proposals.py +0 -0
  30. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/routers/setup.py +0 -0
  31. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/services.py +0 -0
  32. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/templates/document.html +0 -0
  33. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/api/templates/proposal.html +0 -0
  34. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/docs/agents/README.md +0 -0
  35. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/frontend/README.md +0 -0
  36. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/__init__.py +0 -0
  37. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/__init__.py +0 -0
  38. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/bank_transaction.py +0 -0
  39. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/budget.py +0 -0
  40. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  41. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/general_ledger.py +0 -0
  42. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/journal_entry.py +0 -0
  43. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/payment_entry.py +0 -0
  44. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/pos_invoice.py +0 -0
  45. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/purchase_invoice.py +0 -0
  46. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/revaluation.py +0 -0
  47. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/sales_invoice.py +0 -0
  48. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/accounting/subscription.py +0 -0
  49. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/buying/__init__.py +0 -0
  50. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/buying/purchase_order.py +0 -0
  51. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/controllers/__init__.py +0 -0
  52. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/controllers/currency.py +0 -0
  53. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/controllers/defaults.py +0 -0
  54. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/controllers/pricing_rule.py +0 -0
  55. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  56. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/database.py +0 -0
  57. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/exceptions.py +0 -0
  58. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/hooks.py +0 -0
  59. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/model.py +0 -0
  60. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/selling/__init__.py +0 -0
  61. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/selling/proposal.py +0 -0
  62. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/selling/quotation.py +0 -0
  63. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/selling/sales_order.py +0 -0
  64. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/simulation.py +0 -0
  65. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/stock/__init__.py +0 -0
  66. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/stock/delivery_note.py +0 -0
  67. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/stock/purchase_receipt.py +0 -0
  68. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/stock/stock_entry.py +0 -0
  69. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/stock/stock_ledger.py +0 -0
  70. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/lambda_erp/utils.py +0 -0
  71. {lambda_erp-0.1.44 → lambda_erp-0.1.46}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.44
3
+ Version: 0.1.46
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -34,7 +34,7 @@ from api.demo_limits import (
34
34
  limiter as demo_limiter,
35
35
  )
36
36
  from api.providers import cost_of_anthropic_call, cost_of_openai_call, cost_of_transcription
37
- from api.routers.masters import create_master_record, update_master_record, MASTER_IDENTITY_ALIAS
37
+ from api.routers.masters import create_master_record, update_master_record, delete_master_record, MASTER_IDENTITY_ALIAS
38
38
  from lambda_erp.database import get_db
39
39
  from lambda_erp.utils import flt, now, nowdate
40
40
 
@@ -661,6 +661,29 @@ TOOLS = [
661
661
  },
662
662
  },
663
663
  },
664
+ {
665
+ "type": "function",
666
+ "function": {
667
+ "name": "delete_master",
668
+ "description": (
669
+ "Delete a master record (customer, supplier, item, ...). ADMIN role only. "
670
+ "Reference-protected and safe by design: an UNREFERENCED record is permanently deleted; "
671
+ "a record referenced anywhere (documents, GL, stock, ...) is NOT deleted — it is automatically "
672
+ "disabled/archived instead and the result names the blocking reference (status='disabled'). "
673
+ "Use when the user explicitly asks to delete/remove a master record (e.g. a mistakenly created "
674
+ "or duplicate one). If they merely want it out of the way, prefer update_master with "
675
+ "{\"disabled\": 1}. Example: {\"master_type\":\"customer\",\"name\":\"CUST-007\"}"
676
+ ),
677
+ "parameters": {
678
+ "type": "object",
679
+ "properties": {
680
+ "master_type": {"type": "string", "enum": MASTER_TYPES},
681
+ "name": {"type": "string", "description": "Existing master record ID/name to delete."},
682
+ },
683
+ "required": ["master_type", "name"],
684
+ },
685
+ },
686
+ },
664
687
  {
665
688
  "type": "function",
666
689
  "function": {
@@ -1304,6 +1327,31 @@ def _handle_update_master(args):
1304
1327
  return result
1305
1328
 
1306
1329
 
1330
+ def _handle_delete_master(args, user_info=None):
1331
+ """Reference-protected master deletion, gated to the admin role.
1332
+
1333
+ Registered per-run (run_thinking_loop scopes it with the caller's
1334
+ user_info) — NOT in the base TOOL_HANDLERS dict, whose handlers are
1335
+ called with (args) only.
1336
+ """
1337
+ role = (user_info or {}).get("role")
1338
+ if role != "admin":
1339
+ return {
1340
+ "error": (
1341
+ "Deleting master records requires the admin role. Ask an ERP admin to do it, "
1342
+ "or disable the record instead: update_master with {\"disabled\": 1}."
1343
+ )
1344
+ }
1345
+ name = args.get("name")
1346
+ if not name:
1347
+ return {"error": "You must pass the existing master record 'name' to delete. Example: {\"master_type\":\"customer\",\"name\":\"CUST-007\"}"}
1348
+ try:
1349
+ return dict(delete_master_record(args["master_type"], name))
1350
+ except Exception as exc:
1351
+ detail = getattr(exc, "detail", None)
1352
+ return {"error": detail or str(exc)}
1353
+
1354
+
1307
1355
  def _handle_get_current_time(args):
1308
1356
  from datetime import datetime, timezone
1309
1357
  now = datetime.now(timezone.utc)
@@ -1855,7 +1903,7 @@ For purchased goods, prefer Purchase Receipt when receiving separately from bill
1855
1903
  - **Draft (docstatus=0):** Document is editable. Has NO financial impact — no GL entries, no stock movement. Can be updated freely.
1856
1904
  - **Submitted (docstatus=1):** Document is locked. GL entries and stock ledger entries are posted. Cannot be edited — only cancelled.
1857
1905
  - **Cancelled (docstatus=2):** GL and stock entries are reversed. Document is permanently archived. Cannot be reused or edited.
1858
- - **There is NO delete operation.** Documents are permanent records. This is by design for audit integrity.
1906
+ - **DOCUMENTS have NO delete operation.** Documents are permanent records — by design for audit integrity. This rule applies to DOCUMENTS ONLY; never claim master records can't be deleted "for audit integrity" (see Master Data Deletion below).
1859
1907
  - **To void a draft you no longer need:** submit it first (docstatus 0→1), then cancel it (docstatus 1→2). Do NOT tell the user to "delete" anything.
1860
1908
  - **To correct a submitted document:** cancel it and create a new one with the correct values.
1861
1909
  - Only submitted documents can be converted (e.g. Sales Order → Sales Invoice). Draft or cancelled documents cannot.
@@ -1864,6 +1912,9 @@ For purchased goods, prefer Purchase Receipt when receiving separately from bill
1864
1912
  ## Master Data Types
1865
1913
  customer, supplier, item, warehouse, account, company, cost-center
1866
1914
 
1915
+ ## Master Data Deletion
1916
+ Master records CAN be deleted — use the `delete_master` tool (ADMIN role only). It is reference-protected: an unreferenced record (e.g. a mistakenly created or duplicate one) is permanently deleted; a record referenced by any document, GL entry, or stock data is NOT deleted but automatically disabled/archived instead, and the result names the blocking reference — relay that reason to the user. When the user merely wants a record out of the way (not destroyed), prefer `update_master` with `{"disabled": 1}`. Non-admin users: explain that deletion needs an admin and offer to disable instead.
1917
+
1867
1918
  ## Warehouse Master Rules
1868
1919
  - To create a warehouse via `create_master`, you MUST pass a `data` object.
1869
1920
  - `warehouse_name` is required.
@@ -2325,6 +2376,9 @@ async def run_thinking_loop(
2325
2376
  )
2326
2377
 
2327
2378
  tool_handlers = dict(TOOL_HANDLERS)
2379
+ # delete_master needs the caller's role (admin-only) — scoped here rather
2380
+ # than in TOOL_HANDLERS, whose handlers are called with (args) only.
2381
+ tool_handlers["delete_master"] = lambda args: _handle_delete_master(args, user_info)
2328
2382
 
2329
2383
  user_role = user_info.get("role") if user_info else None
2330
2384
  demo_mode = is_demo_role(user_role)
@@ -2333,9 +2387,10 @@ async def run_thinking_loop(
2333
2387
  max_completion = demo_max_completion_tokens() if demo_mode else 4096
2334
2388
 
2335
2389
  if demo_mode:
2336
- denied = lambda _args: {"error": "Demo mode cannot create or update master data."}
2390
+ denied = lambda _args: {"error": "Demo mode cannot create, update, or delete master data."}
2337
2391
  tool_handlers["create_master"] = denied
2338
2392
  tool_handlers["update_master"] = denied
2393
+ tool_handlers["delete_master"] = denied
2339
2394
 
2340
2395
  # Scope session-aware tools without mutating globals.
2341
2396
  if session_id:
@@ -353,11 +353,19 @@ def update_master(master_type: str, name: str, data: dict, _user: dict = _manage
353
353
  return update_master_record(master_type, name, data)
354
354
 
355
355
 
356
- @router.delete("/{master_type}/{name}")
357
- def delete_master(master_type: str, name: str, _user: dict = _admin):
356
+ def delete_master_record(master_type: str, name: str) -> dict:
357
+ """Delete a master record with reference protection the ONLY sanctioned
358
+ delete path (the tables have no FK constraints, so every caller — the REST
359
+ route below AND the chat agent's delete_master tool — must go through here).
360
+
361
+ Unreferenced record → hard delete. Referenced record → auto-disabled instead
362
+ (when the doctype has a `disabled` column) with the blocking reference named,
363
+ else a 409. Raising HTTPException keeps route behavior; the chat handler
364
+ catches it and surfaces `detail` as the tool error.
365
+ """
358
366
  doctype, _ = _get_table(master_type)
359
367
  if not doctype:
360
- return {"detail": f"Unknown master type: {master_type}"}
368
+ raise HTTPException(status_code=422, detail=f"Unknown master type: {master_type}")
361
369
  db = get_db()
362
370
  if not db.exists(doctype, name):
363
371
  raise HTTPException(status_code=404, detail=f"{doctype} {name} not found")
@@ -374,6 +382,11 @@ def delete_master(master_type: str, name: str, _user: dict = _admin):
374
382
  return {"ok": True, "status": "deleted"}
375
383
 
376
384
 
385
+ @router.delete("/{master_type}/{name}")
386
+ def delete_master(master_type: str, name: str, _user: dict = _admin):
387
+ return delete_master_record(master_type, name)
388
+
389
+
377
390
  @router.get("/account/tree")
378
391
  def account_tree(company: str | None = None, _user: dict = _viewer):
379
392
  """Return Chart of Accounts as a nested tree."""
@@ -121,6 +121,112 @@ def trial_balance(
121
121
  company, presentation_currency, to_date)
122
122
 
123
123
 
124
+ def _chart_of_accounts(db, company=None, from_date=None, to_date=None):
125
+ """Chart of accounts (every account, incl. groups and zero-balance ones)
126
+ with period balances — the tree the GUI renders.
127
+
128
+ Balance semantics follow standard presentation:
129
+ - Balance Sheet accounts: CLOSING balance as of ``to_date`` (all postings
130
+ up to and including it; ``from_date`` is ignored for them).
131
+ - P&L accounts: movement WITHIN [from_date, to_date].
132
+ Values are natural-signed per root_type (Asset/Expense: debit − credit;
133
+ Liability/Equity/Income: credit − debit) so revenue reads positive.
134
+ Group accounts roll up every descendant's balance (plus any postings of
135
+ their own, though entries normally land on leaves).
136
+ """
137
+ acc_filters = {}
138
+ if company:
139
+ acc_filters["company"] = company
140
+ accounts = db.get_all(
141
+ "Account", filters=acc_filters,
142
+ fields=["name", "account_name", "parent_account", "root_type",
143
+ "report_type", "account_type", "is_group", "disabled"],
144
+ order_by="name",
145
+ )
146
+
147
+ def _movement(where_extra, params_extra):
148
+ clause = "is_cancelled = 0" + where_extra
149
+ params = list(params_extra)
150
+ if company:
151
+ clause += " AND company = ?"
152
+ params.append(company)
153
+ found = db.sql(
154
+ f"""SELECT account,
155
+ COALESCE(SUM(debit), 0) AS d,
156
+ COALESCE(SUM(credit), 0) AS c
157
+ FROM "GL Entry" WHERE {clause} GROUP BY account""",
158
+ params,
159
+ )
160
+ return {r["account"]: (flt(r["d"], 2), flt(r["c"], 2)) for r in found}
161
+
162
+ bs_where, bs_params = "", []
163
+ if to_date:
164
+ bs_where, bs_params = " AND posting_date <= ?", [to_date]
165
+ bs_map = _movement(bs_where, bs_params)
166
+
167
+ if from_date or to_date:
168
+ pl_where, pl_params = "", []
169
+ if from_date:
170
+ pl_where += " AND posting_date >= ?"
171
+ pl_params.append(from_date)
172
+ if to_date:
173
+ pl_where += " AND posting_date <= ?"
174
+ pl_params.append(to_date)
175
+ pl_map = _movement(pl_where, pl_params)
176
+ else:
177
+ pl_map = bs_map
178
+
179
+ rows = []
180
+ by_name = {}
181
+ for acc in accounts:
182
+ is_pl = acc.get("report_type") == "Profit and Loss"
183
+ d, c = (pl_map if is_pl else bs_map).get(acc["name"], (0.0, 0.0))
184
+ if acc.get("root_type") in ("Liability", "Equity", "Income"):
185
+ natural = c - d
186
+ else:
187
+ natural = d - c
188
+ row = {
189
+ "name": acc["name"],
190
+ "account_name": acc.get("account_name"),
191
+ "parent_account": acc.get("parent_account") or None,
192
+ "root_type": acc.get("root_type"),
193
+ "account_type": acc.get("account_type"),
194
+ "is_group": bool(acc.get("is_group")),
195
+ "disabled": bool(acc.get("disabled")),
196
+ "balance": flt(natural, 2),
197
+ "has_entries": bool(d or c),
198
+ }
199
+ rows.append(row)
200
+ by_name[row["name"]] = row
201
+
202
+ # Roll every account's own balance up its ancestor chain so group rows
203
+ # show subtree totals.
204
+ subtree_extra = {r["name"]: 0.0 for r in rows}
205
+ for row in rows:
206
+ parent = row["parent_account"]
207
+ while parent and parent in by_name:
208
+ subtree_extra[parent] += row["balance"]
209
+ parent = by_name[parent]["parent_account"]
210
+ for row in rows:
211
+ if row["is_group"]:
212
+ row["balance"] = flt(row["balance"] + subtree_extra[row["name"]], 2)
213
+ row["has_entries"] = row["has_entries"] or bool(subtree_extra[row["name"]])
214
+
215
+ return {"accounts": rows, "from_date": from_date, "to_date": to_date}
216
+
217
+
218
+ @router.get("/chart-of-accounts")
219
+ def chart_of_accounts(
220
+ company: str | None = None,
221
+ from_date: str | None = None,
222
+ to_date: str | None = None,
223
+ presentation_currency: str | None = None,
224
+ ):
225
+ db = get_db()
226
+ return _present(db, _chart_of_accounts(db, company, from_date, to_date),
227
+ company, presentation_currency, to_date)
228
+
229
+
124
230
  def _general_ledger(db, filters=None):
125
231
  filters = filters or {}
126
232
  where = ["is_cancelled = 0"]
@@ -226,6 +226,11 @@ export const api = {
226
226
  `/reports/trial-balance${qs(params)}`,
227
227
  ),
228
228
 
229
+ chartOfAccounts: (params?: Record<string, string>) =>
230
+ request<{ accounts: any[]; from_date: string | null; to_date: string | null }>(
231
+ `/reports/chart-of-accounts${qs(params)}`,
232
+ ),
233
+
229
234
  generalLedger: (params?: Record<string, string>) =>
230
235
  request<{
231
236
  rows: any[];
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.44"
3
+ version = "0.1.46"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes