lambda-erp 1.2.2__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.
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/PKG-INFO +1 -1
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/auth.py +21 -2
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/chat.py +111 -12
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/main.py +32 -25
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/oauth.py +2 -2
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/bank_statements.py +6 -3
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/chat_api.py +22 -2
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/masters.py +9 -21
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/mcp.py +5 -2
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/services.py +6 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_reconciliation.py +11 -47
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/budget.py +10 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/revaluation.py +24 -4
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/database.py +188 -1
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/model.py +11 -1
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/utils.py +7 -45
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/pyproject.toml +1 -1
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/.gitignore +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/LICENSE +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/README.md +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/attachments.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/bootstrap.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/chat_links.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/demo_limits.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/deps.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/erp_context.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/errors.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/list_values.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/mentions.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/pdf.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/pdf_contract.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/pdf_exports.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/providers.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/remarks_md.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/accounting.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/actions.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/admin.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/analytics.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/availability.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/documents.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/proposals.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/reports.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/routers/setup.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/templates/document.html +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/templates/proposal.html +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/templates/record.html +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/time_filters.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/tool_permissions.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/api/write_context.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/docs/agents/README.md +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/frontend/LICENSE +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/frontend/README.md +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.2.2 → lambda_erp-1.3.0}/terraform/README.md +0 -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
|
|
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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
63
|
+
# Load customer extension plugins before anything creates documents.
|
|
64
|
+
load_plugins()
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
72
|
+
from api.pdf_profiles import validate_pdf_registry
|
|
73
|
+
validate_pdf_registry()
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
+
bootstrap_demo()
|
|
86
90
|
|
|
87
91
|
yield
|
|
88
|
-
# Shutdown:
|
|
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
|
|
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
|
-
|
|
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
|
|
68
|
-
await _parse_uploads(files), parsed_mappings,
|
|
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
|
-
|
|
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
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
774
|
-
"""Serialize consumers of one voucher while the group is validated.
|
|
775
|
-
db.
|
|
776
|
-
if db.dialect == "
|
|
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
|
-
|
|
808
|
-
|
|
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.
|
|
870
|
-
|
|
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.
|
|
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:
|