lambda-erp 0.1.28__tar.gz → 0.1.30__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 (69) hide show
  1. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/PKG-INFO +1 -1
  2. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/chat.py +9 -1
  3. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/pdf.py +17 -0
  4. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/controllers/taxes_and_totals.py +73 -0
  5. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/database.py +10 -0
  6. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/selling/quotation.py +45 -0
  7. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/pyproject.toml +1 -1
  8. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/.gitignore +0 -0
  9. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/LICENSE +0 -0
  10. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/README.md +0 -0
  11. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/__init__.py +0 -0
  12. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/attachments.py +0 -0
  13. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/auth.py +0 -0
  14. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/bootstrap.py +0 -0
  15. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/demo_limits.py +0 -0
  16. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/deps.py +0 -0
  17. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/errors.py +0 -0
  18. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/main.py +0 -0
  19. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/providers.py +0 -0
  20. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/remarks_md.py +0 -0
  21. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/__init__.py +0 -0
  22. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/accounting.py +0 -0
  23. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/admin.py +0 -0
  24. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/analytics.py +0 -0
  25. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/bank_reconciliation.py +0 -0
  26. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/documents.py +0 -0
  27. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/masters.py +0 -0
  28. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/proposals.py +0 -0
  29. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/reports.py +0 -0
  30. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/routers/setup.py +0 -0
  31. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/services.py +0 -0
  32. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/templates/document.html +0 -0
  33. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/api/templates/proposal.html +0 -0
  34. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/docs/agents/README.md +0 -0
  35. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/frontend/README.md +0 -0
  36. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/frontend/src/api/client.ts +0 -0
  37. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/__init__.py +0 -0
  38. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/__init__.py +0 -0
  39. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/bank_transaction.py +0 -0
  40. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/budget.py +0 -0
  41. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  42. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/general_ledger.py +0 -0
  43. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/journal_entry.py +0 -0
  44. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/payment_entry.py +0 -0
  45. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/pos_invoice.py +0 -0
  46. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/purchase_invoice.py +0 -0
  47. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/revaluation.py +0 -0
  48. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/sales_invoice.py +0 -0
  49. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/accounting/subscription.py +0 -0
  50. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/buying/__init__.py +0 -0
  51. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/buying/purchase_order.py +0 -0
  52. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/controllers/__init__.py +0 -0
  53. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/controllers/currency.py +0 -0
  54. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/controllers/defaults.py +0 -0
  55. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/controllers/pricing_rule.py +0 -0
  56. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/exceptions.py +0 -0
  57. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/hooks.py +0 -0
  58. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/model.py +0 -0
  59. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/selling/__init__.py +0 -0
  60. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/selling/proposal.py +0 -0
  61. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/selling/sales_order.py +0 -0
  62. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/simulation.py +0 -0
  63. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/stock/__init__.py +0 -0
  64. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/stock/delivery_note.py +0 -0
  65. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/stock/purchase_receipt.py +0 -0
  66. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/stock/stock_entry.py +0 -0
  67. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/stock/stock_ledger.py +0 -0
  68. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/lambda_erp/utils.py +0 -0
  69. {lambda_erp-0.1.28 → lambda_erp-0.1.30}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lambda-erp
3
- Version: 0.1.28
3
+ Version: 0.1.30
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: MIT
@@ -1708,11 +1708,19 @@ Shape — note it does NOT use `items`:
1708
1708
 
1709
1709
  To build one: ensure each offer already exists as its own Quotation (create them first if needed), then call create_document with doctype "proposal" and a data object whose `quotations` array references those quotations by name. Do NOT submit it; link the user to the PDF at `/api/documents/proposal/<name>/pdf` (and the editor at `/app/proposal/<name>`).
1710
1710
 
1711
+ ### Recurring offer lines (quotation line `frequency`)
1712
+ A **Quotation** line item carries an optional `frequency` that controls how it is billed and totalled on the offer:
1713
+ - `One-time` (the default) — a normal one-off line.
1714
+ - `Monthly` / `Quarterly` / `Half-Yearly` / `Yearly` — a recurring line.
1715
+ When a quote mixes one-time and recurring lines, the one-time lines form the headline grand total (the "Gesamttotal"), and each recurring cadence is totalled **separately** (its own net + MWSt) so a monthly fee never inflates the one-time total. To put a recurring service ON the offer as a real line, set that line's `frequency` alongside its item_code / qty / rate — e.g. a CHF 380 line with `frequency` set to `Monthly`. These values match the Subscription billing intervals exactly; the Quotation itself still posts nothing to the ledger.
1716
+
1717
+ Use the line `frequency` for a recurring item that is genuinely part of the quote. For recurring info you only want to SHOW but that is NOT part of the quotation (a "billed separately after implementation" aside), use the `>> … | …` notes markup below instead — that is cosmetic free text, not a line item, and never enters any total.
1718
+
1711
1719
  ### Notes / Terms markup (the `remarks` field)
1712
1720
  The `remarks` (Notes / Terms) field on quotations, sales orders, and invoices renders on the PDF with a small markup vocabulary, so when a user dictates offer notes, conditions, recurring services, or a sign-off you can compose a polished closing block instead of a flat paragraph. Put this ONLY in `remarks` (never in item descriptions):
1713
1721
  - `# Heading` at the start of a line -> bold heading
1714
1722
  - `*italic*` or `_italic_` -> italic; `**bold**` -> bold
1715
- - `>> Period | Amount` at the start of a line -> a right-aligned price line that sits beside the heading/description above it; use it for separately- or recurring-billed items, e.g. `>> Monatlich | CHF 380.—`
1723
+ - A line starting with `>>` is a price line, split by the FIRST `|` pipe: the text BEFORE the pipe goes in the left (frequency) column, the text AFTER it in the right (amount) column — both placed literally as you write them, NOT interpreted (it does not know "Monatlich" is a frequency or that "CHF 380" is a number). Omit the pipe to put everything in the amount column. The two columns line up with the line-items table's frequency/amount columns, so use it for a separately- or recurring-billed item, e.g. `>> Monatlich | CHF 380.—`.
1716
1724
  - Separate blocks with a blank line; a single newline is just a line break.
1717
1725
  Plain text (no special characters) still renders fine, so only reach for the markup when it improves a customer-facing note.
1718
1726
 
@@ -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):
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "0.1.28"
3
+ version = "0.1.30"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "MIT"
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