lambda-erp 0.1.20__tar.gz → 0.1.21__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 (68) hide show
  1. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/PKG-INFO +2 -1
  2. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/main.py +2 -1
  3. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/pdf.py +135 -0
  4. lambda_erp-0.1.21/api/routers/proposals.py +112 -0
  5. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/services.py +2 -0
  6. lambda_erp-0.1.21/api/templates/proposal.html +91 -0
  7. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/frontend/src/api/client.ts +31 -0
  8. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/database.py +66 -1
  9. lambda_erp-0.1.21/lambda_erp/selling/proposal.py +49 -0
  10. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/pyproject.toml +2 -1
  11. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/.gitignore +0 -0
  12. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/LICENSE +0 -0
  13. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/README.md +0 -0
  14. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/__init__.py +0 -0
  15. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/attachments.py +0 -0
  16. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/auth.py +0 -0
  17. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/bootstrap.py +0 -0
  18. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/chat.py +0 -0
  19. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/demo_limits.py +0 -0
  20. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/deps.py +0 -0
  21. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/errors.py +0 -0
  22. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/providers.py +0 -0
  23. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/__init__.py +0 -0
  24. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/accounting.py +0 -0
  25. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/admin.py +0 -0
  26. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/analytics.py +0 -0
  27. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/bank_reconciliation.py +0 -0
  28. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/documents.py +0 -0
  29. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/masters.py +0 -0
  30. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/reports.py +0 -0
  31. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/setup.py +0 -0
  32. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/templates/document.html +0 -0
  33. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/docs/agents/README.md +0 -0
  34. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/frontend/README.md +0 -0
  35. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/__init__.py +0 -0
  36. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/__init__.py +0 -0
  37. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/bank_transaction.py +0 -0
  38. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/budget.py +0 -0
  39. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  40. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/general_ledger.py +0 -0
  41. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/journal_entry.py +0 -0
  42. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/payment_entry.py +0 -0
  43. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/pos_invoice.py +0 -0
  44. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/purchase_invoice.py +0 -0
  45. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/revaluation.py +0 -0
  46. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/sales_invoice.py +0 -0
  47. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/subscription.py +0 -0
  48. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/buying/__init__.py +0 -0
  49. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/buying/purchase_order.py +0 -0
  50. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/__init__.py +0 -0
  51. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/currency.py +0 -0
  52. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/defaults.py +0 -0
  53. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/pricing_rule.py +0 -0
  54. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  55. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/exceptions.py +0 -0
  56. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/hooks.py +0 -0
  57. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/model.py +0 -0
  58. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/selling/__init__.py +0 -0
  59. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/selling/quotation.py +0 -0
  60. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/selling/sales_order.py +0 -0
  61. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/simulation.py +0 -0
  62. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/__init__.py +0 -0
  63. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/delivery_note.py +0 -0
  64. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/purchase_receipt.py +0 -0
  65. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/stock_entry.py +0 -0
  66. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/stock_ledger.py +0 -0
  67. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/utils.py +0 -0
  68. {lambda_erp-0.1.20 → lambda_erp-0.1.21}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.20
3
+ Version: 0.1.21
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -14,6 +14,7 @@ Requires-Dist: httpx>=0.27
14
14
  Requires-Dist: jinja2>=3.1
15
15
  Requires-Dist: openai>=1.0
16
16
  Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: pypdf>=4.0
17
18
  Requires-Dist: python-dateutil>=2.8
18
19
  Requires-Dist: python-dotenv>=1.0
19
20
  Requires-Dist: python-jose[cryptography]>=3.3
@@ -17,7 +17,7 @@ from api.errors import register_exception_handlers
17
17
  from api.auth import router as auth_router, COOKIE_NAME, decode_token
18
18
  from api.attachments import router as attachments_router
19
19
  from api.chat import chat_websocket, router as chat_router
20
- from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting
20
+ from api.routers import admin, documents, masters, reports, setup as setup_router, bank_reconciliation, analytics, accounting, proposals
21
21
 
22
22
 
23
23
  def load_plugins() -> None:
@@ -89,6 +89,7 @@ register_exception_handlers(app)
89
89
  app.include_router(auth_router, prefix="/api")
