lambda-erp 0.6.14__tar.gz → 0.6.16__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 (84) hide show
  1. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/PKG-INFO +2 -1
  2. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/attachments.py +154 -7
  3. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/chat.py +14 -8
  4. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/frontend/src/api/client.ts +1 -0
  5. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/database.py +15 -1
  6. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/pyproject.toml +2 -1
  7. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/.gitignore +0 -0
  8. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/LICENSE +0 -0
  9. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/README.md +0 -0
  10. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/__init__.py +0 -0
  11. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/auth.py +0 -0
  12. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/bootstrap.py +0 -0
  13. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/demo_limits.py +0 -0
  14. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/deps.py +0 -0
  15. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/errors.py +0 -0
  16. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/main.py +0 -0
  17. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/oauth.py +0 -0
  18. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/pdf.py +0 -0
  19. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/providers.py +0 -0
  20. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/remarks_md.py +0 -0
  21. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/__init__.py +0 -0
  22. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/accounting.py +0 -0
  23. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/analytics.py +0 -0
  25. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/bank_reconciliation.py +0 -0
  26. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/chat_api.py +0 -0
  27. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/documents.py +0 -0
  28. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/masters.py +0 -0
  29. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/mcp.py +0 -0
  30. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/proposals.py +0 -0
  31. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/reports.py +0 -0
  32. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/routers/setup.py +0 -0
  33. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/services.py +0 -0
  34. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/templates/document.html +0 -0
  35. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/api/templates/proposal.html +0 -0
  36. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/docs/agents/README.md +0 -0
  37. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/frontend/LICENSE +0 -0
  38. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/frontend/README.md +0 -0
  39. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/__init__.py +0 -0
  40. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/__init__.py +0 -0
  41. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/bank_transaction.py +0 -0
  42. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/budget.py +0 -0
  43. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  44. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/general_ledger.py +0 -0
  45. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/journal_entry.py +0 -0
  46. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/payment_entry.py +0 -0
  47. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/pos_invoice.py +0 -0
  48. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/purchase_invoice.py +0 -0
  49. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/revaluation.py +0 -0
  50. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/sales_invoice.py +0 -0
  51. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/__init__.py +0 -0
  52. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/engine.py +0 -0
  53. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/pack.py +0 -0
  54. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  55. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  56. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  57. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  58. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  59. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  60. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/profiles.py +0 -0
  61. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/setup/spine.py +0 -0
  62. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/accounting/subscription.py +0 -0
  63. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/buying/__init__.py +0 -0
  64. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/buying/purchase_order.py +0 -0
  65. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/__init__.py +0 -0
  66. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/currency.py +0 -0
  67. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/defaults.py +0 -0
  68. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/pricing_rule.py +0 -0
  69. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  70. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/exceptions.py +0 -0
  71. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/hooks.py +0 -0
  72. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/model.py +0 -0
  73. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/__init__.py +0 -0
  74. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/proposal.py +0 -0
  75. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/quotation.py +0 -0
  76. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/selling/sales_order.py +0 -0
  77. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/simulation.py +0 -0
  78. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/__init__.py +0 -0
  79. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/delivery_note.py +0 -0
  80. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/purchase_receipt.py +0 -0
  81. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/stock_entry.py +0 -0
  82. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/stock/stock_ledger.py +0 -0
  83. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/lambda_erp/utils.py +0 -0
  84. {lambda_erp-0.6.14 → lambda_erp-0.6.16}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.6.14
3
+ Version: 0.6.16
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -13,6 +13,7 @@ Requires-Dist: holidays>=0.40
13
13
  Requires-Dist: httpx>=0.27
14
14
  Requires-Dist: jinja2>=3.1
15
15
  Requires-Dist: openai>=1.0
16
+ Requires-Dist: openpyxl>=3.1
16
17
  Requires-Dist: pydantic>=2.0
17
18
  Requires-Dist: pypdf>=4.0
18
19
  Requires-Dist: python-dateutil>=2.8
@@ -1,17 +1,26 @@
1
1
  """Chat attachment upload/download. Files stored on the local filesystem."""
2
2
 
3
3
  import base64
4
+ import io
5
+ import logging
4
6
  import os
7
+ import tempfile
8
+ import time
5
9
  import uuid
10
+ from typing import Optional
6
11
 
12
+ import openpyxl
7
13
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
8
14
  from fastapi.responses import Response
15
+ from openai import OpenAI
9
16
 
10
17
  from lambda_erp.database import get_db
11
18
  from lambda_erp.utils import now
12
19
  from api.auth import require_role, get_current_user
13
20
  from api.demo_limits import demo_max_attachment_bytes, is_demo_role
14
21
 
22
+ logger = logging.getLogger(__name__)
23
+
15
24
  router = APIRouter(prefix="/chat", tags=["chat-attachments"])
16
25
 
17
26
  MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10 MB
@@ -66,7 +75,126 @@ _EXT_TO_MIME = {
66
75
  }
67
76
  _MIME_TO_EXT = {m: e for e, m in _EXT_TO_MIME.items()}
68
77
 
69
- UPLOAD_ROOT = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "uploads")
78
+
79
+ # --- OpenAI Files API (Office documents) -----------------------------------
80
+ #
81
+ # The chat model can't read Office bytes from an inline base64 `file_data` (that
82
+ # path is PDF-only), but it CAN read them when uploaded to the Files API and
83
+ # referenced by file_id. We upload each Office attachment once, cache the id on
84
+ # its row, and reuse it across turns. Every upload carries a 30-day
85
+ # `expires_after` so OpenAI reclaims the file on its own (users rarely delete
86
+ # chats and lambda-erp has no scheduler) — if a stale id is ever referenced we
87
+ # simply re-upload, since the bytes stay in our own storage. The per-use cost is
88
+ # the file's tokens on the completion, already billed via providers.py; there's
89
+ # no separate file fee to book.
90
+ _OPENAI_FILE_TTL_SECONDS = 30 * 24 * 3600 # 30 days
91
+ _OPENAI_FILE_REUSE_MARGIN = 300 # re-upload if within 5 min of expiry
92
+ SPREADSHEET_ROW_WARN_LIMIT = 1000 # OpenAI augments ~1000 rows/sheet
93
+
94
+ _openai_singleton: Optional[OpenAI] = None
95
+ _expires_after_supported = True # flips off if the API rejects it once
96
+
97
+ # The single Office spreadsheet mime we can cheaply row-count (openpyxl).
98
+ _XLSX_MIME = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
99
+
100
+
101
+ def _openai_client() -> OpenAI:
102
+ """Lazily build a module-level OpenAI client for Files uploads (a separate
103
+ call from the chat completion; same OPENAI_API_KEY)."""
104
+ global _openai_singleton
105
+ if _openai_singleton is None:
106
+ _openai_singleton = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
107
+ return _openai_singleton
108
+
109
+
110
+ def _create_openai_file(att: dict):
111
+ """Upload the attachment's bytes to the Files API. Tries once with a 30-day
112
+ `expires_after`; if the SDK/API rejects it (older SDK, or the purpose doesn't
113
+ honor it) we disable it process-wide and fall back to a plain upload so
114
+ attachments keep working (accumulation is then unbounded until a sweep)."""
115
+ global _expires_after_supported
116
+ client = _openai_client()
117
+ file_tuple = (att["filename"], att["data"], att["mime_type"])
118
+ if _expires_after_supported:
119
+ try:
120
+ return client.files.create(
121
+ file=file_tuple,
122
+ purpose="user_data",
123
+ expires_after={"anchor": "created_at", "seconds": _OPENAI_FILE_TTL_SECONDS},
124
+ )
125
+ except Exception as e: # noqa: BLE001 — unsupported param/purpose -> fall back
126
+ _expires_after_supported = False
127
+ logger.warning("Files API expires_after unsupported (%s); using plain uploads", e)
128
+ return client.files.create(file=file_tuple, purpose="user_data")
129
+
130
+
131
+ def ensure_openai_file(att: dict) -> Optional[str]:
132
+ """Return a currently-valid OpenAI file_id for an Office attachment, uploading
133
+ it (once) when there's no cached id or the cached one is at/near expiry.
134
+ Mutates `att` and persists the id + expiry on the Chat Attachment row.
135
+ Returns None on failure (caller degrades to a text note). Blocking — both
136
+ call sites run it in a worker thread, off the event loop."""
137
+ fid = att.get("openai_file_id")
138
+ exp = att.get("openai_file_expires_at")
139
+ if fid and (not exp or time.time() < int(exp) - _OPENAI_FILE_REUSE_MARGIN):
140
+ return fid
141
+ try:
142
+ uploaded = _create_openai_file(att)
143
+ except Exception as e: # noqa: BLE001 — surface as "couldn't load", not a 500
144
+ logger.warning("OpenAI file upload failed for %s: %s", att.get("id"), e)
145
+ return None
146
+
147
+ new_id = uploaded.id
148
+ new_exp = getattr(uploaded, "expires_at", None)
149
+ att["openai_file_id"] = new_id
150
+ att["openai_file_expires_at"] = new_exp
151
+ try:
152
+ db = get_db()
153
+ db.sql(
154
+ 'UPDATE "Chat Attachment" SET openai_file_id = ?, openai_file_expires_at = ? WHERE id = ?',
155
+ [new_id, new_exp, att.get("id")],
156
+ )
157
+ db.conn.commit()
158
+ except Exception as e: # persistence is best-effort — the id still works this turn
159
+ logger.warning("Could not persist openai_file_id for %s: %s", att.get("id"), e)
160
+ return new_id
161
+
162
+
163
+ def spreadsheet_row_warning(data: bytes, mime: str, filename: str) -> Optional[str]:
164
+ """If an .xlsx has a sheet over the model's ~1000-row augmentation limit,
165
+ return a short user-facing warning (else None). Only .xlsx is inspected —
166
+ openpyxl read-only, dimensions only (NOT a content extraction); other
167
+ spreadsheet formats upload without a row check."""
168
+ if mime != _XLSX_MIME:
169
+ return None
170
+ try:
171
+ wb = openpyxl.load_workbook(io.BytesIO(data), read_only=True)
172
+ worst, worst_sheet = 0, None
173
+ for ws in wb.worksheets:
174
+ rows = ws.max_row or 0
175
+ if rows > worst:
176
+ worst, worst_sheet = rows, ws.title
177
+ wb.close()
178
+ except Exception as e: # a warning is best-effort — never block the upload
179
+ logger.info("Row-count check skipped for %s: %s", filename, e)
180
+ return None
181
+ if worst > SPREADSHEET_ROW_WARN_LIMIT:
182
+ return (f"Sheet “{worst_sheet}” has {worst:,} rows — the assistant reads about "
183
+ f"{SPREADSHEET_ROW_WARN_LIMIT:,} rows per sheet, so results may be partial. "
184
+ "Split the file or ask about a specific range.")
185
+ return None
186
+
187
+ # Where uploaded files are stored. Must be a WRITABLE path — a package-relative
188
+ # location (e.g. next to this module) resolves under site-packages when the
189
+ # package is pip-installed in a container, which is read-only for the non-root
190
+ # app user, so every upload 500s with "[Errno 13] Permission denied". Default to
191
+ # the OS temp dir (always writable; chat attachments are session-scoped and
192
+ # get_attachments_by_ids tolerates a missing file). Deployments that need the
193
+ # files to survive a restart set LAMBDA_ERP_UPLOAD_DIR to a mounted volume.
194
+ UPLOAD_ROOT = (
195
+ os.environ.get("LAMBDA_ERP_UPLOAD_DIR")
196
+ or os.path.join(tempfile.gettempdir(), "lambda-erp-uploads")
197
+ )
70
198
 
71
199
 
72
200
  def _ensure_upload_dir(user_id: str) -> str:
@@ -159,13 +287,19 @@ async def upload_attachment(
159
287
  )
160
288
  db.conn.commit()
161
289
 
162
- return {
290
+ resp = {
163
291
  "id": att_id,
164
292
  "filename": file.filename or f"file.{ext}",
165
293
  "mime_type": mime,
166
294
  "size_bytes": len(data),
167
295
  "created_at": now(),
168
296
  }
297
+ # Advisory (non-blocking): warn if a spreadsheet exceeds the model's
298
+ # per-sheet augmentation limit, so the user knows results may be partial.
299
+ warning = spreadsheet_row_warning(data, mime, file.filename or "")
300
+ if warning:
301
+ resp["warning"] = warning
302
+ return resp
169
303
 
170
304
 
171
305
  @router.get("/attachments/{attachment_id}")
@@ -212,7 +346,8 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
212
346
  db = get_db()
213
347
  placeholders = ",".join(["?"] * len(attachment_ids))
214
348
  rows = db.sql(
215
- f'SELECT id, filename, mime_type, file_path, size_bytes FROM "Chat Attachment" '
349
+ f'SELECT id, filename, mime_type, file_path, size_bytes, '
350
+ f'openai_file_id, openai_file_expires_at FROM "Chat Attachment" '
216
351
  f'WHERE id IN ({placeholders}) AND user_id = ?',
217
352
  list(attachment_ids) + [user_id],
218
353
  )
@@ -226,6 +361,8 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
226
361
  "filename": row["filename"],
227
362
  "mime_type": row["mime_type"],
228
363
  "size_bytes": row["size_bytes"],
364
+ "openai_file_id": row.get("openai_file_id"),
365
+ "openai_file_expires_at": row.get("openai_file_expires_at"),
229
366
  "data": data,
230
367
  })
231
368
  except FileNotFoundError:
@@ -236,9 +373,13 @@ def get_attachments_by_ids(attachment_ids: list[str], user_id: str) -> list[dict
236
373
  def build_multimodal_content(attachment: dict) -> dict:
237
374
  """Convert an attachment dict (with raw data) into an OpenAI multimodal content part.
238
375
 
239
- Images go as `image_url`; plain-text files (CSV/TXT) are inlined as text;
240
- PDFs and binary Office/OpenDocument files go as a `file` part carrying the
241
- raw bytes the model reads them directly, no server-side conversion.
376
+ Images go as `image_url`; plain-text files (CSV/TXT) inline as text; PDFs go
377
+ as an inline base64 `file` part; binary Office/OpenDocument files are uploaded
378
+ to the Files API (once, cached) and referenced by `file_id` — OpenAI only
379
+ accepts PDF via inline base64, but reads Office bytes fine by file_id.
380
+
381
+ May upload to the Files API (blocking) for an Office file, so both call sites
382
+ run it in a worker thread, off the event loop.
242
383
  """
243
384
  mime = attachment["mime_type"]
244
385
  filename = attachment["filename"]
@@ -251,7 +392,7 @@ def build_multimodal_content(attachment: dict) -> dict:
251
392
  if mime in TEXT_MIME_TYPES or mime.startswith("text/"):
252
393
  text = attachment["data"].decode("utf-8", errors="replace")
253
394
  return {"type": "text", "text": f"[File: {filename}]\n{text}"}
254
- if mime == "application/pdf" or mime in OFFICE_MIME_TYPES:
395
+ if mime == "application/pdf":
255
396
  data_b64 = base64.b64encode(attachment["data"]).decode("ascii")
256
397
  return {
257
398
  "type": "file",
@@ -260,6 +401,12 @@ def build_multimodal_content(attachment: dict) -> dict:
260
401
  "file_data": f"data:{mime};base64,{data_b64}",
261
402
  },
262
403
  }
404
+ if mime in OFFICE_MIME_TYPES:
405
+ file_id = ensure_openai_file(attachment)
406
+ if file_id:
407
+ return {"type": "file", "file": {"file_id": file_id}}
408
+ return {"type": "text",
409
+ "text": f"[Attachment “{filename}” couldn’t be loaded for analysis — please re-upload it.]"}
263
410
  return {"type": "text", "text": f"[Unsupported attachment: {filename}]"}
264
411
 
265
412
 
@@ -3112,19 +3112,25 @@ async def run_session_turn(
3112
3112
  # Stateless: only the current user message, no prior turns replayed.
3113
3113
  conversation = [{"role": "user", "content": user_content}]
3114
3114
 
3115
- # Multimodal attachments: replace the last user message with an image/PDF-
3116
- # bearing content array so the LLM can see them directly.
3115
+ # Multimodal attachments: replace the last user message with a content array
3116
+ # (image/PDF inline, Office via Files-API file_id) so the LLM can see them.
3117
+ # build_multimodal_content may upload an Office file to the Files API, so run
3118
+ # the whole build off the event loop in a worker thread.
3117
3119
  if attachment_ids and attachments_user_id:
3118
3120
  from api.attachments import get_attachments_by_ids, build_multimodal_content
3119
3121
  atts = get_attachments_by_ids(attachment_ids, attachments_user_id)
3120
3122
  if atts and conversation and conversation[-1].get("role") == "user":
3121
- parts = []
3122
3123
  text = conversation[-1].get("content") or ""
3123
- if text:
3124
- parts.append({"type": "text", "text": text})
3125
- for att in atts:
3126
- parts.append(build_multimodal_content(att))
3127
- conversation[-1] = {"role": "user", "content": parts}
3124
+
3125
+ def _build_attachment_parts():
3126
+ parts = []
3127
+ if text:
3128
+ parts.append({"type": "text", "text": text})
3129
+ for att in atts:
3130
+ parts.append(build_multimodal_content(att))
3131
+ return parts
3132
+
3133
+ conversation[-1] = {"role": "user", "content": await asyncio.to_thread(_build_attachment_parts)}
3128
3134
 
3129
3135
  messages.extend(conversation)
3130
3136
 
@@ -98,6 +98,7 @@ export const api = {
98
98
  mime_type: string;
99
99
  size_bytes: number;
100
100
  created_at: string;
101
+ warning?: string;
101
102
  }>;
102
103
  },
103
104
  getChatAttachmentUrl: (id: string) => `${BASE}/chat/attachments/${encodeURIComponent(id)}`,
@@ -1274,7 +1274,10 @@ class Database:
1274
1274
  FOREIGN KEY (session_id) REFERENCES "Chat Session"(id)
1275
1275
  )""",
1276
1276
 
1277
- # --- Chat Attachments (PDFs + images uploaded via chat) ---
1277
+ # --- Chat Attachments (PDFs, images, and Office docs uploaded via chat) ---
1278
+ # openai_file_id/openai_file_expires_at cache the OpenAI Files API
1279
+ # upload for Office formats (sent to the model by file_id, reused
1280
+ # across turns, re-uploaded once the 30-day expiry lapses).
1278
1281
  """CREATE TABLE IF NOT EXISTS "Chat Attachment" (
1279
1282
  id TEXT PRIMARY KEY,
1280
1283
  session_id TEXT NOT NULL,
@@ -1283,6 +1286,8 @@ class Database:
1283
1286
  mime_type TEXT NOT NULL,
1284
1287
  size_bytes INTEGER NOT NULL,
1285
1288
  file_path TEXT NOT NULL,
1289
+ openai_file_id TEXT,
1290
+ openai_file_expires_at INTEGER,
1286
1291
  created_at TEXT DEFAULT CURRENT_TIMESTAMP,
1287
1292
  FOREIGN KEY (session_id) REFERENCES "Chat Session"(id)
1288
1293
  )""",
@@ -2038,6 +2043,14 @@ def _m019_api_keys_per_user(db: "Database") -> None:
2038
2043
  db._text_col_cache.pop("Api Key", None)
2039
2044
 
2040
2045
 
2046
+ def _m020_chat_attachment_openai_file(db: "Database") -> None:
2047
+ """Cache the OpenAI Files API id (+ its expiry) on a chat attachment, so an
2048
+ uploaded Office file is sent to the model by file_id — reused across turns
2049
+ and re-uploaded only once its 30-day expires_after lapses."""
2050
+ db._add_column_if_missing("Chat Attachment", "openai_file_id", "TEXT")
2051
+ db._add_column_if_missing("Chat Attachment", "openai_file_expires_at", "INTEGER")
2052
+
2053
+
2041
2054
  Database.MIGRATIONS = [
2042
2055
  (1, "chat_message_session_id", _m001_chat_message_session_id),
2043
2056
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -2058,6 +2071,7 @@ Database.MIGRATIONS = [
2058
2071
  (17, "proposal_cover_template", _m017_proposal_cover_template),
2059
2072
  (18, "quotation_item_frequency", _m018_quotation_item_frequency),
2060
2073
  (19, "api_keys_per_user", _m019_api_keys_per_user),
2074
+ (20, "chat_attachment_openai_file", _m020_chat_attachment_openai_file),
2061
2075
  ]
2062
2076
 
2063
2077
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.6.14"
3
+ version = "0.6.16"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -22,6 +22,7 @@ dependencies = [
22
22
  "weasyprint>=62.0",
23
23
  "pypdf>=4.0",
24
24
  "holidays>=0.40",
25
+ "openpyxl>=3.1",
25
26
  ]
26
27
 
27
28
  # Postgres backend is opt-in: `pip install lambda-erp[postgres]`. SQLite (the
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes