lambda-erp 1.0.2__tar.gz → 1.1.1__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 (114) hide show
  1. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/PKG-INFO +1 -1
  2. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/chat.py +18 -12
  3. lambda_erp-1.1.1/api/chat_links.py +59 -0
  4. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/pdf_profiles.py +6 -0
  5. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/masters.py +5 -0
  6. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/services.py +3 -0
  7. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/pos_invoice.py +42 -17
  8. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/purchase_invoice.py +31 -15
  9. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/sales_invoice.py +37 -22
  10. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/buying/purchase_order.py +11 -15
  11. lambda_erp-1.1.1/lambda_erp/controllers/defaults.py +114 -0
  12. lambda_erp-1.1.1/lambda_erp/controllers/item_price.py +247 -0
  13. lambda_erp-1.1.1/lambda_erp/controllers/pricing_rule.py +273 -0
  14. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/database.py +226 -12
  15. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/selling/quotation.py +15 -15
  16. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/selling/sales_order.py +12 -16
  17. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/pyproject.toml +1 -1
  18. lambda_erp-1.0.2/lambda_erp/controllers/defaults.py +0 -51
  19. lambda_erp-1.0.2/lambda_erp/controllers/pricing_rule.py +0 -112
  20. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/.gitignore +0 -0
  21. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/LICENSE +0 -0
  22. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/README.md +0 -0
  23. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/__init__.py +0 -0
  24. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/attachments.py +0 -0
  25. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/auth.py +0 -0
  26. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/bootstrap.py +0 -0
  27. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/demo_limits.py +0 -0
  28. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/deps.py +0 -0
  29. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/errors.py +0 -0
  30. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/list_values.py +0 -0
  31. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/main.py +0 -0
  32. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/oauth.py +0 -0
  33. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/pdf.py +0 -0
  34. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/pdf_contract.py +0 -0
  35. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/pdf_exports.py +0 -0
  36. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/providers.py +0 -0
  37. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/remarks_md.py +0 -0
  38. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/__init__.py +0 -0
  39. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/accounting.py +0 -0
  40. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/actions.py +0 -0
  41. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/admin.py +0 -0
  42. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/analytics.py +0 -0
  43. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/availability.py +0 -0
  44. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/bank_reconciliation.py +0 -0
  45. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/bank_statements.py +0 -0
  46. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/chat_api.py +0 -0
  47. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/documents.py +0 -0
  48. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/mcp.py +0 -0
  49. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/proposals.py +0 -0
  50. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/reports.py +0 -0
  51. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/routers/setup.py +0 -0
  52. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/templates/_proposal_totals.html +0 -0
  53. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/templates/_record_base.html +0 -0
  54. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/templates/document.html +0 -0
  55. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/templates/proposal.html +0 -0
  56. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/templates/record.html +0 -0
  57. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/time_filters.py +0 -0
  58. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/api/tool_permissions.py +0 -0
  59. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/docs/agents/README.md +0 -0
  60. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/frontend/LICENSE +0 -0
  61. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/frontend/README.md +0 -0
  62. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/frontend/src/api/client.ts +0 -0
  63. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/__init__.py +0 -0
  64. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/__init__.py +0 -0
  65. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/bank_account.py +0 -0
  66. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  67. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/bank_statement_import.py +0 -0
  68. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/bank_transaction.py +0 -0
  69. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/budget.py +0 -0
  70. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/camt.py +0 -0
  71. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  72. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/general_ledger.py +0 -0
  73. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/journal_entry.py +0 -0
  74. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/payment_entry.py +0 -0
  75. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/revaluation.py +0 -0
  76. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/settlement.py +0 -0
  77. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/__init__.py +0 -0
  78. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/engine.py +0 -0
  79. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/pack.py +0 -0
  80. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  81. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  82. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  83. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  84. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  85. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  86. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/profiles.py +0 -0
  87. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/setup/spine.py +0 -0
  88. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/accounting/subscription.py +0 -0
  89. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/assets/__init__.py +0 -0
  90. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/assets/asset.py +0 -0
  91. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/assets/lifecycle.py +0 -0
  92. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/assets/reservation.py +0 -0
  93. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/buying/__init__.py +0 -0
  94. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/controllers/__init__.py +0 -0
  95. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/controllers/currency.py +0 -0
  96. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/controllers/item_prices.py +0 -0
  97. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  98. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/exceptions.py +0 -0
  99. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/hooks.py +0 -0
  100. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/master_integrity.py +0 -0
  101. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/model.py +0 -0
  102. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/selling/__init__.py +0 -0
  103. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/selling/proposal.py +0 -0
  104. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/simulation.py +0 -0
  105. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/stock/__init__.py +0 -0
  106. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/stock/delivery_note.py +0 -0
  107. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/stock/purchase_receipt.py +0 -0
  108. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/stock/stock_entry.py +0 -0
  109. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/stock/stock_ledger.py +0 -0
  110. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/timestamps.py +0 -0
  111. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/utils.py +0 -0
  112. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/validation.py +0 -0
  113. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/lambda_erp/workflow.py +0 -0
  114. {lambda_erp-1.0.2 → lambda_erp-1.1.1}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.0.2
