lambda-erp 1.2.2__py3-none-any.whl → 1.3.0__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/auth.py CHANGED
@@ -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"
api/chat.py CHANGED
@@ -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)
api/main.py CHANGED
@@ -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(
api/oauth.py CHANGED
@@ -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
api/routers/chat_api.py CHANGED
@@ -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}
api/routers/masters.py CHANGED
@@ -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:
api/routers/mcp.py CHANGED
@@ -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)
api/services.py CHANGED
@@ -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:
@@ -161,11 +161,31 @@ def run_period_revaluation(company, date=None, *, post=True):
161
161
  "No Unrealized Exchange Gain/Loss account is configured on the company; "
162
162
  "cannot post the period revaluation."
163
163
  )
164
- reval_no = new_name("REVAL")
165
- reversal_no = new_name("REVAL")
166
164
  next_date = add_days(on_date, 1).isoformat()
167
- make_gl_entries(_build_entries(postable, fx_account, company, on_date, reval_no, swap=False))
168
- make_gl_entries(_build_entries(postable, fx_account, company, next_date, reversal_no, swap=True))
165
+ # One transaction: the voucher and its reversal post together or not
166
+ # at all. Serialized per company, and refused when this date is already
167
+ # revalued: party balances are measured from invoice rates, so a
168
+ # second run on the same date would book the same delta again.
169
+ with db.atomic():
170
+ if db.dialect == "postgres":
171
+ db.sql("SELECT pg_advisory_xact_lock(hashtext(?))",
172
+ [f"period-revaluation:{company}"])
173
+ existing = db.sql(
174
+ 'SELECT voucher_no FROM "GL Entry" WHERE voucher_type = ? '
175
+ 'AND company = ? AND posting_date = ? AND COALESCE(is_cancelled, 0) = 0 '
176
+ "AND remarks NOT LIKE 'Reversal of %' LIMIT 1",
177
+ ["Period Revaluation", company, str(on_date)],
178
+ )
179
+ if existing:
180
+ raise ValidationError(
181
+ f"{company} is already revalued on {on_date} "
182
+ f"({existing[0]['voucher_no']}); posting again would double the "
183
+ "unrealized FX."
184
+ )
185
+ reval_no = new_name("REVAL")
186
+ reversal_no = new_name("REVAL")
187
+ make_gl_entries(_build_entries(postable, fx_account, company, on_date, reval_no, swap=False))
188
+ make_gl_entries(_build_entries(postable, fx_account, company, next_date, reversal_no, swap=True))
169
189
  result.update(posted=True, voucher_no=reval_no,
170
190
  reversal_voucher_no=reversal_no, reversal_date=next_date)
171
191
 
lambda_erp/database.py CHANGED
@@ -258,6 +258,10 @@ class Database:
258
258
  self._local = threading.local()
259
259
  self._col_cache = {} # doctype -> set(columns); invalidated on ALTER
260
260
  self._text_col_cache = {} # doctype -> set(text columns); invalidated on ALTER
261
+ # Postgres naming series run on their own autocommit connection (see
262
+ # next_in_series); one per process, serialized by this lock.
263
+ self._series_conn = None
264
+ self._series_lock = threading.Lock()
261
265
  if self._is_memory:
262
266
  self._lock = threading.RLock()
263
267
  self._shared_conn = self._open_conn()
@@ -268,7 +272,35 @@ class Database:
268
272
  # Open the init-thread connection now so _setup_schema can use it
269
273
  # via the self.conn property.
270
274
  self._open_conn()
271
- self._setup_schema()
275
+ with self.startup_lock():
276
+ self._setup_schema()
277
+
278
+ # Arbitrary constant naming the "schema setup / migrations" advisory lock.
279
+ _STARTUP_LOCK_KEY = 715624180032
280
+
281
+ @contextmanager
282
+ def startup_lock(self):
283
+ """Run schema setup and migrations in one process at a time.
284
+
285
+ Every process applies pending migrations when it boots. Two replicas,
286
+ or a new revision starting next to a restarting old one, would otherwise
287
+ run the same migration concurrently: the slower one fails on the
288
+ migration marker (a crash loop for required migrations) and a data
289
+ backfill can run twice. A Postgres session advisory lock, held on its
290
+ own connection, makes the others wait and then find the work done.
291
+ Not reentrant: don't nest two startup_lock() blocks in one process.
292
+ SQLite is single-process, so it needs no lock."""
293
+ if self.dialect != "postgres":
294
+ yield
295
+ return
296
+ import psycopg
297
+
298
+ with psycopg.connect(self.db_path, autocommit=True) as conn:
299
+ conn.execute("SELECT pg_advisory_lock(%s)", [self._STARTUP_LOCK_KEY])
300
+ try:
301
+ yield
302
+ finally:
303
+ conn.execute("SELECT pg_advisory_unlock(%s)", [self._STARTUP_LOCK_KEY])
272
304
 
273
305
  def _open_conn(self):
274
306
  """Open a new connection (per-backend) with the same per-conn settings.
@@ -316,6 +348,13 @@ class Database:
316
348
  conn = getattr(self._local, "conn", None)
317
349
  if conn is None:
318
350
  conn = self._open_conn()
351
+ elif (self.dialect == "postgres" and conn._raw.closed
352
+ and not getattr(self._local, "in_transaction", False)):
353
+ # A dropped connection (server restart, failover, idle kill) would
354
+ # otherwise fail every later request this pooled thread serves.
355
+ # Mid-transaction the error must surface; reconnecting there would
356
+ # silently continue in a new transaction.
357
+ conn = self._open_conn()
319
358
  return conn
320
359
 
321
360
  @property
@@ -1961,7 +2000,15 @@ class Database:
1961
2000
  evaluates the parser at query time: persisting an index that calls a
1962
2001
  connection-local UDF would break ordinary sqlite3 writers and imports.
1963
2002
  Apply after core/plugin schema setup; failures retry on subsequent boots.
2003
+ Indexes that already exist are skipped up front: on PostgreSQL even
2004
+ CREATE INDEX IF NOT EXISTS takes the table lock before checking, so
2005
+ every boot would otherwise queue behind live writes.
1964
2006
  """
2007
+ if self.dialect == "postgres":
2008
+ existing = {r[0] for r in self.conn.execute(
2009
+ "SELECT indexname FROM pg_indexes WHERE schemaname = 'public'").fetchall()}
2010
+ else:
2011
+ existing = set()
1965
2012
  for table in self._all_user_tables():
1966
2013
  try:
1967
2014
  columns = self._get_table_columns(table)
@@ -1986,7 +2033,14 @@ class Database:
1986
2033
  self.conn.execute(f'DROP INDEX IF EXISTS "{idx}"')
1987
2034
  self.conn.commit()
1988
2035
  for idx, expression in indexes:
2036
+ if idx in existing:
2037
+ continue
1989
2038
  try:
2039
+ if self.dialect == "postgres":
2040
+ # CREATE INDEX waits for a SHARE lock, and every new
2041
+ # write to the table queues behind that wait. Give up
2042
+ # quickly instead; the next boot retries.
2043
+ self.conn.execute("SET LOCAL lock_timeout = '3s'")
1990
2044
  self.conn.execute(f'CREATE INDEX IF NOT EXISTS "{idx}" ON "{table}" ({expression})')
1991
2045
  self.conn.commit()
1992
2046
  except Exception:
@@ -2272,6 +2326,115 @@ class Database:
2272
2326
  finally:
2273
2327
  self._in_transaction = outer
2274
2328
 
2329
+ # --- Naming series ---
2330
+
2331
+ def next_in_series(self, prefix: str) -> int:
2332
+ """Allocate the next number of the naming series `prefix`.
2333
+
2334
+ The counter lives in "_NamingSeries", so every process, replica and
2335
+ revision on this database draws from the same sequence. A process-local
2336
+ counter handed out numbers another writer had already used.
2337
+
2338
+ On Postgres the increment runs on a separate autocommit connection:
2339
+ the number is taken at once and never handed out again, even if the
2340
+ caller's transaction rolls back (a gap, never a duplicate), and the
2341
+ series row is not locked for the length of a document submit. SQLite has
2342
+ a single writer anyway, so there the increment joins the caller's
2343
+ transaction.
2344
+
2345
+ A prefix seen for the first time is seeded from the highest
2346
+ "PREFIX-<digits>" already stored, so existing data keeps its numbers.
2347
+ """
2348
+ if self.dialect == "postgres":
2349
+ return self._next_in_series_pg(prefix)
2350
+ with self._lock:
2351
+ conn = self.conn
2352
+ update = 'UPDATE "_NamingSeries" SET current = current + 1 WHERE prefix = ?'
2353
+ if conn.execute(update, [prefix]).rowcount == 0:
2354
+ floor = self._series_floor(
2355
+ prefix, lambda sql, params: conn.execute(sql, params).fetchall())
2356
+ conn.execute(
2357
+ 'INSERT INTO "_NamingSeries" (prefix, current) VALUES (?, ?)',
2358
+ [prefix, floor + 1])
2359
+ current = conn.execute(
2360
+ 'SELECT current FROM "_NamingSeries" WHERE prefix = ?', [prefix]
2361
+ ).fetchone()[0]
2362
+ if not self._in_transaction:
2363
+ conn.commit()
2364
+ return int(current)
2365
+
2366
+ def _next_in_series_pg(self, prefix: str) -> int:
2367
+ import psycopg
2368
+
2369
+ update = ('UPDATE "_NamingSeries" SET current = current + 1 '
2370
+ 'WHERE prefix = %s RETURNING current')
2371
+ with self._series_lock:
2372
+ for attempt in (1, 2):
2373
+ try:
2374
+ conn = self._series_conn
2375
+ if conn is None or conn.closed:
2376
+ conn = self._series_conn = psycopg.connect(self.db_path, autocommit=True)
2377
+ row = conn.execute(update, [prefix]).fetchone()
2378
+ if row is None:
2379
+ floor = self._series_floor(
2380
+ prefix,
2381
+ lambda sql, params: conn.execute(sql.replace("?", "%s"), params).fetchall())
2382
+ # Two processes may seed the same new prefix at once;
2383
+ # both floors are valid, so keep the higher one.
2384
+ conn.execute(
2385
+ 'INSERT INTO "_NamingSeries" (prefix, current) VALUES (%s, %s) '
2386
+ 'ON CONFLICT (prefix) DO UPDATE SET current = '
2387
+ 'GREATEST("_NamingSeries".current, EXCLUDED.current)',
2388
+ [prefix, floor])
2389
+ row = conn.execute(update, [prefix]).fetchone()
2390
+ return int(row[0])
2391
+ except psycopg.OperationalError:
2392
+ # A dropped connection (failover, idle kill): reconnect once.
2393
+ if self._series_conn is not None:
2394
+ try:
2395
+ self._series_conn.close()
2396
+ except Exception:
2397
+ pass
2398
+ self._series_conn = None
2399
+ if attempt == 2:
2400
+ raise
2401
+
2402
+ def _series_floor(self, prefix: str, fetch) -> int:
2403
+ """Highest number already used as "PREFIX-<digits>" in any table.
2404
+
2405
+ Only exact "PREFIX-<digits>" names count, so "ACT-IMP-LEAD-90951" or a
2406
+ child row "LEAD-ITEM-0005" don't inflate ACT or LEAD. REVAL vouchers
2407
+ have no row of their own and are found through GL Entry.voucher_no.
2408
+ A failing probe raises: seeding a series too low would hand out
2409
+ numbers that are already taken, one collision after another.
2410
+ """
2411
+ import re
2412
+
2413
+ if self.dialect == "postgres":
2414
+ tables = [r[0] for r in fetch(
2415
+ "SELECT c.table_name FROM information_schema.columns c "
2416
+ "JOIN information_schema.tables t "
2417
+ "ON t.table_schema = c.table_schema AND t.table_name = c.table_name "
2418
+ "WHERE c.table_schema = 'public' AND c.column_name = 'name' "
2419
+ "AND t.table_type = 'BASE TABLE'", [])]
2420
+ else:
2421
+ tables = [r[0] for r in fetch(
2422
+ "SELECT name FROM sqlite_master WHERE type = 'table'", [])
2423
+ if "name" in self._get_table_columns(r[0])]
2424
+ probes = [(table, "name") for table in tables]
2425
+ if "GL Entry" in tables:
2426
+ probes.append(("GL Entry", "voucher_no"))
2427
+
2428
+ pattern = re.compile(rf"^{re.escape(prefix)}-(\d+)$")
2429
+ floor = 0
2430
+ for table, column in probes:
2431
+ for row in fetch(f'SELECT "{column}" FROM "{table}" WHERE "{column}" LIKE ?',
2432
+ [f"{prefix}-%"]):
2433
+ match = pattern.match(row[0] or "")
2434
+ if match:
2435
+ floor = max(floor, int(match.group(1)))
2436
+ return floor
2437
+
2275
2438
  def commit(self):
