lambda-erp 1.4.1__tar.gz → 1.4.3__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 (125) hide show
  1. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/PKG-INFO +1 -1
  2. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/auth.py +24 -0
  3. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/main.py +2 -1
  4. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/masters.py +5 -0
  5. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/pos_invoice.py +2 -1
  6. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/purchase_invoice.py +15 -7
  7. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/sales_invoice.py +11 -2
  8. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/defaults.py +8 -2
  9. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/pricing_rule.py +92 -3
  10. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/database.py +41 -1
  11. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/sales_order.py +10 -11
  12. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/delivery_note.py +2 -1
  13. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/purchase_receipt.py +22 -16
  14. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/stock_entry.py +3 -7
  15. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/stock_ledger.py +69 -37
  16. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/pyproject.toml +1 -1
  17. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/.gitignore +0 -0
  18. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/LICENSE +0 -0
  19. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/README.md +0 -0
  20. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/__init__.py +0 -0
  21. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/attachments.py +0 -0
  22. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/bootstrap.py +0 -0
  23. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/chat.py +0 -0
  24. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/chat_links.py +0 -0
  25. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/demo_limits.py +0 -0
  26. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/deps.py +0 -0
  27. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/erp_context.py +0 -0
  28. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/errors.py +0 -0
  29. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/list_values.py +0 -0
  30. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/llm_provider.py +0 -0
  31. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/mcp_oauth.py +0 -0
  32. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/mentions.py +0 -0
  33. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/menu_layout.py +0 -0
  34. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/oauth.py +0 -0
  35. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf.py +0 -0
  36. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_contract.py +0 -0
  37. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_exports.py +0 -0
  38. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_fonts.py +0 -0
  39. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_pages.py +0 -0
  40. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_profiles.py +0 -0
  41. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_templates.py +0 -0
  42. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/providers.py +0 -0
  43. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/record_notes.py +0 -0
  44. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/remarks_md.py +0 -0
  45. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/__init__.py +0 -0
  46. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/accounting.py +0 -0
  47. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/actions.py +0 -0
  48. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/admin.py +0 -0
  49. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/analytics.py +0 -0
  50. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/availability.py +0 -0
  51. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/bank_reconciliation.py +0 -0
  52. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/bank_statements.py +0 -0
  53. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/chat_api.py +0 -0
  54. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/documents.py +0 -0
  55. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/mcp.py +0 -0
  56. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/pdf_templates.py +0 -0
  57. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/proposals.py +0 -0
  58. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/reports.py +0 -0
  59. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/setup.py +0 -0
  60. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/services.py +0 -0
  61. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/_proposal_totals.html +0 -0
  62. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/_record_base.html +0 -0
  63. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/delivery_note.html +0 -0
  64. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/document.html +0 -0
  65. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/proposal.html +0 -0
  66. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/record.html +0 -0
  67. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/reservation.html +0 -0
  68. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/time_filters.py +0 -0
  69. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/tool_permissions.py +0 -0
  70. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/write_context.py +0 -0
  71. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/docs/agents/README.md +0 -0
  72. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/frontend/LICENSE +0 -0
  73. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/frontend/README.md +0 -0
  74. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/frontend/src/api/client.ts +0 -0
  75. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/__init__.py +0 -0
  76. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/__init__.py +0 -0
  77. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_account.py +0 -0
  78. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
  79. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
  80. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_transaction.py +0 -0
  81. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/budget.py +0 -0
  82. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/camt.py +0 -0
  83. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
  84. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/general_ledger.py +0 -0
  85. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/journal_entry.py +0 -0
  86. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/payment_entry.py +0 -0
  87. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/revaluation.py +0 -0
  88. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/settlement.py +0 -0
  89. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/__init__.py +0 -0
  90. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/engine.py +0 -0
  91. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/pack.py +0 -0
  92. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
  93. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
  94. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
  95. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
  96. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
  97. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
  98. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/profiles.py +0 -0
  99. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/spine.py +0 -0
  100. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/subscription.py +0 -0
  101. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/__init__.py +0 -0
  102. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/asset.py +0 -0
  103. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/lifecycle.py +0 -0
  104. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/reservation.py +0 -0
  105. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/buying/__init__.py +0 -0
  106. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/buying/purchase_order.py +0 -0
  107. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/__init__.py +0 -0
  108. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/currency.py +0 -0
  109. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/item_price.py +0 -0
  110. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/item_prices.py +0 -0
  111. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
  112. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/exceptions.py +0 -0
  113. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/hooks.py +0 -0
  114. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/master_integrity.py +0 -0
  115. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/model.py +0 -0
  116. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/__init__.py +0 -0
  117. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/proposal.py +0 -0
  118. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/quotation.py +0 -0
  119. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/simulation.py +0 -0
  120. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/__init__.py +0 -0
  121. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/timestamps.py +0 -0
  122. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/utils.py +0 -0
  123. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/validation.py +0 -0
  124. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/workflow.py +0 -0
  125. {lambda_erp-1.4.1 → lambda_erp-1.4.3}/terraform/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lambda-erp
3
- Version: 1.4.1
3
+ Version: 1.4.3
4
4
  Summary: Core ERP logic - accounting, sales, purchasing, inventory
5
5
  Author: TORUS INVESTMENTS AG
6
6
  License-Expression: Apache-2.0
@@ -762,6 +762,30 @@ def _setting_enabled(db, key: str) -> bool:
762
762
  return str(value) == "1"
