lambda-erp 1.3.0__py3-none-any.whl → 1.3.2__py3-none-any.whl
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.
- api/chat.py +45 -20
- api/erp_context.py +7 -0
- api/main.py +4 -0
- api/mentions.py +31 -5
- api/menu_layout.py +206 -0
- api/providers.py +18 -40
- api/record_notes.py +233 -0
- api/routers/masters.py +4 -1
- api/routers/mcp.py +1 -1
- lambda_erp/database.py +20 -0
- {lambda_erp-1.3.0.dist-info → lambda_erp-1.3.2.dist-info}/METADATA +6 -4
- {lambda_erp-1.3.0.dist-info → lambda_erp-1.3.2.dist-info}/RECORD +14 -12
- {lambda_erp-1.3.0.dist-info → lambda_erp-1.3.2.dist-info}/WHEEL +0 -0
- {lambda_erp-1.3.0.dist-info → lambda_erp-1.3.2.dist-info}/licenses/LICENSE +0 -0
api/chat.py
CHANGED
|
@@ -2720,6 +2720,9 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
|
|
|
2720
2720
|
- Separate blocks with a blank line; a single newline is just a line break.
|
|
2721
2721
|
Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
|
|
2722
2722
|
|
|
2723
|
+
### Internal notes (never printed)
|
|
2724
|
+
`remarks` is PRINTED and goes to the customer or supplier. Internal team remarks ("customer disputes line 2", "@Anna please check the price") belong in an internal note on the record instead: `add_record_note` (doctype = the record type slug, e.g. `sales-invoice` or `customer`; record_id = its name), read with `list_record_notes`, change with `update_record_note` / `delete_record_note`. Every document and master type supports them; they appear in the record's "Internal notes" card and never change the record. To notify a colleague, first `search_mention_users` and put their exact `mention_text` in the body. Never put internal remarks into `remarks`, and never copy internal notes into customer-facing text.
|
|
2725
|
+
|
|
2723
2726
|
### Purchase Cycle
|
|
2724
2727
|
Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
|
|
2725
2728
|
|
|
@@ -3280,6 +3283,15 @@ def _generate_report_spec_via_openai(
|
|
|
3280
3283
|
# ---------------------------------------------------------------------------
|
|
3281
3284
|
|
|
3282
3285
|
|
|
3286
|
+
# Small and cheap: a 3-6 word label. gpt-4.1-nano was retired by OpenAI and
|
|
3287
|
+
# every call failed silently, leaving all chats as "New Chat".
|
|
3288
|
+
TITLE_MODEL = "gpt-6-luna"
|
|
3289
|
+
|
|
3290
|
+
|
|
3291
|
+
def _title_model() -> str:
|
|
3292
|
+
return os.environ.get("LAMBDA_ERP_TITLE_MODEL") or TITLE_MODEL
|
|
3293
|
+
|
|
3294
|
+
|
|
3283
3295
|
async def generate_title(
|
|
3284
3296
|
session_id: str,
|
|
3285
3297
|
user_message: str,
|
|
@@ -3296,12 +3308,12 @@ async def generate_title(
|
|
|
3296
3308
|
if not api_key or api_key == "sk-your-key-here":
|
|
3297
3309
|
return
|
|
3298
3310
|
|
|
3311
|
+
title_model = _title_model()
|
|
3299
3312
|
try:
|
|
3300
3313
|
client = OpenAI(
|
|
3301
3314
|
api_key=api_key,
|
|
3302
3315
|
timeout=httpx.Timeout(30.0, connect=5.0),
|
|
3303
3316
|
)
|
|
3304
|
-
title_model = "gpt-4.1-nano"
|
|
3305
3317
|
reservation_id = None
|
|
3306
3318
|
if is_demo_role(user_role):
|
|
3307
3319
|
_blocked, reservation_id = demo_limiter.reserve(
|
|
@@ -3314,14 +3326,15 @@ async def generate_title(
|
|
|
3314
3326
|
|
|
3315
3327
|
settled = False
|
|
3316
3328
|
try:
|
|
3329
|
+
# Responses API with low effort: GPT-6 models reason, and the
|
|
3330
|
+
# output budget must leave room for that before the title itself.
|
|
3317
3331
|
response = await asyncio.to_thread(
|
|
3318
|
-
client.
|
|
3332
|
+
client.responses.create,
|
|
3319
3333
|
model=title_model,
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
max_completion_tokens=30,
|
|
3334
|
+
instructions="Generate a very short title (3-6 words, no quotes) for this chat based on the first exchange. Just the title, nothing else.",
|
|
3335
|
+
input=f"User: {user_message[:200]}\nAssistant: {assistant_message[:200]}",
|
|
3336
|
+
max_output_tokens=400,
|
|
3337
|
+
reasoning={"effort": "low"},
|
|
3325
3338
|
)
|
|
3326
3339
|
usage = getattr(response, "usage", None)
|
|
3327
3340
|
demo_limiter.settle(
|
|
@@ -3331,20 +3344,25 @@ async def generate_title(
|
|
|
3331
3344
|
role=user_role,
|
|
3332
3345
|
provider="openai",
|
|
3333
3346
|
model=title_model,
|
|
3334
|
-
prompt_tokens=int(getattr(usage, "
|
|
3335
|
-
completion_tokens=int(getattr(usage, "
|
|
3347
|
+
prompt_tokens=int(getattr(usage, "input_tokens", 0) or 0) if usage else 0,
|
|
3348
|
+
completion_tokens=int(getattr(usage, "output_tokens", 0) or 0) if usage else 0,
|
|
3336
3349
|
session_id=session_id,
|
|
3337
3350
|
)
|
|
3338
3351
|
settled = True
|
|
3339
3352
|
|
|
3340
|
-
title = response.
|
|
3353
|
+
title = (response.output_text or "").strip().strip('"\'')[:120]
|
|
3341
3354
|
if title:
|
|
3342
3355
|
update_session_title(session_id, title)
|
|
3356
|
+
else:
|
|
3357
|
+
logger.warning("[chat_title] model=%s returned no title (status=%s)",
|
|
3358
|
+
title_model, getattr(response, "status", None))
|
|
3343
3359
|
finally:
|
|
3344
3360
|
if not settled:
|
|
3345
3361
|
demo_limiter.release(reservation_id)
|
|
3346
|
-
except Exception:
|
|
3347
|
-
|
|
3362
|
+
except Exception as exc:
|
|
3363
|
+
# Best-effort, but never silent: a retired model once left every chat
|
|
3364
|
+
# untitled without a single log line.
|
|
3365
|
+
logger.warning("[chat_title] model=%s failed: %s", title_model, exc)
|
|
3348
3366
|
|
|
3349
3367
|
|
|
3350
3368
|
# ---------------------------------------------------------------------------
|
|
@@ -3353,9 +3371,15 @@ async def generate_title(
|
|
|
3353
3371
|
|
|
3354
3372
|
# GPT-6.1 Sol requires Responses for function tools and does not support
|
|
3355
3373
|
# reasoning effort "none", including tool-free wrap-ups after the step limit.
|
|
3374
|
+
# LAMBDA_ERP_CHAT_MODEL overrides it per deployment (e.g. to trial a cheaper
|
|
3375
|
+
# model on dev); see docs/llm-pricing.md.
|
|
3356
3376
|
ORCHESTRATOR_MODEL = "gpt-6.1-sol"
|
|
3357
3377
|
|
|
3358
3378
|
|
|
3379
|
+
def _chat_model() -> str:
|
|
3380
|
+
return os.environ.get("LAMBDA_ERP_CHAT_MODEL") or ORCHESTRATOR_MODEL
|
|
3381
|
+
|
|
3382
|
+
|
|
3359
3383
|
# ---------------------------------------------------------------------------
|
|
3360
3384
|
# LLM API selector: Responses API (default) vs Chat Completions
|
|
3361
3385
|
#
|
|
@@ -3370,8 +3394,9 @@ ORCHESTRATOR_MODEL = "gpt-6.1-sol"
|
|
|
3370
3394
|
# ---------------------------------------------------------------------------
|
|
3371
3395
|
|
|
3372
3396
|
def _use_responses_api() -> bool:
|
|
3373
|
-
# A legacy deployment flag must not route
|
|
3374
|
-
|
|
3397
|
+
# A legacy deployment flag must not route a GPT-6.1 model into an
|
|
3398
|
+
# unsupported API.
|
|
3399
|
+
if _chat_model().startswith("gpt-6.1-"):
|
|
3375
3400
|
return True
|
|
3376
3401
|
# Default: the Responses API (needed for Office attachments + native
|
|
3377
3402
|
# reasoning). Set ERP_CHAT_API=chat to fall back to Chat Completions.
|
|
@@ -3532,12 +3557,12 @@ def _shim_usage_from_responses(usage):
|
|
|
3532
3557
|
|
|
3533
3558
|
def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto"):
|
|
3534
3559
|
"""One orchestrator LLM turn. Returns (message, usage) shaped like Chat
|
|
3535
|
-
Completions.
|
|
3560
|
+
Completions. GPT-6.1 models always use Responses; others honor ERP_CHAT_API.
|
|
3536
3561
|
Blocking — call via asyncio.to_thread."""
|
|
3537
3562
|
if _use_responses_api():
|
|
3538
3563
|
instructions, input_items = _responses_request_from_chat(messages)
|
|
3539
3564
|
resp = client.responses.create(
|
|
3540
|
-
model=
|
|
3565
|
+
model=_chat_model(),
|
|
3541
3566
|
instructions=instructions,
|
|
3542
3567
|
input=input_items,
|
|
3543
3568
|
tools=_responses_tools_from_chat(tools),
|
|
@@ -3547,7 +3572,7 @@ def _orchestrator_turn(client, messages, tools, max_tokens, *, tool_choice="auto
|
|
|
3547
3572
|
)
|
|
3548
3573
|
return _shim_message_from_responses(resp), _shim_usage_from_responses(getattr(resp, "usage", None))
|
|
3549
3574
|
resp = client.chat.completions.create(
|
|
3550
|
-
model=
|
|
3575
|
+
model=_chat_model(),
|
|
3551
3576
|
messages=messages,
|
|
3552
3577
|
tools=tools,
|
|
3553
3578
|
tool_choice=tool_choice,
|
|
@@ -3712,7 +3737,7 @@ async def run_thinking_loop(
|
|
|
3712
3737
|
# on ANY exit path — including asyncio.CancelledError raised after
|
|
3713
3738
|
# the SDK call has already returned but before settle() ran.
|
|
3714
3739
|
settled = False
|
|
3715
|
-
model_name =
|
|
3740
|
+
model_name = _chat_model()
|
|
3716
3741
|
try:
|
|
3717
3742
|
print(f"[chat_llm] provider=openai model={model_name} session_id={session_id or '-'} iter={iteration + 1}", flush=True)
|
|
3718
3743
|
await on_event({"type": "llm_provider", "provider": "openai", "model": model_name})
|
|
@@ -3899,9 +3924,9 @@ async def run_thinking_loop(
|
|
|
3899
3924
|
tool_choice="none",
|
|
3900
3925
|
)
|
|
3901
3926
|
demo_limiter.settle(
|
|
3902
|
-
None, actual_cost_usd=cost_of_openai_call(
|
|
3927
|
+
None, actual_cost_usd=cost_of_openai_call(_chat_model(), usage),
|
|
3903
3928
|
ip=client_ip or "unknown", role=user_role, provider="openai",
|
|
3904
|
-
model=
|
|
3929
|
+
model=_chat_model(),
|
|
3905
3930
|
prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0) if usage else 0,
|
|
3906
3931
|
completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0) if usage else 0,
|
|
3907
3932
|
session_id=session_id,
|
api/erp_context.py
CHANGED
|
@@ -24,6 +24,13 @@ draft creation does not imply submission, stock movement or ledger posting. Chec
|
|
|
24
24
|
status and warnings. Correct validation errors using the field metadata and user information.
|
|
25
25
|
Use list/search filters and pagination; an empty or partial search is not proof of zero
|
|
26
26
|
stock. Use query_dataset/get_report for totals and stock, with the requested scope and dates.
|
|
27
|
+
Text for the customer or supplier goes in a document's printed `remarks` (Notes / Terms)
|
|
28
|
+
field. Internal team remarks ("customer disputes line 2", "@Anna please check") go in an
|
|
29
|
+
internal note instead (add_record_note / list_record_notes): never printed, visible to
|
|
30
|
+
signed-in colleagues only, and @mentions there notify colleagues. Never put internal
|
|
31
|
+
remarks into `remarks`, and never copy internal notes into a document the customer sees.
|
|
32
|
+
Requests about the user's own sidebar menu ("hide rentals", "put CRM first") use
|
|
33
|
+
get_menu_layout, then update_menu_layout. They change only that user's menu, not access.
|
|
27
34
|
Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
|
|
28
35
|
download that artifact, check its SHA-256 and deliver it through the calling application's
|
|
29
36
|
attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
|
api/main.py
CHANGED
|
@@ -59,6 +59,10 @@ async def lifespan(app: FastAPI):
|
|
|
59
59
|
with db.startup_lock():
|
|
60
60
|
from api.mentions import initialize_mentions
|
|
61
61
|
initialize_mentions()
|
|
62
|
+
from api.record_notes import initialize_record_notes
|
|
63
|
+
initialize_record_notes()
|
|
64
|
+
from api.menu_layout import initialize_menu_layout
|
|
65
|
+
initialize_menu_layout()
|
|
62
66
|
|
|
63
67
|
# Load customer extension plugins before anything creates documents.
|
|
64
68
|
load_plugins()
|
api/mentions.py
CHANGED
|
@@ -18,6 +18,7 @@ from lambda_erp.database import get_db
|
|
|
18
18
|
from lambda_erp.utils import now
|
|
19
19
|
|
|
20
20
|
SOURCES = {}
|
|
21
|
+
_FALLBACKS = [] # (resolve(doctype) -> source | None, list_types() -> [doctype]) for unregistered types
|
|
21
22
|
REAL_ROLES = {"viewer", "manager", "admin"}
|
|
22
23
|
_WRITING_MENTION_NOTE = ContextVar("writing_mention_note", default=False)
|
|
23
24
|
_MENTION_START = re.compile(r"(?<![\w@./+\-])@(?=\S)")
|
|
@@ -79,6 +80,31 @@ def register_mention_source(doctype, *, label, can_access, create_note, resolve_
|
|
|
79
80
|
create_note=create_note, resolve_note=resolve_note, resolve_notes=resolve_notes)
|
|
80
81
|
|
|
81
82
|
|
|
83
|
+
def register_mention_source_fallback(resolve, list_types):
|
|
84
|
+
"""Resolve sources for record types without an explicit registration
|
|
85
|
+
(api.record_notes uses this for every document and master type)."""
|
|
86
|
+
if all(entry[0] is not resolve for entry in _FALLBACKS):
|
|
87
|
+
_FALLBACKS.append((resolve, list_types))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def find_source(doctype):
|
|
91
|
+
source = SOURCES.get(doctype)
|
|
92
|
+
for resolve, _ in _FALLBACKS:
|
|
93
|
+
if source:
|
|
94
|
+
break
|
|
95
|
+
source = resolve(doctype)
|
|
96
|
+
return source
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def supported_sources():
|
|
100
|
+
sources = {key: value["label"] for key, value in SOURCES.items()}
|
|
101
|
+
for resolve, list_types in _FALLBACKS:
|
|
102
|
+
for doctype in list_types():
|
|
103
|
+
if doctype not in sources and (source := resolve(doctype)):
|
|
104
|
+
sources[doctype] = source["label"]
|
|
105
|
+
return [{"doctype": key, "label": label} for key, label in sources.items()]
|
|
106
|
+
|
|
107
|
+
|
|
82
108
|
class Input(BaseModel):
|
|
83
109
|
model_config = ConfigDict(extra="forbid", strict=True)
|
|
84
110
|
|
|
@@ -121,7 +147,7 @@ def _identity(actor):
|
|
|
121
147
|
|
|
122
148
|
|
|
123
149
|
def _source(doctype):
|
|
124
|
-
source =
|
|
150
|
+
source = find_source(doctype)
|
|
125
151
|
if not source:
|
|
126
152
|
raise ValueError("This record type does not support mention notes.")
|
|
127
153
|
return source
|
|
@@ -141,7 +167,7 @@ def search_mention_users(args, user):
|
|
|
141
167
|
token = full_name if full_name and len(aliases.get(full_name.lower(), {})) == 1 else row["email"]
|
|
142
168
|
row["mention_text"] = "@" + token
|
|
143
169
|
return {"users": rows, "acting_user": _identity(actor),
|
|
144
|
-
"sources":
|
|
170
|
+
"sources": supported_sources()}
|
|
145
171
|
|
|
146
172
|
|
|
147
173
|
def add_record_note(args, user):
|
|
@@ -221,7 +247,7 @@ def sync_note_mentions(doctype, record_id, note_id, body, user):
|
|
|
221
247
|
|
|
222
248
|
|
|
223
249
|
def _visible(row, actor, notes=None, authors=None):
|
|
224
|
-
source =
|
|
250
|
+
source = find_source(row["doctype"])
|
|
225
251
|
note = (notes.get((row["doctype"], row["note_id"])) if notes is not None else
|
|
226
252
|
source["resolve_note"](row["note_id"], actor) if source else None)
|
|
227
253
|
if not note:
|
|
@@ -242,7 +268,7 @@ def list_my_mentions(args, user):
|
|
|
242
268
|
# source and author lookups so polling a populated inbox avoids N+1 queries.
|
|
243
269
|
notes, authors = {}, {}
|
|
244
270
|
for doctype in {row["doctype"] for row in rows}:
|
|
245
|
-
source =
|
|
271
|
+
source = find_source(doctype)
|
|
246
272
|
if not source:
|
|
247
273
|
continue
|
|
248
274
|
ids = list({row["note_id"] for row in rows if row["doctype"] == doctype})
|
|
@@ -301,7 +327,7 @@ def initialize_mentions():
|
|
|
301
327
|
("search_mention_users", search_mention_users, Search, "viewer",
|
|
302
328
|
"Find enabled ERP colleagues for visible @mentions by name/email. Returns mention_text: insert that exact text in the note body to tag the person. Also returns canonical user IDs, acting_user and supported record types. Do not guess a person when ambiguous."),
|
|
303
329
|
("add_record_note", add_record_note, Note, "manager",
|
|
304
|
-
"Save an internal note and notify people visibly @mentioned in its body, including yourself.
|
|
330
|
+
"Save an internal team note on a record and notify people visibly @mentioned in its body, including yourself. doctype is the record type slug: any document or master type (e.g. sales-invoice, purchase-order, customer, supplier, item) or a plugin type listed in search_mention_users sources; record_id is the record's name. Internal notes are never printed and do not change the record; customer-facing text belongs in the document's printed `remarks` (Notes / Terms) field instead. A note needs no mention. To tag someone, first use search_mention_users, then include each recipient's exact mention_text in body, e.g. 'salü vom johnny @Jon Torus'. Recipients are determined only from body; there is no recipient-ID argument. Unknown @handles and ordinary emails remain text. Ambiguous names require @email. CRM lead notes written through the generic document tools use the same mention processing. Use a new request_id (UUID) per intended note; reuse it unchanged on retries. No email is sent. Authorship is the authenticated ERP account."),
|
|
305
331
|
("list_my_mentions", list_my_mentions, Inbox, "viewer",
|
|
306
332
|
"List the authenticated ERP user's mentions, with record/note links. open means not done; unread is separate. Use this for 'my mentions', never guess or pass another user. Returns acting_user, unread_count and pagination."),
|
|
307
333
|
("set_mention_state", set_mention_state, State, "viewer",
|
api/menu_layout.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Personal sidebar menu: hide and reorder groups and items, per user.
|
|
2
|
+
|
|
3
|
+
The menu itself is defined by the frontend (core groups plus whatever plugins
|
|
4
|
+
register), so the web app publishes what it shows to each user as the
|
|
5
|
+
`nav_catalog` preference. Reads and writes here validate against that catalog;
|
|
6
|
+
the layout is stored as the `nav_layout` preference. Hiding only tidies the
|
|
7
|
+
menu: routes and permissions are unchanged.
|
|
8
|
+
"""
|
|
9
|
+
import json
|
|
10
|
+
|
|
11
|
+
from pydantic import Field
|
|
12
|
+
|
|
13
|
+
from api import mentions, services
|
|
14
|
+
from lambda_erp.database import get_db
|
|
15
|
+
|
|
16
|
+
CATALOG_KEY = "nav_catalog"
|
|
17
|
+
LAYOUT_KEY = "nav_layout"
|
|
18
|
+
# The page that edits the menu can never be hidden, or a user could lock
|
|
19
|
+
# themselves out of undoing it. Its group therefore can't be hidden either.
|
|
20
|
+
LOCKED_ITEM = "/settings/menu"
|
|
21
|
+
REF = Field(min_length=1, max_length=200)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _actor(user):
|
|
25
|
+
if (user or {}).get("_shared_connection"):
|
|
26
|
+
raise PermissionError("The menu is personal. The shared team key identifies its owner, "
|
|
27
|
+
"not the current user; connect with a personal ERP account.")
|
|
28
|
+
from api.auth import refresh_auth_principal
|
|
29
|
+
actor = refresh_auth_principal(user)
|
|
30
|
+
if not actor or actor.get("role") not in mentions.REAL_ROLES:
|
|
31
|
+
raise PermissionError("Sign in with a personal ERP account to change your menu.")
|
|
32
|
+
return actor.get("user_id") or actor["name"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read(user_name, key, default):
|
|
36
|
+
rows = get_db().sql('SELECT value FROM "User Preference" WHERE user_name = ? AND key = ?',
|
|
37
|
+
[user_name, key])
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(rows[0]["value"]) if rows else default
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
return default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write(user_name, key, value):
|
|
45
|
+
db, text = get_db(), json.dumps(value, separators=(",", ":"))
|
|
46
|
+
with db.atomic():
|
|
47
|
+
if db.sql('SELECT 1 FROM "User Preference" WHERE user_name = ? AND key = ?', [user_name, key]):
|
|
48
|
+
db.sql('UPDATE "User Preference" SET value = ? WHERE user_name = ? AND key = ?',
|
|
49
|
+
[text, user_name, key])
|
|
50
|
+
else:
|
|
51
|
+
db.sql('INSERT INTO "User Preference" (user_name, key, value) VALUES (?, ?, ?)',
|
|
52
|
+
[user_name, key, text])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _layout(user_name):
|
|
56
|
+
raw = _read(user_name, LAYOUT_KEY, {})
|
|
57
|
+
raw = raw if isinstance(raw, dict) else {}
|
|
58
|
+
items = raw.get("items") if isinstance(raw.get("items"), dict) else {}
|
|
59
|
+
return {"order": [k for k in raw.get("order") or [] if isinstance(k, str)],
|
|
60
|
+
"items": {g: [k for k in v if isinstance(k, str)] for g, v in items.items() if isinstance(v, list)},
|
|
61
|
+
"hidden": [k for k in raw.get("hidden") or [] if isinstance(k, str)]}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _catalog(user_name):
|
|
65
|
+
raw = _read(user_name, CATALOG_KEY, [])
|
|
66
|
+
groups = []
|
|
67
|
+
for group in raw if isinstance(raw, list) else []:
|
|
68
|
+
if not isinstance(group, dict) or not isinstance(group.get("key"), str):
|
|
69
|
+
continue
|
|
70
|
+
items = [{"key": i["key"], "label": str(i.get("label") or i["key"]), "title": str(i.get("title") or "")}
|
|
71
|
+
for i in group.get("items") or [] if isinstance(i, dict) and isinstance(i.get("key"), str)]
|
|
72
|
+
groups.append({"key": group["key"], "label": str(group.get("label") or group["key"]),
|
|
73
|
+
"title": str(group.get("title") or ""), "items": items})
|
|
74
|
+
return groups
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _ordered(default, preferred):
|
|
78
|
+
"""Keys named in `preferred` first, in that order; the rest keep their default order."""
|
|
79
|
+
first = [k for k in dict.fromkeys(preferred) if k in default]
|
|
80
|
+
return first + [k for k in default if k not in first]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def effective_menu(catalog, layout):
|
|
84
|
+
hidden = set(layout["hidden"])
|
|
85
|
+
by_key = {g["key"]: g for g in catalog}
|
|
86
|
+
menu = []
|
|
87
|
+
for key in _ordered([g["key"] for g in catalog], layout["order"]):
|
|
88
|
+
group = by_key[key]
|
|
89
|
+
items = {i["key"]: i for i in group["items"]}
|
|
90
|
+
menu.append({**{k: group[k] for k in ("key", "label", "title")}, "hidden": key in hidden,
|
|
91
|
+
"items": [{**items[i], "hidden": i in hidden, "locked": i == LOCKED_ITEM}
|
|
92
|
+
for i in _ordered(list(items), layout["items"].get(key, []))],
|
|
93
|
+
"locked": LOCKED_ITEM in items})
|
|
94
|
+
return menu
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolver(catalog):
|
|
98
|
+
"""Map a reference (group key, item path, or an English or translated label) to a key."""
|
|
99
|
+
exact, named = {}, {}
|
|
100
|
+
for group in catalog:
|
|
101
|
+
exact[group["key"]] = ("group", group["key"], None)
|
|
102
|
+
for item in group["items"]:
|
|
103
|
+
exact[item["key"]] = ("item", item["key"], group["key"])
|
|
104
|
+
for group in catalog:
|
|
105
|
+
refs = [(group, "group", None)] + [(item, "item", group["key"]) for item in group["items"]]
|
|
106
|
+
for entry, kind, parent in refs:
|
|
107
|
+
for name in {entry["label"].casefold(), entry["title"].casefold()} - {""}:
|
|
108
|
+
named.setdefault(name, set()).add((kind, entry["key"], parent))
|
|
109
|
+
|
|
110
|
+
def resolve(ref):
|
|
111
|
+
if ref in exact:
|
|
112
|
+
return exact[ref]
|
|
113
|
+
matches = named.get(ref.strip().casefold(), set())
|
|
114
|
+
if len(matches) == 1:
|
|
115
|
+
return next(iter(matches))
|
|
116
|
+
if matches:
|
|
117
|
+
raise ValueError(f"{ref!r} is ambiguous: use one of the keys "
|
|
118
|
+
f"{', '.join(sorted(m[1] for m in matches))}.")
|
|
119
|
+
raise ValueError(f"Unknown menu entry {ref!r}. Call get_menu_layout for the available "
|
|
120
|
+
"groups and items.")
|
|
121
|
+
return resolve
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class GetLayout(mentions.Input):
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class UpdateLayout(mentions.Input):
|
|
129
|
+
hide: list[str] = Field(default_factory=list, max_length=200)
|
|
130
|
+
show: list[str] = Field(default_factory=list, max_length=200)
|
|
131
|
+
group_order: list[str] | None = Field(default=None, max_length=200)
|
|
132
|
+
item_order: dict[str, list[str]] = Field(default_factory=dict, max_length=100)
|
|
133
|
+
reset: bool = False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _response(catalog, layout, note=None):
|
|
137
|
+
result = {"menu": effective_menu(catalog, layout), "layout": layout}
|
|
138
|
+
if not catalog:
|
|
139
|
+
result["note"] = ("Your menu is not known yet: open the ERP web app once, then try again.")
|
|
140
|
+
if note:
|
|
141
|
+
result["note"] = note
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def get_menu_layout(args, user):
|
|
146
|
+
GetLayout.model_validate(args)
|
|
147
|
+
user_name = _actor(user)
|
|
148
|
+
return _response(_catalog(user_name), _layout(user_name))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def update_menu_layout(args, user):
|
|
152
|
+
data, user_name = UpdateLayout.model_validate(args), _actor(user)
|
|
153
|
+
catalog = _catalog(user_name)
|
|
154
|
+
if not catalog:
|
|
155
|
+
raise ValueError("Your menu is not known yet: open the ERP web app once, then try again.")
|
|
156
|
+
resolve = _resolver(catalog)
|
|
157
|
+
layout = {"order": [], "items": {}, "hidden": []} if data.reset else _layout(user_name)
|
|
158
|
+
hidden = dict.fromkeys(layout["hidden"])
|
|
159
|
+
for ref in data.show:
|
|
160
|
+
hidden.pop(resolve(ref)[1], None)
|
|
161
|
+
locked_group = next((g["key"] for g in catalog if any(i["key"] == LOCKED_ITEM for i in g["items"])), None)
|
|
162
|
+
for ref in data.hide:
|
|
163
|
+
kind, key, _ = resolve(ref)
|
|
164
|
+
if key == LOCKED_ITEM or key == locked_group:
|
|
165
|
+
raise ValueError(f"{ref!r} can't be hidden: it holds the menu settings page. "
|
|
166
|
+
"Hide the other items in it instead.")
|
|
167
|
+
hidden[key] = None
|
|
168
|
+
layout["hidden"] = list(hidden)
|
|
169
|
+
if data.group_order is not None:
|
|
170
|
+
keys = [resolve(ref) for ref in data.group_order]
|
|
171
|
+
if any(kind != "group" for kind, _, _ in keys):
|
|
172
|
+
raise ValueError("group_order takes groups only; reorder items with item_order.")
|
|
173
|
+
layout["order"] = [key for _, key, _ in keys]
|
|
174
|
+
for group_ref, refs in data.item_order.items():
|
|
175
|
+
kind, group, _ = resolve(group_ref)
|
|
176
|
+
if kind != "group":
|
|
177
|
+
raise ValueError(f"item_order keys are groups; {group_ref!r} is an item.")
|
|
178
|
+
keys = [resolve(ref) for ref in refs]
|
|
179
|
+
if any(kind != "item" or parent != group for kind, _, parent in keys):
|
|
180
|
+
raise ValueError(f"item_order[{group_ref!r}] may only list items of that group; "
|
|
181
|
+
"items can't move between groups.")
|
|
182
|
+
layout["items"][group] = [key for _, key, _ in keys]
|
|
183
|
+
_write(user_name, LAYOUT_KEY, layout)
|
|
184
|
+
return _response(catalog, layout)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def initialize_menu_layout():
|
|
188
|
+
services.register_action(
|
|
189
|
+
"get_menu_layout", get_menu_layout, minimum_role="viewer", pass_user=True,
|
|
190
|
+
parameters=GetLayout.model_json_schema(),
|
|
191
|
+
description=(
|
|
192
|
+
"Show the current user's personal sidebar menu: every group and item in display order, "
|
|
193
|
+
"with its key, English label, label in the user's language (title) and whether it is "
|
|
194
|
+
"hidden. Call this before update_menu_layout."))
|
|
195
|
+
services.register_action(
|
|
196
|
+
"update_menu_layout", update_menu_layout, minimum_role="viewer", pass_user=True,
|
|
197
|
+
parameters=UpdateLayout.model_json_schema(),
|
|
198
|
+
description=(
|
|
199
|
+
"Hide, show or reorder entries in the current user's own sidebar menu (no one else's). "
|
|
200
|
+
"Refer to an entry by its key from get_menu_layout (a group label such as 'Rentals', or "
|
|
201
|
+
"an item path such as '/app/asset') or by its label. hide/show take groups or items; "
|
|
202
|
+
"hiding a group hides all of it. group_order lists groups to put first, in that order "
|
|
203
|
+
"(the rest follow); item_order maps a group to its items in the new order. Items stay "
|
|
204
|
+
"in their group. reset=true restores the default menu before applying the rest. The "
|
|
205
|
+
"menu settings entry can't be hidden. Hiding only tidies the menu: pages stay "
|
|
206
|
+
"reachable and permissions are unchanged."))
|
api/providers.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Pricing data for external LLM APIs the chat orchestrator uses.
|
|
2
2
|
|
|
3
|
-
Mirrors the shape of `lambda-web/backend/providers.py`.
|
|
4
|
-
|
|
3
|
+
Mirrors the shape of `lambda-web/backend/providers.py`. Lists the OpenAI models
|
|
4
|
+
available to us. Prices are Standard USD per 1M tokens;
|
|
5
5
|
see docs/llm-pricing.md for sources and billing scope.
|
|
6
6
|
|
|
7
7
|
Consumers import `cost_of_openai_call` to turn an SDK `response.usage` object
|
|
@@ -19,60 +19,38 @@ from typing import Any, Optional
|
|
|
19
19
|
# ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
21
|
OPENAI_PRICING: dict[str, dict[str, Any]] = {
|
|
22
|
-
#
|
|
23
|
-
# the higher rates apply to the entire request
|
|
22
|
+
# The models available to us, Standard tier, verified 2026-10-30. Above
|
|
23
|
+
# 272K input tokens the higher rates apply to the entire request
|
|
24
|
+
# (including cache hits). cache_write is the 30-minute retention rate,
|
|
25
|
+
# cache_write_12h the 12-hour one.
|
|
24
26
|
"gpt-6-astra": {
|
|
25
27
|
"tiered": True,
|
|
26
28
|
"tiers": [
|
|
27
|
-
{"max_input_tokens": 272_000, "input": 10.00, "cached_input": 1.00, "cache_write": 12.50, "output": 50.00},
|
|
28
|
-
{"max_input_tokens": None, "input": 20.00, "cached_input": 2.00, "cache_write": 25.00, "output": 75.00},
|
|
29
|
-
],
|
|
30
|
-
},
|
|
31
|
-
"gpt-6-sol": {
|
|
32
|
-
"tiered": True,
|
|
33
|
-
"tiers": [
|
|
34
|
-
{"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.20, "cache_write": 2.50, "output": 10.00},
|
|
35
|
-
{"max_input_tokens": None, "input": 4.00, "cached_input": 0.40, "cache_write": 5.00, "output": 15.00},
|
|
36
|
-
],
|
|
37
|
-
},
|
|
38
|
-
"gpt-6-luna": {
|
|
39
|
-
"tiered": True,
|
|
40
|
-
"tiers": [
|
|
41
|
-
{"max_input_tokens": 272_000, "input": 0.10, "cached_input": 0.01, "cache_write": 0.125, "output": 0.50},
|
|
42
|
-
{"max_input_tokens": None, "input": 0.20, "cached_input": 0.02, "cache_write": 0.25, "output": 0.75},
|
|
29
|
+
{"max_input_tokens": 272_000, "input": 10.00, "cached_input": 1.00, "cache_write": 12.50, "cache_write_12h": 25.00, "output": 50.00},
|
|
30
|
+
{"max_input_tokens": None, "input": 20.00, "cached_input": 2.00, "cache_write": 25.00, "cache_write_12h": 50.00, "output": 75.00},
|
|
43
31
|
],
|
|
44
32
|
},
|
|
45
33
|
"gpt-6.1-sol": {
|
|
46
34
|
"tiered": True,
|
|
47
35
|
"tiers": [
|
|
48
|
-
{"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "output": 10.00},
|
|
49
|
-
{"max_input_tokens": None, "input": 4.00, "cached_input": 0.20, "cache_write": 5.00, "output": 15.00},
|
|
36
|
+
{"max_input_tokens": 272_000, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "cache_write_12h": 5.00, "output": 10.00},
|
|
37
|
+
{"max_input_tokens": None, "input": 4.00, "cached_input": 0.20, "cache_write": 5.00, "cache_write_12h": 10.00, "output": 15.00},
|
|
50
38
|
],
|
|
51
39
|
},
|
|
52
|
-
"gpt-
|
|
40
|
+
"gpt-6.1-terra": {
|
|
53
41
|
"tiered": True,
|
|
54
42
|
"tiers": [
|
|
55
|
-
{"max_input_tokens": 272_000, "input":
|
|
56
|
-
{"max_input_tokens": None, "input":
|
|
43
|
+
{"max_input_tokens": 272_000, "input": 1.00, "cached_input": 0.05, "cache_write": 1.25, "cache_write_12h": 2.50, "output": 5.00},
|
|
44
|
+
{"max_input_tokens": None, "input": 2.00, "cached_input": 0.10, "cache_write": 2.50, "cache_write_12h": 5.00, "output": 7.50},
|
|
57
45
|
],
|
|
58
46
|
},
|
|
59
|
-
|
|
60
|
-
# output above 272K input; the 5.6 family also prices cache writes).
|
|
61
|
-
"gpt-5.6-terra": {
|
|
47
|
+
"gpt-6-luna": {
|
|
62
48
|
"tiered": True,
|
|
63
49
|
"tiers": [
|
|
64
|
-
{"max_input_tokens": 272_000, "input":
|
|
65
|
-
{"max_input_tokens": None, "input":
|
|
50
|
+
{"max_input_tokens": 272_000, "input": 0.10, "cached_input": 0.01, "cache_write": 0.125, "cache_write_12h": 0.25, "output": 0.50},
|
|
51
|
+
{"max_input_tokens": None, "input": 0.20, "cached_input": 0.02, "cache_write": 0.25, "cache_write_12h": 0.50, "output": 0.75},
|
|
66
52
|
],
|
|
67
53
|
},
|
|
68
|
-
"gpt-4.1-nano": {"input": 0.10, "cached_input": 0.025, "output": 0.40},
|
|
69
|
-
"gpt-4.1-mini": {"input": 0.40, "cached_input": 0.10, "output": 1.60},
|
|
70
|
-
"gpt-4.1": {"input": 2.00, "cached_input": 0.50, "output": 8.00},
|
|
71
|
-
"gpt-5": {"input": 1.25, "cached_input": 0.125, "output": 10.00},
|
|
72
|
-
"gpt-5-mini": {"input": 0.25, "cached_input": 0.025, "output": 2.00},
|
|
73
|
-
"gpt-5-nano": {"input": 0.05, "cached_input": 0.005, "output": 0.40},
|
|
74
|
-
"gpt-4o": {"input": 2.50, "cached_input": 1.25, "output": 10.00},
|
|
75
|
-
"gpt-4o-mini": {"input": 0.15, "cached_input": 0.075, "output": 0.60},
|
|
76
54
|
}
|
|
77
55
|
|
|
78
56
|
# Speech-to-text, priced per minute of audio (not per token).
|
|
@@ -84,14 +62,14 @@ TRANSCRIBE_PRICING: dict[str, dict[str, float]] = {
|
|
|
84
62
|
|
|
85
63
|
|
|
86
64
|
# ---------------------------------------------------------------------------
|
|
87
|
-
# Rate lookup —
|
|
65
|
+
# Rate lookup — unknown model IDs are estimated at the gpt-6.1-sol rates.
|
|
88
66
|
# Register a model explicitly before using it; this fallback is not a price cap.
|
|
89
67
|
# ---------------------------------------------------------------------------
|
|
90
68
|
|
|
91
69
|
def get_openai_rates(model: str, input_tokens: int = 0) -> dict[str, float]:
|
|
92
70
|
entry = OPENAI_PRICING.get(model)
|
|
93
71
|
if entry is None:
|
|
94
|
-
entry = OPENAI_PRICING["gpt-
|
|
72
|
+
entry = OPENAI_PRICING["gpt-6.1-sol"]
|
|
95
73
|
if entry.get("tiered"):
|
|
96
74
|
for tier in entry["tiers"]:
|
|
97
75
|
cap = tier.get("max_input_tokens")
|
api/record_notes.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Internal notes on any ERP document or master record.
|
|
2
|
+
|
|
3
|
+
Notes are private to the team: they never print and never change the record
|
|
4
|
+
itself (no `modified` bump, so open forms are not marked stale). They plug into
|
|
5
|
+
the personal mention inbox as a fallback source, so every document and master
|
|
6
|
+
type supports @mentions without registering anything. Record types with their
|
|
7
|
+
own notes (a plugin's explicit mention source, e.g. CRM leads) keep theirs.
|
|
8
|
+
"""
|
|
9
|
+
import uuid
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
from pydantic import Field
|
|
14
|
+
|
|
15
|
+
from api import mentions, services
|
|
16
|
+
from lambda_erp.database import get_db
|
|
17
|
+
from lambda_erp.utils import now
|
|
18
|
+
|
|
19
|
+
TABLE = "Record Note"
|
|
20
|
+
_DISABLED: set[str] = set()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def disable_record_notes(record_type):
|
|
24
|
+
"""Opt a record type out, e.g. one whose rows are themselves notes."""
|
|
25
|
+
_DISABLED.add(record_type)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _target(record_type):
|
|
29
|
+
"""(table, kind) for a record type that takes generic notes, else None."""
|
|
30
|
+
if record_type in _DISABLED or record_type in mentions.SOURCES:
|
|
31
|
+
return None
|
|
32
|
+
doctype = services.SLUG_TO_DOCTYPE.get(record_type)
|
|
33
|
+
if doctype:
|
|
34
|
+
return doctype, "document"
|
|
35
|
+
master = services.MASTER_TABLES.get(record_type)
|
|
36
|
+
if master:
|
|
37
|
+
return master[0], "master"
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def record_types():
|
|
42
|
+
return sorted({*services.SLUG_TO_DOCTYPE, *services.MASTER_TABLES} - _DISABLED - mentions.SOURCES.keys())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _reader(user):
|
|
46
|
+
return (user or {}).get("role") in mentions.REAL_ROLES
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _existing(table, ids):
|
|
50
|
+
db, found = get_db(), {}
|
|
51
|
+
ids = list(ids)
|
|
52
|
+
for start in range(0, len(ids), 250):
|
|
53
|
+
chunk = ids[start:start + 250]
|
|
54
|
+
found.update({row["name"]: row for row in db.sql(
|
|
55
|
+
f'SELECT * FROM "{table}" WHERE name IN (' + ','.join('?' for _ in chunk) + ')', chunk)})
|
|
56
|
+
return found
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _title(record_type, record):
|
|
60
|
+
table, kind = _target(record_type)
|
|
61
|
+
if kind == "master":
|
|
62
|
+
label = record.get(services.MASTER_TABLES[record_type][1])
|
|
63
|
+
return f"{table} {record['name']}" + (f" · {label}" if label and label != record["name"] else "")
|
|
64
|
+
party = record.get("customer_name") or record.get("supplier_name") or record.get("party_name")
|
|
65
|
+
return f"{table} {record['name']}" + (f" · {party}" if party else "")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def view_url(record_type, record_id, note_id=None):
|
|
69
|
+
_, kind = _target(record_type)
|
|
70
|
+
base = "/app" if kind == "document" else "/masters"
|
|
71
|
+
url = f"{base}/{record_type}/{quote(record_id, safe='')}"
|
|
72
|
+
return url + (f"#note-{note_id}" if note_id else "")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _can_access(record_type, record_id, user):
|
|
76
|
+
target = _target(record_type)
|
|
77
|
+
return bool(target and _reader(user) and get_db().exists(target[0], record_id))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _create(record_type, record_id, body, user):
|
|
81
|
+
note_id, stamp = uuid.uuid4().hex, now()
|
|
82
|
+
get_db().insert(TABLE, {
|
|
83
|
+
"name": note_id, "record_type": record_type, "record_id": record_id, "body": body,
|
|
84
|
+
"author_id": user.get("user_id") or user["name"], "created_at": stamp, "updated_at": stamp,
|
|
85
|
+
})
|
|
86
|
+
return note_id
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _resolve(record_type, note_ids, user):
|
|
90
|
+
target = _target(record_type)
|
|
91
|
+
if not note_ids or not target or not _reader(user):
|
|
92
|
+
return {}
|
|
93
|
+
rows = get_db().sql(f'SELECT name, record_id, body FROM "{TABLE}" WHERE record_type = ? AND name IN ('
|
|
94
|
+
+ ','.join('?' for _ in note_ids) + ')', [record_type, *note_ids])
|
|
95
|
+
records = _existing(target[0], {row["record_id"] for row in rows})
|
|
96
|
+
return {row["name"]: {
|
|
97
|
+
"record_id": row["record_id"], "title": _title(record_type, records[row["record_id"]]),
|
|
98
|
+
"body": row["body"], "view_url": view_url(record_type, row["record_id"], row["name"]),
|
|
99
|
+
} for row in rows if row["record_id"] in records}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _source(record_type):
|
|
103
|
+
target = _target(record_type)
|
|
104
|
+
if not target:
|
|
105
|
+
return None
|
|
106
|
+
return dict(
|
|
107
|
+
label=target[0],
|
|
108
|
+
can_access=lambda record_id, user: _can_access(record_type, record_id, user),
|
|
109
|
+
create_note=lambda record_id, body, user: _create(record_type, record_id, body, user),
|
|
110
|
+
resolve_note=lambda note_id, user: _resolve(record_type, [note_id], user).get(note_id),
|
|
111
|
+
resolve_notes=lambda note_ids, user: _resolve(record_type, note_ids, user),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ListNotes(mentions.Input):
|
|
116
|
+
record_type: str = Field(min_length=1, max_length=100)
|
|
117
|
+
record_id: str = Field(min_length=1, max_length=200)
|
|
118
|
+
limit: int = Field(default=50, ge=1, le=200)
|
|
119
|
+
offset: int = Field(default=0, ge=0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class EditNote(mentions.Input):
|
|
123
|
+
note_id: str = Field(min_length=1, max_length=100)
|
|
124
|
+
body: str = Field(min_length=1, max_length=8000)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class DeleteNote(mentions.Input):
|
|
128
|
+
note_id: str = Field(min_length=1, max_length=100)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _require_record(record_type, record_id, user):
|
|
132
|
+
if not _target(record_type):
|
|
133
|
+
raise ValueError(f"{record_type!r} does not take internal notes. "
|
|
134
|
+
f"Supported record types: {', '.join(record_types())}.")
|
|
135
|
+
if not _can_access(record_type, record_id, user):
|
|
136
|
+
raise HTTPException(404, "Record is unavailable")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def list_record_notes(args, user):
|
|
140
|
+
data = ListNotes.model_validate(args)
|
|
141
|
+
if not _reader(user):
|
|
142
|
+
raise PermissionError("Sign in with an ERP account to read internal notes.")
|
|
143
|
+
_require_record(data.record_type, data.record_id, user)
|
|
144
|
+
db, me = get_db(), user.get("user_id") or user.get("name")
|
|
145
|
+
where = [data.record_type, data.record_id]
|
|
146
|
+
total = db.sql(f'SELECT COUNT(*) AS n FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', where)[0]["n"]
|
|
147
|
+
rows = db.sql(f'SELECT * FROM "{TABLE}" WHERE record_type = ? AND record_id = ? '
|
|
148
|
+
'ORDER BY created_at DESC, name DESC LIMIT ? OFFSET ?', [*where, data.limit, data.offset])
|
|
149
|
+
authors = _existing("User", {row["author_id"] for row in rows})
|
|
150
|
+
notes = [{
|
|
151
|
+
"id": row["name"], "body": row["body"], "author_id": row["author_id"],
|
|
152
|
+
"author_name": (authors.get(row["author_id"]) or {}).get("full_name")
|
|
153
|
+
or (authors.get(row["author_id"]) or {}).get("email") or row["author_id"],
|
|
154
|
+
"created_at": row["created_at"], "updated_at": row["updated_at"],
|
|
155
|
+
"edited": row["updated_at"] != row["created_at"],
|
|
156
|
+
"can_edit": row["author_id"] == me,
|
|
157
|
+
"can_delete": row["author_id"] == me or user.get("role") == "admin",
|
|
158
|
+
"view_url": view_url(data.record_type, data.record_id, row["name"]),
|
|
159
|
+
} for row in rows]
|
|
160
|
+
return {"record_type": data.record_type, "record_id": data.record_id, "notes": notes,
|
|
161
|
+
"total": total, "has_more": data.offset + len(notes) < total}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _load_note(note_id):
|
|
165
|
+
rows = get_db().sql(f'SELECT * FROM "{TABLE}" WHERE name = ?', [note_id])
|
|
166
|
+
if not rows:
|
|
167
|
+
raise HTTPException(404, "Note not found")
|
|
168
|
+
return rows[0]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def update_record_note(args, user):
|
|
172
|
+
data, actor = EditNote.model_validate(args), mentions._actor(user)
|
|
173
|
+
body = data.body.strip()
|
|
174
|
+
if not body:
|
|
175
|
+
raise ValueError("Note body must not be blank.")
|
|
176
|
+
db = get_db()
|
|
177
|
+
with db.atomic():
|
|
178
|
+
note = _load_note(data.note_id)
|
|
179
|
+
_require_record(note["record_type"], note["record_id"], actor)
|
|
180
|
+
if note["author_id"] != actor["user_id"]:
|
|
181
|
+
raise PermissionError("Only the author can edit a note.")
|
|
182
|
+
db.sql(f'UPDATE "{TABLE}" SET body = ?, updated_at = ? WHERE name = ?', [body, now(), note["name"]])
|
|
183
|
+
notified = mentions.sync_note_mentions(note["record_type"], note["record_id"], note["name"], body, actor)
|
|
184
|
+
return {"note_id": note["name"], "notified_count": notified,
|
|
185
|
+
"view_url": view_url(note["record_type"], note["record_id"], note["name"])}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def delete_record_note(args, user):
|
|
189
|
+
data, actor = DeleteNote.model_validate(args), mentions._actor(user)
|
|
190
|
+
db = get_db()
|
|
191
|
+
with db.atomic():
|
|
192
|
+
note = _load_note(data.note_id)
|
|
193
|
+
_require_record(note["record_type"], note["record_id"], actor)
|
|
194
|
+
if note["author_id"] != actor["user_id"] and actor["role"] != "admin":
|
|
195
|
+
raise PermissionError("Only the author or an admin can delete a note.")
|
|
196
|
+
db.sql('DELETE FROM "Mention Notification" WHERE doctype = ? AND note_id = ?',
|
|
197
|
+
[note["record_type"], note["name"]])
|
|
198
|
+
db.sql(f'DELETE FROM "{TABLE}" WHERE name = ?', [note["name"]])
|
|
199
|
+
return {"success": True, "note_id": note["name"]}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def delete_record_notes_for(record_type, record_id):
|
|
203
|
+
"""Drop a hard-deleted record's notes and their inbox entries."""
|
|
204
|
+
db = get_db()
|
|
205
|
+
ids = [row["name"] for row in db.sql(
|
|
206
|
+
f'SELECT name FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', [record_type, record_id])]
|
|
207
|
+
for start in range(0, len(ids), 250):
|
|
208
|
+
chunk = ids[start:start + 250]
|
|
209
|
+
db.sql('DELETE FROM "Mention Notification" WHERE doctype = ? AND note_id IN ('
|
|
210
|
+
+ ','.join('?' for _ in chunk) + ')', [record_type, *chunk])
|
|
211
|
+
db.sql(f'DELETE FROM "{TABLE}" WHERE record_type = ? AND record_id = ?', [record_type, record_id])
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
_NOT_PRINTED = ("Internal notes are visible to signed-in colleagues only; they are never printed and "
|
|
215
|
+
"never change the record. Not the printed `remarks` (Notes / Terms) field.")
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def initialize_record_notes():
|
|
219
|
+
mentions.register_mention_source_fallback(_source, record_types)
|
|
220
|
+
for name, handler, schema, role, description in (
|
|
221
|
+
("list_record_notes", list_record_notes, ListNotes, "viewer",
|
|
222
|
+
"List the internal team notes on one document or master record, newest first. record_type is the "
|
|
223
|
+
"document or master type slug (e.g. sales-invoice, customer) and record_id its name. "
|
|
224
|
+
+ _NOT_PRINTED),
|
|
225
|
+
("update_record_note", update_record_note, EditNote, "manager",
|
|
226
|
+
"Replace the text of an internal note you wrote (note_id from list_record_notes). @mentions are "
|
|
227
|
+
"re-read from the new text: added colleagues are notified, removed ones lose the notification."),
|
|
228
|
+
("delete_record_note", delete_record_note, DeleteNote, "manager",
|
|
229
|
+
"Delete an internal note. Authors can delete their own notes; admins can delete any. Also removes "
|
|
230
|
+
"its mention notifications."),
|
|
231
|
+
):
|
|
232
|
+
services.register_action(name, handler, description=description,
|
|
233
|
+
parameters=schema.model_json_schema(), minimum_role=role, pass_user=True)
|
api/routers/masters.py
CHANGED
|
@@ -748,7 +748,10 @@ def delete_master_record(master_type: str, name: str) -> dict:
|
|
|
748
748
|
return {"ok": True, "status": "disabled", "reason": f"Referenced by {reference}"}
|
|
749
749
|
raise HTTPException(status_code=409, detail=f"Cannot delete {doctype} {name}: referenced by {reference}")
|
|
750
750
|
|
|
751
|
-
|
|
751
|
+
from api.record_notes import delete_record_notes_for
|
|
752
|
+
with db.atomic():
|
|
753
|
+
delete_record_notes_for(master_type, name)
|
|
754
|
+
db.delete(doctype, name=name)
|
|
752
755
|
return {"ok": True, "status": "deleted"}
|
|
753
756
|
|
|
754
757
|
|
api/routers/mcp.py
CHANGED
|
@@ -50,7 +50,7 @@ _EXCLUDE = {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
_READ_ONLY = {
|
|
53
|
-
'search_mention_users', 'list_my_mentions',
|
|
53
|
+
'search_mention_users', 'list_my_mentions', 'list_record_notes', 'get_menu_layout',
|
|
54
54
|
'get_erp_context',
|
|
55
55
|
'list_documents', 'get_document_fields', 'get_document', 'get_master_fields',
|
|
56
56
|
'search_masters', 'get_report', 'get_current_time', 'query_dataset',
|
lambda_erp/database.py
CHANGED
|
@@ -3063,6 +3063,25 @@ def _m041_chat_turn_lease(db: "Database") -> None:
|
|
|
3063
3063
|
db.ensure_column("Chat Session", "active_turn_until", "TEXT")
|
|
3064
3064
|
|
|
3065
3065
|
|
|
3066
|
+
def _m042_record_notes(db: "Database") -> None:
|
|
3067
|
+
"""Internal team notes on any document or master (see api.record_notes)."""
|
|
3068
|
+
db.conn.execute(
|
|
3069
|
+
"""CREATE TABLE IF NOT EXISTS "Record Note" (
|
|
3070
|
+
name TEXT PRIMARY KEY,
|
|
3071
|
+
record_type TEXT NOT NULL,
|
|
3072
|
+
record_id TEXT NOT NULL,
|
|
3073
|
+
body TEXT NOT NULL,
|
|
3074
|
+
author_id TEXT NOT NULL,
|
|
3075
|
+
created_at TEXT NOT NULL,
|
|
3076
|
+
updated_at TEXT NOT NULL
|
|
3077
|
+
)"""
|
|
3078
|
+
)
|
|
3079
|
+
db.conn.execute(
|
|
3080
|
+
'CREATE INDEX IF NOT EXISTS record_note_record ON "Record Note" (record_type, record_id, created_at)'
|
|
3081
|
+
)
|
|
3082
|
+
db.conn.commit()
|
|
3083
|
+
|
|
3084
|
+
|
|
3066
3085
|
Database.MIGRATIONS = [
|
|
3067
3086
|
(1, "chat_message_session_id", _m001_chat_message_session_id),
|
|
3068
3087
|
(2, "chat_session_user_id", _m002_chat_session_user_id),
|
|
@@ -3105,6 +3124,7 @@ Database.MIGRATIONS = [
|
|
|
3105
3124
|
(39, "connection_app_type", _m039_connection_app_type),
|
|
3106
3125
|
(40, "naming_series", _m040_naming_series),
|
|
3107
3126
|
(41, "chat_turn_lease", _m041_chat_turn_lease),
|
|
3127
|
+
(42, "record_notes", _m042_record_notes),
|
|
3108
3128
|
]
|
|
3109
3129
|
|
|
3110
3130
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lambda-erp
|
|
3
|
-
Version: 1.3.
|
|
3
|
+
Version: 1.3.2
|
|
4
4
|
Summary: Core ERP logic - accounting, sales, purchasing, inventory
|
|
5
5
|
Author: TORUS INVESTMENTS AG
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -261,15 +261,17 @@ Open `http://localhost:5173`. Vite proxies `/api/*` to the backend.
|
|
|
261
261
|
|
|
262
262
|
```
|
|
263
263
|
OPENAI_API_KEY=sk-...
|
|
264
|
+
LAMBDA_ERP_CHAT_MODEL=gpt-6.1-sol # optional chat override; default shown
|
|
264
265
|
LAMBDA_ERP_REPORT_MODEL=gpt-6.1-sol # optional specialist override; default shown
|
|
266
|
+
LAMBDA_ERP_TITLE_MODEL=gpt-6-luna # optional chat-title override; default shown
|
|
265
267
|
LAMBDA_ERP_ADMIN_EMAIL=admin@example.com # optional, seeds the admin at boot
|
|
266
268
|
LAMBDA_ERP_ADMIN_PASSWORD=... # optional, required with the line above
|
|
267
269
|
LAMBDA_ERP_ADMIN_NAME=Administrator # optional, display name for the seeded admin
|
|
268
270
|
```
|
|
269
271
|
|
|
270
|
-
Chat and custom-report specification generation share
|
|
271
|
-
|
|
272
|
-
|
|
272
|
+
Chat, chat titles and custom-report specification generation share
|
|
273
|
+
`OPENAI_API_KEY`. The chat and the report specialist default to GPT-6.1 Sol,
|
|
274
|
+
titles to GPT-6 Luna; see [docs/llm-pricing.md](docs/llm-pricing.md) for prices.
|
|
273
275
|
|
|
274
276
|
**Seeded admin.** By default the first person to register becomes the admin. Set `LAMBDA_ERP_ADMIN_EMAIL` + `LAMBDA_ERP_ADMIN_PASSWORD` to instead provision that admin automatically at startup — useful when the database is recreated on every deploy, so a redeployed instance can't be claimed by whoever visits first. It's create-if-missing and idempotent (an existing account with that email is promoted to an enabled admin; its password is left untouched), and the password is only ever read from the environment.
|
|
275
277
|
|
|
@@ -2,22 +2,24 @@ api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
2
2
|
api/attachments.py,sha256=v5WAGUafYqA4tHWkPHHnP5pYxsJx9wDf6Mn1fTyUmYc,20496
|
|
3
3
|
api/auth.py,sha256=IcZnUBMv_6u4CrEPENUtsV2DdLQ2rgZGzgpbGXX7s4o,44073
|
|
4
4
|
api/bootstrap.py,sha256=9r6Bz93Puhz06d8C5LsbYk2NUt2Gr6Nz85FJxOWriCc,21308
|
|
5
|
-
api/chat.py,sha256=
|
|
5
|
+
api/chat.py,sha256=4VPRhHwVxT91LwrP6VMFkK-If8ghe8U7EScOufuO0UI,246998
|
|
6
6
|
api/chat_links.py,sha256=eHuGxuMvWh42-UC3i97GVxV04NemXGMQDUhfoKiLO-A,2344
|
|
7
7
|
api/demo_limits.py,sha256=b5PSNWgPIRuIVsAcJZyGLCivUv4RorEzJlZXf5LNwh0,14191
|
|
8
8
|
api/deps.py,sha256=HaFNz0q0NlX49kNMs1xMe0rrO6Slqpt8-TlPOATirT4,140
|
|
9
|
-
api/erp_context.py,sha256=
|
|
9
|
+
api/erp_context.py,sha256=c_Jkik6iPtZr__fLU81rHtYCB9EcROhYjJu_f-v8xoo,5092
|
|
10
10
|
api/errors.py,sha256=kEC6BUU4koPlyGoCDobAA8sHRoMqw50Xtc2105ZFKF4,5376
|
|
11
11
|
api/list_values.py,sha256=mGe74gJ4fHPa5X2djJNTsRE8txa0hG40GAxMfTumU_w,963
|
|
12
|
-
api/main.py,sha256=
|
|
12
|
+
api/main.py,sha256=jBqyJGtswyf7MBBtdbDDKYvlR4oxF2hjbuD96XJmODg,11532
|
|
13
13
|
api/mcp_oauth.py,sha256=MjmScAvihkvEXdmM3QJP0FLqqXq-pQ1zmWa67LiArDM,21392
|
|
14
|
-
api/mentions.py,sha256=
|
|
14
|
+
api/mentions.py,sha256=4XS8yEjVroIlxdfxCxiu7WIxtaekrG6Z7iqCs-6DqLY,18104
|
|
15
|
+
api/menu_layout.py,sha256=0uqL2Co1SX7_XqVdtjovL70UWOS0qaJfwr_0PeM0CE4,9574
|
|
15
16
|
api/oauth.py,sha256=w1XO__oQVQse0S3U03DYLN-_jUPOTliZ4Grr1Nhs4Fs,22450
|
|
16
17
|
api/pdf.py,sha256=HnJzRYVx6D43ULvYCb2ehwqrGVvaqbzT1vsNg1Gxg7M,19228
|
|
17
18
|
api/pdf_contract.py,sha256=cEUntXz951OaXTvGdhYnL58LbR_efowGGHhNEBP7y5Q,16118
|
|
18
19
|
api/pdf_exports.py,sha256=bFdfJVInkCH3BV8e9PpBLF9sOX2OkcFnNO0emuVnrps,3199
|
|
19
20
|
api/pdf_profiles.py,sha256=gqMRM-T6nzQnJ0DTxUf9MBFtpJws07MpNUQHHgI3Y7c,9206
|
|
20
|
-
api/providers.py,sha256=
|
|
21
|
+
api/providers.py,sha256=3B4P4J6Ox-biA8lGsGWR4AaAkz8SI4TmF68rsv6kRNQ,5468
|
|
22
|
+
api/record_notes.py,sha256=ohaQcWsCSxgRDha9wc8Lk7nOUS0p4uqolv_Or_EuEd4,10320
|
|
21
23
|
api/remarks_md.py,sha256=8MZ5oO3TNLDbjdMF2PjdFW13LdAffogOxT2nT0eYrVY,3754
|
|
22
24
|
api/services.py,sha256=F-7O9faPulVFV11RNLquxmPli1dEezUMztsip8h5-E4,61682
|
|
23
25
|
api/time_filters.py,sha256=nAlyyZD7i3UOMq8kJdLGo2-jKYl84IZ8ad-oYBTjg1c,4882
|
|
@@ -33,8 +35,8 @@ api/routers/bank_reconciliation.py,sha256=USv3-WEsgmXvpXALkwVvE9E536hYQ8Wdlr3c1z
|
|
|
33
35
|
api/routers/bank_statements.py,sha256=QCUW0Fu3nBnPuM9zky3ne_7FEkKoCEitibheBP0EJ1I,4678
|
|
34
36
|
api/routers/chat_api.py,sha256=AdnKm5XtrdMnaMhhwYm_Ck8bvh8RnExtP9K0z2o47eo,8050
|
|
35
37
|
api/routers/documents.py,sha256=Ww3bL-BN0uIYPRg6UO-Gq3YjtC-nX2DUYNKOqkZOn0Q,11736
|
|
36
|
-
api/routers/masters.py,sha256=
|
|
37
|
-
api/routers/mcp.py,sha256=
|
|
38
|
+
api/routers/masters.py,sha256=C18L7i9r7jiZuDXjC6uJLOph2KqP1iQVqV2Q-wzfO0E,38831
|
|
39
|
+
api/routers/mcp.py,sha256=hiHKEIJH2ec7ex_SOLccRsbSzYComuy-Fh696D7cuSY,11208
|
|
38
40
|
api/routers/proposals.py,sha256=KtIITJ-GHa3GSMzSDE205nnFNvMwt0KUVAxU1M8-uyU,4520
|
|
39
41
|
api/routers/reports.py,sha256=6GLlUJQfstS2p_7F5Il_XYwmZIifSHqaVjFdxTQM1OU,29738
|
|
40
42
|
api/routers/setup.py,sha256=4qVLO0TExVCTGORDI4dNMUyxauvhLRbUS1VydnSubNw,16206
|
|
@@ -44,7 +46,7 @@ api/templates/document.html,sha256=6B1fhdrgq2WaEHmpcGR_LkbZ7hNU6wggLt-a9ojO5Pw,9
|
|
|
44
46
|
api/templates/proposal.html,sha256=sx2qRsfl0kp8VeqgYrU_4MVs7rokWavfuDH0fU71ppU,5169
|
|
45
47
|
api/templates/record.html,sha256=aPCWz4uB1ZBEf8KTFHeChMAbWgObEvx3VuhV1aC4x7A,34
|
|
46
48
|
lambda_erp/__init__.py,sha256=ahOPqWyRzyy70oKnhA95TZnOWOCSaOZIjGSQ-qLojro,1088
|
|
47
|
-
lambda_erp/database.py,sha256=
|
|
49
|
+
lambda_erp/database.py,sha256=WGgbWy5palYdohL8LPN4OJcCNdwskj7pKHlUJMuIz8I,136933
|
|
48
50
|
lambda_erp/exceptions.py,sha256=ml-iuIefPDTNKqsOJCbdipstaAEKK5re9djIG-UFDxw,516
|
|
49
51
|
lambda_erp/hooks.py,sha256=koA8a_kNGVIknIhDWjqaq_LI0OYS9Tjt3JT08O5XJcc,1369
|
|
50
52
|
lambda_erp/master_integrity.py,sha256=xGonkRsJZn92CrBcj8oFBnNnzIncIR1C3fuTOM3UrOw,1191
|
|
@@ -104,7 +106,7 @@ lambda_erp/stock/delivery_note.py,sha256=Z3s57gVA7oSIpLauSd3DIWu-1y3tmvEE-zsopTv
|
|
|
104
106
|
lambda_erp/stock/purchase_receipt.py,sha256=E42oHRO4MAowQniLumbOjYY38vVfHbHchkxCWyM3KDg,11463
|
|
105
107
|
lambda_erp/stock/stock_entry.py,sha256=Itbt9a1u8b1s1OItKwVYMJDLjbNze2-UI2raIemCuts,12493
|
|
106
108
|
lambda_erp/stock/stock_ledger.py,sha256=AFybuScWwfaTvsqOSAon3gwsjIza7skXvrvyQowL7b0,13265
|
|
107
|
-
lambda_erp-1.3.
|
|
108
|
-
lambda_erp-1.3.
|
|
109
|
-
lambda_erp-1.3.
|
|
110
|
-
lambda_erp-1.3.
|
|
109
|
+
lambda_erp-1.3.2.dist-info/METADATA,sha256=kg58R1l0PQtbb_xN_n9ccGliyrW1YHjGKIDK0pXQOJI,32030
|
|
110
|
+
lambda_erp-1.3.2.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
111
|
+
lambda_erp-1.3.2.dist-info/licenses/LICENSE,sha256=8H08P22jiM0mJ97SgnpYchU0NeaPunGxZc3IqEaLgtc,11394
|
|
112
|
+
lambda_erp-1.3.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|