90
90
  app.include_router(attachments_router, prefix="/api")
91
91
  app.include_router(documents.router, prefix="/api")
92
+ app.include_router(proposals.router, prefix="/api")
92
93
  app.include_router(masters.router, prefix="/api")
93
94
  app.include_router(reports.router, prefix="/api")
94
95
  app.include_router(analytics.router, prefix="/api")
@@ -1,5 +1,6 @@
1
1
  """PDF generation for ERP documents."""
2
2
 
3
+ import io
3
4
  import os
4
5
  from jinja2 import Environment, FileSystemLoader, ChoiceLoader
5
6
  from weasyprint import HTML
@@ -78,6 +79,12 @@ def _get_dict(row):
78
79
 
79
80
  def generate_pdf(doctype_slug: str, name: str) -> bytes:
80
81
  """Generate a PDF for a document and return raw bytes."""
82
+ # The Proposal (Sammelofferte) has a wholly different shape — several
83
+ # quotations rendered as lettered positions + an appended appendix — so it
84
+ # gets its own render path rather than the single-document template below.
85
+ if doctype_slug == "proposal":
86
+ return generate_proposal_pdf(name)
87
+
81
88
  doc = load_document(doctype_slug, name)
82
89
  db = get_db()
83
90
 
@@ -209,3 +216,131 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
209
216
  html_str = template.render(**context)
210
217
 
211
218
  return HTML(string=html_str, base_url=base_url).write_pdf()
219
+
220
+
221
+ def _append_pdf(base_pdf: bytes, extra_pdf: bytes) -> bytes:
222
+ """Concatenate `extra_pdf` after `base_pdf`. Used to staple the uploaded
223
+ appendix onto the rendered offers. A corrupt/unreadable appendix must not
224
+ sink the whole proposal, so on any failure we return the base unchanged."""
225
+ try:
226
+ from pypdf import PdfReader, PdfWriter
227
+ except Exception:
228
+ return base_pdf
229
+ try:
230
+ writer = PdfWriter()
231
+ for src in (base_pdf, extra_pdf):
232
+ reader = PdfReader(io.BytesIO(src))
233
+ for page in reader.pages:
234
+ writer.add_page(page)
235
+ out = io.BytesIO()
236
+ writer.write(out)
237
+ return out.getvalue()
238
+ except Exception:
239
+ return base_pdf
240
+
241
+
242
+ def generate_proposal_pdf(name: str) -> bytes:
243
+ """Render a Proposal (Sammelofferte): a cover letter plus each referenced
244
+ Quotation as a lettered position (A, B, C…), then append the uploaded
245
+ appendix PDF if there is one. Never mutates the quotations."""
246
+ proposal = load_document("proposal", name)
247
+ db = get_db()
248
+
249
+ # Customer (party) — looked up live so a corrected name/address shows.
250
+ party_name = proposal.get("customer_name") or proposal.get("customer") or ""
251
+ party_info = {}
252
+ if proposal.get("customer"):
253
+ row = db.get_value(
254
+ "Customer", proposal["customer"],
255
+ ["customer_name", "email", "phone", "address", "city", "zip_code",
256
+ "country", "tax_id", "contact_person", "contact_email", "contact_phone"],
257
+ )
258
+ if row:
259
+ party_info = _get_dict(row)
260
+ party_name = party_info.get("customer_name") or party_name
261
+
262
+ # Company / letterhead.
263
+ company_id = proposal.get("company", "") or ""
264
+ company_name = company_id
265
+ company_info = {}
266
+ if company_id:
267
+ row = db.get_value(
268
+ "Company", company_id,
269
+ ["company_name", "email", "phone", "address", "city", "zip_code",
270
+ "country", "tax_id", "iban"],
271
+ )
272
+ if row:
273
+ company_info = _get_dict(row)
274
+ company_name = company_info.get("company_name") or company_id
275
+
276
+ # Build the lettered positions from the referenced quotations (idx order).
277
+ positions = []
278
+ rows = sorted(proposal.get("quotations", []) or [], key=lambda r: r.get("idx") or 0)
279
+ for i, row in enumerate(rows):
280
+ qname = row.get("quotation")
281
+ if not qname:
282
+ continue
283
+ try:
284
+ quote = load_document("quotation", qname)
285
+ except Exception:
286
+ continue
287
+ items = quote.get("items", []) or []
288
+ # Refresh each line's item_name from the master, consistent with the
289
+ # single-document path.
290
+ for item in items:
291
+ code = item.get("item_code")
292
+ if code:
293
+ live = db.get_value("Item", code, "item_name")
294
+ if live:
295
+ item["item_name"] = live
296
+ default_title = items[0].get("item_name") if items else qname
297
+ positions.append({
298
+ "letter": chr(ord("A") + i),
299
+ "quotation": qname,
300
+ "title": row.get("position_title") or default_title or qname,
301
+ "blurb": row.get("position_blurb") or quote.get("remarks") or "",
302
+ "is_recommended": bool(row.get("is_recommended")),
303
+ "currency": quote.get("currency", "USD") or "USD",
304
+ "grand_total": quote.get("grand_total") or 0,
305
+ "items": items,
306
+ })
307
+
308
+ # Page size setting (shared with the single-document path).
309
+ page_size_row = db.sql('SELECT value FROM "Settings" WHERE key = \'pdf_page_size\'')
310
+ page_size = page_size_row[0]["value"] if page_size_row else "A4"
311
+
312
+ template = _jinja_env.get_template("proposal.html")
313
+ base_url = template.filename or os.path.join(TEMPLATE_DIR, "proposal.html")
314
+ context = dict(
315
+ proposal=proposal,
316
+ title=proposal.get("title") or "Offerte",
317
+ company_name=company_name,
318
+ company_info=company_info,
319
+ party_name=party_name,
320
+ party_info=party_info,
321
+ positions=positions,
322
+ page_size=page_size,
323
+ )
324
+
325
+ # Let deployment plugins augment the context, same seam as generate_pdf.
326
+ for provider in _pdf_context_providers:
327
+ try:
328
+ extra = provider("Proposal", name, context)
329
+ if extra:
330
+ context.update(extra)
331
+ except Exception:
332
+ pass
333
+
334
+ html_str = template.render(**context)
335
+ pdf_bytes = HTML(string=html_str, base_url=base_url).write_pdf()
336
+
337
+ # Staple the uploaded appendix (stored as a blob) onto the end.
338
+ appendix = db.sql('SELECT data FROM "Proposal Appendix" WHERE parent = ?', [name])
339
+ if appendix and appendix[0].get("data"):
340
+ data = appendix[0]["data"]
341
+ if isinstance(data, memoryview):
342
+ data = data.tobytes()
343
+ if data:
344
+ pdf_bytes = _append_pdf(pdf_bytes, bytes(data))
345
+
346
+ return pdf_bytes
@@ -0,0 +1,112 @@
1
+ """Proposal (Sammelofferte) extras: the uploaded appendix PDF and the
2
+ cover-letter default.
3
+
4
+ The Proposal itself is a normal document — list/get/create/update go through the
5
+ generic `/documents/proposal` routes. Only the appendix (a binary blob stored
6
+ out of the Proposal row) and the cover-letter pre-fill need dedicated endpoints.
7
+ """
8
+
9
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
10
+ from fastapi.responses import Response
11
+
12
+ from lambda_erp.database import get_db
13
+ from lambda_erp.utils import now
14
+ from api.auth import require_role
15
+
16
+ router = APIRouter(prefix="/proposals", tags=["proposals"])
17
+
18
+ _viewer = Depends(require_role("viewer"))
19
+ _manager = Depends(require_role("manager"))
20
+
21
+ MAX_APPENDIX_SIZE = 15 * 1024 * 1024 # 15 MB
22
+
23
+
24
+ def _fill(template: str, customer: dict) -> str:
25
+ """Resolve the handful of placeholders a cover-letter template may use."""
26
+ contact = (customer.get("contact_person") or "").strip()
27
+ return (template or "").format(
28
+ customer_name=customer.get("customer_name") or "",
29
+ contact_person=contact,
30
+ salutation=f"Sehr geehrte Damen und Herren" if not contact else f"Sehr geehrte/r {contact}",
31
+ )
32
+
33
+
34
+ @router.get("/cover-default")
35
+ def cover_default(company: str = "", customer: str = "", _user: dict = _viewer):
36
+ """The default cover-letter text a new proposal pre-fills, taken from the
37
+ company's `proposal_cover_template` with placeholders resolved against the
38
+ chosen customer. Returns "" when no template is configured."""
39
+ db = get_db()
40
+ template = ""
41
+ if company:
42
+ template = db.get_value("Company", company, "proposal_cover_template") or ""
43
+ cust = {}
44
+ if customer:
45
+ row = db.get_value("Customer", customer, ["customer_name", "contact_person"])
46
+ if row:
47
+ cust = dict(row)
48
+ try:
49
+ text = _fill(template, cust)
50
+ except (KeyError, IndexError, ValueError):
51
+ # A template with an unknown {placeholder} shouldn't 500 — hand back raw.
52
+ text = template
53
+ return {"cover_letter": text}
54
+
55
+
56
+ @router.post("/{name}/appendix")
57
+ async def upload_appendix(name: str, file: UploadFile = File(...), user: dict = _manager):
58
+ """Attach (or replace) the appendix PDF stapled to the end of the proposal."""
59
+ db = get_db()
60
+ if not db.exists("Proposal", name):
61
+ raise HTTPException(status_code=404, detail=f"Proposal {name} not found")
62
+
63
+ mime = (file.content_type or "").lower()
64
+ if mime != "application/pdf":
65
+ raise HTTPException(status_code=400, detail="Appendix must be a PDF.")
66
+ data = await file.read()
67
+ if not data:
68
+ raise HTTPException(status_code=400, detail="Empty file.")
69
+ if len(data) > MAX_APPENDIX_SIZE:
70
+ raise HTTPException(status_code=413, detail=f"Appendix too large (max {MAX_APPENDIX_SIZE // (1024*1024)} MB).")
71
+
72
+ filename = file.filename or "appendix.pdf"
73
+ # Upsert the 1:1 appendix row, then mark the filename on the proposal so the
74
+ # UI can show what's attached.
75
+ db.delete("Proposal Appendix", filters={"parent": name})
76
+ db.sql(
77
+ 'INSERT INTO "Proposal Appendix" (parent, filename, data, uploaded) VALUES (?, ?, ?, ?)',
78
+ [name, filename, data, now()],
79
+ )
80
+ db.set_value("Proposal", name, "appendix_filename", filename)
81
+ db.commit()
82
+ return {"appendix_filename": filename, "size": len(data)}
83
+
84
+
85
+ @router.delete("/{name}/appendix")
86
+ def delete_appendix(name: str, user: dict = _manager):
87
+ """Remove the appendix PDF from the proposal."""
88
+ db = get_db()
89
+ if not db.exists("Proposal", name):
90
+ raise HTTPException(status_code=404, detail=f"Proposal {name} not found")
91
+ db.delete("Proposal Appendix", filters={"parent": name})
92
+ db.set_value("Proposal", name, "appendix_filename", None)
93
+ db.commit()
94
+ return {"ok": True}
95
+
96
+
97
+ @router.get("/{name}/appendix")
98
+ def download_appendix(name: str, _user: dict = _viewer):
99
+ """Download the stored appendix PDF (for previewing what's attached)."""
100
+ db = get_db()
101
+ rows = db.sql('SELECT filename, data FROM "Proposal Appendix" WHERE parent = ?', [name])
102
+ if not rows or not rows[0].get("data"):
103
+ raise HTTPException(status_code=404, detail="No appendix attached.")
104
+ data = rows[0]["data"]
105
+ if isinstance(data, memoryview):
106
+ data = data.tobytes()
107
+ filename = rows[0].get("filename") or "appendix.pdf"
108
+ return Response(
109
+ content=bytes(data),
110
+ media_type="application/pdf",
111
+ headers={"Content-Disposition": f'inline; filename="{filename}"'},
112
+ )
@@ -7,6 +7,7 @@ from lambda_erp.selling.quotation import (
7
7
  Quotation, make_sales_order,
8
8
  make_sales_invoice_from_quotation, make_delivery_note_from_quotation,
9
9
  )
10
+ from lambda_erp.selling.proposal import Proposal
10
11
  from lambda_erp.selling.sales_order import SalesOrder, make_sales_invoice
11
12
  from lambda_erp.buying.purchase_order import PurchaseOrder, make_purchase_invoice
12
13
  from lambda_erp.accounting.sales_invoice import SalesInvoice, make_sales_return
@@ -27,6 +28,7 @@ from lambda_erp.accounting.bank_transaction import BankTransaction
27
28
 
28
29
  DOCUMENT_CLASSES = {
29
30
  "Quotation": Quotation,
31
+ "Proposal": Proposal,
30
32
  "Sales Order": SalesOrder,
31
33
  "Sales Invoice": SalesInvoice,
32
34
  "Purchase Order": PurchaseOrder,
@@ -0,0 +1,91 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <style>
6
+ @page { size: {{ page_size }}; margin: 20mm 15mm; }
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ body { font-family: Helvetica, Arial, sans-serif; font-size: 9pt; color: #1a1a1a; line-height: 1.45; }
9
+
10
+ .header { display: flex; justify-content: space-between; margin-bottom: 24px; }
11
+ .company { font-size: 14pt; font-weight: bold; color: #2563eb; }
12
+ .doc-title { text-align: right; }
13
+ .doc-title h1 { font-size: 16pt; }
14
+ .doc-title .doc-name { font-size: 10pt; color: #666; }
15
+ .doc-title .doc-date { font-size: 9pt; color: #888; margin-top: 2px; }
16
+
17
+ .parties { display: flex; justify-content: space-between; margin-bottom: 18px; }
18
+ .party-block h3 { font-size: 8pt; text-transform: uppercase; letter-spacing: 0.5px; color: #888; margin-bottom: 4px; }
19
+ .party-block .name { font-weight: bold; font-size: 10pt; }
20
+ .party-block .detail { font-size: 8pt; color: #555; }
21
+
22
+ .cover { margin-bottom: 22px; white-space: pre-line; font-size: 9.5pt; }
23
+
24
+ .position { margin-bottom: 16px; padding-bottom: 12px; border-bottom: 1px solid #e5e7eb; page-break-inside: avoid; }
25
+ .position .pos-head { display: flex; align-items: baseline; gap: 10px; }
26
+ .position .letter { font-weight: bold; font-size: 11pt; color: #2563eb; min-width: 18px; }
27
+ .position .pos-title { font-weight: bold; font-size: 10.5pt; }
28
+ .position .recommended { display: inline-block; margin-left: 8px; padding: 1px 9px; border-radius: 10px; background: #2563eb; color: #fff; font-size: 7.5pt; font-weight: bold; vertical-align: middle; }
29
+ .position .blurb { margin: 3px 0 8px 28px; font-size: 8.5pt; color: #444; }
30
+
31
+ table.lines { width: 100%; border-collapse: collapse; margin-left: 28px; width: calc(100% - 28px); }
32
+ table.lines td { padding: 3px 0; font-size: 9pt; }
33
+ table.lines td.amount { text-align: right; white-space: nowrap; }
34
+ table.lines td.desc { color: #333; }
35
+
36
+ .footer { margin-top: 28px; padding-top: 10px; border-top: 1px solid #e5e7eb; font-size: 7.5pt; color: #999; text-align: center; }
37
+ </style>
38
+ </head>
39
+ <body>
40
+
41
+ <div class="header">
42
+ <div><div class="company">{{ company_name or proposal.company or "" }}</div></div>
43
+ <div class="doc-title">
44
+ <h1>{{ title }}</h1>
45
+ <div class="doc-name">{{ proposal.name }}</div>
46
+ {% if proposal.proposal_date %}<div class="doc-date">{{ proposal.proposal_date }}</div>{% endif %}
47
+ </div>
48
+ </div>
49
+
50
+ <div class="parties">
51
+ <div class="party-block">
52
+ <h3>From</h3>
53
+ <div class="name">{{ company_name or proposal.company or "" }}</div>
54
+ {% if company_info.get("address") %}<div class="detail">{{ company_info.address }}</div>{% endif %}
55
+ {% if company_info.get("city") or company_info.get("zip_code") %}<div class="detail">{{ company_info.get("zip_code", "") }} {{ company_info.get("city", "") }}</div>{% endif %}
56
+ {% if company_info.get("email") %}<div class="detail">{{ company_info.email }}</div>{% endif %}
57
+ {% if proposal.partner_name %}<div class="detail" style="margin-top:4px;">{{ proposal.partner_name }}{% if proposal.partner_email %} · {{ proposal.partner_email }}{% endif %}</div>{% endif %}
58
+ </div>
59
+ <div class="party-block" style="text-align: right;">
60
+ <h3>To</h3>
61
+ <div class="name">{{ party_name }}</div>
62
+ {% if party_info.get("contact_person") %}<div class="detail">{{ party_info.contact_person }}</div>{% endif %}
63
+ {% if party_info.get("address") %}<div class="detail">{{ party_info.address }}</div>{% endif %}
64
+ {% if party_info.get("city") or party_info.get("zip_code") %}<div class="detail">{{ party_info.get("zip_code", "") }} {{ party_info.get("city", "") }}</div>{% endif %}
65
+ </div>
66
+ </div>
67
+
68
+ {% if proposal.cover_letter %}<div class="cover">{{ proposal.cover_letter }}</div>{% endif %}
69
+
70
+ {% for p in positions %}
71
+ <div class="position">
72
+ <div class="pos-head">
73
+ <span class="letter">{{ p.letter }}</span>
74
+ <span class="pos-title">{{ p.title }}{% if p.is_recommended %}<span class="recommended">Empfehlung</span>{% endif %}</span>
75
+ </div>
76
+ {% if p.blurb %}<div class="blurb">{{ p.blurb }}</div>{% endif %}
77
+ <table class="lines">
78
+ {% for item in p["items"] %}
79
+ <tr>
80
+ <td class="desc">{{ item.item_name or item.description or item.item_code or "" }}{% if item.qty and item.qty != 1 %} ({{ "%g"|format(item.qty) }}×){% endif %}</td>
81
+ <td class="amount">{{ p.currency }} {{ "%.2f"|format(item.amount or item.net_amount or (item.qty or 0) * (item.rate or 0)) }}</td>
82
+ </tr>
83
+ {% endfor %}
84
+ </table>
85
+ </div>
86
+ {% endfor %}
87
+
88
+ <div class="footer">{{ company_name or proposal.company or "" }}</div>
89
+
90
+ </body>
91
+ </html>
@@ -139,6 +139,37 @@ export const api = {
139
139
  body: JSON.stringify({ target_doctype: targetDoctype }),
140
140
  }),
141
141
 
142
+ // Proposal (Sammelofferte) extras — CRUD goes through the document methods
143
+ // above (doctype "proposal"); these cover the appendix blob + cover default.
144
+ getProposalCoverDefault: (company?: string, customer?: string) =>
145
+ request<{ cover_letter: string }>(
146
+ `/proposals/cover-default${qs({ company, customer })}`,
147
+ ),
148
+
149
+ uploadProposalAppendix: async (name: string, file: File) => {
150
+ const form = new FormData();
151
+ form.append("file", file);
152
+ const res = await fetch(`${BASE}/proposals/${encodeURIComponent(name)}/appendix`, {
153
+ method: "POST",
154
+ credentials: "include",
155
+ body: form,
156
+ });
157
+ if (!res.ok) {
158
+ if (res.status === 401) {
159
+ window.location.href = "/login";
160
+ throw new ApiError(401, "Session expired");
161
+ }
162
+ const body = await res.json().catch(() => ({ detail: res.statusText }));
163
+ throw new ApiError(res.status, body.detail || "Upload failed");
164
+ }
165
+ return res.json() as Promise<{ appendix_filename: string; size: number }>;
166
+ },
167
+
168
+ deleteProposalAppendix: (name: string) =>
169
+ request<{ ok: boolean }>(`/proposals/${encodeURIComponent(name)}/appendix`, { method: "DELETE" }),
170
+
171
+ proposalPdfUrl: (name: string) => `${BASE}/documents/proposal/${encodeURIComponent(name)}/pdf`,
172
+
142
173
  // Masters
143
174
  listMasters: (type: string, params?: Record<string, string | number | undefined>) =>
144
175
  request<{ rows: any[]; total: number; limit: number; offset: number }>(
@@ -287,7 +287,8 @@ class Database:
287
287
  default_freight_in_account TEXT,
288
288
  default_customs_account TEXT,
289
289
  default_exchange_gain_loss_account TEXT,
290
- default_unrealized_exchange_account TEXT
290
+ default_unrealized_exchange_account TEXT,
291
+ proposal_cover_template TEXT
291
292
  )""",
292
293
 
293
294
  """CREATE TABLE IF NOT EXISTS "Account" (
@@ -524,6 +525,56 @@ class Database:
524
525
  FOREIGN KEY (parent) REFERENCES "Quotation"(name)
525
526
  )""",
526
527
 
528
+ # --- Proposal (Sammelofferte) ---
529
+ # A print-only assembly of several independent Quotations into one
530
+ # branded PDF. No docstatus lifecycle that posts anything: it never
531
+ # touches the quotations it references. `discarded` lets a saved
532
+ # proposal be soft-deleted like other documents.
533
+ """CREATE TABLE IF NOT EXISTS "Proposal" (
534
+ name TEXT PRIMARY KEY,
535
+ title TEXT,
536
+ customer TEXT,
537
+ customer_name TEXT,
538
+ company TEXT,
539
+ proposal_date TEXT,
540
+ partner_name TEXT,
541
+ partner_email TEXT,
542
+ cover_letter TEXT,
543
+ appendix_filename TEXT,
544
+ status TEXT DEFAULT 'Draft',
545
+ docstatus INTEGER DEFAULT 0,
546
+ discarded INTEGER DEFAULT 0,
547
+ creation TEXT,
548
+ modified TEXT,
549
+ FOREIGN KEY (customer) REFERENCES "Customer"(name)
550
+ )""",
551
+
552
+ # Each row references one independent Quotation, rendered as a
553
+ # lettered position (A, B, C…) in the combined PDF. position_title /
554
+ # position_blurb are per-proposal copy; is_recommended draws the badge.
555
+ """CREATE TABLE IF NOT EXISTS "Proposal Item" (
556
+ name TEXT PRIMARY KEY,
557
+ parent TEXT,
558
+ idx INTEGER DEFAULT 0,
559
+ quotation TEXT,
560
+ position_title TEXT,
561
+ position_blurb TEXT,
562
+ is_recommended INTEGER DEFAULT 0,
563
+ FOREIGN KEY (parent) REFERENCES "Proposal"(name)
564
+ )""",
565
+
566
+ # The optional uploaded appendix PDF (e.g. a static price overview),
567
+ # stored as a blob so it survives container restarts (the app disk is
568
+ # ephemeral). 1:1 with a Proposal; kept out of the Proposal row so the
569
+ # generic document CRUD/list never has to serialise bytes.
570
+ """CREATE TABLE IF NOT EXISTS "Proposal Appendix" (
571
+ parent TEXT PRIMARY KEY,
572
+ filename TEXT,
573
+ data BLOB,
574
+ uploaded TEXT,
575
+ FOREIGN KEY (parent) REFERENCES "Proposal"(name)
576
+ )""",
577
+
527
578
  # --- Sales Order ---
528
579
  """CREATE TABLE IF NOT EXISTS "Sales Order" (
529
580
  name TEXT PRIMARY KEY,
@@ -1274,6 +1325,8 @@ class Database:
1274
1325
  "BIGSERIAL PRIMARY KEY", stmt, flags=re.IGNORECASE)
1275
1326
  stmt = re.sub(r"\bAUTOINCREMENT\b", "", stmt, flags=re.IGNORECASE)
1276
1327
  stmt = re.sub(r"\bREAL\b", "DOUBLE PRECISION", stmt, flags=re.IGNORECASE)
1328
+ # SQLite's BLOB is Postgres' BYTEA (used for the Proposal appendix PDF).
1329
+ stmt = re.sub(r"\bBLOB\b", "BYTEA", stmt, flags=re.IGNORECASE)
1277
1330
  return stmt
1278
1331
 
1279
1332
  # -----------------------------------------------------------------
@@ -1755,6 +1808,17 @@ def _m016_customer_contact_person(db: "Database") -> None:
1755
1808
  db._add_column_if_missing("Customer", "contact_phone", "TEXT")
1756
1809
 
1757
1810
 
1811
+ def _m017_proposal_cover_template(db: "Database") -> None:
1812
+ """Add Company.proposal_cover_template — the default cover-letter text a new
1813
+ Proposal (Sammelofferte) pre-fills, so account managers don't retype the
1814
+ salutation/intro every time. May contain {placeholders} resolved at build.
1815
+
1816
+ The Proposal / Proposal Item / Proposal Appendix tables themselves are
1817
+ created by _setup_schema (CREATE TABLE IF NOT EXISTS runs every startup);
1818
+ only this column on the pre-existing Company table needs a migration."""
1819
+ db._add_column_if_missing("Company", "proposal_cover_template", "TEXT")
1820
+
1821
+
1758
1822
  Database.MIGRATIONS = [
1759
1823
  (1, "chat_message_session_id", _m001_chat_message_session_id),
1760
1824
  (2, "chat_session_user_id", _m002_chat_session_user_id),
@@ -1772,6 +1836,7 @@ Database.MIGRATIONS = [
1772
1836
  (14, "company_iban", _m014_company_iban),
1773
1837
  (15, "document_discarded", _m015_document_discarded),
1774
1838
  (16, "customer_contact_person", _m016_customer_contact_person),
1839
+ (17, "proposal_cover_template", _m017_proposal_cover_template),
1775
1840
  ]
1776
1841
 
1777
1842
 
@@ -0,0 +1,49 @@
1
+ """
2
+ Proposal (Sammelofferte).
3
+
4
+ A print-only assembly of several *independent* Quotations into one branded,
5
+ customer-facing PDF — the partner picks which existing offers to present (each a
6
+ lettered position A, B, C…), writes a cover letter, optionally appends a static
7
+ PDF (e.g. a price overview), and generates one cohesive document.
8
+
9
+ Deliberately has NO financial behaviour:
10
+ - no submit, no GL, no stock — it is never submitted;
11
+ - it never mutates the Quotations it references; it only points at them so the
12
+ PDF can render their line items. The quotations stay fully independent and are
13
+ accepted/converted/billed on their own.
14
+
15
+ Saving a Proposal exists only so the partner can reopen and duplicate it (so the
16
+ cover letter and per-position copy aren't retyped), not to link the offers.
17
+ """
18
+
19
+ from lambda_erp.model import Document
20
+ from lambda_erp.database import get_db
21
+
22
+
23
+ class Proposal(Document):
24
+ DOCTYPE = "Proposal"
25
+ PREFIX = "PROP"
26
+
27
+ # Each child row references one independent Quotation; the appendix PDF is
28
+ # stored out-of-band (Proposal Appendix table) so this CRUD never serialises
29
+ # bytes.
30
+ CHILD_TABLES = {
31
+ "quotations": ("Proposal Item", None),
32
+ }
33
+
34
+ LINK_FIELDS = {
35
+ "customer": "Customer",
36
+ "company": "Company",
37
+ }
38
+ CHILD_LINK_FIELDS = {
39
+ "quotations": {"quotation": "Quotation"},
40
+ }
41
+
42
+ def before_save(self) -> None:
43
+ # Denormalise the customer's display name so the list view has it without
44
+ # a join. The PDF still looks the name up live at render time.
45
+ if self.customer and not self.customer_name:
46
+ self.customer_name = (
47
+ get_db().get_value("Customer", self.customer, "customer_name")
48
+ or self.customer
49
+ )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.20"
3
+ version = "0.1.21"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -20,6 +20,7 @@ dependencies = [
20
20
  "bcrypt>=5",
21
21
  "Jinja2>=3.1",
22
22
  "weasyprint>=62.0",
23
+ "pypdf>=4.0",
23
24
  "holidays>=0.40",
24
25
  ]
25
26
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes