lambda-erp 0.1.24__tar.gz → 0.1.26__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/chat.py +159 -11
  3. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/pdf.py +6 -0
  4. lambda_erp-0.1.26/api/remarks_md.py +91 -0
  5. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/templates/document.html +13 -2
  6. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/database.py +30 -0
  7. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/pyproject.toml +1 -1
  8. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/.gitignore +0 -0
  9. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/LICENSE +0 -0
  10. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/README.md +0 -0
  11. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/__init__.py +0 -0
  12. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/attachments.py +0 -0
  13. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/auth.py +0 -0
  14. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/bootstrap.py +0 -0
  15. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/demo_limits.py +0 -0
  16. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/deps.py +0 -0
  17. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/errors.py +0 -0
  18. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/main.py +0 -0
  19. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/providers.py +0 -0
  20. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/__init__.py +0 -0
  21. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/accounting.py +0 -0
  22. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/admin.py +0 -0
  23. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/analytics.py +0 -0
  24. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/bank_reconciliation.py +0 -0
  25. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/documents.py +0 -0
  26. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/masters.py +0 -0
  27. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/proposals.py +0 -0
  28. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/reports.py +0 -0
  29. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/setup.py +0 -0
  30. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/services.py +0 -0
  31. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/templates/proposal.html +0 -0
  32. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/docs/agents/README.md +0 -0
  33. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/frontend/README.md +0 -0
  34. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/frontend/src/api/client.ts +0 -0
  35. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/__init__.py +0 -0
  36. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/__init__.py +0 -0
  37. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/bank_transaction.py +0 -0
  38. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/budget.py +0 -0
  39. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  40. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/general_ledger.py +0 -0
  41. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/journal_entry.py +0 -0
  42. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/payment_entry.py +0 -0
  43. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/pos_invoice.py +0 -0
  44. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/purchase_invoice.py +0 -0
  45. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/revaluation.py +0 -0
  46. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/sales_invoice.py +0 -0
  47. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/subscription.py +0 -0
  48. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/buying/__init__.py +0 -0
  49. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/buying/purchase_order.py +0 -0
  50. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/__init__.py +0 -0
  51. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/currency.py +0 -0
  52. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/defaults.py +0 -0
  53. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/pricing_rule.py +0 -0
  54. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  55. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/exceptions.py +0 -0
  56. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/hooks.py +0 -0
  57. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/model.py +0 -0
  58. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/__init__.py +0 -0
  59. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/proposal.py +0 -0
  60. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/quotation.py +0 -0
  61. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/sales_order.py +0 -0
  62. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/simulation.py +0 -0
  63. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/__init__.py +0 -0
  64. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/delivery_note.py +0 -0
  65. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/purchase_receipt.py +0 -0
  66. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/stock_entry.py +0 -0
  67. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/stock_ledger.py +0 -0
  68. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/utils.py +0 -0
  69. {lambda_erp-0.1.24 → lambda_erp-0.1.26}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.24
3
+ Version: 0.1.26
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -10,6 +10,7 @@ Supports multiple chat sessions, each with their own history and auto-generated
10
10
 
11
11
  import asyncio
12
12
  import base64
13
+ import difflib
13
14
  import io
14
15
  import json
15
16
  import logging
@@ -582,12 +583,31 @@ TOOLS = [
582
583
  "type": "function",
583
584
  "function": {
584
585
  "name": "search_masters",
585
- "description": "Search master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). Returns matching records.",
586
+ "description": "Search master data (customers, suppliers, items, warehouses, accounts, companies, cost centers). Case-insensitive, with fuzzy fallback for misspellings. By default matches across standard text fields (name, display name, address/city/zip); large free-text columns like description/templates are skipped unless named in `fields`. PREFER passing `fields` whenever you know which attribute you're matching on (e.g. a city, an email, a tax id) — it's faster, more precise, and avoids false hits from other columns. Returns matching records.",
586
587
  "parameters": {
587
588
  "type": "object",
588
589
  "properties": {
589
590
  "master_type": {"type": "string", "enum": MASTER_TYPES},
590
591
  "query": {"type": "string", "description": "Search term (empty string returns all)", "default": ""},
592
+ "fields": {
593
+ "type": "array",
594
+ "items": {"type": "string"},
595
+ "description": "Recommended: the column(s) to search, e.g. [\"city\"] or [\"customer_name\"]. Narrowing here is faster and avoids matching unrelated columns. Omit only when you genuinely don't know which field holds the value, to search all standard text fields.",
596
+ },
597
+ },
598
+ "required": ["master_type"],
599
+ },
600
+ },
601
+ },
602
+ {
603
+ "type": "function",
604
+ "function": {
605
+ "name": "get_master_fields",
606
+ "description": "List the available columns of a master type (customer, supplier, item, ...). Call this WHENEVER you're unsure which columns exist: before passing `fields` to search_masters, and before building a `data` payload for create_master/update_master when you're not certain a field exists or where a value belongs (e.g. a contact person, a tax id, a payment term). It lets you target real fields instead of guessing — or wrongly concluding a value can't be stored. Returns: `fields` (all columns), `default_search_fields` (what search_masters searches when `fields` is omitted), and `bulk_text_fields` (large text columns searched only when named in `fields`).",
607
+ "parameters": {
608
+ "type": "object",
609
+ "properties": {
610
+ "master_type": {"type": "string", "enum": MASTER_TYPES},
591
611
  },
592
612
  "required": ["master_type"],
593
613
  },
@@ -608,8 +628,10 @@ TOOLS = [
608
628
  "**Account fields:** name (REQUIRED — full account id, conventionally \"<account_name> - <company abbr>\", e.g. \"Marketing Expenses - LAMB\"), account_name (required), company (required), root_type (Asset/Liability/Equity/Income/Expense), report_type (\"Balance Sheet\" or \"Profit and Loss\"), account_type (e.g. Receivable, Payable, Bank, Cash, Stock, Tax), parent_account, account_currency, is_group (0/1).\n"
609
629
  "**Cost Center fields:** name (REQUIRED — e.g. \"Marketing - LAMB\"), cost_center_name (required), company (required), parent_cost_center, is_group (0/1).\n\n"
610
630
  "zip_code is free text (e.g. \"8400\", \"ZH 8400\", \"59123\"), never numeric.\n"
631
+ "When the user names a contact person at a customer (e.g. \"Kontakt/Ansprechpartner ist Marlene Voss, 079 123 45 67\"), put the person's name in contact_person and their phone/email in contact_phone/contact_email — NOT in the company-level phone/email. There is no separate contact tool; contact-person data lives on these Customer columns, so never claim you cannot store a contact person.\n"
611
632
  "Supplier example: {\"master_type\":\"supplier\",\"data\":{\"supplier_name\":\"Schlafteq\",\"email\":\"jacob@schlafteq.ch\",\"phone\":\"+1 555-0104\",\"address\":\"145 Harbor Rd\",\"city\":\"Seattle\",\"zip_code\":\"98101\",\"country\":\"US\",\"tax_id\":\"98-7654321\"}}\n"
612
- "Item example (custom code): {\"master_type\":\"item\",\"data\":{\"item_code\":\"SVC-SPARK\",\"item_name\":\"Spark\",\"item_group\":\"Services\",\"is_stock_item\":0,\"standard_rate\":310}}"
633
+ "Item example (custom code): {\"master_type\":\"item\",\"data\":{\"item_code\":\"SVC-SPARK\",\"item_name\":\"Spark\",\"item_group\":\"Services\",\"is_stock_item\":0,\"standard_rate\":310}}\n"
634
+ "Customer example (with contact person): {\"master_type\":\"customer\",\"data\":{\"customer_name\":\"Foglio AG\",\"address\":\"Seeweg 12\",\"city\":\"Bramblewick\",\"zip_code\":\"9999\",\"contact_person\":\"Marlene Voss\",\"contact_phone\":\"079 123 45 67\"}}"
613
635
  ),
614
636
  "parameters": {
615
637
  "type": "object",
@@ -1077,26 +1099,139 @@ def _handle_convert_document(args):
1077
1099
  return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
1078
1100
 
1079
1101
 
1102
+ # Columns that are technically text but are noise for free-text master search.
1103
+ _MASTER_SEARCH_SKIP_COLUMNS = {
1104
+ "naming_series",
1105
+ "owner",
1106
+ "modified_by",
1107
+ "creation",
1108
+ "modified",
1109
+ "created_at",
1110
+ "updated_at",
1111
+ }
1112
+
1113
+ # Large free-text columns excluded from the DEFAULT search: scanning/fuzzing a
1114
+ # big blob (e.g. an item description or an HTML template) is costly and rarely
1115
+ # how you identify a record. Still searchable on demand via the `fields` arg.
1116
+ _MASTER_BULK_TEXT_COLUMNS = {"description", "notes", "remarks", "terms", "comments"}
1117
+
1118
+ # Minimum difflib similarity for a fuzzy (misspelled) match to be returned.
1119
+ _MASTER_FUZZY_THRESHOLD = 0.6
1120
+ # Bounds for the fuzzy scorer so a single large value can't blow up cost:
1121
+ # skip whole-value scoring past this length, and only score the first N tokens.
1122
+ _FUZZY_MAX_VALUE_LEN = 200
1123
+ _FUZZY_MAX_TOKENS = 16
1124
+
1125
+
1126
+ def _is_bulk_text_column(col):
1127
+ return (col in _MASTER_BULK_TEXT_COLUMNS
1128
+ or col.endswith("_template") or col.endswith("_html"))
1129
+
1130
+
1131
+ def _master_search_columns(db, doctype):
1132
+ """Text columns worth matching a query against by default. Discovered from
1133
+ the live schema (new text fields become searchable automatically), minus
1134
+ audit noise and large free-text/template columns."""
1135
+ cols = db._get_text_columns(doctype) - _MASTER_SEARCH_SKIP_COLUMNS
1136
+ # Deterministic order keeps generated SQL and fuzzy scoring stable.
1137
+ return sorted(c for c in cols if not _is_bulk_text_column(c))
1138
+
1139
+
1140
+ def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
1141
+ """Fallback for misspellings: rank active rows by best per-field similarity.
1142
+
1143
+ Substring search misses typos ("Meynex" vs "medynex"), so when it finds
1144
+ nothing we pull a capped candidate set and score it in Python with difflib —
1145
+ portable across SQLite and Postgres, no DB extensions required."""
1146
+ where = "WHERE disabled = 0 " if has_disabled else ""
1147
+ candidates = db.sql(f'SELECT * FROM "{doctype}" {where}LIMIT 1000')
1148
+
1149
+ q = query.lower()
1150
+ scored = []
1151
+ for row in candidates:
1152
+ best = 0.0
1153
+ for col in search_cols:
1154
+ val = row.get(col)
1155
+ if not val:
1156
+ continue
1157
+ val = str(val).lower()
1158
+ # Skip whole-value scoring of big blobs (low ratio anyway, high cost).
1159
+ if len(val) <= _FUZZY_MAX_VALUE_LEN:
1160
+ best = max(best, difflib.SequenceMatcher(None, q, val).ratio())
1161
+ # Also score the first few words so a typo'd single token still
1162
+ # matches a multi-word field (e.g. "medynex" within "medynex ag").
1163
+ for token in val.split()[:_FUZZY_MAX_TOKENS]:
1164
+ best = max(best, difflib.SequenceMatcher(None, q, token).ratio())
1165
+ if best >= _MASTER_FUZZY_THRESHOLD:
1166
+ scored.append((best, row))
1167
+
1168
+ scored.sort(key=lambda pair: pair[0], reverse=True)
1169
+ return [dict(row) for _, row in scored[:limit]]
1170
+
1171
+
1172
+ def _handle_get_master_fields(args):
1173
+ db = get_db()
1174
+ master_type = args["master_type"]
1175
+ entry = services.MASTER_TABLES.get(master_type)
1176
+ if not entry:
1177
+ return {"error": f"Unknown master type: {master_type}"}
1178
+ doctype, _ = entry
1179
+ default_search = _master_search_columns(db, doctype)
1180
+ bulk = sorted(c for c in db._get_text_columns(doctype) if _is_bulk_text_column(c))
1181
+ return {
1182
+ "master_type": master_type,
1183
+ "fields": sorted(db._get_table_columns(doctype)),
1184
+ # What search_masters searches when `fields` is omitted.
1185
+ "default_search_fields": default_search,
1186
+ # Large text fields searched ONLY when named in search_masters `fields`.
1187
+ "bulk_text_fields": bulk,
1188
+ }
1189
+
1190
+
1080
1191
  def _handle_search_masters(args):
1081
1192
  db = get_db()
1082
1193
  master_type = args["master_type"]
1083
- query = args.get("query", "")
1194
+ query = (args.get("query") or "").strip()
1084
1195
 
1085
1196
  entry = services.MASTER_TABLES.get(master_type)
1086
1197
  if not entry:
1087
1198
  return {"error": f"Unknown master type: {master_type}"}
1088
1199
 
1089
- doctype, name_field = entry
1090
- active_prefix = 'disabled = 0 AND ' if "disabled" in db._get_table_columns(doctype) else ""
1200
+ doctype, _name_field = entry
1201
+ has_disabled = "disabled" in db._get_table_columns(doctype)
1202
+
1091
1203
  if not query:
1092
- filters = {"disabled": 0} if "disabled" in db._get_table_columns(doctype) else None
1204
+ filters = {"disabled": 0} if has_disabled else None
1093
1205
  return db.get_all(doctype, filters=filters, fields=["*"], limit=20)
1094
1206
 
1207
+ # Optional `fields` narrows the search to specific columns — cheaper, more
1208
+ # precise, and the only way to reach bulk columns (description, templates)
1209
+ # that the default search skips. Unknown names are ignored.
1210
+ requested = args.get("fields") or []
1211
+ if requested:
1212
+ valid = db._get_table_columns(doctype)
1213
+ search_cols = sorted(c for c in requested if c in valid)
1214
+ if not search_cols:
1215
+ return {"error": f"None of fields {requested} exist on {master_type}"}
1216
+ else:
1217
+ search_cols = _master_search_columns(db, doctype)
1218
+
1219
+ active_prefix = "disabled = 0 AND " if has_disabled else ""
1220
+
1221
+ # Case-insensitive substring match. lower() on both sides is portable (bare
1222
+ # LIKE is case-insensitive on SQLite but case-sensitive on Postgres, which
1223
+ # silently broke prod search); CAST lets targeted non-text columns match too.
1224
+ where = " OR ".join(f'lower(CAST("{col}" AS TEXT)) LIKE ?' for col in search_cols)
1225
+ pattern = f"%{query.lower()}%"
1095
1226
  rows = db.sql(
1096
- f'SELECT * FROM "{doctype}" WHERE {active_prefix}(name LIKE ? OR "{name_field}" LIKE ?) LIMIT 20',
1097
- [f"%{query}%", f"%{query}%"],
1227
+ f'SELECT * FROM "{doctype}" WHERE {active_prefix}({where}) LIMIT 20',
1228
+ [pattern] * len(search_cols),
1098
1229
  )
1099
- return [dict(r) for r in rows]
1230
+ if rows:
1231
+ return [dict(r) for r in rows]
1232
+
1233
+ # Nothing matched literally — try fuzzy matching to catch misspellings.
1234
+ return _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit=20)
1100
1235
 
1101
1236
 
1102
1237
  def _ignored_master_fields(master_type: str, data: dict) -> list[str]:
@@ -1135,7 +1270,8 @@ def _handle_create_master(args):
1135
1270
  if ignored:
1136
1271
  result["_warning"] = (
1137
1272
  f"These fields were IGNORED because they are not valid columns on the {master_type}: "
1138
- f"{ignored}. Retry the call using the correct field names from the create_master tool description."
1273
+ f"{ignored}. Call get_master_fields(master_type=\"{master_type}\") to see the real columns, "
1274
+ f"then retry mapping those values onto valid field names."
1139
1275
  )
1140
1276
  return result
1141
1277
 
@@ -1408,6 +1544,7 @@ TOOL_HANDLERS = {
1408
1544
  "cancel_document": _handle_cancel_document,
1409
1545
  "discard_document": _handle_discard_document,
1410
1546
  "convert_document": _handle_convert_document,
1547
+ "get_master_fields": _handle_get_master_fields,
1411
1548
  "search_masters": _handle_search_masters,
1412
1549
  "create_master": _handle_create_master,
1413
1550
  "update_master": _handle_update_master,
@@ -1571,6 +1708,14 @@ Shape — note it does NOT use `items`:
1571
1708
 
1572
1709
  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; link the user to the PDF at `/api/documents/proposal/<name>/pdf` (and the editor at `/app/proposal/<name>`).
1573
1710
 
1711
+ ### Notes / Terms markup (the `remarks` field)
1712
+ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices renders on the PDF with a small markup vocabulary, so when a user dictates offer notes, conditions, recurring services, or a sign-off you can compose a polished closing block instead of a flat paragraph. Put this ONLY in `remarks` (never in item descriptions):
1713
+ - `# Heading` at the start of a line -> bold heading
1714
+ - `*italic*` or `_italic_` -> italic; `**bold**` -> bold
1715
+ - `>> Period | Amount` at the start of a line -> a right-aligned price line that sits beside the heading/description above it; use it for separately- or recurring-billed items, e.g. `>> Monatlich | CHF 380.—`
1716
+ - Separate blocks with a blank line; a single newline is just a line break.
1717
+ Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
1718
+
1574
1719
  ### Purchase Cycle
1575
1720
  Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
1576
1721
 
@@ -1726,7 +1871,10 @@ When you fill in `item_code`, `customer`, `supplier`, `warehouse`, `company`, et
1726
1871
 
1727
1872
  If the user refers to something by its human name ("bill them 8 hours of project mgmt", "add Redstone to the quote"), resolve the key first:
1728
1873
  - `search_masters(master_type="item", q="project management")` → returns `[{{name: "SVC-005", item_name: "Project Management"}}]`. Use `name` as `item_code`.
1729
- - Same for customers, suppliers, warehouses, etc. — `search_masters` matches **both** the key and the display field.
1874
+ - Same for customers, suppliers, warehouses, etc. — `search_masters` is **case-insensitive** and falls back to **fuzzy matching for misspellings**, so a typo'd name ("Meynex") still resolves. Trust its results instead of concluding "not found" after one narrow try.
1875
+ - **Prefer narrowing with `fields`** whenever you know the attribute: search a customer by city with `fields=["city"]`, by email with `fields=["contact_email"]`, etc. It's faster and avoids false matches from unrelated columns. Omitting `fields` searches all standard text fields (a good fallback when you're unsure where the value lives), but large free-text columns (e.g. item `description`) are only searched when you name them explicitly in `fields`.
1876
+ - **Don't guess column names** — call `get_master_fields(master_type=...)` first to see the real columns (and which are searched by default), then pass the exact names to `search_masters` `fields`. This also tells you which large text fields (like `description`) you must name explicitly to search.
1877
+ - **When unsure where a value belongs, discover the schema — don't guess or give up.** Before a `create_master`/`update_master` where you're not certain a field exists or which column fits (the user gives a contact person, a VAT/tax id, a payment term, an IBAN, …), call `get_master_fields(master_type=...)` and map the value onto the real column. Never tell the user you can't store something without checking the fields first — the master usually has a column for it (e.g. a customer's contact person goes in `contact_person`/`contact_phone`/`contact_email`, not the company-level `phone`/`email`).
1730
1878
 
1731
1879
  When you list masters back to the user (items on an invoice, customers on a report), include the key in parentheses so follow-ups are unambiguous. Example: "Project Management (SVC-005) — 16 Hour".
1732
1880
 
@@ -6,6 +6,7 @@ from jinja2 import Environment, FileSystemLoader, ChoiceLoader
6
6
  from weasyprint import HTML
7
7
  from lambda_erp.database import get_db
8
8
  from api.services import load_document
9
+ from api.remarks_md import render_remarks
9
10
 
10
11
  TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
11
12
 
@@ -201,6 +202,11 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
201
202
  taxes=taxes,
202
203
  show_warehouse=doctype in SHOW_WAREHOUSE,
203
204
  page_size=page_size,
205
+ # Notes / Terms rendered from a small markup subset (headings, bold/
206
+ # italic, right-aligned price lines) into safe HTML; templates style the
207
+ # emitted classes. Falls back to plain `doc.remarks` if a template
208
+ # doesn't use it. See api/remarks_md.py.
209
+ remarks_html=render_remarks(doc.get("remarks")),
204
210
  )
205
211
 
206
212
  # Let deployment plugins augment the context (e.g. a Swiss QR-bill image for
@@ -0,0 +1,91 @@
1
+ """Lightweight markup for the Notes / Terms (`remarks`) block on documents.
2
+
3
+ `remarks` is free text the user types on a document (quotation, sales order,
4
+ invoice, …), but on the PDF it often wants to read like a real offer: italic
5
+ asides, bold service headings, and a right-aligned recurring-price line beside a
6
+ description. Rather than pulling in a full Markdown dependency we support a tiny,
7
+ predictable subset and render it to **safe** HTML. `generate_pdf()` exposes the
8
+ result as `remarks_html`; templates render it with `| safe` and style the emitted
9
+ class names (`.rm-block`, `.rm-h`, `.rm-p`, `.rm-amt`) however they like — so a
10
+ branded template can restyle the same markup without changing this converter.
11
+
12
+ Syntax (authored in the same textarea, also what the chat assistant emits):
13
+ blank line -> separates blocks (a float stays beside its block)
14
+ # Heading -> bold heading line (leading #'s stripped)
15
+ *italic* _italic_ -> italic
16
+ **bold** -> bold
17
+ >> Monatlich | CHF 380.— -> right-aligned price box (period over amount),
18
+ floated beside the block's heading/description
19
+ Everything is HTML-escaped first, so user text can't inject markup.
20
+ """
21
+ import html
22
+ import re
23
+
24
+
25
+ def _inline(text):
26
+ """Escape, then apply inline **bold** / *italic* / _italic_."""
27
+ out = html.escape(text)
28
+ out = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", out)
29
+ out = re.sub(r"\*(.+?)\*", r"<em>\1</em>", out)
30
+ out = re.sub(r"_(.+?)_", r"<em>\1</em>", out)
31
+ return out
32
+
33
+
34
+ def _price(line):
35
+ """`>> period | amount` -> a right-floated box with period over amount."""
36
+ body = line.lstrip()[2:].strip() # drop the leading '>>'
37
+ if "|" in body:
38
+ period, amount = (p.strip() for p in body.split("|", 1))
39
+ else:
40
+ period, amount = "", body
41
+ parts = []
42
+ if period:
43
+ parts.append(f'<div class="rm-amt-period">{_inline(period)}</div>')
44
+ if amount:
45
+ parts.append(f'<div class="rm-amt-val">{_inline(amount)}</div>')
46
+ return f'<div class="rm-amt">{"".join(parts)}</div>'
47
+
48
+
49
+ def _block(lines):
50
+ """Render one block (lines between blank lines) to HTML.
51
+
52
+ Price line(s) are emitted first and floated right so they sit beside the
53
+ heading/description that follow, matching a typical offer layout.
54
+ """
55
+ price_html = ""
56
+ body = ""
57
+ para = []
58
+
59
+ def flush_para():
60
+ nonlocal body, para
61
+ if para:
62
+ body += '<div class="rm-p">' + "<br>".join(_inline(p) for p in para) + "</div>"
63
+ para = []
64
+
65
+ for raw in lines:
66
+ stripped = raw.lstrip()
67
+ if stripped.startswith(">>"):
68
+ flush_para()
69
+ price_html += _price(raw)
70
+ elif stripped.startswith("#"):
71
+ flush_para()
72
+ heading = stripped.lstrip("#").strip()
73
+ body += f'<div class="rm-h">{_inline(heading)}</div>'
74
+ else:
75
+ para.append(raw)
76
+ flush_para()
77
+ return f'<div class="rm-block">{price_html}{body}</div>'
78
+
79
+
80
+ def render_remarks(text):
81
+ """Convert the remarks free text to safe styled HTML (or '' if empty)."""
82
+ if not text or not text.strip():
83
+ return ""
84
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
85
+ blocks = []
86
+ for raw_block in re.split(r"\n[ \t]*\n", text):
87
+ lines = raw_block.split("\n")
88
+ if not any(l.strip() for l in lines):
89
+ continue
90
+ blocks.append(_block(lines))
91
+ return "".join(blocks)
@@ -48,6 +48,15 @@
48
48
 
49
49
  .footer { margin-top: 30px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 7.5pt; color: #999; text-align: center; }
50
50
  .remarks { margin-top: 15px; padding: 8px; background: #fefce8; border-radius: 4px; font-size: 8pt; color: #713f12; }
51
+ /* Structured notes (remarks_html). A template can restyle these freely. */
52
+ .remarks .rm-block { overflow: hidden; margin-bottom: 8px; }
53
+ .remarks .rm-block:last-child { margin-bottom: 0; }
54
+ .remarks .rm-h { font-weight: 700; }
55
+ .remarks .rm-h + .rm-p, .remarks .rm-p + .rm-p { margin-top: 2px; }
56
+ .remarks .rm-amt { float: right; text-align: right; margin: 0 0 4px 16px; }
57
+ .remarks .rm-amt-val { white-space: nowrap; }
58
+ .remarks em { font-style: italic; }
59
+ .remarks strong { font-weight: 700; }
51
60
  </style>
52
61
  </head>
53
62
  <body>
@@ -182,8 +191,10 @@
182
191
  </div>
183
192
 
184
193
  {# ---- Remarks ---- #}
185
- {% if doc.remarks %}
186
- <div class="remarks">
194
+ {% if remarks_html is defined and remarks_html %}
195
+ <div class="remarks">{{ remarks_html | safe }}</div>
196
+ {% elif doc.remarks %}
197
+ <div class="remarks" style="white-space: pre-line;">
187
198
  <strong>Remarks:</strong> {{ doc.remarks }}
188
199
  </div>
189
200
  {% endif %}
@@ -179,6 +179,7 @@ class Database:
179
179
  self._is_memory = (db_path == ":memory:")
180
180
  self._local = threading.local()
181
181
  self._col_cache = {} # doctype -> set(columns); invalidated on ALTER
182
+ self._text_col_cache = {} # doctype -> set(text columns); invalidated on ALTER
182
183
  if self._is_memory:
183
184
  self._lock = threading.Lock()
184
185
  self._shared_conn = self._open_conn()
@@ -1347,6 +1348,7 @@ class Database:
1347
1348
  if column not in self._get_table_columns(table):
1348
1349
  self.conn.execute(self._ddl(f'ALTER TABLE "{table}" ADD COLUMN {column} {definition}'))
1349
1350
  self._col_cache.pop(table, None)
1351
+ self._text_col_cache.pop(table, None)
1350
1352
 
1351
1353
  def _migrate(self):
1352
1354
  """Run each pending migration in order, tracking applied versions."""
@@ -1517,6 +1519,34 @@ class Database:
1517
1519
  self._col_cache[doctype] = cols
1518
1520
  return cols
1519
1521
 
1522
+ def _get_text_columns(self, doctype):
1523
+ """Text/character columns of a table — the ones worth matching a
1524
+ free-text search against. Discovered from the live schema so new
1525
+ columns become searchable automatically. Cached; invalidated on ALTER."""
1526
+ cached = self._text_col_cache.get(doctype)
1527
+ if cached is not None:
1528
+ return cached
1529
+ if self.dialect == "postgres":
1530
+ rows = self.conn.execute(
1531
+ "SELECT column_name, data_type FROM information_schema.columns "
1532
+ "WHERE table_name = ?",
1533
+ [doctype],
1534
+ ).fetchall()
1535
+ cols = {
1536
+ row[0]
1537
+ for row in rows
1538
+ if "char" in (row[1] or "").lower() or (row[1] or "").lower() in ("text", "citext")
1539
+ }
1540
+ else:
1541
+ cursor = self.conn.execute(f'PRAGMA table_info("{doctype}")')
1542
+ cols = {
1543
+ row[1]
1544
+ for row in cursor.fetchall()
1545
+ if any(t in (row[2] or "").upper() for t in ("CHAR", "CLOB", "TEXT"))
1546
+ }
1547
+ self._text_col_cache[doctype] = cols
1548
+ return cols
1549
+
1520
1550
  def _select_fields(self, doctype, fields):
1521
1551
  """Build the SELECT column list, selecting only columns that exist and
1522
1552
  padding the rest as NULL. SQLite silently returns an unknown quoted
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.24"
3
+ version = "0.1.26"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes