lambda-erp 0.8.33__tar.gz → 1.0.0__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.8.33 → lambda_erp-1.0.0}/PKG-INFO +1 -1
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/chat.py +116 -28
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/errors.py +13 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/main.py +3 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/pdf.py +106 -40
- lambda_erp-1.0.0/api/pdf_contract.py +280 -0
- lambda_erp-1.0.0/api/pdf_exports.py +64 -0
- lambda_erp-1.0.0/api/pdf_profiles.py +132 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/availability.py +1 -1
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/chat_api.py +40 -45
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/documents.py +15 -3
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/masters.py +116 -2
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/mcp.py +7 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/services.py +81 -7
- lambda_erp-1.0.0/api/templates/_proposal_totals.html +11 -0
- lambda_erp-1.0.0/api/templates/_record_base.html +35 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/templates/document.html +4 -2
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/templates/proposal.html +5 -2
- lambda_erp-1.0.0/api/templates/record.html +1 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/tool_permissions.py +1 -1
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/frontend/src/api/client.ts +7 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_reconciliation.py +6 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_transaction.py +14 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/budget.py +19 -8
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/general_ledger.py +2 -1
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/journal_entry.py +47 -35
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/payment_entry.py +64 -25
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/pos_invoice.py +6 -23
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/purchase_invoice.py +11 -65
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/sales_invoice.py +11 -88
- lambda_erp-1.0.0/lambda_erp/accounting/settlement.py +51 -0
- lambda_erp-1.0.0/lambda_erp/accounting/subscription.py +187 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/assets/asset.py +9 -0
- lambda_erp-1.0.0/lambda_erp/assets/lifecycle.py +103 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/assets/reservation.py +42 -6
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/buying/purchase_order.py +9 -34
- lambda_erp-1.0.0/lambda_erp/controllers/item_prices.py +22 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/pricing_rule.py +11 -2
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/taxes_and_totals.py +5 -3
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/database.py +119 -4
- lambda_erp-1.0.0/lambda_erp/master_integrity.py +25 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/model.py +147 -105
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/proposal.py +28 -1
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/quotation.py +2 -1
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/sales_order.py +14 -41
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/delivery_note.py +10 -51
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/purchase_receipt.py +14 -60
- lambda_erp-1.0.0/lambda_erp/stock/stock_entry.py +231 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/stock_ledger.py +13 -5
- lambda_erp-1.0.0/lambda_erp/validation.py +153 -0
- lambda_erp-1.0.0/lambda_erp/workflow.py +224 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/pyproject.toml +1 -1
- lambda_erp-0.8.33/lambda_erp/accounting/subscription.py +0 -132
- lambda_erp-0.8.33/lambda_erp/stock/stock_entry.py +0 -330
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/.gitignore +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/LICENSE +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/README.md +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/attachments.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/auth.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/deps.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/list_values.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/oauth.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/providers.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/bank_statements.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/frontend/README.md +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.33 → lambda_erp-1.0.0}/terraform/README.md +0 -0
|
@@ -179,6 +179,17 @@ def save_chat_message(session_id: str, role: str, content: str, message_type: st
|
|
|
179
179
|
touch_session(session_id)
|
|
180
180
|
|
|
181
181
|
|
|
182
|
+
def _save_tool_trace(session_id: str, message_type: str, payload: dict):
|
|
183
|
+
"""Audit visible tool activity without masking a completed business write."""
|
|
184
|
+
try:
|
|
185
|
+
save_chat_message(session_id, "tool", json.dumps(payload, default=str), message_type=message_type)
|
|
186
|
+
except Exception:
|
|
187
|
+
# A session can be deleted while a tool is running. In particular, a
|
|
188
|
+
# failed result audit must not turn a successful write into a retry.
|
|
189
|
+
get_db().conn.rollback()
|
|
190
|
+
logger.warning("Could not persist %s trace for session %s", message_type, session_id)
|
|
191
|
+
|
|
192
|
+
|
|
182
193
|
def load_chat_history(session_id: str, limit: int = 50, before_id: int | None = None) -> list[dict]:
|
|
183
194
|
"""Load recent chat messages for a session.
|
|
184
195
|
|
|
@@ -189,13 +200,13 @@ def load_chat_history(session_id: str, limit: int = 50, before_id: int | None =
|
|
|
189
200
|
if before_id:
|
|
190
201
|
rows = db.sql(
|
|
191
202
|
'SELECT id, role, message_type, content, metadata_json, created_at '
|
|
192
|
-
'FROM "Chat Message" WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?',
|
|
203
|
+
'FROM "Chat Message" WHERE session_id = ? AND role IN (\'user\', \'assistant\') AND id < ? ORDER BY id DESC LIMIT ?',
|
|
193
204
|
[session_id, int(before_id), limit],
|
|
194
205
|
)
|
|
195
206
|
else:
|
|
196
207
|
rows = db.sql(
|
|
197
208
|
'SELECT id, role, message_type, content, metadata_json, created_at '
|
|
198
|
-
'FROM "Chat Message" WHERE session_id = ? ORDER BY id DESC LIMIT ?',
|
|
209
|
+
'FROM "Chat Message" WHERE session_id = ? AND role IN (\'user\', \'assistant\') ORDER BY id DESC LIMIT ?',
|
|
199
210
|
[session_id, limit],
|
|
200
211
|
)
|
|
201
212
|
rows.reverse()
|
|
@@ -275,7 +286,7 @@ def load_serialized_chat_history(
|
|
|
275
286
|
if oldest_id is not None:
|
|
276
287
|
db = get_db()
|
|
277
288
|
older = db.sql(
|
|
278
|
-
'SELECT 1 FROM "Chat Message" WHERE session_id = ? AND id < ? LIMIT 1',
|
|
289
|
+
'SELECT 1 FROM "Chat Message" WHERE session_id = ? AND role IN (\'user\', \'assistant\') AND id < ? LIMIT 1',
|
|
279
290
|
[session_id, int(oldest_id)],
|
|
280
291
|
)
|
|
281
292
|
has_more = bool(older)
|
|
@@ -483,7 +494,7 @@ TOOLS = [
|
|
|
483
494
|
"type": "function",
|
|
484
495
|
"function": {
|
|
485
496
|
"name": "get_document_fields",
|
|
486
|
-
"description": "List the available columns of a document type and identify which are text fields. Call this before building list_documents filters when you are unsure of a field name or whether it supports the case-insensitive contains operator. Also returns
|
|
497
|
+
"description": "List the available columns of a document type and identify which are text fields. Call this before building list_documents filters when you are unsure of a field name or whether it supports the case-insensitive contains operator. Also returns required fields, conditional business rules, child requirements, dynamic links and supported transient inputs. Check these before creating or updating a document; unknown fields are rejected.",
|
|
487
498
|
"parameters": {
|
|
488
499
|
"type": "object",
|
|
489
500
|
"properties": {
|
|
@@ -493,6 +504,16 @@ TOOLS = [
|
|
|
493
504
|
},
|
|
494
505
|
},
|
|
495
506
|
},
|
|
507
|
+
{
|
|
508
|
+
"type": "function",
|
|
509
|
+
"function": {
|
|
510
|
+
"name": "generate_document_pdf",
|
|
511
|
+
"description": "Generate and validate the actual PDF before offering or sending it. Returns a downloadable file snapshot only after successful rendering and content checks. Read get_document_fields.pdf for support and output requirements. Relay errors; never invent a PDF URL or claim a file is attached without this tool succeeding.",
|
|
512
|
+
"parameters": {"type": "object", "properties": {
|
|
513
|
+
"doctype": {"type": "string", "enum": DOCUMENT_SLUGS},
|
|
514
|
+
"name": {"type": "string"}}, "required": ["doctype", "name"]},
|
|
515
|
+
},
|
|
516
|
+
},
|
|
496
517
|
{
|
|
497
518
|
"type": "function",
|
|
498
519
|
"function": {
|
|
@@ -513,7 +534,7 @@ TOOLS = [
|
|
|
513
534
|
"function": {
|
|
514
535
|
"name": "create_document",
|
|
515
536
|
"description": (
|
|
516
|
-
"Create a new draft document (docstatus=0). The document is saved but NOT submitted. IMPORTANT: you MUST pass the 'data' object with ALL document fields — doctype alone is not enough. Example: {\"doctype\": \"purchase-order\", \"data\": {\"supplier\": \"SUPP-001\", \"company\": \"My Co\", \"transaction_date\": \"2026-04-14\", \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 10, \"rate\": 100}]}}\n"
|
|
537
|
+
"Create a new draft document (docstatus=0). The document is saved but NOT submitted. Use get_document_fields for required/conditional fields. Never omit an ambiguous business reference to make a call succeed: ask a targeted question. Relay _validation.warnings from saved documents. IMPORTANT: you MUST pass the 'data' object with ALL document fields — doctype alone is not enough. Example: {\"doctype\": \"purchase-order\", \"data\": {\"supplier\": \"SUPP-001\", \"company\": \"My Co\", \"transaction_date\": \"2026-04-14\", \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 10, \"rate\": 100}]}}\n"
|
|
517
538
|
"Currency: `currency` is optional — it defaults to the customer/supplier's currency, else the company's base currency. To bill in a foreign currency, also pass `conversion_rate` = how many units of the company's base currency equal 1 unit of the document currency (no automatic FX lookup yet, so you MUST supply it for a foreign currency). Item rates stay in the document currency. Example (base USD, invoicing in EUR at 1 EUR = 1.10 USD): {\"doctype\": \"sales-invoice\", \"data\": {\"customer\": \"CUST-001\", \"company\": \"My Co\", \"posting_date\": \"2026-05-22\", \"currency\": \"EUR\", \"conversion_rate\": 1.10, \"items\": [{\"item_code\": \"ITEM-001\", \"qty\": 1, \"rate\": 100}]}}"
|
|
518
539
|
),
|
|
519
540
|
"parameters": {
|
|
@@ -1468,21 +1489,17 @@ def _handle_list_documents(args):
|
|
|
1468
1489
|
return rows
|
|
1469
1490
|
|
|
1470
1491
|
|
|
1492
|
+
def _handle_generate_document_pdf(args, user=None):
|
|
1493
|
+
from api.pdf_exports import create_pdf_export
|
|
1494
|
+
from api.pdf_contract import PDFError
|
|
1495
|
+
try:
|
|
1496
|
+
return create_pdf_export(args.get('doctype'), args.get('name'), user)
|
|
1497
|
+
except PDFError as exc:
|
|
1498
|
+
return {'error': str(exc), 'code': exc.code, 'fields': exc.fields}
|
|
1499
|
+
|
|
1500
|
+
|
|
1471
1501
|
def _handle_get_document_fields(args):
|
|
1472
|
-
|
|
1473
|
-
table = services.SLUG_TO_DOCTYPE.get(doctype)
|
|
1474
|
-
if not table:
|
|
1475
|
-
return {"error": f"Unknown document type: {doctype}"}
|
|
1476
|
-
db = get_db()
|
|
1477
|
-
cls = services.DOCUMENT_CLASSES.get(table)
|
|
1478
|
-
return {
|
|
1479
|
-
"doctype": doctype,
|
|
1480
|
-
"fields": sorted(db._get_table_columns(table)),
|
|
1481
|
-
"text_fields": sorted(db._get_text_columns(table)),
|
|
1482
|
-
"default_search_fields": services.document_search_columns(db, doctype),
|
|
1483
|
-
"child_tables": sorted((cls.CHILD_TABLES or {}).keys()) if cls else [],
|
|
1484
|
-
"link_fields": dict(getattr(cls, "LINK_FIELDS", None) or {}) if cls else {},
|
|
1485
|
-
}
|
|
1502
|
+
return services.document_field_metadata(args["doctype"])
|
|
1486
1503
|
|
|
1487
1504
|
|
|
1488
1505
|
def _handle_get_document(args):
|
|
@@ -1569,6 +1586,7 @@ def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
|
|
|
1569
1586
|
|
|
1570
1587
|
|
|
1571
1588
|
def _handle_get_master_fields(args):
|
|
1589
|
+
from api.routers.masters import master_requirements, MASTER_LINK_FIELDS
|
|
1572
1590
|
db = get_db()
|
|
1573
1591
|
master_type = args["master_type"]
|
|
1574
1592
|
entry = services.MASTER_TABLES.get(master_type)
|
|
@@ -1577,6 +1595,7 @@ def _handle_get_master_fields(args):
|
|
|
1577
1595
|
doctype, _ = entry
|
|
1578
1596
|
default_search = _master_search_columns(db, doctype)
|
|
1579
1597
|
bulk = services.master_bulk_text_columns(db, doctype)
|
|
1598
|
+
cls = services.DOCUMENT_CLASSES.get(doctype)
|
|
1580
1599
|
return {
|
|
1581
1600
|
"master_type": master_type,
|
|
1582
1601
|
"fields": sorted(db._get_table_columns(doctype)),
|
|
@@ -1585,6 +1604,10 @@ def _handle_get_master_fields(args):
|
|
|
1585
1604
|
"default_search_fields": default_search,
|
|
1586
1605
|
# Large text fields searched ONLY when named in search_masters `fields`.
|
|
1587
1606
|
"bulk_text_fields": bulk,
|
|
1607
|
+
"requirements": master_requirements(master_type),
|
|
1608
|
+
"link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
|
|
1609
|
+
"dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
|
|
1610
|
+
"input_fields": sorted(set(cls.INPUT_FIELDS if cls else ()) | ({services.MASTER_IDENTITY_ALIAS[master_type]} if master_type in services.MASTER_IDENTITY_ALIAS else set())),
|
|
1588
1611
|
}
|
|
1589
1612
|
|
|
1590
1613
|
|
|
@@ -2236,6 +2259,7 @@ def _handle_apply_company_setup(args):
|
|
|
2236
2259
|
TOOL_HANDLERS = {
|
|
2237
2260
|
"list_documents": _handle_list_documents,
|
|
2238
2261
|
"get_document_fields": _handle_get_document_fields,
|
|
2262
|
+
"generate_document_pdf": _handle_generate_document_pdf,
|
|
2239
2263
|
"get_document": _handle_get_document,
|
|
2240
2264
|
"create_document": _handle_create_document,
|
|
2241
2265
|
"update_document": _handle_update_document,
|
|
@@ -2356,12 +2380,30 @@ def _prompt_uom_context() -> str:
|
|
|
2356
2380
|
)
|
|
2357
2381
|
|
|
2358
2382
|
|
|
2383
|
+
def _prompt_validation_context():
|
|
2384
|
+
from lambda_erp.validation import document_requirements
|
|
2385
|
+
from api.routers.masters import master_requirements
|
|
2386
|
+
groups = {}
|
|
2387
|
+
for doctype, cls in services.DOCUMENT_CLASSES.items():
|
|
2388
|
+
rules = document_requirements(cls)
|
|
2389
|
+
if not any(rules.values()):
|
|
2390
|
+
continue
|
|
2391
|
+
key = json.dumps(rules, sort_keys=True)
|
|
2392
|
+
groups.setdefault(key, []).append(doctype)
|
|
2393
|
+
for slug, (doctype, _) in services.MASTER_TABLES.items():
|
|
2394
|
+
if doctype not in services.DOCUMENT_CLASSES:
|
|
2395
|
+
key = json.dumps(master_requirements(slug), sort_keys=True)
|
|
2396
|
+
groups.setdefault(key, []).append(doctype + ' (master)')
|
|
2397
|
+
return "\n".join(f"- {', '.join(names)}: {rules}" for rules, names in groups.items())
|
|
2398
|
+
|
|
2399
|
+
|
|
2359
2400
|
def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
2360
2401
|
user_name = user_info.get("full_name", "User") if user_info else "User"
|
|
2361
2402
|
user_role = user_info.get("role", "viewer") if user_info else "viewer"
|
|
2362
2403
|
company_context = _prompt_company_context()
|
|
2363
2404
|
analytics_context = _prompt_analytics_context()
|
|
2364
2405
|
uom_context = _prompt_uom_context()
|
|
2406
|
+
validation_context = _prompt_validation_context()
|
|
2365
2407
|
|
|
2366
2408
|
# Deployment-registered types (register_doctype / register_master). Folded
|
|
2367
2409
|
# into the type lists below so the assistant knows they exist and can act
|
|
@@ -2387,6 +2429,11 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
|
2387
2429
|
+ ", ".join(f"`{field}`" for field in meta["fields"])
|
|
2388
2430
|
)
|
|
2389
2431
|
chat_doctype_section = _chat_doctype_section()
|
|
2432
|
+
from api.pdf_profiles import pdf_metadata
|
|
2433
|
+
chat_doctype_section += "\n## PDF output contracts\n" + "\n".join(
|
|
2434
|
+
f"- {services.DOCTYPE_TO_SLUG[dt]}: {json.dumps(pdf_metadata(dt), ensure_ascii=False)}"
|
|
2435
|
+
for dt in services.DOCUMENT_CLASSES
|
|
2436
|
+
)
|
|
2390
2437
|
|
|
2391
2438
|
# Channel-aware link guidance. On the "web" channel the reader is a browser
|
|
2392
2439
|
# inside the ERP, so web-relative links are clickable. On the "api" channel the
|
|
@@ -2400,7 +2447,7 @@ def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
|
2400
2447
|
markdown_links_section = """## Referring to records
|
|
2401
2448
|
Your reply is relayed to an external application (the user's Lambda assistant / app), NOT the ERP web interface. Links into the ERP web UI are NOT clickable there, so refer to every record by its human identifier in plain text — e.g. "Quotation **QTN-2298**", "Sales Invoice **SINV-0012**". Do not paste `/app/...`, `/masters/...`, or `/reports/...` URLs; they do not work outside the ERP."""
|
|
2402
2449
|
document_links_section = """## Delivering document PDFs
|
|
2403
|
-
|
|
2450
|
+
Call generate_document_pdf for every requested file. Only a successful result means a file is generated and available for attachment. The platform attaches that exact validated file. Do not invent PDF URLs or claim generation after an error. Name the document in prose; no /app or /masters view links on this channel."""
|
|
2404
2451
|
else:
|
|
2405
2452
|
markdown_links_section = """## Always use markdown links
|
|
2406
2453
|
Every URL you mention in chat MUST be written as a markdown link `[label](url)` — never a bare URL on its own. The chat UI only turns `[label](url)` into a proper clickable link. A bare `/reports/analytics?report_id=...` still works (a fallback linkifier catches it), but markdown form is the expected shape.
|
|
@@ -2414,14 +2461,14 @@ When referencing records, always use clickable markdown links so the user can op
|
|
|
2414
2461
|
|
|
2415
2462
|
**Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
|
|
2416
2463
|
- **View/edit link:** `/app/{doctype-slug}/{name}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
|
|
2417
|
-
- **PDF
|
|
2464
|
+
- **PDF files:** call generate_document_pdf. Only link the returned download_url after successful generation; never construct PDF links yourself.
|
|
2418
2465
|
The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
|
|
2419
2466
|
|
|
2420
2467
|
**Master records** (customer, supplier, item, warehouse, company):
|
|
2421
2468
|
- **View/edit link:** `/masters/{master-type}/{name}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
|
|
2422
2469
|
- NEVER use `/app/...` for masters — that path is only for transactional documents.
|
|
2423
2470
|
|
|
2424
|
-
Always include the view link after creating, submitting, converting, or updating a record.
|
|
2471
|
+
Always include the view link after creating, submitting, converting, or updating a record. Call generate_document_pdf when a printable file is requested. Only confirm and link a successfully generated file."""
|
|
2425
2472
|
|
|
2426
2473
|
if user_role == "admin":
|
|
2427
2474
|
role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
|
|
@@ -2453,11 +2500,16 @@ If `jurisdiction.is_fallback` is true, tell the user their country isn't localiz
|
|
|
2453
2500
|
|
|
2454
2501
|
You help users manage their business by creating documents, looking up data, and running reports — all through natural conversation.
|
|
2455
2502
|
|
|
2503
|
+
## Enforced document requirements (generated from backend rule metadata)
|
|
2504
|
+
{validation_context}
|
|
2505
|
+
Use get_document_fields for full field and relationship metadata. On updates these rules apply to the merged document, so already stored values need not be resubmitted. Never remove an ambiguous link to bypass a validation error.
|
|
2506
|
+
|
|
2456
2507
|
## Never fabricate actions or results
|
|
2457
|
-
Only state that something was done — created, changed, enabled/disabled, booked, repointed — **after a tool call returns a success result that confirms it**, and check the returned record actually reflects the change (after `update_master` with {{"disabled": 0}}, confirm the returned row shows `disabled = 0`; after submitting a journal entry, confirm it posted non-zero GL).
|
|
2508
|
+
Only state that something was done — created, changed, enabled/disabled, booked, repointed — **after a tool call returns a success result that confirms it**, and check the returned record actually reflects the change (after `update_master` with {{"disabled": 0}}, confirm the returned row shows `disabled = 0`; after submitting a journal entry, confirm it posted non-zero GL). An error means the action failed. For a successful result with warnings, explain both what was saved and what remains incomplete (for example, pool capacity reserved but no machine assigned). If the record doesn't reflect your intent, explain the mismatch — never smooth it over as success.
|
|
2458
2509
|
- If a capability doesn't exist, say so plainly. There is **no rename** for accounts or other masters — a master's identifying `name`/number is immutable; you can only edit display fields or create a new record and migrate. Never claim you "renamed" an account.
|
|
2459
2510
|
- Never assert a field or capability is missing without checking (e.g. an item's income account) — verify with `get_master_fields` / `search_masters` first.
|
|
2460
2511
|
- A master you can't find via `search_masters` may just be **disabled** (search hides disabled by default). Retry with `include_disabled: true` before concluding it doesn't exist — you need this to re-enable a disabled account.
|
|
2512
|
+
- Respect document requirements from get_document_fields. Missing required data or ambiguous matches require a targeted question, not guessed IDs, omitted links, or zero quantities. A tool error means the action failed; _validation.warnings must be explained.
|
|
2461
2513
|
- If you can't confirm an action took effect, re-fetch and look, or tell the user you couldn't confirm it — never guess.
|
|
2462
2514
|
|
|
2463
2515
|
## Answering data questions — three paths
|
|
@@ -2556,7 +2608,7 @@ Shape — note it does NOT use `items`:
|
|
|
2556
2608
|
- Parent fields: `title` (e.g. "Offerte"), `customer`, `company`, `proposal_date`, `partner_name`, `partner_email`, `cover_letter` (the intro/greeting letter text).
|
|
2557
2609
|
- Child table `quotations[]` — one row per offer to include, in display order. Each row has: `quotation` (the name of an existing Quotation), optional `position_title` and `position_blurb` (default from the quotation), and `is_recommended` (0 or 1 — set 1 on the offer you recommend, which draws an "Empfehlung" badge).
|
|
2558
2610
|
|
|
2559
|
-
To build one: ensure each offer already exists as its own Quotation (create them first if needed), then call create_document with doctype "proposal" and a data object whose `quotations` array references those quotations by name. Do NOT submit it;
|
|
2611
|
+
To build one: ensure each offer already exists as its own Quotation (create them first if needed), then call create_document with doctype "proposal" and a data object whose `quotations` array references those quotations by name. Do NOT submit it; call generate_document_pdf with doctype proposal and link the returned download_url (and the editor at `/app/proposal/<name>`).
|
|
2560
2612
|
|
|
2561
2613
|
### Recurring offer lines (quotation line `frequency`)
|
|
2562
2614
|
A **Quotation** line item carries an optional `frequency` that controls how it is billed and totalled on the offer:
|
|
@@ -2643,9 +2695,11 @@ Use these only for items flagged `is_asset_tracked` on the Item master. Neither
|
|
|
2643
2695
|
- **Item vs Asset:** the Item is the *type* ("17t Excavator") and carries the rate and pricing rules. An **Asset** (slug `asset`) is ONE physical unit of it. Three identical machines = one Item + three Assets. Create with `item_code` (required) plus `asset_tag` (plate / serial — optional but must be unique), `warehouse` (its home yard), and optionally `purchase_date`, `purchase_value`, `meter_reading`. `status` is Available / On Hire / Maintenance / Retired — the unit's state *today*, not a date range. Retired units leave the pool.
|
|
2644
2696
|
- **Opting in:** creating an Asset for an Item that isn't asset-tracked is refused. Fix it by setting `is_asset_tracked` = 1 on the Item via update_master, then retry. Never suggest the user edit the database.
|
|
2645
2697
|
- **Reservation** (slug `reservation`) books a time window. Required: `from_datetime` and `to_datetime` (`YYYY-MM-DD` or `YYYY-MM-DD HH:MM:SS`; a bare date means midnight). Windows are **half-open** — a hire ending on the 19th and the next starting on the 19th do NOT clash. Two ways to book:
|
|
2646
|
-
- **pooled** — set `item_code`, `warehouse` and `qty`: "*a* machine of this type from that yard". Use this at quotation/order time when the specific unit doesn't matter yet.
|
|
2698
|
+
- **pooled** — explicitly set `allocation_mode="Pool"`; set `item_code`, `warehouse` and `qty`: "*a* machine of this type from that yard". Use this at quotation/order time when the specific unit doesn't matter yet.
|
|
2647
2699
|
- **unit** — set `asset`: "*that* machine". `item_code`, `warehouse` and `qty` are filled in from the Asset automatically.
|
|
2648
|
-
- **
|
|
2700
|
+
- **Choosing a machine:** resolve the Item, then list active, non-discarded Assets and the relevant reservations. If multiple usable units match and the customer has not specified a yard/unit, ask which yard/plate is meant; do not guess and do not silently omit asset. Only create a Pool booking when the user intentionally wants unassigned capacity. A uniquely identified available unit can be booked by asset ID. Asset and reservation warehouse must match; record a transfer first if needed.
|
|
2701
|
+
- **Calendar:** Pool bookings appear on separate unassigned rows, not on a specific machine. Always tell the user when no machine is assigned. On a report of a missing booking, get_document and inspect asset, status, discarded and warehouse; do not claim a unit is booked merely because a Reservation exists. Out requires an asset.
|
|
2702
|
+
- **Who it's for (do NOT skip this):** set `party` to the hiring customer's id (e.g. `CUST-005`) with `party_type = "Customer"`. The customer is almost always given by NAME — "reserve the 17t for Hans Meisterhans" — so FIRST resolve the name to a record with `search_masters` (master_type "customer") and set `party` to the matched id; never put the raw name in `party`. No match → create the customer (or ask which existing one is meant), then book. A reservation with no `party` is only for an internal block (maintenance, transport, transfer) — if the user named a customer, a booking without `party` set is a bug, not an option. Internal blocks without party require an explicit purpose.
|
|
2649
2703
|
- `status` is Reserved / Out / Returned / Cancelled. **Reserved and Out block the calendar; Returned and Cancelled do not** — so a hire that comes back early frees its slot the moment you set it to Returned. Link a booking to the order that caused it with `voucher_type` + `voucher_no`.
|
|
2650
2704
|
- **Double-booking is refused at save time.** If you get "already committed" (that unit is taken) or "free at" (the yard's pool is exhausted for that window), do NOT retry blindly — report the conflict, then offer the alternatives: different dates, a different yard, or a different model.
|
|
2651
2705
|
- To answer "what's free between X and Y", list reservations for the item over the window and compare against the Assets that exist; a unit with no overlapping Reserved/Out row is available.
|
|
@@ -3439,6 +3493,7 @@ async def run_thinking_loop(
|
|
|
3439
3493
|
# immediately affects resource-aware handlers as well as the shared policy.
|
|
3440
3494
|
principal_ref = [user_info]
|
|
3441
3495
|
tool_handlers = dict(TOOL_HANDLERS)
|
|
3496
|
+
tool_handlers["generate_document_pdf"] = lambda args: _handle_generate_document_pdf(args, principal_ref[0])
|
|
3442
3497
|
# delete_master needs the caller's role (admin-only) — scoped here rather
|
|
3443
3498
|
# than in TOOL_HANDLERS, whose handlers are called with (args) only.
|
|
3444
3499
|
tool_handlers["delete_master"] = lambda args: _handle_delete_master(args, principal_ref[0])
|
|
@@ -3621,6 +3676,8 @@ async def run_thinking_loop(
|
|
|
3621
3676
|
tool_call_id = tc.id
|
|
3622
3677
|
|
|
3623
3678
|
await on_event({"type": "tool_call", "tool": fn_name, "args": fn_args})
|
|
3679
|
+
if session_id:
|
|
3680
|
+
_save_tool_trace(session_id, "tool_call", {"tool": fn_name, "args": fn_args})
|
|
3624
3681
|
|
|
3625
3682
|
# LLM turns can run for many seconds. Re-resolve the principal at
|
|
3626
3683
|
# the actual execution boundary so a demotion, disable, or key
|
|
@@ -3656,7 +3713,7 @@ async def run_thinking_loop(
|
|
|
3656
3713
|
else:
|
|
3657
3714
|
try:
|
|
3658
3715
|
result = await asyncio.to_thread(handler, fn_args)
|
|
3659
|
-
success =
|
|
3716
|
+
success = not (isinstance(result, dict) and ("error" in result or result.get("ok") is False or result.get("failed", 0)))
|
|
3660
3717
|
except Exception as e:
|
|
3661
3718
|
result = {"error": str(e)}
|
|
3662
3719
|
success = False
|
|
@@ -3677,6 +3734,10 @@ async def run_thinking_loop(
|
|
|
3677
3734
|
"success": success,
|
|
3678
3735
|
"summary": summary,
|
|
3679
3736
|
}
|
|
3737
|
+
if isinstance(result, dict):
|
|
3738
|
+
event_payload["warnings"] = (result.get("_validation") or {}).get("warnings", [])
|
|
3739
|
+
if not success:
|
|
3740
|
+
event_payload["error"] = result.get("error") or "One or more operations failed; inspect the tool result."
|
|
3680
3741
|
# Surface the report id so the sidebar can flash the specific draft.
|
|
3681
3742
|
if (
|
|
3682
3743
|
success
|
|
@@ -3685,6 +3746,12 @@ async def run_thinking_loop(
|
|
|
3685
3746
|
and result.get("id")
|
|
3686
3747
|
):
|
|
3687
3748
|
event_payload["report_id"] = result["id"]
|
|
3749
|
+
if fn_name == "generate_document_pdf":
|
|
3750
|
+
event_payload["document_request"] = {key: fn_args.get(key) for key in ("doctype", "name")}
|
|
3751
|
+
if success and fn_name == "generate_document_pdf" and isinstance(result, dict) and result.get("artifact_id"):
|
|
3752
|
+
event_payload["document"] = result
|
|
3753
|
+
if session_id:
|
|
3754
|
+
_save_tool_trace(session_id, "tool_result", {**event_payload, "result": result})
|
|
3688
3755
|
await on_event(event_payload)
|
|
3689
3756
|
|
|
3690
3757
|
messages.append({
|
|
@@ -3875,8 +3942,15 @@ async def run_session_turn(
|
|
|
3875
3942
|
|
|
3876
3943
|
messages.extend(conversation)
|
|
3877
3944
|
|
|
3945
|
+
generated_files = []
|
|
3946
|
+
|
|
3947
|
+
async def pdf_events(event):
|
|
3948
|
+
if event.get('type') == 'tool_result' and event.get('tool') == 'generate_document_pdf' and event.get('success') and event.get('document'):
|
|
3949
|
+
generated_files.append(event['document'])
|
|
3950
|
+
await on_event(event)
|
|
3951
|
+
|
|
3878
3952
|
await run_thinking_loop(
|
|
3879
|
-
messages,
|
|
3953
|
+
messages, pdf_events,
|
|
3880
3954
|
session_id=session_id,
|
|
3881
3955
|
user_info=user_info,
|
|
3882
3956
|
client_ip=client_ip,
|
|
@@ -3886,6 +3960,20 @@ async def run_session_turn(
|
|
|
3886
3960
|
for msg in reversed(messages):
|
|
3887
3961
|
if msg.get("role") == "assistant" and msg.get("content"):
|
|
3888
3962
|
assistant_content = msg["content"]
|
|
3963
|
+
allowed_urls = {d[key] for d in generated_files for key in ('download_url', 'pdf_url')}
|
|
3964
|
+
pdf_pattern = r"/api/(?:v1/)?documents/[^\s`<>)]*/pdf(?:\?[^\s`<>)]*)?"
|
|
3965
|
+
ungenerated = []
|
|
3966
|
+
def verified_link(match):
|
|
3967
|
+
if match.group(0) in allowed_urls:
|
|
3968
|
+
return match.group(0)
|
|
3969
|
+
ungenerated.append(match.group(0))
|
|
3970
|
+
return '[PDF not generated]'
|
|
3971
|
+
assistant_content = re.sub(pdf_pattern, verified_link, assistant_content)
|
|
3972
|
+
if ungenerated:
|
|
3973
|
+
event = {'type': 'tool_result', 'tool': 'generate_document_pdf', 'success': False,
|
|
3974
|
+
'error': 'The reply referenced a PDF that was not generated. Generate the file before offering it.', 'warnings': [], 'document_request': {'doctype': None, 'name': None}}
|
|
3975
|
+
await on_event(event)
|
|
3976
|
+
_save_tool_trace(session_id, 'tool_result', event)
|
|
3889
3977
|
save_chat_message(session_id, "assistant", assistant_content)
|
|
3890
3978
|
if on_assistant_message:
|
|
3891
3979
|
await on_assistant_message(session_id, assistant_content)
|
|
@@ -63,10 +63,23 @@ def _friendly_integrity(msg: str) -> str:
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
async def _integrity_handler(request: Request, exc: Exception):
|
|
66
|
+
msg = str(exc)
|
|
67
|
+
code = getattr(exc, 'sqlstate', None)
|
|
68
|
+
if code == '23502' or 'NOT NULL constraint failed:' in msg:
|
|
69
|
+
column = getattr(getattr(exc, 'diag', None), 'column_name', None)
|
|
70
|
+
if not column and 'NOT NULL constraint failed:' in msg:
|
|
71
|
+
column = msg.split('NOT NULL constraint failed:', 1)[1].strip()
|
|
72
|
+
return JSONResponse(status_code=422, content={'detail': f'{column or "A required field"} is required'})
|
|
73
|
+
if code == '23503' or 'foreign key constraint failed' in msg.lower():
|
|
74
|
+
return JSONResponse(status_code=422, content={'detail': "This links to a record that does not exist or is still referenced."})
|
|
66
75
|
return JSONResponse(status_code=409, content={"detail": _friendly_integrity(str(exc))})
|
|
67
76
|
|
|
68
77
|
|
|
69
78
|
def register_exception_handlers(app: FastAPI):
|
|
79
|
+
from api.pdf_contract import PDFError
|
|
80
|
+
async def pdf_error_handler(request, exc):
|
|
81
|
+
return JSONResponse(status_code=422, content={"detail": {"code": exc.code, "message": str(exc), "fields": exc.fields}})
|
|
82
|
+
app.add_exception_handler(PDFError, pdf_error_handler)
|
|
70
83
|
|
|
71
84
|
@app.exception_handler(ValueError)
|
|
72
85
|
async def value_error(request: Request, exc: ValueError):
|
|
@@ -61,6 +61,9 @@ async def lifespan(app: FastAPI):
|
|
|
61
61
|
from api.services import apply_plugin_schema
|
|
62
62
|
apply_plugin_schema()
|
|
63
63
|
|
|
64
|
+
from api.pdf_profiles import validate_pdf_registry
|
|
65
|
+
validate_pdf_registry()
|
|
66
|
+
|
|
64
67
|
# Ensure the demo spend log table exists before any LLM call happens.
|
|
65
68
|
from api.demo_limits import init_schema as init_demo_spend_schema
|
|
66
69
|
init_demo_spend_schema()
|