lambda-erp 1.2.3__tar.gz → 1.3.1__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 (117) hide show
  1. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/PKG-INFO +1 -1
  2. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/auth.py +21 -2
  3. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/chat.py +114 -12
  4. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/erp_context.py +5 -0
  5. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/main.py +34 -25
  6. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/mentions.py +31 -5
  7. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/oauth.py +2 -2
  8. lambda_erp-1.3.1/api/record_notes.py +233 -0
  9. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/bank_statements.py +6 -3
  10. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/chat_api.py +22 -2
  11. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/masters.py +13 -22
  12. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/mcp.py +6 -3
  13. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/services.py +6 -0
  14. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_reconciliation.py +11 -47
  15. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/budget.py +10 -0
  16. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/revaluation.py +24 -4
  17. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/database.py +208 -1
  18. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/model.py +11 -1
  19. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/utils.py +7 -45
  20. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/pyproject.toml +1 -1
  21. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/.gitignore +0 -0
  22. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/LICENSE +0 -0
  23. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/README.md +0 -0
  24. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/__init__.py +0 -0
  25. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/attachments.py +0 -0
  26. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/bootstrap.py +0 -0
  27. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/chat_links.py +0 -0
  28. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/demo_limits.py +0 -0
  29. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/deps.py +0 -0
  30. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/errors.py +0 -0
  31. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/list_values.py +0 -0
  32. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/mcp_oauth.py +0 -0
  33. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf.py +0 -0
  34. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf_contract.py +0 -0
  35. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf_exports.py +0 -0
  36. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/pdf_profiles.py +0 -0
  37. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/providers.py +0 -0
  38. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/remarks_md.py +0 -0
  39. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/__init__.py +0 -0
  40. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/accounting.py +0 -0
  41. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/actions.py +0 -0
  42. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/admin.py +0 -0
  43. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/analytics.py +0 -0
  44. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/availability.py +0 -0
  45. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/bank_reconciliation.py +0 -0
  46. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/documents.py +0 -0
  47. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/proposals.py +0 -0
  48. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/reports.py +0 -0
  49. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/routers/setup.py +0 -0
  50. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/_proposal_totals.html +0 -0
  51. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/_record_base.html +0 -0
  52. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/document.html +0 -0
  53. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/proposal.html +0 -0
  54. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/templates/record.html +0 -0
  55. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/time_filters.py +0 -0
  56. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/tool_permissions.py +0 -0
  57. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/api/write_context.py +0 -0
  58. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/docs/agents/README.md +0 -0
  59. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/frontend/LICENSE +0 -0
  60. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/frontend/README.md +0 -0
  61. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/frontend/src/api/client.ts +0 -0
  62. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/__init__.py +0 -0
  63. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/__init__.py +0 -0
  64. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_account.py +0 -0
  65. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
  66. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/bank_transaction.py +0 -0
  67. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/camt.py +0 -0
  68. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  69. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/general_ledger.py +0 -0
  70. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/journal_entry.py +0 -0
  71. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/payment_entry.py +0 -0
  72. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/pos_invoice.py +0 -0
  73. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/purchase_invoice.py +0 -0
  74. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/sales_invoice.py +0 -0
  75. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/settlement.py +0 -0
  76. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/__init__.py +0 -0
  77. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/engine.py +0 -0
  78. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/pack.py +0 -0
  79. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  80. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  81. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  82. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  83. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  84. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  85. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/profiles.py +0 -0
  86. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/setup/spine.py +0 -0
  87. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/accounting/subscription.py +0 -0
  88. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/__init__.py +0 -0
  89. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/asset.py +0 -0
  90. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/lifecycle.py +0 -0
  91. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/assets/reservation.py +0 -0
  92. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/buying/__init__.py +0 -0
  93. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/buying/purchase_order.py +0 -0
  94. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/__init__.py +0 -0
  95. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/currency.py +0 -0
  96. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/defaults.py +0 -0
  97. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/item_price.py +0 -0
  98. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/item_prices.py +0 -0
  99. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/pricing_rule.py +0 -0
  100. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  101. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/exceptions.py +0 -0
  102. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/hooks.py +0 -0
  103. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/master_integrity.py +0 -0
  104. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/__init__.py +0 -0
  105. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/proposal.py +0 -0
  106. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/quotation.py +0 -0
  107. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/selling/sales_order.py +0 -0
  108. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/simulation.py +0 -0
  109. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/__init__.py +0 -0
  110. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/delivery_note.py +0 -0
  111. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/purchase_receipt.py +0 -0
  112. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/stock_entry.py +0 -0
  113. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/stock/stock_ledger.py +0 -0
  114. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/timestamps.py +0 -0
  115. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/validation.py +0 -0
  116. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/lambda_erp/workflow.py +0 -0
  117. {lambda_erp-1.2.3 → lambda_erp-1.3.1}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.2.3
3
+ Version: 1.3.1
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -35,12 +35,19 @@ def _resolve_jwt_secret() -> str:
35
35
  3. Newly-generated random secret (only used if the filesystem is
36
36
  read-only or inaccessible — cookies will be invalidated on
37
37
  restart in that degenerate case).
38
+
39
+ A Postgres deployment must set JWT_SECRET_KEY: there is no DB file to
40
+ sit next to, so the file would land on the container's own disk and
41
+ every replica and restart would sign with a different key, logging
42
+ users out at random.
38
43
  """
39
44
  env_secret = os.environ.get("JWT_SECRET_KEY")
40
45
  if env_secret:
41
46
  return env_secret
42
47
 
43
48
  db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
49
+ if db_path.startswith(("postgresql://", "postgres://")):
50
+ raise RuntimeError("JWT_SECRET_KEY must be set when LAMBDA_ERP_DB is a Postgres URL")
44
51
  data_dir = os.path.dirname(os.path.abspath(db_path)) or "."
45
52
  secret_file = os.path.join(data_dir, ".jwt_secret")
46
53
 
@@ -338,6 +345,19 @@ def validate_assignable_role(role: str) -> str:
338
345
  return role
339
346
 
340
347
 
348
+ def claim_invite(db, token: str) -> None:
349
+ """Mark an invite used, exactly once. The conditional UPDATE is the check:
350
+ two sign-ups racing on one invite both pass the earlier read, but only one
351
+ row update succeeds."""
352
+ with db._lock:
353
+ cursor = db.conn.execute(
354
+ 'UPDATE "Invite" SET used = 1 WHERE token = ? AND COALESCE(used, 0) = 0', [token])
355
+ claimed = cursor.rowcount == 1
356
+ db.conn.commit()
357
+ if not claimed:
358
+ raise HTTPException(status_code=410, detail="Invite already used")
359
+
360
+
341
361
  def require_role(minimum_role: str):
342
362
  """Returns a dependency that checks the user has at least the specified role."""
343
363
  min_level = ROLE_HIERARCHY[minimum_role]
@@ -450,8 +470,7 @@ def register(data: RegisterRequest, request: Request, response: Response):
450
470
  raise HTTPException(status_code=403, detail="This invite was issued for a different email")
451
471
 
452
472
  role = validate_assignable_role(invite["role"])
453
- db.sql('UPDATE "Invite" SET used = 1 WHERE token = ?', [data.invite_token])
454
- db.conn.commit()
473
+ claim_invite(db, data.invite_token)
455
474
  elif _setting_enabled(db, "allow_public_signup"):
456
475
  # Open signup is enabled: anyone may self-register, but only as a viewer.
457
476
  role = "viewer"
@@ -146,6 +146,74 @@ def can_access_session(session: dict | None, user: dict | None) -> bool:
146
146
  return session.get("user_id") == user.get("name")
147
147
 
148
148
 
149
+ # A session runs one agent turn at a time. The claim lives on the session row,
150
+ # so it holds across browser tabs, the REST chat API and app processes; a
151
+ # per-connection check let two turns build on the same history and both write.
152
+ # The deadline frees the session if the process running the turn died.
153
+ TURN_LEASE_SECONDS = 30 * 60
154
+
155
+
156
+ def _utc_iso(offset_seconds: int = 0) -> str:
157
+ from datetime import datetime, timedelta, timezone
158
+ return (datetime.now(timezone.utc) + timedelta(seconds=offset_seconds)).isoformat(timespec="seconds")
159
+
160
+
161
+ def claim_session_turn(session_id: str) -> str | None:
162
+ """Claim the session for one turn; return the claim token, or None while
163
+ another turn is still running on it."""
164
+ db = get_db()
165
+ token = uuid.uuid4().hex
166
+ with db._lock:
167
+ cursor = db.conn.execute(
168
+ 'UPDATE "Chat Session" SET active_turn = ?, active_turn_until = ? '
169
+ "WHERE id = ? AND (active_turn IS NULL OR active_turn_until IS NULL "
170
+ "OR active_turn_until < ?)",
171
+ [token, _utc_iso(TURN_LEASE_SECONDS), session_id, _utc_iso()],
172
+ )
173
+ claimed = cursor.rowcount == 1
174
+ db.conn.commit()
175
+ return token if claimed else None
176
+
177
+
178
+ def release_session_turn(session_id: str, token: str | None) -> None:
179
+ if not token:
180
+ return
181
+ db = get_db()
182
+ with db._lock:
183
+ db.conn.execute(
184
+ 'UPDATE "Chat Session" SET active_turn = NULL, active_turn_until = NULL '
185
+ "WHERE id = ? AND active_turn = ?",
186
+ [session_id, token],
187
+ )
188
+ db.conn.commit()
189
+
190
+
191
+ def session_turn_running(session_id: str) -> bool:
192
+ rows = get_db().sql(
193
+ 'SELECT active_turn_until FROM "Chat Session" WHERE id = ? AND active_turn IS NOT NULL',
194
+ [session_id],
195
+ )
196
+ return bool(rows and rows[0]["active_turn_until"] and rows[0]["active_turn_until"] >= _utc_iso())
197
+
198
+
199
+ # Turns whose socket closed keep running to completion (see chat_websocket);
200
+ # hold references so they aren't garbage-collected, and so shutdown can wait.
201
+ _DETACHED_TURNS: set[asyncio.Task] = set()
202
+
203
+
204
+ def detach_turn(task: asyncio.Task) -> None:
205
+ _DETACHED_TURNS.add(task)
206
+ task.add_done_callback(_DETACHED_TURNS.discard)
207
+
208
+
209
+ async def drain_turns(timeout: float) -> None:
210
+ """On shutdown, give turns that are still running time to finish and
211
+ persist their results instead of dying between a tool call and its reply."""
212
+ pending = [task for task in _DETACHED_TURNS if not task.done()]
213
+ if pending:
214
+ await asyncio.wait(pending, timeout=timeout)
215
+
216
+
149
217
  def delete_session(session_id: str):
150
218
  """Delete a chat session, its messages, and its attachments."""
151
219
  try:
@@ -440,6 +508,8 @@ def api_delete_session(session_id: str, user: dict = _Depends(get_current_user))
440
508
  session = get_session(session_id)
441
509
  if not can_access_session(session, user):
442
510
  return {"detail": "Session not found"}
511
+ if session_turn_running(session_id):
512
+ raise HTTPException(status_code=409, detail="Wait for the current response to finish before deleting this chat.")
443
513
  delete_session(session_id)
444
514
  return {"ok": True}
445
515
 
@@ -2650,6 +2720,9 @@ The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices re
2650
2720
  - Separate blocks with a blank line; a single newline is just a line break.
2651
2721
  Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
2652
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
+
2653
2726
  ### Purchase Cycle
2654
2727
  Purchase Order → Purchase Receipt (receiving) / Purchase Invoice (billing) → Payment Entry
2655
2728
 
@@ -4142,7 +4215,9 @@ async def chat_websocket(
4142
4215
  finally:
4143
4216
  session_tasks.pop(target_session_id, None)
4144
4217
 
4145
- async def process_session_message(target_session_id: str, user_content: str, attachment_ids: list | None = None):
4218
+ async def process_session_message(target_session_id: str, user_content: str,
4219
+ attachment_ids: list | None = None,
4220
+ turn_token: str | None = None):
4146
4221
  try:
4147
4222
  async def on_event(event: dict):
4148
4223
  await send_event(event["type"], session_id=target_session_id, **{
@@ -4169,7 +4244,9 @@ async def chat_websocket(
4169
4244
  except Exception as e:
4170
4245
  await send_error(str(e), session_id=target_session_id)
4171
4246
  finally:
4172
- session_tasks.pop(target_session_id, None)
4247
+ release_session_turn(target_session_id, turn_token)
4248
+ if session_tasks.get(target_session_id) is asyncio.current_task():
4249
+ session_tasks.pop(target_session_id, None)
4173
4250
 
4174
4251
  try:
4175
4252
  await send_event("sessions_list", sessions=list_sessions(user_id=ws_user_id, role=ws_user_role))
@@ -4240,7 +4317,7 @@ async def chat_websocket(
4240
4317
  continue
4241
4318
 
4242
4319
  active_task = session_tasks.get(session_id)
4243
- if active_task and not active_task.done():
4320
+ if (active_task and not active_task.done()) or session_turn_running(session_id):
4244
4321
  await send_error(
4245
4322
  "Wait for the current response to finish before deleting this chat.",
4246
4323
  request_id=request_id,
@@ -4263,7 +4340,7 @@ async def chat_websocket(
4263
4340
  continue
4264
4341
 
4265
4342
  active_task = session_tasks.get(session_id)
4266
- if active_task and not active_task.done():
4343
+ if (active_task and not active_task.done()) or session_turn_running(session_id):
4267
4344
  await send_error(
4268
4345
  "Wait for the current response to finish before clearing this chat.",
4269
4346
  request_id=request_id,
@@ -4347,7 +4424,8 @@ async def chat_websocket(
4347
4424
 
4348
4425
  session = get_session(session_id)
4349
4426
  await send_event("demo_started", request_id=request_id, session_id=session_id, title=session["title"] if session else "New Chat")
4350
- session_tasks[session_id] = asyncio.create_task(replay_demo_session(session_id))
4427
+ session_tasks[session_id] = asyncio.create_task(
4428
+ replay_demo_session(session_id), name=f"demo:{session_id}")
4351
4429
  continue
4352
4430
 
4353
4431
  if msg_type == "transcribe":
@@ -4503,10 +4581,24 @@ async def chat_websocket(
4503
4581
  if aid in session_attachments
4504
4582
  ]
4505
4583
 
4506
- save_chat_message(
4507
- session_id, "user", user_content or "",
4508
- metadata={"attachments": attached_meta} if attached_meta else None,
4509
- )
4584
+ # Claimed before the user message is stored, so a second tab (or
4585
+ # another app process) can't add a message to a running turn.
4586
+ turn_token = claim_session_turn(session_id)
4587
+ if not turn_token:
4588
+ await send_error(
4589
+ "Wait for the current response to finish before sending another message.",
4590
+ request_id=request_id,
4591
+ session_id=session_id,
4592
+ )
4593
+ continue
4594
+ try:
4595
+ save_chat_message(
4596
+ session_id, "user", user_content or "",
4597
+ metadata={"attachments": attached_meta} if attached_meta else None,
4598
+ )
4599
+ except BaseException:
4600
+ release_session_turn(session_id, turn_token)
4601
+ raise
4510
4602
  await send_message_added(
4511
4603
  session_id, "user", user_content or "",
4512
4604
  attachments=attached_meta if attached_meta else None,
@@ -4520,11 +4612,21 @@ async def chat_websocket(
4520
4612
  await send_event("thinking", session_id=session_id, iteration=1)
4521
4613
 
4522
4614
  session_tasks[session_id] = asyncio.create_task(
4523
- process_session_message(session_id, user_content, attachment_ids)
4615
+ process_session_message(session_id, user_content, attachment_ids, turn_token),
4616
+ name=f"turn:{session_id}",
4524
4617
  )
4525
4618
 
4526
4619
  except WebSocketDisconnect:
4527
- for task in session_tasks.values():
4528
- task.cancel()
4620
+ pass
4529
4621
  except Exception as e:
4530
4622
  await send_error(str(e))
4623
+ finally:
4624
+ # A demo replay is only presentation: stop it. An agent turn may be
4625
+ # between a tool's write and its result: cancelling it would commit the
4626
+ # write but lose the result and the reply, so the user retries and the
4627
+ # write happens twice. Let it finish; history shows it on reconnect.
4628
+ for task in list(session_tasks.values()):
4629
+ if task.get_name().startswith("demo:"):
4630
+ task.cancel()
4631
+ elif not task.done():
4632
+ detach_turn(task)
@@ -24,6 +24,11 @@ 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.
27
32
  Generate a fresh PDF after edits. Only a successful artifact descriptor proves generation;
28
33
  download that artifact, check its SHA-256 and deliver it through the calling application's
29
34
  attachment mechanism. Do not invent download URLs or reuse a stale document attachment.
@@ -51,41 +51,50 @@ def load_plugins() -> None:
51
51
  async def lifespan(app: FastAPI):
52
52
  # Startup: initialize database
53
53
  db_path = os.environ.get("LAMBDA_ERP_DB", "lambda_erp.db")
54
- setup(db_path)
54
+ db = setup(db_path)
55
55
 
56
- from api.mentions import initialize_mentions
57
- initialize_mentions()
56
+ # Plugin schema, migrations and seeding run one process at a time, like
57
+ # the core migrations inside setup(): replicas booting together must not
58
+ # race each other through them.
59
+ with db.startup_lock():
60
+ from api.mentions import initialize_mentions
61
+ initialize_mentions()
62
+ from api.record_notes import initialize_record_notes
63
+ initialize_record_notes()
58
64
 
59
- # Load customer extension plugins before anything creates documents.
60
- load_plugins()
65
+ # Load customer extension plugins before anything creates documents.
66
+ load_plugins()
61
67
 
62
- # Create plugin-registered tables and run their pending migrations. Must be
63
- # after load_plugins() (registrations happen in register()) and before any
64
- # document is created.
65
- from api.services import apply_plugin_schema
66
- apply_plugin_schema()
68
+ # Create plugin-registered tables and run their pending migrations. Must be
69
+ # after load_plugins() (registrations happen in register()) and before any
70
+ # document is created.
71
+ from api.services import apply_plugin_schema
72
+ apply_plugin_schema()
67
73
 
68
- from api.pdf_profiles import validate_pdf_registry
69
- validate_pdf_registry()
74
+ from api.pdf_profiles import validate_pdf_registry
75
+ validate_pdf_registry()
70
76
 
71
- # Ensure the demo spend log table exists before any LLM call happens.
72
- from api.demo_limits import init_schema as init_demo_spend_schema
73
- init_demo_spend_schema()
77
+ # Ensure the demo spend log table exists before any LLM call happens.
78
+ from api.demo_limits import init_schema as init_demo_spend_schema
79
+ init_demo_spend_schema()
74
80
 
75
- # Seed a deterministic admin from the environment (if configured) before
76
- # anyone can register. On deployments whose DB is recreated each rollout
77
- # this stops the first visitor after a redeploy from grabbing admin.
78
- from api.auth import ensure_seed_admin
79
- ensure_seed_admin()
81
+ # Seed a deterministic admin from the environment (if configured) before
82
+ # anyone can register. On deployments whose DB is recreated each rollout
83
+ # this stops the first visitor after a redeploy from grabbing admin.
84
+ from api.auth import ensure_seed_admin
85
+ ensure_seed_admin()
80
86
 
81
- # When packaged as a demo container, land visitors straight in demo mode.
82
- if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
83
- from api.bootstrap import bootstrap_demo
87
+ # When packaged as a demo container, land visitors straight in demo mode.
88
+ if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
89
+ from api.bootstrap import bootstrap_demo
84
90
 
85
- bootstrap_demo()
91
+ bootstrap_demo()
86
92
 
87
93
  yield
88
- # Shutdown: nothing to clean up (SQLite handles it)
94
+ # Shutdown: let chat turns whose sockets already closed finish and save
95
+ # their results. Stay inside the container's termination grace period.
96
+ from api.chat import drain_turns
97
+ await drain_turns(timeout=float(os.environ.get("LAMBDA_ERP_SHUTDOWN_DRAIN_SECONDS", "20")))
89
98
 
90
99
 
91
100
  app = FastAPI(
@@ -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 = SOURCES.get(doctype)
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": [{"doctype": key, "label": value["label"]} for key, value in SOURCES.items()]}
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 = SOURCES.get(row["doctype"])
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 = SOURCES.get(doctype)
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. 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. Generic CRM note writes 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."),
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",
@@ -47,6 +47,7 @@ from api.auth import (
47
47
  require_interactive_user,
48
48
  _is_https_request,
49
49
  validate_assignable_role,
50
+ claim_invite,
50
51
  _setting_enabled,
51
52
  )
52
53
 
@@ -362,8 +363,7 @@ def _consume_invite(db, invite_token: str, email: str) -> str:
362
363
  if invite["email"].lower() != email.lower():
363
364
  raise HTTPException(status_code=403, detail="This invite was issued for a different email")
364
365
  role = validate_assignable_role(invite["role"])
365
- db.sql('UPDATE "Invite" SET used = 1 WHERE token = ?', [invite_token])
366
- db.conn.commit()
366
+ claim_invite(db, invite_token)
367
367
  return role
368
368
 
369
369