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.
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/PKG-INFO +2 -1
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/main.py +2 -1
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/pdf.py +135 -0
- lambda_erp-0.1.21/api/routers/proposals.py +112 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/services.py +2 -0
- lambda_erp-0.1.21/api/templates/proposal.html +91 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/frontend/src/api/client.ts +31 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/database.py +66 -1
- lambda_erp-0.1.21/lambda_erp/selling/proposal.py +49 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/pyproject.toml +2 -1
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/.gitignore +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/LICENSE +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/README.md +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/attachments.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/auth.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/bootstrap.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/chat.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/demo_limits.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/deps.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/errors.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/providers.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/analytics.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/api/templates/document.html +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/frontend/README.md +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.20 → lambda_erp-0.1.21}/lambda_erp/utils.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|