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.
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/PKG-INFO +1 -1
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/auth.py +24 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/main.py +2 -1
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/masters.py +5 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/pos_invoice.py +2 -1
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/purchase_invoice.py +15 -7
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/sales_invoice.py +11 -2
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/defaults.py +8 -2
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/pricing_rule.py +92 -3
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/database.py +41 -1
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/sales_order.py +10 -11
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/delivery_note.py +2 -1
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/purchase_receipt.py +22 -16
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/stock_entry.py +3 -7
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/stock_ledger.py +69 -37
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/pyproject.toml +1 -1
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/.gitignore +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/LICENSE +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/README.md +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/attachments.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/bootstrap.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/chat.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/chat_links.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/demo_limits.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/deps.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/erp_context.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/errors.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/list_values.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/llm_provider.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/mcp_oauth.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/mentions.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/menu_layout.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/oauth.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_contract.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_exports.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_fonts.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_pages.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_profiles.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/pdf_templates.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/providers.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/record_notes.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/remarks_md.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/accounting.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/actions.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/admin.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/analytics.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/availability.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/bank_reconciliation.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/bank_statements.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/chat_api.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/documents.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/mcp.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/pdf_templates.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/proposals.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/reports.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/routers/setup.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/services.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/_proposal_totals.html +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/_record_base.html +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/delivery_note.html +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/document.html +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/proposal.html +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/record.html +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/templates/reservation.html +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/time_filters.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/tool_permissions.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/api/write_context.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/docs/agents/README.md +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/frontend/LICENSE +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/frontend/README.md +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/frontend/src/api/client.ts +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_account.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_reconciliation.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_statement_import.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/bank_transaction.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/budget.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/camt.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/chart_of_accounts.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/general_ledger.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/journal_entry.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/payment_entry.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/revaluation.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/settlement.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/engine.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/pack.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/ch.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/de_common.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/de_skr03.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/de_skr04.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/packs/generic.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/profiles.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/setup/spine.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/accounting/subscription.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/asset.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/lifecycle.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/assets/reservation.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/buying/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/buying/purchase_order.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/currency.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/item_price.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/item_prices.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/controllers/taxes_and_totals.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/exceptions.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/hooks.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/master_integrity.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/model.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/proposal.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/selling/quotation.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/simulation.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/stock/__init__.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/timestamps.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/utils.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/validation.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/lambda_erp/workflow.py +0 -0
- {lambda_erp-1.4.1 → lambda_erp-1.4.3}/terraform/README.md +0 -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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|