lambda-erp 0.1.24__tar.gz → 0.1.25__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 (68) hide show
  1. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/chat.py +145 -9
  3. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/database.py +30 -0
  4. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/pyproject.toml +1 -1
  5. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/.gitignore +0 -0
  6. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/LICENSE +0 -0
  7. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/README.md +0 -0
  8. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/__init__.py +0 -0
  9. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/attachments.py +0 -0
  10. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/auth.py +0 -0
  11. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/bootstrap.py +0 -0
  12. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/demo_limits.py +0 -0
  13. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/deps.py +0 -0
  14. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/errors.py +0 -0
  15. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/main.py +0 -0
  16. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/pdf.py +0 -0
  17. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/providers.py +0 -0
  18. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/__init__.py +0 -0
  19. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/accounting.py +0 -0
  20. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/admin.py +0 -0
  21. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/analytics.py +0 -0
  22. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/bank_reconciliation.py +0 -0
  23. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/documents.py +0 -0
  24. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/masters.py +0 -0
  25. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/proposals.py +0 -0
  26. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/reports.py +0 -0
  27. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/routers/setup.py +0 -0
  28. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/services.py +0 -0
  29. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/templates/document.html +0 -0
  30. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/api/templates/proposal.html +0 -0
  31. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/docs/agents/README.md +0 -0
  32. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/frontend/README.md +0 -0
  33. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/frontend/src/api/client.ts +0 -0
  34. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/__init__.py +0 -0
  35. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/__init__.py +0 -0
  36. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/bank_transaction.py +0 -0
  37. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/budget.py +0 -0
  38. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  39. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/general_ledger.py +0 -0
  40. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/journal_entry.py +0 -0
  41. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/payment_entry.py +0 -0
  42. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/pos_invoice.py +0 -0
  43. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/purchase_invoice.py +0 -0
  44. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/revaluation.py +0 -0
  45. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/sales_invoice.py +0 -0
  46. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/accounting/subscription.py +0 -0
  47. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/buying/__init__.py +0 -0
  48. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/buying/purchase_order.py +0 -0
  49. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/controllers/__init__.py +0 -0
  50. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/controllers/currency.py +0 -0
  51. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/controllers/defaults.py +0 -0
  52. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/controllers/pricing_rule.py +0 -0
  53. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  54. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/exceptions.py +0 -0
  55. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/hooks.py +0 -0
  56. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/model.py +0 -0
  57. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/selling/__init__.py +0 -0
  58. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/selling/proposal.py +0 -0
  59. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/selling/quotation.py +0 -0
  60. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/selling/sales_order.py +0 -0
  61. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/simulation.py +0 -0
  62. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/stock/__init__.py +0 -0
  63. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/stock/delivery_note.py +0 -0
  64. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/stock/purchase_receipt.py +0 -0
  65. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/stock/stock_entry.py +0 -0
  66. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/stock/stock_ledger.py +0 -0
  67. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/lambda_erp/utils.py +0 -0
  68. {lambda_erp-0.1.24 → lambda_erp-0.1.25}/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.25
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 BEFORE passing `fields` to search_masters (or before guessing column names) so you target real fields instead of guessing. 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
  },
@@ -1077,26 +1097,139 @@ def _handle_convert_document(args):
1077
1097
  return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
1078
1098
 
1079
1099
 
1100
+ # Columns that are technically text but are noise for free-text master search.
1101
+ _MASTER_SEARCH_SKIP_COLUMNS = {
1102
+ "naming_series",
1103
+ "owner",
1104
+ "modified_by",
1105
+ "creation",
1106
+ "modified",
1107
+ "created_at",
1108
+ "updated_at",
1109
+ }
1110
+
1111
+ # Large free-text columns excluded from the DEFAULT search: scanning/fuzzing a
1112
+ # big blob (e.g. an item description or an HTML template) is costly and rarely
1113
+ # how you identify a record. Still searchable on demand via the `fields` arg.
1114
+ _MASTER_BULK_TEXT_COLUMNS = {"description", "notes", "remarks", "terms", "comments"}
1115
+
1116
+ # Minimum difflib similarity for a fuzzy (misspelled) match to be returned.
1117
+ _MASTER_FUZZY_THRESHOLD = 0.6
1118
+ # Bounds for the fuzzy scorer so a single large value can't blow up cost:
1119
+ # skip whole-value scoring past this length, and only score the first N tokens.
1120
+ _FUZZY_MAX_VALUE_LEN = 200
1121
+ _FUZZY_MAX_TOKENS = 16
1122
+
1123
+
1124
+ def _is_bulk_text_column(col):
1125
+ return (col in _MASTER_BULK_TEXT_COLUMNS
1126
+ or col.endswith("_template") or col.endswith("_html"))
1127
+
1128
+
1129
+ def _master_search_columns(db, doctype):
1130
+ """Text columns worth matching a query against by default. Discovered from
1131
+ the live schema (new text fields become searchable automatically), minus
1132
+ audit noise and large free-text/template columns."""
1133
+ cols = db._get_text_columns(doctype) - _MASTER_SEARCH_SKIP_COLUMNS
1134
+ # Deterministic order keeps generated SQL and fuzzy scoring stable.
1135
+ return sorted(c for c in cols if not _is_bulk_text_column(c))
1136
+
1137
+
1138
+ def _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit):
1139
+ """Fallback for misspellings: rank active rows by best per-field similarity.
1140
+
1141
+ Substring search misses typos ("Meynex" vs "medynex"), so when it finds
1142
+ nothing we pull a capped candidate set and score it in Python with difflib —
1143
+ portable across SQLite and Postgres, no DB extensions required."""
1144
+ where = "WHERE disabled = 0 " if has_disabled else ""
1145
+ candidates = db.sql(f'SELECT * FROM "{doctype}" {where}LIMIT 1000')
1146
+
1147
+ q = query.lower()
1148
+ scored = []
1149
+ for row in candidates:
1150
+ best = 0.0
1151
+ for col in search_cols:
1152
+ val = row.get(col)
1153
+ if not val:
1154
+ continue
1155
+ val = str(val).lower()
1156
+ # Skip whole-value scoring of big blobs (low ratio anyway, high cost).
1157
+ if len(val) <= _FUZZY_MAX_VALUE_LEN:
1158
+ best = max(best, difflib.SequenceMatcher(None, q, val).ratio())
1159
+ # Also score the first few words so a typo'd single token still
1160
+ # matches a multi-word field (e.g. "medynex" within "medynex ag").
1161
+ for token in val.split()[:_FUZZY_MAX_TOKENS]:
1162
+ best = max(best, difflib.SequenceMatcher(None, q, token).ratio())
1163
+ if best >= _MASTER_FUZZY_THRESHOLD:
1164
+ scored.append((best, row))
1165
+
1166
+ scored.sort(key=lambda pair: pair[0], reverse=True)
1167
+ return [dict(row) for _, row in scored[:limit]]
1168
+
1169
+
1170
+ def _handle_get_master_fields(args):
1171
+ db = get_db()
1172
+ master_type = args["master_type"]
1173
+ entry = services.MASTER_TABLES.get(master_type)
1174
+ if not entry:
1175
+ return {"error": f"Unknown master type: {master_type}"}
1176
+ doctype, _ = entry
1177
+ default_search = _master_search_columns(db, doctype)
1178
+ bulk = sorted(c for c in db._get_text_columns(doctype) if _is_bulk_text_column(c))
1179
+ return {
1180
+ "master_type": master_type,
1181
+ "fields": sorted(db._get_table_columns(doctype)),
1182
+ # What search_masters searches when `fields` is omitted.
1183
+ "default_search_fields": default_search,
1184
+ # Large text fields searched ONLY when named in search_masters `fields`.
1185
+ "bulk_text_fields": bulk,
1186
+ }
1187
+
1188
+
1080
1189
  def _handle_search_masters(args):
1081
1190
  db = get_db()
1082
1191
  master_type = args["master_type"]
1083
- query = args.get("query", "")
1192
+ query = (args.get("query") or "").strip()
1084
1193
 
1085
1194
  entry = services.MASTER_TABLES.get(master_type)
1086
1195
  if not entry:
1087
1196
  return {"error": f"Unknown master type: {master_type}"}
1088
1197
 
1089
- doctype, name_field = entry
1090
- active_prefix = 'disabled = 0 AND ' if "disabled" in db._get_table_columns(doctype) else ""
1198
+ doctype, _name_field = entry
1199
+ has_disabled = "disabled" in db._get_table_columns(doctype)
1200
+
1091
1201
  if not query:
1092
- filters = {"disabled": 0} if "disabled" in db._get_table_columns(doctype) else None
1202
+ filters = {"disabled": 0} if has_disabled else None
1093
1203
  return db.get_all(doctype, filters=filters, fields=["*"], limit=20)
1094
1204
 
1205
+ # Optional `fields` narrows the search to specific columns — cheaper, more
1206
+ # precise, and the only way to reach bulk columns (description, templates)
1207
+ # that the default search skips. Unknown names are ignored.
1208
+ requested = args.get("fields") or []
1209
+ if requested:
1210
+ valid = db._get_table_columns(doctype)
1211
+ search_cols = sorted(c for c in requested if c in valid)
1212
+ if not search_cols:
1213
+ return {"error": f"None of fields {requested} exist on {master_type}"}
1214
+ else:
1215
+ search_cols = _master_search_columns(db, doctype)
1216
+
1217
+ active_prefix = "disabled = 0 AND " if has_disabled else ""
1218
+
1219
+ # Case-insensitive substring match. lower() on both sides is portable (bare
1220
+ # LIKE is case-insensitive on SQLite but case-sensitive on Postgres, which
1221
+ # silently broke prod search); CAST lets targeted non-text columns match too.
1222
+ where = " OR ".join(f'lower(CAST("{col}" AS TEXT)) LIKE ?' for col in search_cols)
1223
+ pattern = f"%{query.lower()}%"
1095
1224
  rows = db.sql(
1096
- f'SELECT * FROM "{doctype}" WHERE {active_prefix}(name LIKE ? OR "{name_field}" LIKE ?) LIMIT 20',
1097
- [f"%{query}%", f"%{query}%"],
1225
+ f'SELECT * FROM "{doctype}" WHERE {active_prefix}({where}) LIMIT 20',
1226
+ [pattern] * len(search_cols),
1098
1227
  )
1099
- return [dict(r) for r in rows]
1228
+ if rows:
1229
+ return [dict(r) for r in rows]
1230
+
1231
+ # Nothing matched literally — try fuzzy matching to catch misspellings.
1232
+ return _fuzzy_master_search(db, doctype, search_cols, query, has_disabled, limit=20)
1100
1233
 
1101
1234
 
1102
1235
  def _ignored_master_fields(master_type: str, data: dict) -> list[str]:
@@ -1408,6 +1541,7 @@ TOOL_HANDLERS = {
1408
1541
  "cancel_document": _handle_cancel_document,
1409
1542
  "discard_document": _handle_discard_document,
1410
1543
  "convert_document": _handle_convert_document,
1544
+ "get_master_fields": _handle_get_master_fields,
1411
1545
  "search_masters": _handle_search_masters,
1412
1546
  "create_master": _handle_create_master,
1413
1547
  "update_master": _handle_update_master,
@@ -1726,7 +1860,9 @@ When you fill in `item_code`, `customer`, `supplier`, `warehouse`, `company`, et
1726
1860
 
1727
1861
  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
1862
  - `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.
1863
+ - 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.
1864
+ - **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`.
1865
+ - **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.
1730
1866
 
1731
1867
  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
1868
 
@@ -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.25"
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
File without changes