lambda-erp 1.4.0__tar.gz → 1.4.2__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 (125) hide show
  1. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/PKG-INFO +2 -2
  2. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/README.md +1 -1
  3. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/attachments.py +5 -7
  4. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/auth.py +24 -0
  5. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/chat.py +18 -27
  6. lambda_erp-1.4.2/api/llm_provider.py +97 -0
  7. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/main.py +2 -1
  8. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/masters.py +5 -0
  9. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/pos_invoice.py +2 -1
  10. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/purchase_invoice.py +15 -7
  11. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/sales_invoice.py +11 -2
  12. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/controllers/defaults.py +8 -2
  13. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/controllers/pricing_rule.py +92 -3
  14. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/database.py +41 -1
  15. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/selling/sales_order.py +10 -11
  16. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/stock/delivery_note.py +2 -1
  17. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/stock/purchase_receipt.py +22 -16
  18. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/stock/stock_entry.py +3 -7
  19. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/stock/stock_ledger.py +69 -37
  20. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/pyproject.toml +1 -1
  21. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/.gitignore +0 -0
  22. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/LICENSE +0 -0
  23. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/__init__.py +0 -0
  24. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/bootstrap.py +0 -0
  25. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/chat_links.py +0 -0
  26. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/demo_limits.py +0 -0
  27. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/deps.py +0 -0
  28. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/erp_context.py +0 -0
  29. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/errors.py +0 -0
  30. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/list_values.py +0 -0
  31. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/mcp_oauth.py +0 -0
  32. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/mentions.py +0 -0
  33. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/menu_layout.py +0 -0
  34. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/oauth.py +0 -0
  35. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/pdf.py +0 -0
  36. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/pdf_contract.py +0 -0
  37. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/pdf_exports.py +0 -0
  38. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/pdf_fonts.py +0 -0
  39. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/pdf_pages.py +0 -0
  40. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/pdf_profiles.py +0 -0
  41. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/pdf_templates.py +0 -0
  42. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/providers.py +0 -0
  43. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/record_notes.py +0 -0
  44. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/remarks_md.py +0 -0
  45. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/__init__.py +0 -0
  46. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/accounting.py +0 -0
  47. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/actions.py +0 -0
  48. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/admin.py +0 -0
  49. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/analytics.py +0 -0
  50. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/availability.py +0 -0
  51. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/bank_reconciliation.py +0 -0
  52. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/bank_statements.py +0 -0
  53. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/chat_api.py +0 -0
  54. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/documents.py +0 -0
  55. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/mcp.py +0 -0
  56. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/pdf_templates.py +0 -0
  57. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/proposals.py +0 -0
  58. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/reports.py +0 -0
  59. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/routers/setup.py +0 -0
  60. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/services.py +0 -0
  61. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/templates/_proposal_totals.html +0 -0
  62. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/templates/_record_base.html +0 -0
  63. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/templates/delivery_note.html +0 -0
  64. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/templates/document.html +0 -0
  65. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/templates/proposal.html +0 -0
  66. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/templates/record.html +0 -0
  67. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/templates/reservation.html +0 -0
  68. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/time_filters.py +0 -0
  69. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/tool_permissions.py +0 -0
  70. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/api/write_context.py +0 -0
  71. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/docs/agents/README.md +0 -0
  72. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/frontend/LICENSE +0 -0
  73. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/frontend/README.md +0 -0
  74. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/frontend/src/api/client.ts +0 -0
  75. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/__init__.py +0 -0
  76. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/__init__.py +0 -0
  77. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/bank_account.py +0 -0
  78. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  79. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/bank_statement_import.py +0 -0
  80. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/bank_transaction.py +0 -0
  81. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/budget.py +0 -0
  82. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/camt.py +0 -0
  83. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  84. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/general_ledger.py +0 -0
  85. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/journal_entry.py +0 -0
  86. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/payment_entry.py +0 -0
  87. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/revaluation.py +0 -0
  88. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/settlement.py +0 -0
  89. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/__init__.py +0 -0
  90. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/engine.py +0 -0
  91. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/pack.py +0 -0
  92. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  93. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  94. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  95. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  96. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  97. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  98. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/profiles.py +0 -0
  99. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/setup/spine.py +0 -0
  100. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/accounting/subscription.py +0 -0
  101. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/assets/__init__.py +0 -0
  102. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/assets/asset.py +0 -0
  103. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/assets/lifecycle.py +0 -0
  104. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/assets/reservation.py +0 -0
  105. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/buying/__init__.py +0 -0
  106. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/buying/purchase_order.py +0 -0
  107. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/controllers/__init__.py +0 -0
  108. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/controllers/currency.py +0 -0
  109. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/controllers/item_price.py +0 -0
  110. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/controllers/item_prices.py +0 -0
  111. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  112. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/exceptions.py +0 -0
  113. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/hooks.py +0 -0
  114. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/master_integrity.py +0 -0
  115. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/model.py +0 -0
  116. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/selling/__init__.py +0 -0
  117. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/selling/proposal.py +0 -0
  118. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/selling/quotation.py +0 -0
  119. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/simulation.py +0 -0
  120. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/stock/__init__.py +0 -0
  121. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/timestamps.py +0 -0
  122. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/utils.py +0 -0
  123. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/validation.py +0 -0
  124. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/lambda_erp/workflow.py +0 -0
  125. {lambda_erp-1.4.0 → lambda_erp-1.4.2}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.4.0
3
+ Version: 1.4.2
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -164,6 +164,7 @@ The result is booked in one step — chart of accounts, sensible default account
164
164
  - Custom analytics drafts via chat (persisted, shareable, editable)
165
165
  - Server-side aggregation tool for in-chat factual answers across large datasets
166
166
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
167
+ - **PDF output with your own layouts** — every offer, order, invoice, purchase order, delivery note, proposal and reservation prints as a PDF. Layouts are HTML/CSS templates with your own images and fonts, edited in the settings or simply by asking the chat ("make the offer layout look like our letterhead"), previewed against real documents, then published as versions; submitted documents keep the exact layout they were issued with. See [`docs/pdf-output.md`](docs/pdf-output.md).
167
168
  - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
168
169
  - **MCP server** — the ERP speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `POST /api/mcp`, so **any MCP-capable agent (Claude, Codex, an IDE assistant) can drive it directly** — list/create/update/submit documents, manage master data, run reports — as *structured, validated tool calls*, not brittle screen-scraping or hand-written HTTP glue. The tool list is built from the live registries, so a plugin's own doctypes (e.g. a CRM's leads/contacts) are exposed automatically. It reuses the *same* Bearer keys and roles as the REST API — a viewer key is read-only, a manager key can write — so access is scoped, auditable, and off by default. In practice: point your assistant at your instance and ask "which invoices are overdue?" or "create a quotation for Acme with 3× SVC-SPARK" and it operates on real data, from wherever you already work. The API-keys settings page hands you ready-to-paste Claude and Codex config the moment you create a key.
169
170
  - Auth with admin/manager/viewer roles plus a public demo mode
@@ -178,7 +179,6 @@ The result is booked in one step — chart of accounts, sensible default account
178
179
  - Manufacturing (BOM, work orders)
179
180
  - HR / Payroll beyond the journal-entry workaround
180
181
  - Regional compliance packs (GST, VAT returns, etc.) - see below
181
- - PDF report creation directly inside the chat
182
182
 
183
183
  ---
184
184
 
@@ -134,6 +134,7 @@ The result is booked in one step — chart of accounts, sensible default account
134
134
  - Custom analytics drafts via chat (persisted, shareable, editable)
135
135
  - Server-side aggregation tool for in-chat factual answers across large datasets
136
136
  - PDF / image attachment → add invoices, create quotations, etc. all directly by adding them in the chat
137
+ - **PDF output with your own layouts** — every offer, order, invoice, purchase order, delivery note, proposal and reservation prints as a PDF. Layouts are HTML/CSS templates with your own images and fonts, edited in the settings or simply by asking the chat ("make the offer layout look like our letterhead"), previewed against real documents, then published as versions; submitted documents keep the exact layout they were issued with. See [`docs/pdf-output.md`](docs/pdf-output.md).
137
138
  - **Programmatic API** — drive the ERP from external systems with per-user, role-capped Bearer keys: a [chat API](docs/chat-api.md) (converse with the agent) and a [REST API](docs/rest-api.md) (documents, masters, reports — the same endpoints the web app uses, so a connector or sync job can create and submit documents directly). Both off by default; an admin enables each independently and issues keys.
138
139
  - **MCP server** — the ERP speaks the [Model Context Protocol](https://modelcontextprotocol.io) at `POST /api/mcp`, so **any MCP-capable agent (Claude, Codex, an IDE assistant) can drive it directly** — list/create/update/submit documents, manage master data, run reports — as *structured, validated tool calls*, not brittle screen-scraping or hand-written HTTP glue. The tool list is built from the live registries, so a plugin's own doctypes (e.g. a CRM's leads/contacts) are exposed automatically. It reuses the *same* Bearer keys and roles as the REST API — a viewer key is read-only, a manager key can write — so access is scoped, auditable, and off by default. In practice: point your assistant at your instance and ask "which invoices are overdue?" or "create a quotation for Acme with 3× SVC-SPARK" and it operates on real data, from wherever you already work. The API-keys settings page hands you ready-to-paste Claude and Codex config the moment you create a key.
139
140
  - Auth with admin/manager/viewer roles plus a public demo mode
@@ -148,7 +149,6 @@ The result is booked in one step — chart of accounts, sensible default account
148
149
  - Manufacturing (BOM, work orders)
149
150
  - HR / Payroll beyond the journal-entry workaround
150
151
  - Regional compliance packs (GST, VAT returns, etc.) - see below
151
- - PDF report creation directly inside the chat
152
152
 
153
153
  ---
154
154
 
@@ -9,6 +9,7 @@ import time
9
9
  import uuid
10
10
  from typing import Optional
11
11
 
12
+ import httpx
12
13
  import openpyxl
13
14
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
14
15
  from fastapi.responses import Response
@@ -17,6 +18,7 @@ from openai import OpenAI
17
18
  from lambda_erp.database import get_db
18
19
  from lambda_erp.utils import now
19
20
  from api.auth import require_role, get_current_user
21
+ from api.llm_provider import client_kwargs
20
22
  from api.demo_limits import demo_max_attachment_bytes, is_demo_role
21
23
 
22
24
  logger = logging.getLogger(__name__)
@@ -105,7 +107,6 @@ _OPENAI_FILE_TTL_SECONDS = 30 * 24 * 3600 # 30 days
105
107
  _OPENAI_FILE_REUSE_MARGIN = 300 # re-upload if within 5 min of expiry
106
108
  SPREADSHEET_ROW_WARN_LIMIT = 1000 # OpenAI augments ~1000 rows/sheet
107
109
 
108
- _openai_singleton: Optional[OpenAI] = None
109
110
  _expires_after_supported = True # flips off if the API rejects it once
110
111
 
111
112
  # The single Office spreadsheet mime we can cheaply row-count (openpyxl).
@@ -113,12 +114,9 @@ _XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
113
114
 
114
115
 
115
116
  def _openai_client() -> OpenAI:
116
- """Lazily build a module-level OpenAI client for Files uploads (a separate
117
- call from the chat completion; same OPENAI_API_KEY)."""
118
- global _openai_singleton
119
- if _openai_singleton is None:
120
- _openai_singleton = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
121
- return _openai_singleton
117
+ """An OpenAI client for Files uploads (a separate call from the chat
118
+ turn; same provider and key, see api/llm_provider.py)."""
119
+ return OpenAI(**client_kwargs(httpx.Timeout(60.0, connect=10.0)))
122
120
 
123
121
 
124
122
  def _create_openai_file(att: dict):
@@ -762,6 +762,30 @@ def _setting_enabled(db, key: str) -> bool:
762
762
  return str(value) == "1"
763
763
 
764
764
 
765
+ def disable_public_demo_if_switched_off() -> int:
766
+ """With LAMBDA_ERP_ENABLE_PUBLIC_DEMO explicitly "0", no public demo user
767
+ stays active.
768
+
769
+ Demo mode is the enabled ``public_manager`` user: requests without a
770
+ credential fall back to it (get_current_user, the chat WebSocket). It is
771
+ created by booting with the flag at 1 or from the admin UI, and survived
772
+ switching the flag back off, leaving anonymous write access on a
773
+ deployment that once ran the demo. Unset keeps today's behaviour (the
774
+ admin UI toggle decides). Returns the number of users disabled.
775
+ """
776
+ if os.environ.get("LAMBDA_ERP_ENABLE_PUBLIC_DEMO") != "0":
777
+ return 0
778
+ db = get_db()
779
+ active = db.sql('SELECT name FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
780
+ for row in active:
781
+ db.set_value("User", row["name"], {"enabled": 0, "modified": now()})
782
+ if active:
783
+ db.commit()
784
+ print(f"[public-demo] LAMBDA_ERP_ENABLE_PUBLIC_DEMO=0: disabled {len(active)} public demo "
785
+ "user(s); requests without a credential are now refused", flush=True)
786
+ return len(active)
787
+
788
+
765
789
  def ensure_seed_admin() -> dict | None:
766
790
  """Idempotently provision an admin account from the environment.
767
791
 
@@ -39,6 +39,7 @@ from api.demo_limits import (
39
39
  is_demo_role,
40
40
  limiter as demo_limiter,
41
41
  )
42
+ from api.llm_provider import client_kwargs, llm_configured, not_configured_message
42
43
  from api.providers import cost_of_openai_call, cost_of_transcription
43
44
  from api.routers.masters import create_master_record, update_master_record, delete_master_record
44
45
  from api.routers.analytics import DATASET_FILTER_DESCRIPTION
@@ -3034,7 +3035,7 @@ Users can attach PDFs and images (receipts, bills, contracts, screenshots) to th
3034
3035
  # GPT-6.1 Sol acts as the planner/orchestrator. When it decides a report needs
3035
3036
  # a custom analytics view it calls the create/update custom analytics tools.
3036
3037
  # Those handlers make a separate, narrowly prompted GPT-6.1 Sol call for the bounded
3037
- # declarative report spec. Both roles use the existing OPENAI_API_KEY.
3038
+ # declarative report spec. Both roles use the same provider and key (api/llm_provider.py).
3038
3039
  # ---------------------------------------------------------------------------
3039
3040
 
3040
3041
 
@@ -3246,9 +3247,8 @@ def _generate_report_spec_via_openai(
3246
3247
  user_role: str | None = None,
3247
3248
  session_id: str | None = None,
3248
3249
  ) -> dict:
3249
- api_key = os.environ.get("OPENAI_API_KEY", "")
3250
- if not api_key or api_key == "sk-your-key-here":
3251
- raise RuntimeError("OPENAI_API_KEY is not configured")
3250
+ if not llm_configured():
3251
+ raise RuntimeError(not_configured_message())
3252
3252
 
3253
3253
  model = _report_specialist_model()
3254
3254
 
@@ -3278,10 +3278,7 @@ def _generate_report_spec_via_openai(
3278
3278
  )
3279
3279
  user_msg = "\n\n".join(user_parts)
3280
3280
 
3281
- client = OpenAI(
3282
- api_key=api_key,
3283
- timeout=httpx.Timeout(120.0, connect=10.0),
3284
- )
3281
+ client = OpenAI(**client_kwargs(httpx.Timeout(120.0, connect=10.0)))
3285
3282
  validation_feedback: str | None = None
3286
3283
  max_attempts = 2
3287
3284
 
@@ -3402,16 +3399,12 @@ async def generate_title(
3402
3399
  title shows up while the reply is still being written. Without a text
3403
3400
  message (attachments only) it runs after the first reply and uses that too.
3404
3401
  """
3405
- api_key = os.environ.get("OPENAI_API_KEY", "")
3406
- if not api_key or api_key == "sk-your-key-here":
3402
+ if not llm_configured():
3407
3403
  return
3408
3404
 
3409
3405
  title_model = _title_model()
3410
3406
  try:
3411
- client = OpenAI(
3412
- api_key=api_key,
3413
- timeout=httpx.Timeout(30.0, connect=5.0),
3414
- )
3407
+ client = OpenAI(**client_kwargs(httpx.Timeout(30.0, connect=5.0)))
3415
3408
  reservation_id = None
3416
3409
  if is_demo_role(user_role):
3417
3410
  _blocked, reservation_id = demo_limiter.reserve(
@@ -3708,15 +3701,14 @@ async def run_thinking_loop(
3708
3701
  declarative-spec step to a separate narrowly prompted GPT-6.1 Sol call. We emit an
3709
3702
  `llm_provider` event around that delegation so the UI can surface it.
3710
3703
  """
3711
- openai_api_key = os.environ.get("OPENAI_API_KEY", "")
3712
- if not openai_api_key or openai_api_key == "sk-your-key-here":
3713
- await on_event({"type": "error", "content": "Error: OPENAI_API_KEY is not configured. Please set it in the .env file."})
3704
+ if not llm_configured():
3705
+ await on_event({"type": "error", "content": f"Error: {not_configured_message()}. Set it in the environment (.env)."})
3714
3706
  return
3715
3707
 
3716
- openai_client = OpenAI(
3717
- api_key=openai_api_key,
3718
- timeout=httpx.Timeout(120.0, connect=10.0),
3719
- )
3708
+ openai_client = OpenAI(**client_kwargs(
3709
+ httpx.Timeout(120.0, connect=10.0),
3710
+ erp_user=user_info.get("name") if user_info else None,
3711
+ ))
3720
3712
 
3721
3713
  # Closures below read through this reference so a live role/key refresh
3722
3714
  # immediately affects resource-aware handlers as well as the shared policy.
@@ -4079,14 +4071,14 @@ VOICE_TRANSCRIBE_PROMPT = (
4079
4071
  _MIN_TRANSCRIBE_BYTES = 1500 # ~1s of webm/opus at the ~1.5 KB/s we assume below
4080
4072
 
4081
4073
 
4082
- def transcribe_audio(audio_bytes: bytes, audio_format: str, api_key: str) -> str:
4074
+ def transcribe_audio(audio_bytes: bytes, audio_format: str, erp_user: str | None = None) -> str:
4083
4075
  """Transcribe audio to text via OpenAI gpt-4o-transcribe.
4084
4076
 
4085
4077
  Returns the transcript, or "" when the model detects silence/near-silence
4086
4078
  (empty result or a prompt-echo hallucination on a near-silent clip).
4087
4079
  Raises on an actual API error so the caller can surface it.
4088
4080
  """
4089
- client = OpenAI(api_key=api_key, timeout=httpx.Timeout(60.0, connect=10.0))
4081
+ client = OpenAI(**client_kwargs(httpx.Timeout(60.0, connect=10.0), erp_user=erp_user))
4090
4082
 
4091
4083
  audio_file = io.BytesIO(audio_bytes)
4092
4084
  audio_file.name = f"voice.{audio_format or 'webm'}"
@@ -4583,11 +4575,10 @@ async def chat_websocket(
4583
4575
  # Voice → text: transcribe a recorded clip and return the text
4584
4576
  # for the composer. No LLM turn here — the user reviews/edits
4585
4577
  # the text and sends it like any typed message.
4586
- openai_api_key = os.environ.get("OPENAI_API_KEY", "")
4587
- if not openai_api_key or openai_api_key == "sk-your-key-here":
4578
+ if not llm_configured():
4588
4579
  await send_event(
4589
4580
  "transcription_result", request_id=request_id,
4590
- success=False, error="Speech-to-text is not available (OPENAI_API_KEY not configured).",
4581
+ success=False, error=f"Speech-to-text is not available ({not_configured_message()}).",
4591
4582
  )
4592
4583
  continue
4593
4584
 
@@ -4620,7 +4611,7 @@ async def chat_websocket(
4620
4611
 
4621
4612
  try:
4622
4613
  text = await asyncio.to_thread(
4623
- transcribe_audio, audio_bytes, audio_format, openai_api_key,
4614
+ transcribe_audio, audio_bytes, audio_format, ws_user_id,
4624
4615
  )
4625
4616
  except Exception as e:
4626
4617
  logger.warning("Transcription failed: %s", e)
@@ -0,0 +1,97 @@
1
+ """Where the ERP's model calls go.
2
+
3
+ LAMBDA_ERP_LLM_PROVIDER=openai (default) OpenAI directly with OPENAI_API_KEY.
4
+ OPENAI_BASE_URL is honoured by the SDK as
5
+ before, for any OpenAI-compatible endpoint.
6
+ LAMBDA_ERP_LLM_PROVIDER=lambda Lambda's model gateway (lambda.dev): the
7
+ same OpenAI API, billed to the Lambda
8
+ organization the key belongs to.
9
+ LAMBDA_LLM_API_KEY a Lambda model key
10
+ (scope llm:erp)
11
+ LAMBDA_LLM_BASE_URL default
12
+ https://platform.lambda.dev/v1/llm/openai
13
+
14
+ The gateway relays what the ERP uses: Responses (chat, report specialist,
15
+ titles), file uploads for attachments and voice transcription. Chat
16
+ Completions (ERP_CHAT_API=chat) is not available through it. With the lambda
17
+ provider each call names the ERP user (``X-Lambda-ERP-User``, the login name),
18
+ so Lambda's usage view can split the organization's cost by person; OpenAI
19
+ never sees that header.
20
+
21
+ Every OpenAI client in the ERP is built from ``client_kwargs()``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from dataclasses import dataclass
28
+ from typing import Optional
29
+
30
+ import httpx
31
+ from openai import OpenAI
32
+
33
+ DEFAULT_LAMBDA_BASE_URL = "https://platform.lambda.dev/v1/llm/openai"
34
+ ERP_USER_HEADER = "X-Lambda-ERP-User"
35
+ _PLACEHOLDERS = {"", "sk-your-key-here"}
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class LLMSettings:
40
+ provider: str # "openai" or "lambda"
41
+ api_key: str
42
+ base_url: Optional[str] # None: the SDK's default (or OPENAI_BASE_URL)
43
+ key_variable: str # the env variable the key comes from, for messages
44
+
45
+ @property
46
+ def configured(self) -> bool:
47
+ return self.api_key.strip() not in _PLACEHOLDERS
48
+
49
+
50
+ def llm_settings() -> LLMSettings:
51
+ """Read on every call, so a changed environment applies without a restart."""
52
+ provider = (os.environ.get("LAMBDA_ERP_LLM_PROVIDER") or "openai").strip().lower()
53
+ if provider == "lambda":
54
+ return LLMSettings(
55
+ provider="lambda",
56
+ api_key=os.environ.get("LAMBDA_LLM_API_KEY", ""),
57
+ base_url=(os.environ.get("LAMBDA_LLM_BASE_URL") or DEFAULT_LAMBDA_BASE_URL).rstrip("/"),
58
+ key_variable="LAMBDA_LLM_API_KEY",
59
+ )
60
+ if provider != "openai":
61
+ raise RuntimeError(f"LAMBDA_ERP_LLM_PROVIDER={provider!r}: use 'openai' or 'lambda'")
62
+ return LLMSettings(provider="openai", api_key=os.environ.get("OPENAI_API_KEY", ""),
63
+ base_url=None, key_variable="OPENAI_API_KEY")
64
+
65
+
66
+ def llm_configured() -> bool:
67
+ try:
68
+ return llm_settings().configured
69
+ except RuntimeError:
70
+ return False
71
+
72
+
73
+ def not_configured_message() -> str:
74
+ try:
75
+ settings = llm_settings()
76
+ except RuntimeError as exc:
77
+ return str(exc)
78
+ return f"{settings.key_variable} is not configured"
79
+
80
+
81
+ def client_kwargs(timeout: httpx.Timeout, erp_user: Optional[str] = None) -> dict:
82
+ """Keyword arguments for ``OpenAI(...)`` with the configured provider."""
83
+ settings = llm_settings()
84
+ headers = {}
85
+ if settings.provider == "lambda" and erp_user:
86
+ headers[ERP_USER_HEADER] = str(erp_user)[:100]
87
+ kwargs = {"api_key": settings.api_key, "timeout": timeout}
88
+ if settings.base_url:
89
+ kwargs["base_url"] = settings.base_url
90
+ if headers:
91
+ kwargs["default_headers"] = headers
92
+ return kwargs
93
+
94
+
95
+ def openai_client(timeout: httpx.Timeout, erp_user: Optional[str] = None) -> OpenAI:
96
+ """An OpenAI client for the configured provider."""
97
+ return OpenAI(**client_kwargs(timeout, erp_user))
@@ -88,8 +88,9 @@ async def lifespan(app: FastAPI):
88
88
  # Seed a deterministic admin from the environment (if configured) before
89
89
  # anyone can register. On deployments whose DB is recreated each rollout
90
90
  # this stops the first visitor after a redeploy from grabbing admin.
91
- from api.auth import ensure_seed_admin
91
+ from api.auth import disable_public_demo_if_switched_off, ensure_seed_admin
92
92
  ensure_seed_admin()
93
+ disable_public_demo_if_switched_off()
93
94
 
94
95
  # When packaged as a demo container, land visitors straight in demo mode.
95
96
  if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
@@ -270,6 +270,8 @@ def _validate_master_input(master_type, doctype, data, name=None):
270
270
  for field, table in MASTER_LINK_FIELDS.get(master_type, {}).items():
271
271
  if merged.get(field) and not db.exists(table, merged[field]):
272
272
  raise ValidationError(f'{doctype}: {field} must reference an existing {table}')
273
+ if master_type == 'item' and data.get('valuation_method') not in (None, '', 'Moving Average'):
274
+ raise ValidationError('Item: valuation_method must be Moving Average; it is the only valuation the stock ledger implements')
273
275
  if master_type == 'company' and data.get('default_sales_tax_template'):
274
276
  template = db.get_value('Tax Template', data['default_sales_tax_template'], ['company', 'tax_type'])
275
277
  if not template or template['company'] != (name or data.get('name')) or template['tax_type'] != 'Sales':
@@ -300,6 +302,9 @@ def _create_master_record(master_type: str, data: dict) -> dict:
300
302
  doc = _normalize_master_data(data)
301
303
  doc.pop("creation", None)
302
304
  doc.pop("modified", None)
305
+ if master_type == "item" and not doc.get("valuation_method"):
306
+ # Databases created before 1.4.2 still carry the column default FIFO.
307
+ doc["valuation_method"] = "Moving Average"
303
308
 
304
309
  # Let callers set the code under its intuitive alias (item_code -> name).
305
310
  # Conflicting identities must never silently select one of the records.
@@ -20,6 +20,7 @@ from lambda_erp.controllers.defaults import (
20
20
  from lambda_erp.controllers.item_price import set_item_defaults
21
21
  from lambda_erp.exceptions import ValidationError
22
22
  from lambda_erp.stock.stock_ledger import (
23
+ make_cancel_sl_entries,
23
24
  make_sl_entries,
24
25
  build_sell_side_sles,
25
26
  build_cost_basis_gl,
@@ -183,7 +184,7 @@ class POSInvoice(Document):
183
184
  if flt(self._data.get("update_stock")):
184
185
  reversed_sles = reverse_stock_sles(self._get_sl_entries())
185
186
  if reversed_sles:
186
- make_sl_entries(reversed_sles, allow_negative_stock=True)
187
+ make_cancel_sl_entries(reversed_sles)
187
188
 
188
189
  make_reverse_gl_entries(
189
190
  voucher_type=self.DOCTYPE,
@@ -21,9 +21,11 @@ from lambda_erp.controllers.defaults import (
21
21
  from lambda_erp.controllers.item_price import set_item_defaults
22
22
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
23
23
  from lambda_erp.stock.stock_ledger import (
24
+ make_cancel_sl_entries,
24
25
  make_sl_entries,
25
26
  build_buy_side_sles,
26
27
  reverse_stock_sles,
28
+ stock_account_for,
27
29
  )
28
30
  from lambda_erp.exceptions import ValidationError
29
31
 
@@ -123,9 +125,6 @@ class PurchaseInvoice(Document):
123
125
  stock_received_account = db.get_value(
124
126
  "Company", self.company, "stock_received_but_not_billed"
125
127
  )
126
- stock_in_hand_account = db.get_value(
127
- "Company", self.company, "stock_in_hand_account"
128
- )
129
128
  default_cc = db.get_value("Company", self.company, "default_cost_center")
130
129
  directly_receiving = flt(self.get("update_stock")) == 1
131
130
 
@@ -134,8 +133,10 @@ class PurchaseInvoice(Document):
134
133
  is_stock = 0
135
134
  if item.get("item_code"):
136
135
  is_stock = db.get_value("Item", item["item_code"], "is_stock_item") or 0
137
- if is_stock and directly_receiving and stock_in_hand_account:
138
- item["expense_account"] = stock_in_hand_account
136
+ stock_account = (stock_account_for(self.company, item.get("warehouse"))
137
+ if is_stock and directly_receiving else None)
138
+ if stock_account:
139
+ item["expense_account"] = stock_account
139
140
  elif is_stock and stock_received_account:
140
141
  item["expense_account"] = stock_received_account
141
142
  else:
@@ -200,7 +201,12 @@ class PurchaseInvoice(Document):
200
201
  def _validate_no_double_receipt(self):
201
202
  """Block update_stock=1 when the referenced Purchase Order already has
202
203
  a Purchase Receipt for the line. Otherwise stock arrives twice: once
203
- via the PR and again when this invoice submits. Returns are exempt."""
204
+ via the PR and again when this invoice submits. Returns are exempt.
205
+
206
+ A line linked to its order line (`purchase_order_item`) is covered more
207
+ precisely by workflow.validate_order_quantities (receipts plus
208
+ direct-stock invoices may not exceed the ordered quantity), so the rest
209
+ of a partly received line can still be received with this invoice."""
204
210
  if not flt(self.get("update_stock")) or self.is_return:
205
211
  return
206
212
  db = get_db()
@@ -209,6 +215,8 @@ class PurchaseInvoice(Document):
209
215
  po_item = item.get("purchase_order_item")
210
216
  if not (po or po_item):
211
217
  continue
218
+ if po_item:
219
+ continue # order-line capacity check (validate_order_quantities)
212
220
  pr_rows = db.sql(
213
221
  'SELECT pr.name AS pr_name '
214
222
  'FROM "Purchase Receipt Item" pri '
@@ -309,7 +317,7 @@ class PurchaseInvoice(Document):
309
317
  if flt(self.get("update_stock")):
310
318
  reversed_sles = reverse_stock_sles(self._get_stock_sl_entries())
311
319
  if reversed_sles:
312
- make_sl_entries(reversed_sles, allow_negative_stock=True)
320
+ make_cancel_sl_entries(reversed_sles)
313
321
 
314
322
  make_reverse_gl_entries(
315
323
  voucher_type=self.DOCTYPE,
@@ -25,6 +25,7 @@ from lambda_erp.controllers.defaults import (
25
25
  from lambda_erp.controllers.item_price import set_item_defaults
26
26
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
27
27
  from lambda_erp.stock.stock_ledger import (
28
+ make_cancel_sl_entries,
28
29
  make_sl_entries,
29
30
  build_sell_side_sles,
30
31
  build_cost_basis_gl,
@@ -167,7 +168,13 @@ class SalesInvoice(Document):
167
168
  """Block update_stock=1 when the referenced Sales Order already has a
168
169
  Delivery Note for the line. Otherwise stock ships twice: once via the
169
170
  DN and again when this invoice submits. Returns are exempt (they
170
- reverse the SI's own earlier shipment, not a separate DN)."""
171
+ reverse the SI's own earlier shipment, not a separate DN).
172
+
173
+ A line linked to its order line (`sales_order_item`) is covered more
174
+ precisely by workflow.validate_order_quantities: delivery notes plus
175
+ direct-stock invoices may not move more than the line ordered, so the
176
+ rest of a partly delivered line can still ship with this invoice. The
177
+ coarse check stays for lines that only name the order."""
171
178
  if not flt(self.get("update_stock")) or self.is_return:
172
179
  return
173
180
  db = get_db()
@@ -176,6 +183,8 @@ class SalesInvoice(Document):
176
183
  so = item.get("sales_order")
177
184
  if not (so or so_item):
178
185
  continue
186
+ if item.get("sales_order_item"):
187
+ continue # order-line capacity check (validate_order_quantities)
179
188
  dn_rows = db.sql(
180
189
  'SELECT dn.name AS dn_name '
181
190
  'FROM "Delivery Note Item" dni '
@@ -305,7 +314,7 @@ class SalesInvoice(Document):
305
314
  if flt(self.get("update_stock")):
306
315
  reversed_sles = reverse_stock_sles(self._get_stock_sl_entries())
307
316
  if reversed_sles:
308
- make_sl_entries(reversed_sles, allow_negative_stock=True)
317
+ make_cancel_sl_entries(reversed_sles)
309
318
 
310
319
  make_reverse_gl_entries(
311
320
  voucher_type=self.DOCTYPE,
@@ -109,6 +109,12 @@ def derived_pricing_fields(source, *, is_return=False):
109
109
 
110
110
 
111
111
  def derived_line_pricing_fields(item):
112
- return {field: item.get(field) for field in (
113
- "ignore_pricing_rule", "external_line_reference", "pricing_rule",
112
+ """Line pricing provenance for a converted line. The price was agreed in
113
+ the source document, so pricing rules must not re-run on it (with a later
114
+ date or a newly added rule); lines added to the new document still get
115
+ rules."""
116
+ fields = {field: item.get(field) for field in (
117
+ "external_line_reference", "pricing_rule",
114
118
  )}
119
+ fields["ignore_pricing_rule"] = 1
120
+ return fields