lambda-erp 0.6.1__tar.gz → 0.6.3__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 (83) hide show
  1. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/PKG-INFO +1 -1
  2. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/chat.py +10 -0
  3. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/main.py +21 -0
  4. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/services.py +39 -3
  5. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/database.py +73 -6
  6. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/pyproject.toml +1 -1
  7. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/.gitignore +0 -0
  8. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/LICENSE +0 -0
  9. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/README.md +0 -0
  10. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/__init__.py +0 -0
  11. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/attachments.py +0 -0
  12. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/auth.py +0 -0
  13. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/bootstrap.py +0 -0
  14. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/demo_limits.py +0 -0
  15. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/deps.py +0 -0
  16. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/errors.py +0 -0
  17. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/oauth.py +0 -0
  18. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/pdf.py +0 -0
  19. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/providers.py +0 -0
  20. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/remarks_md.py +0 -0
  21. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/__init__.py +0 -0
  22. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/accounting.py +0 -0
  23. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/analytics.py +0 -0
  25. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/bank_reconciliation.py +0 -0
  26. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/chat_api.py +0 -0
  27. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/documents.py +0 -0
  28. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/masters.py +0 -0
  29. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/proposals.py +0 -0
  30. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/reports.py +0 -0
  31. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/routers/setup.py +0 -0
  32. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/templates/document.html +0 -0
  33. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/api/templates/proposal.html +0 -0
  34. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/docs/agents/README.md +0 -0
  35. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/frontend/LICENSE +0 -0
  36. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/frontend/README.md +0 -0
  37. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/frontend/src/api/client.ts +0 -0
  38. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/__init__.py +0 -0
  39. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/__init__.py +0 -0
  40. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/bank_transaction.py +0 -0
  41. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/budget.py +0 -0
  42. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  43. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/general_ledger.py +0 -0
  44. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/journal_entry.py +0 -0
  45. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/payment_entry.py +0 -0
  46. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/pos_invoice.py +0 -0
  47. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/purchase_invoice.py +0 -0
  48. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/revaluation.py +0 -0
  49. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/sales_invoice.py +0 -0
  50. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/__init__.py +0 -0
  51. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/engine.py +0 -0
  52. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/pack.py +0 -0
  53. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  54. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  55. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  56. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  57. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  58. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  59. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/profiles.py +0 -0
  60. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/setup/spine.py +0 -0
  61. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/accounting/subscription.py +0 -0
  62. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/buying/__init__.py +0 -0
  63. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/buying/purchase_order.py +0 -0
  64. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/controllers/__init__.py +0 -0
  65. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/controllers/currency.py +0 -0
  66. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/controllers/defaults.py +0 -0
  67. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/controllers/pricing_rule.py +0 -0
  68. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  69. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/exceptions.py +0 -0
  70. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/hooks.py +0 -0
  71. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/model.py +0 -0
  72. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/selling/__init__.py +0 -0
  73. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/selling/proposal.py +0 -0
  74. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/selling/quotation.py +0 -0
  75. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/selling/sales_order.py +0 -0
  76. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/simulation.py +0 -0
  77. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/stock/__init__.py +0 -0
  78. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/stock/delivery_note.py +0 -0
  79. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/stock/purchase_receipt.py +0 -0
  80. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/stock/stock_entry.py +0 -0
  81. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/stock/stock_ledger.py +0 -0
  82. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/lambda_erp/utils.py +0 -0
  83. {lambda_erp-0.6.1 → lambda_erp-0.6.3}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.6.1
3
+ Version: 0.6.3
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -1198,6 +1198,16 @@ def _chat_doctype_section() -> str:
1198
1198
  if links:
1199
1199
  link_str = ", ".join(f"`{f}` → the {t}'s name" for f, t in links.items())
1200
1200
  line += f" Links: {link_str}."
1201
+ # How to open a record of this type (so links resolve).
1202
+ page = services.chat_doctype_page_info(slug)
1203
+ if page and page["kind"] == "self":
1204
+ line += f" Open a record at `/app/{slug}/<name>`."
1205
+ elif page and page["kind"] == "via":
1206
+ line += (f" It has NO page of its own — open it via its parent: "
1207
+ f"`/app/{page['parent_slug']}/<{page['link_field']}>`; "
1208
+ f"never link `/app/{slug}/…`.")
1209
+ elif page and page["kind"] == "none":
1210
+ line += " It has no page — do not emit a link for it."
1201
1211
  lines.append(line)
1202
1212
  return (
1203
1213
  "\n\n## Custom record types (deployment-specific)\n"
@@ -121,6 +121,27 @@ def health():
121
121
  return {"status": "ok", "version": get_app_version()}
122
122
 
123
123
 
124
+ @app.get("/api/chat-doctypes")
125
+ def chat_doctypes():
126
+ """Public metadata for doctypes registered via register_chat_doctype: their
127
+ description and, for the frontend, how each is opened (`page`). The frontend
128
+ uses the `page` of kind "via" to auto-register a redirect from the doctype's
129
+ (chat-generated) `/app/{slug}/{name}` link to its parent's page. Not
130
+ sensitive — it's the same info the chat system prompt carries."""
131
+ from api import services
132
+ out = []
133
+ for slug, meta in services.CHAT_DOCTYPES.items():
134
+ if slug not in services.SLUG_TO_DOCTYPE:
135
+ continue
136
+ out.append({
137
+ "slug": slug,
138
+ "description": meta.get("description"),
139
+ "fields": meta.get("fields", []),
140
+ "page": services.chat_doctype_page_info(slug),
141
+ })
142
+ return {"doctypes": out}
143
+
144
+
124
145
  @app.websocket("/ws/chat")
125
146
  async def ws_chat(websocket: WebSocket):
126
147
  # Authenticate via cookie, fall back to public_manager for demo mode
@@ -225,12 +225,48 @@ def register_master(slug: str, table: str, name_field: str, *,
225
225
  CHAT_DOCTYPES: dict = {}
226
226
 
227
227
 
228
- def register_chat_doctype(slug: str, *, description: str, fields: list | None = None) -> None:
228
+ def register_chat_doctype(slug: str, *, description: str, fields: list | None = None,
229
+ page: str | None = "self") -> None:
229
230
  """Give the AI chat a description (and optional key-field hints) for a doctype
230
231
  already registered via `register_doctype`, so build_system_prompt can tell the
231
232
  model what it is and how it links (LINK_FIELDS are surfaced automatically).
232
- No new tools — the document tools already cover every registered doctype."""
233
- CHAT_DOCTYPES[slug] = {"description": description, "fields": fields or []}
233
+ No new tools — the document tools already cover every registered doctype.
234
+
235
+ `page` tells the chat how a record of this doctype is *opened*, so it emits
236
+ correct view links (and the frontend can auto-register redirects):
237
+ "self" (default) — its own page at `/app/{slug}/{name}`;
238
+ "<link_field>" — no page of its own; it's shown inside the parent that
239
+ `link_field` (a LINK_FIELD) references, so it opens at
240
+ `/app/{parent-slug}/{value of link_field}` (e.g. a
241
+ contact opens via its lead: page="lead_id");
242
+ None — no page and no view link at all.
243
+ """
244
+ CHAT_DOCTYPES[slug] = {"description": description, "fields": fields or [], "page": page}
245
+
246
+
247
+ def chat_doctype_page_info(slug: str) -> dict | None:
248
+ """Resolve a chat-doctype's `page` config into a link rule, for the system
249
+ prompt and the /api/chat-doctypes read. Returns one of:
250
+ {"kind": "self", "slug": slug}
251
+ {"kind": "via", "link_field": f, "parent_slug": p} # opens via a parent
252
+ {"kind": "none"}
253
+ or None if the slug isn't a registered chat-doctype."""
254
+ meta = CHAT_DOCTYPES.get(slug)
255
+ if not meta:
256
+ return None
257
+ page = meta.get("page", "self")
258
+ if page == "self":
259
+ return {"kind": "self", "slug": slug}
260
+ if page is None:
261
+ return {"kind": "none"}
262
+ # page names a LINK_FIELD -> resolve the parent doctype -> its slug.
263
+ doctype = SLUG_TO_DOCTYPE.get(slug)
264
+ cls = DOCUMENT_CLASSES.get(doctype) if doctype else None
265
+ links = getattr(cls, "LINK_FIELDS", None) or {}
266
+ parent_slug = DOCTYPE_TO_SLUG.get(links.get(page)) if links.get(page) else None
267
+ if not parent_slug:
268
+ return {"kind": "self", "slug": slug} # misconfigured -> safe fallback
269
+ return {"kind": "via", "link_field": page, "parent_slug": parent_slug}
234
270
 
235
271
 
236
272
  def get_document_class(doctype_slug: str):
@@ -1391,13 +1391,80 @@ class Database:
1391
1391
  self._col_cache.pop(table, None)
1392
1392
  self._text_col_cache.pop(table, None)
1393
1393
 
1394
+ def _alter_table_lock_safe(self, statements, cache_tables, *,
1395
+ lock_timeout_ms=3000, retries=8, backoff=0.5):
1396
+ """Run ALTER TABLE DDL with a bounded lock wait + retry on Postgres.
1397
+
1398
+ A schema change (ADD/DROP COLUMN) needs an ACCESS EXCLUSIVE lock; during
1399
+ a rolling deploy the OLD app revision is still live and querying the
1400
+ table, so an unbounded ALTER blocks *indefinitely* — and because these
1401
+ migrations run in the app lifespan before it serves, a blocked ALTER
1402
+ fails the container's startup probe and crash-loops the revision (this
1403
+ actually happened in production). Bounding `lock_timeout` turns "hang
1404
+ forever" into "fail fast", and retrying catches a gap between the old
1405
+ revision's queries. If every attempt times out it raises, so the caller
1406
+ (e.g. the plugin migration runner) can retry on the next boot rather
1407
+ than hang. SQLite has no concurrent revision, so it runs directly."""
1408
+ if self.dialect != "postgres":
1409
+ for s in statements:
1410
+ self.conn.execute(self._ddl(s))
1411
+ self.conn.commit()
1412
+ for t in cache_tables:
1413
+ self._col_cache.pop(t, None)
1414
+ self._text_col_cache.pop(t, None)
1415
+ return
1416
+
1417
+ import time as _time
1418
+ last = None
1419
+ try:
1420
+ for _ in range(retries):
1421
+ try:
1422
+ self.conn.execute(f"SET lock_timeout = '{int(lock_timeout_ms)}ms'")
1423
+ for s in statements:
1424
+ self.conn.execute(self._ddl(s))
1425
+ self.conn.commit()
1426
+ for t in cache_tables:
1427
+ self._col_cache.pop(t, None)
1428
+ self._text_col_cache.pop(t, None)
1429
+ return
1430
+ except Exception as e:
1431
+ last = e
1432
+ self.conn.rollback()
1433
+ _time.sleep(backoff)
1434
+ raise RuntimeError(
1435
+ f"lock-contended DDL failed after {retries} attempts: {statements!r}: {last!r}"
1436
+ )
1437
+ finally:
1438
+ # lock_timeout is a session setting; restore the default so it
1439
+ # doesn't leak onto later queries on this (pooled) connection.
1440
+ try:
1441
+ self.conn.execute("RESET lock_timeout")
1442
+ self.conn.commit()
1443
+ except Exception:
1444
+ self.conn.rollback()
1445
+
1394
1446
  def ensure_column(self, table: str, column: str, definition: str) -> None:
1395
- """Idempotently add a column if it's missing. Public helper for plugin
1396
- migrations registered via api.services.register_migration — the same
1397
- backend-portable, PRAGMA/information_schema-guarded add the core's own
1398
- migrations use. `definition` is the SQLite-flavoured column type (e.g.
1399
- "TEXT", "INTEGER DEFAULT 0"); it is translated per dialect via _ddl."""
1400
- self._add_column_if_missing(table, column, definition)
1447
+ """Idempotently add a column if it's missing, LOCK-SAFELY. Public helper
1448
+ for plugin migrations registered via api.services.register_migration.
1449
+
1450
+ On Postgres the ADD COLUMN is bounded by `lock_timeout` + retry so a
1451
+ migration can't hang the boot against a live old revision during a
1452
+ rolling deploy (see _alter_table_lock_safe). SQLite adds directly. The
1453
+ `definition` is the SQLite-flavoured column type (e.g. "TEXT",
1454
+ "INTEGER DEFAULT 0"); it is translated per dialect via _ddl."""
1455
+ if column in self._get_table_columns(table):
1456
+ return
1457
+ self._alter_table_lock_safe(
1458
+ [f'ALTER TABLE "{table}" ADD COLUMN {column} {definition}'], [table])
1459
+
1460
+ def drop_column(self, table: str, column: str) -> None:
1461
+ """Idempotently drop a column if present, LOCK-SAFELY (same rolling-deploy
1462
+ protection as ensure_column). No-op if the column is already gone.
1463
+ Requires SQLite >= 3.35 for the SQLite backend (DROP COLUMN support)."""
1464
+ if column not in self._get_table_columns(table):
1465
+ return
1466
+ self._alter_table_lock_safe(
1467
+ [f'ALTER TABLE "{table}" DROP COLUMN "{column}"'], [table])
1401
1468
 
1402
1469
  def _migrate(self):
1403
1470
  """Run each pending migration in order, tracking applied versions."""
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.6.1"
3
+ version = "0.6.3"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
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
File without changes
File without changes