lambda-erp 0.1.28__tar.gz → 0.1.29__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.28 → lambda_erp-0.1.29}/PKG-INFO +1 -1
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/pdf.py +17 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/controllers/taxes_and_totals.py +73 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/database.py +10 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/selling/quotation.py +45 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/pyproject.toml +1 -1
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/.gitignore +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/LICENSE +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/README.md +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/attachments.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/auth.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/bootstrap.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/chat.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/demo_limits.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/deps.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/errors.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/main.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/providers.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/remarks_md.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/accounting.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/admin.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/analytics.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/documents.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/masters.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/proposals.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/reports.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/routers/setup.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/services.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/templates/document.html +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/api/templates/proposal.html +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/docs/agents/README.md +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/frontend/README.md +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/frontend/src/api/client.ts +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/pos_invoice.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/purchase_invoice.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/sales_invoice.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/controllers/defaults.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/controllers/pricing_rule.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/hooks.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/model.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/selling/sales_order.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/simulation.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/stock/delivery_note.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/stock/purchase_receipt.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/stock/stock_entry.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/stock/stock_ledger.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/lambda_erp/utils.py +0 -0
- {lambda_erp-0.1.28 → lambda_erp-0.1.29}/terraform/README.md +0 -0
|
@@ -7,6 +7,7 @@ from weasyprint import HTML
|
|
|
7
7
|
from lambda_erp.database import get_db
|
|
8
8
|
from api.services import load_document
|
|
9
9
|
from api.remarks_md import render_remarks
|
|
10
|
+
from lambda_erp.controllers.taxes_and_totals import split_by_frequency
|
|
10
11
|
|
|
11
12
|
TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
|
|
12
13
|
|
|
@@ -182,6 +183,15 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
|
|
|
182
183
|
page_size_row = db.sql('SELECT value FROM "Settings" WHERE key = \'pdf_page_size\'')
|
|
183
184
|
page_size = page_size_row[0]["value"] if page_size_row else "A4"
|
|
184
185
|
|
|
186
|
+
# Billing-frequency split: recurring line groups for the per-period blocks,
|
|
187
|
+
# and whether to show the frequency column at all (any recurring line, or a
|
|
188
|
+
# `>>` price line in the notes — which renders in that same column).
|
|
189
|
+
_one_time, recurring_summary = split_by_frequency(doc)
|
|
190
|
+
_remarks = doc.get("remarks") or ""
|
|
191
|
+
show_frequency = bool(recurring_summary) or any(
|
|
192
|
+
line.lstrip().startswith(">>") for line in _remarks.splitlines()
|
|
193
|
+
)
|
|
194
|
+
|
|
185
195
|
# Render
|
|
186
196
|
template = _jinja_env.get_template("document.html")
|
|
187
197
|
# base_url = the resolved template's own directory, so a template (e.g. a
|
|
@@ -207,6 +217,13 @@ def generate_pdf(doctype_slug: str, name: str) -> bytes:
|
|
|
207
217
|
# emitted classes. Falls back to plain `doc.remarks` if a template
|
|
208
218
|
# doesn't use it. See api/remarks_md.py.
|
|
209
219
|
remarks_html=render_remarks(doc.get("remarks")),
|
|
220
|
+
# Billing-frequency split (offers with recurring lines). `recurring_summary`
|
|
221
|
+
# is the per-period totals (one entry per Monatlich/Quartalsweise/… group);
|
|
222
|
+
# the doc's own net_total/grand_total already reflect the one-time part only.
|
|
223
|
+
# `show_frequency` tells a template to render the (untitled) frequency column,
|
|
224
|
+
# true when any line is recurring OR the notes carry a `>>` price line.
|
|
225
|
+
recurring_summary=recurring_summary,
|
|
226
|
+
show_frequency=show_frequency,
|
|
210
227
|
)
|
|
211
228
|
|
|
212
229
|
# Let deployment plugins augment the context (e.g. a Swiss QR-bill image for
|
|
@@ -367,3 +367,76 @@ class TaxCalculator:
|
|
|
367
367
|
def calculate_taxes_and_totals(doc):
|
|
368
368
|
"""Convenience function matching the reference implementation's pattern."""
|
|
369
369
|
TaxCalculator(doc).calculate()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# --- Billing frequency split (offers with recurring lines) -----------------
|
|
373
|
+
#
|
|
374
|
+
# A line item may carry a `frequency`: one-time ("Einmalig") or a recurring
|
|
375
|
+
# cadence. On an offer the one-time and recurring lines are totalled
|
|
376
|
+
# separately — a recurring line must not inflate the one-time grand total —
|
|
377
|
+
# and each recurring period shows its own net / tax / grand. These helpers run
|
|
378
|
+
# the SAME tax engine on each frequency group independently so every period
|
|
379
|
+
# carries correct taxes; nothing here changes the shared calculation above.
|
|
380
|
+
|
|
381
|
+
ONE_TIME_FREQUENCY = "One-time"
|
|
382
|
+
# Recurring cadences in display order. These match the Subscription doctype's
|
|
383
|
+
# billing_interval values exactly, so a recurring offer line maps 1:1 onto a
|
|
384
|
+
# Subscription later. (Templates localize these labels for display.)
|
|
385
|
+
RECURRING_FREQUENCY_ORDER = ["Monthly", "Quarterly", "Half-Yearly", "Yearly"]
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _item_frequency(item):
|
|
389
|
+
return (item.get("frequency") or ONE_TIME_FREQUENCY)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def has_recurring_items(doc):
|
|
393
|
+
"""True if any line item carries a recurring (non-Einmalig) frequency."""
|
|
394
|
+
return any(_item_frequency(it) != ONE_TIME_FREQUENCY for it in (doc.get("items") or []))
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def split_by_frequency(doc):
|
|
398
|
+
"""Group a doc's items by `frequency` and total each group independently.
|
|
399
|
+
|
|
400
|
+
Returns `(one_time, recurring)` where `one_time` is a totals dict for the
|
|
401
|
+
Einmalig group and `recurring` is an ordered list of
|
|
402
|
+
`{frequency, net_total, total_taxes_and_charges, grand_total}` — one per
|
|
403
|
+
recurring period present. Each group is run through the standard tax engine
|
|
404
|
+
with the doc's tax rows and conversion rate, so taxes are correct per
|
|
405
|
+
period. (Document-level discounts are not distributed across groups.)
|
|
406
|
+
"""
|
|
407
|
+
import copy
|
|
408
|
+
|
|
409
|
+
items = doc.get("items") or []
|
|
410
|
+
taxes = doc.get("taxes") or []
|
|
411
|
+
conversion_rate = doc.get("conversion_rate") or 1.0
|
|
412
|
+
|
|
413
|
+
groups = {}
|
|
414
|
+
for it in items:
|
|
415
|
+
groups.setdefault(_item_frequency(it), []).append(it)
|
|
416
|
+
|
|
417
|
+
def totals_for(group_items):
|
|
418
|
+
tmp = {
|
|
419
|
+
"items": copy.deepcopy(group_items),
|
|
420
|
+
"taxes": copy.deepcopy(taxes),
|
|
421
|
+
"conversion_rate": conversion_rate,
|
|
422
|
+
}
|
|
423
|
+
calculate_taxes_and_totals(tmp)
|
|
424
|
+
return {
|
|
425
|
+
"net_total": flt(tmp.get("net_total"), 2),
|
|
426
|
+
"total_taxes_and_charges": flt(tmp.get("total_taxes_and_charges"), 2),
|
|
427
|
+
"grand_total": flt(tmp.get("grand_total"), 2),
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
one_time = totals_for(groups.get(ONE_TIME_FREQUENCY, []))
|
|
431
|
+
|
|
432
|
+
recurring, seen = [], set()
|
|
433
|
+
for freq in RECURRING_FREQUENCY_ORDER:
|
|
434
|
+
if groups.get(freq):
|
|
435
|
+
recurring.append({"frequency": freq, **totals_for(groups[freq])})
|
|
436
|
+
seen.add(freq)
|
|
437
|
+
# Any custom/unknown recurring label, kept in insertion order after the known ones.
|
|
438
|
+
for freq, gitems in groups.items():
|
|
439
|
+
if freq != ONE_TIME_FREQUENCY and freq not in seen:
|
|
440
|
+
recurring.append({"frequency": freq, **totals_for(gitems)})
|
|
441
|
+
|
|
442
|
+
return one_time, recurring
|
|
@@ -509,6 +509,7 @@ class Database:
|
|
|
509
509
|
item_code TEXT,
|
|
510
510
|
item_name TEXT,
|
|
511
511
|
description TEXT,
|
|
512
|
+
frequency TEXT DEFAULT 'One-time',
|
|
512
513
|
qty REAL DEFAULT 0,
|
|
513
514
|
uom TEXT DEFAULT 'Nos',
|
|
514
515
|
rate REAL DEFAULT 0,
|
|
@@ -1849,6 +1850,14 @@ def _m017_proposal_cover_template(db: "Database") -> None:
|
|
|
1849
1850
|
db._add_column_if_missing("Company", "proposal_cover_template", "TEXT")
|
|
1850
1851
|
|
|
1851
1852
|
|
|
1853
|
+
def _m018_quotation_item_frequency(db: "Database") -> None:
|
|
1854
|
+
"""Add Quotation Item.frequency — the billing cadence of an offer line
|
|
1855
|
+
(One-time / Monthly / Quarterly / Half-Yearly / Yearly, matching the
|
|
1856
|
+
Subscription billing intervals). One-time and recurring lines are totalled
|
|
1857
|
+
separately on the offer; existing rows are NULL, treated as One-time."""
|
|
1858
|
+
db._add_column_if_missing("Quotation Item", "frequency", "TEXT")
|
|
1859
|
+
|
|
1860
|
+
|
|
1852
1861
|
Database.MIGRATIONS = [
|
|
1853
1862
|
(1, "chat_message_session_id", _m001_chat_message_session_id),
|
|
1854
1863
|
(2, "chat_session_user_id", _m002_chat_session_user_id),
|
|
@@ -1867,6 +1876,7 @@ Database.MIGRATIONS = [
|
|
|
1867
1876
|
(15, "document_discarded", _m015_document_discarded),
|
|
1868
1877
|
(16, "customer_contact_person", _m016_customer_contact_person),
|
|
1869
1878
|
(17, "proposal_cover_template", _m017_proposal_cover_template),
|
|
1879
|
+
(18, "quotation_item_frequency", _m018_quotation_item_frequency),
|
|
1870
1880
|
]
|
|
1871
1881
|
|
|
1872
1882
|
|
|
@@ -68,6 +68,51 @@ class Quotation(Document):
|
|
|
68
68
|
|
|
69
69
|
# Calculate taxes and totals (the core shared calculation)
|
|
70
70
|
calculate_taxes_and_totals(self)
|
|
71
|
+
self._apply_frequency_split()
|
|
72
|
+
|
|
73
|
+
def _apply_frequency_split(self):
|
|
74
|
+
"""When the offer mixes one-time and recurring lines, the headline
|
|
75
|
+
totals AND the tax rows reflect the ONE-TIME part only — recurring
|
|
76
|
+
periods are presented separately (on the PDF) and must not inflate the
|
|
77
|
+
one-time grand total. A quote with no recurring lines is left exactly as
|
|
78
|
+
the shared engine computed it."""
|
|
79
|
+
import copy
|
|
80
|
+
from lambda_erp.controllers.taxes_and_totals import (
|
|
81
|
+
has_recurring_items, calculate_taxes_and_totals,
|
|
82
|
+
ONE_TIME_FREQUENCY, _item_frequency,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if not has_recurring_items(self):
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
items = self.get("items") or []
|
|
89
|
+
one_time_items = [it for it in items if _item_frequency(it) == ONE_TIME_FREQUENCY]
|
|
90
|
+
taxes = self.get("taxes") or []
|
|
91
|
+
|
|
92
|
+
# Re-run the standard engine on the one-time items alone, then copy its
|
|
93
|
+
# results back so the document's totals and tax rows are self-consistent.
|
|
94
|
+
tmp = {
|
|
95
|
+
"items": copy.deepcopy(one_time_items),
|
|
96
|
+
"taxes": copy.deepcopy(taxes),
|
|
97
|
+
"conversion_rate": self.get("conversion_rate") or 1.0,
|
|
98
|
+
}
|
|
99
|
+
calculate_taxes_and_totals(tmp)
|
|
100
|
+
|
|
101
|
+
total_fields = [
|
|
102
|
+
"total", "base_total", "net_total", "base_net_total",
|
|
103
|
+
"total_taxes_and_charges", "base_total_taxes_and_charges",
|
|
104
|
+
"grand_total", "base_grand_total", "rounded_total", "rounding_adjustment",
|
|
105
|
+
]
|
|
106
|
+
for f in total_fields:
|
|
107
|
+
self[f] = flt(tmp.get(f) or 0, 2)
|
|
108
|
+
|
|
109
|
+
# Bring each tax row down to its one-time amount so the printed MWSt line
|
|
110
|
+
# matches the one-time Gesamttotal.
|
|
111
|
+
tmp_taxes = tmp.get("taxes") or []
|
|
112
|
+
for i, tax in enumerate(taxes):
|
|
113
|
+
src = tmp_taxes[i] if i < len(tmp_taxes) else {}
|
|
114
|
+
for tf in ("tax_amount", "base_tax_amount", "total", "base_total"):
|
|
115
|
+
tax[tf] = flt(src.get(tf) or 0, 2)
|
|
71
116
|
|
|
72
117
|
def _validate_valid_till(self):
|
|
73
118
|
if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|