3
+ Version: 1.1.1
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -28,6 +28,7 @@ from openai import OpenAI
28
28
 
29
29
  from api.time_filters import TIME_FILTER_SCHEMA, time_filter_fields
30
30
  from api import services
31
+ from api.chat_links import with_record_view_urls
31
32
  from api.tool_permissions import tool_allowed, tool_permission_error
32
33
  from api.demo_limits import (
33
34
  demo_call_reserve_usd,
@@ -1459,6 +1460,7 @@ def _validate_filter_columns(doctype_slug, filters) -> str | None:
1459
1460
  return None
1460
1461
 
1461
1462
 
1463
+ @with_record_view_urls("doctype")
1462
1464
  def _handle_list_documents(args):
1463
1465
  doctype = args["doctype"]
1464
1466
  try:
@@ -1497,10 +1499,12 @@ def _handle_get_document_fields(args):
1497
1499
  return services.document_field_metadata(args["doctype"])
1498
1500
 
1499
1501
 
1502
+ @with_record_view_urls("doctype")
1500
1503
  def _handle_get_document(args):
1501
1504
  return services.load_document(args["doctype"], args["name"])
1502
1505
 
1503
1506
 
1507
+ @with_record_view_urls("doctype")
1504
1508
  def _handle_create_document(args):
1505
1509
  data = args.get("data")
1506
1510
  if not data:
@@ -1508,10 +1512,12 @@ def _handle_create_document(args):
1508
1512
  return services.create_document(args["doctype"], data)
1509
1513
 
1510
1514
 
1515
+ @with_record_view_urls("doctype")
1511
1516
  def _handle_update_document(args):
1512
1517
  return services.update_document(args["doctype"], args["name"], args.get("data", {}))
1513
1518
 
1514
1519
 
1520
+ @with_record_view_urls("doctype")
1515
1521
  def _handle_batch_update_documents(args):
1516
1522
  try:
1517
1523
  return services.batch_update_documents(args["doctype"], args.get("updates"))
@@ -1519,18 +1525,22 @@ def _handle_batch_update_documents(args):
1519
1525
  return {"error": str(e)}
1520
1526
 
1521
1527
 
1528
+ @with_record_view_urls("doctype")
1522
1529
  def _handle_submit_document(args):
1523
1530
  return services.submit_document(args["doctype"], args["name"])
1524
1531
 
1525
1532
 
1533
+ @with_record_view_urls("doctype")
1526
1534
  def _handle_cancel_document(args):
1527
1535
  return services.cancel_document(args["doctype"], args["name"])
1528
1536
 
1529
1537
 
1538
+ @with_record_view_urls("doctype")
1530
1539
  def _handle_discard_document(args):
1531
1540
  return services.discard_document(args["doctype"], args["name"])
1532
1541
 
1533
1542
 
1543
+ @with_record_view_urls("target_doctype")
1534
1544
  def _handle_convert_document(args):
1535
1545
  return services.convert_document(args["doctype"], args["name"], args["target_doctype"])
1536
1546
 
@@ -1607,6 +1617,7 @@ def _handle_get_master_fields(args):
1607
1617
  }
1608
1618
 
1609
1619
 
1620
+ @with_record_view_urls("master_type", master=True)
1610
1621
  def _handle_search_masters(args):
1611
1622
  db = get_db()
1612
1623
  meta = bool(args.get("include_meta")) or args.get("time_filter") is not None
@@ -1724,6 +1735,7 @@ def _ignored_master_fields(master_type: str, data: dict) -> list[str]:
1724
1735
  return [k for k in data.keys() if k not in valid]
1725
1736
 
1726
1737
 
1738
+ @with_record_view_urls("master_type", master=True)
1727
1739
  def _handle_create_master(args):
1728
1740
  master_type = args["master_type"]
1729
1741
  data = args.get("data")
@@ -1750,6 +1762,7 @@ def _handle_create_master(args):
1750
1762
  return result
1751
1763
 
1752
1764
 
1765
+ @with_record_view_urls("master_type", master=True)
1753
1766
  def _handle_update_master(args):
1754
1767
  master_type = args["master_type"]
1755
1768
  name = args.get("name")
@@ -2464,18 +2477,11 @@ Examples:
2464
2477
  - Correct: `[SINV-0012](/app/sales-invoice/SINV-0012)`
2465
2478
  - Avoid: `/reports/analytics?report_id=RPT-AB12CD34` (bare URL)"""
2466
2479
  document_links_section = """## Document & Master Links
2467
- When referencing records, always use clickable markdown links so the user can open them directly.
2468
-
2469
- **Documents** (quotations, invoices, orders, deliveries, receipts, payments, journal entries, stock entries):
2470
- - **View/edit link:** `/app/{doctype-slug}/{name}` — e.g. [SINV-0001](/app/sales-invoice/SINV-0001)
2471
- - **PDF files:** call generate_document_pdf. Only link the returned download_url after successful generation; never construct PDF links yourself.
2472
- The doctype slug is the lowercase, hyphenated form: sales-invoice, purchase-order, delivery-note, etc.
2473
-
2474
- **Master records** (customer, supplier, item, warehouse, company):
2475
- - **View/edit link:** `/masters/{master-type}/{name}` — e.g. [SUPP-001](/masters/supplier/SUPP-001), [CUST-003](/masters/customer/CUST-003), [ITEM-001](/masters/item/ITEM-001)
2476
- - NEVER use `/app/...` for masters — that path is only for transactional documents.
2477
-
2478
- Always include the view link after creating, submitting, converting, or updating a record. Call generate_document_pdf when a printable file is requested. Only confirm and link a successfully generated file."""
2480
+ Document and master tools return a canonical `view_url` on each record, including list/search rows and successful batch results. When linking a record, copy that exact value into a markdown link `[label](view_url)`. Never construct a record URL yourself, add a `#`, change the path, or re-encode the returned URL.
2481
+ `view_url` is tool output metadata, not a document/master field; never include it in create/update data.
2482
+ If `view_url` is null, refer to the record in plain text. For a projected document list or batch result missing parent-page information, call get_document to obtain its view_url if a link is needed. Some records have no page at all.
2483
+ Always include the returned view link, when available, after creating, submitting, converting, or updating a record.
2484
+ For PDF files, call generate_document_pdf and copy its returned download_url only after successful generation. Never construct PDF links yourself."""
2479
2485
 
2480
2486
  if user_role == "admin":
2481
2487
  role_desc = "You have **admin** access — full permissions to create, edit, submit, cancel documents, manage master data, run reports, and manage users."
@@ -0,0 +1,59 @@
1
+ """Canonical record links on chat/MCP tool results, never persisted fields."""
2
+
3
+ from functools import wraps
4
+ from urllib.parse import quote, urlencode
5
+
6
+ from api import services
7
+
8
+
9
+ def record_view_url(record_type: str, record: dict, *, master: bool = False) -> str | None:
10
+ name = record.get("name")
11
+ if not name:
12
+ return None
13
+ if master:
14
+ if record_type not in services.MASTER_TABLES or record_type == "cost-center":
15
+ return None
16
+ if record_type == "account":
17
+ return "/reports/general-ledger?" + urlencode({"account": name})
18
+ return f"/masters/{quote(record_type, safe='')}/{quote(str(name), safe='')}"
19
+
20
+ slug = services.DOCTYPE_TO_SLUG.get(record_type, record_type)
21
+ if slug not in services.SLUG_TO_DOCTYPE:
22
+ return None
23
+ page = services.chat_doctype_page_info(slug)
24
+ if page:
25
+ if page["kind"] == "none":
26
+ return None
27
+ if page["kind"] == "via":
28
+ name = record.get(page["link_field"])
29
+ if not name:
30
+ # Projected list/batch results may omit the parent. The model
31
+ # can get_document to obtain it; do not guess a destination.
32
+ return None
33
+ slug = page["parent_slug"]
34
+ return f"/app/{quote(slug, safe='')}/{quote(str(name), safe='')}"
35
+
36
+
37
+ def with_record_view_urls(type_arg: str, *, master: bool = False):
38
+ """Enrich records and list/batch envelopes without changing service data."""
39
+ def decorate(handler):
40
+ @wraps(handler)
41
+ def wrapped(args):
42
+ result = handler(args)
43
+
44
+ def enrich(row):
45
+ if not isinstance(row, dict) or "error" in row or row.get("ok") is False:
46
+ return row
47
+ return {**row, "view_url": record_view_url(args.get(type_arg), row, master=master)}
48
+
49
+ if isinstance(result, list):
50
+ return [enrich(row) for row in result]
51
+ if isinstance(result, dict) and "error" not in result:
52
+ if "name" not in result:
53
+ for key in ("rows", "results"):
54
+ if isinstance(result.get(key), list):
55
+ return {**result, key: [enrich(row) for row in result[key]]}
56
+ return enrich(result)
57
+ return result
58
+ return wrapped
59
+ return decorate
@@ -88,6 +88,12 @@ PROFILES.update({
88
88
  tables=(PDFTable('monthly_distribution', ('month', 'percentage'), ('month', 'percentage')),)),
89
89
  'Pricing Rule': PDFProfile('record', 'Pricing rule overview',
90
90
  fields=('title', 'company', 'item_code', 'selling', 'buying', 'rate_or_discount', 'rate', 'discount_percentage', 'discount_amount', 'min_qty', 'valid_from', 'valid_upto', 'priority', 'enabled'), required=('title', 'rate_or_discount')),
91
+ 'Price List': PDFProfile('record', 'Price list overview',
92
+ fields=('price_list_name', 'currency', 'selling', 'buying', 'enabled'),
93
+ required=('price_list_name', 'currency')),
94
+ 'Item Price': PDFProfile('record', 'Item price overview',
95
+ fields=('item_code', 'price_list', 'customer', 'supplier', 'uom', 'min_qty', 'rate', 'valid_from', 'valid_upto', 'enabled'),
96
+ required=('item_code', 'price_list', 'rate')),
91
97
  'Bank Account': PDFProfile('record', 'Bank account overview',
92
98
  fields=('account_name', 'company', 'account', 'iban', 'currency', 'bank_name', 'bic', 'disabled'), required=('account_name', 'company', 'account', 'iban', 'currency')),
93
99
  'Bank Transaction': PDFProfile('record', 'Bank transaction overview',
@@ -67,6 +67,8 @@ DELETE_REFERENCE_CHECKS = {
67
67
  ('SELECT 1 FROM "POS Invoice" WHERE customer = ? LIMIT 1', "POS invoice"),
68
68
  ('SELECT 1 FROM "Payment Entry" WHERE party_type = \'Customer\' AND party = ? LIMIT 1', "payment entry"),
69
69
  ('SELECT 1 FROM "Subscription" WHERE party_type = \'Customer\' AND party = ? LIMIT 1', "subscription"),
70
+ ('SELECT 1 FROM "Item Price" WHERE customer = ? LIMIT 1', "item price"),
71
+ ('SELECT 1 FROM "Pricing Rule" WHERE customer = ? LIMIT 1', "pricing rule"),
70
72
  ],
71
73
  "supplier": [
72
74
  ('SELECT 1 FROM "Purchase Order" WHERE supplier = ? LIMIT 1', "purchase order"),
@@ -88,6 +90,7 @@ DELETE_REFERENCE_CHECKS = {
88
90
  ('SELECT 1 FROM "Stock Ledger Entry" WHERE item_code = ? LIMIT 1', "stock ledger entry"),
89
91
  ('SELECT 1 FROM "Bin" WHERE item_code = ? LIMIT 1', "bin"),
90
92
  ('SELECT 1 FROM "Pricing Rule" WHERE item_code = ? LIMIT 1', "pricing rule"),
93
+ ('SELECT 1 FROM "Item Price" WHERE item_code = ? LIMIT 1', "item price"),
91
94
  ('SELECT 1 FROM "Subscription Plan" WHERE item_code = ? LIMIT 1', "subscription plan"),
92
95
  ('SELECT 1 FROM "Asset" WHERE item_code = ? LIMIT 1', "asset"),
93
96
  ('SELECT 1 FROM "Reservation" WHERE item_code = ? LIMIT 1', "reservation"),
@@ -213,6 +216,8 @@ def _normalize_master_data(data: dict) -> dict:
213
216
 
214
217
 
215
218
  MASTER_LINK_FIELDS = {
219
+ 'customer': {'default_price_list': 'Price List'},
220
+ 'company': {'default_price_list': 'Price List'},
216
221
  'item': {'default_warehouse': 'Warehouse'},
217
222
  'warehouse': {'company': 'Company', 'account': 'Account', 'parent_warehouse': 'Warehouse'},
218
223
  'account': {'company': 'Company', 'parent_account': 'Account'},
@@ -23,6 +23,7 @@ from lambda_erp.stock.delivery_note import DeliveryNote, make_delivery_note, mak
23
23
  from lambda_erp.stock.purchase_receipt import PurchaseReceipt, make_purchase_receipt, make_purchase_receipt_return
24
24
  from lambda_erp.accounting.pos_invoice import POSInvoice
25
25
  from lambda_erp.controllers.pricing_rule import PricingRule
26
+ from lambda_erp.controllers.item_price import PriceList, ItemPrice
26
27
  from lambda_erp.accounting.budget import Budget
27
28
  from lambda_erp.accounting.subscription import Subscription
28
29
  from lambda_erp.accounting.bank_transaction import BankTransaction
@@ -47,6 +48,8 @@ DOCUMENT_CLASSES = {
47
48
  "Purchase Receipt": PurchaseReceipt,
48
49
  "POS Invoice": POSInvoice,
49
50
  "Pricing Rule": PricingRule,
51
+ "Price List": PriceList,
52
+ "Item Price": ItemPrice,
50
53
  "Budget": Budget,
51
54
  "Subscription": Subscription,
52
55
  "Bank Transaction": BankTransaction,
@@ -13,7 +13,11 @@ from lambda_erp.model import Document
13
13
  from lambda_erp.utils import _dict, flt, nowdate
14
14
  from lambda_erp.database import get_db
15
15
  from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
16
- from lambda_erp.controllers.defaults import set_default_currency
16
+ from lambda_erp.controllers.defaults import (
17
+ set_default_currency, apply_external_source_defaults,
18
+ derived_pricing_fields, derived_line_pricing_fields,
19
+ )
20
+ from lambda_erp.controllers.item_price import set_item_defaults
17
21
  from lambda_erp.exceptions import ValidationError
18
22
  from lambda_erp.stock.stock_ledger import (
19
23
  make_sl_entries,
@@ -65,16 +69,44 @@ class POSInvoice(Document):
65
69
  if not self.posting_date:
66
70
  self.posting_date = nowdate()
67
71
 
72
+ apply_external_source_defaults(self)
68
73
  self._set_customer_name()
69
74
  self._set_missing_accounts()
75
+ set_default_currency(self, "Customer", "customer")
70
76
  self._set_item_defaults()
77
+ self._validate_external_source_stock()
71
78
  if self.is_return:
72
79
  self._validate_return()
73
- set_default_currency(self, "Customer", "customer")
80
+ # POS was the only selling document that never applied pricing rules.
81
+ # Nothing about a till sale makes a discount inapplicable, so the same
82
+ # item rang up at a different price depending on which document the
83
+ # sale happened to be written to.
84
+ from lambda_erp.controllers.pricing_rule import apply_pricing_rules
85
+ apply_pricing_rules(self)
86
+
74
87
  calculate_taxes_and_totals(self)
75
88
  self._calculate_payments()
76
89
  self._set_status()
77
90
 
91
+ def _validate_external_source_stock(self):
92
+ """An upstream system's stock movement already accounts for the goods.
93
+
94
+ `_validate_no_double_shipment` catches the same mistake on the Sales
95
+ Order -> Delivery Note path by looking up `sales_order_item`. A document
96
+ created from an external system has no such link, so that lookup finds
97
+ nothing and passes. Without this guard the upstream issue and this
98
+ document's own update_stock each decrement the same quantity.
99
+ """
100
+ if not self._data.get("external_source"):
101
+ return
102
+ if flt(self.get("update_stock")):
103
+ raise ValidationError(
104
+ f"Cannot submit with update_stock=1: this document is sourced "
105
+ f"from {self._data['external_source']}, which already recorded "
106
+ f"the stock movement. Use this invoice as a bill only and "
107
+ f"record any required stock movement in a separate stock document."
108
+ )
109
+
78
110
  def _validate_return(self):
79
111
  from lambda_erp.workflow import validate_return
80
112
  validate_return(self)
@@ -100,18 +132,8 @@ class POSInvoice(Document):
100
132
  item["cost_center"] = default_cc
101
133
 
102
134
  def _set_item_defaults(self):
103
- db = get_db()
104
- for item in self.get("items"):
105
- if item.get("item_code") and not item.get("item_name"):
106
- item_data = db.get_value(
107
- "Item", item["item_code"],
108
- ["item_name", "description", "stock_uom", "standard_rate"]
109
- )
110
- if item_data:
111
- item["item_name"] = item_data.item_name
112
- item["uom"] = item.get("uom") or item_data.stock_uom
113
- if item.get("rate") is None and item.get("price_list_rate") is None:
114
- item["rate"] = flt(item_data.standard_rate)
135
+ """Names, units and unsupplied rates. See controllers/item_price.py."""
136
+ set_item_defaults(self, "Customer", "customer", set_description=False)
115
137
 
116
138
  def _calculate_payments(self):
117
139
  paid = sum(flt(p.get("amount", 0)) for p in self.get("payments") or [])
@@ -197,16 +219,17 @@ class POSInvoice(Document):
197
219
  account = item.get("income_account")
198
220
  if not account:
199
221
  continue
200
- income_accounts[account] = income_accounts.get(account, 0) + flt(item.get("net_amount", 0))
222
+ key = (account, item.get("cost_center"))
223
+ income_accounts[key] = income_accounts.get(key, 0) + flt(item.get("net_amount", 0))
201
224
 
202
- for account, amount in income_accounts.items():
225
+ for (account, cost_center), amount in income_accounts.items():
203
226
  gl_entries.append(_dict(
204
227
  account=account,
205
228
  credit=flt(amount, 2),
206
229
  credit_in_account_currency=flt(amount, 2),
207
230
  debit=0,
208
231
  debit_in_account_currency=0,
209
- cost_center=db.get_value("Company", self.company, "default_cost_center"),
232
+ cost_center=cost_center,
210
233
  voucher_type=self.DOCTYPE,
211
234
  voucher_no=self.name,
212
235
  posting_date=self.posting_date,
@@ -288,6 +311,7 @@ def make_pos_return(posi_name):
288
311
  raise ValidationError("Cannot create a return against a return")
289
312
 
290
313
  return_pos = POSInvoice(
314
+ **derived_pricing_fields(original, is_return=True),
291
315
  customer=original.customer,
292
316
  company=original.company,
293
317
  currency=original.get("currency") or "USD",
@@ -302,6 +326,7 @@ def make_pos_return(posi_name):
302
326
  from lambda_erp.workflow import returnable_rows
303
327
  for item in returnable_rows(original):
304
328
  return_pos.append("items", _dict(
329
+ **derived_line_pricing_fields(item),
305
330
  item_code=item.get("item_code"),
306
331
  item_name=item.get("item_name"),
307
332
  description=item.get("description"),
@@ -14,7 +14,11 @@ from lambda_erp.model import Document
14
14
  from lambda_erp.utils import _dict, flt, nowdate, add_days
15
15
  from lambda_erp.database import get_db
16
16
  from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
17
- from lambda_erp.controllers.defaults import set_default_currency
17
+ from lambda_erp.controllers.defaults import (
18
+ set_default_currency, apply_external_source_defaults,
19
+ derived_pricing_fields, derived_line_pricing_fields,
20
+ )
21
+ from lambda_erp.controllers.item_price import set_item_defaults
18
22
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
19
23
  from lambda_erp.stock.stock_ledger import (
20
24
  make_sl_entries,
@@ -65,11 +69,14 @@ class PurchaseInvoice(Document):
65
69
  if not self.posting_date:
66
70
  self.posting_date = nowdate()
67
71
 
72
+ apply_external_source_defaults(self)
68
73
  self._set_supplier_name()
69
74
  self._set_missing_accounts()
75
+ set_default_currency(self, "Supplier", "supplier")
70
76
  self._set_item_defaults()
71
77
  self._validate_stock_warehouses()
72
78
  self._validate_no_double_receipt()
79
+ self._validate_external_source_stock()
73
80
 
74
81
  if self.is_return:
75
82
  self._validate_return()
@@ -77,7 +84,6 @@ class PurchaseInvoice(Document):
77
84
  from lambda_erp.controllers.pricing_rule import apply_pricing_rules
78
85
  apply_pricing_rules(self)
79
86
 
80
- set_default_currency(self, "Supplier", "supplier")
81
87
 
82
88
  calculate_taxes_and_totals(self)
83
89
 
@@ -172,6 +178,25 @@ class PurchaseInvoice(Document):
172
178
  f"is already allocated against it. Cancel the Payment Entry first."
173
179
  )
174
180
 
181
+ def _validate_external_source_stock(self):
182
+ """An upstream system's stock movement already accounts for the goods.
183
+
184
+ `_validate_no_double_shipment` catches the same mistake on the Sales
185
+ Order -> Delivery Note path by looking up `sales_order_item`. A document
186
+ created from an external system has no such link, so that lookup finds
187
+ nothing and passes. Without this guard the upstream issue and this
188
+ document's own update_stock each decrement the same quantity.
189
+ """
190
+ if not self._data.get("external_source"):
191
+ return
192
+ if flt(self.get("update_stock")):
193
+ raise ValidationError(
194
+ f"Cannot submit with update_stock=1: this document is sourced "
195
+ f"from {self._data['external_source']}, which already recorded "
196
+ f"the stock movement. Use this invoice as a bill only and "
197
+ f"record any required stock movement in a separate stock document."
198
+ )
199
+
175
200
  def _validate_no_double_receipt(self):
176
201
  """Block update_stock=1 when the referenced Purchase Order already has
177
202
  a Purchase Receipt for the line. Otherwise stock arrives twice: once
@@ -202,19 +227,8 @@ class PurchaseInvoice(Document):
202
227
  )
203
228
 
204
229
  def _set_item_defaults(self):
205
- db = get_db()
206
- for item in self.get("items"):
207
- if item.get("item_code") and not item.get("item_name"):
208
- item_data = db.get_value(
209
- "Item", item["item_code"],
210
- ["item_name", "description", "stock_uom", "standard_rate"]
211
- )
212
- if item_data:
213
- item["item_name"] = item_data.item_name
214
- item["description"] = item.get("description") or item_data.description
215
- item["uom"] = item.get("uom") or item_data.stock_uom
216
- if item.get("rate") is None and item.get("price_list_rate") is None:
217
- item["rate"] = flt(item_data.standard_rate)
230
+ """Names, units and unsupplied rates. See controllers/item_price.py."""
231
+ set_item_defaults(self, "Supplier", "supplier")
218
232
 
219
233
  def _validate_return(self):
220
234
  from lambda_erp.workflow import validate_return
@@ -408,6 +422,7 @@ def make_purchase_return(pinv_name):
408
422
  raise ValidationError("Cannot create a return against a return")
409
423
 
410
424
  return_inv = PurchaseInvoice(
425
+ **derived_pricing_fields(original, is_return=True),
411
426
  supplier=original.supplier,
412
427
  company=original.company,
413
428
  currency=original.get("currency") or "USD",
@@ -425,6 +440,7 @@ def make_purchase_return(pinv_name):
425
440
  from lambda_erp.workflow import returnable_rows
426
441
  for item in returnable_rows(original):
427
442
  return_inv.append("items", _dict(
443
+ **derived_line_pricing_fields(item),
428
444
  item_code=item.get("item_code"),
429
445
  item_name=item.get("item_name"),
430
446
  description=item.get("description"),
@@ -18,7 +18,11 @@ from lambda_erp.model import Document
18
18
  from lambda_erp.utils import _dict, flt, getdate, nowdate, add_days
19
19
  from lambda_erp.database import get_db
20
20
  from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
21
- from lambda_erp.controllers.defaults import set_default_currency
21
+ from lambda_erp.controllers.defaults import (
22
+ set_default_currency, apply_external_source_defaults,
23
+ derived_pricing_fields, derived_line_pricing_fields,
24
+ )
25
+ from lambda_erp.controllers.item_price import set_item_defaults
22
26
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
23
27
  from lambda_erp.stock.stock_ledger import (
24
28
  make_sl_entries,
@@ -81,10 +85,13 @@ class SalesInvoice(Document):
81
85
  if not self.posting_date:
82
86
  self.posting_date = nowdate()
83
87
 
88
+ apply_external_source_defaults(self)
84
89
  self._set_customer_name()
85
90
  self._set_missing_accounts()
91
+ set_default_currency(self, "Customer", "customer")
86
92
  self._set_item_defaults()
87
93
  self._validate_no_double_shipment()
94
+ self._validate_external_source_stock()
88
95
 
89
96
  if self.is_return:
90
97
  self._validate_return()
@@ -92,7 +99,6 @@ class SalesInvoice(Document):
92
99
  from lambda_erp.controllers.pricing_rule import apply_pricing_rules
93
100
  apply_pricing_rules(self)
94
101
 
95
- set_default_currency(self, "Customer", "customer")
96
102
 
97
103
  calculate_taxes_and_totals(self)
98
104
 
@@ -133,19 +139,27 @@ class SalesInvoice(Document):
133
139
  item["cost_center"] = default_cc
134
140
 
135
141
  def _set_item_defaults(self):
136
- db = get_db()
137
- for item in self.get("items"):
138
- if item.get("item_code") and not item.get("item_name"):
139
- item_data = db.get_value(
140
- "Item", item["item_code"],
141
- ["item_name", "description", "stock_uom", "standard_rate"]
142
- )
143
- if item_data:
144
- item["item_name"] = item_data.item_name
145
- item["description"] = item.get("description") or item_data.description
146
- item["uom"] = item.get("uom") or item_data.stock_uom
147
- if item.get("rate") is None and item.get("price_list_rate") is None:
148
- item["rate"] = flt(item_data.standard_rate)
142
+ """Names, units and unsupplied rates. See controllers/item_price.py."""
143
+ set_item_defaults(self, "Customer", "customer")
144
+
145
+ def _validate_external_source_stock(self):
146
+ """An upstream system's stock movement already accounts for the goods.
147
+
148
+ `_validate_no_double_shipment` catches the same mistake on the Sales
149
+ Order -> Delivery Note path by looking up `sales_order_item`. A document
150
+ created from an external system has no such link, so that lookup finds
151
+ nothing and passes. Without this guard the upstream issue and this
152
+ document's own update_stock each decrement the same quantity.
153
+ """
154
+ if not self._data.get("external_source"):
155
+ return
156
+ if flt(self.get("update_stock")):
157
+ raise ValidationError(
158
+ f"Cannot submit with update_stock=1: this document is sourced "
159
+ f"from {self._data['external_source']}, which already recorded "
160
+ f"the stock movement. Use this invoice as a bill only and "
161
+ f"record any required stock movement in a separate stock document."
162
+ )
149
163
 
150
164
  def _validate_no_double_shipment(self):
151
165
  """Block update_stock=1 when the referenced Sales Order already has a
@@ -338,15 +352,14 @@ class SalesInvoice(Document):
338
352
  )
339
353
 
340
354
  # 2. Credit: Income accounts (per item)
341
- # In the reference implementation, items with the same income_account are grouped
355
+ # Group only lines with the same account AND cost centre.
342
356
  income_accounts = {}
343
357
  for item in self.get("items"):
344
358
  account = item.get("income_account")
345
- if account not in income_accounts:
346
- income_accounts[account] = 0
347
- income_accounts[account] += flt(item.get("net_amount", 0))
359
+ key = (account, item.get("cost_center"))
360
+ income_accounts[key] = income_accounts.get(key, 0) + flt(item.get("net_amount", 0))
348
361
 
349
- for account, amount in income_accounts.items():
362
+ for (account, cost_center), amount in income_accounts.items():
350
363
  gl_entries.append(
351
364
  _dict(
352
365
  account=account,
@@ -354,7 +367,7 @@ class SalesInvoice(Document):
354
367
  credit_in_account_currency=flt(amount, 2),
355
368
  debit=0,
356
369
  debit_in_account_currency=0,
357
- cost_center=self.get("items")[0].get("cost_center") if self.get("items") else None,
370
+ cost_center=cost_center,
358
371
  voucher_type=self.DOCTYPE,
359
372
  voucher_no=self.name,
360
373
  posting_date=self.posting_date,
@@ -373,7 +386,7 @@ class SalesInvoice(Document):
373
386
  credit_in_account_currency=flt(tax["tax_amount"], 2),
374
387
  debit=0,
375
388
  debit_in_account_currency=0,
376
- cost_center=self.get("items")[0].get("cost_center") if self.get("items") else None,
389
+ cost_center=get_db().get_value("Company", self.company, "default_cost_center"),
377
390
  voucher_type=self.DOCTYPE,
378
391
  voucher_no=self.name,
379
392
  posting_date=self.posting_date,
@@ -404,6 +417,7 @@ def make_sales_return(sinv_name):
404
417
  raise ValidationError("Cannot create a return against a return")
405
418
 
406
419
  return_inv = SalesInvoice(
420
+ **derived_pricing_fields(original, is_return=True),
407
421
  customer=original.customer,
408
422
  company=original.company,
409
423
  currency=original.get("currency") or "USD",
@@ -421,6 +435,7 @@ def make_sales_return(sinv_name):
421
435
  from lambda_erp.workflow import returnable_rows
422
436
  for item in returnable_rows(original):
423
437
  return_inv.append("items", _dict(
438
+ **derived_line_pricing_fields(item),
424
439
  item_code=item.get("item_code"),
425
440
  item_name=item.get("item_name"),
426
441
  description=item.get("description"),
@@ -14,7 +14,11 @@ from lambda_erp.model import Document
14
14
  from lambda_erp.utils import _dict, flt, getdate, nowdate
15
15
  from lambda_erp.database import get_db
16
16
  from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
17
- from lambda_erp.controllers.defaults import set_default_currency
17
+ from lambda_erp.controllers.defaults import (
18
+ set_default_currency, apply_external_source_defaults,
19
+ derived_pricing_fields, derived_line_pricing_fields,
20
+ )
21
+ from lambda_erp.controllers.item_price import set_item_defaults
18
22
  from lambda_erp.exceptions import ValidationError
19
23
 
20
24
  class PurchaseOrder(Document):
@@ -49,13 +53,14 @@ class PurchaseOrder(Document):
49
53
  if not self.transaction_date:
50
54
  self.transaction_date = nowdate()
51
55
 
56
+ apply_external_source_defaults(self)
52
57
  self._set_supplier_name()
58
+ set_default_currency(self, "Supplier", "supplier")
53
59
  self._set_item_defaults()
54
60
 
55
61
  from lambda_erp.controllers.pricing_rule import apply_pricing_rules
56
62
  apply_pricing_rules(self)
57
63
 
58
- set_default_currency(self, "Supplier", "supplier")
59
64
 
60
65
  calculate_taxes_and_totals(self)
61
66
 
@@ -65,19 +70,8 @@ class PurchaseOrder(Document):
65
70
  self.supplier_name = db.get_value("Supplier", self.supplier, "supplier_name")
66
71
 
67
72
  def _set_item_defaults(self):
68
- db = get_db()
69
- for item in self.get("items"):
70
- if item.get("item_code") and not item.get("item_name"):
71
- item_data = db.get_value(
72
- "Item", item["item_code"],
73
- ["item_name", "description", "stock_uom", "standard_rate"]
74
- )
75
- if item_data:
76
- item["item_name"] = item_data.item_name
77
- item["description"] = item.get("description") or item_data.description
78
- item["uom"] = item.get("uom") or item_data.stock_uom
79
- if item.get("rate") is None and item.get("price_list_rate") is None:
80
- item["rate"] = flt(item_data.standard_rate)
73
+ """Names, units and unsupplied rates. See controllers/item_price.py."""
74
+ set_item_defaults(self, "Supplier", "supplier")
81
75
 
82
76
  def _update_ordered_qty(self, direction=1):
83
77
  from lambda_erp.workflow import refresh_order_progress
@@ -101,6 +95,7 @@ def make_purchase_invoice(purchase_order_name):
101
95
  raise ValidationError("Purchase Order must be submitted before creating Purchase Invoice")
102
96
 
103
97
  pi = PurchaseInvoice(
98
+ **derived_pricing_fields(po, is_return=False),
104
99
  supplier=po.supplier,
105
100
  supplier_name=po.supplier_name,
106
101
  company=po.company,
@@ -116,6 +111,7 @@ def make_purchase_invoice(purchase_order_name):
116
111
  continue
117
112
 
118
113
  pi.append("items", _dict(
114
+ **derived_line_pricing_fields(item),
119
115
  item_code=item.get("item_code"),
120
116
  item_name=item.get("item_name"),
121
117
  description=item.get("description"),