763
763
 
764
764
 
765
+ def disable_public_demo_if_switched_off() -> int:
766
+ """With LAMBDA_ERP_ENABLE_PUBLIC_DEMO explicitly "0", no public demo user
767
+ stays active.
768
+
769
+ Demo mode is the enabled ``public_manager`` user: requests without a
770
+ credential fall back to it (get_current_user, the chat WebSocket). It is
771
+ created by booting with the flag at 1 or from the admin UI, and survived
772
+ switching the flag back off, leaving anonymous write access on a
773
+ deployment that once ran the demo. Unset keeps today's behaviour (the
774
+ admin UI toggle decides). Returns the number of users disabled.
775
+ """
776
+ if os.environ.get("LAMBDA_ERP_ENABLE_PUBLIC_DEMO") != "0":
777
+ return 0
778
+ db = get_db()
779
+ active = db.sql('SELECT name FROM "User" WHERE role = \'public_manager\' AND enabled = 1')
780
+ for row in active:
781
+ db.set_value("User", row["name"], {"enabled": 0, "modified": now()})
782
+ if active:
783
+ db.commit()
784
+ print(f"[public-demo] LAMBDA_ERP_ENABLE_PUBLIC_DEMO=0: disabled {len(active)} public demo "
785
+ "user(s); requests without a credential are now refused", flush=True)
786
+ return len(active)
787
+
788
+
765
789
  def ensure_seed_admin() -> dict | None:
766
790
  """Idempotently provision an admin account from the environment.
767
791
 
@@ -88,8 +88,9 @@ async def lifespan(app: FastAPI):
88
88
  # Seed a deterministic admin from the environment (if configured) before
89
89
  # anyone can register. On deployments whose DB is recreated each rollout
90
90
  # this stops the first visitor after a redeploy from grabbing admin.
91
- from api.auth import ensure_seed_admin
91
+ from api.auth import disable_public_demo_if_switched_off, ensure_seed_admin
92
92
  ensure_seed_admin()
93
+ disable_public_demo_if_switched_off()
93
94
 
94
95
  # When packaged as a demo container, land visitors straight in demo mode.
95
96
  if os.environ.get("LAMBDA_ERP_AUTO_DEMO") == "1":
@@ -270,6 +270,8 @@ def _validate_master_input(master_type, doctype, data, name=None):
270
270
  for field, table in MASTER_LINK_FIELDS.get(master_type, {}).items():
271
271
  if merged.get(field) and not db.exists(table, merged[field]):
272
272
  raise ValidationError(f'{doctype}: {field} must reference an existing {table}')
273
+ if master_type == 'item' and data.get('valuation_method') not in (None, '', 'Moving Average'):
274
+ raise ValidationError('Item: valuation_method must be Moving Average; it is the only valuation the stock ledger implements')
273
275
  if master_type == 'company' and data.get('default_sales_tax_template'):
274
276
  template = db.get_value('Tax Template', data['default_sales_tax_template'], ['company', 'tax_type'])
275
277
  if not template or template['company'] != (name or data.get('name')) or template['tax_type'] != 'Sales':
@@ -300,6 +302,9 @@ def _create_master_record(master_type: str, data: dict) -> dict:
300
302
  doc = _normalize_master_data(data)
301
303
  doc.pop("creation", None)
302
304
  doc.pop("modified", None)
305
+ if master_type == "item" and not doc.get("valuation_method"):
306
+ # Databases created before 1.4.2 still carry the column default FIFO.
307
+ doc["valuation_method"] = "Moving Average"
303
308
 
304
309
  # Let callers set the code under its intuitive alias (item_code -> name).
305
310
  # Conflicting identities must never silently select one of the records.
@@ -20,6 +20,7 @@ from lambda_erp.controllers.defaults import (
20
20
  from lambda_erp.controllers.item_price import set_item_defaults
21
21
  from lambda_erp.exceptions import ValidationError
22
22
  from lambda_erp.stock.stock_ledger import (
23
+ make_cancel_sl_entries,
23
24
  make_sl_entries,
24
25
  build_sell_side_sles,
25
26
  build_cost_basis_gl,
@@ -183,7 +184,7 @@ class POSInvoice(Document):
183
184
  if flt(self._data.get("update_stock")):
184
185
  reversed_sles = reverse_stock_sles(self._get_sl_entries())
185
186
  if reversed_sles:
186
- make_sl_entries(reversed_sles, allow_negative_stock=True)
187
+ make_cancel_sl_entries(reversed_sles)
187
188
 
188
189
  make_reverse_gl_entries(
189
190
  voucher_type=self.DOCTYPE,
@@ -21,9 +21,11 @@ from lambda_erp.controllers.defaults import (
21
21
  from lambda_erp.controllers.item_price import set_item_defaults
22
22
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
23
23
  from lambda_erp.stock.stock_ledger import (
24
+ make_cancel_sl_entries,
24
25
  make_sl_entries,
25
26
  build_buy_side_sles,
26
27
  reverse_stock_sles,
28
+ stock_account_for,
27
29
  )
28
30
  from lambda_erp.exceptions import ValidationError
29
31
 
@@ -123,9 +125,6 @@ class PurchaseInvoice(Document):
123
125
  stock_received_account = db.get_value(
124
126
  "Company", self.company, "stock_received_but_not_billed"
125
127
  )
126
- stock_in_hand_account = db.get_value(
127
- "Company", self.company, "stock_in_hand_account"
128
- )
129
128
  default_cc = db.get_value("Company", self.company, "default_cost_center")
130
129
  directly_receiving = flt(self.get("update_stock")) == 1
131
130
 
@@ -134,8 +133,10 @@ class PurchaseInvoice(Document):
134
133
  is_stock = 0
135
134
  if item.get("item_code"):
136
135
  is_stock = db.get_value("Item", item["item_code"], "is_stock_item") or 0
137
- if is_stock and directly_receiving and stock_in_hand_account:
138
- item["expense_account"] = stock_in_hand_account
136
+ stock_account = (stock_account_for(self.company, item.get("warehouse"))
137
+ if is_stock and directly_receiving else None)
138
+ if stock_account:
139
+ item["expense_account"] = stock_account
139
140
  elif is_stock and stock_received_account:
140
141
  item["expense_account"] = stock_received_account
141
142
  else:
@@ -200,7 +201,12 @@ class PurchaseInvoice(Document):
200
201
  def _validate_no_double_receipt(self):
201
202
  """Block update_stock=1 when the referenced Purchase Order already has