2276
2439
  with self._lock:
2277
2440
  self.conn.commit()
@@ -2281,6 +2444,9 @@ class Database:
2281
2444
 
2282
2445
  def close(self):
2283
2446
  self.conn.close()
2447
+ if self._series_conn is not None:
2448
+ self._series_conn.close()
2449
+ self._series_conn = None
2284
2450
 
2285
2451
 
2286
2452
  # Module-level singleton, initialized by setup()
@@ -2878,6 +3044,25 @@ def _m039_connection_app_type(db: "Database") -> None:
2878
3044
  db.ensure_column("Api Key", "app_type", "TEXT DEFAULT 'other'")
2879
3045
 
2880
3046
 
3047
+ def _m040_naming_series(db: "Database") -> None:
3048
+ """Shared document numbering (see Database.next_in_series). Rows are
3049
+ seeded lazily from existing names, so this only creates the table."""
3050
+ db.conn.execute(
3051
+ """CREATE TABLE IF NOT EXISTS "_NamingSeries" (
3052
+ prefix TEXT PRIMARY KEY,
3053
+ current INTEGER NOT NULL
3054
+ )"""
3055
+ )
3056
+ db.conn.commit()
3057
+
3058
+
3059
+ def _m041_chat_turn_lease(db: "Database") -> None:
3060
+ """One running agent turn per chat session, across connections and
3061
+ processes (see api.chat.claim_session_turn)."""
3062
+ db.ensure_column("Chat Session", "active_turn", "TEXT")
3063
+ db.ensure_column("Chat Session", "active_turn_until", "TEXT")
3064
+
3065
+
2881
3066
  Database.MIGRATIONS = [
2882
3067
  (1, "chat_message_session_id", _m001_chat_message_session_id),
2883
3068
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -2918,6 +3103,8 @@ Database.MIGRATIONS = [
2918
3103
  (37, "pricing_record_lifecycle", _m037_pricing_record_lifecycle),
2919
3104
  (38, "customer_website", _m038_customer_website),
2920
3105
  (39, "connection_app_type", _m039_connection_app_type),
3106
+ (40, "naming_series", _m040_naming_series),
3107
+ (41, "chat_turn_lease", _m041_chat_turn_lease),
2921
3108
  ]
2922
3109
 
2923
3110
 
lambda_erp/model.py CHANGED
@@ -73,7 +73,7 @@ class Document:
73
73
  self._loaded_modified = None
74
74
 
75
75
  if not self._data.get("name"):
76
- self._data["name"] = new_name(self.PREFIX)
76
+ self._data["name"] = self._unused_name()
77
77
  if not self._data.get("docstatus"):
78
78
  self._data["docstatus"] = DRAFT
79
79
  if not self._data.get("creation"):
@@ -92,6 +92,16 @@ class Document:
92
92
  child["name"] = new_name(f"{self.PREFIX}-ITEM")
93
93
  self._children[field_name].append(child)
94
94
 
95
+ def _unused_name(self):
96
+ """Next series name not already taken. A record created with an
97
+ explicit name (import, API caller, migration) may sit ahead of the
98
+ series; skip past it rather than fail the save with "already exists"."""
99
+ db = get_db()
100
+ while True:
101
+ name = new_name(self.PREFIX)
102
+ if not db.exists(self.DOCTYPE, name):
103
+ return name
104
+
95
105
  # --- Attribute access (mirrors the framework's Document) ---
96
106
 
97
107
  def __getattr__(self, key):
lambda_erp/utils.py CHANGED
@@ -134,51 +134,13 @@ class _dict(dict):
134
134
  super().update(*args, **kwargs)
135
135
  return self
136
136
 
137
- def new_name(prefix, sequence_store={}):
137
+ def new_name(prefix):
138
138
  """Generate a sequential name like 'ACC-SINV-0001'.
139
139
 
140
- Simple replacement for the framework's naming system.
141
- On first use of a prefix, checks the database for the highest existing
142
- sequence number so we don't collide after a server restart.
140
+ Simple replacement for the framework's naming system. The number comes
141
+ from the database's shared naming series (Database.next_in_series), so
142
+ every process on the same database draws from one sequence.
143
143
  """
144
- if prefix not in sequence_store:
145
- # Bootstrap from database
146
- from lambda_erp.database import get_db
147
- try:
148
- db = get_db()
149
- # Search all tables for names matching this prefix pattern
150
- if getattr(db, "dialect", "sqlite") == "postgres":
151
- # Only probe tables that HAVE a `name` column. On Postgres a
152
- # failing statement (e.g. SELECT name FROM a table without one)
153
- # aborts the whole transaction, unlike SQLite which shrugs it
154
- # off — so we must not issue a query that can error here.
155
- tables = db.sql(
156
- "SELECT table_name AS name FROM information_schema.columns "
157
- "WHERE column_name = 'name' AND table_schema = 'public'"
158
- )
159
- else:
160
- tables = db.sql("SELECT name FROM sqlite_master WHERE type='table'")
161
- max_num = 0
162
- for t in tables:
163
- table_name = t["name"] if isinstance(t, dict) else t[0]
164
- try:
165
- rows = db.sql(
166
- f'SELECT name FROM "{table_name}" WHERE name LIKE ?',
167
- [f"{prefix}-%"],
168
- )
169
- for row in rows:
170
- n = row["name"] if isinstance(row, dict) else row[0]
171
- try:
172
- num = int(n.rsplit("-", 1)[-1])
173
- max_num = max(max_num, num)
174
- except (ValueError, IndexError):
175
- pass
176
- except Exception:
177
- pass
178
- sequence_store[prefix] = max_num
179
- except Exception:
180
- sequence_store[prefix] = 0
181
-
182
- count = sequence_store[prefix] + 1
183
- sequence_store[prefix] = count
184
- return f"{prefix}-{count:04d}"
144
+ from lambda_erp.database import get_db
145
+
146
+ return f"{prefix}-{get_db().next_in_series(prefix):04d}"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.2.2
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
@@ -1,25 +1,25 @@
1
1
  api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
2
  api/attachments.py,sha256=v5WAGUafYqA4tHWkPHHnP5pYxsJx9wDf6Mn1fTyUmYc,20496
3
- api/auth.py,sha256=xkyMjAFO1RpiwevPzjEw194x5M06F54LLLBx93RsV5Q,43203
3
+ api/auth.py,sha256=IcZnUBMv_6u4CrEPENUtsV2DdLQ2rgZGzgpbGXX7s4o,44073
4
4
  api/bootstrap.py,sha256=9r6Bz93Puhz06d8C5LsbYk2NUt2Gr6Nz85FJxOWriCc,21308
5
- api/chat.py,sha256=-FLrT5znrDJs1YrUB6gsmEbg9dQEI3bvQ2Em7qHKNTI,241088
5
+ api/chat.py,sha256=JaifG2AiT5OtBOGLC3nXuZ-hLhwKwpLJkLXnhj0eWKY,245353
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
9
  api/erp_context.py,sha256=FTVou9bGO_9X7xPBqaf2H9QG7qmKJvWPcw4Kduc4qBc,4483
10
10
  api/errors.py,sha256=kEC6BUU4koPlyGoCDobAA8sHRoMqw50Xtc2105ZFKF4,5376
11
11
  api/list_values.py,sha256=mGe74gJ4fHPa5X2djJNTsRE8txa0hG40GAxMfTumU_w,963
12
- api/main.py,sha256=1rwZsXJRxIooFECRTZd-UwRUAGMp6SPL_sxLOCDploo,10803
12
+ api/main.py,sha256=t4fj7kpXURkpU39O3Tv9SFc30Un7jtB75wmZtraZk1k,11345
13
13
  api/mcp_oauth.py,sha256=MjmScAvihkvEXdmM3QJP0FLqqXq-pQ1zmWa67LiArDM,21392
14
14
  api/mentions.py,sha256=3A4hJ8mUD9IVisz21x6XtsHeR0tHqMyIbLPRn3mb9Ps,16712
15
- api/oauth.py,sha256=nRJGGweUGvD_yN7KBSuO7dhL7ynSnCjiPCQdvaWDgnY,22493
15
+ api/oauth.py,sha256=w1XO__oQVQse0S3U03DYLN-_jUPOTliZ4Grr1Nhs4Fs,22450
16
16
  api/pdf.py,sha256=HnJzRYVx6D43ULvYCb2ehwqrGVvaqbzT1vsNg1Gxg7M,19228
17
17
  api/pdf_contract.py,sha256=cEUntXz951OaXTvGdhYnL58LbR_efowGGHhNEBP7y5Q,16118
18
18
  api/pdf_exports.py,sha256=bFdfJVInkCH3BV8e9PpBLF9sOX2OkcFnNO0emuVnrps,3199
19
19
  api/pdf_profiles.py,sha256=gqMRM-T6nzQnJ0DTxUf9MBFtpJws07MpNUQHHgI3Y7c,9206
20
20
  api/providers.py,sha256=ZwuDtukTpHh553wln-AEvRKQ5kE4296rj5_Kgh84O30,6525
21
21
  api/remarks_md.py,sha256=8MZ5oO3TNLDbjdMF2PjdFW13LdAffogOxT2nT0eYrVY,3754
22
- api/services.py,sha256=tXgkxV-U-KymK9xDo5Q6ILZg-roD9c8Uhmi6Uq4qPMI,61293
22
+ api/services.py,sha256=F-7O9faPulVFV11RNLquxmPli1dEezUMztsip8h5-E4,61682
23
23
  api/time_filters.py,sha256=nAlyyZD7i3UOMq8kJdLGo2-jKYl84IZ8ad-oYBTjg1c,4882
24
24
  api/tool_permissions.py,sha256=CgVUUFLuJxEjQ44EWhzpIDNpzIY_tjSrgb5o5SBXU2Y,2216
25
25
  api/write_context.py,sha256=RsrpMTa7k78_wWPGGE2KwLmIOdt_hF54kji5HdjGNI0,649
@@ -30,11 +30,11 @@ api/routers/admin.py,sha256=0OrQ6Zg28pV56vwxnzYicaUriAP4mdrj81iIZK4aXcA,3835
30
30
  api/routers/analytics.py,sha256=XACviKwpvYn1i2LcjwfEd05wwzCFyQYopYW45krhz04,55293
31
31
  api/routers/availability.py,sha256=6iyVtIksPI3aC1tFFNAo4bwQ3iTiv3ebRmfhJzJrKPY,6141
32
32
  api/routers/bank_reconciliation.py,sha256=USv3-WEsgmXvpXALkwVvE9E536hYQ8Wdlr3c1znyBiQ,3717
33
- api/routers/bank_statements.py,sha256=8d6ur-ZS7BCFv8mgdUJ7Il7qKjOxCj4rWdQgS1K4qIA,4518
34
- api/routers/chat_api.py,sha256=4XAtp3wszIkVB5n9IrPLscK3vNT4QHx5FRYk8JyJK3s,7084
33
+ api/routers/bank_statements.py,sha256=QCUW0Fu3nBnPuM9zky3ne_7FEkKoCEitibheBP0EJ1I,4678
34
+ api/routers/chat_api.py,sha256=AdnKm5XtrdMnaMhhwYm_Ck8bvh8RnExtP9K0z2o47eo,8050
35
35
  api/routers/documents.py,sha256=Ww3bL-BN0uIYPRg6UO-Gq3YjtC-nX2DUYNKOqkZOn0Q,11736
36
- api/routers/masters.py,sha256=hg8eO_TsN57ULrM20xTbf_P7sGQpAyRhGnIi9ZgoKRQ,39043
37
- api/routers/mcp.py,sha256=6MP7TYw-Its0f7uNfsa4CbgFoQUqongB0vsxwPGj2iM,10948
36
+ api/routers/masters.py,sha256=oAPwv_zaByl_R-krloUch5NkA0BT0tmvyPvUf0VUqiI,38697
37
+ api/routers/mcp.py,sha256=X91Bigk3RDmshQ49tQeAFqp3HCKO6xZGheGzweHqF1E,11168
38
38
  api/routers/proposals.py,sha256=KtIITJ-GHa3GSMzSDE205nnFNvMwt0KUVAxU1M8-uyU,4520
39
39
  api/routers/reports.py,sha256=6GLlUJQfstS2p_7F5Il_XYwmZIifSHqaVjFdxTQM1OU,29738
40
40
  api/routers/setup.py,sha256=4qVLO0TExVCTGORDI4dNMUyxauvhLRbUS1VydnSubNw,16206
@@ -44,22 +44,22 @@ api/templates/document.html,sha256=6B1fhdrgq2WaEHmpcGR_LkbZ7hNU6wggLt-a9ojO5Pw,9
44
44
  api/templates/proposal.html,sha256=sx2qRsfl0kp8VeqgYrU_4MVs7rokWavfuDH0fU71ppU,5169
45
45
  api/templates/record.html,sha256=aPCWz4uB1ZBEf8KTFHeChMAbWgObEvx3VuhV1aC4x7A,34
46
46
  lambda_erp/__init__.py,sha256=ahOPqWyRzyy70oKnhA95TZnOWOCSaOZIjGSQ-qLojro,1088
47
- lambda_erp/database.py,sha256=5ML7kgKarrP57nVLFZPdEa0iNiLLwyfm_lId1EVNyZk,127227
47
+ lambda_erp/database.py,sha256=v3JcOZrg8hhTLxze2mmNLoSOMHKtLalLrXSut64JMFs,136250
48
48
  lambda_erp/exceptions.py,sha256=ml-iuIefPDTNKqsOJCbdipstaAEKK5re9djIG-UFDxw,516
49
49
  lambda_erp/hooks.py,sha256=koA8a_kNGVIknIhDWjqaq_LI0OYS9Tjt3JT08O5XJcc,1369
50
50
  lambda_erp/master_integrity.py,sha256=xGonkRsJZn92CrBcj8oFBnNnzIncIR1C3fuTOM3UrOw,1191
51
- lambda_erp/model.py,sha256=RkFUryllJsiCUpQaKAmYy9tNlHoJ0dJyFI1OsUOiQdw,24730
51
+ lambda_erp/model.py,sha256=S4XKCXcih8P31ESOhPW_nquaBZcEWz96Ml76NdNk4D8,25146
52
52
  lambda_erp/simulation.py,sha256=iwuTpNvZ35woeE-utveNgWSyDhlE2MK3Id_TTuNTNxg,27554
53
53
  lambda_erp/timestamps.py,sha256=iEOvJCOPoQ0YjbLv5CV2fEH7e002h2caWuYD2ko5Q9I,3067
54
- lambda_erp/utils.py,sha256=TMnufnsdKCsh8eg5kiz1AiJxQlkjponh_oBX6OzAMG4,6165
54
+ lambda_erp/utils.py,sha256=VKXhpsiWIAKc_Kf1aCDnwmnrmMIsK5egHuB2zrddfVo,4404
55
55
  lambda_erp/validation.py,sha256=45SKxyhggbush7mR3l-CtvInyLmaMnISFmFavVry77I,9844
56
56
  lambda_erp/workflow.py,sha256=LMkjWXSrTywl9fo1knIUOsmrTmgkmTJsrfQqPYTqYxA,12732
57
57
  lambda_erp/accounting/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
58
58
  lambda_erp/accounting/bank_account.py,sha256=3zwf2thIlB_JAFuaaK2EjhFtfZv-R2JJ3oklplM9Ab0,2862
59
- lambda_erp/accounting/bank_reconciliation.py,sha256=bc75k1grFI7LlwOVdpGDTxBcqEb_RkvKtcweSH76CK8,39662
59
+ lambda_erp/accounting/bank_reconciliation.py,sha256=8YoVFV44AkEYEKix1NXhzO_r4RtfvS6CqfRedx6bpTA,38804
60
60
  lambda_erp/accounting/bank_statement_import.py,sha256=52JwE_s6laxOVX7GGtE4xWG6Y8p0i-iFySGfXrJVOZk,18978
61
61
  lambda_erp/accounting/bank_transaction.py,sha256=5BSR4jA_4pDJ7moxHss5WJEFaBE4d5QTc4xGn8x24Q0,4939
62
- lambda_erp/accounting/budget.py,sha256=3VyMYMbCZsa5TXIu9oV4ItARhDQ1rEpu6vtOMQLLnS0,4675
62
+ lambda_erp/accounting/budget.py,sha256=uviD4vAunMneorfuNZ8MhFSQu8NoWjGjvopc1vHKNlc,5324
63
63
  lambda_erp/accounting/camt.py,sha256=zF2bppz4YXlifN1co5hNZR36xv4U3tUFB8bFxJ_f9Z8,17455
64
64
  lambda_erp/accounting/chart_of_accounts.py,sha256=pOS0FbwFQ-GPGl996UXb305ZLum15GYaLuqS2OiPHp0,10207
65
65
  lambda_erp/accounting/general_ledger.py,sha256=ySlaYYUS-hIqOVRZaBl_3t8VFp4E9Ua66aiNrdBYh-0,13459
@@ -67,7 +67,7 @@ lambda_erp/accounting/journal_entry.py,sha256=pA6gD5penIO__0L4PMM30J6i3O7Bj6WmEw
67
67
  lambda_erp/accounting/payment_entry.py,sha256=_-7q-aiq1Ky7PmtIAi6n8ou608zudHCJCGOSQDjaE4s,26504
68
68
  lambda_erp/accounting/pos_invoice.py,sha256=icxAaFfoe6A_zpg_ZTxcFgk9-BZ5MZvnOXsFzVhrTvA,13668
69
69
  lambda_erp/accounting/purchase_invoice.py,sha256=ZzpHuRaob1ZWWZblesy2-k7Jp5iXUzTPcjVW3jjIrBo,19360
70
- lambda_erp/accounting/revaluation.py,sha256=jIDb6ZwrclQfCiqh4Estc94qJdX2RrgWA7vVmy3Vpiw,7742
70
+ lambda_erp/accounting/revaluation.py,sha256=40OF8ngnMp6eCYa3R0262B3WvAaxnyeEWTtrgzFOtfE,8862
71
71
  lambda_erp/accounting/sales_invoice.py,sha256=n66xi7VMaER-BlR1q6khBzEpKXeN5LIRccy6LS13lnE,18971
72
72
  lambda_erp/accounting/settlement.py,sha256=ej43n59qORcKJZTLOmnqg6QsiiMKxSqLPh7cVIZxeqw,3033
73
73
  lambda_erp/accounting/subscription.py,sha256=Xzk2465D-pp8-fKp73pF96L600KBP_qXjEJg54ncSIM,8585
@@ -104,7 +104,7 @@ lambda_erp/stock/delivery_note.py,sha256=Z3s57gVA7oSIpLauSd3DIWu-1y3tmvEE-zsopTv
104
104
  lambda_erp/stock/purchase_receipt.py,sha256=E42oHRO4MAowQniLumbOjYY38vVfHbHchkxCWyM3KDg,11463
105
105
  lambda_erp/stock/stock_entry.py,sha256=Itbt9a1u8b1s1OItKwVYMJDLjbNze2-UI2raIemCuts,12493
106
106
  lambda_erp/stock/stock_ledger.py,sha256=AFybuScWwfaTvsqOSAon3gwsjIza7skXvrvyQowL7b0,13265
107
- lambda_erp-1.2.2.dist-info/METADATA,sha256=P5e1cb8A_f9b5tY9YjS2mDJMV9Ph5Qft5LZCoyK35Aw,31852
108
- lambda_erp-1.2.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
109
- lambda_erp-1.2.2.dist-info/licenses/LICENSE,sha256=8H08P22jiM0mJ97SgnpYchU0NeaPunGxZc3IqEaLgtc,11394
110
- lambda_erp-1.2.2.dist-info/RECORD,,
107
+ lambda_erp-1.3.0.dist-info/METADATA,sha256=ifoUfHeiHp1YhAATK6TabtikjcCPGS-r9QRLEwSglu0,31852
108
+ lambda_erp-1.3.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
109
+ lambda_erp-1.3.0.dist-info/licenses/LICENSE,sha256=8H08P22jiM0mJ97SgnpYchU0NeaPunGxZc3IqEaLgtc,11394
110
+ lambda_erp-1.3.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.32.4
2
+ Generator: hatchling 1.33.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any