lambda-erp 1.2.3__tar.gz → 1.3.0__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 (116) hide show
  1. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/PKG-INFO +1 -1
  2. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/auth.py +21 -2
  3. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/chat.py +111 -12
  4. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/main.py +32 -25
  5. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/oauth.py +2 -2
  6. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/bank_statements.py +6 -3
  7. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/chat_api.py +22 -2
  8. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/masters.py +9 -21
  9. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/mcp.py +5 -2
  10. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/services.py +6 -0
  11. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_reconciliation.py +11 -47
  12. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/budget.py +10 -0
  13. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/revaluation.py +24 -4
  14. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/database.py +188 -1
  15. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/model.py +11 -1
  16. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/utils.py +7 -45
  17. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/pyproject.toml +1 -1
  18. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/.gitignore +0 -0
  19. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/LICENSE +0 -0
  20. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/README.md +0 -0
  21. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/__init__.py +0 -0
  22. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/attachments.py +0 -0
  23. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/bootstrap.py +0 -0
  24. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/chat_links.py +0 -0
  25. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/demo_limits.py +0 -0
  26. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/deps.py +0 -0
  27. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/erp_context.py +0 -0
  28. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/errors.py +0 -0
  29. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/list_values.py +0 -0
  30. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/mcp_oauth.py +0 -0
  31. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/mentions.py +0 -0
  32. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/pdf.py +0 -0
  33. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/pdf_contract.py +0 -0
  34. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/pdf_exports.py +0 -0
  35. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/pdf_profiles.py +0 -0
  36. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/providers.py +0 -0
  37. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/remarks_md.py +0 -0
  38. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/__init__.py +0 -0
  39. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/accounting.py +0 -0
  40. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/actions.py +0 -0
  41. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/admin.py +0 -0
  42. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/analytics.py +0 -0
  43. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/availability.py +0 -0
  44. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/bank_reconciliation.py +0 -0
  45. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/documents.py +0 -0
  46. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/proposals.py +0 -0
  47. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/reports.py +0 -0
  48. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/routers/setup.py +0 -0
  49. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/templates/_proposal_totals.html +0 -0
  50. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/templates/_record_base.html +0 -0
  51. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/templates/document.html +0 -0
  52. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/templates/proposal.html +0 -0
  53. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/templates/record.html +0 -0
  54. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/time_filters.py +0 -0
  55. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/tool_permissions.py +0 -0
  56. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/api/write_context.py +0 -0
  57. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/docs/agents/README.md +0 -0
  58. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/frontend/LICENSE +0 -0
  59. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/frontend/README.md +0 -0
  60. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/frontend/src/api/client.ts +0 -0
  61. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/__init__.py +0 -0
  62. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/__init__.py +0 -0
  63. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_account.py +0 -0
  64. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
  65. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_transaction.py +0 -0
  66. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/camt.py +0 -0
  67. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  68. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/general_ledger.py +0 -0
  69. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/journal_entry.py +0 -0
  70. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/payment_entry.py +0 -0
  71. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/pos_invoice.py +0 -0
  72. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
  73. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/sales_invoice.py +0 -0
  74. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/settlement.py +0 -0
  75. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/__init__.py +0 -0
  76. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/engine.py +0 -0
  77. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/pack.py +0 -0
  78. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  79. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  80. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  81. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  82. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  83. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  84. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/profiles.py +0 -0
  85. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/spine.py +0 -0
  86. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/accounting/subscription.py +0 -0
  87. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/assets/__init__.py +0 -0
  88. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/assets/asset.py +0 -0
  89. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/assets/lifecycle.py +0 -0
  90. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/assets/reservation.py +0 -0
  91. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/buying/__init__.py +0 -0
  92. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/buying/purchase_order.py +0 -0
  93. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/controllers/__init__.py +0 -0
  94. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/controllers/currency.py +0 -0
  95. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/controllers/defaults.py +0 -0
  96. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/controllers/item_price.py +0 -0
  97. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/controllers/item_prices.py +0 -0
  98. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/controllers/pricing_rule.py +0 -0
  99. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  100. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/exceptions.py +0 -0
  101. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/hooks.py +0 -0
  102. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/master_integrity.py +0 -0
  103. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/selling/__init__.py +0 -0
  104. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/selling/proposal.py +0 -0
  105. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/selling/quotation.py +0 -0
  106. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/selling/sales_order.py +0 -0
  107. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/simulation.py +0 -0
  108. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/stock/__init__.py +0 -0
  109. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/stock/delivery_note.py +0 -0
  110. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/stock/purchase_receipt.py +0 -0
  111. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/stock/stock_entry.py +0 -0
  112. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/stock/stock_ledger.py +0 -0
  113. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/timestamps.py +0 -0
  114. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/validation.py +0 -0
  115. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/lambda_erp/workflow.py +0 -0
  116. {lambda_erp-1.2.3 → lambda_erp-1.3.0}/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.0
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
 
@@ -4142,7 +4212,9 @@ async def chat_websocket(
4142
4212
  finally:
4143
4213
  session_tasks.pop(target_session_id, None)
4144
4214
 
4145
- async def process_session_message(target_session_id: str, user_content: str, attachment_ids: list | None = None):
4215
+ async def process_session_message(target_session_id: str, user_content: str,
4216
+ attachment_ids: list | None = None,
4217
+ turn_token: str | None = None):
4146
4218
  try:
4147
4219
  async def on_event(event: dict):
4148
4220
  await send_event(event["type"], session_id=target_session_id, **{
@@ -4169,7 +4241,9 @@ async def chat_websocket(
4169
4241
  except Exception as e:
4170
4242
  await send_error(str(e), session_id=target_session_id)
4171
4243
  finally:
4172
- session_tasks.pop(target_session_id, None)
4244
+ release_session_turn(target_session_id, turn_token)
4245
+ if session_tasks.get(target_session_id) is asyncio.current_task():
4246
+ session_tasks.pop(target_session_id, None)
4173
4247
 
4174
4248
  try:
4175
4249
  await send_event("sessions_list", sessions=list_sessions(user_id=ws_user_id, role=ws_user_role))
@@ -4240,7 +4314,7 @@ async def chat_websocket(
4240
4314
  continue
4241
4315
 
4242
4316
  active_task = session_tasks.get(session_id)
4243
- if active_task and not active_task.done():
4317
+ if (active_task and not active_task.done()) or session_turn_running(session_id):
4244
4318
  await send_error(
4245
4319
  "Wait for the current response to finish before deleting this chat.",
4246
4320
  request_id=request_id,
@@ -4263,7 +4337,7 @@ async def chat_websocket(
4263
4337
  continue
4264
4338
 
4265
4339
  active_task = session_tasks.get(session_id)
4266
- if active_task and not active_task.done():
4340
+ if (active_task and not active_task.done()) or session_turn_running(session_id):
4267
4341
  await send_error(
4268
4342
  "Wait for the current response to finish before clearing this chat.",
4269
4343
  request_id=request_id,
@@ -4347,7 +4421,8 @@ async def chat_websocket(
4347
4421
 
4348
4422
  session = get_session(session_id)
4349
4423
  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))
4424
+ session_tasks[session_id] = asyncio.create_task(
4425
+ replay_demo_session(session_id), name=f"demo:{session_id}")
4351
4426
  continue
4352
4427
 
4353
4428
  if msg_type == "transcribe":
@@ -4503,10 +4578,24 @@ async def chat_websocket(
4503
4578
  if aid in session_attachments
4504
4579
  ]
4505
4580
 
4506
- save_chat_message(
4507
- session_id, "user", user_content or "",
4508
- metadata={"attachments": attached_meta} if attached_meta else None,
4509
- )
4581
+ # Claimed before the user message is stored, so a second tab (or
4582
+ # another app process) can't add a message to a running turn.
4583
+ turn_token = claim_session_turn(session_id)
4584
+ if not turn_token:
4585
+ await send_error(
4586
+ "Wait for the current response to finish before sending another message.",
4587
+ request_id=request_id,
4588
+ session_id=session_id,
4589
+ )
4590
+ continue
4591
+ try:
4592
+ save_chat_message(
4593
+ session_id, "user", user_content or "",
4594
+ metadata={"attachments": attached_meta} if attached_meta else None,
4595
+ )
4596
+ except BaseException:
4597
+ release_session_turn(session_id, turn_token)
4598
+ raise
4510
4599
  await send_message_added(
4511
4600
  session_id, "user", user_content or "",
4512
4601
  attachments=attached_meta if attached_meta else None,
@@ -4520,11 +4609,21 @@ async def chat_websocket(
4520
4609
  await send_event("thinking", session_id=session_id, iteration=1)
4521
4610
 
4522
4611
  session_tasks[session_id] = asyncio.create_task(
4523
- process_session_message(session_id, user_content, attachment_ids)
4612
+ process_session_message(session_id, user_content, attachment_ids, turn_token),
4613
+ name=f"turn:{session_id}",
4524
4614
  )
4525
4615
 
4526
4616
  except WebSocketDisconnect:
4527
- for task in session_tasks.values():
4528
- task.cancel()
4617
+ pass
4529
4618
  except Exception as e:
4530
4619
  await send_error(str(e))
4620
+ finally:
4621
+ # A demo replay is only presentation: stop it. An agent turn may be
4622
+ # between a tool's write and its result: cancelling it would commit the
4623
+ # write but lose the result and the reply, so the user retries and the
4624
+ # write happens twice. Let it finish; history shows it on reconnect.
4625
+ for task in list(session_tasks.values()):
4626
+ if task.get_name().startswith("demo:"):
4627
+ task.cancel()
4628
+ elif not task.done():
4629
+ detach_turn(task)
@@ -51,41 +51,48 @@ 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()
58
62
 
59
- # Load customer extension plugins before anything creates documents.
60
- load_plugins()
63
+ # Load customer extension plugins before anything creates documents.
64
+ load_plugins()
61
65
 
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()
66
+ # Create plugin-registered tables and run their pending migrations. Must be
67
+ # after load_plugins() (registrations happen in register()) and before any
68
+ # document is created.
69
+ from api.services import apply_plugin_schema
70
+ apply_plugin_schema()
67
71
 
68
- from api.pdf_profiles import validate_pdf_registry
69
- validate_pdf_registry()
72
+ from api.pdf_profiles import validate_pdf_registry
73
+ validate_pdf_registry()
70
74
 
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()
75
+ # Ensure the demo spend log table exists before any LLM call happens.
76
+ from api.demo_limits import init_schema as init_demo_spend_schema
77
+ init_demo_spend_schema()
74
78
 
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()
79
+ # Seed a deterministic admin from the environment (if configured) before
80
+ # anyone can register. On deployments whose DB is recreated each rollout
81
+ # this stops the first visitor after a redeploy from grabbing admin.
82
+ from api.auth import ensure_seed_admin
83
+ ensure_seed_admin()
80
84
 
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
85
+ # When packaged as a demo container, land visitors straight in demo mode.
86
+ if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
87
+ from api.bootstrap import bootstrap_demo
84
88
 
85
- bootstrap_demo()
89
+ bootstrap_demo()
86
90
 
87
91
  yield
88
- # Shutdown: nothing to clean up (SQLite handles it)
92
+ # Shutdown: let chat turns whose sockets already closed finish and save
93
+ # their results. Stay inside the container's termination grace period.
94
+ from api.chat import drain_turns
95
+ await drain_turns(timeout=float(os.environ.get("LAMBDA_ERP_SHUTDOWN_DRAIN_SECONDS", "20")))
89
96
 
90
97
 
91
98
  app = FastAPI(
@@ -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
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import asyncio
5
6
  import json
6
7
  from urllib.parse import quote
7
8
 
@@ -44,7 +45,8 @@ async def preview_bank_statements(
44
45
  _user: dict = Depends(require_non_public_manager),
45
46
  ):
46
47
  """Parse uploads without writing any statement or transaction data."""
47
- return preview_documents(await _parse_uploads(files))
48
+ # Parsing and matching are synchronous DB work: keep them off the event loop.
49
+ return await asyncio.to_thread(preview_documents, await _parse_uploads(files))
48
50
 
49
51
 
50
52
  @router.post("/import")
@@ -64,8 +66,9 @@ async def import_bank_statements(
64
66
  ):
65
67
  raise HTTPException(status_code=422, detail="mappings must map statement keys to Bank Accounts")
66
68
  try:
67
- return import_documents(
68
- await _parse_uploads(files), parsed_mappings, imported_by=user.get("name")
69
+ return await asyncio.to_thread(
70
+ import_documents, await _parse_uploads(files), parsed_mappings,
71
+ imported_by=user.get("name"),
69
72
  )
70
73
  except ValidationError as exc:
71
74
  raise HTTPException(status_code=422, detail=str(exc)) from exc
@@ -23,12 +23,15 @@ from pydantic import BaseModel
23
23
 
24
24
  from api.auth import get_api_caller
25
25
  from api.chat import (
26
+ claim_session_turn,
26
27
  create_session,
27
28
  delete_session,
28
29
  get_session,
29
30
  list_sessions,
31
+ release_session_turn,
30
32
  run_session_turn,
31
33
  save_chat_message,
34
+ session_turn_running,
32
35
  )
33
36
  from api.pdf import generate_pdf
34
37
  from api.pdf_exports import create_pdf_export, get_pdf_export, pdf_content_disposition
@@ -81,8 +84,23 @@ async def chat(payload: ChatApiRequest, request: Request, caller: dict = Depends
81
84
  target_session_id = existing[0]["id"] if existing else create_session(user_id=owner)["id"]
82
85
  replay_history = False
83
86
 
84
- save_chat_message(target_session_id, "user", message)
85
-
87
+ # A continued session replays its history, so a second message must wait
88
+ # for the running turn (same rule as the web chat). The rolling audit
89
+ # session is stateless; parallel calls on it stay allowed.
90
+ turn_token = None
91
+ if replay_history:
92
+ turn_token = claim_session_turn(target_session_id)
93
+ if not turn_token:
94
+ raise HTTPException(status_code=409, detail="A reply is still running in this session; retry when it finishes")
95
+ try:
96
+ save_chat_message(target_session_id, "user", message)
97
+ return await _run_chat_turn(
98
+ target_session_id, message, caller, request, replay_history)
99
+ finally:
100
+ release_session_turn(target_session_id, turn_token)
101
+
102
+
103
+ async def _run_chat_turn(target_session_id, message, caller, request, replay_history):
86
104
  tool_results = []
87
105
  generated = []
88
106
  pdf_attempts = {}
@@ -168,5 +186,7 @@ def delete(session_id: str, caller: dict = Depends(get_api_caller)):
168
186
  session = get_session(session_id)
169
187
  if not session or session.get("user_id") != caller["name"]:
170
188
  raise HTTPException(status_code=404, detail="Session not found")
189
+ if session_turn_running(session_id):
190
+ raise HTTPException(status_code=409, detail="A reply is still running in this session")
171
191
  delete_session(session_id)
172
192
  return {"id": session_id, "deleted": True}
@@ -1,6 +1,5 @@
1
1
  """Master data CRUD: Customer, Supplier, Item, Warehouse, Account, Company."""
2
2
 
3
- import re
4
3
  import secrets
5
4
 
6
5
  from fastapi import APIRouter, Depends, HTTPException, Query, Request
@@ -171,26 +170,15 @@ def _find_reference(master_type: str, name: str) -> str | None:
171
170
 
172
171
 
173
172
  def _generate_master_name(db, doctype: str, prefix: str, digits: int = 3) -> str:
174
- # Take the highest numeric suffix among strict "PREFIX-<digits>" names.
175
- # Names with a non-numeric tail (e.g. a custom "ITEM-COST-TEST") are ignored
176
- # rather than parsed — string-sorting them to the top used to reset the
177
- # counter to 001 and collide with an existing record.
178
- rows = db.sql(
179
- f'SELECT name FROM "{doctype}" WHERE name LIKE ?',
180
- [f"{prefix}-%"],
181
- )
182
- pattern = re.compile(rf"^{re.escape(prefix)}-(\d+)$")
183
- max_number = 0
184
- for row in rows:
185
- match = pattern.match(row["name"] or "")
186
- if match:
187
- max_number = max(max_number, int(match.group(1)))
188
-
189
- # Skip any number already taken by a non-standard name so we never collide.
190
- number = max_number + 1
191
- while db.exists(doctype, f"{prefix}-{number:0{digits}d}"):
192
- number += 1
193
- return f"{prefix}-{number:0{digits}d}"
173
+ # The shared naming series (the same one documents use), so concurrent
174
+ # creates, other processes and a doctype that is both a master and a
175
+ # document (Lead) never pick the same number. Seeding counts only strict
176
+ # "PREFIX-<digits>" names, so a custom "ITEM-COST-TEST" doesn't move it.
177
+ # Skip any number already taken by a record created with an explicit name.
178
+ while True:
179
+ name = f"{prefix}-{db.next_in_series(prefix):0{digits}d}"
180
+ if not db.exists(doctype, name):
181
+ return name
194
182
 
195
183
 
196
184
  def _generate_random_master_name(db, doctype: str, prefix: str) -> str:
@@ -15,6 +15,7 @@ automatically — the MCP surface is modular by construction.
15
15
  Transport: a single POST /api/mcp speaking JSON-RPC 2.0 (request → JSON result;
16
16
  notifications → 202, no body). That's the minimum a tool-only server needs.
17
17
  """
18
+ import asyncio
18
19
  import json
19
20
  import os
20
21
 
@@ -225,10 +226,12 @@ async def mcp_endpoint(request: Request):
225
226
  version = request.headers.get('mcp-protocol-version')
226
227
  if not initializing and version and version not in SUPPORTED_PROTOCOL_VERSIONS:
227
228
  raise HTTPException(400, 'Unsupported MCP protocol version')
229
+ # Tool calls do synchronous database and PDF work; running them on the
230
+ # event loop would stall every other request and websocket on this process.
228
231
  if isinstance(body, list):
229
- responses = [r for r in (_handle(m, user) for m in body) if r is not None]
232
+ responses = [r for r in [await asyncio.to_thread(_handle, m, user) for m in body] if r is not None]
230
233
  return JSONResponse(responses) if responses else Response(status_code=202)
231
- resp = _handle(body, user)
234
+ resp = await asyncio.to_thread(_handle, body, user)
232
235
  if resp is None:
233
236
  return Response(status_code=202)
234
237
  return JSONResponse(resp)
@@ -572,6 +572,12 @@ def update_document(doctype_slug: str, name: str, data: dict) -> dict:
572
572
  raise ValueError(f"Unknown document type: {doctype_slug}")
573
573
  data = _validate_document_input(doctype, cls, data)
574
574
  doc = cls.load(name)
575
+ # A client that sends back the `modified` it loaded (the document form
576
+ # does) edits that version: if someone saved in between, the write-state
577
+ # check refuses instead of the stale copy silently replacing their changes
578
+ # (including whole child tables). Callers that omit it keep last-write-wins.
579
+ if data.get("modified"):
580
+ doc._loaded_modified = data["modified"]
575
581
  # Update parent fields
576
582
  for key, value in data.items():
577
583
  if key not in ("name", "docstatus", "creation") and key not in doc.CHILD_TABLES:
@@ -611,20 +611,13 @@ def reconcile_with_payment(bank_transaction: str, allocations: list[dict], *,
611
611
  "references": references,
612
612
  })
613
613
  db = get_db()
614
- db._in_transaction = True
615
- try:
614
+ with db.atomic():
616
615
  reconciliation = _new_audit(
617
616
  tx, mode="Created Payment Entry", voucher_type="Payment Entry",
618
617
  voucher_no=payment.name, user=user,
619
618
  )
620
619
  payment._data["bank_reconciliation"] = reconciliation
621
620
  payment.submit()
622
- db.commit()
623
- db._in_transaction = False
624
- except Exception:
625
- db.conn.rollback()
626
- db._in_transaction = False
627
- raise
628
621
  return {
629
622
  "bank_transaction": bank_transaction,
630
623
  "reconciliation": reconciliation,
@@ -693,20 +686,13 @@ def reconcile_with_journal(bank_transaction: str, counterparty_account: str, *,
693
686
  "remark": remarks or tx.get("remittance_information") or tx.get("description") or f"Bank reconciliation {tx['name']}",
694
687
  "accounts": accounts,
695
688
  })
696
- db._in_transaction = True
697
- try:
689
+ with db.atomic():
698
690
  reconciliation = _new_audit(
699
691
  tx, mode="Created Journal Entry", voucher_type="Journal Entry",
700
692
  voucher_no=journal.name, user=user,
701
693
  )
702
694
  journal._data["bank_reconciliation"] = reconciliation
703
695
  journal.submit()
704
- db.commit()
705
- db._in_transaction = False
706
- except Exception:
707
- db.conn.rollback()
708
- db._in_transaction = False
709
- raise
710
696
  return {
711
697
  "bank_transaction": bank_transaction,
712
698
  "reconciliation": reconciliation,
@@ -770,12 +756,10 @@ def _validate_existing_voucher_group(transactions: list[dict], voucher_type: str
770
756
  )
771
757
 
772
758
 
773
- def _begin_existing_voucher_transaction(db, voucher_type: str, voucher_no: str) -> None:
774
- """Serialize consumers of one voucher while the group is validated."""
775
- db._in_transaction = True
776
- if db.dialect == "sqlite":
777
- db.conn.execute("BEGIN IMMEDIATE")
778
- else:
759
+ def _lock_existing_voucher(db, voucher_type: str, voucher_no: str) -> None:
760
+ """Serialize consumers of one voucher while the group is validated. Runs
761
+ inside db.atomic(), which already holds SQLite's write reservation."""
762
+ if db.dialect == "postgres":
779
763
  # ``voucher_type`` is checked against the fixed allowlist before this
780
764
  # helper is called, so quoting the table name is safe.
781
765
  db.conn.execute(
@@ -804,8 +788,8 @@ def reconcile_with_existing_voucher_group(bank_transactions: list[str], voucher_
804
788
  names.append(name)
805
789
 
806
790
  db = get_db()
807
- try:
808
- _begin_existing_voucher_transaction(db, voucher_type, voucher_no)
791
+ with db.atomic():
792
+ _lock_existing_voucher(db, voucher_type, voucher_no)
809
793
  transactions = [_transaction(name) for name in names]
810
794
  for tx in transactions:
811
795
  _ensure_available(tx)
@@ -826,12 +810,6 @@ def reconcile_with_existing_voucher_group(bank_transactions: list[str], voucher_
826
810
  activate_generated_reconciliation(
827
811
  reconciliation, voucher_type=voucher_type, voucher_no=voucher_no,
828
812
  )
829
- db.commit()
830
- except Exception:
831
- db.conn.rollback()
832
- raise
833
- finally:
834
- db._in_transaction = False
835
813
  return {
836
814
  "bank_transaction": names[0],
837
815
  "bank_transactions": names,
@@ -866,19 +844,11 @@ def undo_reconciliation(bank_transaction: str, *, user: str | None = None,
866
844
  if audit["mode"] in {"Created Payment Entry", "Created Journal Entry"}:
867
845
  document_cls = PaymentEntry if audit["voucher_type"] == "Payment Entry" else JournalEntry
868
846
  document = document_cls.load(audit["voucher_no"])
869
- db._in_transaction = True
870
- db.set_value("Bank Reconciliation", audit["name"], "reversed_by", user)
871
- try:
847
+ with db.atomic():
848
+ db.set_value("Bank Reconciliation", audit["name"], "reversed_by", user)
872
849
  document.cancel()
873
- db.commit()
874
- db._in_transaction = False
875
- except Exception:
876
- db.conn.rollback()
877
- db._in_transaction = False
878
- raise
879
850
  else:
880
- db._in_transaction = True
881
- try:
851
+ with db.atomic():
882
852
  group = _group_rows(audit.get("group_id")) or [audit]
883
853
  for member in group:
884
854
  member_tx = _transaction(member["bank_transaction"])
@@ -891,12 +861,6 @@ def undo_reconciliation(bank_transaction: str, *, user: str | None = None,
891
861
  "status": "Unreconciled", "reconciled_by": None,
892
862
  "reconciled_at": None, "modified": now(),
893
863
  })
894
- db.commit()
895
- except Exception:
896
- db.conn.rollback()
897
- raise
898
- finally:
899
- db._in_transaction = False
900
864
  return {
901
865
  "bank_transaction": bank_transaction,
902
866
  "bank_transactions": [
@@ -87,6 +87,16 @@ def validate_expense_against_budget(gl_entry):
87
87
 
88
88
  budgets = db.get_all("Budget", filters=filters, fields=["*"])
89
89
 
90
+ # Two postings against one "Stop" budget must not both pass on the same
91
+ # old total. Lock the budget rows (in name order, so concurrent postings
92
+ # can't deadlock) before summing: the lock waits for the other posting to
93
+ # commit, and the SUM below then sees its GL entries.
94
+ stop_budgets = sorted(b["name"] for b in budgets if b.get("action_if_exceeded") == "Stop")
95
+ if stop_budgets and db.dialect == "postgres" and db._in_transaction:
96
+ placeholders = ", ".join("?" * len(stop_budgets))
97
+ db.sql(f'SELECT name FROM "Budget" WHERE name IN ({placeholders}) '
98
+ 'ORDER BY name FOR UPDATE', stop_budgets)
99
+
90
100
  for budget in budgets:
91
101
  fiscal_year = budget.get("fiscal_year", "")
92
102
  if fiscal_year and str(year) not in fiscal_year: