lambda-erp 0.8.32__tar.gz → 0.8.34__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.32 → lambda_erp-0.8.34}/PKG-INFO +1 -1
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/chat.py +65 -24
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/errors.py +9 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/pdf.py +3 -6
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/availability.py +1 -1
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/chat_api.py +8 -6
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/documents.py +6 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/masters.py +116 -2
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/services.py +71 -6
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_reconciliation.py +6 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_transaction.py +14 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/budget.py +19 -8
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/general_ledger.py +2 -1
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/journal_entry.py +47 -35
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/payment_entry.py +64 -25
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/pos_invoice.py +6 -23
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/purchase_invoice.py +11 -65
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/sales_invoice.py +11 -88
- lambda_erp-0.8.34/lambda_erp/accounting/settlement.py +51 -0
- lambda_erp-0.8.34/lambda_erp/accounting/subscription.py +187 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/assets/asset.py +9 -0
- lambda_erp-0.8.34/lambda_erp/assets/lifecycle.py +103 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/assets/reservation.py +42 -6
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/buying/purchase_order.py +9 -34
- lambda_erp-0.8.34/lambda_erp/controllers/item_prices.py +22 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/pricing_rule.py +11 -2
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/taxes_and_totals.py +5 -3
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/database.py +106 -4
- lambda_erp-0.8.34/lambda_erp/master_integrity.py +25 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/model.py +147 -105
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/proposal.py +28 -1
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/quotation.py +2 -1
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/sales_order.py +14 -41
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/delivery_note.py +10 -51
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/purchase_receipt.py +14 -60
- lambda_erp-0.8.34/lambda_erp/stock/stock_entry.py +231 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/stock_ledger.py +13 -5
- lambda_erp-0.8.34/lambda_erp/validation.py +153 -0
- lambda_erp-0.8.34/lambda_erp/workflow.py +224 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/pyproject.toml +1 -1
- lambda_erp-0.8.32/lambda_erp/accounting/subscription.py +0 -132
- lambda_erp-0.8.32/lambda_erp/stock/stock_entry.py +0 -330
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/.gitignore +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/LICENSE +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/README.md +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/attachments.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/auth.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/bootstrap.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/demo_limits.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/deps.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/list_values.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/main.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/oauth.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/providers.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/remarks_md.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/accounting.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/actions.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/admin.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/analytics.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/bank_statements.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/mcp.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/proposals.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/reports.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/routers/setup.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/templates/document.html +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/templates/proposal.html +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/api/tool_permissions.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/docs/agents/README.md +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/frontend/LICENSE +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/frontend/README.md +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.8.32 → lambda_erp-0.8.34}/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": {
|
|
@@ -513,7 +524,7 @@ TOOLS = [
|
|
|
513
524
|
"function": {
|
|
514
525
|
"name": "create_document",
|
|
515
526
|
"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"
|
|
527
|
+
"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
528
|
"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
529
|
),
|
|
519
530
|
"parameters": {
|
|
@@ -1469,20 +1480,7 @@ def _handle_list_documents(args):
|
|
|
1469
1480
|
|
|
1470
1481
|
|
|
1471
1482
|
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
|
-
}
|
|
1483
|
+
return services.document_field_metadata(args["doctype"])
|
|
1486
1484
|
|
|
1487
1485
|
|
|
1488
1486
|
def _handle_get_document(args):
|
|
@@ -1569,6 +1567,7 @@ def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
|
|
|
1569
1567
|
|
|
1570
1568
|
|
|
1571
1569
|
def _handle_get_master_fields(args):
|
|
1570
|
+
from api.routers.masters import master_requirements, MASTER_LINK_FIELDS
|
|
1572
1571
|
db = get_db()
|
|
1573
1572
|
master_type = args["master_type"]
|
|
1574
1573
|
entry = services.MASTER_TABLES.get(master_type)
|
|
@@ -1577,6 +1576,7 @@ def _handle_get_master_fields(args):
|
|
|
1577
1576
|
doctype, _ = entry
|
|
1578
1577
|
default_search = _master_search_columns(db, doctype)
|
|
1579
1578
|
bulk = services.master_bulk_text_columns(db, doctype)
|
|
1579
|
+
cls = services.DOCUMENT_CLASSES.get(doctype)
|
|
1580
1580
|
return {
|
|
1581
1581
|
"master_type": master_type,
|
|
1582
1582
|
"fields": sorted(db._get_table_columns(doctype)),
|
|
@@ -1585,6 +1585,10 @@ def _handle_get_master_fields(args):
|
|
|
1585
1585
|
"default_search_fields": default_search,
|
|
1586
1586
|
# Large text fields searched ONLY when named in search_masters `fields`.
|
|
1587
1587
|
"bulk_text_fields": bulk,
|
|
1588
|
+
"requirements": master_requirements(master_type),
|
|
1589
|
+
"link_fields": dict(cls.LINK_FIELDS) if cls else MASTER_LINK_FIELDS.get(master_type, {}),
|
|
1590
|
+
"dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS) if cls else {},
|
|
1591
|
+
"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
1592
|
}
|
|
1589
1593
|
|
|
1590
1594
|
|
|
@@ -2138,7 +2142,11 @@ def _handle_create_custom_analytics_report(args, user_info: dict | None = None,
|
|
|
2138
2142
|
|
|
2139
2143
|
if not args.get("title"):
|
|
2140
2144
|
return {"error": "title is required"}
|
|
2141
|
-
|
|
2145
|
+
# ``intent`` is a tool-control field for the specialist handoff, not part
|
|
2146
|
+
# of the persisted declarative report. Keep every other key so the strict
|
|
2147
|
+
# ReportDraftPayload boundary still rejects unexpected/executable fields.
|
|
2148
|
+
payload = {key: value for key, value in args.items() if key != "intent"}
|
|
2149
|
+
return create_report_draft_record(payload, user_info, source_chat_session_id=session_id)
|
|
2142
2150
|
|
|
2143
2151
|
|
|
2144
2152
|
def _handle_get_custom_analytics_report(args, user_info: dict | None = None):
|
|
@@ -2352,12 +2360,30 @@ def _prompt_uom_context() -> str:
|
|
|
2352
2360
|
)
|
|
2353
2361
|
|
|
2354
2362
|
|
|
2363
|
+
def _prompt_validation_context():
|
|
2364
|
+
from lambda_erp.validation import document_requirements
|
|
2365
|
+
from api.routers.masters import master_requirements
|
|
2366
|
+
groups = {}
|
|
2367
|
+
for doctype, cls in services.DOCUMENT_CLASSES.items():
|
|
2368
|
+
rules = document_requirements(cls)
|
|
2369
|
+
if not any(rules.values()):
|
|
2370
|
+
continue
|
|
2371
|
+
key = json.dumps(rules, sort_keys=True)
|
|
2372
|
+
groups.setdefault(key, []).append(doctype)
|
|
2373
|
+
for slug, (doctype, _) in services.MASTER_TABLES.items():
|
|
2374
|
+
if doctype not in services.DOCUMENT_CLASSES:
|
|
2375
|
+
key = json.dumps(master_requirements(slug), sort_keys=True)
|
|
2376
|
+
groups.setdefault(key, []).append(doctype + ' (master)')
|
|
2377
|
+
return "\n".join(f"- {', '.join(names)}: {rules}" for rules, names in groups.items())
|
|
2378
|
+
|
|
2379
|
+
|
|
2355
2380
|
def build_system_prompt(user_info: dict | None = None, channel: str = "web"):
|
|
2356
2381
|
user_name = user_info.get("full_name", "User") if user_info else "User"
|
|
2357
2382
|
user_role = user_info.get("role", "viewer") if user_info else "viewer"
|
|
2358
2383
|
company_context = _prompt_company_context()
|
|
2359
2384
|
analytics_context = _prompt_analytics_context()
|
|
2360
2385
|
uom_context = _prompt_uom_context()
|
|
2386
|
+
validation_context = _prompt_validation_context()
|
|
2361
2387
|
|
|
2362
2388
|
# Deployment-registered types (register_doctype / register_master). Folded
|
|
2363
2389
|
# into the type lists below so the assistant knows they exist and can act
|
|
@@ -2449,11 +2475,16 @@ If `jurisdiction.is_fallback` is true, tell the user their country isn't localiz
|
|
|
2449
2475
|
|
|
2450
2476
|
You help users manage their business by creating documents, looking up data, and running reports — all through natural conversation.
|
|
2451
2477
|
|
|
2478
|
+
## Enforced document requirements (generated from backend rule metadata)
|
|
2479
|
+
{validation_context}
|
|
2480
|
+
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.
|
|
2481
|
+
|
|
2452
2482
|
## Never fabricate actions or results
|
|
2453
|
-
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).
|
|
2483
|
+
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.
|
|
2454
2484
|
- 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.
|
|
2455
2485
|
- 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.
|
|
2456
2486
|
- 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.
|
|
2487
|
+
- 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.
|
|
2457
2488
|
- If you can't confirm an action took effect, re-fetch and look, or tell the user you couldn't confirm it — never guess.
|
|
2458
2489
|
|
|
2459
2490
|
## Answering data questions — three paths
|
|
@@ -2639,9 +2670,11 @@ Use these only for items flagged `is_asset_tracked` on the Item master. Neither
|
|
|
2639
2670
|
- **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.
|
|
2640
2671
|
- **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.
|
|
2641
2672
|
- **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:
|
|
2642
|
-
- **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.
|
|
2673
|
+
- **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.
|
|
2643
2674
|
- **unit** — set `asset`: "*that* machine". `item_code`, `warehouse` and `qty` are filled in from the Asset automatically.
|
|
2644
|
-
- **
|
|
2675
|
+
- **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.
|
|
2676
|
+
- **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.
|
|
2677
|
+
- **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.
|
|
2645
2678
|
- `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`.
|
|
2646
2679
|
- **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.
|
|
2647
2680
|
- 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.
|
|
@@ -3617,6 +3650,8 @@ async def run_thinking_loop(
|
|
|
3617
3650
|
tool_call_id = tc.id
|
|
3618
3651
|
|
|
3619
3652
|
await on_event({"type": "tool_call", "tool": fn_name, "args": fn_args})
|
|
3653
|
+
if session_id:
|
|
3654
|
+
_save_tool_trace(session_id, "tool_call", {"tool": fn_name, "args": fn_args})
|
|
3620
3655
|
|
|
3621
3656
|
# LLM turns can run for many seconds. Re-resolve the principal at
|
|
3622
3657
|
# the actual execution boundary so a demotion, disable, or key
|
|
@@ -3652,7 +3687,7 @@ async def run_thinking_loop(
|
|
|
3652
3687
|
else:
|
|
3653
3688
|
try:
|
|
3654
3689
|
result = await asyncio.to_thread(handler, fn_args)
|
|
3655
|
-
success =
|
|
3690
|
+
success = not (isinstance(result, dict) and ("error" in result or result.get("ok") is False or result.get("failed", 0)))
|
|
3656
3691
|
except Exception as e:
|
|
3657
3692
|
result = {"error": str(e)}
|
|
3658
3693
|
success = False
|
|
@@ -3673,6 +3708,10 @@ async def run_thinking_loop(
|
|
|
3673
3708
|
"success": success,
|
|
3674
3709
|
"summary": summary,
|
|
3675
3710
|
}
|
|
3711
|
+
if isinstance(result, dict):
|
|
3712
|
+
event_payload["warnings"] = (result.get("_validation") or {}).get("warnings", [])
|
|
3713
|
+
if not success:
|
|
3714
|
+
event_payload["error"] = result.get("error") or "One or more operations failed; inspect the tool result."
|
|
3676
3715
|
# Surface the report id so the sidebar can flash the specific draft.
|
|
3677
3716
|
if (
|
|
3678
3717
|
success
|
|
@@ -3681,6 +3720,8 @@ async def run_thinking_loop(
|
|
|
3681
3720
|
and result.get("id")
|
|
3682
3721
|
):
|
|
3683
3722
|
event_payload["report_id"] = result["id"]
|
|
3723
|
+
if session_id:
|
|
3724
|
+
_save_tool_trace(session_id, "tool_result", {**event_payload, "result": result})
|
|
3684
3725
|
await on_event(event_payload)
|
|
3685
3726
|
|
|
3686
3727
|
messages.append({
|
|
@@ -63,6 +63,15 @@ 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
|
|
|
@@ -266,6 +266,8 @@ def generate_proposal_pdf(name: str) -> bytes:
|
|
|
266
266
|
"""Render a Proposal (Sammelofferte): a cover letter plus each referenced
|
|
267
267
|
Quotation as a lettered position (A, B, C…), then append the uploaded
|
|
268
268
|
appendix PDF if there is one. Never mutates the quotations."""
|
|
269
|
+
from lambda_erp.selling.proposal import Proposal
|
|
270
|
+
Proposal.load(name).validate_for_output()
|
|
269
271
|
proposal = load_document("proposal", name)
|
|
270
272
|
db = get_db()
|
|
271
273
|
|
|
@@ -301,12 +303,7 @@ def generate_proposal_pdf(name: str) -> bytes:
|
|
|
301
303
|
rows = sorted(proposal.get("quotations", []) or [], key=lambda r: r.get("idx") or 0)
|
|
302
304
|
for i, row in enumerate(rows):
|
|
303
305
|
qname = row.get("quotation")
|
|
304
|
-
|
|
305
|
-
continue
|
|
306
|
-
try:
|
|
307
|
-
quote = load_document("quotation", qname)
|
|
308
|
-
except Exception:
|
|
309
|
-
continue
|
|
306
|
+
quote = load_document("quotation", qname)
|
|
310
307
|
items = quote.get("items", []) or []
|
|
311
308
|
# Refresh each line's item_name from the master, consistent with the
|
|
312
309
|
# single-document path.
|
|
@@ -117,7 +117,7 @@ def calendar_feed(
|
|
|
117
117
|
"meter_reading": a.get("meter_reading"),
|
|
118
118
|
}
|
|
119
119
|
for a in db.get_all("Asset", filters=asset_filters, fields=["*"], order_by="item_code, asset_tag")
|
|
120
|
-
if a.get("status") != "Retired"
|
|
120
|
+
if a.get("status") != "Retired" and not a.get("discarded")
|
|
121
121
|
]
|
|
122
122
|
|
|
123
123
|
try:
|
|
@@ -78,11 +78,6 @@ def _extract_documents(reply: str, request: Request) -> list[dict]:
|
|
|
78
78
|
return documents
|
|
79
79
|
|
|
80
80
|
|
|
81
|
-
async def _noop_event(event: dict) -> None:
|
|
82
|
-
"""Discard streamed loop events — the REST response is the final reply only."""
|
|
83
|
-
return None
|
|
84
|
-
|
|
85
|
-
|
|
86
81
|
def _client_ip(request: Request) -> str | None:
|
|
87
82
|
xff = request.headers.get("x-forwarded-for", "")
|
|
88
83
|
if xff:
|
|
@@ -117,11 +112,17 @@ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends
|
|
|
117
112
|
|
|
118
113
|
save_chat_message(target_session_id, "user", message)
|
|
119
114
|
|
|
115
|
+
tool_results = []
|
|
116
|
+
|
|
117
|
+
async def collect_event(event):
|
|
118
|
+
if event.get("type") == "tool_result":
|
|
119
|
+
tool_results.append({key: event[key] for key in ("tool", "success", "error", "warnings") if key in event})
|
|
120
|
+
|
|
120
121
|
reply = await run_session_turn(
|
|
121
122
|
target_session_id,
|
|
122
123
|
message,
|
|
123
124
|
caller,
|
|
124
|
-
|
|
125
|
+
collect_event,
|
|
125
126
|
client_ip=_client_ip(request),
|
|
126
127
|
replay_history=replay_history,
|
|
127
128
|
channel="api",
|
|
@@ -133,6 +134,7 @@ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends
|
|
|
133
134
|
"session_id": target_session_id,
|
|
134
135
|
"title": session["title"] if session else None,
|
|
135
136
|
"documents": _extract_documents(reply or "", request),
|
|
137
|
+
"tool_results": tool_results,
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
|
|
@@ -37,6 +37,12 @@ _LIST_RESERVED = {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
@router.get("/{doctype_slug}/fields")
|
|
41
|
+
def document_fields(doctype_slug: str, _user: dict = _viewer):
|
|
42
|
+
from api.services import document_field_metadata
|
|
43
|
+
return document_field_metadata(doctype_slug)
|
|
44
|
+
|
|
45
|
+
|
|
40
46
|
@router.get("/{doctype_slug}")
|
|
41
47
|
def list_docs(
|
|
42
48
|
doctype_slug: str,
|
|
@@ -18,6 +18,8 @@ from api.services import (
|
|
|
18
18
|
)
|
|
19
19
|
from api.auth import require_role, require_non_public_manager
|
|
20
20
|
from api.list_values import distinct_list_values
|
|
21
|
+
from lambda_erp.exceptions import ValidationError
|
|
22
|
+
from lambda_erp.validation import missing
|
|
21
23
|
|
|
22
24
|
router = APIRouter(prefix="/masters", tags=["masters"])
|
|
23
25
|
|
|
@@ -54,7 +56,7 @@ DELETE_REFERENCE_CHECKS = {
|
|
|
54
56
|
('SELECT 1 FROM "POS Invoice" WHERE company = ? LIMIT 1', "POS invoice"),
|
|
55
57
|
('SELECT 1 FROM "Budget" WHERE company = ? LIMIT 1', "budget"),
|
|
56
58
|
('SELECT 1 FROM "Subscription" WHERE company = ? LIMIT 1', "subscription"),
|
|
57
|
-
('SELECT 1 FROM "Bank Transaction" WHERE company = ? LIMIT 1', "bank transaction"),
|
|
59
|
+
('SELECT 1 FROM "Bank Transaction" t JOIN "Bank Account" a ON a.name = t.bank_account WHERE a.company = ? LIMIT 1', "bank transaction"),
|
|
58
60
|
],
|
|
59
61
|
"customer": [
|
|
60
62
|
('SELECT 1 FROM "Quotation" WHERE customer = ? LIMIT 1', "quotation"),
|
|
@@ -117,7 +119,6 @@ DELETE_REFERENCE_CHECKS = {
|
|
|
117
119
|
('SELECT 1 FROM "Tax Template Detail" WHERE account_head = ? LIMIT 1', "tax template detail"),
|
|
118
120
|
('SELECT 1 FROM "Company" WHERE round_off_account = ? OR default_receivable_account = ? OR default_payable_account = ? OR default_income_account = ? OR default_expense_account = ? OR stock_received_but_not_billed = ? OR stock_adjustment_account = ? OR accumulated_depreciation_account = ? OR depreciation_expense_account = ? LIMIT 1', "company"),
|
|
119
121
|
('SELECT 1 FROM "Warehouse" WHERE account = ? LIMIT 1', "warehouse"),
|
|
120
|
-
('SELECT 1 FROM "Pricing Rule" WHERE discount_account = ? LIMIT 1', "pricing rule"),
|
|
121
122
|
('SELECT 1 FROM "Budget" WHERE account = ? LIMIT 1', "budget"),
|
|
122
123
|
('SELECT 1 FROM "Bank Transaction" WHERE bank_account = ? LIMIT 1', "bank transaction"),
|
|
123
124
|
],
|
|
@@ -210,7 +211,78 @@ def _normalize_master_data(data: dict) -> dict:
|
|
|
210
211
|
return normalized
|
|
211
212
|
|
|
212
213
|
|
|
214
|
+
MASTER_LINK_FIELDS = {
|
|
215
|
+
'item': {'default_warehouse': 'Warehouse'},
|
|
216
|
+
'warehouse': {'company': 'Company', 'account': 'Account', 'parent_warehouse': 'Warehouse'},
|
|
217
|
+
'account': {'company': 'Company', 'parent_account': 'Account'},
|
|
218
|
+
'cost-center': {'company': 'Company', 'parent_cost_center': 'Cost Center'},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def master_requirements(master_type):
|
|
223
|
+
from api import services
|
|
224
|
+
doctype, display = _get_table(master_type)
|
|
225
|
+
if doctype in services.DOCUMENT_CLASSES:
|
|
226
|
+
from lambda_erp.validation import document_requirements
|
|
227
|
+
return document_requirements(services.DOCUMENT_CLASSES[doctype])
|
|
228
|
+
from lambda_erp.master_integrity import structure_requirements, STRUCTURAL_FIELDS
|
|
229
|
+
return {'required': [display], 'conditional': ['Unknown input fields are rejected; supplied links must exist.'] + structure_requirements(master_type), 'children': {},
|
|
230
|
+
'structural_fields': list(STRUCTURAL_FIELDS.get(master_type, ()))}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _structural_reference(master_type, doctype, name):
|
|
234
|
+
"""Check declared links too; new document types inherit reference protection.
|
|
235
|
+
|
|
236
|
+
Unlike best-effort delete diagnostics, validation must not swallow SQL
|
|
237
|
+
failures and then approve a structural change.
|
|
238
|
+
"""
|
|
239
|
+
from api import services
|
|
240
|
+
db = get_db()
|
|
241
|
+
links = set()
|
|
242
|
+
for cls in services.DOCUMENT_CLASSES.values():
|
|
243
|
+
links.update((cls.DOCTYPE, field) for field, target in cls.LINK_FIELDS.items() if target == doctype)
|
|
244
|
+
for key, fields in cls.CHILD_LINK_FIELDS.items():
|
|
245
|
+
links.update((cls.CHILD_TABLES[key][0], field) for field, target in fields.items() if target == doctype)
|
|
246
|
+
for kind, fields in MASTER_LINK_FIELDS.items():
|
|
247
|
+
table, _ = _get_table(kind)
|
|
248
|
+
links.update((table, field) for field, target in fields.items() if target == doctype)
|
|
249
|
+
for table, field in sorted(links):
|
|
250
|
+
# Some declarations validate transient/defaulted inputs. Only stored
|
|
251
|
+
# columns can carry a persistent reference (SQLite masks missing ones).
|
|
252
|
+
if field not in db._get_table_columns(table):
|
|
253
|
+
continue
|
|
254
|
+
if db.sql(f'SELECT name FROM "{table}" WHERE "{field}" = ? LIMIT 1', [name]):
|
|
255
|
+
return f'{table}.{field}'
|
|
256
|
+
for query, label in [*DELETE_REFERENCE_CHECKS.get(master_type, []), *MASTER_REFERENCE_CHECKS.get(master_type, [])]:
|
|
257
|
+
if db.sql(query, [name] * query.count('?')):
|
|
258
|
+
return label
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _validate_master_input(master_type, doctype, data, name=None):
|
|
263
|
+
db = get_db()
|
|
264
|
+
allowed = db._get_table_columns(doctype)
|
|
265
|
+
unknown = set(data) - allowed
|
|
266
|
+
if unknown:
|
|
267
|
+
raise ValidationError(f'{doctype}: Unknown field(s): {", ".join(sorted(unknown))}. Use get_master_fields.')
|
|
268
|
+
if name and data.get('name') not in (None, name):
|
|
269
|
+
raise ValidationError('Cannot change master identity; create a new record instead')
|
|
270
|
+
merged = dict(db.get_value(doctype, name, list(allowed)) or {}) if name else {}
|
|
271
|
+
merged.update(data)
|
|
272
|
+
for field in master_requirements(master_type)['required']:
|
|
273
|
+
if missing(merged.get(field)):
|
|
274
|
+
raise ValidationError(f'{doctype}: {field} is required')
|
|
275
|
+
for field, table in MASTER_LINK_FIELDS.get(master_type, {}).items():
|
|
276
|
+
if merged.get(field) and not db.exists(table, merged[field]):
|
|
277
|
+
raise ValidationError(f'{doctype}: {field} must reference an existing {table}')
|
|
278
|
+
|
|
279
|
+
|
|
213
280
|
def create_master_record(master_type: str, data: dict) -> dict:
|
|
281
|
+
with get_db().atomic():
|
|
282
|
+
return _create_master_record(master_type, data)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _create_master_record(master_type: str, data: dict) -> dict:
|
|
214
286
|
doctype, _ = _get_table(master_type)
|
|
215
287
|
if not doctype:
|
|
216
288
|
raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
|
|
@@ -245,6 +317,12 @@ def create_master_record(master_type: str, data: dict) -> dict:
|
|
|
245
317
|
if db.exists(doctype, doc["name"]):
|
|
246
318
|
raise HTTPException(status_code=409, detail=f"{doctype} {doc['name']} already exists")
|
|
247
319
|
|
|
320
|
+
from api import services
|
|
321
|
+
if doctype in services.DOCUMENT_CLASSES:
|
|
322
|
+
return _echo_identity_alias(master_type, services.create_document(services.DOCTYPE_TO_SLUG[doctype], doc))
|
|
323
|
+
|
|
324
|
+
_validate_master_input(master_type, doctype, doc)
|
|
325
|
+
|
|
248
326
|
columns = db._get_table_columns(doctype)
|
|
249
327
|
stamp = now()
|
|
250
328
|
if "creation" in columns and not doc.get("creation"):
|
|
@@ -267,6 +345,13 @@ def create_master_record(master_type: str, data: dict) -> dict:
|
|
|
267
345
|
|
|
268
346
|
|
|
269
347
|
def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
348
|
+
from lambda_erp.master_integrity import STRUCTURAL_FIELDS
|
|
349
|
+
exclusive = bool(set(data) & set(STRUCTURAL_FIELDS.get(master_type, ())))
|
|
350
|
+
with get_db().atomic(exclusive_master_edit=exclusive):
|
|
351
|
+
return _update_master_record(master_type, name, data)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
270
355
|
doctype, _ = _get_table(master_type)
|
|
271
356
|
if not doctype:
|
|
272
357
|
raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
|
|
@@ -289,7 +374,23 @@ def update_master_record(master_type: str, name: str, data: dict) -> dict:
|
|
|
289
374
|
),
|
|
290
375
|
)
|
|
291
376
|
|
|
377
|
+
if normalized.get('name') not in (None, name):
|
|
378
|
+
raise ValidationError('Cannot change master identity; create a new record instead')
|
|
379
|
+
from api import services
|
|
380
|
+
if doctype in services.DOCUMENT_CLASSES:
|
|
381
|
+
return _echo_identity_alias(master_type, services.update_document(services.DOCTYPE_TO_SLUG[doctype], name, normalized))
|
|
382
|
+
|
|
292
383
|
update_fields = {k: v for k, v in normalized.items() if k != "name"}
|
|
384
|
+
if not db.exists(doctype, name):
|
|
385
|
+
raise HTTPException(status_code=404, detail=f"{doctype} {name} not found")
|
|
386
|
+
_validate_master_input(master_type, doctype, normalized, name)
|
|
387
|
+
from lambda_erp.master_integrity import structural_changes
|
|
388
|
+
stored = db.get_value(doctype, name, list(db._get_table_columns(doctype)))
|
|
389
|
+
changed = structural_changes(master_type, stored, normalized)
|
|
390
|
+
if changed:
|
|
391
|
+
reference = _structural_reference(master_type, doctype, name)
|
|
392
|
+
if reference:
|
|
393
|
+
raise ValidationError(f'{doctype} {name}: cannot change structural fields {", ".join(changed)}; referenced by {reference}. Use a new master or a deliberate migration.')
|
|
293
394
|
if "modified" in db._get_table_columns(doctype) and "modified" not in update_fields:
|
|
294
395
|
update_fields["modified"] = now()
|
|
295
396
|
if update_fields:
|
|
@@ -559,6 +660,14 @@ def adjacent_master(
|
|
|
559
660
|
return {"prev": neighbor("prev"), "next": neighbor("next")}
|
|
560
661
|
|
|
561
662
|
|
|
663
|
+
@router.get("/{master_type}/fields")
|
|
664
|
+
def master_fields(master_type: str, _user: dict = _viewer):
|
|
665
|
+
if not _get_table(master_type)[0]:
|
|
666
|
+
raise HTTPException(status_code=404, detail=f"Unknown master type: {master_type}")
|
|
667
|
+
from api.chat import _handle_get_master_fields
|
|
668
|
+
return _handle_get_master_fields({'master_type': master_type})
|
|
669
|
+
|
|
670
|
+
|
|
562
671
|
@router.get("/{master_type}/{name}")
|
|
563
672
|
def get_master(master_type: str, name: str, _user: dict = _viewer):
|
|
564
673
|
doctype, _ = _get_table(master_type)
|
|
@@ -598,6 +707,11 @@ def delete_master_record(master_type: str, name: str) -> dict:
|
|
|
598
707
|
if not db.exists(doctype, name):
|
|
599
708
|
raise HTTPException(status_code=404, detail=f"{doctype} {name} not found")
|
|
600
709
|
|
|
710
|
+
from api import services
|
|
711
|
+
if doctype in services.DOCUMENT_CLASSES and 'discarded' in db._get_table_columns(doctype):
|
|
712
|
+
services.discard_document(services.DOCTYPE_TO_SLUG[doctype], name)
|
|
713
|
+
return {'ok': True, 'status': 'discarded'}
|
|
714
|
+
|
|
601
715
|
reference = _find_reference(master_type, name)
|
|
602
716
|
if reference:
|
|
603
717
|
columns = db._get_table_columns(doctype)
|
|
@@ -458,16 +458,77 @@ def _default_tax_rows(cls, data: dict) -> list | None:
|
|
|
458
458
|
]
|
|
459
459
|
|
|
460
460
|
|
|
461
|
+
def document_field_metadata(doctype_slug: str) -> dict:
|
|
462
|
+
from lambda_erp.validation import document_requirements
|
|
463
|
+
doctype, cls = get_document_class(doctype_slug)
|
|
464
|
+
if not cls:
|
|
465
|
+
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
466
|
+
db = get_db()
|
|
467
|
+
return {
|
|
468
|
+
"doctype": doctype_slug,
|
|
469
|
+
"fields": sorted(db._get_table_columns(doctype)),
|
|
470
|
+
"text_fields": sorted(db._get_text_columns(doctype)),
|
|
471
|
+
"default_search_fields": document_search_columns(db, doctype_slug),
|
|
472
|
+
"child_tables": sorted(cls.CHILD_TABLES),
|
|
473
|
+
"link_fields": dict(cls.LINK_FIELDS),
|
|
474
|
+
"dynamic_link_fields": dict(cls.DYNAMIC_LINK_FIELDS),
|
|
475
|
+
"input_fields": sorted(cls.INPUT_FIELDS),
|
|
476
|
+
"read_only_fields": sorted(cls.SERVER_MANAGED_FIELDS),
|
|
477
|
+
"child_input_fields": {key: sorted(value) for key, value in cls.CHILD_INPUT_FIELDS.items()},
|
|
478
|
+
"requirements": document_requirements(cls),
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _validate_document_input(doctype, cls, data):
|
|
483
|
+
from lambda_erp.exceptions import ValidationError
|
|
484
|
+
if not isinstance(data, dict):
|
|
485
|
+
raise ValidationError("Document data must be an object")
|
|
486
|
+
# Response-only annotations can accompany a round trip through the form.
|
|
487
|
+
data = {key: value for key, value in data.items() if key != "_validation"}
|
|
488
|
+
db = get_db()
|
|
489
|
+
allowed = db._get_table_columns(doctype) | set(cls.CHILD_TABLES) | set(cls.INPUT_FIELDS)
|
|
490
|
+
unknown = set(data) - allowed
|
|
491
|
+
if unknown:
|
|
492
|
+
raise ValidationError(f"{doctype}: Unknown field(s): {', '.join(sorted(unknown))}. Use get_document_fields to inspect supported fields.")
|
|
493
|
+
for key, (table, _) in cls.CHILD_TABLES.items():
|
|
494
|
+
if key not in data:
|
|
495
|
+
continue
|
|
496
|
+
rows = data[key]
|
|
497
|
+
if not isinstance(rows, list):
|
|
498
|
+
raise ValidationError(f"{doctype}: {key} must be an array")
|
|
499
|
+
allowed_child = db._get_table_columns(table) | set(cls.CHILD_INPUT_FIELDS.get(key, ()))
|
|
500
|
+
for idx, row in enumerate(rows, 1):
|
|
501
|
+
if not isinstance(row, dict):
|
|
502
|
+
raise ValidationError(f"{doctype}: {key} row {idx} must be an object")
|
|
503
|
+
unknown = set(row) - allowed_child
|
|
504
|
+
if unknown:
|
|
505
|
+
raise ValidationError(f"{doctype}: {key} row {idx}: Unknown field(s): {', '.join(sorted(unknown))}")
|
|
506
|
+
return data
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _document_validation(doc):
|
|
510
|
+
warnings = []
|
|
511
|
+
if doc.DOCTYPE == 'Proposal' and (not doc.get('customer') or not doc.get('company') or not doc.get('quotations')):
|
|
512
|
+
warnings.append({'code': 'proposal_incomplete', 'message': 'Draft saved; PDF output requires Customer, Company and at least one Quotation.'})
|
|
513
|
+
if doc.DOCTYPE == "Reservation" and not doc.get("asset") and doc.get("status") in {"Reserved", "Out"}:
|
|
514
|
+
warnings.append({
|
|
515
|
+
"code": "asset_unassigned", "field": "asset",
|
|
516
|
+
"message": "Pool capacity is reserved; no specific machine is assigned. This booking appears on a pool row in the fleet calendar. Assign an asset before dispatch.",
|
|
517
|
+
})
|
|
518
|
+
return {"warnings": warnings}
|
|
519
|
+
|
|
520
|
+
|
|
461
521
|
def create_document(doctype_slug: str, data: dict) -> dict:
|
|
462
522
|
doctype, cls = get_document_class(doctype_slug)
|
|
463
523
|
if not cls:
|
|
464
524
|
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
525
|
+
data = _validate_document_input(doctype, cls, data)
|
|
465
526
|
default_taxes = _default_tax_rows(cls, data)
|
|
466
527
|
if default_taxes is not None:
|
|
467
528
|
data = {**data, "taxes": default_taxes}
|
|
468
529
|
doc = cls(data)
|
|
469
530
|
doc.save()
|
|
470
|
-
return doc.
|
|
531
|
+
return load_document(doctype_slug, doc.name)
|
|
471
532
|
|
|
472
533
|
|
|
473
534
|
def load_document(doctype_slug: str, name: str) -> dict:
|
|
@@ -475,13 +536,16 @@ def load_document(doctype_slug: str, name: str) -> dict:
|
|
|
475
536
|
if not cls:
|
|
476
537
|
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
477
538
|
doc = cls.load(name)
|
|
478
|
-
|
|
539
|
+
result = doc.as_dict()
|
|
540
|
+
result["_validation"] = _document_validation(doc)
|
|
541
|
+
return result
|
|
479
542
|
|
|
480
543
|
|
|
481
544
|
def update_document(doctype_slug: str, name: str, data: dict) -> dict:
|
|
482
545
|
doctype, cls = get_document_class(doctype_slug)
|
|
483
546
|
if not cls:
|
|
484
547
|
raise ValueError(f"Unknown document type: {doctype_slug}")
|
|
548
|
+
data = _validate_document_input(doctype, cls, data)
|
|
485
549
|
doc = cls.load(name)
|
|
486
550
|
# Update parent fields
|
|
487
551
|
for key, value in data.items():
|
|
@@ -494,7 +558,7 @@ def update_document(doctype_slug: str, name: str, data: dict) -> dict:
|
|
|
494
558
|
for row in data[table_name]:
|
|
495
559
|
doc.append(table_name, _dict(row))
|
|
496
560
|
doc.save()
|
|
497
|
-
return doc.
|
|
561
|
+
return load_document(doctype_slug, doc.name)
|
|
498
562
|
|
|
499
563
|
|
|
500
564
|
def batch_update_documents(doctype_slug: str, updates: list) -> dict:
|
|
@@ -528,12 +592,13 @@ def batch_update_documents(doctype_slug: str, updates: list) -> dict:
|
|
|
528
592
|
results.append({"name": name, "ok": False, "error": "missing 'data' object"})
|
|
529
593
|
continue
|
|
530
594
|
try:
|
|
531
|
-
update_document(doctype_slug, name, data)
|
|
532
|
-
results.append({"name": name, "ok": True})
|
|
595
|
+
saved = update_document(doctype_slug, name, data)
|
|
596
|
+
results.append({"name": name, "ok": True, "warnings": saved.get('_validation', {}).get('warnings', [])})
|
|
533
597
|
updated += 1
|
|
534
598
|
except Exception as e: # noqa: BLE001 — per-item best effort; report, don't abort
|
|
535
599
|
results.append({"name": name, "ok": False, "error": str(e)})
|
|
536
|
-
|
|
600
|
+
warnings = [{**warning, 'name': row['name']} for row in results for warning in row.get('warnings', [])]
|
|
601
|
+
return {"updated": updated, "failed": len(results) - updated, "results": results, '_validation': {'warnings': warnings}}
|
|
537
602
|
|
|
538
603
|
|
|
539
604
|
def submit_document(doctype_slug: str, name: str) -> dict:
|