202
203
  a Purchase Receipt for the line. Otherwise stock arrives twice: once
203
- via the PR and again when this invoice submits. Returns are exempt."""
204
+ via the PR and again when this invoice submits. Returns are exempt.
205
+
206
+ A line linked to its order line (`purchase_order_item`) is covered more
207
+ precisely by workflow.validate_order_quantities (receipts plus
208
+ direct-stock invoices may not exceed the ordered quantity), so the rest
209
+ of a partly received line can still be received with this invoice."""
204
210
  if not flt(self.get("update_stock")) or self.is_return:
205
211
  return
206
212
  db = get_db()
@@ -209,6 +215,8 @@ class PurchaseInvoice(Document):
209
215
  po_item = item.get("purchase_order_item")
210
216
  if not (po or po_item):
211
217
  continue
218
+ if po_item:
219
+ continue # order-line capacity check (validate_order_quantities)
212
220
  pr_rows = db.sql(
213
221
  'SELECT pr.name AS pr_name '
214
222
  'FROM "Purchase Receipt Item" pri '
@@ -309,7 +317,7 @@ class PurchaseInvoice(Document):
309
317
  if flt(self.get("update_stock")):
310
318
  reversed_sles = reverse_stock_sles(self._get_stock_sl_entries())
311
319
  if reversed_sles:
312
- make_sl_entries(reversed_sles, allow_negative_stock=True)
320
+ make_cancel_sl_entries(reversed_sles)
313
321
 
314
322
  make_reverse_gl_entries(
315
323
  voucher_type=self.DOCTYPE,
@@ -25,6 +25,7 @@ from lambda_erp.controllers.defaults import (
25
25
  from lambda_erp.controllers.item_price import set_item_defaults
26
26
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
27
27
  from lambda_erp.stock.stock_ledger import (
28
+ make_cancel_sl_entries,
28
29
  make_sl_entries,
29
30
  build_sell_side_sles,
30
31
  build_cost_basis_gl,
@@ -167,7 +168,13 @@ class SalesInvoice(Document):
167
168
  """Block update_stock=1 when the referenced Sales Order already has a
168
169
  Delivery Note for the line. Otherwise stock ships twice: once via the
169
170
  DN and again when this invoice submits. Returns are exempt (they
170
- reverse the SI's own earlier shipment, not a separate DN)."""
171
+ reverse the SI's own earlier shipment, not a separate DN).
172
+
173
+ A line linked to its order line (`sales_order_item`) is covered more
174
+ precisely by workflow.validate_order_quantities: delivery notes plus
175
+ direct-stock invoices may not move more than the line ordered, so the
176
+ rest of a partly delivered line can still ship with this invoice. The
177
+ coarse check stays for lines that only name the order."""
171
178
  if not flt(self.get("update_stock")) or self.is_return:
172
179
  return
173
180
  db = get_db()
@@ -176,6 +183,8 @@ class SalesInvoice(Document):
176
183
  so = item.get("sales_order")
177
184
  if not (so or so_item):
178
185
  continue
186
+ if item.get("sales_order_item"):
187
+ continue # order-line capacity check (validate_order_quantities)
179
188
  dn_rows = db.sql(
180
189
  'SELECT dn.name AS dn_name '
181
190
  'FROM "Delivery Note Item" dni '
@@ -305,7 +314,7 @@ class SalesInvoice(Document):
305
314
  if flt(self.get("update_stock")):
306
315
  reversed_sles = reverse_stock_sles(self._get_stock_sl_entries())
307
316
  if reversed_sles:
308
- make_sl_entries(reversed_sles, allow_negative_stock=True)
317
+ make_cancel_sl_entries(reversed_sles)
309
318
 
310
319
  make_reverse_gl_entries(
311
320
  voucher_type=self.DOCTYPE,
@@ -109,6 +109,12 @@ def derived_pricing_fields(source, *, is_return=False):
109
109
 
110
110
 
111
111
  def derived_line_pricing_fields(item):
112
- return {field: item.get(field) for field in (
113
- "ignore_pricing_rule", "external_line_reference", "pricing_rule",
112
+ """Line pricing provenance for a converted line. The price was agreed in
113
+ the source document, so pricing rules must not re-run on it (with a later
114
+ date or a newly added rule); lines added to the new document still get
115
+ rules."""
116
+ fields = {field: item.get(field) for field in (
117
+ "external_line_reference", "pricing_rule",
114
118
  )}
119
+ fields["ignore_pricing_rule"] = 1
120
+ return fields
@@ -17,6 +17,19 @@ tightest quantity band, then name. Priority is the deliberate override — it is
17
17
  campaign beat a customer's standing rate without rewriting either.
18
18
 
19
19
  One rule applies per line. Rules do not stack.
20
+
21
+ Rules match on the document's own date (posting_date / transaction_date), not
22
+ on today. A Rate or Discount Amount rule is a number in one currency
23
+ (`currency`, defaulting to the company's); it only applies to documents in
24
+ that currency. A Discount Percentage rule applies in any currency.
25
+
26
+ What the rules never override: a rate the user changed on a saved line (the
27
+ line is then marked `ignore_pricing_rule`), lines carried over from a source
28
+ document by a conversion (their price was agreed there), free lines and lines
29
+ with a written deduction. A Rate rule sets the line rate but leaves
30
+ `price_list_rate` at the list price, so the base stays honest when the rule is
31
+ later disabled; a line whose rule no longer matches falls back to its list
32
+ price.
20
33
  """
21
34
 
22
35
  from lambda_erp.model import Document
@@ -68,6 +81,7 @@ class PricingRule(Document):
68
81
  normalize_pricing_number(self, field, 1 if field == "enabled" else 0, integer=True)
69
82
  if not self.title:
70
83
  raise ValidationError("Title is required")
84
+ self._set_currency()
71
85
 
72
86
  apply_on = self.apply_on or "Item Code"
73
87
  if apply_on not in APPLY_ON:
@@ -117,6 +131,25 @@ class PricingRule(Document):
117
131
  raise ValidationError("Min Amt cannot be greater than Max Amt")
118
132
 
119
133
 
134
+ def _set_currency(self):
135
+ """A Rate / Discount Amount is a number in a currency: default it to
136
+ the company's, or to the one currency all companies share. A global
137
+ rule across companies with different currencies stays empty and then
138
+ counts in each document's company currency."""
139
+ if self._data.get("currency"):
140
+ return
141
+ db = get_db()
142
+ if self.company:
143
+ self._data["currency"] = db.get_value("Company", self.company, "default_currency")
144
+ return
145
+ if (self.rate_or_discount or "Discount Percentage") == "Discount Percentage":
146
+ return
147
+ currencies = {r["default_currency"] for r in db.sql(
148
+ 'SELECT DISTINCT default_currency FROM "Company" WHERE default_currency IS NOT NULL')}
149
+ if len(currencies) == 1:
150
+ self._data["currency"] = currencies.pop()
151
+
152
+
120
153
  def _trade_side(doc):
121
154
  """(is_selling, is_buying) for a document, by type."""
122
155
  doctype = getattr(doc, "DOCTYPE", None)
@@ -178,15 +211,21 @@ def apply_pricing_rules(doc):
178
211
  return
179
212
 
180
213
  db = get_db()
181
- today = nowdate()
214
+ on_date = doc._data.get("posting_date") or doc._data.get("transaction_date") or nowdate()
182
215
  is_selling, is_buying = _trade_side(doc)
183
216
  if not (is_selling or is_buying):
184
217
  return
185
218
  party = _party_context(db, doc, is_selling)
219
+ company_currency = db.get_value("Company", doc.get("company"), "default_currency") if doc.get("company") else None
220
+ doc_currency = doc._data.get("currency") or company_currency
221
+ stored = _stored_lines(db, doc)
186
222
 
187
223
  for item in doc.get("items") or []:
188
224
  if flt(item.get("ignore_pricing_rule")):
189
225
  continue
226
+ if _rate_changed_by_user(item, stored.get(item.get("name"))):
227
+ _keep_manual_rate(item)
228
+ continue
190
229
  # An explicit free line must not acquire a price from a rule.
191
230
  if item.get('rate') is not None and flt(item['rate']) == 0:
192
231
  continue
@@ -229,7 +268,7 @@ def apply_pricing_rules(doc):
229
268
  [
230
269
  doc.get('company'),
231
270
  int(bool(is_selling)), int(bool(is_buying)),
232
- today, today,
271
+ on_date, on_date,
233
272
  qty, qty,
234
273
  amount, amount,
235
274
  item_code, item_group,
@@ -238,7 +277,12 @@ def apply_pricing_rules(doc):
238
277
  ],
239
278
  )
240
279
 
280
+ rules = [r for r in rules
281
+ if (r.get("rate_or_discount") or "Discount Percentage") == "Discount Percentage"
282
+ or (r.get("currency") or company_currency) == doc_currency]
241
283
  if not rules:
284
+ if item.get("pricing_rule"):
285
+ _drop_rule(item)
242
286
  continue
243
287
 
244
288
  # Priority is the explicit override; specificity is the sensible
@@ -257,7 +301,8 @@ def apply_pricing_rules(doc):
257
301
 
258
302
  if rtype == "Rate":
259
303
  item["rate"] = flt(rule["rate"])
260
- item["price_list_rate"] = flt(rule["rate"])
304
+ if base_rate:
305
+ item["price_list_rate"] = base_rate
261
306
  item["discount_percentage"] = 0
262
307
  item["discount_amount"] = 0
263
308
  elif rtype == "Discount Percentage":
@@ -274,3 +319,47 @@ def apply_pricing_rules(doc):
274
319
  item["rate"] = flt(base_rate - amt, 2)
275
320
 
276
321
  item["pricing_rule"] = rule.get("name")
322
+
323
+
324
+ def _stored_lines(db, doc):
325
+ """The saved state of this document's lines, by row name (empty for a new
326
+ document). Lets pricing tell a user's rate edit from a rule's output."""
327
+ if not getattr(doc, "name", None) or "items" not in getattr(doc, "CHILD_TABLES", {}):
328
+ return {}
329
+ table = doc.CHILD_TABLES["items"][0]
330
+ rows = db.sql(f'SELECT name, item_code, rate, price_list_rate FROM "{table}" WHERE parent = ?', [doc.name])
331
+ return {r["name"]: r for r in rows}
332
+
333
+
334
+ def _rate_changed_by_user(item, saved):
335
+ """True when a saved line comes back with a different rate but the same
336
+ item and list price: the user typed a price and it must survive."""
337
+ if not saved or item.get("rate") is None:
338
+ return False
339
+ if item.get("item_code") != saved.get("item_code"):
340
+ return False
341
+ if flt(item.get("price_list_rate"), 2) != flt(saved.get("price_list_rate"), 2):
342
+ return False
343
+ return flt(item.get("rate"), 2) != flt(saved.get("rate"), 2)
344
+
345
+
346
+ def _keep_manual_rate(item):
347
+ """Freeze a user-set rate: no rule applies to this line any more, and the
348
+ discount fields say how far it is below the list price (totals would
349
+ otherwise recompute the rate from a leftover discount percentage)."""
350
+ rate = flt(item.get("rate"))
351
+ list_rate = flt(item.get("price_list_rate"))
352
+ item["ignore_pricing_rule"] = 1
353
+ item["pricing_rule"] = None
354
+ item["discount_percentage"] = 0
355
+ item["discount_amount"] = flt(list_rate - rate, 2) if list_rate > rate else 0
356
+
357
+
358
+ def _drop_rule(item):
359
+ """The line's rule no longer matches (disabled, expired, other currency,
360
+ quantity changed): back to the list price."""
361
+ item["pricing_rule"] = None
362
+ item["discount_percentage"] = 0
363
+ item["discount_amount"] = 0
364
+ if flt(item.get("price_list_rate")):
365
+ item["rate"] = flt(item["price_list_rate"])
@@ -484,7 +484,7 @@ class Database:
484
484
  is_stock_item INTEGER DEFAULT 1,
485
485
  is_fixed_asset INTEGER DEFAULT 0,
486
486
  is_asset_tracked INTEGER DEFAULT 0,
487
- valuation_method TEXT DEFAULT 'FIFO',
487
+ valuation_method TEXT DEFAULT 'Moving Average',
488
488
  default_warehouse TEXT,
489
489
  standard_rate REAL DEFAULT 0,
490
490
  description TEXT
@@ -1318,6 +1318,7 @@ class Database:
1318
1318
  valid_upto TEXT,
1319
1319
  priority INTEGER DEFAULT 0,
1320
1320
  company TEXT,
1321
+ currency TEXT,
1321
1322
  enabled INTEGER DEFAULT 1,
1322
1323
  status TEXT DEFAULT 'Active',
1323
1324
  docstatus INTEGER DEFAULT 0,
@@ -3163,6 +3164,42 @@ def _m046_pdf_templates_all_documents(db: "Database") -> None:
3163
3164
  db.conn.commit()
3164
3165
 
3165
3166
 
3167
+ def _m047_sales_order_status_left_to_do(db: "Database") -> None:
3168
+ """Sales Order status says what is left to do. Before 1.4.2 a fully
3169
+ delivered, unbilled order read "To Deliver and Bill" (and a fully billed,
3170
+ undelivered one too); recompute submitted orders with the fixed rule."""
3171
+ db.sql('UPDATE "Sales Order" SET status = CASE '
3172
+ 'WHEN COALESCE(per_delivered, 0) < 100 AND COALESCE(per_billed, 0) < 100 THEN \'To Deliver and Bill\' '
3173
+ 'WHEN COALESCE(per_delivered, 0) < 100 THEN \'To Deliver\' '
3174
+ 'WHEN COALESCE(per_billed, 0) < 100 THEN \'To Bill\' '
3175
+ 'ELSE \'Completed\' END WHERE docstatus = 1')
3176
+
3177
+
3178
+ def _m048_item_valuation_moving_average(db: "Database") -> None:
3179
+ """Item.valuation_method defaulted to FIFO although the stock ledger only
3180
+ implements moving average; make the stored value say what happens."""
3181
+ db.sql('UPDATE "Item" SET valuation_method = \'Moving Average\' '
3182
+ 'WHERE valuation_method IS NULL OR valuation_method <> \'Moving Average\'')
3183
+
3184
+
3185
+ def _m049_pricing_rule_currency(db: "Database") -> None:
3186
+ """A Rate / Discount Amount rule is a number in one currency. Backfill the
3187
+ company's currency, and for global rules the one currency all companies
3188
+ share (left empty when companies differ: such a rule then counts in each
3189
+ document's company currency)."""
3190
+ db.ensure_column("Pricing Rule", "currency", "TEXT")
3191
+ for company in db.sql('SELECT name, default_currency FROM "Company" WHERE default_currency IS NOT NULL'):
3192
+ db.sql('UPDATE "Pricing Rule" SET currency = ? '
3193
+ 'WHERE (currency IS NULL OR currency = \'\') AND company = ?',
3194
+ [company["default_currency"], company["name"]])
3195
+ currencies = [r["default_currency"] for r in db.sql(
3196
+ 'SELECT DISTINCT default_currency FROM "Company" WHERE default_currency IS NOT NULL')]
3197
+ if len(currencies) == 1:
3198
+ db.sql('UPDATE "Pricing Rule" SET currency = ? '
3199
+ 'WHERE (currency IS NULL OR currency = \'\') AND (company IS NULL OR company = \'\')',
3200
+ [currencies[0]])
3201
+
3202
+
3166
3203
  def _m042_record_notes(db: "Database") -> None:
3167
3204
  """Internal team notes on any document or master (see api.record_notes)."""
3168
3205
  db.conn.execute(
@@ -3229,6 +3266,9 @@ Database.MIGRATIONS = [
3229
3266
  (44, "quotation_intro_text", _m044_quotation_intro_text),
3230
3267
  (45, "line_discount_label", _m045_line_discount_label),
3231
3268
  (46, "pdf_templates_all_documents", _m046_pdf_templates_all_documents),
3269
+ (47, "sales_order_status_left_to_do", _m047_sales_order_status_left_to_do),
3270
+ (48, "item_valuation_moving_average", _m048_item_valuation_moving_average),
3271
+ (49, "pricing_rule_currency", _m049_pricing_rule_currency),
3232
3272
  ]
3233
3273
 
3234
3274
 
@@ -96,17 +96,16 @@ class SalesOrder(Document):
96
96
  elif self.docstatus == 2:
97
97
  self._data["status"] = "Cancelled"
98
98
  elif self.docstatus == 1:
99
- per_delivered = flt(self.per_delivered)
100
- per_billed = flt(self.per_billed)
101
-
102
- if per_delivered >= 100 and per_billed >= 100:
103
- self._data["status"] = "Completed"
104
- elif per_delivered > 0 and per_delivered < 100:
105
- self._data["status"] = "To Deliver"
106
- elif per_billed > 0 and per_billed < 100:
107
- self._data["status"] = "To Bill"
108
- else:
109
- self._data["status"] = "To Deliver and Bill"
99
+ to_deliver = flt(self.per_delivered) < 100
100
+ to_bill = flt(self.per_billed) < 100
101
+ # What is still left to do (fully delivered but not billed is "To
102
+ # Bill", not "To Deliver and Bill").
103
+ self._data["status"] = (
104
+ "To Deliver and Bill" if to_deliver and to_bill
105
+ else "To Deliver" if to_deliver
106
+ else "To Bill" if to_bill
107
+ else "Completed"
108
+ )
110
109
 
111
110
  def _update_reserved_qty(self, direction=1):
112
111
  from lambda_erp.workflow import refresh_order_progress
@@ -17,6 +17,7 @@ from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
17
17
  from lambda_erp.controllers.defaults import set_default_currency
18
18
  from lambda_erp.exceptions import ValidationError
19
19
  from lambda_erp.stock.stock_ledger import (
20
+ make_cancel_sl_entries,
20
21
  make_sl_entries,
21
22
  build_sell_side_sles,
22
23
  build_cost_basis_gl,
@@ -116,7 +117,7 @@ class DeliveryNote(Document):
116
117
  def on_cancel(self):
117
118
  reversed_sles = reverse_stock_sles(self._get_sl_entries())
118
119
  if reversed_sles:
119
- make_sl_entries(reversed_sles, allow_negative_stock=True)
120
+ make_cancel_sl_entries(reversed_sles)
120
121
 
121
122
  make_reverse_gl_entries(
122
123
  voucher_type=self.DOCTYPE,
@@ -16,7 +16,7 @@ from lambda_erp.database import get_db
16
16
  from lambda_erp.controllers.taxes_and_totals import calculate_taxes_and_totals
17
17
  from lambda_erp.controllers.defaults import set_default_currency
18
18
  from lambda_erp.exceptions import ValidationError
19
- from lambda_erp.stock.stock_ledger import make_sl_entries, reverse_stock_sles
19
+ from lambda_erp.stock.stock_ledger import make_cancel_sl_entries, make_sl_entries, reverse_stock_sles, stock_account_for
20
20
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries, to_base_currency
21
21
 
22
22
  class PurchaseReceipt(Document):
@@ -116,7 +116,7 @@ class PurchaseReceipt(Document):
116
116
  # rolls back. Cheap check first, expensive work after.
117
117
  self._check_no_linked_purchase_invoice()
118
118
 
119
- make_sl_entries(reverse_stock_sles(self._get_sl_entries()), allow_negative_stock=True)
119
+ make_cancel_sl_entries(reverse_stock_sles(self._get_sl_entries()))
120
120
 
121
121
  make_reverse_gl_entries(
122
122
  voucher_type=self.DOCTYPE,
@@ -188,28 +188,34 @@ class PurchaseReceipt(Document):
188
188
  db = get_db()
189
189
  gl_entries = []
190
190
 
191
- stock_account = db.get_value("Company", self.company, "stock_in_hand_account")
192
191
  srbnb_account = db.get_value("Company", self.company, "stock_received_but_not_billed")
193
192
 
194
- if not stock_account or not srbnb_account:
193
+ if not stock_account_for(self.company) or not srbnb_account:
195
194
  return []
196
195
 
197
196
  total = sum(flt(item.get("amount", 0)) for item in self.get("items"))
198
197
  if not total:
199
198
  return []
200
199
 
201
- # Dr: Stock In Hand
202
- gl_entries.append(_dict(
203
- account=stock_account,
204
- debit=flt(total, 2),
205
- debit_in_account_currency=flt(total, 2),
206
- credit=0,
207
- credit_in_account_currency=0,
208
- voucher_type=self.DOCTYPE,
209
- voucher_no=self.name,
210
- posting_date=self.posting_date,
211
- company=self.company,
212
- ))
200
+ # Dr: each warehouse's stock account
201
+ by_account = {}
202
+ for item in self.get("items"):
203
+ account = stock_account_for(self.company, item.get("warehouse"))
204
+ by_account[account] = by_account.get(account, 0) + flt(item.get("amount", 0))
205
+ for account, amount in by_account.items():
206
+ if not flt(amount, 2):
207
+ continue
208
+ gl_entries.append(_dict(
209
+ account=account,
210
+ debit=flt(amount, 2),
211
+ debit_in_account_currency=flt(amount, 2),
212
+ credit=0,
213
+ credit_in_account_currency=0,
214
+ voucher_type=self.DOCTYPE,
215
+ voucher_no=self.name,
216
+ posting_date=self.posting_date,
217
+ company=self.company,
218
+ ))
213
219
 
214
220
  # Cr: Stock Received But Not Billed
215
221
  gl_entries.append(_dict(
@@ -13,7 +13,7 @@ GL entries for perpetual inventory.
13
13
  from lambda_erp.model import Document
14
14
  from lambda_erp.utils import _dict, flt, nowdate
15
15
  from lambda_erp.database import get_db
16
- from lambda_erp.stock.stock_ledger import make_sl_entries, reverse_stock_sles
16
+ from lambda_erp.stock.stock_ledger import make_cancel_sl_entries, make_sl_entries, reverse_stock_sles, stock_account_for
17
17
  from lambda_erp.accounting.general_ledger import make_gl_entries, make_reverse_gl_entries
18
18
  from lambda_erp.exceptions import ValidationError
19
19
  import math
@@ -192,7 +192,7 @@ class StockEntry(Document):
192
192
  for row in reversals:
193
193
  row['incoming_rate_is_explicit'] = True
194
194
  row['outgoing_rate_is_explicit'] = True
195
- make_sl_entries(reversals, allow_negative_stock=True)
195
+ make_cancel_sl_entries(reversals)
196
196
  make_reverse_gl_entries(voucher_type=self.DOCTYPE, voucher_no=self.name)
197
197
 
198
198
  def _get_gl_entries(self):
@@ -205,13 +205,9 @@ class StockEntry(Document):
205
205
  value = flt(row.value, 2)
206
206
  if not value:
207
207
  continue
208
- account = db.get_value('Warehouse', row.warehouse, 'account') or db.get_value(
209
- 'Account', {'company': self.company, 'account_type': 'Stock', 'is_group': 0}, 'name')
208
+ account = stock_account_for(self.company, row.warehouse)
210
209
  if not account:
211
210
  raise ValidationError('Stock account is required before posting a valued stock movement')
212
- info = db.get_value('Account', account, ['company', 'is_group', 'account_type'])
213
- if not info or info.company != self.company or info.is_group or info.account_type != 'Stock':
214
- raise ValidationError('Warehouse stock account must be a Stock account belonging to Company')
215
211
  amounts[account] = amounts.get(account, 0) + value
216
212
  if self.stock_entry_type != 'Material Transfer':
217
213
  total = sum(amounts.values())
@@ -83,6 +83,27 @@ def make_sl_entries(sl_entries, allow_negative_stock=False):
83
83
  if not db._in_transaction:
84
84
  db.commit()
85
85
 
86
+ def make_cancel_sl_entries(sl_entries):
87
+ """Post the reversal of a voucher's stock entries on cancel.
88
+
89
+ Unlike before 1.4.2 (every cancel passed allow_negative_stock=True), a
90
+ reversal may not take stock below zero: cancelling a receipt whose goods
91
+ have since been delivered or issued is refused, the caller's transaction
92
+ rolls back, and nothing is half-reversed. Reversals that put stock back
93
+ (cancelling a delivery or an issue) are never affected.
94
+ """
95
+ try:
96
+ make_sl_entries(sl_entries, allow_negative_stock=False)
97
+ except NegativeStockError as exc:
98
+ first = sl_entries[0] if sl_entries else {}
99
+ voucher = f"{first.get('voucher_type') or 'this document'} {first.get('voucher_no') or ''}".strip()
100
+ raise NegativeStockError(
101
+ f"Cannot cancel {voucher}: its goods have already left the warehouse "
102
+ f"(they were delivered, issued or moved since). Cancel or return those "
103
+ f"documents first. {exc}"
104
+ ) from exc
105
+
106
+
86
107
  def update_stock_values(sle, allow_negative_stock=False):
87
108
  """Calculate qty_after_transaction, valuation_rate, stock_value.
88
109
 
@@ -270,63 +291,74 @@ def build_buy_side_sles(doc, items):
270
291
  ))
271
292
  return sl_entries
272
293
 
294
+ def stock_account_for(company, warehouse=None):
295
+ """The Stock account a warehouse's stock value is booked on.
296
+
297
+ The warehouse's own `account` if set, else the company's
298
+ `stock_in_hand_account`, else the company's first leaf account of type
299
+ Stock. Every stock-moving document resolves it here: before 1.4.2 Stock
300
+ Entry used the warehouse account (falling back to *any* Stock account)
301
+ while receipts, invoices and deliveries always used the company's
302
+ stock_in_hand_account, so the GL drifted from the warehouses' stock value
303
+ as soon as a warehouse had its own account (or a chart had several Stock
304
+ accounts).
305
+ """
306
+ from lambda_erp.exceptions import ValidationError
307
+
308
+ db = get_db()
309
+ account = db.get_value("Warehouse", warehouse, "account") if warehouse else None
310
+ if account:
311
+ info = db.get_value("Account", account, ["company", "is_group", "account_type"])
312
+ if not info or info.company != company or info.is_group or info.account_type != "Stock":
313
+ raise ValidationError(
314
+ f"Warehouse {warehouse}: its account {account} must be a Stock account belonging to {company}")
315
+ return account
316
+ return db.get_value("Company", company, "stock_in_hand_account") or db.get_value(
317
+ "Account", {"company": company, "account_type": "Stock", "is_group": 0}, "name")
318
+
319
+
273
320
  def build_cost_basis_gl(doc, *, remarks=None):
274
321
  """Dr COGS / Cr Stock In Hand at cost for a sell-side doc.
275
322
 
276
323
  Reads stock_value_difference from the SLEs already posted for this
277
324
  voucher, so call this AFTER make_sl_entries. Sign handles returns:
278
325
  stock_value_difference is negative for outgoing (Dr COGS/Cr SIH) and
279
- positive for returns (Cr COGS/Dr SIH).
326
+ positive for returns (Cr COGS/Dr SIH). The stock side is booked per
327
+ warehouse on its stock account (stock_account_for).
280
328
  """
281
329
  db = get_db()
282
330
 
283
331
  default_expense = db.get_value("Company", doc.company, "default_expense_account")
284
- stock_account = db.get_value("Company", doc.company, "stock_in_hand_account")
285
- if not default_expense or not stock_account:
332
+ if not default_expense or not stock_account_for(doc.company):
286
333
  return []
287
334
 
288
- cost_rows = db.sql(
289
- 'SELECT COALESCE(SUM(stock_value_difference), 0) as diff '
335
+ rows = db.sql(
336
+ 'SELECT warehouse, COALESCE(SUM(stock_value_difference), 0) as diff '
290
337
  'FROM "Stock Ledger Entry" '
291
- 'WHERE voucher_type = ? AND voucher_no = ? AND is_cancelled = 0',
338
+ 'WHERE voucher_type = ? AND voucher_no = ? AND is_cancelled = 0 GROUP BY warehouse',
292
339
  [doc.DOCTYPE, doc.name],
293
340
  )
294
- cost_diff = flt(cost_rows[0]["diff"]) if cost_rows else 0
341
+ by_account = {}
342
+ for row in rows:
343
+ diff = flt(row["diff"])
344
+ if diff:
345
+ account = stock_account_for(doc.company, row["warehouse"])
346
+ by_account[account] = by_account.get(account, 0) + diff
347
+ cost_diff = sum(by_account.values())
295
348
  if not cost_diff:
296
349
  return []
297
350
 
298
- cogs_debit = -cost_diff
299
- sih_credit = -cost_diff
300
351
  cost_center = db.get_value("Company", doc.company, "default_cost_center")
301
352
  remark = remarks or f"{doc.DOCTYPE} {doc.name}"
302
-
303
- return [
304
- _dict(
305
- account=default_expense,
306
- debit=flt(cogs_debit, 2),
307
- debit_in_account_currency=flt(cogs_debit, 2),
308
- credit=0,
309
- credit_in_account_currency=0,
310
- cost_center=cost_center,
311
- voucher_type=doc.DOCTYPE,
312
- voucher_no=doc.name,
313
- posting_date=doc.posting_date,
314
- company=doc.company,
315
- remarks=remark,
316
- ),
317
- _dict(
318
- account=stock_account,
319
- debit=0,
320
- debit_in_account_currency=0,
321
- credit=flt(sih_credit, 2),
322
- credit_in_account_currency=flt(sih_credit, 2),
323
- voucher_type=doc.DOCTYPE,
324
- voucher_no=doc.name,
325
- posting_date=doc.posting_date,
326
- company=doc.company,
327
- remarks=remark,
328
- ),
329
- ]
353
+ common = dict(voucher_type=doc.DOCTYPE, voucher_no=doc.name, posting_date=doc.posting_date,
354
+ company=doc.company, remarks=remark)
355
+
356
+ gl = [_dict(account=default_expense, debit=flt(-cost_diff, 2), debit_in_account_currency=flt(-cost_diff, 2),
357
+ credit=0, credit_in_account_currency=0, cost_center=cost_center, **common)]
358
+ for account, diff in by_account.items():
359
+ gl.append(_dict(account=account, debit=0, debit_in_account_currency=0,
360
+ credit=flt(-diff, 2), credit_in_account_currency=flt(-diff, 2), **common))
361
+ return gl
330
362
 
331
363
  def reverse_stock_sles(sl_entries):
332
364
  """Flip actual_qty and swap incoming/outgoing rates for cancel-time
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lambda-erp"
3
- version = "1.4.1"
3
+ version = "1.4.3"
4
4
  description = "Core ERP logic - accounting, sales, purchasing, inventory"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
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