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.
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/PKG-INFO +1 -1
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/chat.py +159 -11
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/pdf.py +6 -0
- lambda_erp-0.1.26/api/remarks_md.py +91 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/templates/document.html +13 -2
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/database.py +30 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/pyproject.toml +1 -1
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/.gitignore +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/LICENSE +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/README.md +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/attachments.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/auth.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/bootstrap.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/demo_limits.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/deps.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/errors.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/main.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/providers.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/analytics.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/proposals.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/services.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/api/templates/proposal.html +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/frontend/README.md +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.1.24 → lambda_erp-0.1.26}/terraform/README.md +0 -0
|
@@ -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,
|
|
1090
|
-
|
|
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
|
|
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}(
|
|
1097
|
-
[
|
|
1227
|
+
f'SELECT * FROM "{doctype}" WHERE {active_prefix}({where}) LIMIT 20',
|
|
1228
|
+
[pattern] * len(search_cols),
|
|
1098
1229
|
)
|
|
1099
|
-
|
|
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}.
|
|
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`
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|