lambda-erp 0.6.0__tar.gz → 0.6.2__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.
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/PKG-INFO +1 -1
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/chat.py +39 -2
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/services.py +17 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/database.py +73 -6
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/pyproject.toml +1 -1
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/.gitignore +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/LICENSE +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/README.md +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/attachments.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/auth.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/bootstrap.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/demo_limits.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/deps.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/errors.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/main.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/oauth.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/pdf.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/providers.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/remarks_md.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/accounting.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/admin.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/analytics.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/chat_api.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/documents.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/masters.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/proposals.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/reports.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/routers/setup.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/templates/document.html +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/api/templates/proposal.html +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/docs/agents/README.md +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/frontend/LICENSE +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/frontend/README.md +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/model.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.6.0 → lambda_erp-0.6.2}/terraform/README.md +0 -0
|
@@ -463,7 +463,9 @@ TOOLS = [
|
|
|
463
463
|
"type": "object",
|
|
464
464
|
"properties": {
|
|
465
465
|
"doctype": {"type": "string", "enum": DOCUMENT_SLUGS, "description": "Document type slug"},
|
|
466
|
-
"filters": {"type": "object", "description": "Optional filters
|
|
466
|
+
"filters": {"type": "object", "description": "Optional equality filters on any column, e.g. {\"status\": \"Draft\", \"customer\": \"CUST-001\", \"lead_id\": \"LEAD-3316\"}", "default": {}},
|
|
467
|
+
"order_by": {"type": "string", "description": "Optional column to sort by (e.g. \"occurred_at\" for a timeline). Defaults to creation."},
|
|
468
|
+
"order": {"type": "string", "enum": ["asc", "desc"], "description": "Sort direction (default desc)", "default": "desc"},
|
|
467
469
|
"limit": {"type": "integer", "description": "Max results (default 20, max 500)", "default": 20},
|
|
468
470
|
},
|
|
469
471
|
"required": ["doctype"],
|
|
@@ -1175,6 +1177,38 @@ def _extra_master_types():
|
|
|
1175
1177
|
return sorted(m for m in services.MASTER_TABLES if m not in MASTER_TYPES)
|
|
1176
1178
|
|
|
1177
1179
|
|
|
1180
|
+
def _chat_doctype_section() -> str:
|
|
1181
|
+
"""A '## Custom record types' block for doctypes registered via
|
|
1182
|
+
register_chat_doctype: each type's description, key fields, and its
|
|
1183
|
+
LINK_FIELDS relationships (read live from the Document class). Tells the
|
|
1184
|
+
model these are driven with the DOCUMENT tools and how they attach to a
|
|
1185
|
+
parent (e.g. a contact links to a lead via `lead_id`). Empty when none."""
|
|
1186
|
+
slugs = [s for s in services.CHAT_DOCTYPES if s in services.SLUG_TO_DOCTYPE]
|
|
1187
|
+
if not slugs:
|
|
1188
|
+
return ""
|
|
1189
|
+
lines = []
|
|
1190
|
+
for slug in slugs:
|
|
1191
|
+
meta = services.CHAT_DOCTYPES[slug]
|
|
1192
|
+
doctype = services.SLUG_TO_DOCTYPE[slug]
|
|
1193
|
+
cls = services.DOCUMENT_CLASSES.get(doctype)
|
|
1194
|
+
links = getattr(cls, "LINK_FIELDS", None) or {}
|
|
1195
|
+
line = f"- **{doctype}** (slug `{slug}`): {meta['description']}"
|
|
1196
|
+
if meta.get("fields"):
|
|
1197
|
+
line += f" Key fields: {', '.join('`' + f + '`' for f in meta['fields'])}."
|
|
1198
|
+
if links:
|
|
1199
|
+
link_str = ", ".join(f"`{f}` → the {t}'s name" for f, t in links.items())
|
|
1200
|
+
line += f" Links: {link_str}."
|
|
1201
|
+
lines.append(line)
|
|
1202
|
+
return (
|
|
1203
|
+
"\n\n## Custom record types (deployment-specific)\n"
|
|
1204
|
+
"Manage these with the DOCUMENT tools (`create_document`, `update_document`, "
|
|
1205
|
+
"`list_documents`, `get_document`) — NOT the master tools — so their `validate()` "
|
|
1206
|
+
"runs. To attach one to a parent, set its link field to the parent record's `name` "
|
|
1207
|
+
"(find the parent first, e.g. with `search_masters` or `list_documents`). Do not put "
|
|
1208
|
+
"child data on the parent's own fields.\n" + "\n".join(lines)
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
|
|
1178
1212
|
def build_tools():
|
|
1179
1213
|
"""TOOLS with the doctype/master enums widened from the live registries.
|
|
1180
1214
|
|
|
@@ -1217,6 +1251,8 @@ def _handle_list_documents(args):
|
|
|
1217
1251
|
args["doctype"],
|
|
1218
1252
|
filters=args.get("filters"),
|
|
1219
1253
|
limit=args.get("limit", 20),
|
|
1254
|
+
order_by=args.get("order_by"),
|
|
1255
|
+
order=args.get("order", "desc"),
|
|
1220
1256
|
)
|
|
1221
1257
|
for row in rows:
|
|
1222
1258
|
for key in child_keys:
|
|
@@ -1898,6 +1934,7 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
|
1898
1934
|
f"display = `{services.MASTER_TABLES[m][1]}`"
|
|
1899
1935
|
for m in extra_masters
|
|
1900
1936
|
)
|
|
1937
|
+
chat_doctype_section = _chat_doctype_section()
|
|
1901
1938
|
|
|
1902
1939
|
# Channel-aware link guidance. On the "web" channel the reader is a browser
|
|
1903
1940
|
# inside the ERP, so web-relative links are clickable. On the "api" channel the
|
|
@@ -2006,7 +2043,7 @@ When a user asks you to do something they don't have permission for, explain wha
|
|
|
2006
2043
|
- **Buying:** purchase-order, purchase-invoice
|
|
2007
2044
|
- **Accounting:** payment-entry, journal-entry, budget, subscription, bank-transaction
|
|
2008
2045
|
- **Stock:** stock-entry, delivery-note, purchase-receipt
|
|
2009
|
-
- **Settings:** pricing-rule{extension_doctypes_line}
|
|
2046
|
+
- **Settings:** pricing-rule{extension_doctypes_line}{chat_doctype_section}
|
|
2010
2047
|
|
|
2011
2048
|
## Document Workflow & What Each Document Does
|
|
2012
2049
|
|
|
@@ -216,6 +216,23 @@ def register_master(slug: str, table: str, name_field: str, *,
|
|
|
216
216
|
MASTER_IDENTITY_ALIAS[slug] = identity_alias
|
|
217
217
|
|
|
218
218
|
|
|
219
|
+
# Chat guidance for registered doctypes: slug -> {"description", "fields"}. The
|
|
220
|
+
# document tools (create/update/list/get_document) already accept any registered
|
|
221
|
+
# doctype and run its validate(); this registry only teaches the AI chat WHAT a
|
|
222
|
+
# custom doctype is and HOW it links, so it uses the validated document path
|
|
223
|
+
# instead of, say, scribbling on a parent's fields. Relationships are read
|
|
224
|
+
# automatically from the Document class's LINK_FIELDS — not declared here.
|
|
225
|
+
CHAT_DOCTYPES: dict = {}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def register_chat_doctype(slug: str, *, description: str, fields: list | None = None) -> None:
|
|
229
|
+
"""Give the AI chat a description (and optional key-field hints) for a doctype
|
|
230
|
+
already registered via `register_doctype`, so build_system_prompt can tell the
|
|
231
|
+
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 []}
|
|
234
|
+
|
|
235
|
+
|
|
219
236
|
def get_document_class(doctype_slug: str):
|
|
220
237
|
"""Get document class from URL slug."""
|
|
221
238
|
doctype = SLUG_TO_DOCTYPE.get(doctype_slug)
|
|
@@ -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
|
|
1396
|
-
migrations registered via api.services.register_migration
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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."""
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|