diff --git a/erpnext/accounts/doctype/dunning/dunning.js b/erpnext/accounts/doctype/dunning/dunning.js
index e9d091f2e85..cd928a414c1 100644
--- a/erpnext/accounts/doctype/dunning/dunning.js
+++ b/erpnext/accounts/doctype/dunning/dunning.js
@@ -60,7 +60,7 @@ frappe.ui.form.on("Dunning", {
if (frm.doc.docstatus === 0) {
frm.add_custom_button(__("Fetch Overdue Payments"), () => {
erpnext.utils.map_current_doc({
- method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
+ method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
source_doctype: "Sales Invoice",
date_field: "due_date",
target: frm,
diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py
index 6eaf1d8798e..0110877ce90 100644
--- a/erpnext/accounts/doctype/dunning/test_dunning.py
+++ b/erpnext/accounts/doctype/dunning/test_dunning.py
@@ -8,7 +8,7 @@ from frappe.utils import add_days, nowdate, today
from erpnext import get_default_cost_center
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry
-from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
+from erpnext.accounts.doctype.sales_invoice.mapper import (
create_dunning as create_dunning_from_sales_invoice,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
@@ -73,7 +73,7 @@ class TestDunning(ERPNextTestSuite):
dunning = create_dunning_from_sales_invoice(si1.name)
dunning.overdue_payments = []
- method = "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning"
+ method = "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning"
updated_dunning = mapper.map_docs(method, json.dumps([si1.name, si2.name]), dunning)
self.assertEqual(len(updated_dunning.overdue_payments), 2)
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index a2d0ceddd88..24cce464be8 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -24,7 +24,6 @@ from erpnext.accounts.party import get_party_account
from erpnext.accounts.utils import (
cancel_exchange_gain_loss_journal,
get_account_currency,
- get_advance_payment_doctypes,
get_balance_on,
get_stock_accounts,
get_stock_and_account_balance,
@@ -1120,87 +1119,9 @@ class JournalEntry(AccountsController):
self.total_amount_in_words = money_in_words(amt, currency)
def build_gl_map(self):
- gl_map = []
+ from erpnext.accounts.doctype.journal_entry.services.gl_composer import JournalEntryGLComposer
- company_currency = erpnext.get_company_currency(self.company)
- self.transaction_currency = company_currency
- self.transaction_exchange_rate = 1
- if self.multi_currency:
- for row in self.get("accounts"):
- if row.account_currency != company_currency:
- # Journal assumes the first foreign currency as transaction currency
- self.transaction_currency = row.account_currency
- self.transaction_exchange_rate = row.exchange_rate
- break
-
- advance_doctypes = get_advance_payment_doctypes()
-
- for d in self.get("accounts"):
- if d.debit or d.credit or (self.voucher_type == "Exchange Gain Or Loss"):
- r = [d.user_remark, self.remark]
- r = [x for x in r if x]
- remarks = "\n".join(r)
-
- row = {
- "account": d.account,
- "party_type": d.party_type,
- "due_date": self.due_date,
- "party": d.party,
- "against": d.against_account,
- "debit": flt(d.debit, d.precision("debit")),
- "credit": flt(d.credit, d.precision("credit")),
- "account_currency": d.account_currency,
- "debit_in_account_currency": flt(
- d.debit_in_account_currency, d.precision("debit_in_account_currency")
- ),
- "credit_in_account_currency": flt(
- d.credit_in_account_currency, d.precision("credit_in_account_currency")
- ),
- "transaction_currency": self.transaction_currency,
- "transaction_exchange_rate": self.transaction_exchange_rate,
- "debit_in_transaction_currency": flt(
- d.debit_in_account_currency, d.precision("debit_in_account_currency")
- )
- if self.transaction_currency == d.account_currency
- else flt(d.debit, d.precision("debit")) / self.transaction_exchange_rate,
- "credit_in_transaction_currency": flt(
- d.credit_in_account_currency, d.precision("credit_in_account_currency")
- )
- if self.transaction_currency == d.account_currency
- else flt(d.credit, d.precision("credit")) / self.transaction_exchange_rate,
- "against_voucher_type": d.reference_type,
- "against_voucher": d.reference_name,
- "remarks": remarks,
- "voucher_detail_no": d.reference_detail_no,
- "cost_center": d.cost_center,
- "project": d.project,
- "finance_book": self.finance_book,
- "advance_voucher_type": d.advance_voucher_type,
- "advance_voucher_no": d.advance_voucher_no,
- }
-
- if d.reference_type in advance_doctypes:
- row.update(
- {
- "against_voucher_type": self.doctype,
- "against_voucher": self.name,
- "advance_voucher_type": d.reference_type,
- "advance_voucher_no": d.reference_name,
- }
- )
-
- # set flag to skip party validation
- account_type = frappe.get_cached_value("Account", d.account, "account_type")
- if account_type in ["Receivable", "Payable"] and self.party_not_required:
- frappe.flags.party_not_required = True
-
- gl_map.append(
- self.get_gl_dict(
- row,
- item=d,
- )
- )
- return gl_map
+ return JournalEntryGLComposer(self).compose()
def make_gl_entries(self, cancel=0, adv_adj=0):
from erpnext.accounts.general_ledger import make_gl_entries
diff --git a/erpnext/accounts/doctype/journal_entry/services/__init__.py b/erpnext/accounts/doctype/journal_entry/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/journal_entry/services/gl_composer.py b/erpnext/accounts/doctype/journal_entry/services/gl_composer.py
new file mode 100644
index 00000000000..16b78eae3b9
--- /dev/null
+++ b/erpnext/accounts/doctype/journal_entry/services/gl_composer.py
@@ -0,0 +1,103 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe.utils import flt
+
+import erpnext
+from erpnext.accounts.services.base_gl_composer import BaseGLComposer
+from erpnext.accounts.utils import get_advance_payment_doctypes
+
+
+class JournalEntryGLComposer(BaseGLComposer):
+ """Assembles the GL entries for a Journal Entry.
+
+ A Journal Entry already carries its ledger rows in the ``accounts`` child
+ table, so composing is a straight projection of those rows into GL dicts
+ via ``self.get_gl_dict``. The transaction currency/rate are resolved
+ from the first foreign-currency row (mirroring the former build_gl_map).
+ """
+
+ def compose(self):
+ doc = self.doc
+ gl_map = []
+
+ company_currency = erpnext.get_company_currency(doc.company)
+ doc.transaction_currency = company_currency
+ doc.transaction_exchange_rate = 1
+ if doc.multi_currency:
+ for row in doc.get("accounts"):
+ if row.account_currency != company_currency:
+ # Journal assumes the first foreign currency as transaction currency
+ doc.transaction_currency = row.account_currency
+ doc.transaction_exchange_rate = row.exchange_rate
+ break
+
+ advance_doctypes = get_advance_payment_doctypes()
+
+ for d in doc.get("accounts"):
+ if d.debit or d.credit or (doc.voucher_type == "Exchange Gain Or Loss"):
+ r = [d.user_remark, doc.remark]
+ r = [x for x in r if x]
+ remarks = "\n".join(r)
+
+ row = {
+ "account": d.account,
+ "party_type": d.party_type,
+ "due_date": doc.due_date,
+ "party": d.party,
+ "against": d.against_account,
+ "debit": flt(d.debit, d.precision("debit")),
+ "credit": flt(d.credit, d.precision("credit")),
+ "account_currency": d.account_currency,
+ "debit_in_account_currency": flt(
+ d.debit_in_account_currency, d.precision("debit_in_account_currency")
+ ),
+ "credit_in_account_currency": flt(
+ d.credit_in_account_currency, d.precision("credit_in_account_currency")
+ ),
+ "transaction_currency": doc.transaction_currency,
+ "transaction_exchange_rate": doc.transaction_exchange_rate,
+ "debit_in_transaction_currency": flt(
+ d.debit_in_account_currency, d.precision("debit_in_account_currency")
+ )
+ if doc.transaction_currency == d.account_currency
+ else flt(d.debit, d.precision("debit")) / doc.transaction_exchange_rate,
+ "credit_in_transaction_currency": flt(
+ d.credit_in_account_currency, d.precision("credit_in_account_currency")
+ )
+ if doc.transaction_currency == d.account_currency
+ else flt(d.credit, d.precision("credit")) / doc.transaction_exchange_rate,
+ "against_voucher_type": d.reference_type,
+ "against_voucher": d.reference_name,
+ "remarks": remarks,
+ "voucher_detail_no": d.reference_detail_no,
+ "cost_center": d.cost_center,
+ "project": d.project,
+ "finance_book": doc.finance_book,
+ "advance_voucher_type": d.advance_voucher_type,
+ "advance_voucher_no": d.advance_voucher_no,
+ }
+
+ if d.reference_type in advance_doctypes:
+ row.update(
+ {
+ "against_voucher_type": doc.doctype,
+ "against_voucher": doc.name,
+ "advance_voucher_type": d.reference_type,
+ "advance_voucher_no": d.reference_name,
+ }
+ )
+
+ # set flag to skip party validation
+ account_type = frappe.get_cached_value("Account", d.account, "account_type")
+ if account_type in ["Receivable", "Payable"] and doc.party_not_required:
+ frappe.flags.party_not_required = True
+
+ gl_map.append(
+ self.get_gl_dict(
+ row,
+ item=d,
+ )
+ )
+ return gl_map
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 54de412c966..721ad96a8be 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -1287,17 +1287,9 @@ class PaymentEntry(AccountsController):
self.transaction_exchange_rate = self.target_exchange_rate
def build_gl_map(self):
- if self.payment_type in ("Receive", "Pay") and not self.get("party_account_field"):
- self.setup_party_account_field()
- self.set_transaction_currency_and_rate()
+ from erpnext.accounts.doctype.payment_entry.services.gl_composer import PaymentEntryGLComposer
- gl_entries = []
- self.add_party_gl_entries(gl_entries)
- self.add_bank_gl_entries(gl_entries)
- self.add_deductions_gl_entries(gl_entries)
- self.add_tax_gl_entries(gl_entries)
- add_regional_gl_entries(gl_entries, self)
- return gl_entries
+ return PaymentEntryGLComposer(self).compose()
def make_gl_entries(self, cancel=0, adv_adj=0):
gl_entries = self.build_gl_map()
@@ -1313,132 +1305,6 @@ class PaymentEntry(AccountsController):
self.make_advance_gl_entries(cancel=cancel)
- def add_party_gl_entries(self, gl_entries):
- if not self.party_account:
- return
-
- advance_payment_doctypes = get_advance_payment_doctypes()
- if self.payment_type == "Receive":
- against_account = self.paid_to
- else:
- against_account = self.paid_from
-
- party_account_type = frappe.db.get_value("Party Type", self.party_type, "account_type")
-
- party_gl_dict = self.get_gl_dict(
- {
- "account": self.party_account,
- "party_type": self.party_type,
- "party": self.party,
- "against": against_account,
- "account_currency": self.party_account_currency,
- "cost_center": self.cost_center,
- },
- item=self,
- )
-
- for d in self.get("references"):
- # re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
- dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
- cost_center = self.cost_center
- if d.reference_doctype == "Sales Invoice" and not cost_center:
- cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
-
- gle = party_gl_dict.copy()
-
- allocated_amount_in_company_currency = self.calculate_base_allocated_amount_for_reference(d)
-
- if (
- d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
- and d.allocated_amount < 0
- and (
- (party_account_type == "Receivable" and self.payment_type == "Pay")
- or (party_account_type == "Payable" and self.payment_type == "Receive")
- )
- ):
- # reversing dr_cr because because it will get reversed in gl processing due to negative amount
- dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
-
- gle.update(
- self.get_gl_dict(
- {
- "account": self.party_account,
- "party_type": self.party_type,
- "party": self.party,
- "against": against_account,
- "account_currency": self.party_account_currency,
- "cost_center": cost_center,
- dr_or_cr + "_in_account_currency": d.allocated_amount,
- dr_or_cr: allocated_amount_in_company_currency,
- dr_or_cr + "_in_transaction_currency": d.allocated_amount
- if self.transaction_currency == self.party_account_currency
- else allocated_amount_in_company_currency / self.transaction_exchange_rate,
- "advance_voucher_type": d.advance_voucher_type,
- "advance_voucher_no": d.advance_voucher_no,
- "transaction_exchange_rate": self.target_exchange_rate,
- },
- item=self,
- )
- )
-
- if d.reference_doctype in advance_payment_doctypes:
- # advance reference
- gle.update(
- {
- "against_voucher_type": self.doctype,
- "against_voucher": self.name,
- "advance_voucher_type": d.reference_doctype,
- "advance_voucher_no": d.reference_name,
- }
- )
-
- elif self.book_advance_payments_in_separate_party_account:
- # Do not reference Invoices while Advance is in separate party account
- gle.update({"against_voucher_type": self.doctype, "against_voucher": self.name})
- else:
- gle.update(
- {
- "against_voucher_type": d.reference_doctype,
- "against_voucher": d.reference_name,
- }
- )
-
- gl_entries.append(gle)
-
- if self.unallocated_amount:
- dr_or_cr = "credit" if self.payment_type == "Receive" else "debit"
- exchange_rate = self.get_exchange_rate()
- base_unallocated_amount = self.unallocated_amount * exchange_rate
-
- gle = party_gl_dict.copy()
-
- gle.update(
- self.get_gl_dict(
- {
- "account": self.party_account,
- "party_type": self.party_type,
- "party": self.party,
- "against": against_account,
- "account_currency": self.party_account_currency,
- "cost_center": self.cost_center,
- dr_or_cr + "_in_account_currency": self.unallocated_amount,
- dr_or_cr + "_in_transaction_currency": self.unallocated_amount
- if self.party_account_currency == self.transaction_currency
- else base_unallocated_amount / self.transaction_exchange_rate,
- dr_or_cr: base_unallocated_amount,
- },
- item=self,
- )
- )
- if self.book_advance_payments_in_separate_party_account:
- gle.update(
- {
- "against_voucher_type": "Payment Entry",
- "against_voucher": self.name,
- }
- )
- gl_entries.append(gle)
-
def make_advance_gl_entries(
self, entry: object | dict = None, cancel: bool = 0, update_outstanding: str = "Yes"
):
@@ -1560,132 +1426,6 @@ class PaymentEntry(AccountsController):
)
gl_entries.append(gle)
- def add_bank_gl_entries(self, gl_entries):
- if self.payment_type in ("Pay", "Internal Transfer"):
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.paid_from,
- "account_currency": self.paid_from_account_currency,
- "against": self.party if self.payment_type == "Pay" else self.paid_to,
- "credit_in_account_currency": self.paid_amount,
- "credit_in_transaction_currency": self.paid_amount
- if self.paid_from_account_currency == self.transaction_currency
- else self.base_paid_amount / self.transaction_exchange_rate,
- "credit": self.base_paid_amount,
- "cost_center": self.cost_center,
- "post_net_value": True,
- },
- item=self,
- )
- )
- if self.payment_type in ("Receive", "Internal Transfer"):
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.paid_to,
- "account_currency": self.paid_to_account_currency,
- "against": self.party if self.payment_type == "Receive" else self.paid_from,
- "debit_in_account_currency": self.received_amount,
- "debit_in_transaction_currency": self.received_amount
- if self.paid_to_account_currency == self.transaction_currency
- else self.base_received_amount / self.transaction_exchange_rate,
- "debit": self.base_received_amount,
- "cost_center": self.cost_center,
- },
- item=self,
- )
- )
-
- def add_tax_gl_entries(self, gl_entries):
- for d in self.get("taxes"):
- account_currency = get_account_currency(d.account_head)
- if account_currency != self.company_currency:
- frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency))
-
- if self.payment_type in ("Pay", "Internal Transfer"):
- dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
- rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
- against = self.party or self.paid_from
- elif self.payment_type == "Receive":
- dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
- rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
- against = self.party or self.paid_to
-
- payment_account = self.get_party_account_for_taxes()
- tax_amount = d.tax_amount
- base_tax_amount = d.base_tax_amount
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": d.account_head,
- "against": against,
- dr_or_cr: tax_amount,
- dr_or_cr + "_in_account_currency": base_tax_amount
- if account_currency == self.company_currency
- else d.tax_amount,
- dr_or_cr + "_in_transaction_currency": base_tax_amount
- / self.transaction_exchange_rate,
- "cost_center": d.cost_center,
- "post_net_value": True,
- },
- account_currency,
- item=d,
- )
- )
-
- if not d.included_in_paid_amount:
- if get_account_currency(payment_account) != self.company_currency:
- if self.payment_type == "Receive":
- exchange_rate = self.target_exchange_rate
- elif self.payment_type in ["Pay", "Internal Transfer"]:
- exchange_rate = self.source_exchange_rate
- base_tax_amount = flt((tax_amount / exchange_rate), self.precision("paid_amount"))
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": payment_account,
- "against": against,
- rev_dr_or_cr: tax_amount,
- rev_dr_or_cr + "_in_account_currency": base_tax_amount
- if account_currency == self.company_currency
- else d.tax_amount,
- rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
- / self.transaction_exchange_rate,
- "cost_center": self.cost_center,
- "post_net_value": True,
- },
- account_currency,
- item=d,
- )
- )
-
- def add_deductions_gl_entries(self, gl_entries):
- for d in self.get("deductions"):
- if not d.amount:
- continue
-
- account_currency = get_account_currency(d.account)
- if account_currency != self.company_currency:
- frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": d.account,
- "account_currency": account_currency,
- "against": self.party or self.paid_from,
- "debit_in_account_currency": d.amount,
- "debit_in_transaction_currency": d.amount / self.transaction_exchange_rate,
- "debit": d.amount,
- "cost_center": d.cost_center,
- },
- item=d,
- )
- )
-
def get_party_account_for_taxes(self):
if self.payment_type == "Receive":
return self.paid_to
diff --git a/erpnext/accounts/doctype/payment_entry/services/__init__.py b/erpnext/accounts/doctype/payment_entry/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/payment_entry/services/gl_composer.py b/erpnext/accounts/doctype/payment_entry/services/gl_composer.py
new file mode 100644
index 00000000000..28af09ec309
--- /dev/null
+++ b/erpnext/accounts/doctype/payment_entry/services/gl_composer.py
@@ -0,0 +1,293 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+from erpnext.accounts.services.base_gl_composer import BaseGLComposer
+from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes
+
+
+class PaymentEntryGLComposer(BaseGLComposer):
+ """Assembles the GL entries for a Payment Entry.
+
+ The voucher-specific row builders live here and operate on ``self.doc``.
+ Shared helpers (get_gl_dict, calculate_base_allocated_amount_for_reference,
+ get_exchange_rate, get_party_account_for_taxes) remain on the document for
+ now and are invoked via ``self.doc``. The advance-posting builders stay on
+ the document; they post separately from this compose pass and move with the
+ advances service in a later phase.
+ """
+
+ def compose(self):
+ from erpnext.accounts.doctype.payment_entry.payment_entry import add_regional_gl_entries
+
+ doc = self.doc
+ if doc.payment_type in ("Receive", "Pay") and not doc.get("party_account_field"):
+ doc.setup_party_account_field()
+ doc.set_transaction_currency_and_rate()
+
+ gl_entries = []
+ self.add_party_gl_entries(gl_entries)
+ self.add_bank_gl_entries(gl_entries)
+ self.add_deductions_gl_entries(gl_entries)
+ self.add_tax_gl_entries(gl_entries)
+ add_regional_gl_entries(gl_entries, doc)
+ return gl_entries
+
+ def add_party_gl_entries(self, gl_entries):
+ doc = self.doc
+ if not doc.party_account:
+ return
+
+ advance_payment_doctypes = get_advance_payment_doctypes()
+ if doc.payment_type == "Receive":
+ against_account = doc.paid_to
+ else:
+ against_account = doc.paid_from
+
+ party_account_type = frappe.db.get_value("Party Type", doc.party_type, "account_type")
+
+ party_gl_dict = self.get_gl_dict(
+ {
+ "account": doc.party_account,
+ "party_type": doc.party_type,
+ "party": doc.party,
+ "against": against_account,
+ "account_currency": doc.party_account_currency,
+ "cost_center": doc.cost_center,
+ },
+ item=doc,
+ )
+
+ for d in doc.get("references"):
+ # re-defining dr_or_cr for every reference in order to avoid the last value affecting calculation of reverse
+ dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
+ cost_center = doc.cost_center
+ if d.reference_doctype == "Sales Invoice" and not cost_center:
+ cost_center = frappe.db.get_value(d.reference_doctype, d.reference_name, "cost_center")
+
+ gle = party_gl_dict.copy()
+
+ allocated_amount_in_company_currency = doc.calculate_base_allocated_amount_for_reference(d)
+
+ if (
+ d.reference_doctype in ["Sales Invoice", "Purchase Invoice"]
+ and d.allocated_amount < 0
+ and (
+ (party_account_type == "Receivable" and doc.payment_type == "Pay")
+ or (party_account_type == "Payable" and doc.payment_type == "Receive")
+ )
+ ):
+ # reversing dr_cr because because it will get reversed in gl processing due to negative amount
+ dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ gle.update(
+ self.get_gl_dict(
+ {
+ "account": doc.party_account,
+ "party_type": doc.party_type,
+ "party": doc.party,
+ "against": against_account,
+ "account_currency": doc.party_account_currency,
+ "cost_center": cost_center,
+ dr_or_cr + "_in_account_currency": d.allocated_amount,
+ dr_or_cr: allocated_amount_in_company_currency,
+ dr_or_cr + "_in_transaction_currency": d.allocated_amount
+ if doc.transaction_currency == doc.party_account_currency
+ else allocated_amount_in_company_currency / doc.transaction_exchange_rate,
+ "advance_voucher_type": d.advance_voucher_type,
+ "advance_voucher_no": d.advance_voucher_no,
+ "transaction_exchange_rate": doc.target_exchange_rate,
+ },
+ item=doc,
+ )
+ )
+
+ if d.reference_doctype in advance_payment_doctypes:
+ # advance reference
+ gle.update(
+ {
+ "against_voucher_type": doc.doctype,
+ "against_voucher": doc.name,
+ "advance_voucher_type": d.reference_doctype,
+ "advance_voucher_no": d.reference_name,
+ }
+ )
+
+ elif doc.book_advance_payments_in_separate_party_account:
+ # Do not reference Invoices while Advance is in separate party account
+ gle.update({"against_voucher_type": doc.doctype, "against_voucher": doc.name})
+ else:
+ gle.update(
+ {
+ "against_voucher_type": d.reference_doctype,
+ "against_voucher": d.reference_name,
+ }
+ )
+
+ gl_entries.append(gle)
+
+ if doc.unallocated_amount:
+ dr_or_cr = "credit" if doc.payment_type == "Receive" else "debit"
+ exchange_rate = doc.get_exchange_rate()
+ base_unallocated_amount = doc.unallocated_amount * exchange_rate
+
+ gle = party_gl_dict.copy()
+
+ gle.update(
+ self.get_gl_dict(
+ {
+ "account": doc.party_account,
+ "party_type": doc.party_type,
+ "party": doc.party,
+ "against": against_account,
+ "account_currency": doc.party_account_currency,
+ "cost_center": doc.cost_center,
+ dr_or_cr + "_in_account_currency": doc.unallocated_amount,
+ dr_or_cr + "_in_transaction_currency": doc.unallocated_amount
+ if doc.party_account_currency == doc.transaction_currency
+ else base_unallocated_amount / doc.transaction_exchange_rate,
+ dr_or_cr: base_unallocated_amount,
+ },
+ item=doc,
+ )
+ )
+ if doc.book_advance_payments_in_separate_party_account:
+ gle.update(
+ {
+ "against_voucher_type": "Payment Entry",
+ "against_voucher": doc.name,
+ }
+ )
+ gl_entries.append(gle)
+
+ def add_bank_gl_entries(self, gl_entries):
+ doc = self.doc
+ if doc.payment_type in ("Pay", "Internal Transfer"):
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.paid_from,
+ "account_currency": doc.paid_from_account_currency,
+ "against": doc.party if doc.payment_type == "Pay" else doc.paid_to,
+ "credit_in_account_currency": doc.paid_amount,
+ "credit_in_transaction_currency": doc.paid_amount
+ if doc.paid_from_account_currency == doc.transaction_currency
+ else doc.base_paid_amount / doc.transaction_exchange_rate,
+ "credit": doc.base_paid_amount,
+ "cost_center": doc.cost_center,
+ "post_net_value": True,
+ },
+ item=doc,
+ )
+ )
+ if doc.payment_type in ("Receive", "Internal Transfer"):
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.paid_to,
+ "account_currency": doc.paid_to_account_currency,
+ "against": doc.party if doc.payment_type == "Receive" else doc.paid_from,
+ "debit_in_account_currency": doc.received_amount,
+ "debit_in_transaction_currency": doc.received_amount
+ if doc.paid_to_account_currency == doc.transaction_currency
+ else doc.base_received_amount / doc.transaction_exchange_rate,
+ "debit": doc.base_received_amount,
+ "cost_center": doc.cost_center,
+ },
+ item=doc,
+ )
+ )
+
+ def add_tax_gl_entries(self, gl_entries):
+ doc = self.doc
+ for d in doc.get("taxes"):
+ account_currency = get_account_currency(d.account_head)
+ if account_currency != doc.company_currency:
+ frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, doc.company_currency))
+
+ if doc.payment_type in ("Pay", "Internal Transfer"):
+ dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit"
+ rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
+ against = doc.party or doc.paid_from
+ elif doc.payment_type == "Receive":
+ dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit"
+ rev_dr_or_cr = "credit" if dr_or_cr == "debit" else "debit"
+ against = doc.party or doc.paid_to
+
+ payment_account = doc.get_party_account_for_taxes()
+ tax_amount = d.tax_amount
+ base_tax_amount = d.base_tax_amount
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": d.account_head,
+ "against": against,
+ dr_or_cr: tax_amount,
+ dr_or_cr + "_in_account_currency": base_tax_amount
+ if account_currency == doc.company_currency
+ else d.tax_amount,
+ dr_or_cr + "_in_transaction_currency": base_tax_amount
+ / doc.transaction_exchange_rate,
+ "cost_center": d.cost_center,
+ "post_net_value": True,
+ },
+ account_currency,
+ item=d,
+ )
+ )
+
+ if not d.included_in_paid_amount:
+ if get_account_currency(payment_account) != doc.company_currency:
+ if doc.payment_type == "Receive":
+ exchange_rate = doc.target_exchange_rate
+ elif doc.payment_type in ["Pay", "Internal Transfer"]:
+ exchange_rate = doc.source_exchange_rate
+ base_tax_amount = flt((tax_amount / exchange_rate), doc.precision("paid_amount"))
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": payment_account,
+ "against": against,
+ rev_dr_or_cr: tax_amount,
+ rev_dr_or_cr + "_in_account_currency": base_tax_amount
+ if account_currency == doc.company_currency
+ else d.tax_amount,
+ rev_dr_or_cr + "_in_transaction_currency": base_tax_amount
+ / doc.transaction_exchange_rate,
+ "cost_center": doc.cost_center,
+ "post_net_value": True,
+ },
+ account_currency,
+ item=d,
+ )
+ )
+
+ def add_deductions_gl_entries(self, gl_entries):
+ doc = self.doc
+ for d in doc.get("deductions"):
+ if not d.amount:
+ continue
+
+ account_currency = get_account_currency(d.account)
+ if account_currency != doc.company_currency:
+ frappe.throw(_("Currency for {0} must be {1}").format(d.account, doc.company_currency))
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": d.account,
+ "account_currency": account_currency,
+ "against": doc.party or doc.paid_from,
+ "debit_in_account_currency": d.amount,
+ "debit_in_transaction_currency": d.amount / doc.transaction_exchange_rate,
+ "debit": d.amount,
+ "cost_center": d.cost_center,
+ },
+ item=d,
+ )
+ )
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 1d145ef5a00..b566b625934 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -196,7 +196,7 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertEqual(outstanding_amount, 100)
def test_reference_outstanding_amount_on_advance_pull(self):
- from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
so = make_sales_order(qty=1, rate=1000)
pe = get_payment_entry("Sales Order", so.name, bank_account="_Test Cash - _TC")
@@ -1567,7 +1567,7 @@ class TestPaymentEntry(ERPNextTestSuite):
self.check_pl_entries()
def test_advance_as_liability_against_order(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import (
+ from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_invoice as _make_purchase_invoice,
)
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
index d1ffca800a3..6e5998a62a2 100644
--- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
+++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py
@@ -15,13 +15,13 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import g
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
is_any_doc_running,
)
+from erpnext.accounts.services.advances import get_advance_payment_entries_for_regional
from erpnext.accounts.utils import (
QueryPaymentLedger,
create_gain_loss_journal,
get_outstanding_invoices,
reconcile_against_document,
)
-from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional
class PaymentReconciliation(Document):
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index c3dcea4772e..537ef4c7644 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -443,7 +443,7 @@ class PaymentRequest(Document):
self.update_reference_advance_payment_status()
def make_invoice(self):
- from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
si = make_sales_invoice(self.reference_name, ignore_permissions=True)
si.allocate_advances_automatically = True
diff --git a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py
index d066c0910d4..bcee69b64ba 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py
+++ b/erpnext/accounts/doctype/pos_closing_entry/test_pos_closing_entry.py
@@ -330,7 +330,7 @@ class TestPOSClosingEntry(ERPNextTestSuite):
"""
Test Sales Invoice and Return Sales Invoice creation during POS Invoice mode.
"""
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
test_user, pos_profile = init_user_and_profile()
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 2925def8408..f556275b2f5 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -17,6 +17,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
get_mode_of_payment_info,
update_multi_mode_option,
)
+from erpnext.accounts.doctype.sales_invoice.services.loyalty import LoyaltyService
from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.controllers.queries import item_query as _item_query
from erpnext.controllers.sales_and_purchase_return import get_sales_invoice_item_from_consolidated_invoice
@@ -241,13 +242,13 @@ class POSInvoice(SalesInvoice):
def on_submit(self):
# create the loyalty point ledger entry if the customer is enrolled in any loyalty program
if not self.is_return and self.loyalty_program:
- self.make_loyalty_point_entry()
+ LoyaltyService(self).make_loyalty_point_entry()
elif self.is_return and self.return_against and self.loyalty_program:
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
- against_psi_doc.delete_loyalty_point_entry()
- against_psi_doc.make_loyalty_point_entry()
+ LoyaltyService(against_psi_doc).delete_loyalty_point_entry()
+ LoyaltyService(against_psi_doc).make_loyalty_point_entry()
if self.redeem_loyalty_points and self.loyalty_points:
- self.apply_loyalty_points()
+ LoyaltyService(self).apply_loyalty_points()
self.check_phone_payments()
self.set_status(update=True)
self.make_bundle_for_sales_purchase_return()
@@ -288,11 +289,11 @@ class POSInvoice(SalesInvoice):
# run on cancel method of selling controller
super(SalesInvoice, self).on_cancel()
if not self.is_return and self.loyalty_program:
- self.delete_loyalty_point_entry()
+ LoyaltyService(self).delete_loyalty_point_entry()
elif self.is_return and self.return_against and self.loyalty_program:
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
- against_psi_doc.delete_loyalty_point_entry()
- against_psi_doc.make_loyalty_point_entry()
+ LoyaltyService(against_psi_doc).delete_loyalty_point_entry()
+ LoyaltyService(against_psi_doc).make_loyalty_point_entry()
self.db_set("status", "Cancelled")
@@ -745,7 +746,9 @@ class POSInvoice(SalesInvoice):
# fetch charges
if self.taxes_and_charges and not len(self.get("taxes")):
- self.set_taxes()
+ from erpnext.accounts.services.taxes import TaxService
+
+ TaxService(self).set_taxes()
if not self.account_for_change_amount:
self.account_for_change_amount = frappe.get_cached_value(
diff --git a/erpnext/accounts/doctype/purchase_invoice/mapper.py b/erpnext/accounts/doctype/purchase_invoice/mapper.py
new file mode 100644
index 00000000000..7c50121f1e5
--- /dev/null
+++ b/erpnext/accounts/doctype/purchase_invoice/mapper.py
@@ -0,0 +1,129 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import flt
+
+from erpnext.controllers.accounts_controller import merge_taxes
+
+
+@frappe.whitelist()
+def make_debit_note(source_name: str, target_doc: str | Document | None = None):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Purchase Invoice", source_name, target_doc)
+
+
+@frappe.whitelist()
+def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
+ doc = get_mapped_doc(
+ "Purchase Invoice",
+ source_name,
+ {
+ "Purchase Invoice": {"doctype": "Stock Entry", "validation": {"docstatus": ["=", 1]}},
+ "Purchase Invoice Item": {
+ "doctype": "Stock Entry Detail",
+ "field_map": {"stock_qty": "transfer_qty", "batch_no": "batch_no"},
+ },
+ },
+ target_doc,
+ )
+
+ return doc
+
+
+@frappe.whitelist()
+def make_inter_company_sales_invoice(source_name: str, target_doc: Document | None = None):
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
+
+ return make_inter_company_transaction("Purchase Invoice", source_name, target_doc)
+
+
+@frappe.whitelist()
+def make_purchase_receipt(
+ source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
+):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ def post_parent_process(source_parent, target_parent):
+ remove_items_with_zero_qty(target_parent)
+ set_missing_values(source_parent, target_parent)
+
+ def remove_items_with_zero_qty(target_parent):
+ target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
+
+ def set_missing_values(source_parent, target_parent):
+ target_parent.run_method("set_missing_values")
+ if args and args.get("merge_taxes"):
+ merge_taxes(source_parent, target_parent)
+ target_parent.run_method("calculate_taxes_and_totals")
+
+ def update_item(obj, target, source_parent):
+ from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
+
+ returned_qty_map = (
+ get_returned_qty_map_for_row(
+ source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice"
+ )
+ or {}
+ )
+
+ target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
+ target.received_qty = flt(obj.qty) - flt(obj.received_qty)
+ target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
+ obj.conversion_factor
+ )
+ target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
+ target.base_amount = (
+ (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
+ )
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ doc = get_mapped_doc(
+ "Purchase Invoice",
+ source_name,
+ {
+ "Purchase Invoice": {
+ "doctype": "Purchase Receipt",
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Purchase Invoice Item": {
+ "doctype": "Purchase Receipt Item",
+ "field_map": {
+ "name": "purchase_invoice_item",
+ "parent": "purchase_invoice",
+ "bom": "bom",
+ "purchase_order": "purchase_order",
+ "po_detail": "purchase_order_item",
+ "material_request": "material_request",
+ "material_request_item": "material_request_item",
+ "wip_composite_asset": "wip_composite_asset",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
+ },
+ "Purchase Taxes and Charges": {
+ "doctype": "Purchase Taxes and Charges",
+ "reset_value": not (args and args.get("merge_taxes")),
+ "ignore": args.get("merge_taxes") if args else 0,
+ },
+ },
+ target_doc,
+ post_parent_process,
+ )
+
+ return doc
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index 0a77fdb7506..9fd911a2762 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -156,7 +156,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
__("Purchase Order"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice",
source_doctype: "Purchase Order",
target: me.frm,
setters: {
@@ -181,7 +181,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
__("Purchase Receipt"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
+ method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice",
source_doctype: "Purchase Receipt",
target: me.frm,
setters: {
@@ -414,7 +414,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
make_inter_company_invoice(frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_inter_company_sales_invoice",
+ method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_inter_company_sales_invoice",
frm: frm,
});
}
@@ -474,7 +474,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
make_debit_note() {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_debit_note",
+ method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_debit_note",
frm: this.frm,
});
}
@@ -720,7 +720,7 @@ frappe.ui.form.on("Purchase Invoice", {
make_purchase_receipt: function (frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
+ method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt",
frm: frm,
freeze_message: __("Creating Purchase Receipt ..."),
});
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index e262cdbc03f..71981673b8e 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -2,12 +2,9 @@
# License: GNU General Public License v3. See license.txt
-import json
-
import frappe
from frappe import _, qb, throw
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
@@ -36,7 +33,7 @@ from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.accounts.utils import get_account_currency, get_fiscal_year, update_voucher_outstanding
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
-from erpnext.controllers.accounts_controller import merge_taxes, validate_account_head
+from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.buying_controller import BuyingController
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
update_billed_amount_based_on_po,
@@ -292,7 +289,10 @@ class PurchaseInvoice(BuyingController):
self.set_against_expense_account()
self.validate_write_off_account()
self.validate_write_off_cost_center()
- self.validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
+
+ from erpnext.accounts.services.billing_validation import BillingValidationService
+
+ BillingValidationService(self).validate_multiple_billing("Purchase Receipt", "pr_detail", "amount")
self.set_status()
self.validate_purchase_receipt_if_update_stock()
validate_inter_company_party(
@@ -875,34 +875,11 @@ class PurchaseInvoice(BuyingController):
)
def get_gl_entries(self, inventory_account_map=None):
- self.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(self.company)
+ from erpnext.accounts.doctype.purchase_invoice.services.gl_composer import (
+ PurchaseInvoiceGLComposer,
+ )
- if self.auto_accounting_for_stock:
- self.stock_received_but_not_billed = self.get_company_default("stock_received_but_not_billed")
- else:
- self.stock_received_but_not_billed = None
-
- self.negative_expense_to_be_booked = 0.0
- gl_entries = []
-
- self.make_supplier_gl_entry(gl_entries)
- self.make_item_gl_entries(gl_entries)
- self.make_precision_loss_gl_entry(gl_entries)
-
- self.make_tax_gl_entries(gl_entries)
- self.make_internal_transfer_gl_entries(gl_entries)
- self.make_gl_entries_for_tax_withholding(gl_entries)
-
- gl_entries = make_regional_gl_entries(gl_entries, self)
-
- gl_entries = merge_similar_entries(gl_entries)
-
- self.make_payment_gl_entries(gl_entries)
- self.make_write_off_gl_entry(gl_entries)
- self.make_gle_for_rounding_adjustment(gl_entries)
- self.set_transaction_currency_and_rate_in_gl_map(gl_entries)
- self.set_gl_entry_for_purchase_expense(gl_entries)
- return gl_entries
+ return PurchaseInvoiceGLComposer(self).compose(inventory_account_map)
def check_asset_cwip_enabled(self):
# Check if there exists any item with cwip accounting enabled in it's asset category
@@ -913,788 +890,6 @@ class PurchaseInvoice(BuyingController):
return 1
return 0
- def make_supplier_gl_entry(self, gl_entries):
- # Checked both rounding_adjustment and rounded_total
- # because rounded_total had value even before introduction of posting GLE based on rounded total
- grand_total = (
- self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total
- )
- base_grand_total = flt(
- self.base_rounded_total
- if (self.base_rounding_adjustment and self.base_rounded_total)
- else self.base_grand_total,
- self.precision("base_grand_total"),
- )
-
- if grand_total and not self.is_internal_transfer():
- self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total)
-
- def add_supplier_gl_entry(
- self, gl_entries, base_grand_total, grand_total, against_account=None, remarks=None, skip_merge=False
- ):
- against_voucher = self.name
- if self.is_return and self.return_against and not self.update_outstanding_for_self:
- against_voucher = self.return_against
-
- # Did not use base_grand_total to book rounding loss gle
- gl = {
- "account": self.credit_to,
- "party_type": "Supplier",
- "party": self.supplier,
- "due_date": self.due_date,
- "against": against_account or self.against_expense_account,
- "credit": base_grand_total,
- "credit_in_account_currency": base_grand_total
- if self.party_account_currency == self.company_currency
- else grand_total,
- "credit_in_transaction_currency": grand_total,
- "against_voucher": against_voucher,
- "against_voucher_type": self.doctype,
- "project": self.project,
- "cost_center": self.cost_center,
- "_skip_merge": skip_merge,
- }
-
- if remarks:
- gl["remarks"] = remarks
-
- gl_entries.append(self.get_gl_dict(gl, self.party_account_currency, item=self))
-
- def make_item_gl_entries(self, gl_entries):
- # item gl entries
- stock_items = self.get_stock_items()
- if self.update_stock and self.auto_accounting_for_stock:
- inventory_account_map = self.get_inventory_account_map()
-
- landed_cost_entries = self.get_item_account_wise_lcv_entries()
-
- voucher_wise_stock_value = {}
- if self.update_stock:
- stock_ledger_entries = frappe.get_all(
- "Stock Ledger Entry",
- fields=["voucher_detail_no", "stock_value_difference", "warehouse"],
- filters={"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0},
- )
- for d in stock_ledger_entries:
- voucher_wise_stock_value.setdefault(
- (d.voucher_detail_no, d.warehouse), d.stock_value_difference
- )
-
- valuation_tax_accounts = [
- d.account_head
- for d in self.get("taxes")
- if d.category in ("Valuation", "Valuation and Total")
- and flt(d.base_tax_amount_after_discount_amount)
- ]
-
- exchange_rate_map, net_rate_map = get_purchase_document_details(self)
-
- provisional_accounting_for_non_stock_items = cint(
- frappe.get_cached_value(
- "Company", self.company, "enable_provisional_accounting_for_non_stock_items"
- )
- )
- if provisional_accounting_for_non_stock_items:
- self.get_provisional_accounts()
-
- adjust_incoming_rate = frappe.db.get_single_value(
- "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
- )
-
- for item in self.get("items"):
- if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate):
- if item.item_code:
- frappe.get_cached_value("Item", item.item_code, "asset_category")
-
- if (
- self.update_stock
- and self.auto_accounting_for_stock
- and (item.item_code in stock_items or item.is_fixed_asset)
- ):
- account_currency = get_account_currency(item.expense_account)
- # warehouse account
- warehouse_debit_amount = self.make_stock_adjustment_entry(
- gl_entries, item, voucher_wise_stock_value, account_currency
- )
-
- if item.from_warehouse:
- _inv_dict = self.get_inventory_account_dict(item, inventory_account_map)
-
- _inv_dict_from_warehouse = self.get_inventory_account_dict(
- item, inventory_account_map, "from_warehouse"
- )
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": _inv_dict["account"],
- "against": _inv_dict_from_warehouse["account"],
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "debit": warehouse_debit_amount,
- "debit_in_transaction_currency": item.net_amount,
- },
- _inv_dict["account_currency"],
- item=item,
- )
- )
-
- credit_amount = item.base_net_amount
- if self.is_internal_supplier and item.valuation_rate:
- credit_amount = flt(item.valuation_rate * item.stock_qty)
-
- # Intentionally passed negative debit amount to avoid incorrect GL Entry validation
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": _inv_dict_from_warehouse["account"],
- "against": _inv_dict["account"],
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "debit": -1 * flt(credit_amount, item.precision("base_net_amount")),
- "debit_in_transaction_currency": item.net_amount,
- },
- _inv_dict_from_warehouse["account_currency"],
- item=item,
- )
- )
-
- # Do not book expense for transfer within same company transfer
- if not self.is_internal_transfer():
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item.expense_account,
- "against": self.supplier,
- "debit": flt(item.base_net_amount, item.precision("base_net_amount")),
- "debit_in_transaction_currency": item.net_amount,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "cost_center": item.cost_center,
- "project": item.project,
- },
- account_currency,
- item=item,
- )
- )
-
- else:
- if not self.is_internal_transfer():
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item.expense_account,
- "against": self.supplier,
- "debit": warehouse_debit_amount,
- "debit_in_transaction_currency": flt(
- warehouse_debit_amount / self.conversion_rate,
- item.precision("net_amount"),
- ),
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
-
- # Amount added through landed-cost-voucher
- if landed_cost_entries:
- if (item.item_code, item.name) in landed_cost_entries:
- for account, base_amount in landed_cost_entries[
- (item.item_code, item.name)
- ].items():
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": account,
- "against": item.expense_account,
- "cost_center": item.cost_center,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "credit": flt(base_amount["base_amount"]),
- "credit_in_account_currency": flt(base_amount["amount"]),
- "credit_in_transaction_currency": item.net_amount,
- "project": item.project or self.project,
- },
- item=item,
- )
- )
-
- # sub-contracting warehouse
- if flt(item.rm_supp_cost):
- supplier_wh_dict = self.get_inventory_account_dict(
- item, inventory_account_map, "supplier_warehouse"
- )
-
- supplier_inventory_account = supplier_wh_dict["account"]
- if not supplier_inventory_account:
- frappe.throw(
- _("Please set account in Warehouse {0}").format(self.supplier_warehouse)
- )
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": supplier_inventory_account,
- "against": item.expense_account,
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "credit": flt(item.rm_supp_cost),
- "credit_in_transaction_currency": item.net_amount,
- },
- supplier_wh_dict["account_currency"],
- item=item,
- )
- )
-
- else:
- expense_account = (
- item.expense_account
- if (not item.enable_deferred_expense or self.is_return)
- else item.deferred_expense_account
- )
-
- account_currency = get_account_currency(expense_account)
- amount, base_amount = self.get_amount_and_base_amount(item, None)
-
- if provisional_accounting_for_non_stock_items:
- self.make_provisional_gl_entry(gl_entries, item)
-
- if not self.is_internal_transfer():
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": expense_account,
- "against": self.supplier,
- "debit": base_amount,
- "debit_in_transaction_currency": amount,
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
-
- # check if the exchange rate has changed
- if (
- not adjust_incoming_rate
- and item.get("purchase_receipt")
- and self.auto_accounting_for_stock
- ):
- if (
- exchange_rate_map[item.purchase_receipt]
- and self.conversion_rate != exchange_rate_map[item.purchase_receipt]
- and item.net_rate == net_rate_map[item.pr_detail]
- and item.item_code in stock_items
- ):
- discrepancy_caused_by_exchange_rate_difference = (
- item.qty * item.net_rate
- ) * (exchange_rate_map[item.purchase_receipt] - self.conversion_rate)
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": expense_account,
- "against": self.supplier,
- "debit": discrepancy_caused_by_exchange_rate_difference,
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.get_company_default("exchange_gain_loss_account"),
- "against": self.supplier,
- "credit": discrepancy_caused_by_exchange_rate_difference,
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
-
- if (
- self.auto_accounting_for_stock
- and self.is_opening == "No"
- and item.item_code in stock_items
- and item.item_tax_amount
- ):
- # Post reverse entry for Stock-Received-But-Not-Billed if it is booked in Purchase Receipt
- if item.purchase_receipt and valuation_tax_accounts:
- negative_expense_booked_in_pr = frappe.db.sql(
- """select name from `tabGL Entry`
- where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
- (item.purchase_receipt, valuation_tax_accounts),
- )
-
- (
- self.get_company_default("asset_received_but_not_billed")
- if item.is_fixed_asset
- else self.stock_received_but_not_billed
- )
-
- if not negative_expense_booked_in_pr:
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.stock_received_but_not_billed,
- "against": self.supplier,
- "debit": flt(item.item_tax_amount, item.precision("item_tax_amount")),
- "debit_in_transaction_currency": flt(
- item.item_tax_amount / self.conversion_rate,
- item.precision("item_tax_amount"),
- ),
- "remarks": self.remarks or _("Accounting Entry for Stock"),
- "cost_center": self.cost_center,
- "project": item.project or self.project,
- },
- item=item,
- )
- )
-
- self.negative_expense_to_be_booked += flt(
- item.item_tax_amount, item.precision("item_tax_amount")
- )
-
- if item.is_fixed_asset and item.landed_cost_voucher_amount:
- self.update_net_purchase_amount_for_linked_assets(item)
-
- def get_provisional_accounts(self):
- self.provisional_accounts = frappe._dict()
- linked_purchase_receipts = set([d.purchase_receipt for d in self.items if d.purchase_receipt])
- if not linked_purchase_receipts:
- return
-
- pr_items = frappe.get_all(
- "Purchase Receipt Item",
- filters={"parent": ("in", linked_purchase_receipts)},
- fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"],
- )
- default_provisional_account = self.get_company_default("default_provisional_account")
- provisional_accounts = set(
- [
- d.provisional_expense_account
- if d.provisional_expense_account
- else default_provisional_account
- for d in pr_items
- ]
- )
-
- provisional_gl_entries = frappe.get_all(
- "GL Entry",
- filters={
- "voucher_type": "Purchase Receipt",
- "voucher_no": ("in", linked_purchase_receipts),
- "account": ("in", provisional_accounts),
- "is_cancelled": 0,
- },
- fields=["voucher_detail_no"],
- )
- rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries]
- for item in pr_items:
- self.provisional_accounts[item.name] = {
- "provisional_account": item.provisional_expense_account or default_provisional_account,
- "qty": item.qty,
- "base_rate": item.base_rate,
- "rate": item.rate,
- "has_provisional_entry": item.name in rows_with_provisional_entries,
- }
-
- def make_provisional_gl_entry(self, gl_entries, item):
- if item.purchase_receipt:
- pr_item = self.provisional_accounts.get(item.pr_detail, {})
- if pr_item.get("has_provisional_entry"):
- purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt)
-
- # Intentionally passing purchase invoice item to handle partial billing
- purchase_receipt_doc.add_provisional_gl_entry(
- item,
- gl_entries,
- self.posting_date,
- pr_item.get("provisional_account"),
- reverse=1,
- item_amount=(
- (min(item.qty, pr_item.get("qty")) * pr_item.get("rate"))
- * purchase_receipt_doc.get("conversion_rate")
- ),
- )
-
- def update_net_purchase_amount_for_linked_assets(self, item):
- assets = frappe.db.get_all(
- "Asset",
- filters={
- "purchase_invoice": self.name,
- "item_code": item.item_code,
- "purchase_invoice_item": ("in", [item.name, ""]),
- },
- fields=["name", "asset_quantity"],
- )
- for asset in assets:
- purchase_amount = flt(item.valuation_rate) * asset.asset_quantity
- frappe.db.set_value(
- "Asset",
- asset.name,
- {
- "net_purchase_amount": purchase_amount,
- "purchase_amount": purchase_amount,
- },
- )
-
- def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
- net_amt_precision = item.precision("base_net_amount")
- val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9
-
- warehouse_debit_amount = flt(
- flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor),
- net_amt_precision,
- )
-
- if self.is_return and self.update_stock and (self.is_internal_supplier or not self.return_against):
- net_rate = item.base_net_amount
- if item.sales_incoming_rate: # for internal transfer
- net_rate = item.qty * item.sales_incoming_rate
-
- stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
- warehouse_debit_amount = flt(
- voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
- )
-
- if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
- cost_of_goods_sold_account = self.get_company_default("default_expense_account")
- stock_adjustment_amt = stock_amount - warehouse_debit_amount
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": cost_of_goods_sold_account,
- "against": item.expense_account,
- "debit": stock_adjustment_amt,
- "debit_in_transaction_currency": stock_adjustment_amt / self.conversion_rate,
- "remarks": self.get("remarks") or _("Stock Adjustment"),
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
-
- elif (
- self.update_stock
- and voucher_wise_stock_value.get((item.name, item.warehouse))
- and warehouse_debit_amount
- != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
- ):
- cost_of_goods_sold_account = self.get_company_default("default_expense_account")
- stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
- stock_adjustment_amt = warehouse_debit_amount - stock_amount
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": cost_of_goods_sold_account,
- "against": item.expense_account,
- "debit": stock_adjustment_amt,
- "debit_in_transaction_currency": stock_adjustment_amt / self.conversion_rate,
- "remarks": self.get("remarks") or _("Stock Adjustment"),
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
-
- warehouse_debit_amount = stock_amount
-
- return warehouse_debit_amount
-
- def make_tax_gl_entries(self, gl_entries):
- # tax table gl entries
- valuation_tax = {}
-
- for tax in self.get("taxes"):
- amount, base_amount = self.get_tax_amounts(tax, None)
- if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
- account_currency = get_account_currency(tax.account_head)
-
- dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit"
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": tax.account_head,
- "against": self.supplier,
- dr_or_cr: base_amount,
- dr_or_cr + "_in_account_currency": base_amount
- if account_currency == self.company_currency
- else amount,
- dr_or_cr + "_in_transaction_currency": amount,
- "cost_center": tax.cost_center,
- },
- account_currency,
- item=tax,
- )
- )
- # accumulate valuation tax
- if (
- self.is_opening == "No"
- and tax.category in ("Valuation", "Valuation and Total")
- and flt(base_amount)
- and not self.is_internal_transfer()
- ):
- if self.auto_accounting_for_stock and not tax.cost_center:
- frappe.throw(
- _("Cost Center is required in row {0} in Taxes table for type {1}").format(
- tax.idx, _(tax.category)
- )
- )
- valuation_tax.setdefault(tax.name, 0)
- valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
-
- if self.is_opening == "No" and self.negative_expense_to_be_booked and valuation_tax:
- # credit valuation tax amount in "Expenses Included In Valuation"
- # this will balance out valuation amount included in cost of goods sold
-
- total_valuation_amount = sum(valuation_tax.values())
- amount_including_divisional_loss = self.negative_expense_to_be_booked
- i = 1
- for tax in self.get("taxes"):
- if valuation_tax.get(tax.name):
- if i == len(valuation_tax):
- applicable_amount = amount_including_divisional_loss
- else:
- applicable_amount = self.negative_expense_to_be_booked * (
- valuation_tax[tax.name] / total_valuation_amount
- )
- amount_including_divisional_loss -= applicable_amount
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": tax.account_head,
- "cost_center": tax.cost_center,
- "against": self.supplier,
- "credit": applicable_amount,
- "credit_in_transaction_currency": flt(
- applicable_amount / self.conversion_rate,
- frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
- ),
- "remarks": self.remarks or _("Accounting Entry for Stock"),
- },
- item=tax,
- )
- )
-
- i += 1
-
- if self.auto_accounting_for_stock and self.update_stock and valuation_tax:
- for tax in self.get("taxes"):
- if valuation_tax.get(tax.name):
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": tax.account_head,
- "cost_center": tax.cost_center,
- "against": self.supplier,
- "credit": valuation_tax[tax.name],
- "credit_in_transaction_currency": flt(
- valuation_tax[tax.name] / self.conversion_rate,
- frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
- ),
- "remarks": self.remarks or _("Accounting Entry for Stock"),
- },
- item=tax,
- )
- )
-
- def make_internal_transfer_gl_entries(self, gl_entries):
- if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges):
- account_currency = get_account_currency(self.unrealized_profit_loss_account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.unrealized_profit_loss_account,
- "against": self.supplier,
- "credit": flt(self.total_taxes_and_charges),
- "credit_in_transaction_currency": flt(self.total_taxes_and_charges),
- "credit_in_account_currency": flt(self.base_total_taxes_and_charges),
- "cost_center": self.cost_center,
- },
- account_currency,
- item=self,
- )
- )
-
- def make_gl_entries_for_tax_withholding(self, gl_entries):
- """
- Tax withholding amount is not part of supplier invoice.
- Separate supplier GL Entry for correct reporting.
- """
- if not self.apply_tds:
- return
-
- for row in self.get("taxes"):
- if not row.is_tax_withholding_account or not row.tax_amount:
- continue
-
- base_tds_amount = row.base_tax_amount_after_discount_amount
- tds_amount = row.tax_amount_after_discount_amount
-
- self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount)
- self.add_supplier_gl_entry(
- gl_entries,
- -base_tds_amount,
- -tds_amount,
- against_account=row.account_head,
- remarks=_("TDS Deducted"),
- skip_merge=True,
- )
-
- def make_payment_gl_entries(self, gl_entries):
- # Make Cash GL Entries
- if cint(self.is_paid) and self.cash_bank_account and self.paid_amount:
- against_voucher = self.name
- if self.is_return and self.return_against and not self.update_outstanding_for_self:
- against_voucher = self.return_against
- bank_account_currency = get_account_currency(self.cash_bank_account)
- # CASH, make payment entries
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.credit_to,
- "party_type": "Supplier",
- "party": self.supplier,
- "against": self.cash_bank_account,
- "debit": self.base_paid_amount,
- "debit_in_account_currency": self.base_paid_amount
- if self.party_account_currency == self.company_currency
- else self.paid_amount,
- "debit_in_transaction_currency": self.paid_amount,
- "against_voucher": against_voucher,
- "against_voucher_type": self.doctype,
- "cost_center": self.cost_center,
- "project": self.project,
- },
- self.party_account_currency,
- item=self,
- )
- )
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.cash_bank_account,
- "against": self.supplier,
- "credit": self.base_paid_amount,
- "credit_in_account_currency": self.base_paid_amount
- if bank_account_currency == self.company_currency
- else self.paid_amount,
- "credit_in_transaction_currency": self.paid_amount,
- "cost_center": self.cost_center,
- },
- bank_account_currency,
- item=self,
- )
- )
-
- def make_write_off_gl_entry(self, gl_entries):
- # writeoff account includes petty difference in the invoice amount
- # and the amount that is paid
- if self.write_off_account and flt(self.write_off_amount):
- write_off_account_currency = get_account_currency(self.write_off_account)
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.credit_to,
- "party_type": "Supplier",
- "party": self.supplier,
- "against": self.write_off_account,
- "debit": self.base_write_off_amount,
- "debit_in_account_currency": self.base_write_off_amount
- if self.party_account_currency == self.company_currency
- else self.write_off_amount,
- "debit_in_transaction_currency": self.write_off_amount,
- "against_voucher": self.return_against
- if cint(self.is_return) and self.return_against
- else self.name,
- "against_voucher_type": self.doctype,
- "cost_center": self.cost_center,
- "project": self.project,
- },
- self.party_account_currency,
- item=self,
- )
- )
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.write_off_account,
- "against": self.supplier,
- "credit": flt(self.base_write_off_amount),
- "credit_in_account_currency": self.base_write_off_amount
- if write_off_account_currency == self.company_currency
- else self.write_off_amount,
- "credit_in_transaction_currency": self.write_off_amount,
- "cost_center": self.cost_center or self.write_off_cost_center,
- },
- item=self,
- )
- )
-
- def make_gle_for_rounding_adjustment(self, gl_entries):
- # if rounding adjustment in small and conversion rate is also small then
- # base_rounding_adjustment may become zero due to small precision
- # eg: rounding_adjustment = 0.01 and exchange rate = 0.05 and precision of base_rounding_adjustment is 2
- # then base_rounding_adjustment becomes zero and error is thrown in GL Entry
- if not self.is_internal_transfer() and self.rounding_adjustment and self.base_rounding_adjustment:
- (
- round_off_account,
- round_off_cost_center,
- round_off_for_opening,
- ) = get_round_off_account_and_cost_center(
- self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center
- )
-
- if self.is_opening == "Yes" and self.rounding_adjustment:
- if not round_off_for_opening:
- frappe.throw(
- _(
- "Opening Invoice has rounding adjustment of {0}.
'{1}' account is required to post these values. Please set it in Company: {2}.
Or, '{3}' can be enabled to not post any rounding adjustment."
- ).format(
- frappe.bold(self.rounding_adjustment),
- frappe.bold("Round Off for Opening"),
- get_link_to_form("Company", self.company),
- frappe.bold("Disable Rounded Total"),
- )
- )
- else:
- round_off_account = round_off_for_opening
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": round_off_account,
- "against": self.supplier,
- "debit_in_account_currency": self.rounding_adjustment,
- "debit": self.base_rounding_adjustment,
- "cost_center": round_off_cost_center
- if self.use_company_roundoff_cost_center
- else (self.cost_center or round_off_cost_center),
- },
- item=self,
- )
- )
-
def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
@@ -1964,31 +1159,6 @@ def make_regional_gl_entries(gl_entries, doc):
return gl_entries
-@frappe.whitelist()
-def make_debit_note(source_name: str, target_doc: str | Document | None = None):
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
-
- return make_return_doc("Purchase Invoice", source_name, target_doc)
-
-
-@frappe.whitelist()
-def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
- doc = get_mapped_doc(
- "Purchase Invoice",
- source_name,
- {
- "Purchase Invoice": {"doctype": "Stock Entry", "validation": {"docstatus": ["=", 1]}},
- "Purchase Invoice Item": {
- "doctype": "Stock Entry Detail",
- "field_map": {"stock_qty": "transfer_qty", "batch_no": "batch_no"},
- },
- },
- target_doc,
- )
-
- return doc
-
-
@frappe.whitelist()
def change_release_date(name: str, release_date: str | None = None):
if frappe.db.exists("Purchase Invoice", name):
@@ -2008,95 +1178,3 @@ def block_invoice(name: str, release_date: str, hold_comment: str | None = None)
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.block_invoice(hold_comment, release_date)
-
-
-@frappe.whitelist()
-def make_inter_company_sales_invoice(source_name: str, target_doc: Document | None = None):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
-
- return make_inter_company_transaction("Purchase Invoice", source_name, target_doc)
-
-
-@frappe.whitelist()
-def make_purchase_receipt(
- source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
-):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- def post_parent_process(source_parent, target_parent):
- remove_items_with_zero_qty(target_parent)
- set_missing_values(source_parent, target_parent)
-
- def remove_items_with_zero_qty(target_parent):
- target_parent.items = [row for row in target_parent.get("items") if row.get("qty") != 0]
-
- def set_missing_values(source_parent, target_parent):
- target_parent.run_method("set_missing_values")
- if args and args.get("merge_taxes"):
- merge_taxes(source_parent, target_parent)
- target_parent.run_method("calculate_taxes_and_totals")
-
- def update_item(obj, target, source_parent):
- from erpnext.controllers.sales_and_purchase_return import get_returned_qty_map_for_row
-
- returned_qty_map = (
- get_returned_qty_map_for_row(
- source_parent.name, source_parent.supplier, obj.name, "Purchase Invoice"
- )
- or {}
- )
-
- target.qty = flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))
- target.received_qty = flt(obj.qty) - flt(obj.received_qty)
- target.stock_qty = (flt(obj.qty) - flt(obj.received_qty) - flt(returned_qty_map.get("qty"))) * flt(
- obj.conversion_factor
- )
- target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate)
- target.base_amount = (
- (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) * flt(source_parent.conversion_rate)
- )
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- doc = get_mapped_doc(
- "Purchase Invoice",
- source_name,
- {
- "Purchase Invoice": {
- "doctype": "Purchase Receipt",
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Purchase Invoice Item": {
- "doctype": "Purchase Receipt Item",
- "field_map": {
- "name": "purchase_invoice_item",
- "parent": "purchase_invoice",
- "bom": "bom",
- "purchase_order": "purchase_order",
- "po_detail": "purchase_order_item",
- "material_request": "material_request",
- "material_request_item": "material_request_item",
- "wip_composite_asset": "wip_composite_asset",
- },
- "postprocess": update_item,
- "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) and select_item(doc),
- },
- "Purchase Taxes and Charges": {
- "doctype": "Purchase Taxes and Charges",
- "reset_value": not (args and args.get("merge_taxes")),
- "ignore": args.get("merge_taxes") if args else 0,
- },
- },
- target_doc,
- post_parent_process,
- )
-
- return doc
diff --git a/erpnext/accounts/doctype/purchase_invoice/services/__init__.py b/erpnext/accounts/doctype/purchase_invoice/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
new file mode 100644
index 00000000000..1c4ea5c9e7f
--- /dev/null
+++ b/erpnext/accounts/doctype/purchase_invoice/services/gl_composer.py
@@ -0,0 +1,850 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import cint, flt, get_link_to_form
+
+import erpnext
+from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
+from erpnext.accounts.services.base_gl_composer import BaseGLComposer
+from erpnext.accounts.services.taxes import TaxService
+from erpnext.accounts.utils import get_account_currency
+
+
+class PurchaseInvoiceGLComposer(BaseGLComposer):
+ """Assembles the GL entries for a Purchase Invoice."""
+
+ def compose(self, inventory_account_map=None):
+ from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_regional_gl_entries
+ from erpnext.accounts.general_ledger import merge_similar_entries
+
+ doc = self.doc
+ doc.auto_accounting_for_stock = erpnext.is_perpetual_inventory_enabled(doc.company)
+
+ if doc.auto_accounting_for_stock:
+ doc.stock_received_but_not_billed = doc.get_company_default("stock_received_but_not_billed")
+ else:
+ doc.stock_received_but_not_billed = None
+
+ doc.negative_expense_to_be_booked = 0.0
+ gl_entries = []
+
+ self.make_supplier_gl_entry(gl_entries)
+ self.make_item_gl_entries(gl_entries)
+ self.make_precision_loss_gl_entry(gl_entries)
+
+ self.make_tax_gl_entries(gl_entries)
+ self.make_internal_transfer_gl_entries(gl_entries)
+ self.make_gl_entries_for_tax_withholding(gl_entries)
+
+ gl_entries = make_regional_gl_entries(gl_entries, doc)
+ gl_entries = merge_similar_entries(gl_entries)
+
+ self.make_payment_gl_entries(gl_entries)
+ self.make_write_off_gl_entry(gl_entries)
+ self.make_gle_for_rounding_adjustment(gl_entries)
+ doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
+ doc.set_gl_entry_for_purchase_expense(gl_entries)
+ return gl_entries
+
+ def make_precision_loss_gl_entry(self, gl_entries):
+ doc = self.doc
+ (
+ round_off_account,
+ round_off_cost_center,
+ _round_off_for_opening,
+ ) = get_round_off_account_and_cost_center(
+ doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
+ )
+
+ precision_loss = doc.get("base_net_total") - flt(
+ doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
+ )
+
+ if precision_loss:
+ gl_entries.append(
+ doc.get_gl_dict(
+ {
+ "account": round_off_account,
+ "against": doc.supplier,
+ "credit": precision_loss,
+ "cost_center": round_off_cost_center
+ if doc.use_company_roundoff_cost_center
+ else doc.cost_center or round_off_cost_center,
+ "remarks": _("Net total calculation precision loss"),
+ }
+ )
+ )
+
+ def make_supplier_gl_entry(self, gl_entries):
+ doc = self.doc
+ grand_total = (
+ doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
+ )
+ base_grand_total = flt(
+ doc.base_rounded_total
+ if (doc.base_rounding_adjustment and doc.base_rounded_total)
+ else doc.base_grand_total,
+ doc.precision("base_grand_total"),
+ )
+ if grand_total and not doc.is_internal_transfer():
+ self.add_supplier_gl_entry(gl_entries, base_grand_total, grand_total)
+
+ def add_supplier_gl_entry(
+ self,
+ gl_entries,
+ base_grand_total,
+ grand_total,
+ against_account=None,
+ remarks=None,
+ skip_merge=False,
+ ):
+ doc = self.doc
+ against_voucher = doc.name
+ if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
+ against_voucher = doc.return_against
+
+ gl = {
+ "account": doc.credit_to,
+ "party_type": "Supplier",
+ "party": doc.supplier,
+ "due_date": doc.due_date,
+ "against": against_account or doc.against_expense_account,
+ "credit": base_grand_total,
+ "credit_in_account_currency": base_grand_total
+ if doc.party_account_currency == doc.company_currency
+ else grand_total,
+ "credit_in_transaction_currency": grand_total,
+ "against_voucher": against_voucher,
+ "against_voucher_type": doc.doctype,
+ "project": doc.project,
+ "cost_center": doc.cost_center,
+ "_skip_merge": skip_merge,
+ }
+ if remarks:
+ gl["remarks"] = remarks
+ gl_entries.append(self.get_gl_dict(gl, doc.party_account_currency, item=doc))
+
+ def make_item_gl_entries(self, gl_entries):
+ from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
+ get_purchase_document_details,
+ )
+
+ doc = self.doc
+ tax_service = TaxService(doc)
+ stock_items = doc.get_stock_items()
+ if doc.update_stock and doc.auto_accounting_for_stock:
+ inventory_account_map = doc.get_inventory_account_map()
+
+ landed_cost_entries = doc.get_item_account_wise_lcv_entries()
+
+ voucher_wise_stock_value = {}
+ if doc.update_stock:
+ stock_ledger_entries = frappe.get_all(
+ "Stock Ledger Entry",
+ fields=["voucher_detail_no", "stock_value_difference", "warehouse"],
+ filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled": 0},
+ )
+ for d in stock_ledger_entries:
+ voucher_wise_stock_value.setdefault(
+ (d.voucher_detail_no, d.warehouse), d.stock_value_difference
+ )
+
+ valuation_tax_accounts = [
+ d.account_head
+ for d in doc.get("taxes")
+ if d.category in ("Valuation", "Valuation and Total")
+ and flt(d.base_tax_amount_after_discount_amount)
+ ]
+
+ exchange_rate_map, net_rate_map = get_purchase_document_details(doc)
+
+ provisional_accounting_for_non_stock_items = cint(
+ frappe.get_cached_value(
+ "Company", doc.company, "enable_provisional_accounting_for_non_stock_items"
+ )
+ )
+ if provisional_accounting_for_non_stock_items:
+ self.get_provisional_accounts()
+
+ adjust_incoming_rate = frappe.db.get_single_value(
+ "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate"
+ )
+
+ for item in doc.get("items"):
+ if flt(item.base_net_amount) or (doc.get("update_stock") and item.valuation_rate):
+ if item.item_code:
+ frappe.get_cached_value("Item", item.item_code, "asset_category")
+
+ if (
+ doc.update_stock
+ and doc.auto_accounting_for_stock
+ and (item.item_code in stock_items or item.is_fixed_asset)
+ ):
+ account_currency = get_account_currency(item.expense_account)
+ warehouse_debit_amount = self.make_stock_adjustment_entry(
+ gl_entries, item, voucher_wise_stock_value, account_currency
+ )
+
+ if item.from_warehouse:
+ _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
+ _inv_dict_from_warehouse = doc.get_inventory_account_dict(
+ item, inventory_account_map, "from_warehouse"
+ )
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": _inv_dict["account"],
+ "against": _inv_dict_from_warehouse["account"],
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "debit": warehouse_debit_amount,
+ "debit_in_transaction_currency": item.net_amount,
+ },
+ _inv_dict["account_currency"],
+ item=item,
+ )
+ )
+
+ credit_amount = item.base_net_amount
+ if doc.is_internal_supplier and item.valuation_rate:
+ credit_amount = flt(item.valuation_rate * item.stock_qty)
+
+ # Intentionally passed negative debit amount to avoid incorrect GL Entry validation
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": _inv_dict_from_warehouse["account"],
+ "against": _inv_dict["account"],
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "debit": -1 * flt(credit_amount, item.precision("base_net_amount")),
+ "debit_in_transaction_currency": item.net_amount,
+ },
+ _inv_dict_from_warehouse["account_currency"],
+ item=item,
+ )
+ )
+
+ if not doc.is_internal_transfer():
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item.expense_account,
+ "against": doc.supplier,
+ "debit": flt(item.base_net_amount, item.precision("base_net_amount")),
+ "debit_in_transaction_currency": item.net_amount,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "cost_center": item.cost_center,
+ "project": item.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ else:
+ if not doc.is_internal_transfer():
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item.expense_account,
+ "against": doc.supplier,
+ "debit": warehouse_debit_amount,
+ "debit_in_transaction_currency": flt(
+ warehouse_debit_amount / doc.conversion_rate,
+ item.precision("net_amount"),
+ ),
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ # Amount added through landed-cost-voucher
+ if landed_cost_entries:
+ if (item.item_code, item.name) in landed_cost_entries:
+ for account, base_amount in landed_cost_entries[
+ (item.item_code, item.name)
+ ].items():
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": account,
+ "against": item.expense_account,
+ "cost_center": item.cost_center,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "credit": flt(base_amount["base_amount"]),
+ "credit_in_account_currency": flt(base_amount["amount"]),
+ "credit_in_transaction_currency": item.net_amount,
+ "project": item.project or doc.project,
+ },
+ item=item,
+ )
+ )
+
+ # sub-contracting warehouse
+ if flt(item.rm_supp_cost):
+ supplier_wh_dict = doc.get_inventory_account_dict(
+ item, inventory_account_map, "supplier_warehouse"
+ )
+ supplier_inventory_account = supplier_wh_dict["account"]
+ if not supplier_inventory_account:
+ frappe.throw(
+ _("Please set account in Warehouse {0}").format(doc.supplier_warehouse)
+ )
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": supplier_inventory_account,
+ "against": item.expense_account,
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "credit": flt(item.rm_supp_cost),
+ "credit_in_transaction_currency": item.net_amount,
+ },
+ supplier_wh_dict["account_currency"],
+ item=item,
+ )
+ )
+
+ else:
+ expense_account = (
+ item.expense_account
+ if (not item.enable_deferred_expense or doc.is_return)
+ else item.deferred_expense_account
+ )
+ account_currency = get_account_currency(expense_account)
+ amount, base_amount = tax_service.get_amount_and_base_amount(item, None)
+
+ if provisional_accounting_for_non_stock_items:
+ self.make_provisional_gl_entry(gl_entries, item)
+
+ if not doc.is_internal_transfer():
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": expense_account,
+ "against": doc.supplier,
+ "debit": base_amount,
+ "debit_in_transaction_currency": amount,
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ # check if the exchange rate has changed
+ if (
+ not adjust_incoming_rate
+ and item.get("purchase_receipt")
+ and doc.auto_accounting_for_stock
+ ):
+ if (
+ exchange_rate_map[item.purchase_receipt]
+ and doc.conversion_rate != exchange_rate_map[item.purchase_receipt]
+ and item.net_rate == net_rate_map[item.pr_detail]
+ and item.item_code in stock_items
+ ):
+ discrepancy_caused_by_exchange_rate_difference = (
+ item.qty * item.net_rate
+ ) * (exchange_rate_map[item.purchase_receipt] - doc.conversion_rate)
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": expense_account,
+ "against": doc.supplier,
+ "debit": discrepancy_caused_by_exchange_rate_difference,
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.get_company_default("exchange_gain_loss_account"),
+ "against": doc.supplier,
+ "credit": discrepancy_caused_by_exchange_rate_difference,
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ if (
+ doc.auto_accounting_for_stock
+ and doc.is_opening == "No"
+ and item.item_code in stock_items
+ and item.item_tax_amount
+ ):
+ # Post reverse entry for Stock-Received-But-Not-Billed if booked in Purchase Receipt
+ if item.purchase_receipt and valuation_tax_accounts:
+ negative_expense_booked_in_pr = frappe.db.sql(
+ """select name from `tabGL Entry`
+ where voucher_type='Purchase Receipt' and voucher_no=%s and account in %s""",
+ (item.purchase_receipt, valuation_tax_accounts),
+ )
+
+ (
+ doc.get_company_default("asset_received_but_not_billed")
+ if item.is_fixed_asset
+ else doc.stock_received_but_not_billed
+ )
+
+ if not negative_expense_booked_in_pr:
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.stock_received_but_not_billed,
+ "against": doc.supplier,
+ "debit": flt(item.item_tax_amount, item.precision("item_tax_amount")),
+ "debit_in_transaction_currency": flt(
+ item.item_tax_amount / doc.conversion_rate,
+ item.precision("item_tax_amount"),
+ ),
+ "remarks": doc.remarks or _("Accounting Entry for Stock"),
+ "cost_center": doc.cost_center,
+ "project": item.project or doc.project,
+ },
+ item=item,
+ )
+ )
+ doc.negative_expense_to_be_booked += flt(
+ item.item_tax_amount, item.precision("item_tax_amount")
+ )
+
+ if item.is_fixed_asset and item.landed_cost_voucher_amount:
+ self.update_net_purchase_amount_for_linked_assets(item)
+
+ def get_provisional_accounts(self):
+ doc = self.doc
+ self.provisional_accounts = frappe._dict()
+ linked_purchase_receipts = {d.purchase_receipt for d in doc.items if d.purchase_receipt}
+ if not linked_purchase_receipts:
+ return
+
+ pr_items = frappe.get_all(
+ "Purchase Receipt Item",
+ filters={"parent": ("in", linked_purchase_receipts)},
+ fields=["name", "provisional_expense_account", "qty", "base_rate", "rate"],
+ )
+ default_provisional_account = doc.get_company_default("default_provisional_account")
+ provisional_accounts = {
+ d.provisional_expense_account if d.provisional_expense_account else default_provisional_account
+ for d in pr_items
+ }
+
+ provisional_gl_entries = frappe.get_all(
+ "GL Entry",
+ filters={
+ "voucher_type": "Purchase Receipt",
+ "voucher_no": ("in", linked_purchase_receipts),
+ "account": ("in", provisional_accounts),
+ "is_cancelled": 0,
+ },
+ fields=["voucher_detail_no"],
+ )
+ rows_with_provisional_entries = [d.voucher_detail_no for d in provisional_gl_entries]
+ for item in pr_items:
+ self.provisional_accounts[item.name] = {
+ "provisional_account": item.provisional_expense_account or default_provisional_account,
+ "qty": item.qty,
+ "base_rate": item.base_rate,
+ "rate": item.rate,
+ "has_provisional_entry": item.name in rows_with_provisional_entries,
+ }
+
+ def make_provisional_gl_entry(self, gl_entries, item):
+ if item.purchase_receipt:
+ pr_item = self.provisional_accounts.get(item.pr_detail, {})
+ if pr_item.get("has_provisional_entry"):
+ purchase_receipt_doc = frappe.get_cached_doc("Purchase Receipt", item.purchase_receipt)
+
+ # Intentionally passing purchase invoice item to handle partial billing
+ purchase_receipt_doc.add_provisional_gl_entry(
+ item,
+ gl_entries,
+ self.doc.posting_date,
+ pr_item.get("provisional_account"),
+ reverse=1,
+ item_amount=(
+ (min(item.qty, pr_item.get("qty")) * pr_item.get("rate"))
+ * purchase_receipt_doc.get("conversion_rate")
+ ),
+ )
+
+ def update_net_purchase_amount_for_linked_assets(self, item):
+ doc = self.doc
+ assets = frappe.db.get_all(
+ "Asset",
+ filters={
+ "purchase_invoice": doc.name,
+ "item_code": item.item_code,
+ "purchase_invoice_item": ("in", [item.name, ""]),
+ },
+ fields=["name", "asset_quantity"],
+ )
+ for asset in assets:
+ purchase_amount = flt(item.valuation_rate) * asset.asset_quantity
+ frappe.db.set_value(
+ "Asset",
+ asset.name,
+ {
+ "net_purchase_amount": purchase_amount,
+ "purchase_amount": purchase_amount,
+ },
+ )
+
+ def make_stock_adjustment_entry(self, gl_entries, item, voucher_wise_stock_value, account_currency):
+ doc = self.doc
+ net_amt_precision = item.precision("base_net_amount")
+ val_rate_db_precision = 6 if cint(item.precision("valuation_rate")) <= 6 else 9
+
+ warehouse_debit_amount = flt(
+ flt(item.valuation_rate, val_rate_db_precision) * flt(item.qty) * flt(item.conversion_factor),
+ net_amt_precision,
+ )
+
+ if doc.is_return and doc.update_stock and (doc.is_internal_supplier or not doc.return_against):
+ net_rate = item.base_net_amount
+ if item.sales_incoming_rate:
+ net_rate = item.qty * item.sales_incoming_rate
+
+ stock_amount = net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount)
+ warehouse_debit_amount = flt(
+ voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision
+ )
+
+ if flt(stock_amount, net_amt_precision) != flt(warehouse_debit_amount, net_amt_precision):
+ cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
+ stock_adjustment_amt = stock_amount - warehouse_debit_amount
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": cost_of_goods_sold_account,
+ "against": item.expense_account,
+ "debit": stock_adjustment_amt,
+ "debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
+ "remarks": doc.get("remarks") or _("Stock Adjustment"),
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ elif (
+ doc.update_stock
+ and voucher_wise_stock_value.get((item.name, item.warehouse))
+ and warehouse_debit_amount
+ != flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
+ ):
+ cost_of_goods_sold_account = doc.get_company_default("default_expense_account")
+ stock_amount = flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
+ stock_adjustment_amt = warehouse_debit_amount - stock_amount
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": cost_of_goods_sold_account,
+ "against": item.expense_account,
+ "debit": stock_adjustment_amt,
+ "debit_in_transaction_currency": stock_adjustment_amt / doc.conversion_rate,
+ "remarks": doc.get("remarks") or _("Stock Adjustment"),
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ warehouse_debit_amount = stock_amount
+
+ return warehouse_debit_amount
+
+ def make_tax_gl_entries(self, gl_entries):
+ doc = self.doc
+ tax_service = TaxService(doc)
+ valuation_tax = {}
+
+ for tax in doc.get("taxes"):
+ amount, base_amount = tax_service.get_tax_amounts(tax, None)
+ if tax.category in ("Total", "Valuation and Total") and flt(base_amount):
+ account_currency = get_account_currency(tax.account_head)
+ dr_or_cr = "debit" if tax.add_deduct_tax == "Add" else "credit"
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": tax.account_head,
+ "against": doc.supplier,
+ dr_or_cr: base_amount,
+ dr_or_cr + "_in_account_currency": base_amount
+ if account_currency == doc.company_currency
+ else amount,
+ dr_or_cr + "_in_transaction_currency": amount,
+ "cost_center": tax.cost_center,
+ },
+ account_currency,
+ item=tax,
+ )
+ )
+
+ if (
+ doc.is_opening == "No"
+ and tax.category in ("Valuation", "Valuation and Total")
+ and flt(base_amount)
+ and not doc.is_internal_transfer()
+ ):
+ if doc.auto_accounting_for_stock and not tax.cost_center:
+ frappe.throw(
+ _("Cost Center is required in row {0} in Taxes table for type {1}").format(
+ tax.idx, _(tax.category)
+ )
+ )
+ valuation_tax.setdefault(tax.name, 0)
+ valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(base_amount)
+
+ if doc.is_opening == "No" and doc.negative_expense_to_be_booked and valuation_tax:
+ total_valuation_amount = sum(valuation_tax.values())
+ amount_including_divisional_loss = doc.negative_expense_to_be_booked
+ i = 1
+ for tax in doc.get("taxes"):
+ if valuation_tax.get(tax.name):
+ if i == len(valuation_tax):
+ applicable_amount = amount_including_divisional_loss
+ else:
+ applicable_amount = doc.negative_expense_to_be_booked * (
+ valuation_tax[tax.name] / total_valuation_amount
+ )
+ amount_including_divisional_loss -= applicable_amount
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": tax.account_head,
+ "cost_center": tax.cost_center,
+ "against": doc.supplier,
+ "credit": applicable_amount,
+ "credit_in_transaction_currency": flt(
+ applicable_amount / doc.conversion_rate,
+ frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
+ ),
+ "remarks": doc.remarks or _("Accounting Entry for Stock"),
+ },
+ item=tax,
+ )
+ )
+ i += 1
+
+ if doc.auto_accounting_for_stock and doc.update_stock and valuation_tax:
+ for tax in doc.get("taxes"):
+ if valuation_tax.get(tax.name):
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": tax.account_head,
+ "cost_center": tax.cost_center,
+ "against": doc.supplier,
+ "credit": valuation_tax[tax.name],
+ "credit_in_transaction_currency": flt(
+ valuation_tax[tax.name] / doc.conversion_rate,
+ frappe.get_precision("Purchase Invoice Item", "item_tax_amount"),
+ ),
+ "remarks": doc.remarks or _("Accounting Entry for Stock"),
+ },
+ item=tax,
+ )
+ )
+
+ def make_internal_transfer_gl_entries(self, gl_entries):
+ doc = self.doc
+ if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
+ account_currency = get_account_currency(doc.unrealized_profit_loss_account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.unrealized_profit_loss_account,
+ "against": doc.supplier,
+ "credit": flt(doc.total_taxes_and_charges),
+ "credit_in_transaction_currency": flt(doc.total_taxes_and_charges),
+ "credit_in_account_currency": flt(doc.base_total_taxes_and_charges),
+ "cost_center": doc.cost_center,
+ },
+ account_currency,
+ item=doc,
+ )
+ )
+
+ def make_gl_entries_for_tax_withholding(self, gl_entries):
+ """Separate supplier GL entry for tax withholding (TDS) — not part of the supplier invoice amount."""
+ doc = self.doc
+ if not doc.apply_tds:
+ return
+
+ for row in doc.get("taxes"):
+ if not row.is_tax_withholding_account or not row.tax_amount:
+ continue
+
+ base_tds_amount = row.base_tax_amount_after_discount_amount
+ tds_amount = row.tax_amount_after_discount_amount
+
+ self.add_supplier_gl_entry(gl_entries, base_tds_amount, tds_amount)
+ self.add_supplier_gl_entry(
+ gl_entries,
+ -base_tds_amount,
+ -tds_amount,
+ against_account=row.account_head,
+ remarks=_("TDS Deducted"),
+ skip_merge=True,
+ )
+
+ def make_payment_gl_entries(self, gl_entries):
+ doc = self.doc
+ if cint(doc.is_paid) and doc.cash_bank_account and doc.paid_amount:
+ against_voucher = doc.name
+ if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
+ against_voucher = doc.return_against
+ bank_account_currency = get_account_currency(doc.cash_bank_account)
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.credit_to,
+ "party_type": "Supplier",
+ "party": doc.supplier,
+ "against": doc.cash_bank_account,
+ "debit": doc.base_paid_amount,
+ "debit_in_account_currency": doc.base_paid_amount
+ if doc.party_account_currency == doc.company_currency
+ else doc.paid_amount,
+ "debit_in_transaction_currency": doc.paid_amount,
+ "against_voucher": against_voucher,
+ "against_voucher_type": doc.doctype,
+ "cost_center": doc.cost_center,
+ "project": doc.project,
+ },
+ doc.party_account_currency,
+ item=doc,
+ )
+ )
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.cash_bank_account,
+ "against": doc.supplier,
+ "credit": doc.base_paid_amount,
+ "credit_in_account_currency": doc.base_paid_amount
+ if bank_account_currency == doc.company_currency
+ else doc.paid_amount,
+ "credit_in_transaction_currency": doc.paid_amount,
+ "cost_center": doc.cost_center,
+ },
+ bank_account_currency,
+ item=doc,
+ )
+ )
+
+ def make_write_off_gl_entry(self, gl_entries):
+ doc = self.doc
+ if doc.write_off_account and flt(doc.write_off_amount):
+ write_off_account_currency = get_account_currency(doc.write_off_account)
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.credit_to,
+ "party_type": "Supplier",
+ "party": doc.supplier,
+ "against": doc.write_off_account,
+ "debit": doc.base_write_off_amount,
+ "debit_in_account_currency": doc.base_write_off_amount
+ if doc.party_account_currency == doc.company_currency
+ else doc.write_off_amount,
+ "debit_in_transaction_currency": doc.write_off_amount,
+ "against_voucher": doc.return_against
+ if cint(doc.is_return) and doc.return_against
+ else doc.name,
+ "against_voucher_type": doc.doctype,
+ "cost_center": doc.cost_center,
+ "project": doc.project,
+ },
+ doc.party_account_currency,
+ item=doc,
+ )
+ )
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.write_off_account,
+ "against": doc.supplier,
+ "credit": flt(doc.base_write_off_amount),
+ "credit_in_account_currency": doc.base_write_off_amount
+ if write_off_account_currency == doc.company_currency
+ else doc.write_off_amount,
+ "credit_in_transaction_currency": doc.write_off_amount,
+ "cost_center": doc.cost_center or doc.write_off_cost_center,
+ },
+ item=doc,
+ )
+ )
+
+ def make_gle_for_rounding_adjustment(self, gl_entries):
+ doc = self.doc
+ if not doc.is_internal_transfer() and doc.rounding_adjustment and doc.base_rounding_adjustment:
+ (
+ round_off_account,
+ round_off_cost_center,
+ round_off_for_opening,
+ ) = get_round_off_account_and_cost_center(
+ doc.company, "Purchase Invoice", doc.name, doc.use_company_roundoff_cost_center
+ )
+
+ if doc.is_opening == "Yes" and doc.rounding_adjustment:
+ if not round_off_for_opening:
+ frappe.throw(
+ _(
+ "Opening Invoice has rounding adjustment of {0}.
'{1}' account is required to post these values. Please set it in Company: {2}.
Or, '{3}' can be enabled to not post any rounding adjustment."
+ ).format(
+ frappe.bold(doc.rounding_adjustment),
+ frappe.bold("Round Off for Opening"),
+ get_link_to_form("Company", doc.company),
+ frappe.bold("Disable Rounded Total"),
+ )
+ )
+ else:
+ round_off_account = round_off_for_opening
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": round_off_account,
+ "against": doc.supplier,
+ "debit_in_account_currency": doc.rounding_adjustment,
+ "debit": doc.base_rounding_adjustment,
+ "cost_center": round_off_cost_center
+ if doc.use_company_roundoff_cost_center
+ else (doc.cost_center or round_off_cost_center),
+ },
+ item=doc,
+ )
+ )
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 5d78d895393..6ead96438a3 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -8,8 +8,8 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
-from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
-from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice as make_pi_from_po
+from erpnext.buying.doctype.purchase_order.mapper import get_mapped_purchase_invoice
+from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice as make_pi_from_po
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po,
create_purchase_order,
@@ -20,9 +20,9 @@ from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.exceptions import InvalidCurrency
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import create_item
-from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+from erpnext.stock.doctype.material_request.mapper import make_purchase_order
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
-from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as create_purchase_invoice_from_receipt,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
@@ -80,7 +80,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
pi.delete()
def test_update_received_qty_in_material_request(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
"""
Test if the received_qty in Material Request is updated correctly when
@@ -346,7 +346,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
def test_purchase_invoice_with_exchange_rate_difference(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as create_purchase_invoice,
)
@@ -388,7 +388,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
)
def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as create_purchase_invoice,
)
@@ -2162,7 +2162,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
create_pr_against_po,
create_purchase_order,
)
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_pi_from_pr,
)
@@ -2748,10 +2748,10 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
def test_invoice_against_returned_pr(self):
from erpnext.stock.doctype.item.test_item import make_item
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_purchase_invoice_from_pr,
)
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return_against_rejected_warehouse,
)
@@ -2892,7 +2892,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertEqual(invoice.grand_total, 300)
def test_pr_pi_over_billing(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_purchase_invoice_from_pr,
)
@@ -2940,7 +2940,7 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
self.assertEqual(pi.discount_amount, discount_amount)
def test_returned_item_purchase_receipt(self):
- from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
+ from erpnext.accounts.doctype.purchase_invoice.mapper import (
make_purchase_receipt as make_purchase_receipt_from_pi,
)
diff --git a/erpnext/accounts/doctype/sales_invoice/mapper.py b/erpnext/accounts/doctype/sales_invoice/mapper.py
new file mode 100644
index 00000000000..8247f55a0f6
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/mapper.py
@@ -0,0 +1,615 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.contacts.doctype.address.address import get_address_display
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.model.utils import get_fetch_values
+from frappe.utils import flt, get_link_to_form, getdate
+
+from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, _get_party_details
+
+
+@frappe.whitelist()
+def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None):
+ doclist = get_mapped_doc(
+ "Sales Invoice",
+ source_name,
+ {
+ "Sales Invoice": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}},
+ "Sales Invoice Item": {
+ "doctype": "Maintenance Schedule Item",
+ },
+ },
+ target_doc,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_delivery_note(source_name: str, target_doc: Document | None = None):
+ def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+ target.run_method("set_po_nos")
+ target.run_method("calculate_taxes_and_totals")
+
+ def update_item(source_doc, target_doc, source_parent):
+ target_doc.qty = flt(source_doc.qty) - flt(source_doc.delivered_qty)
+ target_doc.stock_qty = target_doc.qty * flt(source_doc.conversion_factor)
+
+ target_doc.base_amount = target_doc.qty * flt(source_doc.base_rate)
+ target_doc.amount = target_doc.qty * flt(source_doc.rate)
+
+ doclist = get_mapped_doc(
+ "Sales Invoice",
+ source_name,
+ {
+ "Sales Invoice": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
+ "Sales Invoice Item": {
+ "doctype": "Delivery Note Item",
+ "field_map": {
+ "name": "si_detail",
+ "parent": "against_sales_invoice",
+ "serial_no": "serial_no",
+ "sales_order": "against_sales_order",
+ "so_detail": "so_detail",
+ "cost_center": "cost_center",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: doc.delivered_by_supplier != 1
+ and not doc.scio_detail
+ and not doc.dn_detail
+ and doc.qty - doc.delivered_qty > 0,
+ },
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
+ "Sales Team": {
+ "doctype": "Sales Team",
+ "field_map": {"incentives": "incentives"},
+ "add_if_empty": True,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_sales_return(source_name: str, target_doc: Document | None = None):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Sales Invoice", source_name, target_doc)
+
+
+def get_inter_company_details(doc, doctype):
+ if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]:
+ parties = frappe.db.get_all(
+ "Supplier",
+ fields=["name"],
+ filters={"disabled": 0, "is_internal_supplier": 1, "represents_company": doc.company},
+ )
+ company = frappe.get_cached_value("Customer", doc.customer, "represents_company")
+
+ if not parties:
+ frappe.throw(
+ _("No Supplier found for Inter Company Transactions which represents company {0}").format(
+ frappe.bold(doc.company)
+ )
+ )
+
+ party = get_internal_party(parties, "Supplier", doc)
+ else:
+ parties = frappe.db.get_all(
+ "Customer",
+ fields=["name"],
+ filters={"disabled": 0, "is_internal_customer": 1, "represents_company": doc.company},
+ )
+ company = frappe.get_cached_value("Supplier", doc.supplier, "represents_company")
+
+ if not parties:
+ frappe.throw(
+ _("No Customer found for Inter Company Transactions which represents company {0}").format(
+ frappe.bold(doc.company)
+ )
+ )
+
+ party = get_internal_party(parties, "Customer", doc)
+
+ return {"party": party, "company": company}
+
+
+def get_internal_party(parties, link_doctype, doc):
+ if len(parties) == 1:
+ party = parties[0].name
+ else:
+ # If more than one Internal Supplier/Customer, get supplier/customer on basis of address
+ if doc.get("company_address") or doc.get("shipping_address"):
+ party = frappe.db.get_value(
+ "Dynamic Link",
+ {
+ "parent": doc.get("company_address") or doc.get("shipping_address"),
+ "parenttype": "Address",
+ "link_doctype": link_doctype,
+ },
+ "link_name",
+ )
+
+ if not party:
+ party = parties[0].name
+ else:
+ party = parties[0].name
+
+ return party
+
+
+def validate_inter_company_transaction(doc, doctype):
+ details = get_inter_company_details(doc, doctype)
+ price_list = (
+ doc.selling_price_list
+ if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]
+ else doc.buying_price_list
+ )
+ valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1})
+ if not valid_price_list and not doc.is_internal_transfer():
+ frappe.throw(_("Selected Price List should have buying and selling fields checked."))
+
+ party = details.get("party")
+ if not party:
+ partytype = "Supplier" if doctype in ["Sales Invoice", "Sales Order"] else "Customer"
+ frappe.throw(_("No {0} found for Inter Company Transactions.").format(partytype))
+
+ company = details.get("company")
+ default_currency = frappe.get_cached_value("Company", company, "default_currency")
+ if default_currency != doc.currency:
+ frappe.throw(
+ _("Company currencies of both the companies should match for Inter Company Transactions.")
+ )
+
+ return
+
+
+@frappe.whitelist()
+def make_inter_company_purchase_invoice(source_name: str, target_doc: Document | None = None):
+ return make_inter_company_transaction("Sales Invoice", source_name, target_doc)
+
+
+def make_inter_company_transaction(doctype, source_name, target_doc=None):
+ if doctype in ["Sales Invoice", "Sales Order"]:
+ source_doc = frappe.get_doc(doctype, source_name)
+ target_doctype = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
+ target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item"
+ source_document_warehouse_field = "target_warehouse"
+ target_document_warehouse_field = "from_warehouse"
+ received_items = get_received_items(source_name, target_doctype, target_detail_field)
+ else:
+ source_doc = frappe.get_doc(doctype, source_name)
+ target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
+ source_document_warehouse_field = "from_warehouse"
+ target_document_warehouse_field = "target_warehouse"
+ received_items = {}
+
+ validate_inter_company_transaction(source_doc, doctype)
+ details = get_inter_company_details(source_doc, doctype)
+
+ def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+ set_purchase_references(target)
+
+ def update_details(source_doc, target_doc, source_parent):
+ def _validate_address_link(address, link_doctype, link_name):
+ return frappe.db.get_value(
+ "Dynamic Link",
+ {
+ "parent": address,
+ "parenttype": "Address",
+ "link_doctype": link_doctype,
+ "link_name": link_name,
+ },
+ "parent",
+ )
+
+ target_doc.inter_company_invoice_reference = source_doc.name
+ if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
+ currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
+ target_doc.company = details.get("company")
+ target_doc.supplier = details.get("party")
+ target_doc.is_internal_supplier = 1
+ target_doc.ignore_pricing_rule = 1
+ target_doc.buying_price_list = source_doc.selling_price_list
+
+ # Invert Addresses
+ if source_doc.company_address and _validate_address_link(
+ source_doc.company_address, "Supplier", details.get("party")
+ ):
+ update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
+ if source_doc.dispatch_address_name and _validate_address_link(
+ source_doc.dispatch_address_name, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc,
+ "dispatch_address",
+ "dispatch_address_display",
+ source_doc.dispatch_address_name,
+ )
+ if source_doc.shipping_address_name and _validate_address_link(
+ source_doc.shipping_address_name, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc,
+ "shipping_address",
+ "shipping_address_display",
+ source_doc.shipping_address_name,
+ )
+ if source_doc.customer_address and _validate_address_link(
+ source_doc.customer_address, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc, "billing_address", "billing_address_display", source_doc.customer_address
+ )
+
+ if currency:
+ target_doc.currency = currency
+
+ update_taxes(
+ target_doc,
+ party=target_doc.supplier,
+ party_type="Supplier",
+ company=target_doc.company,
+ doctype=target_doc.doctype,
+ party_address=target_doc.supplier_address,
+ company_address=target_doc.shipping_address,
+ )
+
+ else:
+ currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
+ target_doc.company = details.get("company")
+ target_doc.customer = details.get("party")
+ target_doc.selling_price_list = source_doc.buying_price_list
+
+ if source_doc.supplier_address and _validate_address_link(
+ source_doc.supplier_address, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc, "company_address", "company_address_display", source_doc.supplier_address
+ )
+ if source_doc.shipping_address and _validate_address_link(
+ source_doc.shipping_address, "Customer", details.get("party")
+ ):
+ update_address(
+ target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
+ )
+ if source_doc.shipping_address and _validate_address_link(
+ source_doc.shipping_address, "Customer", details.get("party")
+ ):
+ update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
+
+ if currency:
+ target_doc.currency = currency
+
+ update_taxes(
+ target_doc,
+ party=target_doc.customer,
+ party_type="Customer",
+ company=target_doc.company,
+ doctype=target_doc.doctype,
+ party_address=target_doc.customer_address,
+ company_address=target_doc.company_address,
+ shipping_address_name=target_doc.shipping_address_name,
+ )
+
+ def update_item(source, target, source_parent):
+ target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
+ if source.doctype == "Purchase Order Item" and target.doctype == "Sales Order Item":
+ target.purchase_order = source.parent
+ target.purchase_order_item = source.name
+ target.material_request = source.material_request
+ target.material_request_item = source.material_request_item
+
+ if (
+ source.get("purchase_order")
+ and source.get("purchase_order_item")
+ and target.doctype == "Purchase Invoice Item"
+ ):
+ target.purchase_order = source.purchase_order
+ target.po_detail = source.purchase_order_item
+
+ if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
+ target.use_serial_batch_fields = 1
+
+ item_field_map = {
+ "doctype": target_doctype + " Item",
+ "field_no_map": ["income_account", "expense_account", "cost_center", "warehouse"],
+ "field_map": {
+ "rate": "rate",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: doc.qty > 0,
+ }
+
+ if doctype in ["Sales Invoice", "Sales Order"]:
+ item_field_map["field_map"].update(
+ {
+ "name": target_detail_field,
+ }
+ )
+
+ if source_doc.get("update_stock"):
+ item_field_map["field_map"].update(
+ {
+ source_document_warehouse_field: target_document_warehouse_field,
+ "batch_no": "batch_no",
+ "serial_no": "serial_no",
+ }
+ )
+ elif target_doctype == "Sales Order":
+ item_field_map["field_map"].update(
+ {
+ source_document_warehouse_field: "warehouse",
+ }
+ )
+
+ doclist = get_mapped_doc(
+ doctype,
+ source_name,
+ {
+ doctype: {
+ "doctype": target_doctype,
+ "postprocess": update_details,
+ "set_target_warehouse": "set_from_warehouse",
+ "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
+ },
+ doctype + " Item": item_field_map,
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+def get_received_items(reference_name, doctype, reference_fieldname):
+ reference_field = "inter_company_invoice_reference"
+ if doctype == "Purchase Order":
+ reference_field = "inter_company_order_reference"
+
+ filters = {
+ reference_field: reference_name,
+ "docstatus": 1,
+ }
+
+ target_doctypes = frappe.get_all(
+ doctype,
+ filters=filters,
+ as_list=True,
+ )
+
+ if target_doctypes:
+ target_doctypes = list(target_doctypes[0])
+
+ received_items_map = frappe._dict(
+ frappe.get_all(
+ doctype + " Item",
+ filters={"parent": ("in", target_doctypes)},
+ fields=[reference_fieldname, "qty"],
+ as_list=1,
+ )
+ )
+
+ return received_items_map
+
+
+def set_purchase_references(doc):
+ # add internal PO or PR links if any
+
+ if doc.is_internal_transfer():
+ if doc.doctype == "Purchase Receipt":
+ so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference)
+
+ if so_item_map:
+ pd_item_map, parent_child_map, warehouse_map = get_pd_details(
+ "Purchase Order Item", so_item_map, "sales_order_item"
+ )
+
+ update_pr_items(doc, so_item_map, pd_item_map, parent_child_map, warehouse_map)
+
+ elif doc.doctype == "Purchase Invoice":
+ dn_item_map, so_item_map = get_sales_invoice_details(doc.inter_company_invoice_reference)
+ # First check for Purchase receipt
+ if list(dn_item_map.values()):
+ pd_item_map, parent_child_map, warehouse_map = get_pd_details(
+ "Purchase Receipt Item", dn_item_map, "delivery_note_item"
+ )
+
+ update_pi_items(
+ doc,
+ "pr_detail",
+ "purchase_receipt",
+ dn_item_map,
+ pd_item_map,
+ parent_child_map,
+ warehouse_map,
+ )
+
+
+def update_pi_items(
+ doc,
+ detail_field,
+ parent_field,
+ sales_item_map,
+ purchase_item_map,
+ parent_child_map,
+ warehouse_map,
+):
+ for item in doc.get("items"):
+ item.set(detail_field, purchase_item_map.get(sales_item_map.get(item.sales_invoice_item)))
+ item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item)))
+ if doc.update_stock:
+ item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item))
+ if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
+ item.warehouse = frappe.db.get_value(
+ "Purchase Order Item", item.purchase_order_item, "warehouse"
+ )
+
+
+def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map):
+ for item in doc.get("items"):
+ item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item))
+ if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
+ item.warehouse = frappe.db.get_value("Purchase Order Item", item.purchase_order_item, "warehouse")
+
+
+def get_delivery_note_details(internal_reference):
+ si_item_details = frappe.get_all(
+ "Delivery Note Item", fields=["name", "so_detail"], filters={"parent": internal_reference}
+ )
+
+ return {d.name: d.so_detail for d in si_item_details if d.so_detail}
+
+
+def get_sales_invoice_details(internal_reference):
+ dn_item_map = {}
+ so_item_map = {}
+
+ si_item_details = frappe.get_all(
+ "Sales Invoice Item",
+ fields=["name", "so_detail", "dn_detail"],
+ filters={"parent": internal_reference},
+ )
+
+ for d in si_item_details:
+ if d.dn_detail:
+ dn_item_map.setdefault(d.name, d.dn_detail)
+ if d.so_detail:
+ so_item_map.setdefault(d.name, d.so_detail)
+
+ return dn_item_map, so_item_map
+
+
+def get_pd_details(doctype, sd_detail_map, sd_detail_field):
+ pd_item_map = {}
+ accepted_warehouse_map = {}
+ parent_child_map = {}
+
+ pd_item_details = frappe.get_all(
+ doctype,
+ fields=[sd_detail_field, "name", "warehouse", "parent"],
+ filters={sd_detail_field: ("in", list(sd_detail_map.values()))},
+ )
+
+ for d in pd_item_details:
+ pd_item_map.setdefault(d.get(sd_detail_field), d.name)
+ parent_child_map.setdefault(d.get(sd_detail_field), d.parent)
+ accepted_warehouse_map.setdefault(d.get(sd_detail_field), d.warehouse)
+
+ return pd_item_map, parent_child_map, accepted_warehouse_map
+
+
+def update_taxes(
+ doc,
+ party=None,
+ party_type=None,
+ company=None,
+ doctype=None,
+ party_address=None,
+ company_address=None,
+ shipping_address_name=None,
+ master_doctype=None,
+):
+ # Update Party Details
+ party_details = _get_party_details(
+ party=party,
+ party_type=party_type,
+ company=company,
+ doctype=doctype,
+ party_address=party_address,
+ company_address=company_address,
+ shipping_address=shipping_address_name,
+ )
+
+ # Update taxes and charges if any
+ doc.taxes_and_charges = party_details.get("taxes_and_charges")
+ doc.set("taxes", party_details.get("taxes"))
+
+
+def update_address(doc, address_field, address_display_field, address_name):
+ doc.set(address_field, address_name)
+ fetch_values = get_fetch_values(doc.doctype, address_field, address_name)
+
+ for key, value in fetch_values.items():
+ doc.set(key, value)
+
+ doc.set(address_display_field, get_address_display(doc.get(address_field)))
+
+
+@frappe.whitelist()
+def create_invoice_discounting(source_name: str, target_doc: str | Document | None = None):
+ invoice = frappe.get_doc("Sales Invoice", source_name)
+ invoice_discounting = frappe.new_doc("Invoice Discounting")
+ invoice_discounting.company = invoice.company
+ invoice_discounting.append(
+ "invoices",
+ {
+ "sales_invoice": source_name,
+ "customer": invoice.customer,
+ "posting_date": invoice.posting_date,
+ "outstanding_amount": invoice.outstanding_amount,
+ },
+ )
+
+ return invoice_discounting
+
+
+@frappe.whitelist()
+def create_dunning(
+ source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False
+):
+ def postprocess_dunning(source, target):
+ from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
+
+ dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
+ if dunning_type:
+ dunning_type = frappe.get_doc("Dunning Type", dunning_type)
+ target.dunning_type = dunning_type.name
+ target.rate_of_interest = dunning_type.rate_of_interest
+ target.dunning_fee = dunning_type.dunning_fee
+ target.income_account = dunning_type.income_account
+ target.cost_center = dunning_type.cost_center
+ letter_text = get_dunning_letter_text(
+ dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
+ )
+
+ if letter_text:
+ target.body_text = letter_text.get("body_text")
+ target.closing_text = letter_text.get("closing_text")
+ target.language = letter_text.get("language")
+
+ # update outstanding from doc
+ if source.payment_schedule and len(source.payment_schedule) == 1:
+ for row in target.overdue_payments:
+ if row.payment_schedule == source.payment_schedule[0].name:
+ row.outstanding = source.get("outstanding_amount")
+
+ target.validate()
+
+ return get_mapped_doc(
+ from_doctype="Sales Invoice",
+ from_docname=source_name,
+ target_doc=target_doc,
+ table_maps={
+ "Sales Invoice": {
+ "doctype": "Dunning",
+ "field_map": {"customer_address": "customer_address", "parent": "sales_invoice"},
+ },
+ "Payment Schedule": {
+ "doctype": "Overdue Payment",
+ "field_map": {"name": "payment_schedule", "parent": "sales_invoice"},
+ "condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(),
+ },
+ },
+ postprocess=postprocess_dunning,
+ ignore_permissions=ignore_permissions,
+ )
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index 097d4f1ad03..42c51dc2a0b 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -197,21 +197,21 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
make_invoice_discounting() {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_invoice_discounting",
+ method: "erpnext.accounts.doctype.sales_invoice.mapper.create_invoice_discounting",
frm: this.frm,
});
}
make_dunning() {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.create_dunning",
+ method: "erpnext.accounts.doctype.sales_invoice.mapper.create_dunning",
frm: this.frm,
});
}
make_maintenance_schedule() {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",
+ method: "erpnext.accounts.doctype.sales_invoice.mapper.make_maintenance_schedule",
frm: this.frm,
});
}
@@ -361,7 +361,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
__("Sales Order"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_sales_invoice",
source_doctype: "Sales Order",
target: me.frm,
setters: {
@@ -383,7 +383,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
__("Quotation"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.quotation.quotation.make_sales_invoice",
+ method: "erpnext.selling.doctype.quotation.mapper.make_sales_invoice",
source_doctype: "Quotation",
target: me.frm,
setters: [
@@ -421,7 +421,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
});
}
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
source_doctype: "Delivery Note",
target: me.frm,
date_field: "posting_date",
@@ -501,7 +501,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
make_inter_company_invoice() {
let me = this;
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_inter_company_purchase_invoice",
+ method: "erpnext.accounts.doctype.sales_invoice.mapper.make_inter_company_purchase_invoice",
frm: me.frm,
});
}
@@ -579,7 +579,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
make_sales_return() {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return",
+ method: "erpnext.accounts.doctype.sales_invoice.mapper.make_sales_return",
frm: this.frm,
});
}
@@ -712,7 +712,7 @@ extend_cscript(cur_frm.cscript, new erpnext.accounts.SalesInvoiceController({ fr
cur_frm.cscript["Make Delivery Note"] = function () {
frappe.model.open_mapped_doc({
- method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_delivery_note",
+ method: "erpnext.accounts.doctype.sales_invoice.mapper.make_delivery_note",
frm: cur_frm,
});
};
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 7bee1bf8ea7..600002fcfe4 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -5,20 +5,13 @@
import frappe
import frappe.utils
from frappe import _, msgprint, throw
-from frappe.contacts.doctype.address.address import get_address_display
-from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
-from frappe.model.utils import get_fetch_values
from frappe.query_builder import Case
-from frappe.utils import add_days, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
+from frappe.utils import cint, flt, formatdate, get_link_to_form
from frappe.utils.data import comma_and
import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date
-from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
- get_loyalty_program_details_with_points,
- validate_loyalty_points,
-)
+from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points
from erpnext.accounts.doctype.pricing_rule.utils import (
update_coupon_code_count,
validate_coupon_code,
@@ -28,39 +21,42 @@ from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger
validate_docs_for_voucher_types,
)
from erpnext.accounts.doctype.tax_withholding_entry.tax_withholding_entry import SalesTaxWithholding
-from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
-from erpnext.accounts.party import (
- CROSS_PARTY_FIELD_NO_MAP,
- _get_party_details,
- get_due_date,
- get_party_account,
-)
-from erpnext.accounts.utils import (
- get_account_currency,
- update_voucher_outstanding,
-)
-from erpnext.assets.doctype.asset.asset import split_asset
-from erpnext.assets.doctype.asset.depreciation import (
- depreciate_asset,
- get_gl_entries_on_asset_disposal,
- get_gl_entries_on_asset_regain,
- reset_depreciation_schedule,
- reverse_depreciation_entry_made_on_disposal,
-)
-from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
+from erpnext.accounts.party import get_due_date, get_party_account
+from erpnext.accounts.utils import update_voucher_outstanding
from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController
-from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
from erpnext.setup.doctype.company.company import update_company_current_month_sales
from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so
+from .services.fixed_assets import FixedAssetService
+from .services.inter_company import (
+ unlink_inter_company_doc,
+ update_linked_doc,
+ validate_inter_company_party,
+)
+from .services.loyalty import LoyaltyService
+from .services.pos import (
+ PartialPaymentValidationError,
+ POSService,
+ get_all_mode_of_payments,
+ get_mode_of_payment_info,
+ get_mode_of_payments_info,
+ update_multi_mode_option,
+)
+from .services.pos import (
+ get_bank_cash_account as _get_bank_cash_account,
+)
+from .services.status import (
+ StatusService,
+ get_discounting_status,
+ get_total_in_party_account_currency,
+ is_overdue,
+)
+from .services.timesheet_billing import TimesheetBillingService
+
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
-class PartialPaymentValidationError(frappe.ValidationError):
- pass
-
-
class SalesInvoice(SellingController):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
@@ -278,21 +274,7 @@ class SalesInvoice(SellingController):
def set_indicator(self):
"""Set indicator for portal"""
- if self.outstanding_amount < 0:
- self.indicator_title = _("Credit Note Issued")
- self.indicator_color = "gray"
- elif self.outstanding_amount > 0 and getdate(self.due_date) >= getdate(nowdate()):
- self.indicator_color = "orange"
- self.indicator_title = _("Unpaid")
- elif self.outstanding_amount > 0 and getdate(self.due_date) < getdate(nowdate()):
- self.indicator_color = "red"
- self.indicator_title = _("Overdue")
- elif cint(self.is_return) == 1:
- self.indicator_title = _("Return")
- self.indicator_color = "gray"
- else:
- self.indicator_color = "green"
- self.indicator_title = _("Paid")
+ StatusService(self).set_indicator()
def onload(self):
super().onload()
@@ -314,15 +296,15 @@ class SalesInvoice(SellingController):
SalesTaxWithholding(self).on_validate()
self.validate_proj_cust()
- self.validate_pos_return()
+ POSService(self).validate_pos_return()
self.validate_with_previous_doc()
self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_uom_is_integer("uom", "qty")
self.check_sales_order_on_hold_or_close("sales_order")
self.validate_debit_to_acc()
self.clear_unallocated_advances("Sales Invoice Advance", "advances")
- self.validate_fixed_asset()
- self.set_income_account_for_fixed_assets()
+ FixedAssetService(self).validate_fixed_asset()
+ FixedAssetService(self).set_income_account_for_fixed_assets()
self.validate_item_cost_centers()
self.check_conversion_rate()
self.validate_accounts()
@@ -331,7 +313,6 @@ class SalesInvoice(SellingController):
self.doctype, self.customer, self.company, self.inter_company_invoice_reference
)
- # Validating coupon code
if self.coupon_code:
validate_coupon_code(self.coupon_code)
@@ -339,8 +320,8 @@ class SalesInvoice(SellingController):
self.validate_pos()
if cint(self.is_created_using_pos):
- self.validate_created_using_pos()
- self.validate_full_payment()
+ POSService(self).validate_created_using_pos()
+ POSService(self).validate_full_payment()
self.validate_dropship_item()
@@ -350,10 +331,7 @@ class SalesInvoice(SellingController):
self.validate_delivery_note()
- is_deferred_invoice = any(d.get("enable_deferred_revenue") for d in self.get("items"))
-
- # validate service stop date to lie in between start and end date
- if is_deferred_invoice:
+ if any(d.get("enable_deferred_revenue") for d in self.get("items")):
validate_service_stop_date(self)
if not self.is_opening:
@@ -365,9 +343,11 @@ class SalesInvoice(SellingController):
frappe.throw(_("Direct return is not allowed for Timesheet."))
if not self.is_return:
- self.validate_time_sheets_are_submitted()
+ TimesheetBillingService(self).validate_time_sheets_are_submitted()
- self.validate_multiple_billing("Delivery Note", "dn_detail", "amount")
+ from erpnext.accounts.services.billing_validation import BillingValidationService
+
+ BillingValidationService(self).validate_multiple_billing("Delivery Note", "dn_detail", "amount")
if self.is_return and self.return_against:
for row in self.timesheets:
@@ -377,20 +357,19 @@ class SalesInvoice(SellingController):
row.billing_amount = -abs(row.billing_amount)
self.update_packing_list()
- self.set_billing_hours_and_amount()
- self.update_timesheet_billing_for_project()
+ TimesheetBillingService(self).set_billing_hours_and_amount()
+ TimesheetBillingService(self).update_timesheet_billing_for_project()
self.set_status()
if self.is_pos and not self.is_return:
- self.verify_payment_amount_is_positive()
+ POSService(self).verify_payment_amount_is_positive()
- # validate amount in mode of payments for returned invoices for pos must be negative
if self.is_pos and self.is_return:
- self.verify_payment_amount_is_negative()
+ POSService(self).verify_payment_amount_is_negative()
if self.redeem_loyalty_points and self.loyalty_points and not self.is_consolidated:
validate_loyalty_points(self, self.loyalty_points)
- self.allow_write_off_only_on_pos()
+ POSService(self).allow_write_off_only_on_pos()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.validate_subcontracted_sales_order()
self.validate_scio_self_rm_qty()
@@ -407,36 +386,6 @@ class SalesInvoice(SellingController):
validate_docs_for_voucher_types(["Sales Invoice"])
validate_docs_for_deferred_accounting([self.name], [])
- def validate_fixed_asset(self):
- if self.doctype != "Sales Invoice":
- return
-
- for d in self.get("items"):
- if d.is_fixed_asset:
- if d.asset:
- if not self.is_return:
- asset_status = frappe.db.get_value("Asset", d.asset, "status")
- if self.update_stock:
- frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
-
- elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
- frappe.throw(
- _("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
- d.idx, d.asset, asset_status
- )
- )
- elif asset_status == "Sold" and not self.is_return:
- frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
- elif not self.return_against:
- frappe.throw(
- _("Row #{0}: Return Against is required for returning asset").format(d.idx)
- )
- else:
- frappe.throw(
- _("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
- title=_("Missing Asset"),
- )
-
def validate_item_cost_centers(self):
for item in self.items:
item.validate_cost_center(self.company)
@@ -446,14 +395,14 @@ class SalesInvoice(SellingController):
validate_account_head(item.idx, item.income_account, self.company, _("Income"))
def before_save(self):
- self.set_account_for_mode_of_payment()
- self.set_paid_amount()
+ POSService(self).set_account_for_mode_of_payment()
+ POSService(self).set_paid_amount()
def before_submit(self):
self.add_remarks()
def on_submit(self):
- self.validate_pos_paid_amount()
+ POSService(self).validate_pos_paid_amount()
if not self.auto_repeat:
frappe.get_cached_doc("Authorization Control").validate_approving_authority(
@@ -474,8 +423,6 @@ class SalesInvoice(SellingController):
self.update_billing_status_in_dn()
self.clear_unallocated_mode_of_payments()
- # Updating stock ledger should always be called after updating prevdoc status,
- # because updating reserved qty in bin depends upon updated delivered qty in SO
if self.update_stock == 1:
for table_name in ["items", "packed_items"]:
if not self.get(table_name):
@@ -488,11 +435,9 @@ class SalesInvoice(SellingController):
self.update_stock_reservation_entries()
self.update_stock_ledger()
- self.split_asset_based_on_sale_qty()
+ FixedAssetService(self).split_asset_based_on_sale_qty()
+ FixedAssetService(self).process_asset_depreciation()
- self.process_asset_depreciation()
-
- # this sequence because outstanding may get -ve
self.make_gl_entries()
if self.update_stock == 1:
@@ -506,7 +451,9 @@ class SalesInvoice(SellingController):
if cint(self.is_pos) != 1 and not self.is_return:
self.update_against_document_in_jv()
- self.update_time_sheet(None if (self.is_return and self.return_against) else self.name)
+ TimesheetBillingService(self).update_time_sheet(
+ None if (self.is_return and self.return_against) else self.name
+ )
if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
update_company_current_month_sales(self.company)
@@ -516,77 +463,31 @@ class SalesInvoice(SellingController):
if self.coupon_code:
update_coupon_code_count(self.coupon_code, "used")
- # create the loyalty point ledger entry if the customer is enrolled in any loyalty program
if (
not self.is_return
and not self.is_consolidated
and self.loyalty_program
and not self.dont_create_loyalty_points
):
- self.make_loyalty_point_entry()
+ LoyaltyService(self).make_loyalty_point_entry()
elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program:
against_si_doc = frappe.get_doc("Sales Invoice", self.return_against)
- against_si_doc.delete_loyalty_point_entry()
- against_si_doc.make_loyalty_point_entry()
+ LoyaltyService(against_si_doc).delete_loyalty_point_entry()
+ LoyaltyService(against_si_doc).make_loyalty_point_entry()
if self.redeem_loyalty_points and not self.is_consolidated and self.loyalty_points:
- self.apply_loyalty_points()
+ LoyaltyService(self).apply_loyalty_points()
self.process_common_party_accounting()
self.update_billed_qty_in_scio()
- def validate_pos_return(self):
- if self.is_consolidated:
- # pos return is already validated in pos invoice
- return
-
- if self.is_pos and self.is_return:
- total_amount_in_payments = 0
- for payment in self.payments:
- total_amount_in_payments += payment.amount
- invoice_total = self.rounded_total or self.grand_total
- if total_amount_in_payments < invoice_total:
- frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
-
- def validate_pos_paid_amount(self):
- if len(self.payments) == 0 and self.is_pos and flt(self.grand_total) > 0:
- frappe.throw(_("At least one mode of payment is required for POS invoice."))
-
- def check_if_consolidated_invoice(self):
- # since POS Invoice extends Sales Invoice, we explicitly check if doctype is Sales Invoice
- if self.doctype == "Sales Invoice" and self.is_consolidated:
- invoice_or_credit_note = "consolidated_credit_note" if self.is_return else "consolidated_invoice"
- pos_closing_entry = frappe.get_all(
- "POS Invoice Merge Log",
- filters={invoice_or_credit_note: self.name},
- pluck="pos_closing_entry",
- )
- if pos_closing_entry and pos_closing_entry[0]:
- msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
- frappe.bold(_("Consolidated Sales Invoice")),
- get_link_to_form("POS Closing Entry", pos_closing_entry[0]),
- )
- frappe.throw(msg, title=_("Not Allowed"))
-
- def check_if_created_using_pos_and_pos_closing_entry_generated(self):
- if self.doctype == "Sales Invoice" and self.is_created_using_pos and self.pos_closing_entry:
- pos_closing_entry_docstatus = frappe.db.get_value(
- "POS Closing Entry", self.pos_closing_entry, "docstatus"
- )
- if pos_closing_entry_docstatus == 1:
- frappe.throw(
- msg=_("To cancel this Sales Invoice you need to cancel the POS Closing Entry {}.").format(
- get_link_to_form("POS Closing Entry", self.pos_closing_entry)
- ),
- title=_("Not Allowed"),
- )
-
def before_cancel(self):
- # check if generated via POS and already included in POS Closing Entry
- self.check_if_created_using_pos_and_pos_closing_entry_generated()
- self.check_if_consolidated_invoice()
+ POSService(self).check_if_created_using_pos_and_pos_closing_entry_generated()
+ POSService(self).check_if_consolidated_invoice()
super().before_cancel()
- self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None)
+ TimesheetBillingService(self).update_time_sheet(
+ self.return_against if (self.is_return and self.return_against) else None
+ )
def on_cancel(self):
check_if_return_invoice_linked_with_payment_entry(self)
@@ -607,13 +508,11 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
- # Updating stock ledger should always be called after updating prevdoc status,
- # because updating reserved qty in bin depends upon updated delivered qty in SO
SalesTaxWithholding(self).on_cancel()
if self.update_stock == 1:
self.update_stock_ledger()
- self.process_asset_depreciation()
+ FixedAssetService(self).process_asset_depreciation()
self.make_gl_entries_on_cancel()
@@ -629,16 +528,17 @@ class SalesInvoice(SellingController):
if frappe.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction":
update_company_current_month_sales(self.company)
self.update_project()
+
if not self.is_return and not self.is_consolidated and self.loyalty_program:
- self.delete_loyalty_point_entry()
+ LoyaltyService(self).delete_loyalty_point_entry()
elif self.is_return and self.return_against and not self.is_consolidated and self.loyalty_program:
against_si_doc = frappe.get_doc("Sales Invoice", self.return_against)
- against_si_doc.delete_loyalty_point_entry()
- against_si_doc.make_loyalty_point_entry()
+ LoyaltyService(against_si_doc).delete_loyalty_point_entry()
+ LoyaltyService(against_si_doc).make_loyalty_point_entry()
unlink_inter_company_doc(self.doctype, self.name, self.inter_company_invoice_reference)
- self.unlink_sales_invoice_from_timesheets()
+ TimesheetBillingService(self).unlink_sales_invoice_from_timesheets()
self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
@@ -663,7 +563,7 @@ class SalesInvoice(SellingController):
and self.is_created_using_pos
and not self.pos_closing_entry
):
- self.cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
+ POSService(self).cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode()
self.update_billed_qty_in_scio()
@@ -731,25 +631,9 @@ class SalesInvoice(SellingController):
if validate_against_credit_limit:
check_credit_limit(self.customer, self.company, bypass_credit_limit_check_at_sales_order)
- def unlink_sales_invoice_from_timesheets(self):
- for row in self.timesheets:
- timesheet = frappe.get_doc("Timesheet", row.time_sheet)
- timesheet.unlink_sales_invoice(self.name)
- timesheet.flags.ignore_validate_update_after_submit = True
- timesheet.db_update_all()
-
- def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self):
- pos_invoices = frappe.get_all(
- "POS Invoice", filters={"consolidated_invoice": self.name}, pluck="name"
- )
- if pos_invoices:
- for pos_invoice in pos_invoices:
- pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
- pos_invoice_doc.cancel()
-
@frappe.whitelist()
def set_missing_values(self, for_validate: bool = False):
- pos = self.set_pos_fields(for_validate)
+ pos = POSService(self).set_pos_fields(for_validate)
if not self.debit_to:
self.debit_to = get_party_account("Customer", self.customer, self.company)
@@ -783,220 +667,33 @@ class SalesInvoice(SellingController):
"set_default_payment": pos.get("set_grand_total_to_default_mop", 1),
}
+ # Called by POS Invoice
+ def set_pos_fields(self, for_validate=False):
+ return POSService(self).set_pos_fields(for_validate)
+
@frappe.whitelist()
def reset_mode_of_payments(self):
- if self.pos_profile:
- pos_profile = frappe.get_cached_doc("POS Profile", self.pos_profile)
- update_multi_mode_option(self, pos_profile)
- self.paid_amount = 0
-
- def update_time_sheet(self, sales_invoice):
- for d in self.timesheets:
- if d.time_sheet:
- timesheet = frappe.get_doc("Timesheet", d.time_sheet)
- self.update_time_sheet_detail(timesheet, d, sales_invoice)
- timesheet.calculate_total_amounts()
- timesheet.calculate_percentage_billed()
- timesheet.flags.ignore_validate_update_after_submit = True
- timesheet.set_status()
- timesheet.db_update_all()
-
- def update_billed_qty_in_scio(self):
- if self.is_return:
- return
-
- table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
- data = frappe._dict(
- {
- item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty
- for item in self.items
- if item.scio_detail
- }
- )
-
- if data:
- case_expr = Case()
- for name, qty in data.items():
- case_expr = case_expr.when(table.name == name, table.billed_qty + qty)
- frappe.qb.update(table).set(table.billed_qty, case_expr).where(
- (table.name.isin(list(data.keys()))) & (table.docstatus == 1)
- ).run()
-
- def update_time_sheet_detail(self, timesheet, args, sales_invoice):
- for data in timesheet.time_logs:
- if (
- (self.project and args.timesheet_detail == data.name)
- or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name)
- or (
- not sales_invoice
- and data.sales_invoice == self.name
- and args.timesheet_detail == data.name
- )
- or (
- self.is_return
- and self.return_against
- and data.sales_invoice
- and data.sales_invoice == self.return_against
- and not sales_invoice
- and args.timesheet_detail == data.name
- )
- ):
- data.sales_invoice = sales_invoice
-
- def on_update_after_submit(self):
- fields_to_check = [
- "additional_discount_account",
- "cash_bank_account",
- "account_for_change_amount",
- "write_off_account",
- "loyalty_redemption_account",
- "unrealized_profit_loss_account",
- "is_opening",
- ]
- child_tables = {
- "items": ("income_account", "expense_account", "discount_account"),
- "taxes": ("account_head",),
- }
- self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
- if self.needs_repost:
- self.validate_for_repost()
- self.repost_accounting_entries()
-
- def set_paid_amount(self):
- paid_amount = 0.0
- base_paid_amount = 0.0
- for data in self.payments:
- data.base_amount = flt(data.amount * self.conversion_rate, self.precision("base_paid_amount"))
- paid_amount += data.amount
- base_paid_amount += data.base_amount
-
- self.paid_amount = paid_amount
- self.base_paid_amount = base_paid_amount
+ POSService(self).reset_mode_of_payments()
@frappe.whitelist()
def set_account_for_mode_of_payment(self):
- for payment in self.payments:
- payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account")
+ POSService(self).set_account_for_mode_of_payment()
- def validate_time_sheets_are_submitted(self):
- # Note: This validation is skipped for return invoices
- # to allow returns to reference already-billed timesheet details
- for data in self.timesheets:
- # Handle invoice duplication
- if data.time_sheet and data.timesheet_detail:
- if sales_invoice := frappe.db.get_value(
- "Timesheet Detail", data.timesheet_detail, "sales_invoice"
- ):
- frappe.throw(
- _("Row {0}: Sales Invoice {1} is already created for {2}").format(
- data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
- )
- )
+ # Called by POS Invoice
+ def validate_pos(self):
+ POSService(self).validate_pos()
- if data.time_sheet:
- status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
- if status not in ["Submitted", "Payslip", "Partially Billed"]:
- frappe.throw(
- _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
- )
+ # Called by POS Invoice
+ def validate_pos_opening_entry(self):
+ POSService(self).validate_pos_opening_entry()
- def set_pos_fields(self, for_validate=False):
- """Set retail related fields from POS Profiles"""
- if cint(self.is_pos) != 1:
- return
+ # Called by POS Invoice
+ def clear_unallocated_mode_of_payments(self):
+ POSService(self).clear_unallocated_mode_of_payments()
- if not self.account_for_change_amount:
- self.account_for_change_amount = frappe.get_cached_value(
- "Company", self.company, "default_cash_account"
- )
-
- from erpnext.stock.get_item_details import (
- ItemDetailsCtx,
- get_pos_profile,
- get_pos_profile_item_details_,
- )
-
- if not self.pos_profile and not self.flags.ignore_pos_profile:
- pos_profile = get_pos_profile(self.company) or {}
- if not pos_profile:
- return
- self.pos_profile = pos_profile.get("name")
-
- pos = {}
- if self.pos_profile:
- pos = frappe.get_doc("POS Profile", self.pos_profile)
-
- if pos:
- if not for_validate:
- update_multi_mode_option(self, pos)
- self.tax_category = pos.get("tax_category")
-
- if not for_validate and not self.customer:
- self.customer = pos.customer
-
- if not for_validate:
- self.ignore_pricing_rule = pos.ignore_pricing_rule
-
- if pos.get("account_for_change_amount"):
- self.account_for_change_amount = pos.get("account_for_change_amount")
-
- for fieldname in (
- "currency",
- "letter_head",
- "tc_name",
- "company",
- "select_print_heading",
- "write_off_account",
- "taxes_and_charges",
- "write_off_cost_center",
- "apply_discount_on",
- "cost_center",
- ):
- if (not for_validate) or (for_validate and not self.get(fieldname)):
- self.set(fieldname, pos.get(fieldname))
-
- if pos.get("company_address"):
- self.company_address = pos.get("company_address")
-
- if self.customer:
- customer_price_list, customer_group = frappe.get_value(
- "Customer", self.customer, ["default_price_list", "customer_group"]
- )
- customer_group_price_list = frappe.get_value(
- "Customer Group", customer_group, "default_price_list"
- )
- selling_price_list = (
- customer_price_list or customer_group_price_list or pos.get("selling_price_list")
- )
- else:
- selling_price_list = pos.get("selling_price_list")
-
- if selling_price_list:
- self.set("selling_price_list", selling_price_list)
-
- # set pos values in items
- for item in self.get("items"):
- if item.get("item_code"):
- profile_details = get_pos_profile_item_details_(
- ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
- )
- for fname, val in profile_details.items():
- if (not for_validate) or (for_validate and not item.get(fname)):
- item.set(fname, val)
-
- if not for_validate:
- dn_flag = any(d.get("dn_detail") for d in self.get("items"))
- self.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
-
- # fetch terms
- if self.tc_name and not self.terms:
- self.terms = frappe.db.get_value("Terms and Conditions", self.tc_name, "terms")
-
- # fetch charges
- if self.taxes_and_charges and not len(self.get("taxes")):
- self.set_taxes()
-
- return pos
+ # Called by POS Invoice
+ def validate_full_payment(self):
+ POSService(self).validate_full_payment()
def get_company_abbr(self):
return frappe.db.get_value("Company", self.company, "abbr")
@@ -1036,11 +733,6 @@ class SalesInvoice(SellingController):
self.party_account_currency = account.account_currency
- def clear_unallocated_mode_of_payments(self):
- self.set("payments", self.get("payments", {"amount": ["not in", [0, None, ""]]}))
-
- frappe.db.delete("Sales Invoice Payment", filters={"parent": self.name, "amount": 0})
-
def validate_with_previous_doc(self):
super().validate_with_previous_doc(
{
@@ -1106,7 +798,6 @@ class SalesInvoice(SellingController):
self.remarks += " " + _("dated {0}").format(formatdate(self.po_date))
def validate_auto_set_posting_time(self):
- # Don't auto set the posting date and time if invoice is amended
if self.is_new() and self.amended_from:
self.set_posting_time = 1
@@ -1151,68 +842,6 @@ class SalesInvoice(SellingController):
if not query.run():
throw(_("Customer {0} does not belong to project {1}").format(self.customer, self.project))
- def validate_pos(self):
- if self.is_return:
- invoice_total = self.rounded_total or self.grand_total
- if abs(flt(self.paid_amount)) + abs(flt(self.write_off_amount)) - abs(
- flt(invoice_total)
- ) > 1.0 / (10.0 ** (self.precision("grand_total") + 1.0)):
- frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total"))
-
- def validate_created_using_pos(self):
- if self.is_created_using_pos and not self.pos_profile:
- frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction."))
-
- self.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type")
- if self.invoice_type_in_pos == "POS Invoice" and not self.is_return:
- frappe.throw(_("Transactions using Sales Invoice in POS are disabled."))
-
- self.validate_pos_opening_entry()
-
- def validate_full_payment(self):
- allow_partial_payment = frappe.db.get_value("POS Profile", self.pos_profile, "allow_partial_payment")
- invoice_total = flt(self.rounded_total) or flt(self.grand_total)
-
- if (
- self.docstatus == 1
- and not self.is_return
- and not allow_partial_payment
- and self.paid_amount < invoice_total
- ):
- frappe.throw(
- msg=_("Partial Payment in POS Transactions are not allowed."),
- exc=PartialPaymentValidationError,
- )
-
- def validate_pos_opening_entry(self):
- opening_entries = frappe.get_all(
- "POS Opening Entry",
- fields=["name", "period_start_date"],
- filters={"pos_profile": self.pos_profile, "status": "Open"},
- order_by="period_start_date desc",
- )
- if not opening_entries:
- frappe.throw(
- title=_("POS Opening Entry Missing"),
- msg=_("No open POS Opening Entry found for POS Profile {0}.").format(
- frappe.bold(self.pos_profile)
- ),
- )
- if len(opening_entries) > 1:
- frappe.throw(
- title=_("Multiple POS Opening Entry"),
- msg=_(
- "POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding."
- ).format(self.pos_profile),
- )
- if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today():
- frappe.throw(
- title=_("Outdated POS Opening Entry"),
- msg=_(
- "POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry."
- ).format(opening_entries[0].get("name")),
- )
-
def validate_warehouse(self):
super().validate_warehouse()
@@ -1237,10 +866,6 @@ class SalesInvoice(SellingController):
),
)
- def allow_write_off_only_on_pos(self):
- if not self.is_pos and self.write_off_account:
- self.write_off_account = None
-
def validate_subcontracted_sales_order(self):
if self.has_subcontracted:
if [item for item in self.items if not item.sales_order and not item.scio_detail]:
@@ -1321,91 +946,13 @@ class SalesInvoice(SellingController):
else:
self.set("packed_items", [])
- def set_billing_hours_and_amount(self):
- if not self.project:
- for timesheet in self.timesheets:
- ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet)
- if not timesheet.billing_hours and ts_doc.total_billable_hours:
- timesheet.billing_hours = ts_doc.total_billable_hours
-
- if not timesheet.billing_amount and ts_doc.total_billable_amount:
- timesheet.billing_amount = ts_doc.total_billable_amount
-
- def update_timesheet_billing_for_project(self):
- if (
- not self.is_return
- and not self.timesheets
- and self.project
- and self.is_auto_fetch_timesheet_enabled()
- ):
- self.add_timesheet_data()
- else:
- self.calculate_billing_amount_for_timesheet()
-
@frappe.whitelist()
def is_auto_fetch_timesheet_enabled(self):
return frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice")
@frappe.whitelist()
def add_timesheet_data(self):
- self.set("timesheets", [])
- if self.project:
- for data in get_projectwise_timesheet_data(self.project):
- self.append(
- "timesheets",
- {
- "time_sheet": data.time_sheet,
- "billing_hours": data.billing_hours,
- "billing_amount": data.billing_amount,
- "timesheet_detail": data.name,
- "activity_type": data.activity_type,
- "description": data.description,
- },
- )
-
- self.calculate_billing_amount_for_timesheet()
-
- def calculate_billing_amount_for_timesheet(self):
- def timesheet_sum(field):
- return sum((ts.get(field) or 0.0) for ts in self.timesheets)
-
- self.total_billing_amount = timesheet_sum("billing_amount")
- self.total_billing_hours = timesheet_sum("billing_hours")
-
- def get_warehouse(self):
- POSProfile = frappe.qb.DocType("POS Profile")
-
- user_query = (
- frappe.qb.from_(POSProfile)
- .select(POSProfile.name, POSProfile.warehouse)
- .where(POSProfile.company == self.company)
- .where(
- (POSProfile.user == frappe.session["user"])
- | ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
- )
- )
- user_pos_profile = user_query.run()
- warehouse = user_pos_profile[0][1] if user_pos_profile else None
-
- if not warehouse:
- global_query = (
- frappe.qb.from_(POSProfile)
- .select(POSProfile.name, POSProfile.warehouse)
- .where(POSProfile.company == self.company)
- .where(POSProfile.user.isnull() | (POSProfile.user == ""))
- )
- global_pos_profile = global_query.run()
-
- if global_pos_profile:
- warehouse = global_pos_profile[0][1]
- elif not user_pos_profile:
- msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
-
- return warehouse
-
- def set_income_account_for_fixed_assets(self):
- for item in self.items:
- item.set_income_account_for_fixed_asset(self.company)
+ TimesheetBillingService(self).add_timesheet_data()
def check_prev_docstatus(self):
for d in self.get("items"):
@@ -1421,138 +968,6 @@ class SalesInvoice(SellingController):
):
throw(_("Delivery Note {0} is not submitted").format(d.delivery_note))
- def split_asset_based_on_sale_qty(self):
- asset_qty_map = self.get_asset_qty()
- for asset, qty in asset_qty_map.items():
- if qty["actual_qty"] < qty["sale_qty"]:
- frappe.throw(
- _(
- "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)."
- ).format(asset, qty["actual_qty"])
- )
-
- remaining_qty = qty["actual_qty"] - qty["sale_qty"]
- if remaining_qty > 0:
- split_asset(asset, remaining_qty)
-
- def get_asset_qty(self):
- asset_qty_map = {}
-
- assets = {row.asset for row in self.items if row.is_fixed_asset and row.asset}
- if not assets or self.is_return:
- return asset_qty_map
-
- asset_actual_qty = dict(
- frappe.db.get_all(
- "Asset",
- {"name": ["in", list(assets)]},
- ["name", "asset_quantity"],
- as_list=True,
- )
- )
- for row in self.items:
- if row.is_fixed_asset and row.asset:
- actual_qty = asset_actual_qty.get(row.asset)
- if row.asset in asset_qty_map.keys():
- asset_qty_map[row.asset]["sale_qty"] += flt(row.qty)
- else:
- asset_qty_map.setdefault(
- row.asset,
- {
- "sale_qty": flt(row.qty),
- "actual_qty": flt(actual_qty),
- },
- )
-
- return asset_qty_map
-
- def process_asset_depreciation(self):
- if self.is_internal_transfer():
- return
-
- if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1):
- self.depreciate_asset_on_sale()
- else:
- self.restore_asset()
-
- self.update_asset()
-
- def depreciate_asset_on_sale(self):
- """
- Depreciate asset on sale or cancellation of return sales invoice
- """
- disposal_date = self.get_disposal_date()
- for d in self.get("items"):
- if d.asset:
- asset = frappe.get_doc("Asset", d.asset)
- if asset.calculate_depreciation and asset.status != "Fully Depreciated":
- depreciate_asset(asset, disposal_date, self.get_note_for_asset_sale(asset))
-
- def get_note_for_asset_sale(self, asset):
- return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format(
- get_link_to_form(asset.doctype, asset.name),
- _("returned") if self.is_return else _("sold"),
- get_link_to_form(self.doctype, self.get("name")),
- )
-
- def restore_asset(self):
- """
- Restore asset on return or cancellation of original sales invoice
- """
-
- for d in self.get("items"):
- if d.asset:
- asset = frappe.get_cached_doc("Asset", d.asset)
- if asset.calculate_depreciation:
- reverse_depreciation_entry_made_on_disposal(asset)
-
- note = self.get_note_for_asset_return(asset)
- reset_depreciation_schedule(asset, note)
-
- def get_note_for_asset_return(self, asset):
- asset_link = get_link_to_form(asset.doctype, asset.name)
- invoice_link = get_link_to_form(self.doctype, self.get("name"))
- if self.is_return:
- return _(
- "This schedule was created when Asset {0} was returned through Sales Invoice {1}."
- ).format(asset_link, invoice_link)
- else:
- return _(
- "This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation."
- ).format(asset_link, invoice_link)
-
- def update_asset(self):
- """
- Update asset status, disposal date and asset activity on sale or return sales invoice
- """
-
- def _update_asset(asset, disposal_date, note, asset_status=None):
- frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date)
- add_asset_activity(asset.name, note)
- asset.set_status(asset_status)
-
- disposal_date = self.get_disposal_date()
- for d in self.get("items"):
- if d.asset:
- asset = frappe.get_cached_doc("Asset", d.asset)
-
- if (self.is_return and self.docstatus == 1) or (not self.is_return and self.docstatus == 2):
- note = _("Asset returned") if self.is_return else _("Asset sold")
- asset_status, disposal_date = None, None
- else:
- note = _("Asset sold") if not self.is_return else _("Return invoice of asset cancelled")
- asset_status = "Sold"
-
- _update_asset(asset, disposal_date, note, asset_status)
-
- def get_disposal_date(self):
- if self.is_return:
- disposal_date = frappe.db.get_value("Sales Invoice", self.return_against, "posting_date")
- else:
- disposal_date = self.posting_date
-
- return disposal_date
-
def make_gl_entries(self, gl_entries=None, from_repost=False):
from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries
@@ -1561,7 +976,6 @@ class SalesInvoice(SellingController):
gl_entries = self.get_gl_entries()
if gl_entries:
- # if POS and amount is written off, updating outstanding amt after posting all gl entries
update_outstanding = (
"No"
if (cint(self.is_pos) or self.write_off_account or cint(self.redeem_loyalty_points))
@@ -1595,282 +1009,9 @@ class SalesInvoice(SellingController):
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
def get_gl_entries(self, inventory_account_map=None):
- from erpnext.accounts.general_ledger import merge_similar_entries
+ from erpnext.accounts.doctype.sales_invoice.services.gl_composer import SalesInvoiceGLComposer
- gl_entries = []
-
- self.make_customer_gl_entry(gl_entries)
-
- self.make_tax_gl_entries(gl_entries)
- self.make_internal_transfer_gl_entries(gl_entries)
-
- self.make_item_gl_entries(gl_entries)
-
- disable_sdbnb_in_sr = frappe.get_cached_value("Company", self.company, "disable_sdbnb_in_sr")
-
- if not (self.is_return and disable_sdbnb_in_sr):
- self.stock_delivered_but_not_billed_gl_entries(gl_entries)
-
- self.make_precision_loss_gl_entry(gl_entries)
- self.make_discount_gl_entries(gl_entries)
-
- gl_entries = make_regional_gl_entries(gl_entries, self)
-
- # merge gl entries before adding pos entries
- gl_entries = merge_similar_entries(gl_entries)
-
- self.make_loyalty_point_redemption_gle(gl_entries)
- self.make_pos_gl_entries(gl_entries)
-
- self.make_write_off_gl_entry(gl_entries)
- self.make_gle_for_rounding_adjustment(gl_entries)
-
- self.set_transaction_currency_and_rate_in_gl_map(gl_entries)
- return gl_entries
-
- def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
- if self.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(self.company)):
- return
-
- for item in self.get("items"):
- if not item.delivery_note and not item.dn_detail:
- continue
-
- if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
- continue
-
- dn_expense_account = frappe.get_cached_value(
- "Delivery Note Item", item.dn_detail, "expense_account"
- )
- if (
- not dn_expense_account
- or frappe.get_cached_value("Account", dn_expense_account, "account_type")
- != "Stock Delivered But Not Billed"
- or not item.expense_account
- or dn_expense_account == item.expense_account
- ):
- continue
-
- delivery_note = item.delivery_note or frappe.get_cached_value(
- "Delivery Note Item", item.dn_detail, "parent"
- )
- if not delivery_note:
- continue
-
- item_g = frappe.get_cached_value(
- "Stock Ledger Entry",
- {
- "voucher_no": delivery_note,
- "voucher_detail_no": item.dn_detail,
- "item_code": item.item_code,
- "is_cancelled": 0,
- },
- ["stock_value_difference", "actual_qty"],
- as_dict=True,
- )
-
- if not item_g or not flt(item_g.actual_qty):
- continue
- valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
- valuation_amount = valuation_rate * item.stock_qty
- dn_account_currency = get_account_currency(dn_expense_account)
- item_account_currency = get_account_currency(item.expense_account)
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": dn_expense_account,
- "against": item.expense_account,
- "credit": flt(valuation_amount),
- "credit_in_account_currency": flt(valuation_amount),
- "cost_center": item.cost_center,
- },
- dn_account_currency,
- item=item,
- )
- )
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item.expense_account,
- "against": dn_expense_account,
- "debit": flt(valuation_amount),
- "debit_in_account_currency": flt(valuation_amount),
- "cost_center": item.cost_center,
- },
- item_account_currency,
- item=item,
- )
- )
-
- def make_customer_gl_entry(self, gl_entries):
- # Checked both rounding_adjustment and rounded_total
- # because rounded_total had value even before introduction of posting GLE based on rounded total
- grand_total = (
- self.rounded_total if (self.rounding_adjustment and self.rounded_total) else self.grand_total
- )
- base_grand_total = flt(
- self.base_rounded_total
- if (self.base_rounding_adjustment and self.base_rounded_total)
- else self.base_grand_total,
- self.precision("base_grand_total"),
- )
-
- if grand_total and not self.is_internal_transfer():
- against_voucher = self.name
- if self.is_return and self.return_against and not self.update_outstanding_for_self:
- against_voucher = self.return_against
-
- # Did not use base_grand_total to book rounding loss gle
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.debit_to,
- "party_type": "Customer",
- "party": self.customer,
- "due_date": self.due_date,
- "against": self.against_income_account,
- "debit": base_grand_total,
- "debit_in_account_currency": base_grand_total
- if self.party_account_currency == self.company_currency
- else grand_total,
- "debit_in_transaction_currency": grand_total,
- "against_voucher": against_voucher,
- "against_voucher_type": self.doctype,
- "cost_center": self.cost_center,
- "project": self.project,
- },
- self.party_account_currency,
- item=self,
- )
- )
-
- def make_tax_gl_entries(self, gl_entries):
- enable_discount_accounting = cint(
- frappe.get_single_value("Selling Settings", "enable_discount_accounting")
- )
-
- for tax in self.get("taxes"):
- amount, base_amount = self.get_tax_amounts(tax, enable_discount_accounting)
-
- if flt(tax.base_tax_amount_after_discount_amount):
- account_currency = get_account_currency(tax.account_head)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": tax.account_head,
- "against": self.customer,
- "credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
- "credit_in_account_currency": (
- flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
- if account_currency == self.company_currency
- else flt(amount, tax.precision("tax_amount_after_discount_amount"))
- ),
- "credit_in_transaction_currency": flt(
- amount, tax.precision("tax_amount_after_discount_amount")
- ),
- "cost_center": tax.cost_center,
- },
- account_currency,
- item=tax,
- )
- )
-
- def make_internal_transfer_gl_entries(self, gl_entries):
- if self.is_internal_transfer() and flt(self.base_total_taxes_and_charges):
- account_currency = get_account_currency(self.unrealized_profit_loss_account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.unrealized_profit_loss_account,
- "against": self.customer,
- "debit": flt(self.total_taxes_and_charges),
- "debit_in_account_currency": flt(self.base_total_taxes_and_charges),
- "debit_in_transaction_currency": flt(self.total_taxes_and_charges),
- "cost_center": self.cost_center,
- },
- account_currency,
- item=self,
- )
- )
-
- def make_item_gl_entries(self, gl_entries):
- # income account gl entries
- enable_discount_accounting = cint(
- frappe.get_single_value("Selling Settings", "enable_discount_accounting")
- )
-
- for item in self.get("items"):
- if (
- flt(item.base_net_amount, item.precision("base_net_amount"))
- or item.is_fixed_asset
- or enable_discount_accounting
- ):
- # Do not book income for transfer within same company
- if self.is_internal_transfer():
- continue
-
- if item.is_fixed_asset and item.asset:
- self.get_gl_entries_for_fixed_asset(item, gl_entries)
- else:
- income_account = (
- item.income_account
- if (not item.enable_deferred_revenue or self.is_return)
- else item.deferred_revenue_account
- )
-
- amount, base_amount = self.get_amount_and_base_amount(item, enable_discount_accounting)
-
- account_currency = get_account_currency(income_account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": income_account,
- "against": self.customer,
- "credit": flt(base_amount, item.precision("base_net_amount")),
- "credit_in_account_currency": (
- flt(base_amount, item.precision("base_net_amount"))
- if account_currency == self.company_currency
- else flt(amount, item.precision("net_amount"))
- ),
- "credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
-
- # expense account gl entries
- if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company):
- gl_entries += super().get_gl_entries()
-
- def get_gl_entries_for_fixed_asset(self, item, gl_entries):
- asset = frappe.get_cached_doc("Asset", item.asset)
-
- if self.is_return:
- fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
- asset,
- item.base_net_amount,
- item.finance_book,
- self.get("doctype"),
- self.get("name"),
- self.get("posting_date"),
- )
- else:
- fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
- asset,
- item.base_net_amount,
- item.finance_book,
- self.get("doctype"),
- self.get("name"),
- self.get("posting_date"),
- )
-
- for gle in fixed_asset_gl_entries:
- gle["against"] = self.customer
- gl_entries.append(self.get_gl_dict(gle, item=item))
+ return SalesInvoiceGLComposer(self).compose(inventory_account_map)
@property
def enable_discount_accounting(self):
@@ -1881,246 +1022,6 @@ class SalesInvoice(SellingController):
return self._enable_discount_accounting
- def make_loyalty_point_redemption_gle(self, gl_entries):
- if cint(self.redeem_loyalty_points and self.loyalty_points and not self.is_consolidated):
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.debit_to,
- "party_type": "Customer",
- "party": self.customer,
- "against": "Expense account - "
- + cstr(self.loyalty_redemption_account)
- + " for the Loyalty Program",
- "credit": self.loyalty_amount,
- "credit_in_transaction_currency": self.loyalty_amount,
- "against_voucher": self.return_against if cint(self.is_return) else self.name,
- "against_voucher_type": self.doctype,
- "cost_center": self.cost_center,
- },
- item=self,
- )
- )
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.loyalty_redemption_account,
- "cost_center": self.cost_center or self.loyalty_redemption_cost_center,
- "against": self.customer,
- "debit": self.loyalty_amount,
- "debit_in_transaction_currency": self.loyalty_amount,
- "remark": "Loyalty Points redeemed by the customer",
- },
- item=self,
- )
- )
-
- def make_pos_gl_entries(self, gl_entries):
- if cint(self.is_pos):
- skip_change_gl_entries = not cint(
- frappe.get_single_value("POS Settings", "post_change_gl_entries")
- )
-
- for payment_mode in self.payments:
- if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount:
- payment_mode.base_amount -= flt(self.change_amount)
-
- against_voucher = self.name
- if self.is_return and self.return_against and not self.update_outstanding_for_self:
- against_voucher = self.return_against
-
- if payment_mode.base_amount:
- # POS, make payment entries
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.debit_to,
- "party_type": "Customer",
- "party": self.customer,
- "against": payment_mode.account,
- "credit": payment_mode.base_amount,
- "credit_in_account_currency": payment_mode.base_amount
- if self.party_account_currency == self.company_currency
- else payment_mode.amount,
- "credit_in_transaction_currency": payment_mode.amount,
- "against_voucher": against_voucher,
- "against_voucher_type": self.doctype,
- "cost_center": self.cost_center,
- },
- self.party_account_currency,
- item=self,
- )
- )
-
- payment_mode_account_currency = get_account_currency(payment_mode.account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": payment_mode.account,
- "against": self.customer,
- "debit": payment_mode.base_amount,
- "debit_in_account_currency": payment_mode.base_amount
- if payment_mode_account_currency == self.company_currency
- else payment_mode.amount,
- "debit_in_transaction_currency": payment_mode.amount,
- "cost_center": self.cost_center,
- },
- payment_mode_account_currency,
- item=self,
- )
- )
-
- if not skip_change_gl_entries:
- gl_entries.extend(self.get_gle_for_change_amount())
-
- def get_gle_for_change_amount(self) -> list[dict]:
- if not self.change_amount:
- return []
-
- if not self.account_for_change_amount:
- frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field"))
-
- return [
- self.get_gl_dict(
- {
- "account": self.debit_to,
- "party_type": "Customer",
- "party": self.customer,
- "against": self.account_for_change_amount,
- "debit": flt(self.base_change_amount),
- "debit_in_account_currency": flt(self.base_change_amount)
- if self.party_account_currency == self.company_currency
- else flt(self.change_amount),
- "debit_in_transaction_currency": flt(self.change_amount),
- "against_voucher": self.return_against
- if cint(self.is_return) and self.return_against
- else self.name,
- "against_voucher_type": self.doctype,
- "cost_center": self.cost_center,
- "project": self.project,
- },
- self.party_account_currency,
- item=self,
- ),
- self.get_gl_dict(
- {
- "account": self.account_for_change_amount,
- "against": self.customer,
- "credit": self.base_change_amount,
- "credit_in_transaction_currency": self.change_amount,
- "cost_center": self.cost_center,
- },
- item=self,
- ),
- ]
-
- def make_write_off_gl_entry(self, gl_entries):
- # write off entries, applicable if only pos
- if (
- self.is_pos
- and self.write_off_account
- and flt(self.write_off_amount, self.precision("write_off_amount"))
- ):
- write_off_account_currency = get_account_currency(self.write_off_account)
- default_cost_center = frappe.get_cached_value("Company", self.company, "cost_center")
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.debit_to,
- "party_type": "Customer",
- "party": self.customer,
- "against": self.write_off_account,
- "credit": flt(self.base_write_off_amount, self.precision("base_write_off_amount")),
- "credit_in_account_currency": (
- flt(self.base_write_off_amount, self.precision("base_write_off_amount"))
- if self.party_account_currency == self.company_currency
- else flt(self.write_off_amount, self.precision("write_off_amount"))
- ),
- "credit_in_transaction_currency": flt(
- self.write_off_amount, self.precision("write_off_amount")
- ),
- "against_voucher": self.return_against if cint(self.is_return) else self.name,
- "against_voucher_type": self.doctype,
- "cost_center": self.cost_center,
- "project": self.project,
- },
- self.party_account_currency,
- item=self,
- )
- )
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.write_off_account,
- "against": self.customer,
- "debit": flt(self.base_write_off_amount, self.precision("base_write_off_amount")),
- "debit_in_account_currency": (
- flt(self.base_write_off_amount, self.precision("base_write_off_amount"))
- if write_off_account_currency == self.company_currency
- else flt(self.write_off_amount, self.precision("write_off_amount"))
- ),
- "debit_in_transaction_currency": flt(
- self.write_off_amount, self.precision("write_off_amount")
- ),
- "cost_center": self.cost_center or self.write_off_cost_center or default_cost_center,
- },
- write_off_account_currency,
- item=self,
- )
- )
-
- def make_gle_for_rounding_adjustment(self, gl_entries):
- if (
- flt(self.rounding_adjustment, self.precision("rounding_adjustment"))
- and self.base_rounding_adjustment
- and not self.is_internal_transfer()
- ):
- (
- round_off_account,
- round_off_cost_center,
- round_off_for_opening,
- ) = get_round_off_account_and_cost_center(
- self.company, "Sales Invoice", self.name, self.use_company_roundoff_cost_center
- )
-
- if self.is_opening == "Yes" and self.rounding_adjustment:
- if not round_off_for_opening:
- frappe.throw(
- _(
- "Opening Invoice has rounding adjustment of {0}.
'{1}' account is required to post these values. Please set it in Company: {2}.
Or, '{3}' can be enabled to not post any rounding adjustment."
- ).format(
- frappe.bold(self.rounding_adjustment),
- frappe.bold("Round Off for Opening"),
- get_link_to_form("Company", self.company),
- frappe.bold("Disable Rounded Total"),
- )
- )
- else:
- round_off_account = round_off_for_opening
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": round_off_account,
- "against": self.customer,
- "credit_in_account_currency": flt(
- self.rounding_adjustment, self.precision("rounding_adjustment")
- ),
- "credit_in_transaction_currency": flt(
- self.rounding_adjustment, self.precision("rounding_adjustment")
- ),
- "credit": flt(
- self.base_rounding_adjustment, self.precision("base_rounding_adjustment")
- ),
- "cost_center": round_off_cost_center
- if self.use_company_roundoff_cost_center
- else (self.cost_center or round_off_cost_center),
- },
- item=self,
- )
- )
-
def update_billing_status_in_dn(self, update_modified=True):
if self.is_return and not self.update_billed_amount_in_delivery_note:
return
@@ -2171,195 +1072,48 @@ class SalesInvoice(SellingController):
project.calculate_gross_margin()
project.db_update()
- def verify_payment_amount_is_positive(self):
- for entry in self.payments:
- if entry.amount < 0:
- frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx))
-
- def verify_payment_amount_is_negative(self):
- for entry in self.payments:
- if entry.amount > 0:
- frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
-
- # collection of the loyalty points, create the ledger entry for that.
- def make_loyalty_point_entry(self):
- returned_amount = self.get_returned_amount()
- current_amount = flt(self.grand_total) - cint(self.loyalty_amount)
- eligible_amount = current_amount - returned_amount
- lp_details = get_loyalty_program_details_with_points(
- self.customer,
- company=self.company,
- current_transaction_amount=current_amount,
- loyalty_program=self.loyalty_program,
- expiry_date=self.posting_date,
- include_expired_entry=True,
- )
- if (
- lp_details
- and getdate(lp_details.from_date) <= getdate(self.posting_date)
- and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(self.posting_date))
- ):
- collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0
- points_earned = cint(eligible_amount / collection_factor)
-
- doc = frappe.get_doc(
- {
- "doctype": "Loyalty Point Entry",
- "company": self.company,
- "loyalty_program": lp_details.loyalty_program,
- "loyalty_program_tier": lp_details.tier_name,
- "customer": self.customer,
- "invoice_type": self.doctype,
- "invoice": self.name,
- "loyalty_points": points_earned,
- "purchase_amount": eligible_amount,
- "expiry_date": add_days(self.posting_date, lp_details.expiry_duration),
- "posting_date": self.posting_date,
- }
- )
- doc.flags.ignore_permissions = 1
- doc.save()
- self.set_loyalty_program_tier()
-
- # valdite the redemption and then delete the loyalty points earned on cancel of the invoice
- def delete_loyalty_point_entry(self):
- lp_entry = frappe.db.get_all(
- "Loyalty Point Entry", filters={"invoice": self.name, "loyalty_points": (">", 0)}, fields=["name"]
- )
-
- if not lp_entry:
+ def update_billed_qty_in_scio(self):
+ if self.is_return:
return
- against_lp_entry = frappe.db.get_all(
- "Loyalty Point Entry",
- filters={"redeem_against": lp_entry[0].name},
- fields=["name", "invoice"],
+ table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
+ data = frappe._dict(
+ {
+ item.scio_detail: item.stock_qty if self._action == "submit" else -item.stock_qty
+ for item in self.items
+ if item.scio_detail
+ }
)
- if against_lp_entry:
- invoice_list = ", ".join([d.invoice for d in against_lp_entry])
- frappe.throw(
- _(
- "{} can't be cancelled since the Loyalty Points earned has been redeemed. "
- "First cancel the {} No {}"
- ).format(self.doctype, self.doctype, invoice_list)
- )
- else:
- frappe.db.delete("Loyalty Point Entry", filters={"invoice": self.name})
- self.set_loyalty_program_tier()
+ if data:
+ case_expr = Case()
+ for name, qty in data.items():
+ case_expr = case_expr.when(table.name == name, table.billed_qty + qty)
+ frappe.qb.update(table).set(table.billed_qty, case_expr).where(
+ (table.name.isin(list(data.keys()))) & (table.docstatus == 1)
+ ).run()
- def set_loyalty_program_tier(self):
- lp_details = get_loyalty_program_details_with_points(
- self.customer,
- company=self.company,
- loyalty_program=self.loyalty_program,
- include_expired_entry=True,
- )
- customer = frappe.get_doc("Customer", self.customer)
- customer.db_set("loyalty_program_tier", lp_details.tier_name)
-
- def get_returned_amount(self):
- from frappe.query_builder.functions import Sum
-
- doc = frappe.qb.DocType(self.doctype)
- returned_amount = (
- frappe.qb.from_(doc)
- .select(Sum(doc.grand_total))
- .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.name))
- ).run()
-
- return abs(returned_amount[0][0]) if returned_amount[0][0] else 0
-
- # redeem the loyalty points.
- def apply_loyalty_points(self):
- from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import (
- get_loyalty_point_entries,
- get_redemption_details,
- )
-
- loyalty_point_entries = get_loyalty_point_entries(
- self.customer, self.loyalty_program, self.company, self.posting_date
- )
- redemption_details = get_redemption_details(self.customer, self.loyalty_program, self.company)
-
- points_to_redeem = self.loyalty_points
- for lp_entry in loyalty_point_entries:
- if lp_entry.invoice_type != self.doctype or lp_entry.invoice == self.name:
- # redeemption should be done against same doctype
- # also it shouldn't be against itself
- continue
- available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name))
- if available_points > points_to_redeem:
- redeemed_points = points_to_redeem
- else:
- redeemed_points = available_points
- doc = frappe.get_doc(
- {
- "doctype": "Loyalty Point Entry",
- "company": self.company,
- "loyalty_program": self.loyalty_program,
- "loyalty_program_tier": lp_entry.loyalty_program_tier,
- "customer": self.customer,
- "invoice_type": self.doctype,
- "invoice": self.name,
- "redeem_against": lp_entry.name,
- "loyalty_points": -1 * redeemed_points,
- "purchase_amount": self.grand_total,
- "expiry_date": lp_entry.expiry_date,
- "posting_date": self.posting_date,
- }
- )
- doc.flags.ignore_permissions = 1
- doc.save()
- points_to_redeem -= redeemed_points
- if points_to_redeem < 1: # since points_to_redeem is integer
- break
+ def on_update_after_submit(self):
+ fields_to_check = [
+ "additional_discount_account",
+ "cash_bank_account",
+ "account_for_change_amount",
+ "write_off_account",
+ "loyalty_redemption_account",
+ "unrealized_profit_loss_account",
+ "is_opening",
+ ]
+ child_tables = {
+ "items": ("income_account", "expense_account", "discount_account"),
+ "taxes": ("account_head",),
+ }
+ self.needs_repost = self.check_if_fields_updated(fields_to_check, child_tables)
+ if self.needs_repost:
+ self.validate_for_repost()
+ self.repost_accounting_entries()
def set_status(self, update=False, status=None, update_modified=True):
- if self.is_new():
- if self.get("amended_from"):
- self.status = "Draft"
- return
-
- outstanding_amount = flt(self.outstanding_amount, self.precision("outstanding_amount"))
- total = get_total_in_party_account_currency(self)
-
- if not status:
- if self.docstatus == 2:
- status = "Cancelled"
- elif self.docstatus == 1:
- if self.is_internal_transfer():
- self.status = "Internal Transfer"
- elif is_overdue(self, total):
- self.status = "Overdue"
- elif 0 < outstanding_amount < total:
- self.status = "Partly Paid"
- elif outstanding_amount > 0 and getdate(self.due_date) >= getdate():
- self.status = "Unpaid"
- # Check if outstanding amount is 0 due to credit note issued against invoice
- elif self.is_return == 0 and frappe.db.get_value(
- "Sales Invoice", {"is_return": 1, "return_against": self.name, "docstatus": 1}
- ):
- self.status = "Credit Note Issued"
- elif self.is_return == 1:
- self.status = "Return"
- elif outstanding_amount <= 0:
- self.status = "Paid"
- else:
- self.status = "Submitted"
-
- if (
- self.status in ("Unpaid", "Partly Paid", "Overdue")
- and self.is_discounted
- and get_discounting_status(self.name) == "Disbursed"
- ):
- self.status += " and Discounted"
-
- else:
- self.status = "Draft"
-
- if update:
- self.db_set("status", self.status, update_modified=update_modified)
+ StatusService(self).set_status(update, status, update_modified)
@frappe.whitelist()
def is_subcontracted(self):
@@ -2379,131 +1133,6 @@ class SalesInvoice(SellingController):
return self.has_subcontracted
-def get_total_in_party_account_currency(doc):
- total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total"
- if doc.party_account_currency != doc.currency:
- total_fieldname = "base_" + total_fieldname
-
- return flt(doc.get(total_fieldname), doc.precision(total_fieldname))
-
-
-def is_overdue(doc, total):
- outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
- if outstanding_amount <= 0:
- return
-
- today = getdate()
- if doc.get("is_pos") or not doc.get("payment_schedule"):
- return getdate(doc.due_date) < today
-
- # calculate payable amount till date
- payment_amount_field = (
- "base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount"
- )
-
- payable_amount = flt(
- sum(
- payment.get(payment_amount_field)
- for payment in doc.payment_schedule
- if getdate(payment.due_date) < today
- ),
- doc.precision("outstanding_amount"),
- )
-
- return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount
-
-
-def get_discounting_status(sales_invoice):
- status = None
-
- InvoiceDiscounting = frappe.qb.DocType("Invoice Discounting")
- DiscountedInvoice = frappe.qb.DocType("Discounted Invoice")
-
- query = (
- frappe.qb.from_(InvoiceDiscounting)
- .join(DiscountedInvoice)
- .on(InvoiceDiscounting.name == DiscountedInvoice.parent)
- .select(InvoiceDiscounting.status)
- .where(DiscountedInvoice.sales_invoice == sales_invoice)
- .where(InvoiceDiscounting.docstatus == 1)
- .where(InvoiceDiscounting.status.isin(["Disbursed", "Settled"]))
- )
-
- invoice_discounting_list = query.run()
-
- for d in invoice_discounting_list:
- status = d[0]
- if status == "Disbursed":
- break
-
- return status
-
-
-def validate_inter_company_party(doctype, party, company, inter_company_reference):
- if not party:
- return
-
- if doctype in ["Sales Invoice", "Sales Order"]:
- partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer"
-
- if doctype == "Sales Invoice":
- ref_doc = "Purchase Invoice"
- else:
- ref_doc = "Purchase Order"
- else:
- partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier"
-
- if doctype == "Purchase Invoice":
- ref_doc = "Sales Invoice"
- else:
- ref_doc = "Sales Order"
-
- if inter_company_reference:
- doc = frappe.get_doc(ref_doc, inter_company_reference)
- ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
- if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party:
- frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
- if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company:
- frappe.throw(_("Invalid Company for Inter Company Transaction."))
-
- elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party:
- companies = frappe.get_all(
- "Allowed To Transact With",
- fields=["company"],
- filters={"parenttype": partytype, "parent": party},
- )
- companies = [d.company for d in companies]
- if company not in companies:
- frappe.throw(
- _(
- "{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
- ).format(_(partytype), company)
- )
-
-
-def update_linked_doc(doctype, name, inter_company_reference):
- if doctype in ["Sales Invoice", "Purchase Invoice"]:
- ref_field = "inter_company_invoice_reference"
- else:
- ref_field = "inter_company_order_reference"
-
- if inter_company_reference:
- frappe.db.set_value(doctype, inter_company_reference, ref_field, name)
-
-
-def unlink_inter_company_doc(doctype, name, inter_company_reference):
- if doctype in ["Sales Invoice", "Purchase Invoice"]:
- ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice"
- ref_field = "inter_company_invoice_reference"
- else:
- ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order"
- ref_field = "inter_company_order_reference"
-
- if inter_company_reference:
- frappe.db.set_value(doctype, name, ref_field, "")
- frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "")
-
-
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -2520,751 +1149,24 @@ def get_list_context(context=None):
return list_context
-@frappe.whitelist()
-def get_bank_cash_account(mode_of_payment: str, company: str):
- account = frappe.db.get_value(
- "Mode of Payment Account", {"parent": mode_of_payment, "company": company}, "default_account"
- )
- if not account:
- frappe.throw(
- _("Please set default Cash or Bank account in Mode of Payment {0}").format(
- get_link_to_form("Mode of Payment", mode_of_payment)
- ),
- title=_("Missing Account"),
- )
- return {"account": account}
-
-
-@frappe.whitelist()
-def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None):
- doclist = get_mapped_doc(
- "Sales Invoice",
- source_name,
- {
- "Sales Invoice": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}},
- "Sales Invoice Item": {
- "doctype": "Maintenance Schedule Item",
- },
- },
- target_doc,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_delivery_note(source_name: str, target_doc: Document | None = None):
- def set_missing_values(source, target):
- target.run_method("set_missing_values")
- target.run_method("set_po_nos")
- target.run_method("calculate_taxes_and_totals")
-
- def update_item(source_doc, target_doc, source_parent):
- target_doc.qty = flt(source_doc.qty) - flt(source_doc.delivered_qty)
- target_doc.stock_qty = target_doc.qty * flt(source_doc.conversion_factor)
-
- target_doc.base_amount = target_doc.qty * flt(source_doc.base_rate)
- target_doc.amount = target_doc.qty * flt(source_doc.rate)
-
- doclist = get_mapped_doc(
- "Sales Invoice",
- source_name,
- {
- "Sales Invoice": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
- "Sales Invoice Item": {
- "doctype": "Delivery Note Item",
- "field_map": {
- "name": "si_detail",
- "parent": "against_sales_invoice",
- "serial_no": "serial_no",
- "sales_order": "against_sales_order",
- "so_detail": "so_detail",
- "cost_center": "cost_center",
- },
- "postprocess": update_item,
- "condition": lambda doc: doc.delivered_by_supplier != 1
- and not doc.scio_detail
- and not doc.dn_detail
- and doc.qty - doc.delivered_qty > 0,
- },
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
- "Sales Team": {
- "doctype": "Sales Team",
- "field_map": {"incentives": "incentives"},
- "add_if_empty": True,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_sales_return(source_name: str, target_doc: Document | None = None):
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
-
- return make_return_doc("Sales Invoice", source_name, target_doc)
-
-
-def get_inter_company_details(doc, doctype):
- if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]:
- parties = frappe.db.get_all(
- "Supplier",
- fields=["name"],
- filters={"disabled": 0, "is_internal_supplier": 1, "represents_company": doc.company},
- )
- company = frappe.get_cached_value("Customer", doc.customer, "represents_company")
-
- if not parties:
- frappe.throw(
- _("No Supplier found for Inter Company Transactions which represents company {0}").format(
- frappe.bold(doc.company)
- )
- )
-
- party = get_internal_party(parties, "Supplier", doc)
- else:
- parties = frappe.db.get_all(
- "Customer",
- fields=["name"],
- filters={"disabled": 0, "is_internal_customer": 1, "represents_company": doc.company},
- )
- company = frappe.get_cached_value("Supplier", doc.supplier, "represents_company")
-
- if not parties:
- frappe.throw(
- _("No Customer found for Inter Company Transactions which represents company {0}").format(
- frappe.bold(doc.company)
- )
- )
-
- party = get_internal_party(parties, "Customer", doc)
-
- return {"party": party, "company": company}
-
-
-def get_internal_party(parties, link_doctype, doc):
- if len(parties) == 1:
- party = parties[0].name
- else:
- # If more than one Internal Supplier/Customer, get supplier/customer on basis of address
- if doc.get("company_address") or doc.get("shipping_address"):
- party = frappe.db.get_value(
- "Dynamic Link",
- {
- "parent": doc.get("company_address") or doc.get("shipping_address"),
- "parenttype": "Address",
- "link_doctype": link_doctype,
- },
- "link_name",
- )
-
- if not party:
- party = parties[0].name
- else:
- party = parties[0].name
-
- return party
-
-
-def validate_inter_company_transaction(doc, doctype):
- details = get_inter_company_details(doc, doctype)
- price_list = (
- doc.selling_price_list
- if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]
- else doc.buying_price_list
- )
- valid_price_list = frappe.db.get_value("Price List", {"name": price_list, "buying": 1, "selling": 1})
- if not valid_price_list and not doc.is_internal_transfer():
- frappe.throw(_("Selected Price List should have buying and selling fields checked."))
-
- party = details.get("party")
- if not party:
- partytype = "Supplier" if doctype in ["Sales Invoice", "Sales Order"] else "Customer"
- frappe.throw(_("No {0} found for Inter Company Transactions.").format(partytype))
-
- company = details.get("company")
- default_currency = frappe.get_cached_value("Company", company, "default_currency")
- if default_currency != doc.currency:
- frappe.throw(
- _("Company currencies of both the companies should match for Inter Company Transactions.")
- )
-
- return
-
-
-@frappe.whitelist()
-def make_inter_company_purchase_invoice(source_name: str, target_doc: Document | None = None):
- return make_inter_company_transaction("Sales Invoice", source_name, target_doc)
-
-
@erpnext.allow_regional
def make_regional_gl_entries(gl_entries, doc):
return gl_entries
-def make_inter_company_transaction(doctype, source_name, target_doc=None):
- if doctype in ["Sales Invoice", "Sales Order"]:
- source_doc = frappe.get_doc(doctype, source_name)
- target_doctype = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
- target_detail_field = "sales_invoice_item" if doctype == "Sales Invoice" else "sales_order_item"
- source_document_warehouse_field = "target_warehouse"
- target_document_warehouse_field = "from_warehouse"
- received_items = get_received_items(source_name, target_doctype, target_detail_field)
- else:
- source_doc = frappe.get_doc(doctype, source_name)
- target_doctype = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
- source_document_warehouse_field = "from_warehouse"
- target_document_warehouse_field = "target_warehouse"
- received_items = {}
-
- validate_inter_company_transaction(source_doc, doctype)
- details = get_inter_company_details(source_doc, doctype)
-
- def set_missing_values(source, target):
- target.run_method("set_missing_values")
- set_purchase_references(target)
-
- def update_details(source_doc, target_doc, source_parent):
- def _validate_address_link(address, link_doctype, link_name):
- return frappe.db.get_value(
- "Dynamic Link",
- {
- "parent": address,
- "parenttype": "Address",
- "link_doctype": link_doctype,
- "link_name": link_name,
- },
- "parent",
- )
-
- target_doc.inter_company_invoice_reference = source_doc.name
- if target_doc.doctype in ["Purchase Invoice", "Purchase Order"]:
- currency = frappe.db.get_value("Supplier", details.get("party"), "default_currency")
- target_doc.company = details.get("company")
- target_doc.supplier = details.get("party")
- target_doc.is_internal_supplier = 1
- target_doc.ignore_pricing_rule = 1
- target_doc.buying_price_list = source_doc.selling_price_list
-
- # Invert Addresses
- if source_doc.company_address and _validate_address_link(
- source_doc.company_address, "Supplier", details.get("party")
- ):
- update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
- if source_doc.dispatch_address_name and _validate_address_link(
- source_doc.dispatch_address_name, "Company", details.get("company")
- ):
- update_address(
- target_doc,
- "dispatch_address",
- "dispatch_address_display",
- source_doc.dispatch_address_name,
- )
- if source_doc.shipping_address_name and _validate_address_link(
- source_doc.shipping_address_name, "Company", details.get("company")
- ):
- update_address(
- target_doc,
- "shipping_address",
- "shipping_address_display",
- source_doc.shipping_address_name,
- )
- if source_doc.customer_address and _validate_address_link(
- source_doc.customer_address, "Company", details.get("company")
- ):
- update_address(
- target_doc, "billing_address", "billing_address_display", source_doc.customer_address
- )
-
- if currency:
- target_doc.currency = currency
-
- update_taxes(
- target_doc,
- party=target_doc.supplier,
- party_type="Supplier",
- company=target_doc.company,
- doctype=target_doc.doctype,
- party_address=target_doc.supplier_address,
- company_address=target_doc.shipping_address,
- )
-
- else:
- currency = frappe.db.get_value("Customer", details.get("party"), "default_currency")
- target_doc.company = details.get("company")
- target_doc.customer = details.get("party")
- target_doc.selling_price_list = source_doc.buying_price_list
-
- if source_doc.supplier_address and _validate_address_link(
- source_doc.supplier_address, "Company", details.get("company")
- ):
- update_address(
- target_doc, "company_address", "company_address_display", source_doc.supplier_address
- )
- if source_doc.shipping_address and _validate_address_link(
- source_doc.shipping_address, "Customer", details.get("party")
- ):
- update_address(
- target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
- )
- if source_doc.shipping_address and _validate_address_link(
- source_doc.shipping_address, "Customer", details.get("party")
- ):
- update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
-
- if currency:
- target_doc.currency = currency
-
- update_taxes(
- target_doc,
- party=target_doc.customer,
- party_type="Customer",
- company=target_doc.company,
- doctype=target_doc.doctype,
- party_address=target_doc.customer_address,
- company_address=target_doc.company_address,
- shipping_address_name=target_doc.shipping_address_name,
- )
-
- def update_item(source, target, source_parent):
- target.qty = flt(source.qty) - received_items.get(source.name, 0.0)
- if source.doctype == "Purchase Order Item" and target.doctype == "Sales Order Item":
- target.purchase_order = source.parent
- target.purchase_order_item = source.name
- target.material_request = source.material_request
- target.material_request_item = source.material_request_item
-
- if (
- source.get("purchase_order")
- and source.get("purchase_order_item")
- and target.doctype == "Purchase Invoice Item"
- ):
- target.purchase_order = source.purchase_order
- target.po_detail = source.purchase_order_item
-
- if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
- target.use_serial_batch_fields = 1
-
- item_field_map = {
- "doctype": target_doctype + " Item",
- "field_no_map": ["income_account", "expense_account", "cost_center", "warehouse"],
- "field_map": {
- "rate": "rate",
- },
- "postprocess": update_item,
- "condition": lambda doc: doc.qty > 0,
- }
-
- if doctype in ["Sales Invoice", "Sales Order"]:
- item_field_map["field_map"].update(
- {
- "name": target_detail_field,
- }
- )
-
- if source_doc.get("update_stock"):
- item_field_map["field_map"].update(
- {
- source_document_warehouse_field: target_document_warehouse_field,
- "batch_no": "batch_no",
- "serial_no": "serial_no",
- }
- )
- elif target_doctype == "Sales Order":
- item_field_map["field_map"].update(
- {
- source_document_warehouse_field: "warehouse",
- }
- )
-
- doclist = get_mapped_doc(
- doctype,
- source_name,
- {
- doctype: {
- "doctype": target_doctype,
- "postprocess": update_details,
- "set_target_warehouse": "set_from_warehouse",
- "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse", "cost_center"],
- },
- doctype + " Item": item_field_map,
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-def get_received_items(reference_name, doctype, reference_fieldname):
- reference_field = "inter_company_invoice_reference"
- if doctype == "Purchase Order":
- reference_field = "inter_company_order_reference"
-
- filters = {
- reference_field: reference_name,
- "docstatus": 1,
- }
-
- target_doctypes = frappe.get_all(
- doctype,
- filters=filters,
- as_list=True,
- )
-
- if target_doctypes:
- target_doctypes = list(target_doctypes[0])
-
- received_items_map = frappe._dict(
- frappe.get_all(
- doctype + " Item",
- filters={"parent": ("in", target_doctypes)},
- fields=[reference_fieldname, "qty"],
- as_list=1,
- )
- )
-
- return received_items_map
-
-
-def set_purchase_references(doc):
- # add internal PO or PR links if any
-
- if doc.is_internal_transfer():
- if doc.doctype == "Purchase Receipt":
- so_item_map = get_delivery_note_details(doc.inter_company_invoice_reference)
-
- if so_item_map:
- pd_item_map, parent_child_map, warehouse_map = get_pd_details(
- "Purchase Order Item", so_item_map, "sales_order_item"
- )
-
- update_pr_items(doc, so_item_map, pd_item_map, parent_child_map, warehouse_map)
-
- elif doc.doctype == "Purchase Invoice":
- dn_item_map, so_item_map = get_sales_invoice_details(doc.inter_company_invoice_reference)
- # First check for Purchase receipt
- if list(dn_item_map.values()):
- pd_item_map, parent_child_map, warehouse_map = get_pd_details(
- "Purchase Receipt Item", dn_item_map, "delivery_note_item"
- )
-
- update_pi_items(
- doc,
- "pr_detail",
- "purchase_receipt",
- dn_item_map,
- pd_item_map,
- parent_child_map,
- warehouse_map,
- )
-
-
-def update_pi_items(
- doc,
- detail_field,
- parent_field,
- sales_item_map,
- purchase_item_map,
- parent_child_map,
- warehouse_map,
-):
- for item in doc.get("items"):
- item.set(detail_field, purchase_item_map.get(sales_item_map.get(item.sales_invoice_item)))
- item.set(parent_field, parent_child_map.get(sales_item_map.get(item.sales_invoice_item)))
- if doc.update_stock:
- item.warehouse = warehouse_map.get(sales_item_map.get(item.sales_invoice_item))
- if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
- item.warehouse = frappe.db.get_value(
- "Purchase Order Item", item.purchase_order_item, "warehouse"
- )
-
-
-def update_pr_items(doc, sales_item_map, purchase_item_map, parent_child_map, warehouse_map):
- for item in doc.get("items"):
- item.warehouse = warehouse_map.get(sales_item_map.get(item.delivery_note_item))
- if not item.warehouse and item.get("purchase_order") and item.get("purchase_order_item"):
- item.warehouse = frappe.db.get_value("Purchase Order Item", item.purchase_order_item, "warehouse")
-
-
-def get_delivery_note_details(internal_reference):
- si_item_details = frappe.get_all(
- "Delivery Note Item", fields=["name", "so_detail"], filters={"parent": internal_reference}
- )
-
- return {d.name: d.so_detail for d in si_item_details if d.so_detail}
-
-
-def get_sales_invoice_details(internal_reference):
- dn_item_map = {}
- so_item_map = {}
-
- si_item_details = frappe.get_all(
- "Sales Invoice Item",
- fields=["name", "so_detail", "dn_detail"],
- filters={"parent": internal_reference},
- )
-
- for d in si_item_details:
- if d.dn_detail:
- dn_item_map.setdefault(d.name, d.dn_detail)
- if d.so_detail:
- so_item_map.setdefault(d.name, d.so_detail)
-
- return dn_item_map, so_item_map
-
-
-def get_pd_details(doctype, sd_detail_map, sd_detail_field):
- pd_item_map = {}
- accepted_warehouse_map = {}
- parent_child_map = {}
-
- pd_item_details = frappe.get_all(
- doctype,
- fields=[sd_detail_field, "name", "warehouse", "parent"],
- filters={sd_detail_field: ("in", list(sd_detail_map.values()))},
- )
-
- for d in pd_item_details:
- pd_item_map.setdefault(d.get(sd_detail_field), d.name)
- parent_child_map.setdefault(d.get(sd_detail_field), d.parent)
- accepted_warehouse_map.setdefault(d.get(sd_detail_field), d.warehouse)
-
- return pd_item_map, parent_child_map, accepted_warehouse_map
-
-
-def update_taxes(
- doc,
- party=None,
- party_type=None,
- company=None,
- doctype=None,
- party_address=None,
- company_address=None,
- shipping_address_name=None,
- master_doctype=None,
-):
- # Update Party Details
- party_details = _get_party_details(
- party=party,
- party_type=party_type,
- company=company,
- doctype=doctype,
- party_address=party_address,
- company_address=company_address,
- shipping_address=shipping_address_name,
- )
-
- # Update taxes and charges if any
- doc.taxes_and_charges = party_details.get("taxes_and_charges")
- doc.set("taxes", party_details.get("taxes"))
-
-
-def update_address(doc, address_field, address_display_field, address_name):
- doc.set(address_field, address_name)
- fetch_values = get_fetch_values(doc.doctype, address_field, address_name)
-
- for key, value in fetch_values.items():
- doc.set(key, value)
-
- doc.set(address_display_field, get_address_display(doc.get(address_field)))
+@frappe.whitelist()
+def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
+ return _get_bank_cash_account(mode_of_payment, company)
@frappe.whitelist()
-def get_loyalty_programs(customer: str):
- """sets applicable loyalty program to the customer or returns a list of applicable programs"""
- from erpnext.selling.doctype.customer.customer import get_loyalty_programs
+def get_loyalty_programs(customer: str) -> list:
+ from .services.loyalty import get_loyalty_programs as _get
- customer = frappe.get_doc("Customer", customer)
- if customer.loyalty_program:
- return [customer.loyalty_program]
-
- lp_details = get_loyalty_programs(customer)
-
- if len(lp_details) == 1:
- customer.db_set("loyalty_program", lp_details[0])
- return lp_details
- else:
- return lp_details
-
-
-@frappe.whitelist()
-def create_invoice_discounting(source_name: str, target_doc: str | Document | None = None):
- invoice = frappe.get_doc("Sales Invoice", source_name)
- invoice_discounting = frappe.new_doc("Invoice Discounting")
- invoice_discounting.company = invoice.company
- invoice_discounting.append(
- "invoices",
- {
- "sales_invoice": source_name,
- "customer": invoice.customer,
- "posting_date": invoice.posting_date,
- "outstanding_amount": invoice.outstanding_amount,
- },
- )
-
- return invoice_discounting
-
-
-def update_multi_mode_option(doc, pos_profile):
- def append_payment(payment_mode):
- payment = doc.append("payments", {})
- payment.default = payment_mode.default
- payment.mode_of_payment = payment_mode.mop
- payment.account = payment_mode.default_account
- payment.type = payment_mode.type
-
- mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
-
- doc.set("payments", [])
- invalid_modes = []
- mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")]
- mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company)
-
- for row in pos_profile.get("payments"):
- payment_mode = mode_of_payments_info.get(row.mode_of_payment)
- if not payment_mode:
- invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment))
- continue
-
- payment_mode.default = row.default
- append_payment(payment_mode)
-
- if invalid_modes:
- if invalid_modes == 1:
- msg = _("Please set default Cash or Bank account in Mode of Payment {}")
- else:
- msg = _("Please set default Cash or Bank account in Mode of Payments {}")
- frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
-
- if mop_refetched:
- frappe.toast(
- _("Payment methods refreshed. Please review before proceeding."),
- indicator="orange",
- )
-
-
-def get_all_mode_of_payments(doc):
- ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
- ModeOfPayment = frappe.qb.DocType("Mode of Payment")
-
- query = (
- frappe.qb.from_(ModeOfPaymentAccount)
- .join(ModeOfPayment)
- .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
- .select(
- ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
- )
- .where(ModeOfPaymentAccount.company == doc.company)
- .where(ModeOfPayment.enabled == 1)
- )
-
- return query.run(as_dict=1)
-
-
-def get_mode_of_payments_info(mode_of_payments, company):
- ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
- ModeOfPayment = frappe.qb.DocType("Mode of Payment")
-
- query = (
- frappe.qb.from_(ModeOfPaymentAccount)
- .join(ModeOfPayment)
- .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
- .select(
- ModeOfPaymentAccount.default_account,
- ModeOfPaymentAccount.parent.as_("mop"),
- ModeOfPayment.type.as_("type"),
- )
- .where(ModeOfPaymentAccount.company == company)
- .where(ModeOfPayment.enabled == 1)
- .where(ModeOfPayment.name.isin(mode_of_payments))
- .groupby(ModeOfPayment.name)
- )
-
- data = query.run(as_dict=1)
-
- return {row.get("mop"): row for row in data}
-
-
-def get_mode_of_payment_info(mode_of_payment, company):
- ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
- ModeOfPayment = frappe.qb.DocType("Mode of Payment")
-
- query = (
- frappe.qb.from_(ModeOfPayment)
- .join(ModeOfPaymentAccount)
- .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
- .select(
- ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
- )
- .where(ModeOfPaymentAccount.company == company)
- .where(ModeOfPayment.enabled == 1)
- .where(ModeOfPayment.name == mode_of_payment)
- )
-
- return query.run(as_dict=1)
-
-
-@frappe.whitelist()
-def create_dunning(
- source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False
-):
- from frappe.model.mapper import get_mapped_doc
-
- def postprocess_dunning(source, target):
- from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
-
- dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
- if dunning_type:
- dunning_type = frappe.get_doc("Dunning Type", dunning_type)
- target.dunning_type = dunning_type.name
- target.rate_of_interest = dunning_type.rate_of_interest
- target.dunning_fee = dunning_type.dunning_fee
- target.income_account = dunning_type.income_account
- target.cost_center = dunning_type.cost_center
- letter_text = get_dunning_letter_text(
- dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
- )
-
- if letter_text:
- target.body_text = letter_text.get("body_text")
- target.closing_text = letter_text.get("closing_text")
- target.language = letter_text.get("language")
-
- # update outstanding from doc
- if source.payment_schedule and len(source.payment_schedule) == 1:
- for row in target.overdue_payments:
- if row.payment_schedule == source.payment_schedule[0].name:
- row.outstanding = source.get("outstanding_amount")
-
- target.validate()
-
- return get_mapped_doc(
- from_doctype="Sales Invoice",
- from_docname=source_name,
- target_doc=target_doc,
- table_maps={
- "Sales Invoice": {
- "doctype": "Dunning",
- "field_map": {"customer_address": "customer_address", "parent": "sales_invoice"},
- },
- "Payment Schedule": {
- "doctype": "Overdue Payment",
- "field_map": {"name": "payment_schedule", "parent": "sales_invoice"},
- "condition": lambda doc: doc.outstanding > 0 and getdate(doc.due_date) < getdate(),
- },
- },
- postprocess=postprocess_dunning,
- ignore_permissions=ignore_permissions,
- )
+ return _get(customer)
def check_if_return_invoice_linked_with_payment_entry(self):
- # If a Return invoice is linked with payment entry along with other invoices,
- # the cancellation of the Return causes allocated amount to be greater than paid
if not frappe.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"):
return
diff --git a/erpnext/accounts/doctype/sales_invoice/services/__init__.py b/erpnext/accounts/doctype/sales_invoice/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py
new file mode 100644
index 00000000000..a2843bef7a0
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/services/fixed_assets.py
@@ -0,0 +1,173 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Fixed asset lifecycle helpers for Sales Invoice."""
+
+import frappe
+from frappe import _
+from frappe.utils import flt, get_link_to_form
+
+from erpnext.assets.doctype.asset.depreciation import (
+ depreciate_asset,
+ reset_depreciation_schedule,
+ reverse_depreciation_entry_made_on_disposal,
+)
+from erpnext.assets.doctype.asset.mapper import split_asset
+from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
+
+
+class FixedAssetService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def validate_fixed_asset(self) -> None:
+ doc = self.doc
+ if doc.doctype != "Sales Invoice":
+ return
+
+ for d in doc.get("items"):
+ if not d.is_fixed_asset:
+ continue
+
+ if d.asset:
+ if not doc.is_return:
+ asset_status = frappe.db.get_value("Asset", d.asset, "status")
+ if doc.update_stock:
+ frappe.throw(_("'Update Stock' cannot be checked for fixed asset sale"))
+ elif asset_status in ("Scrapped", "Cancelled", "Capitalized"):
+ frappe.throw(
+ _("Row #{0}: Asset {1} cannot be sold, it is already {2}").format(
+ d.idx, d.asset, asset_status
+ )
+ )
+ elif asset_status == "Sold" and not doc.is_return:
+ frappe.throw(_("Row #{0}: Asset {1} is already sold").format(d.idx, d.asset))
+ elif not doc.return_against:
+ frappe.throw(_("Row #{0}: Return Against is required for returning asset").format(d.idx))
+ else:
+ frappe.throw(
+ _("Row #{0}: You must select an Asset for Item {1}.").format(d.idx, d.item_code),
+ title=_("Missing Asset"),
+ )
+
+ def set_income_account_for_fixed_assets(self) -> None:
+ for item in self.doc.items:
+ item.set_income_account_for_fixed_asset(self.doc.company)
+
+ def process_asset_depreciation(self) -> None:
+ doc = self.doc
+ if doc.is_internal_transfer():
+ return
+
+ if (doc.is_return and doc.docstatus == 2) or (not doc.is_return and doc.docstatus == 1):
+ self._depreciate_asset_on_sale()
+ else:
+ self._restore_asset()
+
+ self._update_asset()
+
+ def split_asset_based_on_sale_qty(self) -> None:
+ asset_qty_map = self._get_asset_qty()
+ for asset, qty in asset_qty_map.items():
+ if qty["actual_qty"] < qty["sale_qty"]:
+ frappe.throw(
+ _(
+ "Sell quantity cannot exceed the asset quantity. Asset {0} has only {1} item(s)."
+ ).format(asset, qty["actual_qty"])
+ )
+
+ remaining_qty = qty["actual_qty"] - qty["sale_qty"]
+ if remaining_qty > 0:
+ split_asset(asset, remaining_qty)
+
+ def get_disposal_date(self) -> str:
+ doc = self.doc
+ if doc.is_return:
+ return frappe.db.get_value("Sales Invoice", doc.return_against, "posting_date")
+ return doc.posting_date
+
+ def _depreciate_asset_on_sale(self) -> None:
+ disposal_date = self.get_disposal_date()
+ for d in self.doc.get("items"):
+ if d.asset:
+ asset = frappe.get_doc("Asset", d.asset)
+ if asset.calculate_depreciation and asset.status != "Fully Depreciated":
+ depreciate_asset(asset, disposal_date, self._get_note_for_asset_sale(asset))
+
+ def _restore_asset(self) -> None:
+ for d in self.doc.get("items"):
+ if d.asset:
+ asset = frappe.get_cached_doc("Asset", d.asset)
+ if asset.calculate_depreciation:
+ reverse_depreciation_entry_made_on_disposal(asset)
+ reset_depreciation_schedule(asset, self._get_note_for_asset_return(asset))
+
+ def _update_asset(self) -> None:
+ doc = self.doc
+ disposal_date = self.get_disposal_date()
+
+ for d in doc.get("items"):
+ if not d.asset:
+ continue
+
+ asset = frappe.get_cached_doc("Asset", d.asset)
+
+ if (doc.is_return and doc.docstatus == 1) or (not doc.is_return and doc.docstatus == 2):
+ note = _("Asset returned") if doc.is_return else _("Asset sold")
+ asset_status, disposal_date = None, None
+ else:
+ note = _("Asset sold") if not doc.is_return else _("Return invoice of asset cancelled")
+ asset_status = "Sold"
+
+ frappe.db.set_value("Asset", d.asset, "disposal_date", disposal_date)
+ add_asset_activity(asset.name, note)
+ asset.set_status(asset_status)
+
+ def _get_asset_qty(self) -> dict:
+ doc = self.doc
+ asset_qty_map = {}
+
+ assets = {row.asset for row in doc.items if row.is_fixed_asset and row.asset}
+ if not assets or doc.is_return:
+ return asset_qty_map
+
+ asset_actual_qty = dict(
+ frappe.db.get_all(
+ "Asset",
+ {"name": ["in", list(assets)]},
+ ["name", "asset_quantity"],
+ as_list=True,
+ )
+ )
+ for row in doc.items:
+ if row.is_fixed_asset and row.asset:
+ actual_qty = asset_actual_qty.get(row.asset)
+ if row.asset in asset_qty_map:
+ asset_qty_map[row.asset]["sale_qty"] += flt(row.qty)
+ else:
+ asset_qty_map[row.asset] = {
+ "sale_qty": flt(row.qty),
+ "actual_qty": flt(actual_qty),
+ }
+
+ return asset_qty_map
+
+ def _get_note_for_asset_sale(self, asset) -> str:
+ doc = self.doc
+ return _("This schedule was created when Asset {0} was {1} through Sales Invoice {2}.").format(
+ get_link_to_form(asset.doctype, asset.name),
+ _("returned") if doc.is_return else _("sold"),
+ get_link_to_form(doc.doctype, doc.get("name")),
+ )
+
+ def _get_note_for_asset_return(self, asset) -> str:
+ doc = self.doc
+ asset_link = get_link_to_form(asset.doctype, asset.name)
+ invoice_link = get_link_to_form(doc.doctype, doc.get("name"))
+ if doc.is_return:
+ return _(
+ "This schedule was created when Asset {0} was returned through Sales Invoice {1}."
+ ).format(asset_link, invoice_link)
+ return _(
+ "This schedule was created when Asset {0} was restored due to Sales Invoice {1} cancellation."
+ ).format(asset_link, invoice_link)
diff --git a/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py
new file mode 100644
index 00000000000..9b5d8e2f187
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/services/gl_composer.py
@@ -0,0 +1,661 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import cint, cstr, flt, get_link_to_form
+
+import erpnext
+from erpnext.accounts.general_ledger import get_round_off_account_and_cost_center
+from erpnext.accounts.services.base_gl_composer import BaseGLComposer
+from erpnext.accounts.services.taxes import TaxService
+from erpnext.accounts.utils import get_account_currency
+from erpnext.assets.doctype.asset.depreciation import (
+ get_gl_entries_on_asset_disposal,
+ get_gl_entries_on_asset_regain,
+)
+
+
+class SalesInvoiceGLComposer(BaseGLComposer):
+ """Assembles the GL entries for a Sales Invoice."""
+
+ def compose(self, inventory_account_map=None):
+ from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_regional_gl_entries
+ from erpnext.accounts.general_ledger import merge_similar_entries
+
+ doc = self.doc
+ gl_entries = []
+
+ self.make_customer_gl_entry(gl_entries)
+
+ self.make_tax_gl_entries(gl_entries)
+ self.make_internal_transfer_gl_entries(gl_entries)
+
+ self.make_item_gl_entries(gl_entries)
+
+ disable_sdbnb_in_sr = frappe.get_cached_value("Company", doc.company, "disable_sdbnb_in_sr")
+
+ if not (doc.is_return and disable_sdbnb_in_sr):
+ self.stock_delivered_but_not_billed_gl_entries(gl_entries)
+
+ self.make_precision_loss_gl_entry(gl_entries)
+ self.make_discount_gl_entries(gl_entries)
+
+ gl_entries = make_regional_gl_entries(gl_entries, doc)
+
+ # merge gl entries before adding pos entries
+ gl_entries = merge_similar_entries(gl_entries)
+
+ self.make_loyalty_point_redemption_gle(gl_entries)
+ self.make_pos_gl_entries(gl_entries)
+
+ self.make_write_off_gl_entry(gl_entries)
+ self.make_gle_for_rounding_adjustment(gl_entries)
+
+ doc.set_transaction_currency_and_rate_in_gl_map(gl_entries)
+ return gl_entries
+
+ def make_precision_loss_gl_entry(self, gl_entries):
+ doc = self.doc
+ (
+ round_off_account,
+ round_off_cost_center,
+ _round_off_for_opening,
+ ) = get_round_off_account_and_cost_center(
+ doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center
+ )
+
+ precision_loss = doc.get("base_net_total") - flt(
+ doc.get("net_total") * doc.conversion_rate, doc.precision("net_total")
+ )
+
+ if precision_loss:
+ gl_entries.append(
+ doc.get_gl_dict(
+ {
+ "account": round_off_account,
+ "against": doc.customer,
+ "debit": precision_loss,
+ "cost_center": round_off_cost_center
+ if doc.use_company_roundoff_cost_center
+ else doc.cost_center or round_off_cost_center,
+ "remarks": _("Net total calculation precision loss"),
+ }
+ )
+ )
+
+ def make_discount_gl_entries(self, gl_entries):
+ doc = self.doc
+ enable_discount_accounting = cint(
+ frappe.get_single_value("Selling Settings", "enable_discount_accounting")
+ )
+
+ if enable_discount_accounting:
+ for item in doc.get("items"):
+ if item.get("discount_amount") and item.get("discount_account"):
+ discount_amount = item.discount_amount * item.qty
+ income_account = (
+ item.income_account
+ if (not item.enable_deferred_revenue or doc.is_return)
+ else item.deferred_revenue_account
+ )
+
+ account_currency = get_account_currency(item.discount_account)
+ gl_entries.append(
+ doc.get_gl_dict(
+ {
+ "account": item.discount_account,
+ "against": doc.customer,
+ "debit": flt(
+ discount_amount * doc.get("conversion_rate"),
+ item.precision("discount_amount"),
+ ),
+ "debit_in_transaction_currency": flt(
+ discount_amount, item.precision("discount_amount")
+ ),
+ "cost_center": item.cost_center,
+ "project": item.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ account_currency = get_account_currency(income_account)
+ gl_entries.append(
+ doc.get_gl_dict(
+ {
+ "account": income_account,
+ "against": doc.customer,
+ "credit": flt(
+ discount_amount * doc.get("conversion_rate"),
+ item.precision("discount_amount"),
+ ),
+ "credit_in_transaction_currency": flt(
+ discount_amount, item.precision("discount_amount")
+ ),
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ if (
+ (enable_discount_accounting or doc.get("is_cash_or_non_trade_discount"))
+ and doc.get("additional_discount_account")
+ and doc.get("discount_amount")
+ ):
+ gl_entries.append(
+ doc.get_gl_dict(
+ {
+ "account": doc.additional_discount_account,
+ "against": doc.customer,
+ "debit": doc.base_discount_amount,
+ "cost_center": doc.cost_center or erpnext.get_default_cost_center(doc.company),
+ },
+ item=doc,
+ )
+ )
+
+ def stock_delivered_but_not_billed_gl_entries(self, gl_entries):
+ doc = self.doc
+ if doc.update_stock or not cint(erpnext.is_perpetual_inventory_enabled(doc.company)):
+ return
+
+ for item in doc.get("items"):
+ if not item.delivery_note and not item.dn_detail:
+ continue
+
+ if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"):
+ continue
+
+ dn_expense_account = frappe.get_cached_value(
+ "Delivery Note Item", item.dn_detail, "expense_account"
+ )
+ if (
+ not dn_expense_account
+ or frappe.get_cached_value("Account", dn_expense_account, "account_type")
+ != "Stock Delivered But Not Billed"
+ or not item.expense_account
+ or dn_expense_account == item.expense_account
+ ):
+ continue
+
+ delivery_note = item.delivery_note or frappe.get_cached_value(
+ "Delivery Note Item", item.dn_detail, "parent"
+ )
+ if not delivery_note:
+ continue
+
+ item_g = frappe.get_cached_value(
+ "Stock Ledger Entry",
+ {
+ "voucher_no": delivery_note,
+ "voucher_detail_no": item.dn_detail,
+ "item_code": item.item_code,
+ "is_cancelled": 0,
+ },
+ ["stock_value_difference", "actual_qty"],
+ as_dict=True,
+ )
+
+ if not item_g or not flt(item_g.actual_qty):
+ continue
+ valuation_rate = flt(item_g.stock_value_difference) / flt(item_g.actual_qty)
+ valuation_amount = valuation_rate * item.stock_qty
+ dn_account_currency = get_account_currency(dn_expense_account)
+ item_account_currency = get_account_currency(item.expense_account)
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": dn_expense_account,
+ "against": item.expense_account,
+ "credit": flt(valuation_amount),
+ "credit_in_account_currency": flt(valuation_amount),
+ "cost_center": item.cost_center,
+ },
+ dn_account_currency,
+ item=item,
+ )
+ )
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item.expense_account,
+ "against": dn_expense_account,
+ "debit": flt(valuation_amount),
+ "debit_in_account_currency": flt(valuation_amount),
+ "cost_center": item.cost_center,
+ },
+ item_account_currency,
+ item=item,
+ )
+ )
+
+ def make_customer_gl_entry(self, gl_entries):
+ doc = self.doc
+ # Checked both rounding_adjustment and rounded_total
+ # because rounded_total had value even before introduction of posting GLE based on rounded total
+ grand_total = (
+ doc.rounded_total if (doc.rounding_adjustment and doc.rounded_total) else doc.grand_total
+ )
+ base_grand_total = flt(
+ doc.base_rounded_total
+ if (doc.base_rounding_adjustment and doc.base_rounded_total)
+ else doc.base_grand_total,
+ doc.precision("base_grand_total"),
+ )
+
+ if grand_total and not doc.is_internal_transfer():
+ against_voucher = doc.name
+ if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
+ against_voucher = doc.return_against
+
+ # Did not use base_grand_total to book rounding loss gle
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.debit_to,
+ "party_type": "Customer",
+ "party": doc.customer,
+ "due_date": doc.due_date,
+ "against": doc.against_income_account,
+ "debit": base_grand_total,
+ "debit_in_account_currency": base_grand_total
+ if doc.party_account_currency == doc.company_currency
+ else grand_total,
+ "debit_in_transaction_currency": grand_total,
+ "against_voucher": against_voucher,
+ "against_voucher_type": doc.doctype,
+ "cost_center": doc.cost_center,
+ "project": doc.project,
+ },
+ doc.party_account_currency,
+ item=doc,
+ )
+ )
+
+ def make_tax_gl_entries(self, gl_entries):
+ doc = self.doc
+ tax_service = TaxService(doc)
+ enable_discount_accounting = cint(
+ frappe.get_single_value("Selling Settings", "enable_discount_accounting")
+ )
+
+ for tax in doc.get("taxes"):
+ amount, base_amount = tax_service.get_tax_amounts(tax, enable_discount_accounting)
+
+ if flt(tax.base_tax_amount_after_discount_amount):
+ account_currency = get_account_currency(tax.account_head)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": tax.account_head,
+ "against": doc.customer,
+ "credit": flt(base_amount, tax.precision("tax_amount_after_discount_amount")),
+ "credit_in_account_currency": (
+ flt(base_amount, tax.precision("base_tax_amount_after_discount_amount"))
+ if account_currency == doc.company_currency
+ else flt(amount, tax.precision("tax_amount_after_discount_amount"))
+ ),
+ "credit_in_transaction_currency": flt(
+ amount, tax.precision("tax_amount_after_discount_amount")
+ ),
+ "cost_center": tax.cost_center,
+ },
+ account_currency,
+ item=tax,
+ )
+ )
+
+ def make_internal_transfer_gl_entries(self, gl_entries):
+ doc = self.doc
+ if doc.is_internal_transfer() and flt(doc.base_total_taxes_and_charges):
+ account_currency = get_account_currency(doc.unrealized_profit_loss_account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.unrealized_profit_loss_account,
+ "against": doc.customer,
+ "debit": flt(doc.total_taxes_and_charges),
+ "debit_in_account_currency": flt(doc.base_total_taxes_and_charges),
+ "debit_in_transaction_currency": flt(doc.total_taxes_and_charges),
+ "cost_center": doc.cost_center,
+ },
+ account_currency,
+ item=doc,
+ )
+ )
+
+ def make_item_gl_entries(self, gl_entries):
+ from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
+
+ doc = self.doc
+ tax_service = TaxService(doc)
+ # income account gl entries
+ enable_discount_accounting = cint(
+ frappe.get_single_value("Selling Settings", "enable_discount_accounting")
+ )
+
+ for item in doc.get("items"):
+ if (
+ flt(item.base_net_amount, item.precision("base_net_amount"))
+ or item.is_fixed_asset
+ or enable_discount_accounting
+ ):
+ # Do not book income for transfer within same company
+ if doc.is_internal_transfer():
+ continue
+
+ if item.is_fixed_asset and item.asset:
+ self.get_gl_entries_for_fixed_asset(item, gl_entries)
+ else:
+ income_account = (
+ item.income_account
+ if (not item.enable_deferred_revenue or doc.is_return)
+ else item.deferred_revenue_account
+ )
+
+ amount, base_amount = tax_service.get_amount_and_base_amount(
+ item, enable_discount_accounting
+ )
+
+ account_currency = get_account_currency(income_account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": income_account,
+ "against": doc.customer,
+ "credit": flt(base_amount, item.precision("base_net_amount")),
+ "credit_in_account_currency": (
+ flt(base_amount, item.precision("base_net_amount"))
+ if account_currency == doc.company_currency
+ else flt(amount, item.precision("net_amount"))
+ ),
+ "credit_in_transaction_currency": flt(amount, item.precision("net_amount")),
+ "cost_center": item.cost_center,
+ "project": item.project or doc.project,
+ },
+ account_currency,
+ item=item,
+ )
+ )
+
+ # expense account gl entries
+ if cint(doc.update_stock) and erpnext.is_perpetual_inventory_enabled(doc.company):
+ gl_entries += super(SalesInvoice, doc).get_gl_entries()
+
+ def get_gl_entries_for_fixed_asset(self, item, gl_entries):
+ doc = self.doc
+ asset = frappe.get_cached_doc("Asset", item.asset)
+
+ if doc.is_return:
+ fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
+ asset,
+ item.base_net_amount,
+ item.finance_book,
+ doc.get("doctype"),
+ doc.get("name"),
+ doc.get("posting_date"),
+ )
+ else:
+ fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
+ asset,
+ item.base_net_amount,
+ item.finance_book,
+ doc.get("doctype"),
+ doc.get("name"),
+ doc.get("posting_date"),
+ )
+
+ for gle in fixed_asset_gl_entries:
+ gle["against"] = doc.customer
+ gl_entries.append(self.get_gl_dict(gle, item=item))
+
+ def make_loyalty_point_redemption_gle(self, gl_entries):
+ doc = self.doc
+ if cint(doc.redeem_loyalty_points and doc.loyalty_points and not doc.is_consolidated):
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.debit_to,
+ "party_type": "Customer",
+ "party": doc.customer,
+ "against": "Expense account - "
+ + cstr(doc.loyalty_redemption_account)
+ + " for the Loyalty Program",
+ "credit": doc.loyalty_amount,
+ "credit_in_transaction_currency": doc.loyalty_amount,
+ "against_voucher": doc.return_against if cint(doc.is_return) else doc.name,
+ "against_voucher_type": doc.doctype,
+ "cost_center": doc.cost_center,
+ },
+ item=doc,
+ )
+ )
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.loyalty_redemption_account,
+ "cost_center": doc.cost_center or doc.loyalty_redemption_cost_center,
+ "against": doc.customer,
+ "debit": doc.loyalty_amount,
+ "debit_in_transaction_currency": doc.loyalty_amount,
+ "remark": "Loyalty Points redeemed by the customer",
+ },
+ item=doc,
+ )
+ )
+
+ def make_pos_gl_entries(self, gl_entries):
+ doc = self.doc
+ if cint(doc.is_pos):
+ skip_change_gl_entries = not cint(
+ frappe.get_single_value("POS Settings", "post_change_gl_entries")
+ )
+
+ for payment_mode in doc.payments:
+ if skip_change_gl_entries and payment_mode.account == doc.account_for_change_amount:
+ payment_mode.base_amount -= flt(doc.change_amount)
+
+ against_voucher = doc.name
+ if doc.is_return and doc.return_against and not doc.update_outstanding_for_self:
+ against_voucher = doc.return_against
+
+ if payment_mode.base_amount:
+ # POS, make payment entries
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.debit_to,
+ "party_type": "Customer",
+ "party": doc.customer,
+ "against": payment_mode.account,
+ "credit": payment_mode.base_amount,
+ "credit_in_account_currency": payment_mode.base_amount
+ if doc.party_account_currency == doc.company_currency
+ else payment_mode.amount,
+ "credit_in_transaction_currency": payment_mode.amount,
+ "against_voucher": against_voucher,
+ "against_voucher_type": doc.doctype,
+ "cost_center": doc.cost_center,
+ },
+ doc.party_account_currency,
+ item=doc,
+ )
+ )
+
+ payment_mode_account_currency = get_account_currency(payment_mode.account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": payment_mode.account,
+ "against": doc.customer,
+ "debit": payment_mode.base_amount,
+ "debit_in_account_currency": payment_mode.base_amount
+ if payment_mode_account_currency == doc.company_currency
+ else payment_mode.amount,
+ "debit_in_transaction_currency": payment_mode.amount,
+ "cost_center": doc.cost_center,
+ },
+ payment_mode_account_currency,
+ item=doc,
+ )
+ )
+
+ if not skip_change_gl_entries:
+ gl_entries.extend(self.get_gle_for_change_amount())
+
+ def get_gle_for_change_amount(self) -> list[dict]:
+ doc = self.doc
+ if not doc.change_amount:
+ return []
+
+ if not doc.account_for_change_amount:
+ frappe.throw(_("Please set Account for Change Amount"), title=_("Mandatory Field"))
+
+ return [
+ self.get_gl_dict(
+ {
+ "account": doc.debit_to,
+ "party_type": "Customer",
+ "party": doc.customer,
+ "against": doc.account_for_change_amount,
+ "debit": flt(doc.base_change_amount),
+ "debit_in_account_currency": flt(doc.base_change_amount)
+ if doc.party_account_currency == doc.company_currency
+ else flt(doc.change_amount),
+ "debit_in_transaction_currency": flt(doc.change_amount),
+ "against_voucher": doc.return_against
+ if cint(doc.is_return) and doc.return_against
+ else doc.name,
+ "against_voucher_type": doc.doctype,
+ "cost_center": doc.cost_center,
+ "project": doc.project,
+ },
+ doc.party_account_currency,
+ item=doc,
+ ),
+ self.get_gl_dict(
+ {
+ "account": doc.account_for_change_amount,
+ "against": doc.customer,
+ "credit": doc.base_change_amount,
+ "credit_in_transaction_currency": doc.change_amount,
+ "cost_center": doc.cost_center,
+ },
+ item=doc,
+ ),
+ ]
+
+ def make_write_off_gl_entry(self, gl_entries):
+ doc = self.doc
+ # write off entries, applicable if only pos
+ if (
+ doc.is_pos
+ and doc.write_off_account
+ and flt(doc.write_off_amount, doc.precision("write_off_amount"))
+ ):
+ write_off_account_currency = get_account_currency(doc.write_off_account)
+ default_cost_center = frappe.get_cached_value("Company", doc.company, "cost_center")
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.debit_to,
+ "party_type": "Customer",
+ "party": doc.customer,
+ "against": doc.write_off_account,
+ "credit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
+ "credit_in_account_currency": (
+ flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
+ if doc.party_account_currency == doc.company_currency
+ else flt(doc.write_off_amount, doc.precision("write_off_amount"))
+ ),
+ "credit_in_transaction_currency": flt(
+ doc.write_off_amount, doc.precision("write_off_amount")
+ ),
+ "against_voucher": doc.return_against if cint(doc.is_return) else doc.name,
+ "against_voucher_type": doc.doctype,
+ "cost_center": doc.cost_center,
+ "project": doc.project,
+ },
+ doc.party_account_currency,
+ item=doc,
+ )
+ )
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": doc.write_off_account,
+ "against": doc.customer,
+ "debit": flt(doc.base_write_off_amount, doc.precision("base_write_off_amount")),
+ "debit_in_account_currency": (
+ flt(doc.base_write_off_amount, doc.precision("base_write_off_amount"))
+ if write_off_account_currency == doc.company_currency
+ else flt(doc.write_off_amount, doc.precision("write_off_amount"))
+ ),
+ "debit_in_transaction_currency": flt(
+ doc.write_off_amount, doc.precision("write_off_amount")
+ ),
+ "cost_center": doc.cost_center or doc.write_off_cost_center or default_cost_center,
+ },
+ write_off_account_currency,
+ item=doc,
+ )
+ )
+
+ def make_gle_for_rounding_adjustment(self, gl_entries):
+ doc = self.doc
+ if (
+ flt(doc.rounding_adjustment, doc.precision("rounding_adjustment"))
+ and doc.base_rounding_adjustment
+ and not doc.is_internal_transfer()
+ ):
+ (
+ round_off_account,
+ round_off_cost_center,
+ round_off_for_opening,
+ ) = get_round_off_account_and_cost_center(
+ doc.company, "Sales Invoice", doc.name, doc.use_company_roundoff_cost_center
+ )
+
+ if doc.is_opening == "Yes" and doc.rounding_adjustment:
+ if not round_off_for_opening:
+ frappe.throw(
+ _(
+ "Opening Invoice has rounding adjustment of {0}.
'{1}' account is required to post these values. Please set it in Company: {2}.
Or, '{3}' can be enabled to not post any rounding adjustment."
+ ).format(
+ frappe.bold(doc.rounding_adjustment),
+ frappe.bold("Round Off for Opening"),
+ get_link_to_form("Company", doc.company),
+ frappe.bold("Disable Rounded Total"),
+ )
+ )
+ else:
+ round_off_account = round_off_for_opening
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": round_off_account,
+ "against": doc.customer,
+ "credit_in_account_currency": flt(
+ doc.rounding_adjustment, doc.precision("rounding_adjustment")
+ ),
+ "credit_in_transaction_currency": flt(
+ doc.rounding_adjustment, doc.precision("rounding_adjustment")
+ ),
+ "credit": flt(
+ doc.base_rounding_adjustment, doc.precision("base_rounding_adjustment")
+ ),
+ "cost_center": round_off_cost_center
+ if doc.use_company_roundoff_cost_center
+ else (doc.cost_center or round_off_cost_center),
+ },
+ item=doc,
+ )
+ )
diff --git a/erpnext/accounts/doctype/sales_invoice/services/inter_company.py b/erpnext/accounts/doctype/sales_invoice/services/inter_company.py
new file mode 100644
index 00000000000..c6e3abaa24b
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/services/inter_company.py
@@ -0,0 +1,68 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Inter-company transaction helpers for Sales Invoice."""
+
+import frappe
+from frappe import _
+
+
+def validate_inter_company_party(
+ doctype: str, party: str, company: str, inter_company_reference: str | None
+) -> None:
+ if not party:
+ return
+
+ if doctype in ["Sales Invoice", "Sales Order"]:
+ partytype, ref_partytype, internal = "Customer", "Supplier", "is_internal_customer"
+ ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Purchase Order"
+ else:
+ partytype, ref_partytype, internal = "Supplier", "Customer", "is_internal_supplier"
+ ref_doc = "Sales Invoice" if doctype == "Purchase Invoice" else "Sales Order"
+
+ if inter_company_reference:
+ doc = frappe.get_doc(ref_doc, inter_company_reference)
+ ref_party = doc.supplier if doctype in ["Sales Invoice", "Sales Order"] else doc.customer
+ if frappe.db.get_value(partytype, {"represents_company": doc.company}, "name") != party:
+ frappe.throw(_("Invalid {0} for Inter Company Transaction.").format(_(partytype)))
+ if frappe.get_cached_value(ref_partytype, ref_party, "represents_company") != company:
+ frappe.throw(_("Invalid Company for Inter Company Transaction."))
+
+ elif frappe.db.get_value(partytype, {"name": party, internal: 1}, "name") == party:
+ companies = [
+ d.company
+ for d in frappe.get_all(
+ "Allowed To Transact With",
+ fields=["company"],
+ filters={"parenttype": partytype, "parent": party},
+ )
+ ]
+ if company not in companies:
+ frappe.throw(
+ _(
+ "{0} not allowed to transact with {1}. Please change the Company or add the Company in the 'Allowed To Transact With'-Section in the Customer record."
+ ).format(_(partytype), company)
+ )
+
+
+def update_linked_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:
+ ref_field = (
+ "inter_company_invoice_reference"
+ if doctype in ["Sales Invoice", "Purchase Invoice"]
+ else "inter_company_order_reference"
+ )
+ if inter_company_reference:
+ frappe.db.set_value(doctype, inter_company_reference, ref_field, name)
+
+
+def unlink_inter_company_doc(doctype: str, name: str, inter_company_reference: str | None) -> None:
+ if doctype in ["Sales Invoice", "Purchase Invoice"]:
+ ref_doc = "Purchase Invoice" if doctype == "Sales Invoice" else "Sales Invoice"
+ ref_field = "inter_company_invoice_reference"
+ else:
+ ref_doc = "Purchase Order" if doctype == "Sales Order" else "Sales Order"
+ ref_field = "inter_company_order_reference"
+
+ if inter_company_reference:
+ frappe.db.set_value(doctype, name, ref_field, "")
+ frappe.db.set_value(ref_doc, inter_company_reference, ref_field, "")
diff --git a/erpnext/accounts/doctype/sales_invoice/services/loyalty.py b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py
new file mode 100644
index 00000000000..69d35429444
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/services/loyalty.py
@@ -0,0 +1,163 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Loyalty program helpers for Sales Invoice."""
+
+import frappe
+from frappe import _
+from frappe.utils import add_days, cint, flt, getdate
+
+from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
+ get_loyalty_program_details_with_points,
+)
+
+
+class LoyaltyService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def make_loyalty_point_entry(self) -> None:
+ doc = self.doc
+ returned_amount = self._get_returned_amount()
+ current_amount = flt(doc.grand_total) - cint(doc.loyalty_amount)
+ eligible_amount = current_amount - returned_amount
+ lp_details = get_loyalty_program_details_with_points(
+ doc.customer,
+ company=doc.company,
+ current_transaction_amount=current_amount,
+ loyalty_program=doc.loyalty_program,
+ expiry_date=doc.posting_date,
+ include_expired_entry=True,
+ )
+ if (
+ lp_details
+ and getdate(lp_details.from_date) <= getdate(doc.posting_date)
+ and (not lp_details.to_date or getdate(lp_details.to_date) >= getdate(doc.posting_date))
+ ):
+ collection_factor = lp_details.collection_factor if lp_details.collection_factor else 1.0
+ points_earned = cint(eligible_amount / collection_factor)
+
+ entry = frappe.get_doc(
+ {
+ "doctype": "Loyalty Point Entry",
+ "company": doc.company,
+ "loyalty_program": lp_details.loyalty_program,
+ "loyalty_program_tier": lp_details.tier_name,
+ "customer": doc.customer,
+ "invoice_type": doc.doctype,
+ "invoice": doc.name,
+ "loyalty_points": points_earned,
+ "purchase_amount": eligible_amount,
+ "expiry_date": add_days(doc.posting_date, lp_details.expiry_duration),
+ "posting_date": doc.posting_date,
+ }
+ )
+ entry.flags.ignore_permissions = 1
+ entry.save()
+ self._set_loyalty_program_tier()
+
+ def delete_loyalty_point_entry(self) -> None:
+ doc = self.doc
+ lp_entry = frappe.db.get_all(
+ "Loyalty Point Entry", filters={"invoice": doc.name, "loyalty_points": (">", 0)}, fields=["name"]
+ )
+
+ if not lp_entry:
+ return
+
+ against_lp_entry = frappe.db.get_all(
+ "Loyalty Point Entry",
+ filters={"redeem_against": lp_entry[0].name},
+ fields=["name", "invoice"],
+ )
+
+ if against_lp_entry:
+ invoice_list = ", ".join([d.invoice for d in against_lp_entry])
+ frappe.throw(
+ _(
+ "{} can't be cancelled since the Loyalty Points earned has been redeemed. "
+ "First cancel the {} No {}"
+ ).format(doc.doctype, doc.doctype, invoice_list)
+ )
+ else:
+ frappe.db.delete("Loyalty Point Entry", filters={"invoice": doc.name})
+ self._set_loyalty_program_tier()
+
+ def apply_loyalty_points(self) -> None:
+ from erpnext.accounts.doctype.loyalty_point_entry.loyalty_point_entry import (
+ get_loyalty_point_entries,
+ get_redemption_details,
+ )
+
+ doc = self.doc
+ loyalty_point_entries = get_loyalty_point_entries(
+ doc.customer, doc.loyalty_program, doc.company, doc.posting_date
+ )
+ redemption_details = get_redemption_details(doc.customer, doc.loyalty_program, doc.company)
+
+ points_to_redeem = doc.loyalty_points
+ for lp_entry in loyalty_point_entries:
+ if lp_entry.invoice_type != doc.doctype or lp_entry.invoice == doc.name:
+ continue
+ available_points = lp_entry.loyalty_points - flt(redemption_details.get(lp_entry.name))
+ redeemed_points = min(available_points, points_to_redeem)
+ entry = frappe.get_doc(
+ {
+ "doctype": "Loyalty Point Entry",
+ "company": doc.company,
+ "loyalty_program": doc.loyalty_program,
+ "loyalty_program_tier": lp_entry.loyalty_program_tier,
+ "customer": doc.customer,
+ "invoice_type": doc.doctype,
+ "invoice": doc.name,
+ "redeem_against": lp_entry.name,
+ "loyalty_points": -1 * redeemed_points,
+ "purchase_amount": doc.grand_total,
+ "expiry_date": lp_entry.expiry_date,
+ "posting_date": doc.posting_date,
+ }
+ )
+ entry.flags.ignore_permissions = 1
+ entry.save()
+ points_to_redeem -= redeemed_points
+ if points_to_redeem < 1:
+ break
+
+ def _set_loyalty_program_tier(self) -> None:
+ doc = self.doc
+ lp_details = get_loyalty_program_details_with_points(
+ doc.customer,
+ company=doc.company,
+ loyalty_program=doc.loyalty_program,
+ include_expired_entry=True,
+ )
+ customer = frappe.get_doc("Customer", doc.customer)
+ customer.db_set("loyalty_program_tier", lp_details.tier_name)
+
+ def _get_returned_amount(self) -> float:
+ from frappe.query_builder.functions import Sum
+
+ doc = frappe.qb.DocType(self.doc.doctype)
+ returned_amount = (
+ frappe.qb.from_(doc)
+ .select(Sum(doc.grand_total))
+ .where((doc.docstatus == 1) & (doc.is_return == 1) & (doc.return_against == self.doc.name))
+ ).run()
+
+ return abs(returned_amount[0][0]) if returned_amount[0][0] else 0
+
+
+def get_loyalty_programs(customer: str) -> list:
+ """Return applicable loyalty programs for the customer."""
+ from erpnext.selling.doctype.customer.customer import get_loyalty_programs as _get
+
+ customer_doc = frappe.get_doc("Customer", customer)
+ if customer_doc.loyalty_program:
+ return [customer_doc.loyalty_program]
+
+ lp_details = _get(customer_doc)
+
+ if len(lp_details) == 1:
+ customer_doc.db_set("loyalty_program", lp_details[0])
+
+ return lp_details
diff --git a/erpnext/accounts/doctype/sales_invoice/services/pos.py b/erpnext/accounts/doctype/sales_invoice/services/pos.py
new file mode 100644
index 00000000000..0dfc9ea870d
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/services/pos.py
@@ -0,0 +1,422 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""POS helpers for Sales Invoice."""
+
+import frappe
+from frappe import _, msgprint
+from frappe.utils import cint, flt, get_link_to_form
+
+
+class PartialPaymentValidationError(frappe.ValidationError):
+ pass
+
+
+class POSService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def set_pos_fields(self, for_validate: bool = False) -> frappe.Document | None:
+ """Populate POS-profile fields on the invoice; return the profile or None."""
+ doc = self.doc
+ if cint(doc.is_pos) != 1:
+ return None
+
+ if not doc.account_for_change_amount:
+ doc.account_for_change_amount = frappe.get_cached_value(
+ "Company", doc.company, "default_cash_account"
+ )
+
+ from erpnext.stock.get_item_details import (
+ ItemDetailsCtx,
+ get_pos_profile,
+ get_pos_profile_item_details_,
+ )
+
+ if not doc.pos_profile and not doc.flags.ignore_pos_profile:
+ pos_profile = get_pos_profile(doc.company) or {}
+ if not pos_profile:
+ return None
+ doc.pos_profile = pos_profile.get("name")
+
+ pos = {}
+ if doc.pos_profile:
+ pos = frappe.get_doc("POS Profile", doc.pos_profile)
+
+ if pos:
+ if not for_validate:
+ update_multi_mode_option(doc, pos)
+ doc.tax_category = pos.get("tax_category")
+
+ if not for_validate and not doc.customer:
+ doc.customer = pos.customer
+
+ if not for_validate:
+ doc.ignore_pricing_rule = pos.ignore_pricing_rule
+
+ if pos.get("account_for_change_amount"):
+ doc.account_for_change_amount = pos.get("account_for_change_amount")
+
+ for fieldname in (
+ "currency",
+ "letter_head",
+ "tc_name",
+ "company",
+ "select_print_heading",
+ "write_off_account",
+ "taxes_and_charges",
+ "write_off_cost_center",
+ "apply_discount_on",
+ "cost_center",
+ ):
+ if (not for_validate) or (for_validate and not doc.get(fieldname)):
+ doc.set(fieldname, pos.get(fieldname))
+
+ if pos.get("company_address"):
+ doc.company_address = pos.get("company_address")
+
+ if doc.customer:
+ customer_price_list, customer_group = frappe.get_value(
+ "Customer", doc.customer, ["default_price_list", "customer_group"]
+ )
+ customer_group_price_list = frappe.get_value(
+ "Customer Group", customer_group, "default_price_list"
+ )
+ selling_price_list = (
+ customer_price_list or customer_group_price_list or pos.get("selling_price_list")
+ )
+ else:
+ selling_price_list = pos.get("selling_price_list")
+
+ if selling_price_list:
+ doc.set("selling_price_list", selling_price_list)
+
+ if not for_validate:
+ dn_flag = any(d.get("dn_detail") for d in doc.get("items"))
+ doc.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
+
+ for item in doc.get("items"):
+ if item.get("item_code"):
+ profile_details = get_pos_profile_item_details_(
+ ItemDetailsCtx(item.as_dict()), pos, pos, update_data=True
+ )
+ for fname, val in profile_details.items():
+ if (not for_validate) or (for_validate and not item.get(fname)):
+ item.set(fname, val)
+
+ if doc.tc_name and not doc.terms:
+ doc.terms = frappe.db.get_value("Terms and Conditions", doc.tc_name, "terms")
+
+ if doc.taxes_and_charges and not len(doc.get("taxes")):
+ from erpnext.accounts.services.taxes import TaxService
+
+ TaxService(doc).set_taxes()
+
+ return pos
+
+ def set_paid_amount(self) -> None:
+ doc = self.doc
+ paid_amount = 0.0
+ base_paid_amount = 0.0
+ for data in doc.payments:
+ data.base_amount = flt(data.amount * doc.conversion_rate, doc.precision("base_paid_amount"))
+ paid_amount += data.amount
+ base_paid_amount += data.base_amount
+ doc.paid_amount = paid_amount
+ doc.base_paid_amount = base_paid_amount
+
+ def set_account_for_mode_of_payment(self) -> None:
+ for payment in self.doc.payments:
+ payment.account = get_bank_cash_account(payment.mode_of_payment, self.doc.company).get("account")
+
+ def reset_mode_of_payments(self) -> None:
+ doc = self.doc
+ if doc.pos_profile:
+ pos_profile = frappe.get_cached_doc("POS Profile", doc.pos_profile)
+ update_multi_mode_option(doc, pos_profile)
+ doc.paid_amount = 0
+
+ def validate_pos_return(self) -> None:
+ doc = self.doc
+ if doc.is_consolidated:
+ return
+
+ if doc.is_pos and doc.is_return:
+ total_amount_in_payments = sum(payment.amount for payment in doc.payments)
+ invoice_total = doc.rounded_total or doc.grand_total
+ if total_amount_in_payments < invoice_total:
+ frappe.throw(_("Total payments amount can't be greater than {}").format(-invoice_total))
+
+ def validate_pos_paid_amount(self) -> None:
+ doc = self.doc
+ if len(doc.payments) == 0 and doc.is_pos and flt(doc.grand_total) > 0:
+ frappe.throw(_("At least one mode of payment is required for POS invoice."))
+
+ def validate_pos(self) -> None:
+ doc = self.doc
+ if doc.is_return:
+ invoice_total = doc.rounded_total or doc.grand_total
+ if abs(flt(doc.paid_amount)) + abs(flt(doc.write_off_amount)) - abs(flt(invoice_total)) > 1.0 / (
+ 10.0 ** (doc.precision("grand_total") + 1.0)
+ ):
+ frappe.throw(_("Paid amount + Write Off Amount can not be greater than Grand Total"))
+
+ def validate_created_using_pos(self) -> None:
+ doc = self.doc
+ if doc.is_created_using_pos and not doc.pos_profile:
+ frappe.throw(_("POS Profile is mandatory to mark this invoice as POS Transaction."))
+
+ doc.invoice_type_in_pos = frappe.db.get_single_value("POS Settings", "invoice_type")
+ if doc.invoice_type_in_pos == "POS Invoice" and not doc.is_return:
+ frappe.throw(_("Transactions using Sales Invoice in POS are disabled."))
+
+ self.validate_pos_opening_entry()
+
+ def validate_full_payment(self) -> None:
+ doc = self.doc
+ allow_partial_payment = frappe.db.get_value("POS Profile", doc.pos_profile, "allow_partial_payment")
+ invoice_total = flt(doc.rounded_total) or flt(doc.grand_total)
+
+ if (
+ doc.docstatus == 1
+ and not doc.is_return
+ and not allow_partial_payment
+ and doc.paid_amount < invoice_total
+ ):
+ frappe.throw(
+ msg=_("Partial Payment in POS Transactions are not allowed."),
+ exc=PartialPaymentValidationError,
+ )
+
+ def validate_pos_opening_entry(self) -> None:
+ doc = self.doc
+ opening_entries = frappe.get_all(
+ "POS Opening Entry",
+ fields=["name", "period_start_date"],
+ filters={"pos_profile": doc.pos_profile, "status": "Open"},
+ order_by="period_start_date desc",
+ )
+ if not opening_entries:
+ frappe.throw(
+ title=_("POS Opening Entry Missing"),
+ msg=_("No open POS Opening Entry found for POS Profile {0}.").format(
+ frappe.bold(doc.pos_profile)
+ ),
+ )
+ if len(opening_entries) > 1:
+ frappe.throw(
+ title=_("Multiple POS Opening Entry"),
+ msg=_(
+ "POS Profile - {0} has multiple open POS Opening Entries. Please close or cancel the existing entries before proceeding."
+ ).format(doc.pos_profile),
+ )
+ if frappe.utils.get_date_str(opening_entries[0].get("period_start_date")) != frappe.utils.today():
+ frappe.throw(
+ title=_("Outdated POS Opening Entry"),
+ msg=_(
+ "POS Opening Entry - {0} is outdated. Please close the POS and create a new POS Opening Entry."
+ ).format(opening_entries[0].get("name")),
+ )
+
+ def check_if_consolidated_invoice(self) -> None:
+ doc = self.doc
+ if doc.doctype == "Sales Invoice" and doc.is_consolidated:
+ invoice_or_credit_note = "consolidated_credit_note" if doc.is_return else "consolidated_invoice"
+ pos_closing_entry = frappe.get_all(
+ "POS Invoice Merge Log",
+ filters={invoice_or_credit_note: doc.name},
+ pluck="pos_closing_entry",
+ )
+ if pos_closing_entry and pos_closing_entry[0]:
+ msg = _("To cancel a {} you need to cancel the POS Closing Entry {}.").format(
+ frappe.bold(_("Consolidated Sales Invoice")),
+ get_link_to_form("POS Closing Entry", pos_closing_entry[0]),
+ )
+ frappe.throw(msg, title=_("Not Allowed"))
+
+ def check_if_created_using_pos_and_pos_closing_entry_generated(self) -> None:
+ doc = self.doc
+ if doc.doctype == "Sales Invoice" and doc.is_created_using_pos and doc.pos_closing_entry:
+ pos_closing_entry_docstatus = frappe.db.get_value(
+ "POS Closing Entry", doc.pos_closing_entry, "docstatus"
+ )
+ if pos_closing_entry_docstatus == 1:
+ frappe.throw(
+ msg=_(
+ "To cancel this Sales Invoice you need to cancel the POS Closing Entry {0}."
+ ).format(get_link_to_form("POS Closing Entry", doc.pos_closing_entry)),
+ title=_("Not Allowed"),
+ )
+
+ def cancel_pos_invoice_credit_note_generated_during_sales_invoice_mode(self) -> None:
+ pos_invoices = frappe.get_all(
+ "POS Invoice", filters={"consolidated_invoice": self.doc.name}, pluck="name"
+ )
+ for pos_invoice in pos_invoices:
+ frappe.get_doc("POS Invoice", pos_invoice).cancel()
+
+ def clear_unallocated_mode_of_payments(self) -> None:
+ doc = self.doc
+ doc.set("payments", doc.get("payments", {"amount": ["not in", [0, None, ""]]}))
+ frappe.db.delete("Sales Invoice Payment", filters={"parent": doc.name, "amount": 0})
+
+ def allow_write_off_only_on_pos(self) -> None:
+ if not self.doc.is_pos and self.doc.write_off_account:
+ self.doc.write_off_account = None
+
+ def verify_payment_amount_is_positive(self) -> None:
+ for entry in self.doc.payments:
+ if entry.amount < 0:
+ frappe.throw(_("Row #{0} (Payment Table): Amount must be positive").format(entry.idx))
+
+ def verify_payment_amount_is_negative(self) -> None:
+ for entry in self.doc.payments:
+ if entry.amount > 0:
+ frappe.throw(_("Row #{0} (Payment Table): Amount must be negative").format(entry.idx))
+
+ def get_warehouse(self) -> str | None:
+ doc = self.doc
+ POSProfile = frappe.qb.DocType("POS Profile")
+
+ user_query = (
+ frappe.qb.from_(POSProfile)
+ .select(POSProfile.name, POSProfile.warehouse)
+ .where(POSProfile.company == doc.company)
+ .where(
+ (POSProfile.user == frappe.session["user"])
+ | ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
+ )
+ )
+ user_pos_profile = user_query.run()
+ warehouse = user_pos_profile[0][1] if user_pos_profile else None
+
+ if not warehouse:
+ global_query = (
+ frappe.qb.from_(POSProfile)
+ .select(POSProfile.name, POSProfile.warehouse)
+ .where(POSProfile.company == doc.company)
+ .where(POSProfile.user.isnull() | (POSProfile.user == ""))
+ )
+ global_pos_profile = global_query.run()
+
+ if global_pos_profile:
+ warehouse = global_pos_profile[0][1]
+ elif not user_pos_profile:
+ msgprint(_("POS Profile required to make POS Entry"), raise_exception=True)
+
+ return warehouse
+
+
+def get_bank_cash_account(mode_of_payment: str, company: str) -> dict:
+ account = frappe.db.get_value(
+ "Mode of Payment Account",
+ {"parent": mode_of_payment, "company": company},
+ "default_account",
+ )
+ if not account:
+ frappe.throw(
+ _("Please set default Cash or Bank account in Mode of Payment {0}").format(
+ get_link_to_form("Mode of Payment", mode_of_payment)
+ ),
+ title=_("Missing Account"),
+ )
+ return {"account": account}
+
+
+def update_multi_mode_option(doc, pos_profile) -> None:
+ def append_payment(payment_mode):
+ payment = doc.append("payments", {})
+ payment.default = payment_mode.default
+ payment.mode_of_payment = payment_mode.mop
+ payment.account = payment_mode.default_account
+ payment.type = payment_mode.type
+
+ mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
+
+ doc.set("payments", [])
+ invalid_modes = []
+ mode_of_payments = [d.mode_of_payment for d in pos_profile.get("payments")]
+ mode_of_payments_info = get_mode_of_payments_info(mode_of_payments, doc.company)
+
+ for row in pos_profile.get("payments"):
+ payment_mode = mode_of_payments_info.get(row.mode_of_payment)
+ if not payment_mode:
+ invalid_modes.append(get_link_to_form("Mode of Payment", row.mode_of_payment))
+ continue
+
+ payment_mode.default = row.default
+ append_payment(payment_mode)
+
+ if invalid_modes:
+ if invalid_modes == 1:
+ msg = _("Please set default Cash or Bank account in Mode of Payment {}")
+ else:
+ msg = _("Please set default Cash or Bank account in Mode of Payments {}")
+ frappe.throw(msg.format(", ".join(invalid_modes)), title=_("Missing Account"))
+
+ if mop_refetched:
+ frappe.toast(
+ _("Payment methods refreshed. Please review before proceeding."),
+ indicator="orange",
+ )
+
+
+def get_all_mode_of_payments(doc) -> list:
+ ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
+ ModeOfPayment = frappe.qb.DocType("Mode of Payment")
+
+ query = (
+ frappe.qb.from_(ModeOfPaymentAccount)
+ .join(ModeOfPayment)
+ .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
+ .select(
+ ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
+ )
+ .where(ModeOfPaymentAccount.company == doc.company)
+ .where(ModeOfPayment.enabled == 1)
+ )
+
+ return query.run(as_dict=1)
+
+
+def get_mode_of_payments_info(mode_of_payments: list, company: str) -> dict:
+ ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
+ ModeOfPayment = frappe.qb.DocType("Mode of Payment")
+
+ query = (
+ frappe.qb.from_(ModeOfPaymentAccount)
+ .join(ModeOfPayment)
+ .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
+ .select(
+ ModeOfPaymentAccount.default_account,
+ ModeOfPaymentAccount.parent.as_("mop"),
+ ModeOfPayment.type.as_("type"),
+ )
+ .where(ModeOfPaymentAccount.company == company)
+ .where(ModeOfPayment.enabled == 1)
+ .where(ModeOfPayment.name.isin(mode_of_payments))
+ .groupby(ModeOfPayment.name)
+ )
+
+ data = query.run(as_dict=1)
+
+ return {row.get("mop"): row for row in data}
+
+
+def get_mode_of_payment_info(mode_of_payment: str, company: str) -> list:
+ ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
+ ModeOfPayment = frappe.qb.DocType("Mode of Payment")
+
+ query = (
+ frappe.qb.from_(ModeOfPayment)
+ .join(ModeOfPaymentAccount)
+ .on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
+ .select(
+ ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
+ )
+ .where(ModeOfPaymentAccount.company == company)
+ .where(ModeOfPayment.enabled == 1)
+ .where(ModeOfPayment.name == mode_of_payment)
+ )
+
+ return query.run(as_dict=1)
diff --git a/erpnext/accounts/doctype/sales_invoice/services/status.py b/erpnext/accounts/doctype/sales_invoice/services/status.py
new file mode 100644
index 00000000000..ae6e6754451
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/services/status.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Status computation and display helpers for Sales Invoice."""
+
+import frappe
+from frappe import _
+from frappe.utils import cint, flt, getdate, nowdate
+
+
+class StatusService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def set_status(
+ self, update: bool = False, status: str | None = None, update_modified: bool = True
+ ) -> None:
+ doc = self.doc
+ if doc.is_new():
+ if doc.get("amended_from"):
+ doc.status = "Draft"
+ return
+
+ outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
+ total = get_total_in_party_account_currency(doc)
+
+ if not status:
+ if doc.docstatus == 2:
+ status = "Cancelled"
+ elif doc.docstatus == 1:
+ if doc.is_internal_transfer():
+ doc.status = "Internal Transfer"
+ elif is_overdue(doc, total):
+ doc.status = "Overdue"
+ elif 0 < outstanding_amount < total:
+ doc.status = "Partly Paid"
+ elif outstanding_amount > 0 and getdate(doc.due_date) >= getdate():
+ doc.status = "Unpaid"
+ elif doc.is_return == 0 and frappe.db.get_value(
+ "Sales Invoice", {"is_return": 1, "return_against": doc.name, "docstatus": 1}
+ ):
+ doc.status = "Credit Note Issued"
+ elif doc.is_return == 1:
+ doc.status = "Return"
+ elif outstanding_amount <= 0:
+ doc.status = "Paid"
+ else:
+ doc.status = "Submitted"
+
+ if (
+ doc.status in ("Unpaid", "Partly Paid", "Overdue")
+ and doc.is_discounted
+ and get_discounting_status(doc.name) == "Disbursed"
+ ):
+ doc.status += " and Discounted"
+
+ else:
+ doc.status = "Draft"
+
+ if update:
+ doc.db_set("status", doc.status, update_modified=update_modified)
+
+ def set_indicator(self) -> None:
+ doc = self.doc
+ if doc.outstanding_amount < 0:
+ doc.indicator_title = _("Credit Note Issued")
+ doc.indicator_color = "gray"
+ elif doc.outstanding_amount > 0 and getdate(doc.due_date) >= getdate(nowdate()):
+ doc.indicator_color = "orange"
+ doc.indicator_title = _("Unpaid")
+ elif doc.outstanding_amount > 0 and getdate(doc.due_date) < getdate(nowdate()):
+ doc.indicator_color = "red"
+ doc.indicator_title = _("Overdue")
+ elif cint(doc.is_return) == 1:
+ doc.indicator_title = _("Return")
+ doc.indicator_color = "gray"
+ else:
+ doc.indicator_color = "green"
+ doc.indicator_title = _("Paid")
+
+
+def get_total_in_party_account_currency(doc) -> float:
+ total_fieldname = "grand_total" if doc.disable_rounded_total else "rounded_total"
+ if doc.party_account_currency != doc.currency:
+ total_fieldname = "base_" + total_fieldname
+ return flt(doc.get(total_fieldname), doc.precision(total_fieldname))
+
+
+def is_overdue(doc, total: float) -> bool | None:
+ outstanding_amount = flt(doc.outstanding_amount, doc.precision("outstanding_amount"))
+ if outstanding_amount <= 0:
+ return
+
+ today = getdate()
+ if doc.get("is_pos") or not doc.get("payment_schedule"):
+ return getdate(doc.due_date) < today
+
+ payment_amount_field = (
+ "base_payment_amount" if doc.party_account_currency != doc.currency else "payment_amount"
+ )
+ payable_amount = flt(
+ sum(
+ payment.get(payment_amount_field)
+ for payment in doc.payment_schedule
+ if getdate(payment.due_date) < today
+ ),
+ doc.precision("outstanding_amount"),
+ )
+ return flt(total - outstanding_amount, doc.precision("outstanding_amount")) < payable_amount
+
+
+def get_discounting_status(sales_invoice: str) -> str | None:
+ status = None
+
+ InvoiceDiscounting = frappe.qb.DocType("Invoice Discounting")
+ DiscountedInvoice = frappe.qb.DocType("Discounted Invoice")
+
+ query = (
+ frappe.qb.from_(InvoiceDiscounting)
+ .join(DiscountedInvoice)
+ .on(InvoiceDiscounting.name == DiscountedInvoice.parent)
+ .select(InvoiceDiscounting.status)
+ .where(DiscountedInvoice.sales_invoice == sales_invoice)
+ .where(InvoiceDiscounting.docstatus == 1)
+ .where(InvoiceDiscounting.status.isin(["Disbursed", "Settled"]))
+ )
+
+ invoice_discounting_list = query.run()
+
+ for d in invoice_discounting_list:
+ status = d[0]
+ if status == "Disbursed":
+ break
+ return status
diff --git a/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py b/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py
new file mode 100644
index 00000000000..f688363dfc7
--- /dev/null
+++ b/erpnext/accounts/doctype/sales_invoice/services/timesheet_billing.py
@@ -0,0 +1,121 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Timesheet billing helpers for Sales Invoice."""
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
+
+
+class TimesheetBillingService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def validate_time_sheets_are_submitted(self) -> None:
+ for data in self.doc.timesheets:
+ if data.time_sheet and data.timesheet_detail:
+ if sales_invoice := frappe.db.get_value(
+ "Timesheet Detail", data.timesheet_detail, "sales_invoice"
+ ):
+ frappe.throw(
+ _("Row {0}: Sales Invoice {1} is already created for {2}").format(
+ data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet)
+ )
+ )
+
+ if data.time_sheet:
+ status = frappe.db.get_value("Timesheet", data.time_sheet, "status")
+ if status not in ["Submitted", "Payslip", "Partially Billed"]:
+ frappe.throw(
+ _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet)
+ )
+
+ def update_time_sheet(self, sales_invoice: str | None) -> None:
+ for d in self.doc.timesheets:
+ if d.time_sheet:
+ timesheet = frappe.get_doc("Timesheet", d.time_sheet)
+ self._update_time_sheet_detail(timesheet, d, sales_invoice)
+ timesheet.calculate_total_amounts()
+ timesheet.calculate_percentage_billed()
+ timesheet.flags.ignore_validate_update_after_submit = True
+ timesheet.set_status()
+ timesheet.db_update_all()
+
+ def unlink_sales_invoice_from_timesheets(self) -> None:
+ for row in self.doc.timesheets:
+ timesheet = frappe.get_doc("Timesheet", row.time_sheet)
+ timesheet.unlink_sales_invoice(self.doc.name)
+ timesheet.flags.ignore_validate_update_after_submit = True
+ timesheet.db_update_all()
+
+ def set_billing_hours_and_amount(self) -> None:
+ doc = self.doc
+ if doc.project:
+ return
+
+ for timesheet in doc.timesheets:
+ ts_doc = frappe.get_doc("Timesheet", timesheet.time_sheet)
+ if not timesheet.billing_hours and ts_doc.total_billable_hours:
+ timesheet.billing_hours = ts_doc.total_billable_hours
+ if not timesheet.billing_amount and ts_doc.total_billable_amount:
+ timesheet.billing_amount = ts_doc.total_billable_amount
+
+ def update_timesheet_billing_for_project(self) -> None:
+ doc = self.doc
+ if (
+ not doc.is_return
+ and not doc.timesheets
+ and doc.project
+ and frappe.db.get_single_value("Projects Settings", "fetch_timesheet_in_sales_invoice")
+ ):
+ self.add_timesheet_data()
+ else:
+ self.calculate_billing_amount_for_timesheet()
+
+ def add_timesheet_data(self) -> None:
+ doc = self.doc
+ doc.set("timesheets", [])
+ if doc.project:
+ for data in get_projectwise_timesheet_data(doc.project):
+ doc.append(
+ "timesheets",
+ {
+ "time_sheet": data.time_sheet,
+ "billing_hours": data.billing_hours,
+ "billing_amount": data.billing_amount,
+ "timesheet_detail": data.name,
+ "activity_type": data.activity_type,
+ "description": data.description,
+ },
+ )
+ self.calculate_billing_amount_for_timesheet()
+
+ def calculate_billing_amount_for_timesheet(self) -> None:
+ doc = self.doc
+ doc.total_billing_amount = sum(flt(ts.billing_amount) for ts in doc.timesheets)
+ doc.total_billing_hours = sum(flt(ts.billing_hours) for ts in doc.timesheets)
+
+ def _update_time_sheet_detail(self, timesheet, args, sales_invoice: str | None) -> None:
+ doc = self.doc
+ for data in timesheet.time_logs:
+ if (
+ (doc.project and args.timesheet_detail == data.name)
+ or (not doc.project and not data.sales_invoice and args.timesheet_detail == data.name)
+ or (
+ not sales_invoice
+ and data.sales_invoice == doc.name
+ and args.timesheet_detail == data.name
+ )
+ or (
+ doc.is_return
+ and doc.return_against
+ and data.sales_invoice
+ and data.sales_invoice == doc.return_against
+ and not sales_invoice
+ and args.timesheet_detail == data.name
+ )
+ ):
+ data.sales_invoice = sales_invoice
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index c144b225ebd..1da356d84ea 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -19,7 +19,7 @@ from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import Warehouse
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
unlink_payment_on_cancel_of_invoice,
)
-from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
+from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
from erpnext.accounts.utils import PaymentEntryUnlinkError
from erpnext.assets.doctype.asset.depreciation import post_depreciation_entries
from erpnext.assets.doctype.asset.test_asset import create_asset
@@ -30,7 +30,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError, update_invo
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_data
from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency
from erpnext.selling.doctype.customer.test_customer import get_customer_dict
-from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
+from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
@@ -78,7 +78,7 @@ class TestSalesInvoice(ERPNextTestSuite):
def test_invalid_rate_without_override(self):
from frappe import ValidationError
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_purchase_invoice
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
si = create_sales_invoice(
customer="_Test Internal Customer 3", company="_Test Company", is_internal_customer=1, rate=100
@@ -1278,7 +1278,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.validate_pos_gl_entry(si, pos, 50)
def test_pos_returns_with_repayment(self):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_profile = make_pos_profile()
@@ -1397,7 +1397,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(pos.outstanding_amount, 0.0)
self.assertEqual(pos.status, "Paid")
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_return = make_sales_return(pos.name)
pos_return.save().submit()
@@ -3777,6 +3777,14 @@ class TestSalesInvoice(ERPNextTestSuite):
si.submit()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
+ def test_sales_invoice_cancellation_post_account_freezing_date(self):
+ si = create_sales_invoice()
+ frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", add_days(getdate(), 1))
+ try:
+ self.assertRaises(frappe.ValidationError, si.cancel)
+ finally:
+ frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
+
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 0})
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_over_billing_case_against_delivery_note(self):
@@ -4200,7 +4208,7 @@ class TestSalesInvoice(ERPNextTestSuite):
from erpnext.accounts.doctype.loyalty_program.test_loyalty_program import (
create_sales_invoice_record,
)
- from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Set up loyalty program
@@ -4338,7 +4346,7 @@ class TestSalesInvoice(ERPNextTestSuite):
from frappe.model.mapper import map_docs
map_docs(
- method="erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
+ method="erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
source_names=json.dumps([dn1.name, dn2.name]),
target_doc=si,
args=json.dumps({"customer": dn1.customer, "merge_taxes": 1, "filtered_children": []}),
@@ -4381,7 +4389,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(expected, actual)
def test_pos_returns_without_update_outstanding_for_self(self):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_profile = make_pos_profile()
pos_profile.payments = []
@@ -4751,7 +4759,7 @@ class TestSalesInvoice(ERPNextTestSuite):
self.assertEqual(project.total_billed_amount, 300)
def test_pos_returns_with_party_account_currency(self):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
pos_profile = make_pos_profile()
pos_profile.payments = []
diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py
index f95056a5390..58c3b3ce7a1 100644
--- a/erpnext/accounts/doctype/subscription/subscription.py
+++ b/erpnext/accounts/doctype/subscription/subscription.py
@@ -455,8 +455,10 @@ class Subscription(Document):
tax_template = self.purchase_tax_template
if tax_template:
+ from erpnext.accounts.services.taxes import TaxService
+
invoice.taxes_and_charges = tax_template
- invoice.set_taxes()
+ TaxService(invoice).set_taxes()
# Due date
if self.days_until_due:
diff --git a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py
index 3ea8726359b..f14bf4563a6 100644
--- a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py
+++ b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py
@@ -4,7 +4,7 @@
import frappe
from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule, get_tax_template
-from erpnext.crm.doctype.opportunity.opportunity import make_quotation
+from erpnext.crm.doctype.opportunity.mapper import make_quotation
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
index a86fc5a1e62..d78f5287be1 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
@@ -9,7 +9,7 @@ from frappe.utils import add_days, add_months, getdate, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.utils import get_fiscal_year
-from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
+from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py
index e3bfed7de55..53d80e4099e 100644
--- a/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py
+++ b/erpnext/accounts/doctype/unreconcile_payment/test_unreconcile_payment.py
@@ -9,7 +9,7 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
from erpnext.accounts.party import get_party_account
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
-from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index 9effa1a09c5..0c535ff6b4b 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -7,7 +7,7 @@ import copy
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
-from frappe.utils import cint, flt, formatdate, get_link_to_form, getdate, now
+from frappe.utils import cint, flt, get_link_to_form, getdate, now
from frappe.utils.caching import request_cache
import erpnext
@@ -18,11 +18,17 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
from erpnext.accounts.doctype.accounting_dimension_filter.accounting_dimension_filter import (
get_dimension_filter_map,
)
-from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
+from erpnext.accounts.services.gl_validator import (
+ check_freezing_date,
+ validate_accounting_period,
+ validate_against_pcv,
+ validate_allowed_dimensions,
+ validate_cwip_accounts,
+ validate_disabled_accounts,
+)
from erpnext.accounts.utils import create_payment_ledger_entry, is_immutable_ledger_enabled
from erpnext.controllers.budget_controller import BudgetValidation
-from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
def make_gl_entries(
@@ -132,60 +138,6 @@ def get_accounting_dimensions_for_offsetting_entry(gl_map, company):
return accounting_dimensions_to_offset
-def validate_disabled_accounts(gl_map):
- accounts = [d.account for d in gl_map if d.account]
-
- disabled_accounts = frappe.get_all(
- "Account",
- filters={"disabled": 1, "is_group": 0, "company": gl_map[0].company},
- fields=["name"],
- )
-
- used_disabled_accounts = set(accounts).intersection(set([d.name for d in disabled_accounts]))
- if used_disabled_accounts:
- account_list = "
"
- account_list += ", ".join([frappe.bold(d) for d in used_disabled_accounts])
- frappe.throw(
- _("Cannot create accounting entries against disabled accounts: {0}").format(account_list),
- title=_("Disabled Account Selected"),
- )
-
-
-def validate_accounting_period(gl_map):
- accounting_periods = frappe.db.sql(
- """ SELECT
- ap.name as name, ap.exempted_role as exempted_role
- FROM
- `tabAccounting Period` ap, `tabClosed Document` cd
- WHERE
- ap.name = cd.parent
- AND ap.company = %(company)s
- AND ap.disabled = 0
- AND cd.closed = 1
- AND cd.document_type = %(voucher_type)s
- AND %(date)s between ap.start_date and ap.end_date
- """,
- {
- "date": gl_map[0].posting_date,
- "company": gl_map[0].company,
- "voucher_type": gl_map[0].voucher_type,
- },
- as_dict=1,
- )
-
- if accounting_periods:
- if accounting_periods[0].exempted_role:
- exempted_roles = accounting_periods[0].exempted_role
- if exempted_roles in frappe.get_roles():
- return
- frappe.throw(
- _(
- "You cannot create or cancel any accounting entries with in the closed Accounting Period {0}"
- ).format(frappe.bold(accounting_periods[0].name)),
- ClosedAccountingPeriod,
- )
-
-
def process_gl_map(gl_map, merge_entries=True, precision=None, from_repost=False):
if not gl_map:
return []
@@ -442,33 +394,6 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
validate_expense_against_budget(args)
-def validate_cwip_accounts(gl_map):
- """Validate that CWIP account are not used in Journal Entry"""
- if gl_map and gl_map[0].voucher_type != "Journal Entry":
- return
-
- cwip_enabled = any(
- cint(ac.enable_cwip_accounting)
- for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
- )
- if cwip_enabled:
- cwip_accounts = [
- d[0]
- for d in frappe.db.sql(
- """select name from tabAccount
- where account_type = 'Capital Work in Progress' and is_group=0"""
- )
- ]
-
- for entry in gl_map:
- if entry.account in cwip_accounts:
- frappe.throw(
- _(
- "Account: {0} is capital Work in progress and can not be updated by Journal Entry"
- ).format(entry.account)
- )
-
-
def process_debit_credit_difference(gl_map):
precision = get_field_precision(
frappe.get_meta("GL Entry").get_field("debit"),
@@ -715,7 +640,7 @@ def make_reverse_gl_entries(
partial_cancel=partial_cancel,
)
validate_accounting_period(gl_entries)
- check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
+ check_freezing_date(gl_entries[0]["posting_date"], gl_entries[0]["company"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
@@ -796,48 +721,6 @@ def make_reverse_gl_entries(
make_entry(new_gle, adv_adj, "Yes")
-def check_freezing_date(posting_date, company, adv_adj=False):
- """
- Nobody can do GL Entries where posting date is before freezing date
- except authorized person
-
- Administrator has all the roles so this check will be bypassed if any role is allowed to post
- Hence stop admin to bypass if accounts are freezed
- """
- if not adv_adj:
- acc_frozen_till_date = frappe.db.get_value("Company", company, "accounts_frozen_till_date")
- if acc_frozen_till_date:
- frozen_accounts_modifier = frappe.db.get_value(
- "Company", company, "role_allowed_for_frozen_entries"
- )
- if getdate(posting_date) <= getdate(acc_frozen_till_date) and (
- frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator"
- ):
- frappe.throw(
- _("You are not authorized to add or update entries before {0}").format(
- formatdate(acc_frozen_till_date)
- )
- )
-
-
-def validate_against_pcv(is_opening, posting_date, company):
- if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
- frappe.throw(
- _("Opening Entry can not be created after Period Closing Voucher is created."),
- title=_("Invalid Opening Entry"),
- )
-
- last_pcv_date = frappe.db.get_value(
- "Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
- )
-
- if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):
- message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date))
- message += ""
- message += _("You cannot create/amend any accounting entries till this date.")
- frappe.throw(message, title=_("Period Closed"))
-
-
def set_as_cancel(voucher_type, voucher_no):
"""
Set is_cancelled=1 in all original gl entries for the voucher
@@ -848,39 +731,3 @@ def set_as_cancel(voucher_type, voucher_no):
where voucher_type=%s and voucher_no=%s and is_cancelled = 0""",
(now(), frappe.session.user, voucher_type, voucher_no),
)
-
-
-def validate_allowed_dimensions(gl_entry, dimension_filter_map):
- for key, value in dimension_filter_map.items():
- dimension = key[0]
- account = key[1]
-
- if gl_entry.account == account:
- if value["is_mandatory"] and not gl_entry.get(dimension):
- frappe.throw(
- _("{0} is mandatory for account {1}").format(
- frappe.bold(frappe.unscrub(dimension)), frappe.bold(gl_entry.account)
- ),
- MandatoryAccountDimensionError,
- )
-
- if value["allow_or_restrict"] == "Allow":
- if gl_entry.get(dimension) and gl_entry.get(dimension) not in value["allowed_dimensions"]:
- frappe.throw(
- _("Invalid value {0} for {1} against account {2}").format(
- frappe.bold(gl_entry.get(dimension)),
- frappe.bold(frappe.unscrub(dimension)),
- frappe.bold(gl_entry.account),
- ),
- InvalidAccountDimensionError,
- )
- else:
- if gl_entry.get(dimension) and gl_entry.get(dimension) in value["allowed_dimensions"]:
- frappe.throw(
- _("Invalid value {0} for {1} against account {2}").format(
- frappe.bold(gl_entry.get(dimension)),
- frappe.bold(frappe.unscrub(dimension)),
- frappe.bold(gl_entry.account),
- ),
- InvalidAccountDimensionError,
- )
diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py
index 74f4a0eba6b..776bed9b7f0 100644
--- a/erpnext/accounts/report/gross_profit/test_gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py
@@ -2,10 +2,10 @@ import frappe
from frappe import qb
from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate
-from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note, make_sales_return
+from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note, make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.gross_profit.gross_profit import execute
-from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
+from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -354,7 +354,7 @@ class TestGrossProfit(ERPNextTestSuite):
do_not_submit=False,
)
- from erpnext.selling.doctype.sales_order.sales_order import (
+ from erpnext.selling.doctype.sales_order.mapper import (
make_delivery_note,
make_sales_invoice,
)
@@ -522,7 +522,7 @@ class TestGrossProfit(ERPNextTestSuite):
do_not_submit=False,
)
- from erpnext.selling.doctype.sales_order.sales_order import (
+ from erpnext.selling.doctype.sales_order.mapper import (
make_delivery_note,
make_sales_invoice,
)
@@ -732,8 +732,8 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertEqual(total[8], 100.0)
def test_drop_ship(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
- from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_purchase_order, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
diff --git a/erpnext/accounts/services/__init__.py b/erpnext/accounts/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/accounts/services/advances.py b/erpnext/accounts/services/advances.py
new file mode 100644
index 00000000000..893ce4ff4f8
--- /dev/null
+++ b/erpnext/accounts/services/advances.py
@@ -0,0 +1,510 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Advance payment query and management functions.
+
+All functions take a `doc` (AccountsController instance) as first argument so
+they can be called as module-level functions from any doctype, while keeping
+the AccountsController methods as thin shims.
+"""
+
+import frappe
+from frappe import _
+from frappe.query_builder import Criterion
+from frappe.query_builder.custom import ConstantColumn
+from frappe.query_builder.functions import Abs, Sum
+from frappe.utils import flt
+
+import erpnext
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
+ get_dimensions,
+)
+from erpnext.accounts.party import get_party_account
+from erpnext.accounts.utils import get_account_currency, get_advance_payment_doctypes
+from erpnext.setup.utils import get_exchange_rate
+
+
+def set_advances(doc) -> None:
+ """Populate the advances child table from open advance entries."""
+ res = get_advance_entries(
+ doc, include_unallocated=not frappe.utils.cint(doc.get("only_include_allocated_payments"))
+ )
+
+ doc.set("advances", [])
+ advance_allocated = 0
+ for d in res:
+ if doc.get("party_account_currency") == doc.company_currency:
+ amount = doc.get("base_rounded_total") or doc.base_grand_total
+ else:
+ amount = doc.get("rounded_total") or doc.grand_total
+ allocated_amount = min(amount - advance_allocated, d.amount)
+ advance_allocated += flt(allocated_amount)
+
+ advance_row = {
+ "doctype": doc.doctype + " Advance",
+ "reference_type": d.reference_type,
+ "reference_name": d.reference_name,
+ "reference_row": d.reference_row,
+ "remarks": d.remarks,
+ "advance_amount": flt(d.amount),
+ "allocated_amount": allocated_amount,
+ "ref_exchange_rate": flt(d.exchange_rate),
+ "difference_posting_date": doc.posting_date,
+ }
+ if d.get("paid_from"):
+ advance_row["account"] = d.paid_from
+ if d.get("paid_to"):
+ advance_row["account"] = d.paid_to
+
+ doc.append("advances", advance_row)
+
+
+def get_advance_entries(doc, include_unallocated: bool = True) -> list:
+ """Return advance journal and payment entries applicable to `doc`."""
+ party_account = []
+ default_advance_account = None
+
+ if doc.doctype in ["Sales Invoice", "POS Invoice"]:
+ party_type = "Customer"
+ party = doc.customer
+ amount_field = "credit_in_account_currency"
+ order_field = "sales_order"
+ order_doctype = "Sales Order"
+ party_account.append(doc.debit_to)
+ else:
+ party_type = "Supplier"
+ party = doc.supplier
+ amount_field = "debit_in_account_currency"
+ order_field = "purchase_order"
+ order_doctype = "Purchase Order"
+ party_account.append(doc.credit_to)
+
+ party_accounts = get_party_account(party_type, party=party, company=doc.company, include_advance=True)
+
+ if party_accounts:
+ party_account.append(party_accounts[0])
+ default_advance_account = party_accounts[1] if len(party_accounts) == 2 else None
+
+ order_list = list(set(d.get(order_field) for d in doc.get("items") if d.get(order_field)))
+
+ journal_entries = get_advance_journal_entries(
+ party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated
+ )
+
+ payment_entries = get_advance_payment_entries_for_regional(
+ party_type,
+ party,
+ party_account,
+ order_doctype,
+ order_list,
+ default_advance_account,
+ include_unallocated,
+ )
+
+ return journal_entries + payment_entries
+
+
+def validate_advance_entries(doc) -> None:
+ """Warn if a payment entry linked to the same order is not pulled as advance."""
+ order_field = "sales_order" if doc.doctype == "Sales Invoice" else "purchase_order"
+ order_list = list(set(d.get(order_field) for d in doc.get("items") if d.get(order_field)))
+
+ if not order_list:
+ return
+
+ advance_entries = get_advance_entries(doc, include_unallocated=False)
+
+ if advance_entries:
+ advance_entries_against_si = [d.reference_name for d in doc.get("advances")]
+ for d in advance_entries:
+ if not advance_entries_against_si or d.reference_name not in advance_entries_against_si:
+ frappe.msgprint(
+ _(
+ "Payment Entry {0} is linked against Order {1}, check if it should be pulled as advance in this invoice."
+ ).format(d.reference_name, d.against_order)
+ )
+
+
+def set_advance_gain_or_loss(doc) -> None:
+ """Compute exchange gain/loss for each allocated advance row."""
+ if doc.get("conversion_rate") == 1 or not doc.get("advances"):
+ return
+
+ is_purchase_invoice = doc.doctype == "Purchase Invoice"
+ party_account = doc.credit_to if is_purchase_invoice else doc.debit_to
+ if get_account_currency(party_account) != doc.currency:
+ return
+
+ for d in doc.get("advances"):
+ advance_exchange_rate = d.ref_exchange_rate
+ if d.allocated_amount and doc.conversion_rate != advance_exchange_rate:
+ base_allocated_amount_in_ref_rate = advance_exchange_rate * d.allocated_amount
+ base_allocated_amount_in_inv_rate = doc.conversion_rate * d.allocated_amount
+ difference = base_allocated_amount_in_ref_rate - base_allocated_amount_in_inv_rate
+
+ d.exchange_gain_loss = difference
+
+
+def calculate_total_advance_from_ledger(doc) -> list:
+ """Query the Advance Payment Ledger for the total advance against `doc`."""
+ adv = frappe.qb.DocType("Advance Payment Ledger Entry")
+ return (
+ frappe.qb.from_(adv)
+ .select(Abs(Sum(adv.amount)).as_("amount"), adv.currency.as_("account_currency"))
+ .where(adv.company == doc.company)
+ .where(adv.delinked == 0)
+ .where(adv.against_voucher_type == doc.doctype)
+ .where(adv.against_voucher_no == doc.name)
+ .run(as_dict=True)
+ )
+
+
+def set_total_advance_paid(doc) -> None:
+ """Update advance_paid field and payment status from the ledger."""
+ advance = calculate_total_advance_from_ledger(doc)
+ advance_paid = 0
+
+ if advance:
+ advance = advance[0]
+ advance_paid = flt(advance.amount, doc.precision("advance_paid"))
+ if advance.account_currency:
+ frappe.db.set_value(doc.doctype, doc.name, "party_account_currency", advance.account_currency)
+
+ doc.db_set("advance_paid", advance_paid)
+ set_advance_payment_status(doc)
+
+
+def set_advance_payment_status(doc) -> None:
+ """Sync advance_payment_status with current ledger and Payment Request state."""
+ new_status = None
+
+ PaymentRequest = frappe.qb.DocType("Payment Request")
+ paid_amount = frappe.get_value(
+ doctype="Payment Request",
+ filters={
+ "reference_doctype": doc.doctype,
+ "reference_name": doc.name,
+ "docstatus": 1,
+ },
+ fieldname=Sum(PaymentRequest.grand_total - PaymentRequest.outstanding_amount),
+ )
+
+ if not paid_amount:
+ if doc.doctype in get_advance_payment_doctypes(payment_type="receivable"):
+ new_status = "Not Requested" if paid_amount is None else "Requested"
+ elif doc.doctype in get_advance_payment_doctypes(payment_type="payable"):
+ new_status = "Not Initiated" if paid_amount is None else "Initiated"
+ else:
+ total_amount = doc.get("rounded_total") or doc.get("grand_total")
+ new_status = "Fully Paid" if paid_amount == total_amount else "Partially Paid"
+
+ if new_status == doc.advance_payment_status:
+ return
+
+ doc.db_set("advance_payment_status", new_status, update_modified=False)
+ doc.set_status(update=True)
+ doc.notify_update()
+
+
+def delink_advance_entries(doc, linked_doc_name: str) -> None:
+ """Remove advance rows linked to `linked_doc_name` and update total_advance."""
+ total_allocated_amount = 0
+ for adv in doc.advances:
+ consider_for_total_advance = True
+ if adv.reference_name == linked_doc_name:
+ doctype = frappe.qb.DocType(doc.doctype + " Advance")
+ frappe.qb.from_(doctype).delete().where(doctype.name == adv.name).run()
+
+ consider_for_total_advance = False
+
+ if consider_for_total_advance:
+ total_allocated_amount += flt(adv.allocated_amount, adv.precision("allocated_amount"))
+
+ frappe.db.set_value(doc.doctype, doc.name, "total_advance", total_allocated_amount, update_modified=False)
+
+
+def create_advance_and_reconcile(doc, party_link) -> None:
+ """Create a Journal Entry to reconcile a party-link advance."""
+ secondary_party_type, secondary_party = doc.get_party()
+ primary_party_type, primary_party = party_link.primary_role, party_link.primary_party
+
+ primary_account = get_party_account(primary_party_type, primary_party, doc.company)
+ secondary_account = get_party_account(secondary_party_type, secondary_party, doc.company)
+ primary_account_currency = get_account_currency(primary_account)
+ secondary_account_currency = get_account_currency(secondary_account)
+ default_currency = erpnext.get_company_currency(doc.company)
+
+ multi_currency = (
+ primary_account_currency != default_currency or secondary_account_currency != default_currency
+ )
+
+ jv = frappe.new_doc("Journal Entry")
+ jv.voucher_type = "Journal Entry"
+ jv.posting_date = doc.posting_date
+ jv.company = doc.company
+ jv.remark = f"Adjustment for {doc.doctype} {doc.name}"
+ jv.is_system_generated = True
+
+ reconcilation_entry = frappe._dict()
+ advance_entry = frappe._dict()
+
+ reconcilation_entry.account = secondary_account
+ reconcilation_entry.party_type = secondary_party_type
+ reconcilation_entry.party = secondary_party
+ reconcilation_entry.reference_type = doc.doctype
+ reconcilation_entry.reference_name = doc.name
+ reconcilation_entry.cost_center = doc.cost_center or erpnext.get_default_cost_center(doc.company)
+
+ advance_entry.account = primary_account
+ advance_entry.party_type = primary_party_type
+ advance_entry.party = primary_party
+ advance_entry.cost_center = doc.cost_center or erpnext.get_default_cost_center(doc.company)
+ advance_entry.is_advance = "No" if doc.is_return else "Yes"
+
+ dimensions_dict = frappe._dict()
+ active_dimensions = get_dimensions()[0]
+ for dim in active_dimensions:
+ dimensions_dict[dim.fieldname] = doc.get(dim.fieldname)
+
+ reconcilation_entry.update(dimensions_dict)
+ advance_entry.update(dimensions_dict)
+
+ if multi_currency:
+ exc_rate_primary_to_default = (
+ 1
+ if primary_account_currency == default_currency
+ else get_exchange_rate(primary_account_currency, default_currency, doc.posting_date)
+ )
+ exc_rate_secondary_to_default = (
+ 1
+ if secondary_account_currency == default_currency
+ else get_exchange_rate(secondary_account_currency, default_currency, doc.posting_date)
+ )
+ exc_rate_secondary_to_primary = (
+ 1
+ if secondary_account_currency == primary_account_currency
+ else get_exchange_rate(secondary_account_currency, primary_account_currency, doc.posting_date)
+ )
+
+ outstanding_amount = abs(doc.outstanding_amount)
+ os_in_default_currency = outstanding_amount * exc_rate_secondary_to_default
+ os_in_primary_currency = outstanding_amount * exc_rate_secondary_to_primary
+
+ reconciliation_is_credit = (doc.doctype == "Sales Invoice") != bool(doc.is_return)
+ _set_je_amounts(
+ reconcilation_entry, outstanding_amount, os_in_default_currency, reconciliation_is_credit
+ )
+ _set_je_amounts(
+ advance_entry, os_in_primary_currency, os_in_default_currency, not reconciliation_is_credit
+ )
+
+ reconcilation_entry.exchange_rate = exc_rate_secondary_to_default
+ advance_entry.exchange_rate = exc_rate_primary_to_default
+ else:
+ outstanding_amount = abs(doc.outstanding_amount)
+ reconciliation_is_credit = (doc.doctype == "Sales Invoice") != bool(doc.is_return)
+ _set_je_amounts(reconcilation_entry, outstanding_amount, is_credit=reconciliation_is_credit)
+ _set_je_amounts(advance_entry, outstanding_amount, is_credit=not reconciliation_is_credit)
+
+ jv.multi_currency = multi_currency
+ jv.append("accounts", reconcilation_entry)
+ jv.append("accounts", advance_entry)
+
+ jv.save()
+ jv.submit()
+
+
+def get_advance_journal_entries(
+ party_type: str,
+ party: str,
+ party_account: list,
+ amount_field: str,
+ order_doctype: str,
+ order_list: list,
+ include_unallocated: bool = True,
+) -> list:
+ """Return open advance journal entry rows matching the given party and orders."""
+ journal_entry = frappe.qb.DocType("Journal Entry")
+ journal_acc = frappe.qb.DocType("Journal Entry Account")
+ q = (
+ frappe.qb.from_(journal_entry)
+ .inner_join(journal_acc)
+ .on(journal_entry.name == journal_acc.parent)
+ .select(
+ ConstantColumn("Journal Entry").as_("reference_type"),
+ (journal_entry.name).as_("reference_name"),
+ (journal_entry.remark).as_("remarks"),
+ (journal_acc[amount_field]).as_("amount"),
+ (journal_acc.name).as_("reference_row"),
+ (journal_acc.reference_name).as_("against_order"),
+ (journal_acc.exchange_rate),
+ )
+ .where(
+ journal_acc.account.isin(party_account)
+ & (journal_acc.party_type == party_type)
+ & (journal_acc.party == party)
+ & (journal_acc.is_advance == "Yes")
+ & (journal_entry.docstatus == 1)
+ )
+ )
+ if party_type == "Customer":
+ q = q.where(journal_acc.credit_in_account_currency > 0)
+ else:
+ q = q.where(journal_acc.debit_in_account_currency > 0)
+
+ reference_or_condition = []
+
+ if include_unallocated:
+ reference_or_condition.append(journal_acc.reference_name.isnull())
+ reference_or_condition.append(journal_acc.reference_name == "")
+
+ if order_list:
+ reference_or_condition.append(
+ (journal_acc.reference_type == order_doctype) & ((journal_acc.reference_name).isin(order_list))
+ )
+
+ if reference_or_condition:
+ q = q.where(Criterion.any(reference_or_condition))
+
+ q = q.orderby(journal_entry.posting_date)
+
+ return list(q.run(as_dict=True))
+
+
+@erpnext.allow_regional
+def get_advance_payment_entries_for_regional(*args, **kwargs):
+ return get_advance_payment_entries(*args, **kwargs)
+
+
+def get_advance_payment_entries(
+ party_type: str,
+ party: str,
+ party_account: list,
+ order_doctype: str,
+ order_list: list | None = None,
+ default_advance_account: str | None = None,
+ include_unallocated: bool = True,
+ against_all_orders: bool = False,
+ limit: int | None = None,
+ condition: dict | None = None,
+) -> list:
+ """Return open advance payment entry rows matching the given party and orders."""
+ payment_entries = []
+ payment_entry = frappe.qb.DocType("Payment Entry")
+
+ if order_list or against_all_orders:
+ q = get_common_query(party_type, party, party_account, default_advance_account, limit, condition)
+ payment_ref = frappe.qb.DocType("Payment Entry Reference")
+
+ q = q.inner_join(payment_ref).on(payment_entry.name == payment_ref.parent)
+ q = q.select(
+ (payment_ref.allocated_amount).as_("amount"),
+ (payment_ref.name).as_("reference_row"),
+ (payment_ref.reference_name).as_("against_order"),
+ (payment_entry.book_advance_payments_in_separate_party_account),
+ )
+
+ q = q.where(payment_ref.reference_doctype == order_doctype)
+ if order_list:
+ q = q.where(payment_ref.reference_name.isin(order_list))
+
+ payment_entries += list(q.run(as_dict=True))
+
+ if include_unallocated:
+ q = get_common_query(party_type, party, party_account, default_advance_account, limit, condition)
+ q = q.select((payment_entry.unallocated_amount).as_("amount"))
+ q = q.where(payment_entry.unallocated_amount > 0)
+
+ payment_entries += list(q.run(as_dict=True))
+
+ return payment_entries
+
+
+def get_common_query(
+ party_type: str,
+ party: str,
+ party_account: list,
+ default_advance_account: str | None,
+ limit: int | None,
+ condition: dict | None,
+):
+ """Build the base Payment Entry query shared by allocated and unallocated advance lookups."""
+ account_type = frappe.db.get_value("Party Type", party_type, "account_type")
+ payment_type = "Receive" if account_type == "Receivable" else "Pay"
+ payment_entry = frappe.qb.DocType("Payment Entry")
+
+ q = (
+ frappe.qb.from_(payment_entry)
+ .select(
+ ConstantColumn("Payment Entry").as_("reference_type"),
+ (payment_entry.name).as_("reference_name"),
+ payment_entry.posting_date,
+ (payment_entry.remarks).as_("remarks"),
+ (payment_entry.book_advance_payments_in_separate_party_account),
+ )
+ .where(payment_entry.payment_type == payment_type)
+ .where(payment_entry.party_type == party_type)
+ .where(payment_entry.party == party)
+ .where(payment_entry.docstatus == 1)
+ )
+
+ field = "paid_from" if payment_type == "Receive" else "paid_to"
+ q = q.select((payment_entry[f"{field}_account_currency"]).as_("currency"))
+ q = q.select(payment_entry[field])
+ account_condition = payment_entry[field].isin(party_account)
+ if default_advance_account:
+ q = q.where(
+ account_condition
+ | (
+ (payment_entry[field] == default_advance_account)
+ & (payment_entry.book_advance_payments_in_separate_party_account == 1)
+ )
+ )
+ else:
+ q = q.where(account_condition)
+
+ if payment_type == "Receive":
+ q = q.select((payment_entry.source_exchange_rate).as_("exchange_rate"))
+ else:
+ q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate"))
+
+ if condition:
+ common_filter_conditions = []
+ common_filter_conditions.append(payment_entry.company == condition["company"])
+ if condition.get("name", None):
+ common_filter_conditions.append(payment_entry.name.like(f"%{condition.get('name')}%"))
+ if condition.get("from_payment_date"):
+ common_filter_conditions.append(payment_entry.posting_date.gte(condition["from_payment_date"]))
+ if condition.get("to_payment_date"):
+ common_filter_conditions.append(payment_entry.posting_date.lte(condition["to_payment_date"]))
+ if condition.get("get_payments") is True:
+ if condition.get("cost_center"):
+ common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
+ if condition.get("accounting_dimensions"):
+ for field, val in condition.get("accounting_dimensions").items():
+ common_filter_conditions.append(payment_entry[field] == val)
+ if condition.get("minimum_payment_amount"):
+ common_filter_conditions.append(
+ payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"])
+ )
+ if condition.get("maximum_payment_amount"):
+ common_filter_conditions.append(
+ payment_entry.unallocated_amount.lte(condition["maximum_payment_amount"])
+ )
+ q = q.where(Criterion.all(common_filter_conditions))
+
+ q = q.orderby(payment_entry.posting_date)
+ q = q.limit(limit) if limit else q
+
+ return q
+
+
+def _set_je_amounts(entry, amount, default_amount=None, is_credit=True):
+ if is_credit:
+ entry.credit_in_account_currency = amount
+ if default_amount is not None:
+ entry.credit = default_amount
+ else:
+ entry.debit_in_account_currency = amount
+ if default_amount is not None:
+ entry.debit = default_amount
diff --git a/erpnext/accounts/services/base_gl_composer.py b/erpnext/accounts/services/base_gl_composer.py
new file mode 100644
index 00000000000..270658aca13
--- /dev/null
+++ b/erpnext/accounts/services/base_gl_composer.py
@@ -0,0 +1,269 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Base class and free functions for per-document GL entry composition.
+
+``BaseGLComposer`` holds the document being composed and exposes
+``get_gl_dict`` / ``add_gl_entry`` as instance methods. The underlying logic
+lives in the module-level free functions below (``doc`` as first argument), so
+``AccountsController`` and ``StockController`` can delegate to them via thin
+shims without forcing every GL-building doctype to inherit from those classes.
+
+Subclasses implement ``compose`` to return the voucher-specific list of GL
+entries.
+"""
+
+import frappe
+from frappe import _
+from frappe.utils import flt, formatdate
+
+import erpnext
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
+from erpnext.accounts.services.taxes import set_balance_in_account_currency
+from erpnext.accounts.utils import get_account_currency, get_fiscal_years
+from erpnext.utilities.regional import temporary_flag
+
+
+def get_gl_dict(doc, args: dict, account_currency: str | None = None, item=None) -> dict:
+ """Build a GL entry dict populated with doc-level fields."""
+ posting_date = args.get("posting_date") or doc.get("posting_date")
+ fiscal_years = get_fiscal_years(posting_date, company=doc.company)
+ if len(fiscal_years) > 1:
+ frappe.throw(
+ _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format(
+ formatdate(posting_date)
+ )
+ )
+ else:
+ fiscal_year = fiscal_years[0][0]
+
+ gl_dict = frappe._dict(
+ {
+ "company": doc.company,
+ "posting_date": posting_date,
+ "fiscal_year": fiscal_year,
+ "voucher_type": doc.doctype,
+ "voucher_no": doc.name,
+ "remarks": doc.get("remarks") or doc.get("remark"),
+ "debit": 0,
+ "credit": 0,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": 0,
+ "is_opening": doc.get("is_opening") or "No",
+ "party_type": None,
+ "party": None,
+ "project": doc.get("project"),
+ "post_net_value": args.get("post_net_value"),
+ "voucher_detail_no": args.get("voucher_detail_no"),
+ "voucher_subtype": get_voucher_subtype(doc),
+ }
+ )
+
+ with temporary_flag("company", doc.company):
+ update_gl_dict_with_regional_fields(doc, gl_dict)
+
+ update_gl_dict_with_app_based_fields(doc, gl_dict)
+
+ accounting_dimensions = get_accounting_dimensions()
+ dimension_dict = frappe._dict()
+ for dimension in accounting_dimensions:
+ dimension_dict[dimension] = doc.get(dimension)
+ if item and item.get(dimension):
+ dimension_dict[dimension] = item.get(dimension)
+
+ gl_dict.update(dimension_dict)
+ gl_dict.update(args)
+
+ if not account_currency:
+ account_currency = get_account_currency(gl_dict.account)
+
+ if gl_dict.account and doc.doctype not in [
+ "Journal Entry",
+ "Period Closing Voucher",
+ "Payment Entry",
+ "Purchase Receipt",
+ "Purchase Invoice",
+ "Stock Entry",
+ ]:
+ validate_account_currency(doc, gl_dict.account, account_currency)
+
+ if gl_dict.account and doc.doctype not in [
+ "Journal Entry",
+ "Period Closing Voucher",
+ "Payment Entry",
+ ]:
+ set_balance_in_account_currency(
+ gl_dict,
+ account_currency,
+ args.get("transaction_exchange_rate") or doc.get("conversion_rate"),
+ doc.company_currency,
+ )
+
+ if doc.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]:
+ gl_dict.update(
+ {
+ "transaction_currency": doc.get("currency") or doc.company_currency,
+ "transaction_exchange_rate": args.get("transaction_exchange_rate")
+ or doc.get("conversion_rate", 1),
+ "debit_in_transaction_currency": get_value_in_transaction_currency(
+ doc, account_currency, gl_dict, "debit"
+ ),
+ "credit_in_transaction_currency": get_value_in_transaction_currency(
+ doc, account_currency, gl_dict, "credit"
+ ),
+ }
+ )
+
+ if not args.get("against_voucher_type") and doc.get("against_voucher_type"):
+ gl_dict.update({"against_voucher_type": doc.get("against_voucher_type")})
+
+ if not args.get("against_voucher") and doc.get("against_voucher"):
+ gl_dict.update({"against_voucher": doc.get("against_voucher")})
+
+ return gl_dict
+
+
+def add_gl_entry(
+ doc,
+ gl_entries: list,
+ account: str,
+ cost_center: str,
+ debit: float,
+ credit: float,
+ remarks: str,
+ against_account: str,
+ debit_in_account_currency: float | None = None,
+ credit_in_account_currency: float | None = None,
+ account_currency: str | None = None,
+ project: str | None = None,
+ voucher_detail_no: str | None = None,
+ item=None,
+ posting_date=None,
+) -> None:
+ """Build a GL entry via get_gl_dict and append it to gl_entries."""
+ gl_entry = {
+ "account": account,
+ "cost_center": cost_center,
+ "debit": debit,
+ "credit": credit,
+ "against": against_account,
+ "remarks": remarks,
+ }
+
+ if voucher_detail_no:
+ gl_entry["voucher_detail_no"] = voucher_detail_no
+
+ if debit_in_account_currency:
+ gl_entry["debit_in_account_currency"] = debit_in_account_currency
+
+ if credit_in_account_currency:
+ gl_entry["credit_in_account_currency"] = credit_in_account_currency
+
+ if posting_date:
+ gl_entry["posting_date"] = posting_date
+
+ gl_entries.append(get_gl_dict(doc, gl_entry, account_currency, item=item))
+
+
+def get_voucher_subtype(doc) -> str:
+ voucher_subtypes = {
+ "Journal Entry": "voucher_type",
+ "Payment Entry": "payment_type",
+ "Stock Entry": "stock_entry_type",
+ "Asset Capitalization": "entry_type",
+ }
+
+ for method_name in frappe.get_hooks("voucher_subtypes"):
+ voucher_subtype = frappe.get_attr(method_name)(doc)
+ if voucher_subtype:
+ return voucher_subtype
+
+ if doc.doctype in voucher_subtypes:
+ return doc.get(voucher_subtypes[doc.doctype])
+ elif doc.doctype == "Purchase Receipt" and doc.is_return:
+ return "Purchase Return"
+ elif doc.doctype == "Delivery Note" and doc.is_return:
+ return "Sales Return"
+ elif doc.doctype == "Sales Invoice" and doc.is_return:
+ return "Credit Note"
+ elif doc.doctype == "Sales Invoice" and doc.is_debit_note:
+ return "Debit Note"
+ elif doc.doctype == "Purchase Invoice" and doc.is_return:
+ return "Debit Note"
+
+ return doc.doctype
+
+
+def get_value_in_transaction_currency(doc, account_currency: str, gl_dict: dict, field: str) -> float:
+ if account_currency == doc.get("currency"):
+ return gl_dict.get(field + "_in_account_currency")
+ return flt(gl_dict.get(field, 0) / doc.get("conversion_rate", 1))
+
+
+def validate_account_currency(doc, account: str, account_currency: str | None = None) -> None:
+ valid_currency = [doc.company_currency]
+ if doc.get("currency") and doc.currency != doc.company_currency:
+ valid_currency.append(doc.currency)
+
+ if account_currency not in valid_currency:
+ frappe.throw(
+ _("Account {0} is invalid. Account Currency must be {1}").format(
+ account, (" " + _("or") + " ").join(valid_currency)
+ )
+ )
+
+
+@erpnext.allow_regional
+def update_gl_dict_with_regional_fields(doc, gl_dict):
+ pass
+
+
+def update_gl_dict_with_app_based_fields(doc, gl_dict):
+ for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]):
+ frappe.get_attr(method)(doc, gl_dict)
+
+
+class BaseGLComposer:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def compose(self):
+ raise NotImplementedError
+
+ def get_gl_dict(self, args: dict, account_currency: str | None = None, item=None) -> dict:
+ return get_gl_dict(self.doc, args, account_currency, item)
+
+ def add_gl_entry(
+ self,
+ gl_entries: list,
+ account: str,
+ cost_center: str,
+ debit: float,
+ credit: float,
+ remarks: str,
+ against_account: str,
+ debit_in_account_currency: float | None = None,
+ credit_in_account_currency: float | None = None,
+ account_currency: str | None = None,
+ project: str | None = None,
+ voucher_detail_no: str | None = None,
+ item=None,
+ posting_date=None,
+ ) -> None:
+ add_gl_entry(
+ self.doc,
+ gl_entries,
+ account,
+ cost_center,
+ debit,
+ credit,
+ remarks,
+ against_account,
+ debit_in_account_currency,
+ credit_in_account_currency,
+ account_currency,
+ project,
+ voucher_detail_no,
+ item,
+ posting_date,
+ )
diff --git a/erpnext/accounts/services/billing_validation.py b/erpnext/accounts/services/billing_validation.py
new file mode 100644
index 00000000000..05a69084344
--- /dev/null
+++ b/erpnext/accounts/services/billing_validation.py
@@ -0,0 +1,151 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Billing amount validation helpers (overbilling checks)."""
+
+import frappe
+from frappe import _
+from frappe.query_builder.functions import Sum
+from frappe.utils import cint, flt, fmt_money
+
+
+class BillingValidationService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def validate_multiple_billing(self, ref_dt: str, item_ref_dn: str, based_on: str) -> None:
+ from erpnext.controllers.status_updater import get_allowance_for
+
+ ref_wise_billed_amount = self.get_reference_wise_billed_amt(ref_dt, item_ref_dn, based_on)
+ if not ref_wise_billed_amount:
+ return
+
+ total_overbilled_amt = 0.0
+ overbilled_items = []
+ precision = self.doc.precision(based_on, "items")
+ precision_allowance = 1 / (10**precision)
+
+ role_allowed_to_overbill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
+ is_overbilling_allowed = role_allowed_to_overbill in frappe.get_roles()
+
+ for row in ref_wise_billed_amount.values():
+ total_billed_amt = row.billed_amt
+ allowance = get_allowance_for(row.item_code, {}, None, None, "amount")[0]
+ max_allowed_amt = flt(row.ref_amt * (100 + allowance) / 100)
+
+ if total_billed_amt < 0 and max_allowed_amt < 0:
+ total_billed_amt, max_allowed_amt = abs(total_billed_amt), abs(max_allowed_amt)
+
+ overbill_amt = total_billed_amt - max_allowed_amt
+ row["max_allowed_amt"] = max_allowed_amt
+ total_overbilled_amt += overbill_amt
+
+ if overbill_amt > precision_allowance and not is_overbilling_allowed:
+ if self.doc.doctype != "Purchase Invoice" or not cint(
+ frappe.db.get_single_value(
+ "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
+ )
+ ):
+ overbilled_items.append(row)
+
+ if overbilled_items:
+ self.throw_overbill_exception(overbilled_items, precision)
+
+ if is_overbilling_allowed and total_overbilled_amt > 0.1:
+ frappe.msgprint(
+ _("Overbilling of {} ignored because you have {} role.").format(
+ total_overbilled_amt, role_allowed_to_overbill
+ ),
+ indicator="orange",
+ alert=True,
+ )
+
+ def get_reference_wise_billed_amt(self, ref_dt: str, item_ref_dn: str, based_on: str) -> dict | None:
+ """Return sum of billed amounts per reference row, including previously submitted invoices."""
+ reference_names = [d.get(item_ref_dn) for d in self.doc.items if d.get(item_ref_dn)]
+ if not reference_names:
+ return
+
+ precision = self.doc.precision(based_on, "items")
+ reference_details = self.get_billing_reference_details(reference_names, ref_dt + " Item", based_on)
+ already_billed = self.get_already_billed_amount(reference_names, item_ref_dn, based_on)
+
+ ref_wise_billed_amount = {}
+ for item in self.doc.items:
+ key = item.get(item_ref_dn)
+ if not key:
+ continue
+
+ ref_amt = flt(reference_details.get(key), precision)
+ current_amount = flt(item.get(based_on), precision)
+
+ if not ref_amt:
+ if current_amount:
+ frappe.msgprint(
+ _(
+ "System will not check over billing since amount for Item {0} in {1} is zero"
+ ).format(item.item_code, ref_dt),
+ title=_("Warning"),
+ indicator="orange",
+ )
+ continue
+
+ ref_wise_billed_amount.setdefault(
+ key,
+ frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]),
+ )
+ ref_wise_billed_amount[key]["rows"].append(item.idx)
+ ref_wise_billed_amount[key]["ref_amt"] = ref_amt
+ ref_wise_billed_amount[key]["billed_amt"] += current_amount
+ if key in already_billed:
+ ref_wise_billed_amount[key]["billed_amt"] += flt(already_billed.pop(key, 0), precision)
+
+ return ref_wise_billed_amount
+
+ def get_billing_reference_details(
+ self, reference_names: list, reference_doctype: str, based_on: str
+ ) -> frappe._dict:
+ return frappe._dict(
+ frappe.get_all(
+ reference_doctype,
+ filters={"name": ("in", reference_names)},
+ fields=["name", based_on],
+ as_list=1,
+ )
+ )
+
+ def get_already_billed_amount(
+ self, reference_names: list, item_ref_dn: str, based_on: str
+ ) -> frappe._dict:
+ item_doctype = frappe.qb.DocType(self.doc.items[0].doctype)
+ based_on_field = frappe.qb.Field(based_on)
+ join_field = frappe.qb.Field(item_ref_dn)
+
+ return frappe._dict(
+ (
+ frappe.qb.from_(item_doctype)
+ .select(join_field, Sum(based_on_field))
+ .where(join_field.isin(reference_names))
+ .where((item_doctype.docstatus == 1) & (item_doctype.parent != self.doc.name))
+ .groupby(join_field)
+ ).run()
+ )
+
+ def throw_overbill_exception(self, overbilled_items: list, precision: int) -> None:
+ message = (
+ _("
Cannot overbill for the following Items:
")
+ + ""
+ + "".join(
+ _("- Item {0} in row(s) {1} billed more than {2}
").format(
+ frappe.bold(item.item_code),
+ ", ".join(str(x) for x in item.rows),
+ frappe.bold(
+ fmt_money(item.max_allowed_amt, precision=precision, currency=self.doc.currency)
+ ),
+ )
+ for item in overbilled_items
+ )
+ + "
"
+ )
+ message += _("To allow over-billing, please set allowance in Accounts Settings.
")
+ frappe.throw(_(message))
diff --git a/erpnext/accounts/services/child_item_update.py b/erpnext/accounts/services/child_item_update.py
new file mode 100644
index 00000000000..704a2f8e820
--- /dev/null
+++ b/erpnext/accounts/services/child_item_update.py
@@ -0,0 +1,593 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Child item update service: ChildItemUpdater class and helpers for the update_child_qty_rate API."""
+
+import frappe
+from frappe import _
+from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
+from frappe.utils import flt, get_link_to_form, getdate
+
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions
+from erpnext.buying.utils import update_last_purchase_rate
+from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
+from erpnext.stock.get_item_details import (
+ get_bin_details,
+ get_conversion_factor,
+ get_item_warehouse_,
+)
+
+
+class ChildItemUpdater:
+ """Validates and applies item-level edits on submitted orders and quotations."""
+
+ def __init__(self, parent_doctype: str, parent_doctype_name: str, child_docname: str = "items"):
+ self.parent_doctype = parent_doctype
+ self.parent_doctype_name = parent_doctype_name
+ self.child_docname = child_docname
+ self.parent = frappe.get_doc(parent_doctype, parent_doctype_name)
+ self.allow_zero_qty = get_allow_zero_qty(parent_doctype)
+ self._ordered_items: dict | None = None
+ self._purchased_items: dict | None = None
+
+ def update(self, trans_items: str) -> None:
+ """Process item additions, edits, and deletions from trans_items JSON."""
+ from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
+ from erpnext.selling.doctype.quotation.mapper import get_ordered_items
+
+ data = frappe.parse_json(trans_items)
+ any_qty_changed = False
+ items_added_or_removed = False
+ any_conversion_factor_changed = False
+
+ self._check_permissions("write")
+
+ if self.parent_doctype == "Quotation":
+ self._ordered_items = get_ordered_items(self.parent.name)
+ items_added_or_removed |= validate_and_delete_children(self.parent, data, self._ordered_items)
+ elif self.parent_doctype == "Supplier Quotation":
+ self._purchased_items = get_purchased_items(self.parent.name)
+ items_added_or_removed |= validate_and_delete_children(self.parent, data, self._purchased_items)
+ else:
+ items_added_or_removed |= validate_and_delete_children(self.parent, data)
+
+ for d in data:
+ new_child_flag = False
+ rate_unchanged = None
+
+ if not d.get("item_code"):
+ continue
+
+ if not d.get("docname"):
+ new_child_flag = True
+ items_added_or_removed = True
+ self._check_permissions("create")
+ child_item = self._get_new_child_item(d)
+ else:
+ self._check_permissions("write")
+ child_item = frappe.get_doc(self.parent_doctype + " Item", d.get("docname"))
+
+ change_state = get_child_item_change_state(self.parent_doctype, child_item, d)
+ rate_unchanged = change_state.rate_unchanged
+ any_conversion_factor_changed |= not change_state.conversion_factor_unchanged
+ if is_child_item_unchanged(change_state):
+ continue
+
+ self._validate_quantity_and_rate(child_item, d, rate_unchanged)
+
+ if flt(child_item.get("qty")) != flt(d.get("qty")):
+ any_qty_changed = True
+
+ if self.parent.doctype in ("Sales Order", "Purchase Order") and self.parent.is_subcontracted:
+ self._validate_fg_item_for_subcontracting(d, new_child_flag)
+ child_item.fg_item_qty = flt(d["fg_item_qty"])
+ if new_child_flag:
+ child_item.fg_item = d["fg_item"]
+
+ child_item.qty = flt(d.get("qty"))
+ child_item.description = d.get("description")
+ update_child_item_rate_and_discount(
+ self.parent_doctype, child_item, d, self.allow_zero_qty, rate_unchanged=rate_unchanged
+ )
+ update_child_item_uom_and_weight(child_item, d)
+
+ if d.get("delivery_date") and self.parent_doctype == "Sales Order":
+ child_item.delivery_date = d.get("delivery_date")
+
+ if d.get("schedule_date") and self.parent_doctype == "Purchase Order":
+ child_item.schedule_date = d.get("schedule_date")
+
+ if d.get("bom_no") and self.parent_doctype == "Sales Order":
+ child_item.bom_no = d.get("bom_no")
+
+ child_item.flags.ignore_validate_update_after_submit = True
+ if new_child_flag:
+ self.parent.load_from_db()
+ child_item.idx = len(self.parent.items) + 1
+ child_item.insert()
+ else:
+ child_item.save(ignore_permissions=True)
+
+ self._post_update(any_qty_changed, items_added_or_removed, any_conversion_factor_changed)
+
+ def _post_update(
+ self, any_qty_changed: bool, items_added_or_removed: bool, any_conversion_factor_changed: bool
+ ) -> None:
+ parent = self.parent
+ parent.reload()
+ parent.flags.ignore_validate_update_after_submit = True
+ parent.set_qty_as_per_stock_uom()
+ parent.calculate_taxes_and_totals()
+ parent.set_total_in_words()
+
+ if self.parent_doctype == "Sales Order" and not parent.is_subcontracted:
+ make_packing_list(parent)
+ parent.set_gross_profit()
+
+ frappe.get_cached_doc("Authorization Control").validate_approving_authority(
+ parent.doctype, parent.company, parent.base_grand_total
+ )
+
+ if self.parent_doctype != "Supplier Quotation":
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ PaymentScheduleService(parent).set_payment_schedule()
+
+ if self.parent_doctype == "Purchase Order":
+ parent.validate_minimum_order_qty()
+ parent.validate_budget()
+ if parent.is_against_so():
+ parent.update_status_updater()
+ elif self.parent_doctype == "Sales Order":
+ parent.check_credit_limit()
+
+ for idx, row in enumerate(parent.get(self.child_docname), start=1):
+ row.idx = idx
+
+ parent.save()
+
+ if self.parent_doctype == "Purchase Order":
+ update_last_purchase_rate(parent, is_submit=1)
+
+ if any_qty_changed or items_added_or_removed or any_conversion_factor_changed:
+ parent.update_prevdoc_status()
+
+ parent.update_requested_qty()
+ parent.update_ordered_qty()
+ parent.update_ordered_and_reserved_qty()
+ parent.update_receiving_percentage()
+
+ if parent.is_subcontracted and not parent.can_update_items():
+ frappe.throw(
+ _(
+ "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}."
+ ).format(frappe.bold(parent.name))
+ )
+
+ elif self.parent_doctype == "Sales Order":
+ if parent.is_subcontracted and not parent.can_update_items():
+ frappe.throw(
+ _(
+ "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order."
+ )
+ )
+ parent.validate_selling_price()
+ parent.validate_for_duplicate_items()
+ parent.validate_warehouse()
+ parent.update_reserved_qty()
+ parent.update_project()
+ parent.update_prevdoc_status("submit")
+ parent.update_delivery_status()
+
+ parent.reload()
+ self._validate_workflow()
+
+ if self.parent_doctype in ("Purchase Order", "Sales Order"):
+ parent.update_blanket_order()
+ parent.update_billing_percentage()
+ parent.set_status()
+
+ parent.validate_uom_is_integer("uom", "qty")
+ parent.validate_uom_is_integer("stock_uom", "stock_qty")
+
+ if self.parent_doctype == "Sales Order" and not parent.is_subcontracted:
+ from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ cancel_stock_reservation_entries,
+ has_reserved_stock,
+ )
+
+ if has_reserved_stock(parent.doctype, parent.name):
+ cancel_stock_reservation_entries(parent.doctype, parent.name)
+ if parent.per_picked == 0:
+ parent.create_stock_reservation_entries()
+
+ def _check_permissions(self, perm_type: str = "create") -> None:
+ try:
+ self.parent.check_permission(perm_type)
+ except frappe.PermissionError:
+ actions = {"create": "add", "write": "update"}
+ frappe.throw(
+ _("You do not have permissions to {} items in a {}.").format(
+ actions[perm_type], self.parent_doctype
+ ),
+ title=_("Insufficient Permissions"),
+ )
+
+ def _validate_workflow(self) -> None:
+ workflow = get_workflow_name(self.parent.doctype)
+ if not workflow:
+ return
+
+ workflow_doc = frappe.get_doc("Workflow", workflow)
+ current_state = self.parent.get(workflow_doc.workflow_state_field)
+ roles = frappe.get_roles()
+
+ transitions = [
+ t.as_dict()
+ for t in workflow_doc.transitions
+ if t.next_state == current_state
+ and t.allowed in roles
+ and is_transition_condition_satisfied(t, self.parent)
+ ]
+
+ if not transitions:
+ frappe.throw(
+ _("You are not allowed to update as per the conditions set in {} Workflow.").format(
+ get_link_to_form("Workflow", workflow)
+ ),
+ title=_("Insufficient Permissions"),
+ )
+
+ def _get_new_child_item(self, item_row) -> "frappe.model.document.Document":
+ child_doctype = self.parent_doctype + " Item"
+ return set_order_defaults(
+ self.parent_doctype,
+ self.parent_doctype_name,
+ child_doctype,
+ self.child_docname,
+ item_row,
+ )
+
+ def _validate_quantity_and_rate(self, child_item, new_data: dict, rate_unchanged: bool | None) -> None:
+ if not flt(new_data.get("qty")) and not self.allow_zero_qty:
+ frappe.throw(
+ _("Row #{0}:Quantity for Item {1} cannot be zero.").format(
+ new_data.get("idx"), frappe.bold(new_data.get("item_code"))
+ ),
+ title=_("Invalid Qty"),
+ )
+
+ qty_limits = {
+ "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")),
+ "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")),
+ }
+
+ if self.parent_doctype in qty_limits:
+ qty_field, error_message = qty_limits[self.parent_doctype]
+ if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
+ frappe.throw(
+ _("Row #{0}:").format(new_data.get("idx")) + error_message,
+ title=_("Invalid Qty"),
+ )
+
+ if self.parent_doctype not in ("Quotation", "Supplier Quotation"):
+ return
+
+ items_map = self._ordered_items if self.parent_doctype == "Quotation" else self._purchased_items
+ if not items_map:
+ return
+
+ qty_to_check = items_map.get(child_item.name)
+ if not qty_to_check:
+ return
+
+ if not rate_unchanged:
+ frappe.throw(
+ _(
+ "Cannot update rate as item {0} is already ordered or purchased against this quotation"
+ ).format(frappe.bold(new_data.get("item_code")))
+ )
+
+ if flt(new_data.get("qty")) < qty_to_check:
+ frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
+
+ def _validate_fg_item_for_subcontracting(self, new_data: dict, is_new: bool) -> None:
+ if is_new:
+ if not new_data.get("fg_item"):
+ frappe.throw(
+ _("Finished Good Item is not specified for service item {0}").format(
+ new_data["item_code"]
+ )
+ )
+
+ is_sub_contracted_item, default_bom = frappe.db.get_value(
+ "Item", new_data["fg_item"], ["is_sub_contracted_item", "default_bom"]
+ )
+
+ if not is_sub_contracted_item:
+ frappe.throw(
+ _("Finished Good Item {0} must be a sub-contracted item").format(new_data["fg_item"])
+ )
+ elif not default_bom:
+ frappe.throw(_("Default BOM not found for FG Item {0}").format(new_data["fg_item"]))
+
+ if not new_data.get("fg_item_qty"):
+ frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"]))
+
+
+@frappe.whitelist()
+def update_child_qty_rate(
+ parent_doctype: str, trans_items: str, parent_doctype_name: str, child_docname: str = "items"
+) -> None:
+ ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items)
+
+
+def set_order_defaults(
+ parent_doctype: str,
+ parent_doctype_name: str,
+ child_doctype: str,
+ child_docname: str,
+ trans_item: dict,
+) -> "frappe.model.document.Document":
+ """Return a new child item populated with item master defaults."""
+ from erpnext.accounts.services.taxes import add_taxes_from_tax_template, set_child_tax_template_and_map
+
+ p_doc = frappe.get_doc(parent_doctype, parent_doctype_name)
+ child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname)
+ item = frappe.get_doc("Item", trans_item.get("item_code"))
+
+ for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"):
+ child_item.update({field: item.get(field)})
+
+ date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date"
+ child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)})
+ child_item.stock_uom = item.stock_uom
+ child_item.uom = trans_item.get("uom") or item.stock_uom
+ child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
+ conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
+ child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
+ child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
+
+ if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"):
+ child_item.base_rate = 1
+ child_item.base_amount = 1
+
+ if child_doctype == "Sales Order Item":
+ child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
+ if not child_item.warehouse:
+ frappe.throw(
+ _(
+ "Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings."
+ ).format(frappe.bold(item.item_code))
+ )
+
+ set_child_tax_template_and_map(item, child_item, p_doc)
+ add_taxes_from_tax_template(child_item, p_doc)
+ return child_item
+
+
+def validate_child_on_delete(row, parent, ordered_item=None) -> None:
+ """Raise if a partially transacted child item is being deleted."""
+ if parent.doctype == "Sales Order":
+ if flt(row.delivered_qty):
+ frappe.throw(
+ _("Row #{0}: Cannot delete item {1} which has already been delivered").format(
+ row.idx, row.item_code
+ )
+ )
+ if flt(row.work_order_qty):
+ frappe.throw(
+ _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format(
+ row.idx, row.item_code
+ )
+ )
+ if flt(row.ordered_qty):
+ frappe.throw(
+ _(
+ "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
+ ).format(row.idx, row.item_code)
+ )
+
+ if parent.doctype == "Purchase Order" and flt(row.received_qty):
+ frappe.throw(
+ _("Row #{0}: Cannot delete item {1} which has already been received").format(
+ row.idx, row.item_code
+ )
+ )
+
+ if parent.doctype in ("Purchase Order", "Sales Order") and flt(row.billed_amt):
+ frappe.throw(
+ _("Row #{0}: Cannot delete item {1} which has already been billed.").format(
+ row.idx, row.item_code
+ )
+ )
+
+ if parent.doctype == "Quotation" and ordered_item and ordered_item.get(row.name):
+ frappe.throw(_("Cannot delete an item which has been ordered"))
+
+
+def update_bin_on_delete(row, doctype: str) -> None:
+ """Update bin quantities after a child item row is deleted."""
+ from erpnext.stock.stock_balance import (
+ get_indented_qty,
+ get_ordered_qty,
+ get_reserved_qty,
+ update_bin_qty,
+ )
+
+ qty_dict = {}
+
+ if doctype == "Sales Order":
+ qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse)
+ else:
+ if row.material_request_item:
+ qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse)
+ qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse)
+
+ if row.warehouse:
+ update_bin_qty(row.item_code, row.warehouse, qty_dict)
+
+
+def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
+ """Delete child rows not present in data; return True if any were removed."""
+ updated_item_names = [d.get("docname") for d in data]
+ deleted_children = [item for item in parent.items if item.name not in updated_item_names]
+
+ for d in deleted_children:
+ validate_child_on_delete(d, parent, ordered_item)
+ d.cancel()
+ d.delete()
+
+ if parent.doctype == "Purchase Order":
+ parent.update_ordered_qty_in_so_for_removed_items(deleted_children)
+
+ if parent.doctype not in ("Quotation", "Supplier Quotation"):
+ parent.update_prevdoc_status()
+ for d in deleted_children:
+ update_bin_on_delete(d, parent.doctype)
+
+ return bool(deleted_children)
+
+
+def get_allow_zero_qty(parent_doctype: str) -> bool:
+ if parent_doctype == "Sales Order":
+ return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False
+ if parent_doctype == "Purchase Order":
+ return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
+ return False
+
+
+def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict:
+ prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate"))
+ prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty"))
+ prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty"))
+ prev_con_fac = flt(child_item.get("conversion_factor"))
+ new_con_fac = flt(new_data.get("conversion_factor"))
+
+ if parent_doctype == "Sales Order":
+ prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date")
+ elif parent_doctype == "Purchase Order":
+ prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date")
+ else:
+ prev_date, new_date = None, None
+
+ if parent_doctype in ("Quotation", "Supplier Quotation"):
+ date_unchanged = False
+ else:
+ prev_date = getdate(prev_date) if prev_date else None
+ new_date = getdate(new_date) if new_date else None
+ date_unchanged = prev_date == new_date
+
+ return frappe._dict(
+ rate_unchanged=prev_rate == new_rate,
+ qty_unchanged=prev_qty == new_qty,
+ fg_qty_unchanged=prev_fg_qty == new_fg_qty,
+ uom_unchanged=child_item.get("uom") == new_data.get("uom"),
+ conversion_factor_unchanged=prev_con_fac == new_con_fac,
+ date_unchanged=date_unchanged,
+ description_unchanged=child_item.get("description") == new_data.get("description"),
+ )
+
+
+def is_child_item_unchanged(change_state: frappe._dict) -> bool:
+ return (
+ change_state.rate_unchanged
+ and change_state.qty_unchanged
+ and change_state.fg_qty_unchanged
+ and change_state.conversion_factor_unchanged
+ and change_state.uom_unchanged
+ and change_state.date_unchanged
+ and change_state.description_unchanged
+ )
+
+
+def update_child_item_rate_and_discount(
+ parent_doctype: str,
+ child_item,
+ new_data,
+ allow_zero_qty: bool,
+ rate_unchanged: bool | None = None,
+) -> None:
+ rate_precision = child_item.precision("rate") or 2
+ qty_precision = child_item.precision("qty") or 2
+
+ if rate_unchanged is None:
+ rate_unchanged = flt(child_item.get("rate")) == flt(new_data.get("rate"))
+
+ if not rate_unchanged and not child_item.get("qty") and allow_zero_qty:
+ frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
+
+ row_rate = flt(new_data.get("rate"), rate_precision)
+
+ if parent_doctype in ("Purchase Order", "Sales Order"):
+ amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
+ row_rate * flt(new_data.get("qty"), qty_precision), rate_precision
+ )
+ if amount_below_billed_amt and row_rate > 0.0:
+ frappe.throw(
+ _(
+ "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
+ ).format(child_item.idx, child_item.item_code)
+ )
+
+ child_item.rate = row_rate
+
+ if parent_doctype not in ("Sales Order", "Purchase Order") or not flt(child_item.price_list_rate):
+ return
+
+ if flt(child_item.rate) > flt(child_item.price_list_rate):
+ child_item.discount_percentage = 0
+ child_item.margin_type = "Amount"
+ child_item.margin_rate_or_amount = flt(
+ child_item.rate - child_item.price_list_rate,
+ child_item.precision("margin_rate_or_amount"),
+ )
+ child_item.rate_with_margin = child_item.rate
+ else:
+ child_item.discount_percentage = flt(
+ (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0,
+ child_item.precision("discount_percentage"),
+ )
+ child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate)
+ child_item.margin_type = ""
+ child_item.margin_rate_or_amount = 0
+ child_item.rate_with_margin = 0
+
+
+def update_child_item_uom_and_weight(child_item, new_data) -> None:
+ conv_fac_precision = child_item.precision("conversion_factor") or 2
+
+ if new_data.get("conversion_factor"):
+ if child_item.stock_uom == child_item.uom:
+ child_item.conversion_factor = 1
+ else:
+ child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision)
+
+ if new_data.get("uom"):
+ child_item.uom = new_data.get("uom")
+ conversion_factor = flt(
+ get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor")
+ )
+ child_item.conversion_factor = (
+ flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor
+ )
+
+ if child_item.get("weight_per_unit"):
+ child_item.total_weight = flt(
+ child_item.weight_per_unit * child_item.qty * child_item.conversion_factor,
+ child_item.precision("total_weight"),
+ )
+
+
+def check_if_child_table_updated(
+ child_table_before_update, child_table_after_update, fields_to_check
+) -> bool:
+ """Return True if any accounting-relevant field changed in a child table."""
+ fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"]
+
+ for index, item in enumerate(child_table_before_update):
+ for field in fields_to_check:
+ if child_table_after_update[index].get(field) != item.get(field):
+ return True
+
+ return False
diff --git a/erpnext/accounts/services/exchange_gain_loss.py b/erpnext/accounts/services/exchange_gain_loss.py
new file mode 100644
index 00000000000..fa3af053a18
--- /dev/null
+++ b/erpnext/accounts/services/exchange_gain_loss.py
@@ -0,0 +1,208 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Exchange gain/loss journal helpers."""
+
+import frappe
+from frappe import _, qb
+from frappe.utils import flt, get_link_to_form
+
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
+from erpnext.accounts.utils import create_gain_loss_journal, get_currency_precision
+
+
+def gain_loss_journal_already_booked(
+ gain_loss_account: str,
+ exc_gain_loss: float,
+ ref2_dt: str,
+ ref2_dn: str,
+ ref2_detail_no: str,
+) -> bool:
+ """Check if a gain/loss journal has already been booked for the given parameters."""
+ if res := frappe.db.get_all(
+ "Journal Entry Account",
+ filters={
+ "docstatus": 1,
+ "account": gain_loss_account,
+ "reference_type": ref2_dt,
+ "reference_name": ref2_dn,
+ "reference_detail_no": ref2_detail_no,
+ },
+ pluck="parent",
+ ):
+ res = list({x for x in res})
+ if exc_vouchers := frappe.db.get_all(
+ "Journal Entry",
+ filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"},
+ fields=["voucher_type", "total_debit", "total_credit"],
+ ):
+ booked_voucher = exc_vouchers[0]
+ if (
+ booked_voucher.total_debit == exc_gain_loss
+ and booked_voucher.total_credit == exc_gain_loss
+ and booked_voucher.voucher_type == "Exchange Gain Or Loss"
+ ):
+ return True
+ return False
+
+
+def make_exchange_gain_loss_journal(
+ doc, args: dict | None = None, dimensions_dict: dict | None = None
+) -> None:
+ """Make Exchange Gain/Loss journal for Invoices and Payments."""
+ # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event.
+ # see accounts/utils.py:cancel_exchange_gain_loss_journal()
+ if doc.docstatus != 1:
+ return
+
+ if dimensions_dict is None:
+ dimensions_dict = frappe._dict()
+ active_dimensions = get_dimensions()[0]
+ for dim in active_dimensions:
+ dimensions_dict[dim.fieldname] = doc.get(dim.fieldname)
+
+ if doc.get("doctype") == "Journal Entry":
+ if args:
+ precision = get_currency_precision()
+ for arg in args:
+ if (
+ flt(arg.get("difference_amount", 0), precision) != 0
+ or flt(arg.get("exchange_gain_loss", 0), precision) != 0
+ ) and arg.get("difference_account"):
+ party_account = arg.get("account")
+ gain_loss_account = arg.get("difference_account")
+ difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss")
+ if difference_amount > 0:
+ dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit"
+ else:
+ dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit"
+
+ reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ if not gain_loss_journal_already_booked(
+ gain_loss_account,
+ difference_amount,
+ doc.doctype,
+ doc.name,
+ arg.get("referenced_row"),
+ ):
+ posting_date = arg.get("difference_posting_date") or frappe.db.get_value(
+ arg.voucher_type, arg.voucher_no, "posting_date"
+ )
+ je = create_gain_loss_journal(
+ doc.company,
+ posting_date,
+ arg.get("party_type"),
+ arg.get("party"),
+ party_account,
+ gain_loss_account,
+ difference_amount,
+ dr_or_cr,
+ reverse_dr_or_cr,
+ arg.get("against_voucher_type"),
+ arg.get("against_voucher"),
+ arg.get("idx"),
+ doc.doctype,
+ doc.name,
+ arg.get("referenced_row"),
+ arg.get("cost_center"),
+ dimensions_dict,
+ arg.get("project"),
+ )
+ frappe.msgprint(
+ _("Exchange Gain/Loss amount has been booked through {0}").format(
+ get_link_to_form("Journal Entry", je)
+ )
+ )
+
+ if doc.get("doctype") == "Payment Entry":
+ gain_loss_to_book = [x for x in doc.references if x.exchange_gain_loss != 0]
+ booked = []
+ if gain_loss_to_book:
+ je = qb.DocType("Journal Entry")
+ jea = qb.DocType("Journal Entry Account")
+ parents = (
+ qb.from_(jea)
+ .select(jea.parent)
+ .where(
+ (jea.reference_type == "Payment Entry")
+ & (jea.reference_name == doc.name)
+ & (jea.docstatus == 1)
+ )
+ .run()
+ )
+
+ if parents:
+ booked = (
+ qb.from_(je)
+ .inner_join(jea)
+ .on(je.name == jea.parent)
+ .select(jea.reference_type, jea.reference_name, jea.reference_detail_no)
+ .where(
+ (je.docstatus == 1)
+ & (je.name.isin(parents))
+ & (je.voucher_type == "Exchange Gain or Loss")
+ )
+ .run()
+ )
+
+ for d in gain_loss_to_book:
+ if d.exchange_gain_loss and ((d.reference_doctype, d.reference_name, str(d.idx)) not in booked):
+ if doc.book_advance_payments_in_separate_party_account:
+ party_account = d.account
+ else:
+ if doc.payment_type == "Receive":
+ party_account = doc.paid_from
+ elif doc.payment_type == "Pay":
+ party_account = doc.paid_to
+
+ dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit"
+
+ if is_payable_account(d.reference_doctype, party_account):
+ dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
+
+ gain_loss_account = frappe.get_cached_value(
+ "Company", doc.company, "exchange_gain_loss_account"
+ )
+ je = create_gain_loss_journal(
+ doc.company,
+ args.get("difference_posting_date") if args else doc.posting_date,
+ doc.party_type,
+ doc.party,
+ party_account,
+ gain_loss_account,
+ d.exchange_gain_loss,
+ dr_or_cr,
+ reverse_dr_or_cr,
+ d.reference_doctype,
+ d.reference_name,
+ d.idx,
+ doc.doctype,
+ doc.name,
+ d.idx,
+ doc.cost_center,
+ dimensions_dict,
+ doc.project,
+ )
+ frappe.msgprint(
+ _("Exchange Gain/Loss amount has been booked through {0}").format(
+ get_link_to_form("Journal Entry", je)
+ )
+ )
+
+
+def is_payable_account(reference_doctype: str, account: str) -> bool:
+ if reference_doctype == "Purchase Invoice" or (
+ reference_doctype == "Journal Entry"
+ and frappe.get_cached_value("Account", account, "account_type") == "Payable"
+ ):
+ return True
+ return False
+
+
+def set_transaction_currency_and_rate_in_gl_map(doc, gl_entries: list) -> None:
+ for entry in gl_entries:
+ entry["transaction_currency"] = doc.currency
+ entry["transaction_exchange_rate"] = doc.get("conversion_rate") or 1
diff --git a/erpnext/accounts/services/gl_validator.py b/erpnext/accounts/services/gl_validator.py
new file mode 100644
index 00000000000..e29b4d42103
--- /dev/null
+++ b/erpnext/accounts/services/gl_validator.py
@@ -0,0 +1,176 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""List-level validations for a GL map.
+
+These functions assert that an assembled list of GL entries is legal to post —
+no disabled accounts, the period/freeze/PCV gates pass, dimensions are allowed.
+They do not mutate or repair the entries; balancing and round-off live with the
+posting sink in ``erpnext.accounts.general_ledger``.
+"""
+
+import frappe
+from frappe import _
+from frappe.utils import cint, formatdate, getdate
+
+from erpnext.accounts.doctype.accounting_period.accounting_period import ClosedAccountingPeriod
+from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
+
+
+def validate_disabled_accounts(gl_map):
+ accounts = [d.account for d in gl_map if d.account]
+
+ disabled_accounts = frappe.get_all(
+ "Account",
+ filters={"disabled": 1, "is_group": 0, "company": gl_map[0].company},
+ fields=["name"],
+ )
+
+ used_disabled_accounts = set(accounts).intersection(set([d.name for d in disabled_accounts]))
+ if used_disabled_accounts:
+ account_list = "
"
+ account_list += ", ".join([frappe.bold(d) for d in used_disabled_accounts])
+ frappe.throw(
+ _("Cannot create accounting entries against disabled accounts: {0}").format(account_list),
+ title=_("Disabled Account Selected"),
+ )
+
+
+def validate_accounting_period(gl_map):
+ accounting_periods = frappe.db.sql(
+ """ SELECT
+ ap.name as name, ap.exempted_role as exempted_role
+ FROM
+ `tabAccounting Period` ap, `tabClosed Document` cd
+ WHERE
+ ap.name = cd.parent
+ AND ap.company = %(company)s
+ AND ap.disabled = 0
+ AND cd.closed = 1
+ AND cd.document_type = %(voucher_type)s
+ AND %(date)s between ap.start_date and ap.end_date
+ """,
+ {
+ "date": gl_map[0].posting_date,
+ "company": gl_map[0].company,
+ "voucher_type": gl_map[0].voucher_type,
+ },
+ as_dict=1,
+ )
+
+ if accounting_periods:
+ if accounting_periods[0].exempted_role:
+ exempted_roles = accounting_periods[0].exempted_role
+ if exempted_roles in frappe.get_roles():
+ return
+ frappe.throw(
+ _(
+ "You cannot create or cancel any accounting entries with in the closed Accounting Period {0}"
+ ).format(frappe.bold(accounting_periods[0].name)),
+ ClosedAccountingPeriod,
+ )
+
+
+def validate_cwip_accounts(gl_map):
+ """Validate that CWIP account are not used in Journal Entry"""
+ if gl_map and gl_map[0].voucher_type != "Journal Entry":
+ return
+
+ cwip_enabled = any(
+ cint(ac.enable_cwip_accounting)
+ for ac in frappe.db.get_all("Asset Category", "enable_cwip_accounting")
+ )
+ if cwip_enabled:
+ cwip_accounts = [
+ d[0]
+ for d in frappe.db.sql(
+ """select name from tabAccount
+ where account_type = 'Capital Work in Progress' and is_group=0"""
+ )
+ ]
+
+ for entry in gl_map:
+ if entry.account in cwip_accounts:
+ frappe.throw(
+ _(
+ "Account: {0} is capital Work in progress and can not be updated by Journal Entry"
+ ).format(entry.account)
+ )
+
+
+def check_freezing_date(posting_date, company, adv_adj=False):
+ """
+ Nobody can do GL Entries where posting date is before freezing date
+ except authorized person
+
+ Administrator has all the roles so this check will be bypassed if any role is allowed to post
+ Hence stop admin to bypass if accounts are freezed
+ """
+ if not adv_adj:
+ acc_frozen_till_date = frappe.db.get_value("Company", company, "accounts_frozen_till_date")
+ if acc_frozen_till_date:
+ frozen_accounts_modifier = frappe.db.get_value(
+ "Company", company, "role_allowed_for_frozen_entries"
+ )
+ if getdate(posting_date) <= getdate(acc_frozen_till_date) and (
+ frozen_accounts_modifier not in frappe.get_roles() or frappe.session.user == "Administrator"
+ ):
+ frappe.throw(
+ _("You are not authorized to add or update entries before {0}").format(
+ formatdate(acc_frozen_till_date)
+ )
+ )
+
+
+def validate_against_pcv(is_opening, posting_date, company):
+ if is_opening and frappe.db.exists("Period Closing Voucher", {"docstatus": 1, "company": company}):
+ frappe.throw(
+ _("Opening Entry can not be created after Period Closing Voucher is created."),
+ title=_("Invalid Opening Entry"),
+ )
+
+ last_pcv_date = frappe.db.get_value(
+ "Period Closing Voucher", {"docstatus": 1, "company": company}, [{"MAX": "period_end_date"}]
+ )
+
+ if last_pcv_date and getdate(posting_date) <= getdate(last_pcv_date):
+ message = _("Books have been closed till the period ending on {0}").format(formatdate(last_pcv_date))
+ message += ""
+ message += _("You cannot create/amend any accounting entries till this date.")
+ frappe.throw(message, title=_("Period Closed"))
+
+
+def validate_allowed_dimensions(gl_entry, dimension_filter_map):
+ for key, value in dimension_filter_map.items():
+ dimension = key[0]
+ account = key[1]
+
+ if gl_entry.account == account:
+ if value["is_mandatory"] and not gl_entry.get(dimension):
+ frappe.throw(
+ _("{0} is mandatory for account {1}").format(
+ frappe.bold(frappe.unscrub(dimension)), frappe.bold(gl_entry.account)
+ ),
+ MandatoryAccountDimensionError,
+ )
+
+ if value["allow_or_restrict"] == "Allow":
+ if gl_entry.get(dimension) and gl_entry.get(dimension) not in value["allowed_dimensions"]:
+ frappe.throw(
+ _("Invalid value {0} for {1} against account {2}").format(
+ frappe.bold(gl_entry.get(dimension)),
+ frappe.bold(frappe.unscrub(dimension)),
+ frappe.bold(gl_entry.account),
+ ),
+ InvalidAccountDimensionError,
+ )
+ else:
+ if gl_entry.get(dimension) and gl_entry.get(dimension) in value["allowed_dimensions"]:
+ frappe.throw(
+ _("Invalid value {0} for {1} against account {2}").format(
+ frappe.bold(gl_entry.get(dimension)),
+ frappe.bold(frappe.unscrub(dimension)),
+ frappe.bold(gl_entry.account),
+ ),
+ InvalidAccountDimensionError,
+ )
diff --git a/erpnext/accounts/services/internal_transfer.py b/erpnext/accounts/services/internal_transfer.py
new file mode 100644
index 00000000000..fdce48e0815
--- /dev/null
+++ b/erpnext/accounts/services/internal_transfer.py
@@ -0,0 +1,196 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Internal transfer helpers: InternalTransferService for inter-company transaction validation and setup."""
+
+import frappe
+from frappe import _, bold
+from frappe.utils import cint, flt
+
+
+class InternalTransferService:
+ """Handles validation and setup for inter-company / internal transfer transactions."""
+
+ def __init__(self, doc):
+ self.doc = doc
+
+ def is_internal_transfer(self) -> bool:
+ """Return True if document is an internal transfer (internal party + same represents_company)."""
+ doc = self.doc
+ if doc.doctype in ("Sales Invoice", "Delivery Note", "Sales Order"):
+ internal_party_field = "is_internal_customer"
+ elif doc.doctype in ("Purchase Invoice", "Purchase Receipt", "Purchase Order"):
+ internal_party_field = "is_internal_supplier"
+ else:
+ return False
+
+ return bool(doc.get(internal_party_field) and doc.represents_company == doc.company)
+
+ def validate(self) -> None:
+ """Run all inter-company validations and apply internal-transfer field overrides."""
+ self.validate_reference()
+ self.validate_transaction()
+ self.disable_pricing_rule()
+ self.disable_tax_included_prices()
+
+ def set_account(self) -> None:
+ """Set unrealized profit/loss account for internal transfers (SI/PI only)."""
+ if not self.is_internal_transfer() or self.doc.unrealized_profit_loss_account:
+ return
+
+ unrealized_profit_loss_account = frappe.get_cached_value(
+ "Company", self.doc.company, "unrealized_profit_loss_account"
+ )
+
+ if not unrealized_profit_loss_account:
+ frappe.throw(
+ _(
+ "Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}"
+ ).format(frappe.bold(self.doc.company))
+ )
+
+ self.doc.unrealized_profit_loss_account = unrealized_profit_loss_account
+
+ def process_common_party_accounting(self) -> None:
+ """Auto-create and reconcile advance for common party links (called from on_submit)."""
+ if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"):
+ return
+
+ if frappe.get_single_value("Accounts Settings", "enable_common_party_accounting"):
+ party_link = self.get_common_party_link()
+ if party_link and self.doc.outstanding_amount:
+ from erpnext.accounts.services.advances import create_advance_and_reconcile
+
+ create_advance_and_reconcile(self.doc, party_link)
+
+ def get_common_party_link(self) -> frappe._dict | None:
+ party_type, party = self.doc.get_party()
+ return frappe.db.get_value(
+ doctype="Party Link",
+ filters={"secondary_role": party_type, "secondary_party": party},
+ fieldname=["primary_role", "primary_party"],
+ as_dict=True,
+ )
+
+ def validate_reference(self) -> None:
+ if self.doc.get("is_return"):
+ return
+ if self.doc.doctype not in ("Purchase Invoice", "Purchase Receipt"):
+ return
+ if not self.is_internal_transfer():
+ return
+
+ if not (
+ self.doc.get("inter_company_reference")
+ or self.doc.get("inter_company_invoice_reference")
+ or self.doc.get("inter_company_order_reference")
+ ):
+ msg = _("Internal Sale or Delivery Reference missing.")
+ msg += _("Please create purchase from internal sale or delivery document itself")
+ frappe.throw(msg, title=_("Internal Sales Reference Missing"))
+
+ label = "Delivery Note Item" if self.doc.doctype == "Purchase Receipt" else "Sales Invoice Item"
+ field = frappe.scrub(label)
+
+ for row in self.doc.get("items"):
+ if not row.get(field):
+ frappe.throw(
+ _(f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer"),
+ title=_("Internal Transfer Reference Missing"),
+ )
+
+ def validate_transaction(self) -> None:
+ if not cint(frappe.get_single_value("Accounts Settings", "maintain_same_internal_transaction_rate")):
+ return
+
+ applicable_doctypes = ("Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice")
+ if self.doc.doctype not in applicable_doctypes:
+ return
+ if not (self.doc.get("is_internal_customer") or self.doc.get("is_internal_supplier")):
+ return
+
+ self._validate_transaction_by_voucher_type()
+
+ def disable_pricing_rule(self) -> None:
+ if not self.doc.get("ignore_pricing_rule") and self.is_internal_transfer():
+ self.doc.ignore_pricing_rule = 1
+ frappe.msgprint(
+ _("Disabled pricing rules since this {} is an internal transfer").format(self.doc.doctype),
+ alert=1,
+ )
+
+ def disable_tax_included_prices(self) -> None:
+ if not self.is_internal_transfer():
+ return
+
+ tax_updated = False
+ for tax in self.doc.get("taxes"):
+ if tax.get("included_in_print_rate"):
+ tax.included_in_print_rate = 0
+ tax_updated = True
+
+ if tax_updated:
+ frappe.msgprint(
+ _("Disabled tax included prices since this {} is an internal transfer").format(
+ self.doc.doctype
+ ),
+ alert=1,
+ )
+
+ def _validate_transaction_by_voucher_type(self) -> None:
+ orders = ("Sales Order", "Purchase Order")
+ invoices = ("Sales Invoice", "Purchase Invoice")
+
+ if self.doc.doctype in orders and self.doc.get("inter_company_order_reference"):
+ linked_doctype = "Sales Order" if self.doc.doctype == "Purchase Order" else "Purchase Order"
+ self._validate_line_items(
+ linked_doctype,
+ "sales_order" if linked_doctype == "Sales Order" else "purchase_order",
+ "sales_order_item" if linked_doctype == "Sales Order" else "purchase_order_item",
+ )
+ elif self.doc.doctype in invoices and self.doc.get("inter_company_invoice_reference"):
+ linked_doctype = "Sales Invoice" if self.doc.doctype == "Purchase Invoice" else "Purchase Invoice"
+ self._validate_line_items(
+ linked_doctype,
+ "sales_invoice" if linked_doctype == "Sales Invoice" else "purchase_invoice",
+ "sales_invoice_item" if linked_doctype == "Sales Invoice" else "purchase_invoice_item",
+ )
+
+ def _validate_line_items(self, ref_dt: str, ref_dn_field: str, ref_link_field: str) -> None:
+ action, role_allowed_to_override = frappe.get_cached_value(
+ "Accounts Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"]
+ )
+
+ reference_names = [d.get(ref_link_field) for d in self.doc.get("items") if d.get(ref_link_field)]
+ reference_details = self.doc.get_reference_details(reference_names, ref_dt + " Item")
+
+ stop_actions = []
+
+ for d in self.doc.get("items"):
+ if not d.get(ref_link_field):
+ continue
+
+ ref_rate = reference_details.get(d.get(ref_link_field))
+ if ref_rate is None or abs(flt(d.rate - ref_rate, d.precision("rate"))) < 0.01:
+ continue
+
+ ref_name = (
+ self.doc.inter_company_invoice_reference
+ if d.parenttype in ("Sales Invoice", "Purchase Invoice")
+ else d.get(ref_dn_field)
+ )
+ msg = _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format(
+ d.idx, ref_dt, ref_name, d.rate, ref_rate
+ )
+
+ if action == "Stop":
+ user_roles = frappe.get_all(
+ "Has Role", filters={"parent": frappe.session.user}, fields=["role"], pluck="role"
+ )
+ if role_allowed_to_override not in user_roles:
+ stop_actions.append(msg)
+ else:
+ frappe.msgprint(msg, title=_("Warning"), indicator="orange")
+
+ if stop_actions:
+ frappe.throw(stop_actions, as_list=True)
diff --git a/erpnext/accounts/services/party_validation.py b/erpnext/accounts/services/party_validation.py
new file mode 100644
index 00000000000..a6ec9716840
--- /dev/null
+++ b/erpnext/accounts/services/party_validation.py
@@ -0,0 +1,223 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Party validation: PartyValidator class for transaction-level party checks."""
+
+import frappe
+from frappe import _
+
+from erpnext.accounts.party import (
+ get_party_account_currency,
+ get_party_gle_currency,
+ validate_party_frozen_disabled,
+)
+from erpnext.accounts.utils import get_account_currency
+from erpnext.exceptions import InvalidCurrency
+
+
+class PartyValidator:
+ """Validates all party-related fields on a transaction document."""
+
+ def __init__(self, doc):
+ self.doc = doc
+
+ def validate(self) -> None:
+ """Run all party-related validations in order."""
+ self.validate_party()
+ self.validate_party_accounts()
+ self.validate_currency()
+ self.validate_party_account_currency()
+ self.validate_address_and_contact()
+ self.validate_company_linked_addresses()
+
+ def get_party(self) -> tuple[str | None, str | None]:
+ """Return (party_type, party_name) for the document."""
+ doc = self.doc
+ party_type = None
+
+ if doc.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
+ party_type = "Customer"
+ elif doc.doctype in (
+ "Supplier Quotation",
+ "Purchase Order",
+ "Purchase Receipt",
+ "Purchase Invoice",
+ ):
+ party_type = "Supplier"
+ elif doc.meta.get_field("customer"):
+ party_type = "Customer"
+ elif doc.meta.get_field("supplier"):
+ party_type = "Supplier"
+
+ party = doc.get(party_type.lower()) if party_type else None
+ return party_type, party
+
+ def validate_party(self) -> None:
+ party_type, party = self.get_party()
+ validate_party_frozen_disabled(self.doc.company, party_type, party)
+
+ def validate_party_accounts(self) -> None:
+ if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"):
+ return
+
+ if self.doc.doctype == "Sales Invoice":
+ party_account_field = "debit_to"
+ item_field = "income_account"
+ else:
+ party_account_field = "credit_to"
+ item_field = "expense_account"
+
+ for item in self.doc.get("items"):
+ if item.get(item_field) == self.doc.get(party_account_field):
+ frappe.throw(
+ _("Row {0}: {1} {2} cannot be same as {3} (Party Account) {4}").format(
+ item.idx,
+ frappe.bold(frappe.unscrub(item_field)),
+ item.get(item_field),
+ frappe.bold(frappe.unscrub(party_account_field)),
+ self.doc.get(party_account_field),
+ )
+ )
+
+ def validate_currency(self) -> None:
+ if not self.doc.get("currency"):
+ return
+
+ party_type, party = self.get_party()
+ if not (party_type and party):
+ return
+
+ party_account_currency = get_party_account_currency(party_type, party, self.doc.company)
+
+ if (
+ party_account_currency
+ and party_account_currency != self.doc.company_currency
+ and self.doc.currency != party_account_currency
+ ):
+ frappe.throw(
+ _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format(
+ party_type, party, party_account_currency
+ ),
+ InvalidCurrency,
+ )
+
+ def validate_party_account_currency(self) -> None:
+ if self.doc.doctype not in ("Sales Invoice", "Purchase Invoice"):
+ return
+ if self.doc.is_opening == "Yes":
+ return
+
+ party_type, party = self.get_party()
+ party_gle_currency = get_party_gle_currency(party_type, party, self.doc.company)
+ party_account = (
+ self.doc.get("debit_to") if self.doc.doctype == "Sales Invoice" else self.doc.get("credit_to")
+ )
+ party_account_currency = get_account_currency(party_account)
+ allow_multi_currency = frappe.db.get_singles_value(
+ "Accounts Settings", "allow_multi_currency_invoices_against_single_party_account"
+ )
+
+ if (
+ not party_gle_currency
+ and party_account_currency != self.doc.currency
+ and not allow_multi_currency
+ ):
+ frappe.throw(
+ _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format(
+ frappe.bold(party_account), party_account_currency, self.doc.currency
+ )
+ )
+
+ def validate_address_and_contact(self) -> None:
+ party_type, party = self.get_party()
+ if not (party_type and party):
+ return
+
+ if party_type == "Customer":
+ self._validate_address(
+ party,
+ party_type,
+ self.doc.get("customer_address"),
+ self.doc.get("shipping_address_name"),
+ )
+ elif party_type == "Supplier":
+ self._validate_address(party, party_type, self.doc.get("supplier_address"))
+
+ self._validate_contact(party, party_type)
+
+ def validate_company_linked_addresses(self) -> None:
+ doc = self.doc
+ sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice")
+ purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation")
+
+ if doc.doctype in sales_doctypes:
+ address_fields = ["dispatch_address_name", "company_address"]
+ elif doc.doctype in purchase_doctypes:
+ address_fields = ["billing_address", "shipping_address"]
+ else:
+ return
+
+ is_drop_ship = (
+ doc.doctype
+ in {
+ "Purchase Order",
+ "Purchase Invoice",
+ "Sales Order",
+ "Sales Invoice",
+ }
+ and self._is_drop_ship()
+ )
+
+ for field in address_fields:
+ address = doc.get(field)
+ if field in ("dispatch_address_name", "shipping_address") and is_drop_ship:
+ continue
+ if address and not frappe.db.exists(
+ "Dynamic Link",
+ {
+ "parent": address,
+ "parenttype": "Address",
+ "link_doctype": "Company",
+ "link_name": doc.company,
+ },
+ ):
+ frappe.throw(
+ _("{0} does not belong to the Company {1}.").format(
+ _(doc.meta.get_label(field)), frappe.bold(doc.company)
+ )
+ )
+
+ def _validate_address(
+ self,
+ party: str,
+ party_type: str,
+ billing_address: str | None,
+ shipping_address: str | None = None,
+ ) -> None:
+ if not (billing_address or shipping_address):
+ return
+
+ party_addresses = frappe.get_all(
+ "Dynamic Link",
+ {"link_doctype": party_type, "link_name": party, "parenttype": "Address"},
+ pluck="parent",
+ )
+ if billing_address and billing_address not in party_addresses:
+ frappe.throw(_("Billing Address does not belong to the {0}").format(party))
+ elif shipping_address and shipping_address not in party_addresses:
+ frappe.throw(_("Shipping Address does not belong to the {0}").format(party))
+
+ def _validate_contact(self, party: str, party_type: str) -> None:
+ if not self.doc.get("contact_person"):
+ return
+
+ contacts = frappe.get_all(
+ "Dynamic Link",
+ {"link_doctype": party_type, "link_name": party, "parenttype": "Contact"},
+ pluck="parent",
+ )
+ if self.doc.contact_person not in contacts:
+ frappe.throw(_("Contact Person does not belong to the {0}").format(party))
+
+ def _is_drop_ship(self) -> bool:
+ return any(item.delivered_by_supplier for item in self.doc.items)
diff --git a/erpnext/accounts/services/payment_schedule.py b/erpnext/accounts/services/payment_schedule.py
new file mode 100644
index 00000000000..3d7ce04452b
--- /dev/null
+++ b/erpnext/accounts/services/payment_schedule.py
@@ -0,0 +1,391 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Payment schedule and payment terms helpers."""
+
+import frappe
+from frappe import _
+from frappe.utils import DateTimeLikeObject, add_days, add_months, cint, flt, get_last_day, getdate
+
+from erpnext.accounts.party import get_party_account_currency
+
+
+class PaymentScheduleService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def set_payment_schedule(self) -> None:
+ doc = self.doc
+ if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes":
+ doc.payment_terms_template = ""
+ return
+
+ party_account_currency = doc.get("party_account_currency")
+ if not party_account_currency:
+ party_type, party = doc.get_party()
+ if party_type and party:
+ party_account_currency = get_party_account_currency(party_type, party, doc.company)
+
+ posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date")
+ due_date = doc.get("due_date") or posting_date
+
+ base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total)
+ grand_total = flt(doc.get("rounded_total") or doc.grand_total)
+ automatically_fetch_payment_terms = 0
+
+ if doc.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"):
+ po_or_so, doctype, fieldname = self.get_order_details()
+ automatically_fetch_payment_terms = cint(
+ frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
+ )
+ if doc.doctype != "Sales Order":
+ base_grand_total = base_grand_total - flt(doc.base_write_off_amount)
+ grand_total = grand_total - flt(doc.write_off_amount)
+
+ if doc.get("total_advance"):
+ if party_account_currency == doc.company_currency:
+ base_grand_total -= doc.get("total_advance")
+ grand_total = flt(base_grand_total / doc.get("conversion_rate"), doc.precision("grand_total"))
+ else:
+ grand_total -= doc.get("total_advance")
+ base_grand_total = flt(
+ grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total")
+ )
+
+ if not doc.get("payment_schedule"):
+ if (
+ doc.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"]
+ and automatically_fetch_payment_terms
+ and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
+ ):
+ self.fetch_payment_terms_from_order(
+ po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
+ )
+ if doc.get("payment_terms_template"):
+ doc.ignore_default_payment_terms_template = 1
+ elif doc.get("payment_terms_template"):
+ data = get_payment_terms(
+ doc.payment_terms_template, posting_date, grand_total, base_grand_total
+ )
+ for item in data:
+ doc.append("payment_schedule", item)
+ elif doc.doctype not in ["Purchase Receipt"]:
+ doc.append(
+ "payment_schedule",
+ dict(
+ due_date=due_date,
+ invoice_portion=100,
+ payment_amount=grand_total,
+ base_payment_amount=base_grand_total,
+ ),
+ )
+
+ allocate_payment_based_on_payment_terms = frappe.db.get_value(
+ "Payment Terms Template",
+ doc.payment_terms_template,
+ "allocate_payment_based_on_payment_terms",
+ )
+
+ if not (
+ automatically_fetch_payment_terms
+ and allocate_payment_based_on_payment_terms
+ and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
+ ):
+ for d in doc.get("payment_schedule"):
+ if d.invoice_portion:
+ d.payment_amount = flt(
+ grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount")
+ )
+ d.base_payment_amount = flt(
+ base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount")
+ )
+ d.outstanding = d.payment_amount
+ d.base_outstanding = d.base_payment_amount
+ elif not d.invoice_portion:
+ d.base_payment_amount = flt(
+ d.payment_amount * doc.get("conversion_rate"), d.precision("base_payment_amount")
+ )
+ d.base_outstanding = d.base_payment_amount
+ else:
+ self.fetch_payment_terms_from_order(
+ po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
+ )
+ doc.ignore_default_payment_terms_template = 1
+
+ def get_order_details(self) -> tuple:
+ doc = self.doc
+ if not doc.get("items"):
+ return None, None, None
+ if doc.doctype == "Sales Invoice":
+ prev_doc = doc.get("items")[0].get("sales_order")
+ prev_doctype = "Sales Order"
+ prev_doctype_name = "sales_order"
+ elif doc.doctype == "Purchase Invoice":
+ prev_doc = doc.get("items")[0].get("purchase_order")
+ prev_doctype = "Purchase Order"
+ prev_doctype_name = "purchase_order"
+ else:
+ prev_doc = doc.get("items")[0].get("prevdoc_docname")
+ prev_doctype = "Quotation"
+ prev_doctype_name = "prevdoc_docname"
+ return prev_doc, prev_doctype, prev_doctype_name
+
+ def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype) -> bool:
+ if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname):
+ if linked_order_has_payment_terms_template(po_or_so, doctype):
+ return True
+ elif linked_order_has_payment_schedule(po_or_so):
+ return True
+ return False
+
+ def all_items_have_same_po_or_so(self, po_or_so, fieldname) -> bool:
+ for item in self.doc.get("items"):
+ if item.get(fieldname) != po_or_so:
+ return False
+ return True
+
+ def fetch_payment_terms_from_order(
+ self,
+ po_or_so,
+ po_or_so_doctype,
+ grand_total,
+ base_grand_total,
+ automatically_fetch_payment_terms,
+ ) -> None:
+ """Fetch Payment Terms from Purchase/Sales Order when creating a new invoice."""
+ doc = self.doc
+ po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so)
+
+ doc.payment_schedule = []
+ doc.payment_terms_template = po_or_so.payment_terms_template
+ posting_date = doc.get("bill_date") or doc.get("posting_date") or doc.get("transaction_date")
+
+ for schedule in po_or_so.payment_schedule:
+ payment_schedule = {
+ "payment_term": schedule.payment_term,
+ "due_date": schedule.due_date,
+ "invoice_portion": schedule.invoice_portion,
+ "mode_of_payment": schedule.mode_of_payment,
+ "description": schedule.description,
+ "paid_amount": schedule.paid_amount,
+ }
+
+ if automatically_fetch_payment_terms:
+ if schedule.due_date_based_on:
+ payment_schedule["due_date"] = get_due_date(schedule, posting_date)
+ payment_schedule["due_date_based_on"] = schedule.due_date_based_on
+ payment_schedule["credit_days"] = cint(schedule.credit_days)
+ payment_schedule["credit_months"] = cint(schedule.credit_months)
+
+ if schedule.discount_validity_based_on and flt(schedule.discount):
+ payment_schedule["discount_date"] = get_discount_date(schedule, posting_date)
+ payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on
+ payment_schedule["discount_validity"] = cint(schedule.discount_validity)
+
+ payment_schedule["payment_amount"] = flt(
+ grand_total * flt(payment_schedule["invoice_portion"]) / 100,
+ schedule.precision("payment_amount"),
+ )
+ payment_schedule["base_payment_amount"] = flt(
+ base_grand_total * flt(payment_schedule["invoice_portion"]) / 100,
+ schedule.precision("base_payment_amount"),
+ )
+ payment_schedule["outstanding"] = payment_schedule["payment_amount"]
+ else:
+ payment_schedule["base_payment_amount"] = flt(
+ schedule.base_payment_amount * doc.get("conversion_rate"),
+ schedule.precision("base_payment_amount"),
+ )
+
+ if schedule.discount_type == "Percentage":
+ payment_schedule["discount_type"] = schedule.discount_type
+ payment_schedule["discount"] = schedule.discount
+
+ if not schedule.invoice_portion:
+ payment_schedule["payment_amount"] = schedule.payment_amount
+
+ doc.append("payment_schedule", payment_schedule)
+
+ def set_due_date(self) -> None:
+ due_dates = [d.due_date for d in self.doc.get("payment_schedule") if d.due_date]
+ if due_dates:
+ self.doc.due_date = max(due_dates)
+
+ def validate_payment_schedule_dates(self) -> None:
+ dates = []
+ li = []
+ doc = self.doc
+
+ if doc.doctype == "Sales Invoice" and doc.is_pos:
+ return
+
+ for d in doc.get("payment_schedule"):
+ if not flt(d.discount):
+ d.discount_date = None
+ d.validate_from_to_dates("discount_date", "due_date")
+ if doc.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate(
+ doc.transaction_date
+ ):
+ frappe.throw(
+ _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(
+ d.idx
+ )
+ )
+ elif d.due_date in dates:
+ li.append(_("{0} in row {1}").format(d.due_date, d.idx))
+ dates.append(d.due_date)
+
+ if li:
+ frappe.throw(
+ _("Rows with duplicate due dates in other rows were found: {0}").format(
+ "
" + "
".join(li)
+ ),
+ title=_("Payment Schedule"),
+ )
+
+ def validate_payment_schedule_amount(self) -> None:
+ doc = self.doc
+ if (doc.doctype == "Sales Invoice" and doc.is_pos) or doc.get("is_opening") == "Yes":
+ return
+
+ party_account_currency = doc.get("party_account_currency")
+ if not party_account_currency:
+ party_type, party = doc.get_party()
+ if party_type and party:
+ party_account_currency = get_party_account_currency(party_type, party, doc.company)
+
+ if doc.get("payment_schedule"):
+ total = 0
+ base_total = 0
+ for d in doc.get("payment_schedule"):
+ total += flt(d.payment_amount, d.precision("payment_amount"))
+ base_total += flt(d.base_payment_amount, d.precision("base_payment_amount"))
+
+ base_grand_total = flt(doc.get("base_rounded_total") or doc.base_grand_total)
+ grand_total = flt(doc.get("rounded_total") or doc.grand_total)
+
+ if doc.doctype in ("Sales Invoice", "Purchase Invoice"):
+ base_grand_total = base_grand_total - flt(doc.base_write_off_amount)
+ grand_total = grand_total - flt(doc.write_off_amount)
+
+ if doc.get("total_advance"):
+ if party_account_currency == doc.company_currency:
+ base_grand_total -= doc.get("total_advance")
+ grand_total = flt(
+ base_grand_total / doc.get("conversion_rate"), doc.precision("grand_total")
+ )
+ else:
+ grand_total -= doc.get("total_advance")
+ base_grand_total = flt(
+ grand_total * doc.get("conversion_rate"), doc.precision("base_grand_total")
+ )
+
+ if (
+ abs(flt(total, doc.precision("grand_total")) - flt(grand_total, doc.precision("grand_total")))
+ > 0.1
+ or abs(
+ flt(base_total, doc.precision("base_grand_total"))
+ - flt(base_grand_total, doc.precision("base_grand_total"))
+ )
+ > 0.1
+ ):
+ frappe.throw(
+ _("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total")
+ )
+
+
+def linked_order_has_payment_terms_template(po_or_so, doctype) -> str | None:
+ return frappe.get_value(doctype, po_or_so, "payment_terms_template")
+
+
+def linked_order_has_payment_schedule(po_or_so) -> list:
+ return frappe.get_all("Payment Schedule", filters={"parent": po_or_so})
+
+
+def get_payment_terms(
+ terms_template: str,
+ posting_date: DateTimeLikeObject | None = None,
+ grand_total: float | None = None,
+ base_grand_total: float | None = None,
+ bill_date: DateTimeLikeObject | None = None,
+) -> list:
+ if not terms_template:
+ return
+
+ terms_doc = frappe.get_doc("Payment Terms Template", terms_template)
+ schedule = []
+ for d in terms_doc.get("terms"):
+ d = frappe._dict(d.as_dict())
+ term_details = get_payment_term_details(d, posting_date, grand_total, base_grand_total, bill_date)
+ schedule.append(term_details)
+
+ return schedule
+
+
+@frappe.whitelist()
+def get_payment_term_details(
+ term: str | frappe._dict,
+ posting_date: DateTimeLikeObject | None = None,
+ grand_total: float | None = None,
+ base_grand_total: float | None = None,
+ bill_date: DateTimeLikeObject | None = None,
+) -> frappe._dict:
+ term_details = frappe._dict()
+ if isinstance(term, str):
+ term = frappe.get_doc("Payment Term", term)
+ else:
+ term_details.payment_term = term.payment_term
+
+ for field in [
+ "description",
+ "invoice_portion",
+ "discount_type",
+ "discount",
+ "mode_of_payment",
+ "due_date_based_on",
+ "credit_days",
+ "credit_months",
+ "discount_validity_based_on",
+ "discount_validity",
+ ]:
+ term_details[field] = term.get(field)
+
+ term_details.payment_amount = flt(term.invoice_portion) * flt(grand_total) / 100
+ term_details.base_payment_amount = flt(term.invoice_portion) * flt(base_grand_total) / 100
+ term_details.outstanding = term_details.payment_amount
+ term_details.base_outstanding = term_details.base_payment_amount
+
+ has_discount = flt(term.get("discount"))
+ date = bill_date or posting_date
+ if date:
+ term_details.due_date = get_due_date(term, date)
+ term_details.discount_date = get_discount_date(term, date) if has_discount else None
+
+ if posting_date and getdate(term_details.due_date) < getdate(posting_date):
+ term_details.due_date = posting_date
+
+ return term_details
+
+
+def get_due_date(term, posting_date=None, bill_date=None):
+ due_date = None
+ date = bill_date or posting_date
+ if term.due_date_based_on == "Day(s) after invoice date":
+ due_date = add_days(date, cint(term.credit_days))
+ elif term.due_date_based_on == "Day(s) after the end of the invoice month":
+ due_date = add_days(get_last_day(date), cint(term.credit_days))
+ elif term.due_date_based_on == "Month(s) after the end of the invoice month":
+ due_date = get_last_day(add_months(date, cint(term.credit_months)))
+ return due_date
+
+
+def get_discount_date(term, posting_date=None, bill_date=None):
+ discount_validity = None
+ date = bill_date or posting_date
+ if term.discount_validity_based_on == "Day(s) after invoice date":
+ discount_validity = add_days(date, cint(term.discount_validity))
+ elif term.discount_validity_based_on == "Day(s) after the end of the invoice month":
+ discount_validity = add_days(get_last_day(date), cint(term.discount_validity))
+ elif term.discount_validity_based_on == "Month(s) after the end of the invoice month":
+ discount_validity = get_last_day(add_months(date, cint(term.discount_validity)))
+ return discount_validity
diff --git a/erpnext/accounts/services/taxes.py b/erpnext/accounts/services/taxes.py
new file mode 100644
index 00000000000..2705bf4ea73
--- /dev/null
+++ b/erpnext/accounts/services/taxes.py
@@ -0,0 +1,446 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+"""Tax helpers: TaxService class for doc-mutating operations, free functions for stateless utilities."""
+
+import json
+
+import frappe
+from frappe import _, throw
+from frappe.utils import cint, flt, parse_json
+
+import erpnext
+from erpnext.stock.get_item_details import (
+ NOT_APPLICABLE_TAX,
+ ItemDetailsCtx,
+ _get_item_tax_template,
+ _get_item_tax_template_from_item_group,
+ get_item_tax_map,
+)
+
+
+class TaxService:
+ def __init__(self, doc):
+ self.doc = doc
+
+ def set_taxes(self) -> None:
+ doc = self.doc
+ if not doc.meta.get_field("taxes"):
+ return
+
+ tax_master_doctype = doc.meta.get_field("taxes_and_charges").options
+
+ if (doc.is_new() or self.is_pos_profile_changed()) and not doc.get("taxes"):
+ if doc.company and not doc.get("taxes_and_charges"):
+ doc.taxes_and_charges = frappe.db.get_value(
+ tax_master_doctype, {"is_default": 1, "company": doc.company}
+ )
+ self.append_taxes_from_master(tax_master_doctype)
+
+ def is_pos_profile_changed(self) -> bool:
+ doc = self.doc
+ if (
+ doc.doctype == "Sales Invoice"
+ and doc.is_pos
+ and doc.pos_profile != frappe.db.get_value("Sales Invoice", doc.name, "pos_profile")
+ ):
+ return True
+
+ def set_taxes_and_charges(self) -> None:
+ doc = self.doc
+ if doc.doctype == "Material Request":
+ return
+
+ if doc.get("taxes") or doc.get("is_pos"):
+ return
+
+ if frappe.get_single_value(
+ "Accounts Settings", "add_taxes_from_taxes_and_charges_template"
+ ) and hasattr(doc, "taxes_and_charges"):
+ if tax_master_doctype := doc.meta.get_field("taxes_and_charges").options:
+ self.append_taxes_from_master(tax_master_doctype)
+
+ if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"):
+ self.append_taxes_from_item_tax_template()
+
+ def append_taxes_from_master(self, tax_master_doctype=None) -> None:
+ doc = self.doc
+ if doc.get("taxes_and_charges"):
+ if not tax_master_doctype:
+ tax_master_doctype = doc.meta.get_field("taxes_and_charges").options
+ doc.extend("taxes", get_taxes_and_charges(tax_master_doctype, doc.get("taxes_and_charges")))
+
+ def append_taxes_from_item_tax_template(self) -> None:
+ doc = self.doc
+ if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"):
+ return
+
+ for row in doc.items:
+ item_tax_rate = row.get("item_tax_rate")
+ if not item_tax_rate:
+ continue
+
+ if isinstance(item_tax_rate, str):
+ item_tax_rate = parse_json(item_tax_rate)
+
+ for account_head, _rate in item_tax_rate.items():
+ if not self.get_tax_row(account_head):
+ doc.append(
+ "taxes",
+ {
+ "charge_type": "On Net Total",
+ "account_head": account_head,
+ "rate": 0,
+ "description": account_head,
+ "set_by_item_tax_template": 1,
+ "category": "Total",
+ "add_deduct_tax": "Add",
+ },
+ )
+
+ def get_tax_row(self, account_head):
+ for row in self.doc.taxes:
+ if row.account_head == account_head:
+ return row
+
+ def set_other_charges(self) -> None:
+ self.doc.set("taxes", [])
+ self.set_taxes()
+
+ def validate_enabled_taxes_and_charges(self) -> None:
+ doc = self.doc
+ taxes_and_charges_doctype = doc.meta.get_options("taxes_and_charges")
+ if doc.taxes_and_charges and frappe.get_cached_value(
+ taxes_and_charges_doctype, doc.taxes_and_charges, "disabled"
+ ):
+ frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, doc.taxes_and_charges))
+
+ def validate_tax_account_company(self) -> None:
+ doc = self.doc
+ for d in doc.get("taxes"):
+ if d.account_head:
+ tax_account_company = frappe.get_cached_value("Account", d.account_head, "company")
+ if tax_account_company != doc.company:
+ frappe.throw(
+ _("Row #{0}: Account {1} does not belong to company {2}").format(
+ d.idx, d.account_head, doc.company
+ )
+ )
+
+ def get_tax_map(self) -> dict:
+ tax_map = {}
+ for tax in self.doc.get("taxes"):
+ tax_map.setdefault(tax.account_head, 0.0)
+ tax_map[tax.account_head] += tax.tax_amount
+ return tax_map
+
+ def get_amount_and_base_amount(self, item, enable_discount_accounting):
+ doc = self.doc
+ amount = item.net_amount
+ base_amount = item.base_net_amount
+
+ if (
+ enable_discount_accounting
+ and doc.get("discount_amount")
+ and doc.get("additional_discount_account")
+ ):
+ if not hasattr(doc, "__has_distributed_discount_set"):
+ doc.__has_distributed_discount_set = any(
+ i.distributed_discount_amount for i in doc.get("items")
+ )
+
+ if not doc.__has_distributed_discount_set:
+ return item.amount, item.base_amount
+
+ amount += item.distributed_discount_amount
+ base_amount += flt(
+ item.distributed_discount_amount * doc.get("conversion_rate"),
+ item.precision("distributed_discount_amount"),
+ )
+
+ return amount, base_amount
+
+ def get_tax_amounts(self, tax, enable_discount_accounting):
+ doc = self.doc
+ amount = tax.tax_amount_after_discount_amount
+ base_amount = tax.base_tax_amount_after_discount_amount
+
+ if (
+ enable_discount_accounting
+ and doc.get("discount_amount")
+ and doc.get("additional_discount_account")
+ and doc.get("apply_discount_on") == "Grand Total"
+ ):
+ amount = tax.tax_amount
+ base_amount = tax.base_tax_amount
+
+ return amount, base_amount
+
+
+def get_tax_rate(account_head: str) -> dict:
+ return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
+
+
+@frappe.whitelist()
+def get_default_taxes_and_charges(
+ master_doctype: str, tax_template: str | None = None, company: str | None = None
+) -> dict | None:
+ if not company:
+ return {}
+
+ if tax_template and company:
+ tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company")
+ if tax_template_company == company:
+ return
+
+ default_tax = frappe.db.get_value(master_doctype, {"is_default": 1, "company": company})
+
+ return {
+ "taxes_and_charges": default_tax,
+ "taxes": get_taxes_and_charges(master_doctype, default_tax),
+ }
+
+
+@frappe.whitelist()
+def get_taxes_and_charges(master_doctype: str, master_name: str | None = None) -> list | None:
+ if not master_name:
+ return
+ from frappe.model import child_table_fields, default_fields
+
+ tax_master = frappe.get_doc(master_doctype, master_name)
+
+ taxes_and_charges = []
+ for _i, tax in enumerate(tax_master.get("taxes")):
+ tax = tax.as_dict()
+
+ for fieldname in default_fields + child_table_fields:
+ if fieldname in tax:
+ del tax[fieldname]
+
+ taxes_and_charges.append(tax)
+
+ return taxes_and_charges
+
+
+def validate_conversion_rate(
+ currency: str, conversion_rate: float, conversion_rate_label: str, company: str
+) -> None:
+ """Throw a validation error if conversion_rate is falsy."""
+ company_currency = frappe.get_cached_value("Company", company, "default_currency")
+
+ if not conversion_rate:
+ throw(
+ _("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format(
+ conversion_rate_label, currency, company_currency
+ )
+ )
+
+
+def validate_taxes_and_charges(tax) -> None:
+ if tax.charge_type in ["Actual", "On Net Total", "On Paid Amount"] and tax.row_id:
+ frappe.throw(
+ _("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'")
+ )
+ elif tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]:
+ if cint(tax.idx) == 1:
+ frappe.throw(
+ _(
+ "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
+ )
+ )
+ elif not tax.row_id:
+ frappe.throw(
+ _("Please specify a valid Row ID for row {0} in table {1}").format(tax.idx, _(tax.doctype))
+ )
+ elif tax.row_id and cint(tax.row_id) >= cint(tax.idx):
+ frappe.throw(
+ _("Cannot refer row number greater than or equal to current row number for this Charge type")
+ )
+
+ if tax.charge_type == "Actual":
+ tax.rate = None
+
+
+def validate_account_head(idx: int, account: str, company: str, context: str | None = None) -> None:
+ """Throw a ValidationError if the account belongs to a different company or is a group account."""
+ if company != frappe.get_cached_value("Account", account, "company"):
+ frappe.throw(
+ _("Row {0}: The {3} Account {1} does not belong to the company {2}").format(
+ idx, frappe.bold(account), frappe.bold(company), context or ""
+ ),
+ title=_("Invalid Account"),
+ )
+
+ if frappe.get_cached_value("Account", account, "is_group"):
+ frappe.throw(
+ _(
+ "You selected the account group {1} as {2} Account in row {0}. Please select a single account."
+ ).format(idx, frappe.bold(account), context or ""),
+ title=_("Invalid Account"),
+ )
+
+
+def validate_cost_center(tax, doc) -> None:
+ if not tax.cost_center:
+ return
+
+ company = frappe.get_cached_value("Cost Center", tax.cost_center, "company")
+
+ if company != doc.company:
+ frappe.throw(
+ _("Row {0}: Cost Center {1} does not belong to Company {2}").format(
+ tax.idx, frappe.bold(tax.cost_center), frappe.bold(doc.company)
+ ),
+ title=_("Invalid Cost Center"),
+ )
+
+
+def validate_inclusive_tax(tax, doc) -> None:
+ def _on_previous_row_error(row_range):
+ throw(
+ _("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(
+ tax.idx, row_range
+ )
+ )
+
+ if cint(getattr(tax, "included_in_print_rate", None)):
+ if tax.charge_type == "Actual":
+ throw(
+ _("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format(
+ tax.idx
+ )
+ )
+ elif tax.charge_type == "On Previous Row Amount" and not cint(
+ doc.get("taxes")[cint(tax.row_id) - 1].included_in_print_rate
+ ):
+ _on_previous_row_error(tax.row_id)
+ elif tax.charge_type == "On Previous Row Total" and not all(
+ [cint(t.included_in_print_rate) for t in doc.get("taxes")[: cint(tax.row_id) - 1]]
+ ):
+ _on_previous_row_error("1 - %d" % (tax.row_id,))
+ elif tax.get("category") == "Valuation":
+ frappe.throw(_("Valuation type charges can not be marked as Inclusive"))
+
+
+def set_balance_in_account_currency(
+ gl_dict,
+ account_currency: str | None = None,
+ conversion_rate: float | None = None,
+ company_currency: str | None = None,
+) -> None:
+ if (not conversion_rate) and (account_currency != company_currency):
+ frappe.throw(
+ _("Account: {0} with currency: {1} can not be selected").format(gl_dict.account, account_currency)
+ )
+
+ gl_dict["account_currency"] = account_currency
+
+ if flt(gl_dict.debit) and not flt(gl_dict.debit_in_account_currency):
+ gl_dict.debit_in_account_currency = (
+ gl_dict.debit if account_currency == company_currency else flt(gl_dict.debit / conversion_rate, 2)
+ )
+
+ if flt(gl_dict.credit) and not flt(gl_dict.credit_in_account_currency):
+ gl_dict.credit_in_account_currency = (
+ gl_dict.credit
+ if account_currency == company_currency
+ else flt(gl_dict.credit / conversion_rate, 2)
+ )
+
+
+def set_child_tax_template_and_map(item, child_item, parent_doc) -> None:
+ ctx = ItemDetailsCtx(
+ {
+ "item_code": item.item_code,
+ "posting_date": parent_doc.transaction_date,
+ "tax_category": parent_doc.get("tax_category"),
+ "company": parent_doc.get("company"),
+ "base_net_rate": item.get("base_net_rate"),
+ }
+ )
+
+ item_tax_template = _get_item_tax_template(ctx, item.taxes)
+
+ if not item_tax_template:
+ item_tax_template = _get_item_tax_template_from_item_group(ctx, item.item_group)
+
+ child_item.item_tax_template = item_tax_template
+ child_item.item_tax_rate = get_item_tax_map(
+ doc=parent_doc,
+ tax_template=child_item.item_tax_template,
+ as_json=True,
+ )
+
+
+def add_taxes_from_tax_template(child_item, parent_doc, db_insert: bool = True) -> None:
+ add_taxes_from_item_tax_template = frappe.get_single_value(
+ "Accounts Settings", "add_taxes_from_item_tax_template"
+ )
+
+ if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template:
+ tax_map = json.loads(child_item.get("item_tax_rate"))
+ for tax_type, tax_rate in tax_map.items():
+ if tax_rate == NOT_APPLICABLE_TAX:
+ continue
+
+ tax_rate = flt(tax_rate)
+ taxes = parent_doc.get("taxes") or []
+ found = any(tax.account_head == tax_type for tax in taxes)
+ if not found:
+ tax_row = parent_doc.append("taxes", {})
+ tax_row.update(
+ {
+ "description": str(tax_type).split(" - ")[0],
+ "charge_type": "On Net Total",
+ "account_head": tax_type,
+ "rate": tax_rate,
+ "set_by_item_tax_template": 1,
+ }
+ )
+ if parent_doc.doctype == "Purchase Order":
+ tax_row.update({"category": "Total", "add_deduct_tax": "Add"})
+ if db_insert:
+ tax_row.db_insert()
+
+
+def merge_taxes(source_doc, target_doc) -> None:
+ tax_map = {}
+ for tax in source_doc.get("taxes") or []:
+ found = False
+ for t in target_doc.get("taxes") or []:
+ if t.account_head == tax.account_head and t.cost_center == tax.cost_center:
+ t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount)
+ t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount)
+ tax_map[tax.name] = t
+ found = True
+
+ if not found:
+ tax.charge_type = "Actual"
+ tax.included_in_print_rate = 0
+ tax.dont_recompute_tax = 1
+ tax.row_id = None
+ tax.idx = None
+ tax.tax_amount = tax.tax_amount_after_discount_amount
+ tax.base_tax_amount = tax.base_tax_amount_after_discount_amount
+ tax_map[tax.name] = target_doc.append("taxes", tax)
+
+ item_map = {d._old_name: d for d in target_doc.get("items") if d.get("_old_name")}
+
+ item_tax_details = target_doc.get("_item_wise_tax_details") or []
+ for row in source_doc.get("item_wise_tax_details"):
+ item = item_map.get(row.item_row)
+ tax = tax_map.get(row.tax_row)
+ if not (item and tax):
+ continue
+
+ item_tax_details.append(
+ frappe._dict(
+ item=item,
+ tax=tax,
+ amount=row.amount,
+ rate=row.rate,
+ taxable_amount=row.taxable_amount,
+ )
+ )
+
+ target_doc._item_wise_tax_details = item_tax_details
diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js
index 1ba9b12d2b1..e269f289307 100644
--- a/erpnext/assets/doctype/asset/asset.js
+++ b/erpnext/assets/doctype/asset/asset.js
@@ -46,7 +46,7 @@ frappe.ui.form.on("Asset", {
frm.make_methods = {
"Asset Movement": () => {
frappe.call({
- method: "erpnext.assets.doctype.asset.asset.make_asset_movement",
+ method: "erpnext.assets.doctype.asset.mapper.make_asset_movement",
freeze: true,
args: {
assets: [{ name: frm.doc.name }],
@@ -333,7 +333,7 @@ frappe.ui.form.on("Asset", {
make_journal_entry: function (frm) {
frappe.call({
- method: "erpnext.assets.doctype.asset.asset.make_journal_entry",
+ method: "erpnext.assets.doctype.asset.mapper.make_journal_entry",
args: {
asset_name: frm.doc.name,
},
@@ -570,7 +570,7 @@ frappe.ui.form.on("Asset", {
asset_category: frm.doc.asset_category,
company: frm.doc.company,
},
- method: "erpnext.assets.doctype.asset.asset.create_asset_maintenance",
+ method: "erpnext.assets.doctype.asset.mapper.create_asset_maintenance",
callback: function (r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
@@ -585,7 +585,7 @@ frappe.ui.form.on("Asset", {
asset: frm.doc.name,
asset_name: frm.doc.asset_name,
},
- method: "erpnext.assets.doctype.asset.asset.create_asset_repair",
+ method: "erpnext.assets.doctype.asset.mapper.create_asset_repair",
callback: function (r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
@@ -601,7 +601,7 @@ frappe.ui.form.on("Asset", {
asset_name: frm.doc.asset_name,
item_code: frm.doc.item_code,
},
- method: "erpnext.assets.doctype.asset.asset.create_asset_capitalization",
+ method: "erpnext.assets.doctype.asset.mapper.create_asset_capitalization",
callback: function (r) {
var doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
@@ -612,7 +612,7 @@ frappe.ui.form.on("Asset", {
sell_asset: function (frm) {
const make_sales_invoice = (sell_qty) => {
frappe.call({
- method: "erpnext.assets.doctype.asset.asset.make_sales_invoice",
+ method: "erpnext.assets.doctype.asset.mapper.make_sales_invoice",
args: {
asset: frm.doc.name,
item_code: frm.doc.item_code,
@@ -696,7 +696,7 @@ frappe.ui.form.on("Asset", {
asset_name: frm.doc.name,
split_qty: cint(dialog_data.split_qty),
},
- method: "erpnext.assets.doctype.asset.asset.split_asset",
+ method: "erpnext.assets.doctype.asset.mapper.split_asset",
callback: function (r) {
let doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
@@ -716,7 +716,7 @@ frappe.ui.form.on("Asset", {
asset_category: frm.doc.asset_category,
company: frm.doc.company,
},
- method: "erpnext.assets.doctype.asset.asset.create_asset_value_adjustment",
+ method: "erpnext.assets.doctype.asset.mapper.create_asset_value_adjustment",
freeze: 1,
callback: function (r) {
var doclist = frappe.model.sync(r.message);
@@ -967,7 +967,7 @@ erpnext.asset.restore_asset = function (frm) {
erpnext.asset.transfer_asset = function (frm) {
frappe.call({
- method: "erpnext.assets.doctype.asset.asset.make_asset_movement",
+ method: "erpnext.assets.doctype.asset.mapper.make_asset_movement",
freeze: true,
args: {
assets: [{ name: frm.doc.name }],
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 98acc169cf2..1c9f3871d47 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -21,7 +21,6 @@ from frappe.utils import (
)
import erpnext
-from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.accounts.general_ledger import make_reverse_gl_entries
from erpnext.assets.doctype.asset.depreciation import (
get_comma_separated_links,
@@ -1092,101 +1091,6 @@ def get_asset_naming_series():
return meta.get_field("naming_series").options
-@frappe.whitelist()
-def make_sales_invoice(asset: str, item_code: str, company: str, sell_qty: int, serial_no: str | None = None):
- asset_doc = frappe.get_doc("Asset", asset)
- si = frappe.new_doc("Sales Invoice")
- si.company = company
- si.currency = frappe.get_cached_value("Company", company, "default_currency")
- disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(company)
- si.append(
- "items",
- {
- "item_code": item_code,
- "is_fixed_asset": 1,
- "asset": asset,
- "income_account": disposal_account,
- "serial_no": serial_no,
- "cost_center": depreciation_cost_center,
- "qty": sell_qty,
- },
- )
-
- accounting_dimensions = get_dimensions(with_cost_center_and_project=True)
- for dimension in accounting_dimensions[0]:
- si.update(
- {
- dimension["fieldname"]: asset_doc.get(dimension["fieldname"])
- or dimension.get("default_dimension")
- }
- )
-
- si.set_missing_values()
- return si
-
-
-@frappe.whitelist()
-def create_asset_maintenance(
- asset: str,
- item_code: str,
- item_name: str,
- asset_category: str,
- company: str,
-):
- asset_maintenance = frappe.new_doc("Asset Maintenance")
- asset_maintenance.update(
- {
- "asset_name": asset,
- "company": company,
- "item_code": item_code,
- "item_name": item_name,
- "asset_category": asset_category,
- }
- )
- return asset_maintenance
-
-
-@frappe.whitelist()
-def create_asset_repair(
- company: str,
- asset: str,
- asset_name: str,
-):
- asset_repair = frappe.new_doc("Asset Repair")
- asset_repair.update({"company": company, "asset": asset, "asset_name": asset_name})
- return asset_repair
-
-
-@frappe.whitelist()
-def create_asset_capitalization(
- company: str,
- asset: str,
- asset_name: str,
- item_code: str,
-):
- asset_capitalization = frappe.new_doc("Asset Capitalization")
- asset_capitalization.update(
- {
- "target_asset": asset,
- "company": company,
- "target_asset_name": asset_name,
- "target_item_code": item_code,
- }
- )
- return asset_capitalization
-
-
-@frappe.whitelist()
-def create_asset_value_adjustment(
- asset: str,
- asset_category: str,
- company: str,
-):
- asset_value_adjustment = frappe.new_doc("Asset Value Adjustment")
- asset_value_adjustment.update({"asset": asset, "company": company, "asset_category": asset_category})
- return asset_value_adjustment
-
-
@frappe.whitelist()
def get_item_details(
item_code: str,
@@ -1241,77 +1145,6 @@ def get_asset_account(account_name, asset=None, asset_category=None, company=Non
return account
-@frappe.whitelist()
-def make_journal_entry(asset_name: str):
- asset = frappe.get_doc("Asset", asset_name)
- (
- fixed_asset_account,
- accumulated_depreciation_account,
- depreciation_expense_account,
- ) = get_depreciation_accounts(asset.asset_category, asset.company)
-
- depreciation_cost_center, depreciation_series = frappe.get_cached_value(
- "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"]
- )
- depreciation_cost_center = asset.cost_center or depreciation_cost_center
-
- je = frappe.new_doc("Journal Entry")
- je.voucher_type = "Depreciation Entry"
- je.naming_series = depreciation_series
- je.company = asset.company
- je.remark = _("Depreciation Entry against asset {0}").format(asset_name)
-
- je.append(
- "accounts",
- {
- "account": depreciation_expense_account,
- "reference_type": "Asset",
- "reference_name": asset.name,
- "cost_center": depreciation_cost_center,
- },
- )
-
- je.append(
- "accounts",
- {
- "account": accumulated_depreciation_account,
- "reference_type": "Asset",
- "reference_name": asset.name,
- },
- )
-
- return je
-
-
-@frappe.whitelist()
-def make_asset_movement(
- assets: list[dict] | str,
- purpose: str = "Transfer",
-):
- if isinstance(assets, str):
- assets = json.loads(assets)
-
- if len(assets) == 0:
- frappe.throw(_("At least one asset has to be selected."))
-
- asset_movement = frappe.new_doc("Asset Movement")
- asset_movement.purpose = purpose
- for asset in assets:
- asset = frappe.get_doc("Asset", asset.get("name"))
- asset_movement.company = asset.get("company")
- asset_movement.append(
- "assets",
- {
- "asset": asset.get("name"),
- "source_location": asset.get("location"),
- "from_employee": asset.get("custodian"),
- },
- )
-
- if asset_movement.get("assets"):
- return asset_movement.as_dict()
-
-
def is_cwip_accounting_enabled(asset_category):
return cint(frappe.db.get_value("Asset Category", asset_category, "enable_cwip_accounting"))
@@ -1360,216 +1193,3 @@ def get_values_from_purchase_doc(
"purchase_receipt_item": first_item.name if doctype == "Purchase Receipt" else None,
"purchase_invoice_item": first_item.name if doctype == "Purchase Invoice" else None,
}
-
-
-@frappe.whitelist()
-def split_asset(asset_name: str, split_qty: int):
- """Split an asset into two based on the given quantity."""
- existing_asset = frappe.get_doc("Asset", asset_name)
- split_qty = cint(split_qty)
-
- validate_split_quantity(existing_asset, split_qty)
- remaining_qty = existing_asset.asset_quantity - split_qty
-
- # Create new asset and update existing one
- splitted_asset = create_new_asset_from_split(existing_asset, split_qty)
- update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset)
-
- return splitted_asset
-
-
-def validate_split_quantity(existing_asset, split_qty):
- if split_qty >= existing_asset.asset_quantity:
- frappe.throw(_("Split Quantity must be less than Asset Quantity"))
-
-
-def create_new_asset_from_split(existing_asset, split_qty):
- """Create a new asset from the split quantity."""
- return process_asset_split(existing_asset, split_qty, is_new_asset=True)
-
-
-def update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset):
- """Update the existing asset with the remaining quantity."""
- process_asset_split(existing_asset, remaining_qty, splitted_asset=splitted_asset)
-
-
-def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_asset=False):
- """Handle asset creation or update during the split."""
- scaling_factor = flt(split_qty) / flt(existing_asset.asset_quantity)
- new_asset = frappe.copy_doc(existing_asset) if is_new_asset else splitted_asset
- asset_doc = new_asset if is_new_asset else existing_asset
- asset_doc.flags.is_split_asset = True
-
- set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset)
- log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset)
-
- # Update finance books and depreciation schedules
- update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset)
- return new_asset
-
-
-def set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset):
- asset_doc.net_purchase_amount = existing_asset.net_purchase_amount * scaling_factor
- asset_doc.purchase_amount = existing_asset.net_purchase_amount * scaling_factor
- asset_doc.additional_asset_cost = existing_asset.additional_asset_cost * scaling_factor
- asset_doc.total_asset_cost = asset_doc.net_purchase_amount + asset_doc.additional_asset_cost
- asset_doc.opening_accumulated_depreciation = (
- existing_asset.opening_accumulated_depreciation * scaling_factor
- )
- asset_doc.value_after_depreciation = existing_asset.value_after_depreciation * scaling_factor
- asset_doc.asset_quantity = split_qty
- asset_doc.split_from = existing_asset.name if is_new_asset else None
-
- for row in asset_doc.get("finance_books"):
- row.value_after_depreciation = row.value_after_depreciation * scaling_factor
- row.expected_value_after_useful_life = row.expected_value_after_useful_life * scaling_factor
-
- if not is_new_asset:
- asset_doc.flags.ignore_validate_update_after_submit = True
- asset_doc.save()
-
-
-def log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset):
- if is_new_asset:
- asset_doc.insert()
- add_asset_activity(
- asset_doc.name,
- _("Asset created after being split from Asset {0}").format(
- get_link_to_form("Asset", existing_asset.name)
- ),
- )
- asset_doc.submit()
- asset_doc.set_status()
- else:
- add_asset_activity(
- existing_asset.name,
- _("Asset updated after being split into Asset {0}").format(
- get_link_to_form("Asset", splitted_asset.name)
- ),
- )
-
-
-def update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset):
- """Update finance books and depreciation schedules for the asset."""
- for fb_row in asset_doc.get("finance_books"):
- reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset)
-
- # Add references in journal entries for new asset
- if is_new_asset:
- for row in new_asset.get("finance_books"):
- depr_schedule_doc = get_depr_schedule(new_asset.name, "Active", row.finance_book)
- for schedule in depr_schedule_doc:
- if schedule.journal_entry:
- add_reference_in_jv_on_split(
- schedule.journal_entry,
- new_asset.name,
- existing_asset.name,
- schedule.depreciation_amount,
- )
-
-
-def reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset):
- """Reschedule depreciation for an asset after a split."""
- current_depr_schedule_doc = get_asset_depr_schedule_doc(
- existing_asset.name, "Active", fb_row.finance_book
- )
- if not current_depr_schedule_doc:
- return
-
- # Create a new depreciation schedule based on the current one
- new_depr_schedule_doc = create_new_depr_schedule(
- current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row
- )
-
- update_depreciation_terms(new_depr_schedule_doc, scaling_factor)
- add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset)
-
- if not is_new_asset:
- current_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True
- current_depr_schedule_doc.cancel()
-
- new_depr_schedule_doc.submit()
-
-
-def create_new_depr_schedule(current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row):
- """Create a new depreciation schedule based on the current one."""
- new_depr_schedule_doc = frappe.copy_doc(current_depr_schedule_doc)
- new_depr_schedule_doc.asset_doc = new_asset if is_new_asset else existing_asset
- new_depr_schedule_doc.fb_row = fb_row
- new_depr_schedule_doc.fetch_asset_details()
- return new_depr_schedule_doc
-
-
-def update_depreciation_terms(new_depr_schedule_doc, scaling_factor):
- """Update depreciation terms with scaled amounts."""
- accumulated_depreciation = 0
- for term in new_depr_schedule_doc.get("depreciation_schedule"):
- depreciation_amount = flt(
- term.depreciation_amount * scaling_factor, term.precision("depreciation_amount")
- )
- term.depreciation_amount = depreciation_amount
- accumulated_depreciation = flt(
- accumulated_depreciation + depreciation_amount, term.precision("depreciation_amount")
- )
- term.accumulated_depreciation_amount = accumulated_depreciation
-
-
-def add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset):
- notes = _("This schedule was created when Asset {0} was {1} into new Asset {2}.").format(
- get_link_to_form(existing_asset.doctype, existing_asset.name),
- "split" if is_new_asset else "updated after being split",
- get_link_to_form(new_asset.doctype, new_asset.name),
- )
- new_depr_schedule_doc.notes = notes
-
-
-def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount):
- """Add a reference to a new asset in a journal entry after a split."""
- journal_entry = frappe.get_doc("Journal Entry", entry_name)
- entries_to_add = []
-
- adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add)
- add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount)
-
- # Save and repost the journal entry
- journal_entry.flags.ignore_validate_update_after_submit = True
- journal_entry.save()
-
- journal_entry.docstatus = 2
- journal_entry.make_gl_entries(1)
- journal_entry.docstatus = 1
- journal_entry.make_gl_entries()
-
-
-def adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add):
- """Adjust existing accounts and prepare new entries for the new asset."""
- for account in journal_entry.get("accounts"):
- if account.reference_name == old_asset_name:
- entries_to_add.append(frappe.copy_doc(account).as_dict())
- adjust_account_balance(account, depreciation_amount)
-
-
-def adjust_account_balance(account, depreciation_amount):
- """Adjust the balance of an account based on the depreciation amount."""
- if account.credit:
- account.credit -= depreciation_amount
- account.credit_in_account_currency -= account.exchange_rate * depreciation_amount
- elif account.debit:
- account.debit -= depreciation_amount
- account.debit_in_account_currency -= account.exchange_rate * depreciation_amount
-
-
-def add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount):
- """Add new entries for the new asset to the journal entry."""
- idx = len(journal_entry.get("accounts")) + 1
- for entry in entries_to_add:
- entry.reference_name = new_asset_name
- if entry.credit:
- entry.credit = depreciation_amount
- entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount
- elif entry.debit:
- entry.debit = depreciation_amount
- entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount
- entry.idx = idx
- idx += 1
- journal_entry.append("accounts", entry)
diff --git a/erpnext/assets/doctype/asset/asset_list.js b/erpnext/assets/doctype/asset/asset_list.js
index 8b0d289dab0..0048454ac86 100644
--- a/erpnext/assets/doctype/asset/asset_list.js
+++ b/erpnext/assets/doctype/asset/asset_list.js
@@ -32,7 +32,7 @@ frappe.listview_settings["Asset"] = {
me.page.add_action_item(__("Make Asset Movement"), function () {
const assets = me.get_checked_items();
frappe.call({
- method: "erpnext.assets.doctype.asset.asset.make_asset_movement",
+ method: "erpnext.assets.doctype.asset.mapper.make_asset_movement",
freeze: true,
args: {
assets: assets,
diff --git a/erpnext/assets/doctype/asset/mapper.py b/erpnext/assets/doctype/asset/mapper.py
new file mode 100644
index 00000000000..282d58a987c
--- /dev/null
+++ b/erpnext/assets/doctype/asset/mapper.py
@@ -0,0 +1,394 @@
+# Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.utils import cint, flt, get_link_to_form
+
+from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
+from erpnext.assets.doctype.asset.depreciation import (
+ get_depreciation_accounts,
+ get_disposal_account_and_cost_center,
+)
+from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
+from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
+ get_asset_depr_schedule_doc,
+ get_depr_schedule,
+)
+
+
+@frappe.whitelist()
+def make_sales_invoice(asset: str, item_code: str, company: str, sell_qty: int, serial_no: str | None = None):
+ asset_doc = frappe.get_doc("Asset", asset)
+ si = frappe.new_doc("Sales Invoice")
+ si.company = company
+ si.currency = frappe.get_cached_value("Company", company, "default_currency")
+ disposal_account, depreciation_cost_center = get_disposal_account_and_cost_center(company)
+ si.append(
+ "items",
+ {
+ "item_code": item_code,
+ "is_fixed_asset": 1,
+ "asset": asset,
+ "income_account": disposal_account,
+ "serial_no": serial_no,
+ "cost_center": depreciation_cost_center,
+ "qty": sell_qty,
+ },
+ )
+
+ accounting_dimensions = get_dimensions(with_cost_center_and_project=True)
+ for dimension in accounting_dimensions[0]:
+ si.update(
+ {
+ dimension["fieldname"]: asset_doc.get(dimension["fieldname"])
+ or dimension.get("default_dimension")
+ }
+ )
+
+ si.set_missing_values()
+ return si
+
+
+@frappe.whitelist()
+def create_asset_maintenance(
+ asset: str,
+ item_code: str,
+ item_name: str,
+ asset_category: str,
+ company: str,
+):
+ asset_maintenance = frappe.new_doc("Asset Maintenance")
+ asset_maintenance.update(
+ {
+ "asset_name": asset,
+ "company": company,
+ "item_code": item_code,
+ "item_name": item_name,
+ "asset_category": asset_category,
+ }
+ )
+ return asset_maintenance
+
+
+@frappe.whitelist()
+def create_asset_repair(
+ company: str,
+ asset: str,
+ asset_name: str,
+):
+ asset_repair = frappe.new_doc("Asset Repair")
+ asset_repair.update({"company": company, "asset": asset, "asset_name": asset_name})
+ return asset_repair
+
+
+@frappe.whitelist()
+def create_asset_capitalization(
+ company: str,
+ asset: str,
+ asset_name: str,
+ item_code: str,
+):
+ asset_capitalization = frappe.new_doc("Asset Capitalization")
+ asset_capitalization.update(
+ {
+ "target_asset": asset,
+ "company": company,
+ "target_asset_name": asset_name,
+ "target_item_code": item_code,
+ }
+ )
+ return asset_capitalization
+
+
+@frappe.whitelist()
+def create_asset_value_adjustment(
+ asset: str,
+ asset_category: str,
+ company: str,
+):
+ asset_value_adjustment = frappe.new_doc("Asset Value Adjustment")
+ asset_value_adjustment.update({"asset": asset, "company": company, "asset_category": asset_category})
+ return asset_value_adjustment
+
+
+@frappe.whitelist()
+def make_journal_entry(asset_name: str):
+ asset = frappe.get_doc("Asset", asset_name)
+ (
+ fixed_asset_account,
+ accumulated_depreciation_account,
+ depreciation_expense_account,
+ ) = get_depreciation_accounts(asset.asset_category, asset.company)
+
+ depreciation_cost_center, depreciation_series = frappe.get_cached_value(
+ "Company", asset.company, ["depreciation_cost_center", "series_for_depreciation_entry"]
+ )
+ depreciation_cost_center = asset.cost_center or depreciation_cost_center
+
+ je = frappe.new_doc("Journal Entry")
+ je.voucher_type = "Depreciation Entry"
+ je.naming_series = depreciation_series
+ je.company = asset.company
+ je.remark = _("Depreciation Entry against asset {0}").format(asset_name)
+
+ je.append(
+ "accounts",
+ {
+ "account": depreciation_expense_account,
+ "reference_type": "Asset",
+ "reference_name": asset.name,
+ "cost_center": depreciation_cost_center,
+ },
+ )
+
+ je.append(
+ "accounts",
+ {
+ "account": accumulated_depreciation_account,
+ "reference_type": "Asset",
+ "reference_name": asset.name,
+ },
+ )
+
+ return je
+
+
+@frappe.whitelist()
+def make_asset_movement(
+ assets: list[dict] | str,
+ purpose: str = "Transfer",
+):
+ if isinstance(assets, str):
+ assets = json.loads(assets)
+
+ if len(assets) == 0:
+ frappe.throw(_("At least one asset has to be selected."))
+
+ asset_movement = frappe.new_doc("Asset Movement")
+ asset_movement.purpose = purpose
+ for asset in assets:
+ asset = frappe.get_doc("Asset", asset.get("name"))
+ asset_movement.company = asset.get("company")
+ asset_movement.append(
+ "assets",
+ {
+ "asset": asset.get("name"),
+ "source_location": asset.get("location"),
+ "from_employee": asset.get("custodian"),
+ },
+ )
+
+ if asset_movement.get("assets"):
+ return asset_movement.as_dict()
+
+
+@frappe.whitelist()
+def split_asset(asset_name: str, split_qty: int):
+ """Split an asset into two based on the given quantity."""
+ existing_asset = frappe.get_doc("Asset", asset_name)
+ split_qty = cint(split_qty)
+
+ validate_split_quantity(existing_asset, split_qty)
+ remaining_qty = existing_asset.asset_quantity - split_qty
+
+ splitted_asset = create_new_asset_from_split(existing_asset, split_qty)
+ update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset)
+
+ return splitted_asset
+
+
+def validate_split_quantity(existing_asset, split_qty):
+ if split_qty >= existing_asset.asset_quantity:
+ frappe.throw(_("Split Quantity must be less than Asset Quantity"))
+
+
+def create_new_asset_from_split(existing_asset, split_qty):
+ """Create a new asset from the split quantity."""
+ return process_asset_split(existing_asset, split_qty, is_new_asset=True)
+
+
+def update_existing_asset_after_split(existing_asset, remaining_qty, splitted_asset):
+ """Update the existing asset with the remaining quantity."""
+ process_asset_split(existing_asset, remaining_qty, splitted_asset=splitted_asset)
+
+
+def process_asset_split(existing_asset, split_qty, splitted_asset=None, is_new_asset=False):
+ """Handle asset creation or update during the split."""
+ scaling_factor = flt(split_qty) / flt(existing_asset.asset_quantity)
+ new_asset = frappe.copy_doc(existing_asset) if is_new_asset else splitted_asset
+ asset_doc = new_asset if is_new_asset else existing_asset
+ asset_doc.flags.is_split_asset = True
+
+ set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset)
+ log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset)
+
+ update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset)
+ return new_asset
+
+
+def set_split_asset_values(asset_doc, scaling_factor, split_qty, existing_asset, is_new_asset):
+ asset_doc.net_purchase_amount = existing_asset.net_purchase_amount * scaling_factor
+ asset_doc.purchase_amount = existing_asset.net_purchase_amount * scaling_factor
+ asset_doc.additional_asset_cost = existing_asset.additional_asset_cost * scaling_factor
+ asset_doc.total_asset_cost = asset_doc.net_purchase_amount + asset_doc.additional_asset_cost
+ asset_doc.opening_accumulated_depreciation = (
+ existing_asset.opening_accumulated_depreciation * scaling_factor
+ )
+ asset_doc.value_after_depreciation = existing_asset.value_after_depreciation * scaling_factor
+ asset_doc.asset_quantity = split_qty
+ asset_doc.split_from = existing_asset.name if is_new_asset else None
+
+ for row in asset_doc.get("finance_books"):
+ row.value_after_depreciation = row.value_after_depreciation * scaling_factor
+ row.expected_value_after_useful_life = row.expected_value_after_useful_life * scaling_factor
+
+ if not is_new_asset:
+ asset_doc.flags.ignore_validate_update_after_submit = True
+ asset_doc.save()
+
+
+def log_asset_activity(existing_asset, asset_doc, splitted_asset, is_new_asset):
+ if is_new_asset:
+ asset_doc.insert()
+ add_asset_activity(
+ asset_doc.name,
+ _("Asset created after being split from Asset {0}").format(
+ get_link_to_form("Asset", existing_asset.name)
+ ),
+ )
+ asset_doc.submit()
+ asset_doc.set_status()
+ else:
+ add_asset_activity(
+ existing_asset.name,
+ _("Asset updated after being split into Asset {0}").format(
+ get_link_to_form("Asset", splitted_asset.name)
+ ),
+ )
+
+
+def update_finance_books(asset_doc, existing_asset, new_asset, scaling_factor, is_new_asset):
+ """Update finance books and depreciation schedules for the asset."""
+ for fb_row in asset_doc.get("finance_books"):
+ reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset)
+
+ if is_new_asset:
+ for row in new_asset.get("finance_books"):
+ depr_schedule_doc = get_depr_schedule(new_asset.name, "Active", row.finance_book)
+ for schedule in depr_schedule_doc:
+ if schedule.journal_entry:
+ add_reference_in_jv_on_split(
+ schedule.journal_entry,
+ new_asset.name,
+ existing_asset.name,
+ schedule.depreciation_amount,
+ )
+
+
+def reschedule_depr_for_updated_asset(existing_asset, new_asset, fb_row, scaling_factor, is_new_asset):
+ """Reschedule depreciation for an asset after a split."""
+ current_depr_schedule_doc = get_asset_depr_schedule_doc(
+ existing_asset.name, "Active", fb_row.finance_book
+ )
+ if not current_depr_schedule_doc:
+ return
+
+ new_depr_schedule_doc = create_new_depr_schedule(
+ current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row
+ )
+
+ update_depreciation_terms(new_depr_schedule_doc, scaling_factor)
+ add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset)
+
+ if not is_new_asset:
+ current_depr_schedule_doc.flags.should_not_cancel_depreciation_entries = True
+ current_depr_schedule_doc.cancel()
+
+ new_depr_schedule_doc.submit()
+
+
+def create_new_depr_schedule(current_depr_schedule_doc, existing_asset, new_asset, is_new_asset, fb_row):
+ """Create a new depreciation schedule based on the current one."""
+ new_depr_schedule_doc = frappe.copy_doc(current_depr_schedule_doc)
+ new_depr_schedule_doc.asset_doc = new_asset if is_new_asset else existing_asset
+ new_depr_schedule_doc.fb_row = fb_row
+ new_depr_schedule_doc.fetch_asset_details()
+ return new_depr_schedule_doc
+
+
+def update_depreciation_terms(new_depr_schedule_doc, scaling_factor):
+ """Update depreciation terms with scaled amounts."""
+ accumulated_depreciation = 0
+ for term in new_depr_schedule_doc.get("depreciation_schedule"):
+ depreciation_amount = flt(
+ term.depreciation_amount * scaling_factor, term.precision("depreciation_amount")
+ )
+ term.depreciation_amount = depreciation_amount
+ accumulated_depreciation = flt(
+ accumulated_depreciation + depreciation_amount, term.precision("depreciation_amount")
+ )
+ term.accumulated_depreciation_amount = accumulated_depreciation
+
+
+def add_depr_schedule_notes(new_depr_schedule_doc, existing_asset, new_asset, is_new_asset):
+ notes = _("This schedule was created when Asset {0} was {1} into new Asset {2}.").format(
+ get_link_to_form(existing_asset.doctype, existing_asset.name),
+ "split" if is_new_asset else "updated after being split",
+ get_link_to_form(new_asset.doctype, new_asset.name),
+ )
+ new_depr_schedule_doc.notes = notes
+
+
+def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount):
+ """Add a reference to a new asset in a journal entry after a split."""
+ journal_entry = frappe.get_doc("Journal Entry", entry_name)
+ entries_to_add = []
+
+ adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add)
+ add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount)
+
+ journal_entry.flags.ignore_validate_update_after_submit = True
+ journal_entry.save()
+
+ journal_entry.docstatus = 2
+ journal_entry.make_gl_entries(1)
+ journal_entry.docstatus = 1
+ journal_entry.make_gl_entries()
+
+
+def adjust_existing_accounts(journal_entry, old_asset_name, depreciation_amount, entries_to_add):
+ """Adjust existing accounts and prepare new entries for the new asset."""
+ for account in journal_entry.get("accounts"):
+ if account.reference_name == old_asset_name:
+ entries_to_add.append(frappe.copy_doc(account).as_dict())
+ adjust_account_balance(account, depreciation_amount)
+
+
+def adjust_account_balance(account, depreciation_amount):
+ """Adjust the balance of an account based on the depreciation amount."""
+ if account.credit:
+ account.credit -= depreciation_amount
+ account.credit_in_account_currency -= account.exchange_rate * depreciation_amount
+ elif account.debit:
+ account.debit -= depreciation_amount
+ account.debit_in_account_currency -= account.exchange_rate * depreciation_amount
+
+
+def add_new_entries(journal_entry, entries_to_add, new_asset_name, depreciation_amount):
+ """Add new entries for the new asset to the journal entry."""
+ idx = len(journal_entry.get("accounts")) + 1
+ for entry in entries_to_add:
+ entry.reference_name = new_asset_name
+ if entry.credit:
+ entry.credit = depreciation_amount
+ entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount
+ elif entry.debit:
+ entry.debit = depreciation_amount
+ entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount
+ entry.idx = idx
+ idx += 1
+ journal_entry.append("accounts", entry)
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index 853d9c1eaa0..da14169787b 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -18,8 +18,6 @@ from frappe.utils.data import add_to_date
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.assets.doctype.asset.asset import (
- make_sales_invoice,
- split_asset,
update_maintenance_status,
)
from erpnext.assets.doctype.asset.depreciation import (
@@ -27,11 +25,15 @@ from erpnext.assets.doctype.asset.depreciation import (
restore_asset,
scrap_asset,
)
+from erpnext.assets.doctype.asset.mapper import (
+ make_sales_invoice,
+ split_asset,
+)
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
get_asset_depr_schedule_doc,
get_depr_schedule,
)
-from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_invoice,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
index e4724dadeee..ada205080cb 100644
--- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
+++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py
@@ -12,8 +12,6 @@ from frappe.utils import cint, flt, get_link_to_form
import erpnext
from erpnext.assets.doctype.asset.asset import get_asset_value_after_depreciation
from erpnext.assets.doctype.asset.depreciation import (
- depreciate_asset,
- get_gl_entries_on_asset_disposal,
get_value_after_depreciation_on_disposal_date,
reset_depreciation_schedule,
reverse_depreciation_entry_made_on_disposal,
@@ -396,30 +394,11 @@ class AssetCapitalization(StockController):
def get_gl_entries(
self, inventory_account_map=None, default_expense_account=None, default_cost_center=None
):
- # Stock GL Entries
- gl_entries = []
-
- self.inventory_account_map = inventory_account_map
- if not self.inventory_account_map:
- self.inventory_account_map = self.get_inventory_account_map()
-
- precision = self.get_debit_field_precision()
- self.sle_map = self.get_stock_ledger_details()
-
- target_account = self.get_target_account()
- target_against = set()
-
- self.get_gl_entries_for_consumed_stock_items(gl_entries, target_account, target_against, precision)
- self.get_gl_entries_for_consumed_asset_items(gl_entries, target_account, target_against, precision)
- self.get_gl_entries_for_consumed_service_items(gl_entries, target_account, target_against, precision)
-
- composite_component_value = self.get_composite_component_value()
-
- self.get_gl_entries_for_target_item(
- gl_entries, target_account, target_against, precision, composite_component_value
+ from erpnext.assets.doctype.asset_capitalization.services.gl_composer import (
+ AssetCapitalizationGLComposer,
)
- return gl_entries
+ return AssetCapitalizationGLComposer(self).compose(inventory_account_map)
def get_target_account(self):
from erpnext.assets.doctype.asset.asset import is_cwip_accounting_enabled
@@ -435,91 +414,6 @@ class AssetCapitalization(StockController):
else:
return self.target_fixed_asset_account
- def get_gl_entries_for_consumed_stock_items(self, gl_entries, target_account, target_against, precision):
- # Consumed Stock Items
- for item_row in self.stock_items:
- sle_list = self.sle_map.get(item_row.name)
- if sle_list:
- _inv_dict = self.get_inventory_account_dict(item_row, self.inventory_account_map)
- for sle in sle_list:
- stock_value_difference = flt(sle.stock_value_difference, precision)
-
- if erpnext.is_perpetual_inventory_enabled(self.company):
- account = _inv_dict["account"]
- else:
- account = self.get_company_default("default_expense_account")
-
- target_against.add(account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": account,
- "against": target_account,
- "cost_center": item_row.cost_center,
- "project": item_row.get("project") or self.get("project"),
- "remarks": self.get("remarks") or "Accounting Entry for Stock",
- "credit": -1 * stock_value_difference,
- },
- _inv_dict["account_currency"],
- item=item_row,
- )
- )
-
- def get_gl_entries_for_consumed_asset_items(self, gl_entries, target_account, target_against, precision):
- # Consumed Assets
- for item in self.asset_items:
- asset = frappe.get_doc("Asset", item.asset)
-
- if asset.asset_type != "Composite Component":
- if asset.calculate_depreciation:
- notes = _(
- "This schedule was created when Asset {0} was consumed through Asset Capitalization {1}."
- ).format(
- get_link_to_form(asset.doctype, asset.name),
- get_link_to_form(self.doctype, self.get("name")),
- )
- depreciate_asset(asset, self.posting_date, notes)
- asset.reload()
-
- fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
- asset,
- item.asset_value,
- item.get("finance_book") or self.get("finance_book"),
- self.get("doctype"),
- self.get("name"),
- self.get("posting_date"),
- )
-
- for gle in fixed_asset_gl_entries:
- gle["against"] = target_account
- gl_entries.append(self.get_gl_dict(gle, item=item))
- target_against.add(gle["account"])
-
- asset.db_set("disposal_date", self.posting_date)
- self.set_consumed_asset_status(asset)
-
- def get_gl_entries_for_consumed_service_items(
- self, gl_entries, target_account, target_against, precision
- ):
- # Service Expenses
- for item_row in self.service_items:
- expense_amount = flt(item_row.amount, precision)
- target_against.add(item_row.expense_account)
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item_row.expense_account,
- "against": target_account,
- "cost_center": item_row.cost_center,
- "project": item_row.get("project") or self.get("project"),
- "remarks": self.get("remarks") or "Accounting Entry for Stock",
- "credit": expense_amount,
- },
- item=item_row,
- )
- )
-
def get_composite_component_value(self):
composite_component_value = 0
for item in self.asset_items:
@@ -528,25 +422,6 @@ class AssetCapitalization(StockController):
composite_component_value += flt(item.asset_value, item.precision("asset_value"))
return composite_component_value
- def get_gl_entries_for_target_item(
- self, gl_entries, target_account, target_against, precision, composite_component_value
- ):
- total_value = flt(self.total_value - composite_component_value, precision)
- if total_value:
- # Capitalization
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": target_account,
- "against": ", ".join(target_against),
- "remarks": self.get("remarks") or _("Accounting Entry for Asset"),
- "debit": total_value,
- "cost_center": self.get("cost_center"),
- },
- item=self,
- )
- )
-
def update_target_asset(self):
total_target_asset_value = flt(self.total_value, self.precision("total_value"))
asset_doc = frappe.get_doc("Asset", self.target_asset)
diff --git a/erpnext/assets/doctype/asset_capitalization/services/__init__.py b/erpnext/assets/doctype/asset_capitalization/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py
new file mode 100644
index 00000000000..2b13bddd5ad
--- /dev/null
+++ b/erpnext/assets/doctype/asset_capitalization/services/gl_composer.py
@@ -0,0 +1,160 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+import erpnext
+from erpnext.assets.doctype.asset.depreciation import (
+ depreciate_asset,
+ get_gl_entries_on_asset_disposal,
+)
+from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
+
+
+class AssetCapitalizationGLComposer(BaseStockGLComposer):
+ """GL composer for Asset Capitalization.
+
+ Builds GL entries for consumed stock items, consumed asset items (with
+ depreciation side-effects), consumed service items, and the target asset debit.
+ """
+
+ def compose(
+ self,
+ inventory_account_map: dict | None = None,
+ default_expense_account: str | None = None,
+ default_cost_center: str | None = None,
+ ) -> list:
+ doc = self.doc
+ gl_entries = []
+
+ self.inventory_account_map = inventory_account_map or doc.get_inventory_account_map()
+ self.precision = doc.get_debit_field_precision()
+ self.sle_map = doc.get_stock_ledger_details()
+
+ target_account = doc.get_target_account()
+ target_against: set = set()
+
+ self._get_gl_entries_for_consumed_stock_items(gl_entries, target_account, target_against)
+ self._get_gl_entries_for_consumed_asset_items(gl_entries, target_account, target_against)
+ self._get_gl_entries_for_consumed_service_items(gl_entries, target_account, target_against)
+
+ composite_component_value = doc.get_composite_component_value()
+ self._get_gl_entries_for_target_item(
+ gl_entries, target_account, target_against, composite_component_value
+ )
+
+ return gl_entries
+
+ def _get_gl_entries_for_consumed_stock_items(
+ self, gl_entries: list, target_account: str, target_against: set
+ ) -> None:
+ doc = self.doc
+ for item_row in doc.stock_items:
+ sle_list = self.sle_map.get(item_row.name)
+ if sle_list:
+ _inv_dict = doc.get_inventory_account_dict(item_row, self.inventory_account_map)
+ for sle in sle_list:
+ stock_value_difference = flt(sle.stock_value_difference, self.precision)
+
+ if erpnext.is_perpetual_inventory_enabled(doc.company):
+ account = _inv_dict["account"]
+ else:
+ account = doc.get_company_default("default_expense_account")
+
+ target_against.add(account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": account,
+ "against": target_account,
+ "cost_center": item_row.cost_center,
+ "project": item_row.get("project") or doc.get("project"),
+ "remarks": doc.get("remarks") or "Accounting Entry for Stock",
+ "credit": -1 * stock_value_difference,
+ },
+ _inv_dict["account_currency"],
+ item=item_row,
+ )
+ )
+
+ def _get_gl_entries_for_consumed_asset_items(
+ self, gl_entries: list, target_account: str, target_against: set
+ ) -> None:
+ doc = self.doc
+ for item in doc.asset_items:
+ asset = frappe.get_doc("Asset", item.asset)
+
+ if asset.asset_type != "Composite Component":
+ if asset.calculate_depreciation:
+ notes = _(
+ "This schedule was created when Asset {0} was consumed through Asset Capitalization {1}."
+ ).format(
+ frappe.utils.get_link_to_form(asset.doctype, asset.name),
+ frappe.utils.get_link_to_form(doc.doctype, doc.get("name")),
+ )
+ depreciate_asset(asset, doc.posting_date, notes)
+ asset.reload()
+
+ fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
+ asset,
+ item.asset_value,
+ item.get("finance_book") or doc.get("finance_book"),
+ doc.get("doctype"),
+ doc.get("name"),
+ doc.get("posting_date"),
+ )
+
+ for gle in fixed_asset_gl_entries:
+ gle["against"] = target_account
+ gl_entries.append(self.get_gl_dict(gle, item=item))
+ target_against.add(gle["account"])
+
+ asset.db_set("disposal_date", doc.posting_date)
+ doc.set_consumed_asset_status(asset)
+
+ def _get_gl_entries_for_consumed_service_items(
+ self, gl_entries: list, target_account: str, target_against: set
+ ) -> None:
+ doc = self.doc
+ for item_row in doc.service_items:
+ expense_amount = flt(item_row.amount, self.precision)
+ target_against.add(item_row.expense_account)
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item_row.expense_account,
+ "against": target_account,
+ "cost_center": item_row.cost_center,
+ "project": item_row.get("project") or doc.get("project"),
+ "remarks": doc.get("remarks") or "Accounting Entry for Stock",
+ "credit": expense_amount,
+ },
+ item=item_row,
+ )
+ )
+
+ def _get_gl_entries_for_target_item(
+ self,
+ gl_entries: list,
+ target_account: str,
+ target_against: set,
+ composite_component_value: float,
+ ) -> None:
+ doc = self.doc
+ total_value = flt(doc.total_value - composite_component_value, self.precision)
+ if total_value:
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": target_account,
+ "against": ", ".join(target_against),
+ "remarks": doc.get("remarks") or _("Accounting Entry for Asset"),
+ "debit": total_value,
+ "cost_center": doc.get("cost_center"),
+ },
+ item=doc,
+ )
+ )
diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py
index 202f16da684..69e9014c90b 100644
--- a/erpnext/assets/doctype/asset_repair/asset_repair.py
+++ b/erpnext/assets/doctype/asset_repair/asset_repair.py
@@ -12,7 +12,6 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.general_ledger import make_gl_entries
-from erpnext.assets.doctype.asset.asset import get_asset_account
from erpnext.assets.doctype.asset_activity.asset_activity import add_asset_activity
from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import (
reschedule_depreciation,
@@ -216,9 +215,10 @@ class AssetRepair(AccountsController):
doc = frappe.get_doc("Serial and Batch Bundle", sabb)
doc.cancel()
- def on_cancel(self):
- self.asset_doc = frappe.get_doc("Asset", self.asset)
+ def on_cancel(self): # nosemgrep
if self.get("capitalize_repair_cost"):
+ self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
+ self.asset_doc = frappe.get_lazy_doc("Asset", self.asset)
self.update_asset_value()
self.make_gl_entries(cancel=True)
self.set_increase_in_asset_life()
@@ -307,121 +307,14 @@ class AssetRepair(AccountsController):
)
def make_gl_entries(self, cancel=False):
- if cancel:
- self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
-
if flt(self.total_repair_cost) > 0:
gl_entries = self.get_gl_entries()
make_gl_entries(gl_entries, cancel)
def get_gl_entries(self):
- gl_entries = []
+ from erpnext.assets.doctype.asset_repair.services.gl_composer import AssetRepairGLComposer
- fixed_asset_account = get_asset_account("fixed_asset_account", asset=self.asset, company=self.company)
- self.get_gl_entries_for_repair_cost(gl_entries, fixed_asset_account)
- self.get_gl_entries_for_consumed_items(gl_entries, fixed_asset_account)
-
- return gl_entries
-
- def get_gl_entries_for_repair_cost(self, gl_entries, fixed_asset_account):
- if flt(self.repair_cost) <= 0:
- return
-
- debit_against_account = set()
-
- for pi in self.invoices:
- debit_against_account.add(pi.expense_account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": pi.expense_account,
- "credit": pi.repair_cost,
- "credit_in_account_currency": pi.repair_cost,
- "against": fixed_asset_account,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "cost_center": self.cost_center,
- "posting_date": self.completion_date,
- "company": self.company,
- },
- item=self,
- )
- )
- debit_against_account = ", ".join(debit_against_account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": fixed_asset_account,
- "debit": self.repair_cost,
- "debit_in_account_currency": self.repair_cost,
- "against": debit_against_account,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "cost_center": self.cost_center,
- "posting_date": self.completion_date,
- "against_voucher_type": "Asset",
- "against_voucher": self.asset,
- "company": self.company,
- },
- item=self,
- )
- )
-
- def get_gl_entries_for_consumed_items(self, gl_entries, fixed_asset_account):
- if not self.get("stock_items"):
- return
-
- # creating GL Entries for each row in Stock Items based on the Stock Entry created for it
- stock_entry_name = frappe.db.get_value("Stock Entry", {"asset_repair": self.name}, "name")
- stock_entry_items = frappe.get_all(
- "Stock Entry Detail", filters={"parent": stock_entry_name}, fields=["expense_account", "amount"]
- )
-
- default_expense_account = None
- if not erpnext.is_perpetual_inventory_enabled(self.company):
- default_expense_account = frappe.get_cached_value(
- "Company", self.company, "default_expense_account"
- )
- if not default_expense_account:
- frappe.throw(_("Please set default Expense Account in Company {0}").format(self.company))
-
- for item in stock_entry_items:
- if flt(item.amount) > 0:
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item.expense_account or default_expense_account,
- "credit": item.amount,
- "credit_in_account_currency": item.amount,
- "against": fixed_asset_account,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "cost_center": self.cost_center,
- "posting_date": self.completion_date,
- "company": self.company,
- },
- item=self,
- )
- )
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": fixed_asset_account,
- "debit": item.amount,
- "debit_in_account_currency": item.amount,
- "against": item.expense_account or default_expense_account,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "cost_center": self.cost_center,
- "posting_date": self.completion_date,
- "against_voucher_type": "Stock Entry",
- "against_voucher": stock_entry_name,
- "company": self.company,
- },
- item=self,
- )
- )
+ return AssetRepairGLComposer(self).compose()
def set_increase_in_asset_life(self):
if self.asset_doc.calculate_depreciation and cint(self.increase_in_asset_life) > 0:
diff --git a/erpnext/assets/doctype/asset_repair/services/__init__.py b/erpnext/assets/doctype/asset_repair/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/assets/doctype/asset_repair/services/gl_composer.py b/erpnext/assets/doctype/asset_repair/services/gl_composer.py
new file mode 100644
index 00000000000..53ec0b3e61a
--- /dev/null
+++ b/erpnext/assets/doctype/asset_repair/services/gl_composer.py
@@ -0,0 +1,130 @@
+# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+import erpnext
+from erpnext.accounts.services.base_gl_composer import BaseGLComposer
+from erpnext.assets.doctype.asset.asset import get_asset_account
+
+
+class AssetRepairGLComposer(BaseGLComposer):
+ """GL composer for Asset Repair.
+
+ Builds GL entries for repair cost (per invoice) and consumed stock items
+ (sourced from the related Stock Entry).
+ """
+
+ def compose(self) -> list:
+ doc = self.doc
+ gl_entries = []
+
+ fixed_asset_account = get_asset_account("fixed_asset_account", asset=doc.asset, company=doc.company)
+ self._get_gl_entries_for_repair_cost(gl_entries, fixed_asset_account)
+ self._get_gl_entries_for_consumed_items(gl_entries, fixed_asset_account)
+
+ return gl_entries
+
+ def _get_gl_entries_for_repair_cost(self, gl_entries: list, fixed_asset_account: str) -> None:
+ doc = self.doc
+ if flt(doc.repair_cost) <= 0:
+ return
+
+ debit_against_account = set()
+
+ for pi in doc.invoices:
+ debit_against_account.add(pi.expense_account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": pi.expense_account,
+ "credit": pi.repair_cost,
+ "credit_in_account_currency": pi.repair_cost,
+ "against": fixed_asset_account,
+ "voucher_type": doc.doctype,
+ "voucher_no": doc.name,
+ "cost_center": doc.cost_center,
+ "posting_date": doc.completion_date,
+ "company": doc.company,
+ },
+ item=doc,
+ )
+ )
+
+ debit_against_account_str = ", ".join(debit_against_account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": fixed_asset_account,
+ "debit": doc.repair_cost,
+ "debit_in_account_currency": doc.repair_cost,
+ "against": debit_against_account_str,
+ "voucher_type": doc.doctype,
+ "voucher_no": doc.name,
+ "cost_center": doc.cost_center,
+ "posting_date": doc.completion_date,
+ "against_voucher_type": "Asset",
+ "against_voucher": doc.asset,
+ "company": doc.company,
+ },
+ item=doc,
+ )
+ )
+
+ def _get_gl_entries_for_consumed_items(self, gl_entries: list, fixed_asset_account: str) -> None:
+ doc = self.doc
+ if not doc.get("stock_items"):
+ return
+
+ stock_entry_name = frappe.db.get_value("Stock Entry", {"asset_repair": doc.name}, "name")
+ stock_entry_items = frappe.get_all(
+ "Stock Entry Detail", filters={"parent": stock_entry_name}, fields=["expense_account", "amount"]
+ )
+
+ default_expense_account = None
+ if not erpnext.is_perpetual_inventory_enabled(doc.company):
+ default_expense_account = frappe.get_cached_value(
+ "Company", doc.company, "default_expense_account"
+ )
+ if not default_expense_account:
+ frappe.throw(_("Please set default Expense Account in Company {0}").format(doc.company))
+
+ for item in stock_entry_items:
+ if flt(item.amount) > 0:
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item.expense_account or default_expense_account,
+ "credit": item.amount,
+ "credit_in_account_currency": item.amount,
+ "against": fixed_asset_account,
+ "voucher_type": doc.doctype,
+ "voucher_no": doc.name,
+ "cost_center": doc.cost_center,
+ "posting_date": doc.completion_date,
+ "company": doc.company,
+ },
+ item=doc,
+ )
+ )
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": fixed_asset_account,
+ "debit": item.amount,
+ "debit_in_account_currency": item.amount,
+ "against": item.expense_account or default_expense_account,
+ "voucher_type": doc.doctype,
+ "voucher_no": doc.name,
+ "cost_center": doc.cost_center,
+ "posting_date": doc.completion_date,
+ "against_voucher_type": "Stock Entry",
+ "against_voucher": stock_entry_name,
+ "company": doc.company,
+ },
+ item=doc,
+ )
+ )
diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
index 717435e4caa..d48890dde7b 100644
--- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py
+++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py
@@ -9,6 +9,8 @@ from frappe.utils import add_days, add_months, flt, get_first_day, nowdate, nowt
from erpnext.assets.doctype.asset.asset import (
get_asset_account,
get_asset_value_after_depreciation,
+)
+from erpnext.assets.doctype.asset.mapper import (
make_sales_invoice,
)
from erpnext.assets.doctype.asset.test_asset import (
diff --git a/erpnext/buying/doctype/purchase_order/mapper.py b/erpnext/buying/doctype/purchase_order/mapper.py
new file mode 100644
index 00000000000..e379824f33f
--- /dev/null
+++ b/erpnext/buying/doctype/purchase_order/mapper.py
@@ -0,0 +1,330 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import flt, get_link_to_form
+
+from erpnext.accounts.party import get_party_account
+from erpnext.controllers.status_updater import get_allowance_for
+from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
+from erpnext.stock.doctype.item.item import get_item_defaults
+
+
+def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+ target.run_method("calculate_taxes_and_totals")
+ target.run_method("set_use_serial_batch_fields")
+
+
+@frappe.whitelist()
+def make_purchase_receipt(
+ source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
+):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
+
+ def is_unit_price_row(source):
+ return has_unit_price_items and source.qty == 0
+
+ def get_max_receivable_qty(source):
+ tolerance = flt(get_allowance_for(source.item_code, qty_or_amount="qty")[0])
+ return flt(source.qty) * (100 + tolerance) / 100
+
+ def update_item(obj, target, source_parent):
+ received_qty = flt(obj.received_qty)
+ qty = flt(obj.qty)
+ pending_qty = qty - received_qty
+
+ if is_unit_price_row(obj):
+ target.qty = qty
+ elif pending_qty > 0:
+ target.qty = pending_qty
+ else:
+ target.qty = max(get_max_receivable_qty(obj) - received_qty, 0)
+
+ target.stock_qty = target.qty * flt(obj.conversion_factor)
+ target.amount = target.qty * flt(obj.rate)
+ target.base_amount = target.qty * flt(obj.rate) * flt(source_parent.conversion_rate)
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ doc = get_mapped_doc(
+ "Purchase Order",
+ source_name,
+ {
+ "Purchase Order": {
+ "doctype": "Purchase Receipt",
+ "field_map": {"supplier_warehouse": "supplier_warehouse"},
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Purchase Order Item": {
+ "doctype": "Purchase Receipt Item",
+ "field_map": {
+ "name": "purchase_order_item",
+ "parent": "purchase_order",
+ "bom": "bom",
+ "material_request": "material_request",
+ "material_request_item": "material_request_item",
+ "sales_order": "sales_order",
+ "sales_order_item": "sales_order_item",
+ "wip_composite_asset": "wip_composite_asset",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: (
+ True
+ if is_unit_price_row(doc)
+ else abs(doc.received_qty) < abs(get_max_receivable_qty(doc))
+ )
+ and doc.delivered_by_supplier != 1
+ and select_item(doc),
+ },
+ "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doc
+
+
+@frappe.whitelist()
+def make_purchase_invoice(
+ source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
+):
+ return get_mapped_purchase_invoice(source_name, target_doc, args=args)
+
+
+@frappe.whitelist()
+def make_purchase_invoice_from_portal(purchase_order_name: str):
+ doc = get_mapped_purchase_invoice(purchase_order_name, ignore_permissions=True)
+ if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"):
+ frappe.throw(_("Not Permitted"), frappe.PermissionError)
+ doc.save()
+ if not frappe.in_test:
+ frappe.db.commit() # nosemgrep
+ frappe.response["type"] = "redirect"
+ frappe.response.location = "/purchase-invoices/" + doc.name
+
+
+def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions=False, args=None):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ def postprocess(source, target):
+ target.flags.ignore_permissions = ignore_permissions
+ set_missing_values(source, target)
+
+ # Get the advance paid Journal Entries in Purchase Invoice Advance
+ if target.get("allocate_advances_automatically"):
+ target.set_advances()
+
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ PaymentScheduleService(target).set_payment_schedule()
+ target.credit_to = get_party_account("Supplier", source.supplier, source.company)
+
+ def get_billed_qty(po_item_name):
+ from frappe.query_builder.functions import Sum
+
+ table = frappe.qb.DocType("Purchase Invoice Item")
+ query = (
+ frappe.qb.from_(table)
+ .select(Sum(table.qty).as_("qty"))
+ .where((table.docstatus == 1) & (table.po_detail == po_item_name))
+ )
+ return query.run(pluck="qty")[0] or 0
+
+ def update_item(obj, target, source_parent):
+ billed_qty = flt(get_billed_qty(obj.name))
+ target.qty = flt(obj.qty) - billed_qty
+
+ item = get_item_defaults(target.item_code, source_parent.company)
+ item_group = get_item_group_defaults(target.item_code, source_parent.company)
+ target.cost_center = (
+ obj.cost_center
+ or frappe.db.get_value("Project", obj.project, "cost_center")
+ or item.get("buying_cost_center")
+ or item_group.get("buying_cost_center")
+ )
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ fields = {
+ "Purchase Order": {
+ "doctype": "Purchase Invoice",
+ "field_map": {
+ "party_account_currency": "party_account_currency",
+ "supplier_warehouse": "supplier_warehouse",
+ },
+ "field_no_map": ["payment_terms_template"],
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Purchase Order Item": {
+ "doctype": "Purchase Invoice Item",
+ "field_map": {
+ "name": "po_detail",
+ "parent": "purchase_order",
+ "material_request": "material_request",
+ "material_request_item": "material_request_item",
+ "wip_composite_asset": "wip_composite_asset",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: (
+ doc.base_amount == 0
+ or abs(doc.billed_amt) < abs(doc.amount)
+ or doc.qty > flt(get_billed_qty(doc.name))
+ )
+ and select_item(doc),
+ },
+ "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
+ }
+
+ doc = get_mapped_doc(
+ "Purchase Order",
+ source_name,
+ fields,
+ target_doc,
+ postprocess,
+ ignore_permissions=ignore_permissions,
+ )
+
+ return doc
+
+
+@frappe.whitelist()
+def make_inter_company_sales_order(source_name: str, target_doc: str | Document | None = None):
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
+
+ return make_inter_company_transaction("Purchase Order", source_name, target_doc)
+
+
+@frappe.whitelist()
+def make_subcontracting_order(
+ source_name: str,
+ target_doc: str | Document | None = None,
+ save: bool = False,
+ submit: bool = False,
+ notify: bool = False,
+):
+ if not is_po_fully_subcontracted(source_name):
+ target_doc = get_mapped_subcontracting_order(source_name, target_doc)
+
+ if (save or submit) and frappe.has_permission(target_doc.doctype, "create"):
+ target_doc.save()
+
+ if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
+ try:
+ target_doc.submit()
+ except Exception as e:
+ target_doc.add_comment("Comment", _("Submit Action Failed") + "
" + str(e))
+
+ if notify:
+ frappe.msgprint(
+ _("Subcontracting Order {0} created.").format(
+ get_link_to_form(target_doc.doctype, target_doc.name)
+ ),
+ indicator="green",
+ alert=True,
+ )
+
+ return target_doc
+ else:
+ frappe.throw(_("This Purchase Order has been fully subcontracted."))
+
+
+def is_po_fully_subcontracted(po_name: str) -> bool:
+ table = frappe.qb.DocType("Purchase Order Item")
+ query = (
+ frappe.qb.from_(table)
+ .select(table.name)
+ .where((table.parent == po_name) & (table.qty != table.subcontracted_qty))
+ )
+ return not query.run(as_dict=True)
+
+
+def get_mapped_subcontracting_order(source_name: str, target_doc: str | Document | None = None) -> Document:
+ def post_process(source_doc, target_doc):
+ target_doc.populate_items_table()
+
+ if target_doc.set_warehouse:
+ for item in target_doc.items:
+ item.warehouse = target_doc.set_warehouse
+ else:
+ if source_doc.set_warehouse:
+ for item in target_doc.items:
+ item.warehouse = source_doc.set_warehouse
+ else:
+ for idx, item in enumerate(target_doc.items):
+ item.warehouse = source_doc.items[idx].warehouse
+
+ for idx, item in enumerate(target_doc.items):
+ item.job_card = source_doc.items[idx].job_card
+ if not target_doc.supplier_warehouse:
+ # WIP warehouse is set as Supplier Warehouse in Job Card
+ target_doc.supplier_warehouse = frappe.get_cached_value(
+ "Job Card", item.job_card, "wip_warehouse"
+ )
+
+ production_plan = set([item.production_plan for item in source_doc.items if item.production_plan])
+ if production_plan:
+ target_doc.production_plan = production_plan.pop()
+ target_doc.reserve_stock = frappe.get_single_value(
+ "Stock Settings", "auto_reserve_stock"
+ ) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock")
+
+ if target_doc and isinstance(target_doc, str):
+ target_doc = json.loads(target_doc)
+ for key in ["service_items", "items", "supplied_items"]:
+ if key in target_doc:
+ del target_doc[key]
+ target_doc = json.dumps(target_doc)
+
+ target_doc = get_mapped_doc(
+ "Purchase Order",
+ source_name,
+ {
+ "Purchase Order": {
+ "doctype": "Subcontracting Order",
+ "field_map": {},
+ "field_no_map": ["total_qty", "total", "net_total"],
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Purchase Order Item": {
+ "doctype": "Subcontracting Order Service Item",
+ "field_map": {
+ "name": "purchase_order_item",
+ "material_request": "material_request",
+ "material_request_item": "material_request_item",
+ },
+ "field_no_map": ["qty", "fg_item_qty", "amount"],
+ "condition": lambda item: item.qty != item.subcontracted_qty,
+ },
+ },
+ target_doc,
+ post_process,
+ )
+
+ return target_doc
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js
index 39b2fa8e037..bc175c8a736 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.js
@@ -459,14 +459,14 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
make_inter_company_order(frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_inter_company_sales_order",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_inter_company_sales_order",
frm: frm,
});
}
make_purchase_receipt() {
frappe.model.open_mapped_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_receipt",
frm: this.frm,
freeze_message: __("Creating Purchase Receipt ..."),
});
@@ -474,14 +474,14 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
make_purchase_invoice() {
frappe.model.open_mapped_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_invoice",
frm: this.frm,
});
}
make_subcontracting_order() {
frappe.model.open_mapped_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_subcontracting_order",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_subcontracting_order",
frm: this.frm,
freeze_message: __("Creating Subcontracting Order ..."),
});
@@ -493,7 +493,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
__("Material Request"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order",
+ method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order",
source_doctype: "Material Request",
target: me.frm,
setters: {
@@ -518,7 +518,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
__("Supplier Quotation"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order",
+ method: "erpnext.buying.doctype.supplier_quotation.mapper.make_purchase_order",
source_doctype: "Supplier Quotation",
target: me.frm,
setters: {
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 4adfb60c35c..7c7604303b4 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -8,28 +8,30 @@ import frappe
from frappe import _
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
-from frappe.utils import cint, cstr, flt, get_link_to_form
+from frappe.utils import cint, cstr, flt
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
unlink_inter_company_doc,
update_linked_doc,
validate_inter_company_party,
)
-from erpnext.accounts.party import get_party_account, get_party_account_currency
+from erpnext.accounts.party import get_party_account_currency
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
from erpnext.controllers.status_updater import get_allowance_for
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
)
-from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
-from erpnext.stock.doctype.item.item import get_item_defaults, get_last_purchase_details
+from erpnext.stock.doctype.item.item import get_last_purchase_details
from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty
from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
get_subcontracting_boms_for_finished_goods,
)
+from .mapper import (
+ make_subcontracting_order,
+)
+
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -742,201 +744,6 @@ def close_or_unclose_purchase_orders(names: str, status: str):
frappe.local.message_log = []
-def set_missing_values(source, target):
- target.run_method("set_missing_values")
- target.run_method("calculate_taxes_and_totals")
- target.run_method("set_use_serial_batch_fields")
-
-
-@frappe.whitelist()
-def make_purchase_receipt(
- source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
-):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items")
-
- def is_unit_price_row(source):
- return has_unit_price_items and source.qty == 0
-
- def get_max_receivable_qty(source):
- tolerance = flt(get_allowance_for(source.item_code, qty_or_amount="qty")[0])
- return flt(source.qty) * (100 + tolerance) / 100
-
- def update_item(obj, target, source_parent):
- received_qty = flt(obj.received_qty)
- qty = flt(obj.qty)
- pending_qty = qty - received_qty
-
- if is_unit_price_row(obj):
- target.qty = qty
- elif pending_qty > 0:
- target.qty = pending_qty
- else:
- target.qty = max(get_max_receivable_qty(obj) - received_qty, 0)
-
- target.stock_qty = target.qty * flt(obj.conversion_factor)
- target.amount = target.qty * flt(obj.rate)
- target.base_amount = target.qty * flt(obj.rate) * flt(source_parent.conversion_rate)
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- doc = get_mapped_doc(
- "Purchase Order",
- source_name,
- {
- "Purchase Order": {
- "doctype": "Purchase Receipt",
- "field_map": {"supplier_warehouse": "supplier_warehouse"},
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Purchase Order Item": {
- "doctype": "Purchase Receipt Item",
- "field_map": {
- "name": "purchase_order_item",
- "parent": "purchase_order",
- "bom": "bom",
- "material_request": "material_request",
- "material_request_item": "material_request_item",
- "sales_order": "sales_order",
- "sales_order_item": "sales_order_item",
- "wip_composite_asset": "wip_composite_asset",
- },
- "postprocess": update_item,
- "condition": lambda doc: (
- True
- if is_unit_price_row(doc)
- else abs(doc.received_qty) < abs(get_max_receivable_qty(doc))
- )
- and doc.delivered_by_supplier != 1
- and select_item(doc),
- },
- "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
- },
- target_doc,
- set_missing_values,
- )
-
- return doc
-
-
-@frappe.whitelist()
-def make_purchase_invoice(
- source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
-):
- return get_mapped_purchase_invoice(source_name, target_doc, args=args)
-
-
-@frappe.whitelist()
-def make_purchase_invoice_from_portal(purchase_order_name: str):
- doc = get_mapped_purchase_invoice(purchase_order_name, ignore_permissions=True)
- if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"):
- frappe.throw(_("Not Permitted"), frappe.PermissionError)
- doc.save()
- if not frappe.in_test:
- frappe.db.commit()
- frappe.response["type"] = "redirect"
- frappe.response.location = "/purchase-invoices/" + doc.name
-
-
-def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions=False, args=None):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- def postprocess(source, target):
- target.flags.ignore_permissions = ignore_permissions
- set_missing_values(source, target)
-
- # Get the advance paid Journal Entries in Purchase Invoice Advance
- if target.get("allocate_advances_automatically"):
- target.set_advances()
-
- target.set_payment_schedule()
- target.credit_to = get_party_account("Supplier", source.supplier, source.company)
-
- def get_billed_qty(po_item_name):
- from frappe.query_builder.functions import Sum
-
- table = frappe.qb.DocType("Purchase Invoice Item")
- query = (
- frappe.qb.from_(table)
- .select(Sum(table.qty).as_("qty"))
- .where((table.docstatus == 1) & (table.po_detail == po_item_name))
- )
- return query.run(pluck="qty")[0] or 0
-
- def update_item(obj, target, source_parent):
- billed_qty = flt(get_billed_qty(obj.name))
- target.qty = flt(obj.qty) - billed_qty
-
- item = get_item_defaults(target.item_code, source_parent.company)
- item_group = get_item_group_defaults(target.item_code, source_parent.company)
- target.cost_center = (
- obj.cost_center
- or frappe.db.get_value("Project", obj.project, "cost_center")
- or item.get("buying_cost_center")
- or item_group.get("buying_cost_center")
- )
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- fields = {
- "Purchase Order": {
- "doctype": "Purchase Invoice",
- "field_map": {
- "party_account_currency": "party_account_currency",
- "supplier_warehouse": "supplier_warehouse",
- },
- "field_no_map": ["payment_terms_template"],
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Purchase Order Item": {
- "doctype": "Purchase Invoice Item",
- "field_map": {
- "name": "po_detail",
- "parent": "purchase_order",
- "material_request": "material_request",
- "material_request_item": "material_request_item",
- "wip_composite_asset": "wip_composite_asset",
- },
- "postprocess": update_item,
- "condition": lambda doc: (
- doc.base_amount == 0
- or abs(doc.billed_amt) < abs(doc.amount)
- or doc.qty > flt(get_billed_qty(doc.name))
- )
- and select_item(doc),
- },
- "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
- }
-
- doc = get_mapped_doc(
- "Purchase Order",
- source_name,
- fields,
- target_doc,
- postprocess,
- ignore_permissions=ignore_permissions,
- )
-
- return doc
-
-
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -958,121 +765,3 @@ def update_status(status: str, name: str):
po = frappe.get_lazy_doc("Purchase Order", name, check_permission="submit")
po.update_status(status)
po.update_delivered_qty_in_sales_order()
-
-
-@frappe.whitelist()
-def make_inter_company_sales_order(source_name: str, target_doc: str | Document | None = None):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
-
- return make_inter_company_transaction("Purchase Order", source_name, target_doc)
-
-
-@frappe.whitelist()
-def make_subcontracting_order(
- source_name: str,
- target_doc: str | Document | None = None,
- save: bool = False,
- submit: bool = False,
- notify: bool = False,
-):
- if not is_po_fully_subcontracted(source_name):
- target_doc = get_mapped_subcontracting_order(source_name, target_doc)
-
- if (save or submit) and frappe.has_permission(target_doc.doctype, "create"):
- target_doc.save()
-
- if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
- try:
- target_doc.submit()
- except Exception as e:
- target_doc.add_comment("Comment", _("Submit Action Failed") + "
" + str(e))
-
- if notify:
- frappe.msgprint(
- _("Subcontracting Order {0} created.").format(
- get_link_to_form(target_doc.doctype, target_doc.name)
- ),
- indicator="green",
- alert=True,
- )
-
- return target_doc
- else:
- frappe.throw(_("This Purchase Order has been fully subcontracted."))
-
-
-def is_po_fully_subcontracted(po_name):
- table = frappe.qb.DocType("Purchase Order Item")
- query = (
- frappe.qb.from_(table)
- .select(table.name)
- .where((table.parent == po_name) & (table.qty != table.subcontracted_qty))
- )
- return not query.run(as_dict=True)
-
-
-def get_mapped_subcontracting_order(source_name, target_doc=None):
- def post_process(source_doc, target_doc):
- target_doc.populate_items_table()
-
- if target_doc.set_warehouse:
- for item in target_doc.items:
- item.warehouse = target_doc.set_warehouse
- else:
- if source_doc.set_warehouse:
- for item in target_doc.items:
- item.warehouse = source_doc.set_warehouse
- else:
- for idx, item in enumerate(target_doc.items):
- item.warehouse = source_doc.items[idx].warehouse
-
- for idx, item in enumerate(target_doc.items):
- item.job_card = source_doc.items[idx].job_card
- if not target_doc.supplier_warehouse:
- # WIP warehouse is set as Supplier Warehouse in Job Card
- target_doc.supplier_warehouse = frappe.get_cached_value(
- "Job Card", item.job_card, "wip_warehouse"
- )
-
- production_plan = set([item.production_plan for item in source_doc.items if item.production_plan])
- if production_plan:
- target_doc.production_plan = production_plan.pop()
- target_doc.reserve_stock = frappe.get_single_value(
- "Stock Settings", "auto_reserve_stock"
- ) or frappe.get_value("Production Plan", target_doc.production_plan, "reserve_stock")
-
- if target_doc and isinstance(target_doc, str):
- target_doc = json.loads(target_doc)
- for key in ["service_items", "items", "supplied_items"]:
- if key in target_doc:
- del target_doc[key]
- target_doc = json.dumps(target_doc)
-
- target_doc = get_mapped_doc(
- "Purchase Order",
- source_name,
- {
- "Purchase Order": {
- "doctype": "Subcontracting Order",
- "field_map": {},
- "field_no_map": ["total_qty", "total", "net_total"],
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Purchase Order Item": {
- "doctype": "Subcontracting Order Service Item",
- "field_map": {
- "name": "purchase_order_item",
- "material_request": "material_request",
- "material_request_item": "material_request_item",
- },
- "field_no_map": ["qty", "fg_item_qty", "amount"],
- "condition": lambda item: item.qty != item.subcontracted_qty,
- },
- },
- target_doc,
- post_process,
- )
-
- return target_doc
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index 0386c9022e2..fe406d6ad3a 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -11,19 +11,19 @@ from frappe.utils.data import today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.party import get_due_date_from_template
-from erpnext.buying.doctype.purchase_order.purchase_order import (
+from erpnext.buying.doctype.purchase_order.mapper import (
make_inter_company_sales_order,
make_purchase_receipt,
)
-from erpnext.buying.doctype.purchase_order.purchase_order import (
+from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_invoice as make_pi_from_po,
)
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order
from erpnext.stock.doctype.item.test_item import make_item
-from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+from erpnext.stock.doctype.material_request.mapper import make_purchase_order
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
-from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_pi_from_pr,
)
from erpnext.tests.utils import ERPNextTestSuite
@@ -105,7 +105,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
Regression test for #55246: the mapper dropped rows once
received_qty >= qty, ignoring the configured tolerance.
"""
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
# 50% tolerance — 10 ordered allows up to 15 received
frappe.db.set_value("Item", "_Test Item", "over_delivery_receipt_allowance", 50)
@@ -611,7 +611,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(po.get("items")[0].received_qty, 5)
def test_purchase_order_invoice_receipt_workflow(self):
- from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_purchase_receipt
+ from erpnext.accounts.doctype.purchase_invoice.mapper import make_purchase_receipt
po = create_purchase_order()
pi = make_pi_from_po(po.name)
@@ -1050,14 +1050,14 @@ class TestPurchaseOrder(ERPNextTestSuite):
def test_internal_transfer_flow(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
+ from erpnext.accounts.doctype.sales_invoice.mapper import (
make_inter_company_purchase_invoice,
)
- from erpnext.selling.doctype.sales_order.sales_order import (
+ from erpnext.selling.doctype.sales_order.mapper import (
make_delivery_note,
make_sales_invoice,
)
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
@@ -1198,7 +1198,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(po.items[0].fg_item_qty, 30)
def test_new_sc_flow(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order
+ from erpnext.buying.doctype.purchase_order.mapper import make_subcontracting_order
po = create_po_for_sc_testing()
sco = make_subcontracting_order(po.name)
@@ -1326,7 +1326,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(frappe.db.get_value(po.doctype, po.name, "advance_payment_status"), "Not Initiated")
def test_po_billed_amount_against_return_entry(self):
- from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note
+ from erpnext.accounts.doctype.purchase_invoice.mapper import make_debit_note
# Create a Purchase Order and Fully Bill it
po = create_purchase_order()
diff --git a/erpnext/buying/doctype/request_for_quotation/mapper.py b/erpnext/buying/doctype/request_for_quotation/mapper.py
new file mode 100644
index 00000000000..e414d943fb0
--- /dev/null
+++ b/erpnext/buying/doctype/request_for_quotation/mapper.py
@@ -0,0 +1,185 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+
+from erpnext.accounts.party import _get_party_details, get_party_account_currency
+from erpnext.stock.doctype.material_request.mapper import set_missing_values
+
+
+@frappe.whitelist()
+def make_supplier_quotation_from_rfq(
+ source_name: str, target_doc: str | Document | None = None, for_supplier: str | None = None
+):
+ def postprocess(source, target_doc):
+ if for_supplier:
+ target_doc.supplier = for_supplier
+ args = _get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True)
+ target_doc.currency = args.currency or get_party_account_currency(
+ "Supplier", for_supplier, source.company
+ )
+ target_doc.buying_price_list = args.buying_price_list or frappe.db.get_single_value(
+ "Buying Settings", "buying_price_list"
+ )
+ set_missing_values(source, target_doc)
+
+ doclist = get_mapped_doc(
+ "Request for Quotation",
+ source_name,
+ {
+ "Request for Quotation": {
+ "doctype": "Supplier Quotation",
+ "validation": {"docstatus": ["=", 1]},
+ "field_map": {"opportunity": "opportunity"},
+ },
+ "Request for Quotation Item": {
+ "doctype": "Supplier Quotation Item",
+ "field_map": {
+ "name": "request_for_quotation_item",
+ "parent": "request_for_quotation",
+ "project_name": "project",
+ },
+ },
+ },
+ target_doc,
+ postprocess,
+ )
+
+ return doclist
+
+
+# This method is used to make supplier quotation from supplier's portal.
+@frappe.whitelist()
+def create_supplier_quotation(doc: str | Document | dict):
+ if isinstance(doc, str):
+ doc = json.loads(doc)
+
+ if frappe.session.user not in frappe.get_all(
+ "Portal User", {"parent": doc.get("supplier")}, pluck="user"
+ ):
+ frappe.throw(_("Not Permitted"), frappe.PermissionError)
+
+ try:
+ sq_doc = frappe.get_doc(
+ {
+ "doctype": "Supplier Quotation",
+ "supplier": doc.get("supplier"),
+ "terms": doc.get("terms"),
+ "company": doc.get("company"),
+ "currency": doc.get("currency")
+ or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
+ "buying_price_list": doc.get("buying_price_list")
+ or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
+ }
+ )
+ add_items(sq_doc, doc.get("supplier"), doc.get("items"))
+ sq_doc.flags.ignore_permissions = True
+ sq_doc.run_method("set_missing_values")
+ sq_doc.save()
+ frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
+ return sq_doc.name
+ except Exception:
+ return None
+
+
+def add_items(sq_doc, supplier, items):
+ for data in items:
+ if isinstance(data, dict):
+ data = frappe._dict(data)
+
+ create_rfq_items(sq_doc, supplier, data)
+
+
+def create_rfq_items(sq_doc, supplier, data):
+ args = {}
+
+ for field in [
+ "item_code",
+ "item_name",
+ "description",
+ "qty",
+ "rate",
+ "conversion_factor",
+ "warehouse",
+ "material_request",
+ "material_request_item",
+ "stock_qty",
+ "uom",
+ ]:
+ args[field] = data.get(field)
+
+ args.update(
+ {
+ "request_for_quotation_item": data.name,
+ "request_for_quotation": data.parent,
+ "supplier_part_no": frappe.db.get_value(
+ "Item Supplier", {"parent": data.item_code, "supplier": supplier}, "supplier_part_no"
+ ),
+ }
+ )
+
+ sq_doc.append("items", args)
+
+
+@frappe.whitelist()
+def get_item_from_material_requests_based_on_supplier(
+ source_name: str, target_doc: str | Document | None = None
+):
+ Item = frappe.qb.DocType("Item")
+ Item_Supp = frappe.qb.DocType("Item Supplier")
+ MR = frappe.qb.DocType("Material Request")
+ MR_Item = frappe.qb.DocType("Material Request Item")
+
+ query = (
+ frappe.qb.from_(MR_Item)
+ .join(MR)
+ .on(MR_Item.parent == MR.name)
+ .join(Item)
+ .on(MR_Item.item_code == Item.name)
+ .join(Item_Supp)
+ .on(Item.name == Item_Supp.parent)
+ .select(MR.name, MR_Item.item_code)
+ .where(Item_Supp.supplier == source_name)
+ .where(MR.status != "Stopped")
+ .where(MR.material_request_type == "Purchase")
+ .where(MR.docstatus == 1)
+ .where(MR.per_ordered < 99.99)
+ )
+
+ mr_items_list = query.run(as_dict=True)
+
+ material_requests = {}
+ for d in mr_items_list:
+ material_requests.setdefault(d.name, []).append(d.item_code)
+
+ for mr, items in material_requests.items():
+ target_doc = get_mapped_doc(
+ "Material Request",
+ mr,
+ {
+ "Material Request": {
+ "doctype": "Request for Quotation",
+ "validation": {
+ "docstatus": ["=", 1],
+ "material_request_type": ["=", "Purchase"],
+ },
+ },
+ "Material Request Item": {
+ "doctype": "Request for Quotation Item",
+ "condition": lambda row: row.item_code in items,
+ "field_map": [
+ ["name", "material_request_item"],
+ ["parent", "material_request"],
+ ["uom", "uom"],
+ ],
+ },
+ },
+ target_doc,
+ )
+
+ return target_doc
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
index 8baeba950b9..33e09c00de2 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js
@@ -209,7 +209,7 @@ frappe.ui.form.on("Request for Quotation", {
return frappe.call({
type: "GET",
- method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq",
+ method: "erpnext.buying.doctype.request_for_quotation.mapper.make_supplier_quotation_from_rfq",
args: {
source_name: doc.name,
for_supplier: args.supplier,
@@ -361,7 +361,7 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
__("Material Request"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_request_for_quotation",
+ method: "erpnext.stock.doctype.material_request.mapper.make_request_for_quotation",
source_doctype: "Material Request",
target: me.frm,
setters: {
@@ -385,7 +385,7 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
__("Opportunity"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.crm.doctype.opportunity.opportunity.make_request_for_quotation",
+ method: "erpnext.crm.doctype.opportunity.mapper.make_request_for_quotation",
source_doctype: "Opportunity",
target: me.frm,
setters: {
@@ -425,7 +425,7 @@ erpnext.buying.RequestforQuotationController = class RequestforQuotationControll
dialog.hide();
erpnext.utils.map_current_doc({
- method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.get_item_from_material_requests_based_on_supplier",
+ method: "erpnext.buying.doctype.request_for_quotation.mapper.get_item_from_material_requests_based_on_supplier",
source_name: args.supplier,
target: me.frm,
setters: {
diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
index dff53355004..36a2a84ac42 100644
--- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py
@@ -2,24 +2,19 @@
# For license information, please see license.txt
-import json
-
import frappe
from frappe import _
from frappe.contacts.doctype.contact.contact import get_full_name
from frappe.core.doctype.communication.email import make
from frappe.desk.form.load import get_attachments
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Order
from frappe.utils import get_url
from frappe.utils.print_format import download_pdf
from frappe.utils.user import get_user_fullname
-from erpnext.accounts.party import _get_party_details, get_party_account_currency
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
-from erpnext.stock.doctype.material_request.material_request import set_missing_values
STANDARD_USERS = ("Guest", "Administrator")
@@ -447,120 +442,6 @@ def get_list_context(context=None):
return list_context
-@frappe.whitelist()
-def make_supplier_quotation_from_rfq(
- source_name: str, target_doc: str | Document | None = None, for_supplier: str | None = None
-):
- def postprocess(source, target_doc):
- if for_supplier:
- target_doc.supplier = for_supplier
- args = _get_party_details(for_supplier, party_type="Supplier", ignore_permissions=True)
- target_doc.currency = args.currency or get_party_account_currency(
- "Supplier", for_supplier, source.company
- )
- target_doc.buying_price_list = args.buying_price_list or frappe.db.get_single_value(
- "Buying Settings", "buying_price_list"
- )
- set_missing_values(source, target_doc)
-
- doclist = get_mapped_doc(
- "Request for Quotation",
- source_name,
- {
- "Request for Quotation": {
- "doctype": "Supplier Quotation",
- "validation": {"docstatus": ["=", 1]},
- "field_map": {"opportunity": "opportunity"},
- },
- "Request for Quotation Item": {
- "doctype": "Supplier Quotation Item",
- "field_map": {
- "name": "request_for_quotation_item",
- "parent": "request_for_quotation",
- "project_name": "project",
- },
- },
- },
- target_doc,
- postprocess,
- )
-
- return doclist
-
-
-# This method is used to make supplier quotation from supplier's portal.
-@frappe.whitelist()
-def create_supplier_quotation(doc: str | Document | dict):
- if isinstance(doc, str):
- doc = json.loads(doc)
-
- if frappe.session.user not in frappe.get_all(
- "Portal User", {"parent": doc.get("supplier")}, pluck="user"
- ):
- frappe.throw(_("Not Permitted"), frappe.PermissionError)
-
- try:
- sq_doc = frappe.get_doc(
- {
- "doctype": "Supplier Quotation",
- "supplier": doc.get("supplier"),
- "terms": doc.get("terms"),
- "company": doc.get("company"),
- "currency": doc.get("currency")
- or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
- "buying_price_list": doc.get("buying_price_list")
- or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
- }
- )
- add_items(sq_doc, doc.get("supplier"), doc.get("items"))
- sq_doc.flags.ignore_permissions = True
- sq_doc.run_method("set_missing_values")
- sq_doc.save()
- frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
- return sq_doc.name
- except Exception:
- return None
-
-
-def add_items(sq_doc, supplier, items):
- for data in items:
- if isinstance(data, dict):
- data = frappe._dict(data)
-
- create_rfq_items(sq_doc, supplier, data)
-
-
-def create_rfq_items(sq_doc, supplier, data):
- args = {}
-
- for field in [
- "item_code",
- "item_name",
- "description",
- "qty",
- "rate",
- "conversion_factor",
- "warehouse",
- "material_request",
- "material_request_item",
- "stock_qty",
- "uom",
- ]:
- args[field] = data.get(field)
-
- args.update(
- {
- "request_for_quotation_item": data.name,
- "request_for_quotation": data.parent,
- "supplier_part_no": frappe.db.get_value(
- "Item Supplier", {"parent": data.item_code, "supplier": supplier}, "supplier_part_no"
- ),
- }
- )
-
- sq_doc.append("items", args)
-
-
@frappe.whitelist()
def get_pdf(
name: str,
@@ -584,65 +465,6 @@ def get_pdf(
)
-@frappe.whitelist()
-def get_item_from_material_requests_based_on_supplier(
- source_name: str, target_doc: str | Document | None = None
-):
- Item = frappe.qb.DocType("Item")
- Item_Supp = frappe.qb.DocType("Item Supplier")
- MR = frappe.qb.DocType("Material Request")
- MR_Item = frappe.qb.DocType("Material Request Item")
-
- query = (
- frappe.qb.from_(MR_Item)
- .join(MR)
- .on(MR_Item.parent == MR.name)
- .join(Item)
- .on(MR_Item.item_code == Item.name)
- .join(Item_Supp)
- .on(Item.name == Item_Supp.parent)
- .select(MR.name, MR_Item.item_code)
- .where(Item_Supp.supplier == source_name)
- .where(MR.status != "Stopped")
- .where(MR.material_request_type == "Purchase")
- .where(MR.docstatus == 1)
- .where(MR.per_ordered < 99.99)
- )
-
- mr_items_list = query.run(as_dict=True)
-
- material_requests = {}
- for d in mr_items_list:
- material_requests.setdefault(d.name, []).append(d.item_code)
-
- for mr, items in material_requests.items():
- target_doc = get_mapped_doc(
- "Material Request",
- mr,
- {
- "Material Request": {
- "doctype": "Request for Quotation",
- "validation": {
- "docstatus": ["=", 1],
- "material_request_type": ["=", "Purchase"],
- },
- },
- "Material Request Item": {
- "doctype": "Request for Quotation Item",
- "condition": lambda row: row.item_code in items,
- "field_map": [
- ["name", "material_request_item"],
- ["parent", "material_request"],
- ["uom", "uom"],
- ],
- },
- },
- target_doc,
- )
-
- return target_doc
-
-
@frappe.whitelist()
def get_supplier_tag():
filters = {"document_type": "Supplier"}
diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
index 6f28a15451b..261fcdfc94a 100644
--- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
+++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py
@@ -8,13 +8,15 @@ import frappe
from frappe.tests import change_settings
from frappe.utils import nowdate
-from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
+from erpnext.buying.doctype.request_for_quotation.mapper import (
create_supplier_quotation,
- get_pdf,
make_supplier_quotation_from_rfq,
)
+from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
+ get_pdf,
+)
from erpnext.controllers.accounts_controller import InvalidQtyError
-from erpnext.crm.doctype.opportunity.opportunity import make_request_for_quotation as make_rfq
+from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index 37d65c23a0d..faa47509b07 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -183,7 +183,7 @@ class Supplier(TransactionBase):
)
def create_primary_contact(self):
- from erpnext.selling.doctype.customer.customer import make_contact
+ from erpnext.selling.doctype.customer.mapper import make_contact
if not self.supplier_primary_contact:
if self.mobile_no or self.email_id:
@@ -195,7 +195,7 @@ class Supplier(TransactionBase):
def create_primary_address(self):
from frappe.contacts.doctype.address.address import get_address_display
- from erpnext.selling.doctype.customer.customer import make_address
+ from erpnext.selling.doctype.customer.mapper import make_address
if self.flags.is_new_doc and self.get("address_line1"):
address = make_address(self)
diff --git a/erpnext/buying/doctype/supplier_quotation/mapper.py b/erpnext/buying/doctype/supplier_quotation/mapper.py
new file mode 100644
index 00000000000..aebe5d94a4c
--- /dev/null
+++ b/erpnext/buying/doctype/supplier_quotation/mapper.py
@@ -0,0 +1,110 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import flt
+
+
+@frappe.whitelist()
+def make_purchase_order(
+ source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
+):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+ target.run_method("get_schedule_dates")
+ target.run_method("calculate_taxes_and_totals")
+
+ def update_item(obj, target, source_parent):
+ target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ doclist = get_mapped_doc(
+ "Supplier Quotation",
+ source_name,
+ {
+ "Supplier Quotation": {
+ "doctype": "Purchase Order",
+ "field_no_map": ["transaction_date"],
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Supplier Quotation Item": {
+ "doctype": "Purchase Order Item",
+ "field_map": [
+ ["name", "supplier_quotation_item"],
+ ["parent", "supplier_quotation"],
+ ["material_request", "material_request"],
+ ["material_request_item", "material_request_item"],
+ ["sales_order", "sales_order"],
+ ],
+ "postprocess": update_item,
+ "condition": select_item,
+ },
+ "Purchase Taxes and Charges": {
+ "doctype": "Purchase Taxes and Charges",
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_purchase_invoice(source_name: str, target_doc: str | Document | None = None):
+ doc = get_mapped_doc(
+ "Supplier Quotation",
+ source_name,
+ {
+ "Supplier Quotation": {
+ "doctype": "Purchase Invoice",
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Supplier Quotation Item": {"doctype": "Purchase Invoice Item"},
+ "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
+ },
+ target_doc,
+ )
+
+ return doc
+
+
+@frappe.whitelist()
+def make_quotation(source_name: str, target_doc: str | Document | None = None):
+ doclist = get_mapped_doc(
+ "Supplier Quotation",
+ source_name,
+ {
+ "Supplier Quotation": {
+ "doctype": "Quotation",
+ "field_map": {
+ "name": "supplier_quotation",
+ },
+ },
+ "Supplier Quotation Item": {
+ "doctype": "Quotation Item",
+ "condition": lambda doc: frappe.db.get_value("Item", doc.item_code, "is_sales_item") == 1,
+ "add_if_empty": True,
+ },
+ },
+ target_doc,
+ )
+
+ return doclist
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
index deb87234c50..d35be93b209 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js
@@ -56,7 +56,7 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
__("Material Request"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_supplier_quotation",
+ method: "erpnext.stock.doctype.material_request.mapper.make_supplier_quotation",
source_doctype: "Material Request",
target: me.frm,
setters: {
@@ -91,7 +91,7 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
frappe.throw({ message: __("Please select a Supplier"), title: __("Mandatory") });
}
erpnext.utils.map_current_doc({
- method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.make_supplier_quotation_from_rfq",
+ method: "erpnext.buying.doctype.request_for_quotation.mapper.make_supplier_quotation_from_rfq",
source_doctype: "Request for Quotation",
target: me.frm,
setters: {
@@ -112,13 +112,13 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e
make_purchase_order() {
frappe.model.open_mapped_doc({
- method: "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_purchase_order",
+ method: "erpnext.buying.doctype.supplier_quotation.mapper.make_purchase_order",
frm: this.frm,
});
}
make_quotation() {
frappe.model.open_mapped_doc({
- method: "erpnext.buying.doctype.supplier_quotation.supplier_quotation.make_quotation",
+ method: "erpnext.buying.doctype.supplier_quotation.mapper.make_quotation",
frm: this.frm,
});
}
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
index c7fa6ecfc63..9521769fa82 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
@@ -2,13 +2,10 @@
# License: GNU General Public License v3. See license.txt
-import json
-
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
-from frappe.utils import flt, getdate, nowdate
+from frappe.utils import getdate, nowdate
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
@@ -245,107 +242,6 @@ def get_list_context(context=None):
return list_context
-@frappe.whitelist()
-def make_purchase_order(
- source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
-):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- def set_missing_values(source, target):
- target.run_method("set_missing_values")
- target.run_method("get_schedule_dates")
- target.run_method("calculate_taxes_and_totals")
-
- def update_item(obj, target, source_parent):
- target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- doclist = get_mapped_doc(
- "Supplier Quotation",
- source_name,
- {
- "Supplier Quotation": {
- "doctype": "Purchase Order",
- "field_no_map": ["transaction_date"],
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Supplier Quotation Item": {
- "doctype": "Purchase Order Item",
- "field_map": [
- ["name", "supplier_quotation_item"],
- ["parent", "supplier_quotation"],
- ["material_request", "material_request"],
- ["material_request_item", "material_request_item"],
- ["sales_order", "sales_order"],
- ],
- "postprocess": update_item,
- "condition": select_item,
- },
- "Purchase Taxes and Charges": {
- "doctype": "Purchase Taxes and Charges",
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_purchase_invoice(source_name: str, target_doc: str | Document | None = None):
- doc = get_mapped_doc(
- "Supplier Quotation",
- source_name,
- {
- "Supplier Quotation": {
- "doctype": "Purchase Invoice",
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Supplier Quotation Item": {"doctype": "Purchase Invoice Item"},
- "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges"},
- },
- target_doc,
- )
-
- return doc
-
-
-@frappe.whitelist()
-def make_quotation(source_name: str, target_doc: str | Document | None = None):
- doclist = get_mapped_doc(
- "Supplier Quotation",
- source_name,
- {
- "Supplier Quotation": {
- "doctype": "Quotation",
- "field_map": {
- "name": "supplier_quotation",
- },
- },
- "Supplier Quotation Item": {
- "doctype": "Quotation Item",
- "condition": lambda doc: frappe.db.get_value("Item", doc.item_code, "is_sales_item") == 1,
- "add_if_empty": True,
- },
- },
- target_doc,
- )
-
- return doclist
-
-
def set_expired_status():
frappe.db.set_value(
"Supplier Quotation",
diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py
index c271d34b35d..e8e03713177 100644
--- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py
+++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py
@@ -8,7 +8,7 @@ import frappe
from frappe.tests import change_settings
from frappe.utils import add_days, today
-from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order
+from erpnext.buying.doctype.supplier_quotation.mapper import make_purchase_order
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py
index 38f2d7426ff..acf29b75043 100644
--- a/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py
+++ b/erpnext/buying/report/requested_items_to_order_and_receive/test_requested_items_to_order_and_receive.py
@@ -4,12 +4,12 @@
import frappe
from frappe.utils import add_days, today
-from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.buying.report.requested_items_to_order_and_receive.requested_items_to_order_and_receive import (
get_data,
)
from erpnext.stock.doctype.item.test_item import create_item
-from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+from erpnext.stock.doctype.material_request.mapper import make_purchase_order
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 350132da175..4dcb6b00b49 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -8,24 +8,15 @@ from collections import defaultdict
import frappe
from frappe import _, bold, qb, throw
from frappe.contacts.doctype.address.address import get_address_display
-from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied
-from frappe.query_builder import Criterion, DocType
-from frappe.query_builder.custom import ConstantColumn
-from frappe.query_builder.functions import Abs, Sum
+from frappe.query_builder import DocType
+from frappe.query_builder.functions import Sum
from frappe.utils import (
- DateTimeLikeObject,
- add_days,
- add_months,
cint,
comma_and,
flt,
- fmt_money,
- formatdate,
- get_last_day,
get_link_to_form,
getdate,
nowdate,
- parse_json,
today,
)
@@ -43,41 +34,21 @@ from erpnext.accounts.general_ledger import get_round_off_account_and_cost_cente
from erpnext.accounts.party import (
PURCHASE_TRANSACTION_TYPES,
SALES_TRANSACTION_TYPES,
- get_party_account,
- get_party_account_currency,
- get_party_gle_currency,
- validate_party_frozen_disabled,
-)
-from erpnext.accounts.utils import (
- create_gain_loss_journal,
- get_account_currency,
- get_currency_precision,
- get_fiscal_years,
- validate_fiscal_year,
)
from erpnext.accounts.utils import (
get_advance_payment_doctypes as _get_advance_payment_doctypes,
)
-from erpnext.buying.utils import update_last_purchase_rate
+from erpnext.accounts.utils import validate_fiscal_year
from erpnext.controllers.print_settings import (
set_print_templates_for_item_table,
set_print_templates_for_taxes,
)
from erpnext.controllers.sales_and_purchase_return import validate_return
-from erpnext.exceptions import InvalidCurrency
from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.item.item import get_uom_conv_factor
-from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.get_item_details import (
- NOT_APPLICABLE_TAX,
ItemDetailsCtx,
- _get_item_tax_template,
- _get_item_tax_template_from_item_group,
- get_bin_details,
- get_conversion_factor,
get_item_details,
- get_item_tax_map,
- get_item_warehouse_,
)
from erpnext.utilities.regional import temporary_flag
from erpnext.utilities.transaction_base import TransactionBase
@@ -140,7 +111,9 @@ class AccountsController(TransactionBase):
"Sales Invoice",
)
if self.doctype in relevant_docs:
- self.set_payment_schedule()
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ PaymentScheduleService(self).set_payment_schedule()
def on_update(self):
from erpnext.controllers.taxes_and_totals import process_item_wise_tax_details
@@ -238,30 +211,29 @@ class AccountsController(TransactionBase):
self.ensure_supplier_is_not_blocked()
self.validate_date_with_fiscal_year()
- self.validate_party_accounts()
if self.doctype in ["Sales Invoice", "Purchase Invoice"]:
if self.is_return:
self.validate_qty()
else:
self.validate_deferred_start_and_end_date()
- self.validate_inter_company_reference()
- # validate inter company transaction rate
- self.validate_internal_transaction()
+ from erpnext.accounts.services.internal_transfer import InternalTransferService
- self.disable_pricing_rule_on_internal_transfer()
- self.disable_tax_included_prices_for_internal_transfer()
+ InternalTransferService(self).validate()
self.set_incoming_rate()
self.init_internal_values()
self.validate_against_voucher_outstanding()
# Need to set taxes based on taxes_and_charges template
# before calculating taxes and totals
- if self.meta.get_field("taxes_and_charges"):
- self.validate_enabled_taxes_and_charges()
- self.validate_tax_account_company()
+ from erpnext.accounts.services.taxes import TaxService
- self.set_taxes_and_charges()
+ tax_service = TaxService(self)
+ if self.meta.get_field("taxes_and_charges"):
+ tax_service.validate_enabled_taxes_and_charges()
+ tax_service.validate_tax_account_company()
+
+ tax_service.set_taxes_and_charges()
if self.meta.get_field("currency"):
self.calculate_taxes_and_totals()
@@ -273,9 +245,9 @@ class AccountsController(TransactionBase):
self.validate_all_documents_schedule()
- self.validate_party()
- self.validate_currency()
- self.validate_party_account_currency()
+ from erpnext.accounts.services.party_validation import PartyValidator
+
+ PartyValidator(self).validate()
self.validate_return_against_account()
if self.doctype in ["Purchase Invoice", "Sales Invoice"]:
@@ -296,7 +268,7 @@ class AccountsController(TransactionBase):
self.set_advance_gain_or_loss()
self.validate_deferred_income_expense_account()
- self.set_inter_company_account()
+ InternalTransferService(self).set_account()
if self.doctype == "Purchase Invoice":
self.calculate_paid_amount()
@@ -311,54 +283,6 @@ class AccountsController(TransactionBase):
self.set_total_in_words()
self.set_default_letter_head()
self.validate_company_in_accounting_dimension()
- self.validate_party_address_and_contact()
- self.validate_company_linked_addresses()
-
- def validate_company_linked_addresses(self):
- address_fields = []
- sales_doctypes = ("Quotation", "Sales Order", "Delivery Note", "Sales Invoice")
- purchase_doctypes = ("Purchase Order", "Purchase Receipt", "Purchase Invoice", "Supplier Quotation")
-
- if self.doctype in sales_doctypes:
- address_fields = ["dispatch_address_name", "company_address"]
- elif self.doctype in purchase_doctypes:
- address_fields = ["billing_address", "shipping_address"]
-
- if not address_fields:
- return
-
- # Determine if drop ship applies
- is_drop_ship = self.doctype in {
- "Purchase Order",
- "Purchase Invoice",
- "Sales Order",
- "Sales Invoice",
- } and self.is_drop_ship(self.items)
-
- for field in address_fields:
- address = self.get(field)
-
- if (field in ["dispatch_address_name", "shipping_address"]) and is_drop_ship:
- continue
-
- if address and not frappe.db.exists(
- "Dynamic Link",
- {
- "parent": address,
- "parenttype": "Address",
- "link_doctype": "Company",
- "link_name": self.company,
- },
- ):
- frappe.throw(
- _("{0} does not belong to the Company {1}.").format(
- _(self.meta.get_label(field)), bold(self.company)
- )
- )
-
- @staticmethod
- def is_drop_ship(items):
- return any(item.delivered_by_supplier for item in items)
def set_default_letter_head(self):
if hasattr(self, "letter_head") and not self.letter_head:
@@ -546,46 +470,6 @@ class AccountsController(TransactionBase):
)
)
- def validate_party_address_and_contact(self):
- party_type, party = self.get_party()
-
- if not (party_type and party):
- return
-
- if party_type == "Customer":
- billing_address, shipping_address = (
- self.get("customer_address"),
- self.get("shipping_address_name"),
- )
- self.validate_party_address(party, party_type, billing_address, shipping_address)
- elif party_type == "Supplier":
- billing_address = self.get("supplier_address")
- self.validate_party_address(party, party_type, billing_address)
-
- self.validate_party_contact(party, party_type)
-
- def validate_party_address(self, party, party_type, billing_address, shipping_address=None):
- if billing_address or shipping_address:
- party_address = frappe.get_all(
- "Dynamic Link",
- {"link_doctype": party_type, "link_name": party, "parenttype": "Address"},
- pluck="parent",
- )
- if billing_address and billing_address not in party_address:
- frappe.throw(_("Billing Address does not belong to the {0}").format(party))
- elif shipping_address and shipping_address not in party_address:
- frappe.throw(_("Shipping Address does not belong to the {0}").format(party))
-
- def validate_party_contact(self, party, party_type):
- if self.get("contact_person"):
- contact = frappe.get_all(
- "Dynamic Link",
- {"link_doctype": party_type, "link_name": party, "parenttype": "Contact"},
- pluck="parent",
- )
- if self.contact_person and self.contact_person not in contact:
- frappe.throw(_("Contact Person does not belong to the {0}").format(party))
-
def validate_return_against_account(self):
if self.doctype in ["Sales Invoice", "Purchase Invoice"] and self.is_return and self.return_against:
cr_dr_account_field = "debit_to" if self.doctype == "Sales Invoice" else "credit_to"
@@ -659,18 +543,24 @@ class AccountsController(TransactionBase):
if self.is_return:
return
- self.validate_payment_schedule_dates()
- self.set_due_date()
- self.set_payment_schedule()
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ ps = PaymentScheduleService(self)
+ ps.validate_payment_schedule_dates()
+ ps.set_due_date()
+ ps.set_payment_schedule()
if not self.get("ignore_default_payment_terms_template"):
- self.validate_payment_schedule_amount()
+ ps.validate_payment_schedule_amount()
self.validate_due_date()
self.validate_advance_entries()
def validate_non_invoice_documents_schedule(self):
- self.set_payment_schedule()
- self.validate_payment_schedule_dates()
- self.validate_payment_schedule_amount()
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ ps = PaymentScheduleService(self)
+ ps.set_payment_schedule()
+ ps.validate_payment_schedule_dates()
+ ps.validate_payment_schedule_amount()
def validate_all_documents_schedule(self):
if self.doctype in ("Sales Invoice", "Purchase Invoice"):
@@ -766,162 +656,6 @@ class AccountsController(TransactionBase):
self,
)
- def validate_party_accounts(self):
- if self.doctype not in ("Sales Invoice", "Purchase Invoice"):
- return
-
- if self.doctype == "Sales Invoice":
- party_account_field = "debit_to"
- item_field = "income_account"
- else:
- party_account_field = "credit_to"
- item_field = "expense_account"
-
- for item in self.get("items"):
- if item.get(item_field) == self.get(party_account_field):
- frappe.throw(
- _("Row {0}: {1} {2} cannot be same as {3} (Party Account) {4}").format(
- item.idx,
- frappe.bold(frappe.unscrub(item_field)),
- item.get(item_field),
- frappe.bold(frappe.unscrub(party_account_field)),
- self.get(party_account_field),
- )
- )
-
- def validate_inter_company_reference(self):
- if self.get("is_return"):
- return
-
- if self.doctype not in ("Purchase Invoice", "Purchase Receipt"):
- return
-
- if self.is_internal_transfer():
- if not (
- self.get("inter_company_reference")
- or self.get("inter_company_invoice_reference")
- or self.get("inter_company_order_reference")
- ) and not self.get("is_return"):
- msg = _("Internal Sale or Delivery Reference missing.")
- msg += _("Please create purchase from internal sale or delivery document itself")
- frappe.throw(msg, title=_("Internal Sales Reference Missing"))
-
- label = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item"
-
- field = frappe.scrub(label)
-
- for row in self.get("items"):
- if not row.get(field):
- msg = f"At Row {row.idx}: The field {bold(label)} is mandatory for internal transfer"
- frappe.throw(_(msg), title=_("Internal Transfer Reference Missing"))
-
- def validate_internal_transaction(self):
- if not cint(frappe.get_single_value("Accounts Settings", "maintain_same_internal_transaction_rate")):
- return
-
- doctypes_list = ["Sales Order", "Sales Invoice", "Purchase Order", "Purchase Invoice"]
-
- if self.doctype in doctypes_list and (
- self.get("is_internal_customer") or self.get("is_internal_supplier")
- ):
- self.validate_internal_transaction_based_on_voucher_type()
-
- def validate_internal_transaction_based_on_voucher_type(self):
- order = ["Sales Order", "Purchase Order"]
- invoice = ["Sales Invoice", "Purchase Invoice"]
-
- if self.doctype in order and self.get("inter_company_order_reference"):
- # Fetch the linked order
- linked_doctype = "Sales Order" if self.doctype == "Purchase Order" else "Purchase Order"
- self.validate_line_items(
- linked_doctype,
- "sales_order" if linked_doctype == "Sales Order" else "purchase_order",
- "sales_order_item" if linked_doctype == "Sales Order" else "purchase_order_item",
- )
- elif self.doctype in invoice and self.get("inter_company_invoice_reference"):
- # Fetch the linked invoice
- linked_doctype = "Sales Invoice" if self.doctype == "Purchase Invoice" else "Purchase Invoice"
- self.validate_line_items(
- linked_doctype,
- "sales_invoice" if linked_doctype == "Sales Invoice" else "purchase_invoice",
- "sales_invoice_item" if linked_doctype == "Sales Invoice" else "purchase_invoice_item",
- )
-
- def validate_line_items(self, ref_dt, ref_dn_field, ref_link_field):
- action, role_allowed_to_override = frappe.get_cached_value(
- "Accounts Settings", "None", ["maintain_same_rate_action", "role_to_override_stop_action"]
- )
-
- reference_names = [d.get(ref_link_field) for d in self.get("items") if d.get(ref_link_field)]
- reference_details = self.get_reference_details(reference_names, ref_dt + " Item")
-
- stop_actions = []
-
- for d in self.get("items"):
- if d.get(ref_link_field):
- ref_rate = reference_details.get(d.get(ref_link_field))
- if ref_rate is not None and abs(flt(d.rate - ref_rate, d.precision("rate"))) >= 0.01:
- if action == "Stop":
- user_roles = [
- r["role"]
- for r in frappe.get_all(
- "Has Role", filters={"parent": frappe.session.user}, fields=["role"]
- )
- ]
- if role_allowed_to_override not in user_roles:
- stop_actions.append(
- _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format(
- d.idx,
- ref_dt,
- self.inter_company_invoice_reference
- if d.parenttype in ("Sales Invoice", "Purchase Invoice")
- else d.get(ref_dn_field),
- d.rate,
- ref_rate,
- )
- )
- else:
- frappe.msgprint(
- _("Row #{0}: Rate must be same as {1}: {2} ({3} / {4})").format(
- d.idx,
- ref_dt,
- self.inter_company_invoice_reference
- if d.parenttype in ("Sales Invoice", "Purchase Invoice")
- else d.get(ref_dn_field),
- d.rate,
- ref_rate,
- ),
- title=_("Warning"),
- indicator="orange",
- )
-
- if stop_actions:
- frappe.throw(stop_actions, as_list=True)
-
- def disable_pricing_rule_on_internal_transfer(self):
- if not self.get("ignore_pricing_rule") and self.is_internal_transfer():
- self.ignore_pricing_rule = 1
- frappe.msgprint(
- _("Disabled pricing rules since this {} is an internal transfer").format(self.doctype),
- alert=1,
- )
-
- def disable_tax_included_prices_for_internal_transfer(self):
- if self.is_internal_transfer():
- tax_updated = False
- for tax in self.get("taxes"):
- if tax.get("included_in_print_rate"):
- tax.included_in_print_rate = 0
- tax_updated = True
-
- if tax_updated:
- frappe.msgprint(
- _("Disabled tax included prices since this {} is an internal transfer").format(
- self.doctype
- ),
- alert=1,
- )
-
def validate_due_date(self):
if self.get("is_pos") or self.doctype not in ["Sales Invoice", "Purchase Invoice"]:
return
@@ -1195,243 +929,20 @@ class AccountsController(TransactionBase):
},
)
- def set_taxes(self):
- if not self.meta.get_field("taxes"):
- return
-
- tax_master_doctype = self.meta.get_field("taxes_and_charges").options
-
- if (self.is_new() or self.is_pos_profile_changed()) and not self.get("taxes"):
- if self.company and not self.get("taxes_and_charges"):
- # get the default tax master
- self.taxes_and_charges = frappe.db.get_value(
- tax_master_doctype, {"is_default": 1, "company": self.company}
- )
-
- self.append_taxes_from_master(tax_master_doctype)
-
- def is_pos_profile_changed(self):
- if (
- self.doctype == "Sales Invoice"
- and self.is_pos
- and self.pos_profile != frappe.db.get_value("Sales Invoice", self.name, "pos_profile")
- ):
- return True
-
- def set_taxes_and_charges(self):
- if self.doctype == "Material Request":
- # Material Request does not have taxes
- return
-
- if self.get("taxes") or self.get("is_pos"):
- return
-
- if frappe.get_single_value(
- "Accounts Settings", "add_taxes_from_taxes_and_charges_template"
- ) and hasattr(self, "taxes_and_charges"):
- if tax_master_doctype := self.meta.get_field("taxes_and_charges").options:
- self.append_taxes_from_master(tax_master_doctype)
-
- if frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"):
- self.append_taxes_from_item_tax_template()
-
- def append_taxes_from_master(self, tax_master_doctype=None):
- if self.get("taxes_and_charges"):
- if not tax_master_doctype:
- tax_master_doctype = self.meta.get_field("taxes_and_charges").options
- self.extend("taxes", get_taxes_and_charges(tax_master_doctype, self.get("taxes_and_charges")))
-
- def append_taxes_from_item_tax_template(self):
- if not frappe.get_single_value("Accounts Settings", "add_taxes_from_item_tax_template"):
- return
-
- for row in self.items:
- item_tax_rate = row.get("item_tax_rate")
- if not item_tax_rate:
- continue
-
- if isinstance(item_tax_rate, str):
- item_tax_rate = parse_json(item_tax_rate)
-
- for account_head, _rate in item_tax_rate.items():
- row = self.get_tax_row(account_head)
-
- if not row:
- self.append(
- "taxes",
- {
- "charge_type": "On Net Total",
- "account_head": account_head,
- "rate": 0,
- "description": account_head,
- "set_by_item_tax_template": 1,
- "category": "Total",
- "add_deduct_tax": "Add",
- },
- )
-
- def get_tax_row(self, account_head):
- for row in self.taxes:
- if row.account_head == account_head:
- return row
-
- def set_other_charges(self):
- self.set("taxes", [])
- self.set_taxes()
-
- def validate_enabled_taxes_and_charges(self):
- taxes_and_charges_doctype = self.meta.get_options("taxes_and_charges")
- if self.taxes_and_charges and frappe.get_cached_value(
- taxes_and_charges_doctype, self.taxes_and_charges, "disabled"
- ):
- frappe.throw(_("{0} '{1}' is disabled").format(taxes_and_charges_doctype, self.taxes_and_charges))
-
- def validate_tax_account_company(self):
- for d in self.get("taxes"):
- if d.account_head:
- tax_account_company = frappe.get_cached_value("Account", d.account_head, "company")
- if tax_account_company != self.company:
- frappe.throw(
- _("Row #{0}: Account {1} does not belong to company {2}").format(
- d.idx, d.account_head, self.company
- )
- )
-
def get_gl_dict(self, args, account_currency=None, item=None):
- """this method populates the common properties of a gl entry record"""
+ from erpnext.accounts.services.base_gl_composer import get_gl_dict
- posting_date = args.get("posting_date") or self.get("posting_date")
- fiscal_years = get_fiscal_years(posting_date, company=self.company)
- if len(fiscal_years) > 1:
- frappe.throw(
- _("Multiple fiscal years exist for the date {0}. Please set company in Fiscal Year").format(
- formatdate(posting_date)
- )
- )
- else:
- fiscal_year = fiscal_years[0][0]
-
- gl_dict = frappe._dict(
- {
- "company": self.company,
- "posting_date": posting_date,
- "fiscal_year": fiscal_year,
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "remarks": self.get("remarks") or self.get("remark"),
- "debit": 0,
- "credit": 0,
- "debit_in_account_currency": 0,
- "credit_in_account_currency": 0,
- "is_opening": self.get("is_opening") or "No",
- "party_type": None,
- "party": None,
- "project": self.get("project"),
- "post_net_value": args.get("post_net_value"),
- "voucher_detail_no": args.get("voucher_detail_no"),
- "voucher_subtype": self.get_voucher_subtype(),
- }
- )
-
- with temporary_flag("company", self.company):
- update_gl_dict_with_regional_fields(self, gl_dict)
-
- update_gl_dict_with_app_based_fields(self, gl_dict)
-
- accounting_dimensions = get_accounting_dimensions()
- dimension_dict = frappe._dict()
-
- for dimension in accounting_dimensions:
- dimension_dict[dimension] = self.get(dimension)
- if item and item.get(dimension):
- dimension_dict[dimension] = item.get(dimension)
-
- gl_dict.update(dimension_dict)
- gl_dict.update(args)
-
- if not account_currency:
- account_currency = get_account_currency(gl_dict.account)
-
- if gl_dict.account and self.doctype not in [
- "Journal Entry",
- "Period Closing Voucher",
- "Payment Entry",
- "Purchase Receipt",
- "Purchase Invoice",
- "Stock Entry",
- ]:
- self.validate_account_currency(gl_dict.account, account_currency)
-
- if gl_dict.account and self.doctype not in [
- "Journal Entry",
- "Period Closing Voucher",
- "Payment Entry",
- ]:
- set_balance_in_account_currency(
- gl_dict,
- account_currency,
- args.get("transaction_exchange_rate") or self.get("conversion_rate"),
- self.company_currency,
- )
-
- # Update details in transaction currency
- if self.doctype not in ["Purchase Invoice", "Sales Invoice", "Journal Entry", "Payment Entry"]:
- gl_dict.update(
- {
- "transaction_currency": self.get("currency") or self.company_currency,
- "transaction_exchange_rate": args.get("transaction_exchange_rate")
- or self.get("conversion_rate", 1),
- "debit_in_transaction_currency": self.get_value_in_transaction_currency(
- account_currency, gl_dict, "debit"
- ),
- "credit_in_transaction_currency": self.get_value_in_transaction_currency(
- account_currency, gl_dict, "credit"
- ),
- }
- )
-
- if not args.get("against_voucher_type") and self.get("against_voucher_type"):
- gl_dict.update({"against_voucher_type": self.get("against_voucher_type")})
-
- if not args.get("against_voucher") and self.get("against_voucher"):
- gl_dict.update({"against_voucher": self.get("against_voucher")})
-
- return gl_dict
+ return get_gl_dict(self, args, account_currency, item)
def get_voucher_subtype(self):
- voucher_subtypes = {
- "Journal Entry": "voucher_type",
- "Payment Entry": "payment_type",
- "Stock Entry": "stock_entry_type",
- "Asset Capitalization": "entry_type",
- }
+ from erpnext.accounts.services.base_gl_composer import get_voucher_subtype
- for method_name in frappe.get_hooks("voucher_subtypes"):
- voucher_subtype = frappe.get_attr(method_name)(self)
-
- if voucher_subtype:
- return voucher_subtype
-
- if self.doctype in voucher_subtypes:
- return self.get(voucher_subtypes[self.doctype])
- elif self.doctype == "Purchase Receipt" and self.is_return:
- return "Purchase Return"
- elif self.doctype == "Delivery Note" and self.is_return:
- return "Sales Return"
- elif self.doctype == "Sales Invoice" and self.is_return:
- return "Credit Note"
- elif self.doctype == "Sales Invoice" and self.is_debit_note:
- return "Debit Note"
- elif self.doctype == "Purchase Invoice" and self.is_return:
- return "Debit Note"
-
- return self.doctype
+ return get_voucher_subtype(self)
def get_value_in_transaction_currency(self, account_currency, gl_dict, field):
- if account_currency == self.get("currency"):
- return gl_dict.get(field + "_in_account_currency")
- else:
- return flt(gl_dict.get(field, 0) / self.get("conversion_rate", 1))
+ from erpnext.accounts.services.base_gl_composer import get_value_in_transaction_currency
+
+ return get_value_in_transaction_currency(self, account_currency, gl_dict, field)
def validate_zero_qty_for_return_invoices_with_stock(self):
rows = []
@@ -1463,16 +974,9 @@ class AccountsController(TransactionBase):
)
def validate_account_currency(self, account, account_currency=None):
- valid_currency = [self.company_currency]
- if self.get("currency") and self.currency != self.company_currency:
- valid_currency.append(self.currency)
+ from erpnext.accounts.services.base_gl_composer import validate_account_currency
- if account_currency not in valid_currency:
- frappe.throw(
- _("Account {0} is invalid. Account Currency must be {1}").format(
- account, (" " + _("or") + " ").join(valid_currency)
- )
- )
+ return validate_account_currency(self, account, account_currency)
def clear_unallocated_advances(self, childtype, parentfield):
self.set(parentfield, self.get(parentfield, {"allocated_amount": ["not in", [0, None, ""]]}))
@@ -1507,86 +1011,14 @@ class AccountsController(TransactionBase):
@frappe.whitelist()
def set_advances(self):
- """Returns list of advances against Account, Party, Reference"""
+ from erpnext.accounts.services.advances import set_advances
- res = self.get_advance_entries(
- include_unallocated=not cint(self.get("only_include_allocated_payments"))
- )
-
- self.set("advances", [])
- advance_allocated = 0
- for d in res:
- if self.get("party_account_currency") == self.company_currency:
- amount = self.get("base_rounded_total") or self.base_grand_total
- else:
- amount = self.get("rounded_total") or self.grand_total
- allocated_amount = min(amount - advance_allocated, d.amount)
- advance_allocated += flt(allocated_amount)
-
- advance_row = {
- "doctype": self.doctype + " Advance",
- "reference_type": d.reference_type,
- "reference_name": d.reference_name,
- "reference_row": d.reference_row,
- "remarks": d.remarks,
- "advance_amount": flt(d.amount),
- "allocated_amount": allocated_amount,
- "ref_exchange_rate": flt(d.exchange_rate), # exchange_rate of advance entry
- "difference_posting_date": self.posting_date,
- }
- if d.get("paid_from"):
- advance_row["account"] = d.paid_from
- if d.get("paid_to"):
- advance_row["account"] = d.paid_to
-
- self.append("advances", advance_row)
+ set_advances(self)
def get_advance_entries(self, include_unallocated=True):
- party_account = []
- default_advance_account = None
+ from erpnext.accounts.services.advances import get_advance_entries
- if self.doctype in ["Sales Invoice", "POS Invoice"]:
- party_type = "Customer"
- party = self.customer
- amount_field = "credit_in_account_currency"
- order_field = "sales_order"
- order_doctype = "Sales Order"
- party_account.append(self.debit_to)
- else:
- party_type = "Supplier"
- party = self.supplier
- amount_field = "debit_in_account_currency"
- order_field = "purchase_order"
- order_doctype = "Purchase Order"
- party_account.append(self.credit_to)
-
- party_accounts = get_party_account(
- party_type, party=party, company=self.company, include_advance=True
- )
-
- if party_accounts:
- party_account.append(party_accounts[0])
- default_advance_account = party_accounts[1] if len(party_accounts) == 2 else None
-
- order_list = list(set(d.get(order_field) for d in self.get("items") if d.get(order_field)))
-
- journal_entries = get_advance_journal_entries(
- party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated
- )
-
- payment_entries = get_advance_payment_entries_for_regional(
- party_type,
- party,
- party_account,
- order_doctype,
- order_list,
- default_advance_account,
- include_unallocated,
- )
-
- res = journal_entries + payment_entries
-
- return res
+ return get_advance_entries(self, include_unallocated)
def is_inclusive_tax(self):
is_inclusive = cint(frappe.get_single_value("Accounts Settings", "show_inclusive_tax_in_print"))
@@ -1602,276 +1034,35 @@ class AccountsController(TransactionBase):
return cint(frappe.get_single_value("Accounts Settings", "show_taxes_as_table_in_print"))
def validate_advance_entries(self):
- order_field = "sales_order" if self.doctype == "Sales Invoice" else "purchase_order"
- order_list = list(set(d.get(order_field) for d in self.get("items") if d.get(order_field)))
+ from erpnext.accounts.services.advances import validate_advance_entries
- if not order_list:
- return
-
- advance_entries = self.get_advance_entries(include_unallocated=False)
-
- if advance_entries:
- advance_entries_against_si = [d.reference_name for d in self.get("advances")]
- for d in advance_entries:
- if not advance_entries_against_si or d.reference_name not in advance_entries_against_si:
- frappe.msgprint(
- _(
- "Payment Entry {0} is linked against Order {1}, check if it should be pulled as advance in this invoice."
- ).format(d.reference_name, d.against_order)
- )
+ validate_advance_entries(self)
def set_advance_gain_or_loss(self):
- if self.get("conversion_rate") == 1 or not self.get("advances"):
- return
+ from erpnext.accounts.services.advances import set_advance_gain_or_loss
- is_purchase_invoice = self.doctype == "Purchase Invoice"
- party_account = self.credit_to if is_purchase_invoice else self.debit_to
- if get_account_currency(party_account) != self.currency:
- return
-
- for d in self.get("advances"):
- advance_exchange_rate = d.ref_exchange_rate
- if d.allocated_amount and self.conversion_rate != advance_exchange_rate:
- base_allocated_amount_in_ref_rate = advance_exchange_rate * d.allocated_amount
- base_allocated_amount_in_inv_rate = self.conversion_rate * d.allocated_amount
- difference = base_allocated_amount_in_ref_rate - base_allocated_amount_in_inv_rate
-
- d.exchange_gain_loss = difference
-
- def make_precision_loss_gl_entry(self, gl_entries):
- (
- round_off_account,
- round_off_cost_center,
- round_off_for_opening,
- ) = get_round_off_account_and_cost_center(
- self.company, "Purchase Invoice", self.name, self.use_company_roundoff_cost_center
- )
-
- precision_loss = self.get("base_net_total") - flt(
- self.get("net_total") * self.conversion_rate, self.precision("net_total")
- )
-
- credit_or_debit = "credit" if self.doctype == "Purchase Invoice" else "debit"
- against = self.supplier if self.doctype == "Purchase Invoice" else self.customer
-
- if precision_loss:
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": round_off_account,
- "against": against,
- credit_or_debit: precision_loss,
- "cost_center": round_off_cost_center
- if self.use_company_roundoff_cost_center
- else self.cost_center or round_off_cost_center,
- "remarks": _("Net total calculation precision loss"),
- }
- )
- )
+ set_advance_gain_or_loss(self)
def gain_loss_journal_already_booked(
- self,
- gain_loss_account,
- exc_gain_loss,
- ref2_dt,
- ref2_dn,
- ref2_detail_no,
+ self, gain_loss_account, exc_gain_loss, ref2_dt, ref2_dn, ref2_detail_no
) -> bool:
- """
- Check if gain/loss is booked
- """
- if res := frappe.db.get_all(
- "Journal Entry Account",
- filters={
- "docstatus": 1,
- "account": gain_loss_account,
- "reference_type": ref2_dt, # this will be Journal Entry
- "reference_name": ref2_dn,
- "reference_detail_no": ref2_detail_no,
- },
- pluck="parent",
- ):
- # deduplicate
- res = list({x for x in res})
- if exc_vouchers := frappe.db.get_all(
- "Journal Entry",
- filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"},
- fields=["voucher_type", "total_debit", "total_credit"],
- ):
- booked_voucher = exc_vouchers[0]
- if (
- booked_voucher.total_debit == exc_gain_loss
- and booked_voucher.total_credit == exc_gain_loss
- and booked_voucher.voucher_type == "Exchange Gain Or Loss"
- ):
- return True
- return False
+ from erpnext.accounts.services.exchange_gain_loss import gain_loss_journal_already_booked
+
+ return gain_loss_journal_already_booked(
+ gain_loss_account, exc_gain_loss, ref2_dt, ref2_dn, ref2_detail_no
+ )
def make_exchange_gain_loss_journal(
self, args: dict | None = None, dimensions_dict: dict | None = None
) -> None:
- """
- Make Exchange Gain/Loss journal for Invoices and Payments
- """
- # Cancelling existing exchange gain/loss journals is handled during the `on_cancel` event.
- # see accounts/utils.py:cancel_exchange_gain_loss_journal()
- if self.docstatus == 1:
- if dimensions_dict is None:
- dimensions_dict = frappe._dict()
- active_dimensions = get_dimensions()[0]
- for dim in active_dimensions:
- dimensions_dict[dim.fieldname] = self.get(dim.fieldname)
+ from erpnext.accounts.services.exchange_gain_loss import make_exchange_gain_loss_journal
- if self.get("doctype") == "Journal Entry":
- # 'args' is populated with exchange gain/loss account and the amount to be booked.
- # These are generated by Sales/Purchase Invoice during reconciliation and advance allocation.
- # and below logic is only for such scenarios
- if args:
- precision = get_currency_precision()
- for arg in args:
- # Advance section uses `exchange_gain_loss` and reconciliation uses `difference_amount`
- if (
- flt(arg.get("difference_amount", 0), precision) != 0
- or flt(arg.get("exchange_gain_loss", 0), precision) != 0
- ) and arg.get("difference_account"):
- party_account = arg.get("account")
- gain_loss_account = arg.get("difference_account")
- difference_amount = arg.get("difference_amount") or arg.get("exchange_gain_loss")
- if difference_amount > 0:
- dr_or_cr = "debit" if arg.get("party_type") == "Customer" else "credit"
- else:
- dr_or_cr = "credit" if arg.get("party_type") == "Customer" else "debit"
-
- reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
-
- if not self.gain_loss_journal_already_booked(
- gain_loss_account,
- difference_amount,
- self.doctype,
- self.name,
- arg.get("referenced_row"),
- ):
- posting_date = arg.get("difference_posting_date") or frappe.db.get_value(
- arg.voucher_type, arg.voucher_no, "posting_date"
- )
- je = create_gain_loss_journal(
- self.company,
- posting_date,
- arg.get("party_type"),
- arg.get("party"),
- party_account,
- gain_loss_account,
- difference_amount,
- dr_or_cr,
- reverse_dr_or_cr,
- arg.get("against_voucher_type"),
- arg.get("against_voucher"),
- arg.get("idx"),
- self.doctype,
- self.name,
- arg.get("referenced_row"),
- arg.get("cost_center"),
- dimensions_dict,
- arg.get("project"),
- )
- frappe.msgprint(
- _("Exchange Gain/Loss amount has been booked through {0}").format(
- get_link_to_form("Journal Entry", je)
- )
- )
-
- if self.get("doctype") == "Payment Entry":
- # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation
- gain_loss_to_book = [x for x in self.references if x.exchange_gain_loss != 0]
- booked = []
- if gain_loss_to_book:
- [x.reference_doctype for x in gain_loss_to_book]
- [x.reference_name for x in gain_loss_to_book]
- je = qb.DocType("Journal Entry")
- jea = qb.DocType("Journal Entry Account")
- parents = (
- qb.from_(jea)
- .select(jea.parent)
- .where(
- (jea.reference_type == "Payment Entry")
- & (jea.reference_name == self.name)
- & (jea.docstatus == 1)
- )
- .run()
- )
-
- booked = []
- if parents:
- booked = (
- qb.from_(je)
- .inner_join(jea)
- .on(je.name == jea.parent)
- .select(jea.reference_type, jea.reference_name, jea.reference_detail_no)
- .where(
- (je.docstatus == 1)
- & (je.name.isin(parents))
- & (je.voucher_type == "Exchange Gain or Loss")
- )
- .run()
- )
-
- for d in gain_loss_to_book:
- # Filter out References for which Gain/Loss is already booked
- if d.exchange_gain_loss and (
- (d.reference_doctype, d.reference_name, str(d.idx)) not in booked
- ):
- if self.book_advance_payments_in_separate_party_account:
- party_account = d.account
- else:
- if self.payment_type == "Receive":
- party_account = self.paid_from
- elif self.payment_type == "Pay":
- party_account = self.paid_to
-
- dr_or_cr = "debit" if d.exchange_gain_loss > 0 else "credit"
-
- # Inverse debit/credit for payable accounts
- if self.is_payable_account(d.reference_doctype, party_account):
- dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
-
- reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit"
-
- gain_loss_account = frappe.get_cached_value(
- "Company", self.company, "exchange_gain_loss_account"
- )
- je = create_gain_loss_journal(
- self.company,
- args.get("difference_posting_date") if args else self.posting_date,
- self.party_type,
- self.party,
- party_account,
- gain_loss_account,
- d.exchange_gain_loss,
- dr_or_cr,
- reverse_dr_or_cr,
- d.reference_doctype,
- d.reference_name,
- d.idx,
- self.doctype,
- self.name,
- d.idx,
- self.cost_center,
- dimensions_dict,
- self.project,
- )
- frappe.msgprint(
- _("Exchange Gain/Loss amount has been booked through {0}").format(
- get_link_to_form("Journal Entry", je)
- )
- )
+ make_exchange_gain_loss_journal(self, args, dimensions_dict)
def is_payable_account(self, reference_doctype, account):
- if reference_doctype == "Purchase Invoice" or (
- reference_doctype == "Journal Entry"
- and frappe.get_cached_value("Account", account, "account_type") == "Payable"
- ):
- return True
- return False
+ from erpnext.accounts.services.exchange_gain_loss import is_payable_account
+
+ return is_payable_account(reference_doctype, account)
def update_against_document_in_jv(self):
"""
@@ -2016,271 +1207,6 @@ class AccountsController(TransactionBase):
frappe.msgprint(_("Purchase Orders {0} are un-linked").format("\n".join(linked_po)))
- def get_tax_map(self):
- tax_map = {}
- for tax in self.get("taxes"):
- tax_map.setdefault(tax.account_head, 0.0)
- tax_map[tax.account_head] += tax.tax_amount
-
- return tax_map
-
- def get_amount_and_base_amount(self, item, enable_discount_accounting):
- amount = item.net_amount
- base_amount = item.base_net_amount
-
- if (
- enable_discount_accounting
- and self.get("discount_amount")
- and self.get("additional_discount_account")
- ):
- # cases where distributed_discount_amount is not patched
- if not hasattr(self, "__has_distributed_discount_set"):
- self.__has_distributed_discount_set = any(
- i.distributed_discount_amount for i in self.get("items")
- )
-
- if not self.__has_distributed_discount_set:
- return item.amount, item.base_amount
-
- amount += item.distributed_discount_amount
- base_amount += flt(
- item.distributed_discount_amount * self.get("conversion_rate"),
- item.precision("distributed_discount_amount"),
- )
-
- return amount, base_amount
-
- def get_tax_amounts(self, tax, enable_discount_accounting):
- amount = tax.tax_amount_after_discount_amount
- base_amount = tax.base_tax_amount_after_discount_amount
-
- if (
- enable_discount_accounting
- and self.get("discount_amount")
- and self.get("additional_discount_account")
- and self.get("apply_discount_on") == "Grand Total"
- ):
- amount = tax.tax_amount
- base_amount = tax.base_tax_amount
-
- return amount, base_amount
-
- def make_discount_gl_entries(self, gl_entries):
- enable_discount_accounting = cint(
- frappe.get_single_value("Selling Settings", "enable_discount_accounting")
- )
-
- if enable_discount_accounting:
- for item in self.get("items"):
- if item.get("discount_amount") and item.get("discount_account"):
- discount_amount = item.discount_amount * item.qty
- income_account = (
- item.income_account
- if (not item.enable_deferred_revenue or self.is_return)
- else item.deferred_revenue_account
- )
-
- account_currency = get_account_currency(item.discount_account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item.discount_account,
- "against": self.customer,
- "debit": flt(
- discount_amount * self.get("conversion_rate"),
- item.precision("discount_amount"),
- ),
- "debit_in_transaction_currency": flt(
- discount_amount, item.precision("discount_amount")
- ),
- "cost_center": item.cost_center,
- "project": item.project,
- },
- account_currency,
- item=item,
- )
- )
-
- account_currency = get_account_currency(income_account)
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": income_account,
- "against": self.customer,
- "credit": flt(
- discount_amount * self.get("conversion_rate"),
- item.precision("discount_amount"),
- ),
- "credit_in_transaction_currency": flt(
- discount_amount, item.precision("discount_amount")
- ),
- "cost_center": item.cost_center,
- "project": item.project or self.project,
- },
- account_currency,
- item=item,
- )
- )
-
- if (
- (enable_discount_accounting or self.get("is_cash_or_non_trade_discount"))
- and self.get("additional_discount_account")
- and self.get("discount_amount")
- ):
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": self.additional_discount_account,
- "against": self.customer,
- "debit": self.base_discount_amount,
- "cost_center": self.cost_center or erpnext.get_default_cost_center(self.company),
- },
- item=self,
- )
- )
-
- def validate_multiple_billing(self, ref_dt, item_ref_dn, based_on):
- from erpnext.controllers.status_updater import get_allowance_for
-
- ref_wise_billed_amount = self.get_reference_wise_billed_amt(ref_dt, item_ref_dn, based_on)
-
- if not ref_wise_billed_amount:
- return
-
- total_overbilled_amt = 0.0
- overbilled_items = []
- precision = self.precision(based_on, "items")
- precision_allowance = 1 / (10**precision)
-
- role_allowed_to_overbill = frappe.get_single_value("Accounts Settings", "role_allowed_to_over_bill")
- is_overbilling_allowed = role_allowed_to_overbill in frappe.get_roles()
-
- for row in ref_wise_billed_amount.values():
- total_billed_amt = row.billed_amt
- allowance = get_allowance_for(row.item_code, {}, None, None, "amount")[0]
-
- max_allowed_amt = flt(row.ref_amt * (100 + allowance) / 100)
-
- if total_billed_amt < 0 and max_allowed_amt < 0:
- # while making debit note against purchase return entry(purchase receipt) getting overbill error
- total_billed_amt, max_allowed_amt = abs(total_billed_amt), abs(max_allowed_amt)
-
- overbill_amt = total_billed_amt - max_allowed_amt
- row["max_allowed_amt"] = max_allowed_amt
- total_overbilled_amt += overbill_amt
-
- if overbill_amt > precision_allowance and not is_overbilling_allowed:
- if self.doctype != "Purchase Invoice" or not cint(
- frappe.db.get_single_value(
- "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
- )
- ):
- overbilled_items.append(row)
-
- if overbilled_items:
- self.throw_overbill_exception(overbilled_items, precision)
-
- if is_overbilling_allowed and total_overbilled_amt > 0.1:
- frappe.msgprint(
- _("Overbilling of {} ignored because you have {} role.").format(
- total_overbilled_amt, role_allowed_to_overbill
- ),
- indicator="orange",
- alert=True,
- )
-
- def get_billing_reference_details(self, reference_names, reference_doctype, based_on):
- return frappe._dict(
- frappe.get_all(
- reference_doctype,
- filters={"name": ("in", reference_names)},
- fields=["name", based_on],
- as_list=1,
- )
- )
-
- def get_reference_wise_billed_amt(self, ref_dt, item_ref_dn, based_on):
- """
- Returns Sum of Amount of
- Sales/Purchase Invoice Items
- that are linked to `item_ref_dn` (`dn_detail` / `pr_detail`)
- that are submitted OR not submitted but are under current invoice
- """
- reference_names = [d.get(item_ref_dn) for d in self.items if d.get(item_ref_dn)]
-
- if not reference_names:
- return
-
- ref_wise_billed_amount = {}
- precision = self.precision(based_on, "items")
- reference_details = self.get_billing_reference_details(reference_names, ref_dt + " Item", based_on)
- already_billed = self.get_already_billed_amount(reference_names, item_ref_dn, based_on)
-
- for item in self.items:
- key = item.get(item_ref_dn)
- if not key:
- continue
-
- ref_amt = flt(reference_details.get(key), precision)
- current_amount = flt(item.get(based_on), precision)
-
- if not ref_amt:
- if current_amount: # Skip warning for free items
- frappe.msgprint(
- _(
- "System will not check over billing since amount for Item {0} in {1} is zero"
- ).format(item.item_code, ref_dt),
- title=_("Warning"),
- indicator="orange",
- )
- continue
-
- ref_wise_billed_amount.setdefault(
- key,
- frappe._dict(item_code=item.item_code, billed_amt=0.0, ref_amt=ref_amt, rows=[]),
- )
-
- ref_wise_billed_amount[key]["rows"].append(item.idx)
- ref_wise_billed_amount[key]["ref_amt"] = ref_amt
- ref_wise_billed_amount[key]["billed_amt"] += current_amount
- if key in already_billed:
- ref_wise_billed_amount[key]["billed_amt"] += flt(already_billed.pop(key, 0), precision)
-
- return ref_wise_billed_amount
-
- def get_already_billed_amount(self, reference_names, item_ref_dn, based_on):
- item_doctype = frappe.qb.DocType(self.items[0].doctype)
- based_on_field = frappe.qb.Field(based_on)
- join_field = frappe.qb.Field(item_ref_dn)
-
- return frappe._dict(
- (
- frappe.qb.from_(item_doctype)
- .select(join_field, Sum(based_on_field))
- .where(join_field.isin(reference_names))
- .where((item_doctype.docstatus == 1) & (item_doctype.parent != self.name))
- .groupby(join_field)
- ).run()
- )
-
- def throw_overbill_exception(self, overbilled_items, precision):
- message = (
- _("Cannot overbill for the following Items:
")
- + ""
- + "".join(
- _("- Item {0} in row(s) {1} billed more than {2}
").format(
- frappe.bold(item.item_code),
- ", ".join(str(x) for x in item.rows),
- frappe.bold(fmt_money(item.max_allowed_amt, precision=precision, currency=self.currency)),
- )
- for item in overbilled_items
- )
- + "
"
- )
- message += _("To allow over-billing, please set allowance in Accounts Settings.
")
-
- frappe.throw(_(message))
-
def get_company_default(self, fieldname, ignore_validation=False):
from erpnext.accounts.utils import get_company_default
@@ -2307,62 +1233,19 @@ class AccountsController(TransactionBase):
return asset_items
def calculate_total_advance_from_ledger(self):
- adv = frappe.qb.DocType("Advance Payment Ledger Entry")
- return (
- qb.from_(adv)
- .select(Abs(Sum(adv.amount)).as_("amount"), adv.currency.as_("account_currency"))
- .where(adv.company == self.company)
- .where(adv.delinked == 0)
- .where(adv.against_voucher_type == self.doctype)
- .where(adv.against_voucher_no == self.name)
- .run(as_dict=True)
- )
+ from erpnext.accounts.services.advances import calculate_total_advance_from_ledger
+
+ return calculate_total_advance_from_ledger(self)
def set_total_advance_paid(self):
- advance = self.calculate_total_advance_from_ledger()
- advance_paid = 0
+ from erpnext.accounts.services.advances import set_total_advance_paid
- if advance:
- advance = advance[0]
-
- advance_paid = flt(advance.amount, self.precision("advance_paid"))
- if advance.account_currency:
- frappe.db.set_value(
- self.doctype, self.name, "party_account_currency", advance.account_currency
- )
-
- self.db_set("advance_paid", advance_paid)
- self.set_advance_payment_status()
+ set_total_advance_paid(self)
def set_advance_payment_status(self):
- new_status = None
+ from erpnext.accounts.services.advances import set_advance_payment_status
- PaymentRequest = frappe.qb.DocType("Payment Request")
- paid_amount = frappe.get_value(
- doctype="Payment Request",
- filters={
- "reference_doctype": self.doctype,
- "reference_name": self.name,
- "docstatus": 1,
- },
- fieldname=Sum(PaymentRequest.grand_total - PaymentRequest.outstanding_amount),
- )
-
- if not paid_amount:
- if self.doctype in self.get_advance_payment_doctypes(payment_type="receivable"):
- new_status = "Not Requested" if paid_amount is None else "Requested"
- elif self.doctype in self.get_advance_payment_doctypes(payment_type="payable"):
- new_status = "Not Initiated" if paid_amount is None else "Initiated"
- else:
- total_amount = self.get("rounded_total") or self.get("grand_total")
- new_status = "Fully Paid" if paid_amount == total_amount else "Partially Paid"
-
- if new_status == self.advance_payment_status:
- return
-
- self.db_set("advance_payment_status", new_status, update_modified=False)
- self.set_status(update=True)
- self.notify_update()
+ set_advance_payment_status(self)
@property
def company_abbr(self):
@@ -2396,97 +1279,15 @@ class AccountsController(TransactionBase):
frappe.throw(message, title=_("Account Missing"), exc=AccountMissingError)
- def validate_party(self):
- party_type, party = self.get_party()
- validate_party_frozen_disabled(self.company, party_type, party)
+ def get_party(self) -> tuple[str | None, str | None]:
+ from erpnext.accounts.services.party_validation import PartyValidator
- def get_party(self):
- party_type = None
- if self.doctype in ("Opportunity", "Quotation", "Sales Order", "Delivery Note", "Sales Invoice"):
- party_type = "Customer"
-
- elif self.doctype in (
- "Supplier Quotation",
- "Purchase Order",
- "Purchase Receipt",
- "Purchase Invoice",
- ):
- party_type = "Supplier"
-
- elif self.meta.get_field("customer"):
- party_type = "Customer"
-
- elif self.meta.get_field("supplier"):
- party_type = "Supplier"
-
- party = self.get(party_type.lower()) if party_type else None
-
- return party_type, party
-
- def validate_currency(self):
- if self.get("currency"):
- party_type, party = self.get_party()
- if party_type and party:
- party_account_currency = get_party_account_currency(party_type, party, self.company)
-
- if (
- party_account_currency
- and party_account_currency != self.company_currency
- and self.currency != party_account_currency
- ):
- frappe.throw(
- _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format(
- party_type, party, party_account_currency
- ),
- InvalidCurrency,
- )
-
- # Note: not validating with gle account because we don't have the account
- # at quotation / sales order level and we shouldn't stop someone
- # from creating a sales invoice if sales order is already created
-
- def validate_party_account_currency(self):
- if self.doctype not in ("Sales Invoice", "Purchase Invoice"):
- return
-
- if self.is_opening == "Yes":
- return
-
- party_type, party = self.get_party()
- party_gle_currency = get_party_gle_currency(party_type, party, self.company)
- party_account = self.get("debit_to") if self.doctype == "Sales Invoice" else self.get("credit_to")
- party_account_currency = get_account_currency(party_account)
- allow_multi_currency_invoices_against_single_party_account = frappe.db.get_singles_value(
- "Accounts Settings", "allow_multi_currency_invoices_against_single_party_account"
- )
-
- if (
- not party_gle_currency
- and (party_account_currency != self.currency)
- and not allow_multi_currency_invoices_against_single_party_account
- ):
- frappe.throw(
- _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format(
- frappe.bold(party_account), party_account_currency, self.currency
- )
- )
+ return PartyValidator(self).get_party()
def delink_advance_entries(self, linked_doc_name):
- total_allocated_amount = 0
- for adv in self.advances:
- consider_for_total_advance = True
- if adv.reference_name == linked_doc_name:
- doctype = frappe.qb.DocType(self.doctype + " Advance")
- frappe.qb.from_(doctype).delete().where(doctype.name == adv.name).run()
+ from erpnext.accounts.services.advances import delink_advance_entries
- consider_for_total_advance = False
-
- if consider_for_total_advance:
- total_allocated_amount += flt(adv.allocated_amount, adv.precision("allocated_amount"))
-
- frappe.db.set_value(
- self.doctype, self.name, "total_advance", total_allocated_amount, update_modified=False
- )
+ delink_advance_entries(self, linked_doc_name)
def group_similar_items(self):
grouped_items = {}
@@ -2522,447 +1323,31 @@ class AccountsController(TransactionBase):
for item in duplicate_list:
self.remove(item)
- def set_payment_schedule(self):
- if (self.doctype == "Sales Invoice" and self.is_pos) or self.get("is_opening") == "Yes":
- self.payment_terms_template = ""
- return
-
- party_account_currency = self.get("party_account_currency")
- if not party_account_currency:
- party_type, party = self.get_party()
-
- if party_type and party:
- party_account_currency = get_party_account_currency(party_type, party, self.company)
-
- posting_date = self.get("bill_date") or self.get("posting_date") or self.get("transaction_date")
- date = self.get("due_date")
- due_date = date or posting_date
-
- base_grand_total = flt(self.get("base_rounded_total") or self.base_grand_total)
- grand_total = flt(self.get("rounded_total") or self.grand_total)
- automatically_fetch_payment_terms = 0
-
- if self.doctype in ("Sales Invoice", "Purchase Invoice", "Sales Order"):
- po_or_so, doctype, fieldname = self.get_order_details()
- automatically_fetch_payment_terms = cint(
- frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
- )
- if self.doctype != "Sales Order":
- base_grand_total = base_grand_total - flt(self.base_write_off_amount)
- grand_total = grand_total - flt(self.write_off_amount)
-
- if self.get("total_advance"):
- if party_account_currency == self.company_currency:
- base_grand_total -= self.get("total_advance")
- grand_total = flt(
- base_grand_total / self.get("conversion_rate"), self.precision("grand_total")
- )
- else:
- grand_total -= self.get("total_advance")
- base_grand_total = flt(
- grand_total * self.get("conversion_rate"), self.precision("base_grand_total")
- )
-
- if not self.get("payment_schedule"):
- if (
- self.doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order"]
- and automatically_fetch_payment_terms
- and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
- ):
- self.fetch_payment_terms_from_order(
- po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
- )
- if self.get("payment_terms_template"):
- self.ignore_default_payment_terms_template = 1
- elif self.get("payment_terms_template"):
- data = get_payment_terms(
- self.payment_terms_template, posting_date, grand_total, base_grand_total
- )
- for item in data:
- self.append("payment_schedule", item)
- elif self.doctype not in ["Purchase Receipt"]:
- data = dict(
- due_date=due_date,
- invoice_portion=100,
- payment_amount=grand_total,
- base_payment_amount=base_grand_total,
- )
- self.append("payment_schedule", data)
-
- allocate_payment_based_on_payment_terms = frappe.db.get_value(
- "Payment Terms Template", self.payment_terms_template, "allocate_payment_based_on_payment_terms"
- )
-
- if not (
- automatically_fetch_payment_terms
- and allocate_payment_based_on_payment_terms
- and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
- ):
- for d in self.get("payment_schedule"):
- if d.invoice_portion:
- d.payment_amount = flt(
- grand_total * flt(d.invoice_portion) / 100, d.precision("payment_amount")
- )
- d.base_payment_amount = flt(
- base_grand_total * flt(d.invoice_portion) / 100, d.precision("base_payment_amount")
- )
- d.outstanding = d.payment_amount
- d.base_outstanding = d.base_payment_amount
- elif not d.invoice_portion:
- d.base_payment_amount = flt(
- d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount")
- )
- d.base_outstanding = d.base_payment_amount
- else:
- self.fetch_payment_terms_from_order(
- po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
- )
- self.ignore_default_payment_terms_template = 1
-
- def get_order_details(self):
- if not self.get("items"):
- return None, None, None
- if self.doctype == "Sales Invoice":
- prev_doc = self.get("items")[0].get("sales_order")
- prev_doctype = "Sales Order"
- prev_doctype_name = "sales_order"
- elif self.doctype == "Purchase Invoice":
- prev_doc = self.get("items")[0].get("purchase_order")
- prev_doctype = "Purchase Order"
- prev_doctype_name = "purchase_order"
- else:
- prev_doc = self.get("items")[0].get("prevdoc_docname")
- prev_doctype = "Quotation"
- prev_doctype_name = "prevdoc_docname"
- return prev_doc, prev_doctype, prev_doctype_name
-
- def linked_order_has_payment_terms(self, po_or_so, fieldname, doctype):
- if po_or_so and self.all_items_have_same_po_or_so(po_or_so, fieldname):
- if self.linked_order_has_payment_terms_template(po_or_so, doctype):
- return True
- elif self.linked_order_has_payment_schedule(po_or_so):
- return True
-
- return False
-
- def all_items_have_same_po_or_so(self, po_or_so, fieldname):
- for item in self.get("items"):
- if item.get(fieldname) != po_or_so:
- return False
-
- return True
-
- def linked_order_has_payment_terms_template(self, po_or_so, doctype):
- return frappe.get_value(doctype, po_or_so, "payment_terms_template")
-
- def linked_order_has_payment_schedule(self, po_or_so):
- return frappe.get_all("Payment Schedule", filters={"parent": po_or_so})
-
- def fetch_payment_terms_from_order(
- self, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
- ):
- """
- Fetch Payment Terms from Purchase/Sales Order on creating a new Purchase/Sales Invoice.
- """
- po_or_so = frappe.get_cached_doc(po_or_so_doctype, po_or_so)
-
- self.payment_schedule = []
- self.payment_terms_template = po_or_so.payment_terms_template
- posting_date = self.get("bill_date") or self.get("posting_date") or self.get("transaction_date")
-
- for schedule in po_or_so.payment_schedule:
- payment_schedule = {
- "payment_term": schedule.payment_term,
- "due_date": schedule.due_date,
- "invoice_portion": schedule.invoice_portion,
- "mode_of_payment": schedule.mode_of_payment,
- "description": schedule.description,
- "paid_amount": schedule.paid_amount,
- }
-
- if automatically_fetch_payment_terms:
- if schedule.due_date_based_on:
- payment_schedule["due_date"] = get_due_date(schedule, posting_date)
- payment_schedule["due_date_based_on"] = schedule.due_date_based_on
- payment_schedule["credit_days"] = cint(schedule.credit_days)
- payment_schedule["credit_months"] = cint(schedule.credit_months)
-
- if schedule.discount_validity_based_on and flt(schedule.discount):
- payment_schedule["discount_date"] = get_discount_date(schedule, posting_date)
- payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on
- payment_schedule["discount_validity"] = cint(schedule.discount_validity)
-
- payment_schedule["payment_amount"] = flt(
- grand_total * flt(payment_schedule["invoice_portion"]) / 100,
- schedule.precision("payment_amount"),
- )
- payment_schedule["base_payment_amount"] = flt(
- base_grand_total * flt(payment_schedule["invoice_portion"]) / 100,
- schedule.precision("base_payment_amount"),
- )
- payment_schedule["outstanding"] = payment_schedule["payment_amount"]
- else:
- payment_schedule["base_payment_amount"] = flt(
- schedule.base_payment_amount * self.get("conversion_rate"),
- schedule.precision("base_payment_amount"),
- )
-
- if schedule.discount_type == "Percentage":
- payment_schedule["discount_type"] = schedule.discount_type
- payment_schedule["discount"] = schedule.discount
-
- if not schedule.invoice_portion:
- payment_schedule["payment_amount"] = schedule.payment_amount
-
- self.append("payment_schedule", payment_schedule)
-
- def set_due_date(self):
- due_dates = [d.due_date for d in self.get("payment_schedule") if d.due_date]
- if due_dates:
- self.due_date = max(due_dates)
-
- def validate_payment_schedule_dates(self):
- dates = []
- li = []
-
- if self.doctype == "Sales Invoice" and self.is_pos:
- return
-
- for d in self.get("payment_schedule"):
- if not flt(d.discount):
- d.discount_date = None
- d.validate_from_to_dates("discount_date", "due_date")
- if self.doctype in ["Sales Order", "Quotation"] and getdate(d.due_date) < getdate(
- self.transaction_date
- ):
- frappe.throw(
- _("Row {0}: Due Date in the Payment Terms table cannot be before Posting Date").format(
- d.idx
- )
- )
- elif d.due_date in dates:
- li.append(_("{0} in row {1}").format(d.due_date, d.idx))
- dates.append(d.due_date)
-
- if li:
- duplicates = "
" + "
".join(li)
- frappe.throw(
- _("Rows with duplicate due dates in other rows were found: {0}").format(duplicates),
- title=_("Payment Schedule"),
- )
-
- def validate_payment_schedule_amount(self):
- if (self.doctype == "Sales Invoice" and self.is_pos) or self.get("is_opening") == "Yes":
- return
-
- party_account_currency = self.get("party_account_currency")
- if not party_account_currency:
- party_type, party = self.get_party()
-
- if party_type and party:
- party_account_currency = get_party_account_currency(party_type, party, self.company)
-
- if self.get("payment_schedule"):
- total = 0
- base_total = 0
- for d in self.get("payment_schedule"):
- total += flt(d.payment_amount, d.precision("payment_amount"))
- base_total += flt(d.base_payment_amount, d.precision("base_payment_amount"))
-
- base_grand_total = flt(self.get("base_rounded_total") or self.base_grand_total)
- grand_total = flt(self.get("rounded_total") or self.grand_total)
-
- if self.doctype in ("Sales Invoice", "Purchase Invoice"):
- base_grand_total = base_grand_total - flt(self.base_write_off_amount)
- grand_total = grand_total - flt(self.write_off_amount)
-
- if self.get("total_advance"):
- if party_account_currency == self.company_currency:
- base_grand_total -= self.get("total_advance")
- grand_total = flt(
- base_grand_total / self.get("conversion_rate"), self.precision("grand_total")
- )
- else:
- grand_total -= self.get("total_advance")
- base_grand_total = flt(
- grand_total * self.get("conversion_rate"), self.precision("base_grand_total")
- )
-
- if (
- abs(
- flt(total, self.precision("grand_total"))
- - flt(grand_total, self.precision("grand_total"))
- )
- > 0.1
- or abs(
- flt(base_total, self.precision("base_grand_total"))
- - flt(base_grand_total, self.precision("base_grand_total"))
- )
- > 0.1
- ):
- frappe.throw(
- _("Total Payment Amount in Payment Schedule must be equal to Grand / Rounded Total")
- )
-
def is_rounded_total_disabled(self):
if self.meta.get_field("disable_rounded_total"):
return self.disable_rounded_total
else:
return frappe.db.get_single_value("Global Defaults", "disable_rounded_total")
- def set_inter_company_account(self):
- """
- Set intercompany account for inter warehouse transactions
- This account will be used in case billing company and internal customer's
- representation company is same
- """
+ def is_internal_transfer(self) -> bool:
+ from erpnext.accounts.services.internal_transfer import InternalTransferService
- if self.is_internal_transfer() and not self.unrealized_profit_loss_account:
- unrealized_profit_loss_account = frappe.get_cached_value(
- "Company", self.company, "unrealized_profit_loss_account"
- )
+ return InternalTransferService(self).is_internal_transfer()
- if not unrealized_profit_loss_account:
- msg = _(
- "Please select Unrealized Profit / Loss account or add default Unrealized Profit / Loss account account for company {0}"
- ).format(frappe.bold(self.company))
- frappe.throw(msg)
+ def process_common_party_accounting(self) -> None:
+ from erpnext.accounts.services.internal_transfer import InternalTransferService
- self.unrealized_profit_loss_account = unrealized_profit_loss_account
+ InternalTransferService(self).process_common_party_accounting()
- def is_internal_transfer(self):
- """
- It will an internal transfer if its an internal customer and representation
- company is same as billing company
- """
- if self.doctype in ("Sales Invoice", "Delivery Note", "Sales Order"):
- internal_party_field = "is_internal_customer"
- elif self.doctype in ("Purchase Invoice", "Purchase Receipt", "Purchase Order"):
- internal_party_field = "is_internal_supplier"
- else:
- return False
+ def get_common_party_link(self) -> frappe._dict | None:
+ from erpnext.accounts.services.internal_transfer import InternalTransferService
- if self.get(internal_party_field) and (self.represents_company == self.company):
- return True
-
- return False
-
- def process_common_party_accounting(self):
- is_invoice = self.doctype in ["Sales Invoice", "Purchase Invoice"]
- if not is_invoice:
- return
-
- if frappe.get_single_value("Accounts Settings", "enable_common_party_accounting"):
- party_link = self.get_common_party_link()
- if party_link and self.outstanding_amount:
- self.create_advance_and_reconcile(party_link)
-
- def get_common_party_link(self):
- party_type, party = self.get_party()
- return frappe.db.get_value(
- doctype="Party Link",
- filters={"secondary_role": party_type, "secondary_party": party},
- fieldname=["primary_role", "primary_party"],
- as_dict=True,
- )
+ return InternalTransferService(self).get_common_party_link()
def create_advance_and_reconcile(self, party_link):
- secondary_party_type, secondary_party = self.get_party()
- primary_party_type, primary_party = party_link.primary_role, party_link.primary_party
+ from erpnext.accounts.services.advances import create_advance_and_reconcile
- primary_account = get_party_account(primary_party_type, primary_party, self.company)
- secondary_account = get_party_account(secondary_party_type, secondary_party, self.company)
- primary_account_currency = get_account_currency(primary_account)
- secondary_account_currency = get_account_currency(secondary_account)
- default_currency = erpnext.get_company_currency(self.company)
-
- # Determine if multi-currency journal entry is needed
- multi_currency = (
- primary_account_currency != default_currency or secondary_account_currency != default_currency
- )
-
- jv = frappe.new_doc("Journal Entry")
- jv.voucher_type = "Journal Entry"
- jv.posting_date = self.posting_date
- jv.company = self.company
- jv.remark = f"Adjustment for {self.doctype} {self.name}"
- jv.is_system_generated = True
-
- reconcilation_entry = frappe._dict()
- advance_entry = frappe._dict()
-
- reconcilation_entry.account = secondary_account
- reconcilation_entry.party_type = secondary_party_type
- reconcilation_entry.party = secondary_party
- reconcilation_entry.reference_type = self.doctype
- reconcilation_entry.reference_name = self.name
- reconcilation_entry.cost_center = self.cost_center or erpnext.get_default_cost_center(self.company)
-
- advance_entry.account = primary_account
- advance_entry.party_type = primary_party_type
- advance_entry.party = primary_party
- advance_entry.cost_center = self.cost_center or erpnext.get_default_cost_center(self.company)
- # For returns the direction is reversed, so this entry cannot be an advance
- # (JE validation: Supplier advance must be debit, Customer advance must be credit)
- advance_entry.is_advance = "No" if self.is_return else "Yes"
-
- # Update dimensions
- dimensions_dict = frappe._dict()
- active_dimensions = get_dimensions()[0]
- for dim in active_dimensions:
- dimensions_dict[dim.fieldname] = self.get(dim.fieldname)
-
- reconcilation_entry.update(dimensions_dict)
- advance_entry.update(dimensions_dict)
-
- # Calculate exchange rates if necessary
- if multi_currency:
- # Exchange rates for primary and secondary accounts
- exc_rate_primary_to_default = (
- 1
- if primary_account_currency == default_currency
- else get_exchange_rate(primary_account_currency, default_currency, self.posting_date)
- )
- exc_rate_secondary_to_default = (
- 1
- if secondary_account_currency == default_currency
- else get_exchange_rate(secondary_account_currency, default_currency, self.posting_date)
- )
- exc_rate_secondary_to_primary = (
- 1
- if secondary_account_currency == primary_account_currency
- else get_exchange_rate(
- secondary_account_currency, primary_account_currency, self.posting_date
- )
- )
-
- outstanding_amount = abs(self.outstanding_amount)
- os_in_default_currency = outstanding_amount * exc_rate_secondary_to_default
- os_in_primary_currency = outstanding_amount * exc_rate_secondary_to_primary
-
- # SI normal and PI return → reconciliation is credit; SI return and PI normal → debit
- reconciliation_is_credit = (self.doctype == "Sales Invoice") != bool(self.is_return)
- _set_je_amounts(
- reconcilation_entry, outstanding_amount, os_in_default_currency, reconciliation_is_credit
- )
- _set_je_amounts(
- advance_entry, os_in_primary_currency, os_in_default_currency, not reconciliation_is_credit
- )
-
- reconcilation_entry.exchange_rate = exc_rate_secondary_to_default
- advance_entry.exchange_rate = exc_rate_primary_to_default
- else:
- outstanding_amount = abs(self.outstanding_amount)
- reconciliation_is_credit = (self.doctype == "Sales Invoice") != bool(self.is_return)
- _set_je_amounts(reconcilation_entry, outstanding_amount, is_credit=reconciliation_is_credit)
- _set_je_amounts(advance_entry, outstanding_amount, is_credit=not reconciliation_is_credit)
-
- jv.multi_currency = multi_currency
- jv.append("accounts", reconcilation_entry)
- jv.append("accounts", advance_entry)
-
- jv.save()
- jv.submit()
+ create_advance_and_reconcile(self, party_link)
def check_conversion_rate(self):
default_currency = erpnext.get_company_currency(self.company)
@@ -2992,17 +1377,16 @@ class AccountsController(TransactionBase):
)
def check_if_fields_updated(self, fields_to_check, child_tables):
- # Check if any field affecting accounting entry is altered
+ from erpnext.accounts.services.child_item_update import check_if_child_table_updated
+
doc_before_update = self.get_doc_before_save()
accounting_dimensions = [*get_accounting_dimensions(), "cost_center", "project"]
- # Parent Level Accounts excluding party account
fields_to_check += accounting_dimensions
for field in fields_to_check:
if doc_before_update.get(field) != self.get(field):
return True
- # Check for child tables
for table in child_tables:
if check_if_child_table_updated(
doc_before_update.get(table), self.get(table), child_tables[table]
@@ -3023,10 +1407,10 @@ class AccountsController(TransactionBase):
def get_advance_payment_doctypes(self, payment_type=None) -> list:
return _get_advance_payment_doctypes(payment_type=payment_type)
- def set_transaction_currency_and_rate_in_gl_map(self, gl_entries):
- for x in gl_entries:
- x["transaction_currency"] = self.currency
- x["transaction_exchange_rate"] = self.get("conversion_rate") or 1
+ def set_transaction_currency_and_rate_in_gl_map(self, gl_entries: list) -> None:
+ from erpnext.accounts.services.exchange_gain_loss import set_transaction_currency_and_rate_in_gl_map
+
+ set_transaction_currency_and_rate_in_gl_map(self, gl_entries)
def after_mapping(self, source_doc):
self.set_discount_amount_after_mapping(source_doc)
@@ -3127,384 +1511,26 @@ class AccountsController(TransactionBase):
self.calculate_taxes_and_totals()
-@frappe.whitelist()
-def get_tax_rate(account_head: str):
- return frappe.get_cached_value("Account", account_head, ["tax_rate", "account_name"], as_dict=True)
-
-
-@frappe.whitelist()
-def get_default_taxes_and_charges(
- master_doctype: str, tax_template: str | None = None, company: str | None = None
-):
- if not company:
- return {}
-
- if tax_template and company:
- tax_template_company = frappe.get_cached_value(master_doctype, tax_template, "company")
- if tax_template_company == company:
- return
-
- default_tax = frappe.db.get_value(master_doctype, {"is_default": 1, "company": company})
-
- return {
- "taxes_and_charges": default_tax,
- "taxes": get_taxes_and_charges(master_doctype, default_tax),
- }
-
-
-@frappe.whitelist()
-def get_taxes_and_charges(master_doctype: str, master_name: str | None = None):
- if not master_name:
- return
- from frappe.model import child_table_fields, default_fields
-
- tax_master = frappe.get_doc(master_doctype, master_name)
-
- taxes_and_charges = []
- for _i, tax in enumerate(tax_master.get("taxes")):
- tax = tax.as_dict()
-
- for fieldname in default_fields + child_table_fields:
- if fieldname in tax:
- del tax[fieldname]
-
- taxes_and_charges.append(tax)
-
- return taxes_and_charges
-
-
-def validate_conversion_rate(currency, conversion_rate, conversion_rate_label, company):
- """common validation for currency and price list currency"""
-
- company_currency = frappe.get_cached_value("Company", company, "default_currency")
-
- if not conversion_rate:
- throw(
- _("{0} is mandatory. Maybe Currency Exchange record is not created for {1} to {2}.").format(
- conversion_rate_label, currency, company_currency
- )
- )
-
-
-def validate_taxes_and_charges(tax):
- if tax.charge_type in ["Actual", "On Net Total", "On Paid Amount"] and tax.row_id:
- frappe.throw(
- _("Can refer row only if the charge type is 'On Previous Row Amount' or 'Previous Row Total'")
- )
- elif tax.charge_type in ["On Previous Row Amount", "On Previous Row Total"]:
- if cint(tax.idx) == 1:
- frappe.throw(
- _(
- "Cannot select charge type as 'On Previous Row Amount' or 'On Previous Row Total' for first row"
- )
- )
- elif not tax.row_id:
- frappe.throw(
- _("Please specify a valid Row ID for row {0} in table {1}").format(tax.idx, _(tax.doctype))
- )
- elif tax.row_id and cint(tax.row_id) >= cint(tax.idx):
- frappe.throw(
- _("Cannot refer row number greater than or equal to current row number for this Charge type")
- )
-
- if tax.charge_type == "Actual":
- tax.rate = None
-
-
-def validate_account_head(idx: int, account: str, company: str, context: str | None = None) -> None:
- """Throw a ValidationError if the account belongs to a different company or is a group account."""
- if company != frappe.get_cached_value("Account", account, "company"):
- frappe.throw(
- _("Row {0}: The {3} Account {1} does not belong to the company {2}").format(
- idx, frappe.bold(account), frappe.bold(company), context or ""
- ),
- title=_("Invalid Account"),
- )
-
- if frappe.get_cached_value("Account", account, "is_group"):
- frappe.throw(
- _(
- "You selected the account group {1} as {2} Account in row {0}. Please select a single account."
- ).format(idx, frappe.bold(account), context or ""),
- title=_("Invalid Account"),
- )
-
-
-def validate_cost_center(tax, doc):
- if not tax.cost_center:
- return
-
- company = frappe.get_cached_value("Cost Center", tax.cost_center, "company")
-
- if company != doc.company:
- frappe.throw(
- _("Row {0}: Cost Center {1} does not belong to Company {2}").format(
- tax.idx, frappe.bold(tax.cost_center), frappe.bold(doc.company)
- ),
- title=_("Invalid Cost Center"),
- )
-
-
-def validate_inclusive_tax(tax, doc):
- def _on_previous_row_error(row_range):
- throw(
- _("To include tax in row {0} in Item rate, taxes in rows {1} must also be included").format(
- tax.idx, row_range
- )
- )
-
- if cint(getattr(tax, "included_in_print_rate", None)):
- if tax.charge_type == "Actual":
- # inclusive tax cannot be of type Actual
- throw(
- _("Charge of type 'Actual' in row {0} cannot be included in Item Rate or Paid Amount").format(
- tax.idx
- )
- )
- elif tax.charge_type == "On Previous Row Amount" and not cint(
- doc.get("taxes")[cint(tax.row_id) - 1].included_in_print_rate
- ):
- # referred row should also be inclusive
- _on_previous_row_error(tax.row_id)
- elif tax.charge_type == "On Previous Row Total" and not all(
- [cint(t.included_in_print_rate) for t in doc.get("taxes")[: cint(tax.row_id) - 1]]
- ):
- # all rows about the referred tax should be inclusive
- _on_previous_row_error("1 - %d" % (tax.row_id,))
- elif tax.get("category") == "Valuation":
- frappe.throw(_("Valuation type charges can not be marked as Inclusive"))
-
-
-def set_balance_in_account_currency(
- gl_dict, account_currency=None, conversion_rate=None, company_currency=None
-):
- if (not conversion_rate) and (account_currency != company_currency):
- frappe.throw(
- _("Account: {0} with currency: {1} can not be selected").format(gl_dict.account, account_currency)
- )
-
- gl_dict["account_currency"] = account_currency
-
- # set debit/credit in account currency if not provided
- if flt(gl_dict.debit) and not flt(gl_dict.debit_in_account_currency):
- gl_dict.debit_in_account_currency = (
- gl_dict.debit if account_currency == company_currency else flt(gl_dict.debit / conversion_rate, 2)
- )
-
- if flt(gl_dict.credit) and not flt(gl_dict.credit_in_account_currency):
- gl_dict.credit_in_account_currency = (
- gl_dict.credit
- if account_currency == company_currency
- else flt(gl_dict.credit / conversion_rate, 2)
- )
-
-
-def get_advance_journal_entries(
- party_type,
- party,
- party_account,
- amount_field,
- order_doctype,
- order_list,
- include_unallocated=True,
-):
- journal_entry = frappe.qb.DocType("Journal Entry")
- journal_acc = frappe.qb.DocType("Journal Entry Account")
- q = (
- frappe.qb.from_(journal_entry)
- .inner_join(journal_acc)
- .on(journal_entry.name == journal_acc.parent)
- .select(
- ConstantColumn("Journal Entry").as_("reference_type"),
- (journal_entry.name).as_("reference_name"),
- (journal_entry.remark).as_("remarks"),
- (journal_acc[amount_field]).as_("amount"),
- (journal_acc.name).as_("reference_row"),
- (journal_acc.reference_name).as_("against_order"),
- (journal_acc.exchange_rate),
- )
- .where(
- journal_acc.account.isin(party_account)
- & (journal_acc.party_type == party_type)
- & (journal_acc.party == party)
- & (journal_acc.is_advance == "Yes")
- & (journal_entry.docstatus == 1)
- )
- )
- if party_type == "Customer":
- q = q.where(journal_acc.credit_in_account_currency > 0)
-
- else:
- q = q.where(journal_acc.debit_in_account_currency > 0)
-
- reference_or_condition = []
-
- if include_unallocated:
- reference_or_condition.append(journal_acc.reference_name.isnull())
- reference_or_condition.append(journal_acc.reference_name == "")
-
- if order_list:
- reference_or_condition.append(
- (journal_acc.reference_type == order_doctype) & ((journal_acc.reference_name).isin(order_list))
- )
-
- if reference_or_condition:
- q = q.where(Criterion.any(reference_or_condition))
-
- q = q.orderby(journal_entry.posting_date)
-
- journal_entries = q.run(as_dict=True)
- return list(journal_entries)
-
-
-@erpnext.allow_regional
-def get_advance_payment_entries_for_regional(*args, **kwargs):
- return get_advance_payment_entries(*args, **kwargs)
-
-
-def get_advance_payment_entries(
- party_type,
- party,
- party_account,
- order_doctype,
- order_list=None,
- default_advance_account=None,
- include_unallocated=True,
- against_all_orders=False,
- limit=None,
- condition=None,
-):
- payment_entries = []
- payment_entry = frappe.qb.DocType("Payment Entry")
-
- if order_list or against_all_orders:
- q = get_common_query(
- party_type,
- party,
- party_account,
- default_advance_account,
- limit,
- condition,
- )
- payment_ref = frappe.qb.DocType("Payment Entry Reference")
-
- q = q.inner_join(payment_ref).on(payment_entry.name == payment_ref.parent)
- q = q.select(
- (payment_ref.allocated_amount).as_("amount"),
- (payment_ref.name).as_("reference_row"),
- (payment_ref.reference_name).as_("against_order"),
- (payment_entry.book_advance_payments_in_separate_party_account),
- )
-
- q = q.where(payment_ref.reference_doctype == order_doctype)
- if order_list:
- q = q.where(payment_ref.reference_name.isin(order_list))
-
- allocated = list(q.run(as_dict=True))
- payment_entries += allocated
- if include_unallocated:
- q = get_common_query(
- party_type,
- party,
- party_account,
- default_advance_account,
- limit,
- condition,
- )
- q = q.select((payment_entry.unallocated_amount).as_("amount"))
- q = q.where(payment_entry.unallocated_amount > 0)
-
- unallocated = list(q.run(as_dict=True))
- payment_entries += unallocated
- return payment_entries
-
-
-def get_common_query(
- party_type,
- party,
- party_account,
- default_advance_account,
- limit,
- condition,
-):
- account_type = frappe.db.get_value("Party Type", party_type, "account_type")
- payment_type = "Receive" if account_type == "Receivable" else "Pay"
- payment_entry = frappe.qb.DocType("Payment Entry")
-
- q = (
- frappe.qb.from_(payment_entry)
- .select(
- ConstantColumn("Payment Entry").as_("reference_type"),
- (payment_entry.name).as_("reference_name"),
- payment_entry.posting_date,
- (payment_entry.remarks).as_("remarks"),
- (payment_entry.book_advance_payments_in_separate_party_account),
- )
- .where(payment_entry.payment_type == payment_type)
- .where(payment_entry.party_type == party_type)
- .where(payment_entry.party == party)
- .where(payment_entry.docstatus == 1)
- )
-
- field = "paid_from" if payment_type == "Receive" else "paid_to"
-
- q = q.select((payment_entry[f"{field}_account_currency"]).as_("currency"))
- q = q.select(payment_entry[field])
- account_condition = payment_entry[field].isin(party_account)
- if default_advance_account:
- q = q.where(
- account_condition
- | (
- (payment_entry[field] == default_advance_account)
- & (payment_entry.book_advance_payments_in_separate_party_account == 1)
- )
- )
-
- else:
- q = q.where(account_condition)
-
- if payment_type == "Receive":
- q = q.select((payment_entry.source_exchange_rate).as_("exchange_rate"))
- else:
- q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate"))
-
- if condition:
- # conditions should be built as an array and passed as Criterion
- common_filter_conditions = []
-
- common_filter_conditions.append(payment_entry.company == condition["company"])
- if condition.get("name", None):
- common_filter_conditions.append(payment_entry.name.like(f"%{condition.get('name')}%"))
-
- if condition.get("from_payment_date"):
- common_filter_conditions.append(payment_entry.posting_date.gte(condition["from_payment_date"]))
-
- if condition.get("to_payment_date"):
- common_filter_conditions.append(payment_entry.posting_date.lte(condition["to_payment_date"]))
-
- if condition.get("get_payments") is True:
- if condition.get("cost_center"):
- common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
-
- if condition.get("accounting_dimensions"):
- for field, val in condition.get("accounting_dimensions").items():
- common_filter_conditions.append(payment_entry[field] == val)
-
- if condition.get("minimum_payment_amount"):
- common_filter_conditions.append(
- payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"])
- )
-
- if condition.get("maximum_payment_amount"):
- common_filter_conditions.append(
- payment_entry.unallocated_amount.lte(condition["maximum_payment_amount"])
- )
- q = q.where(Criterion.all(common_filter_conditions))
-
- q = q.orderby(payment_entry.posting_date)
- q = q.limit(limit) if limit else q
-
- return q
+from erpnext.accounts.services.advances import (
+ get_advance_journal_entries,
+ get_advance_payment_entries,
+ get_advance_payment_entries_for_regional,
+ get_common_query,
+)
+from erpnext.accounts.services.taxes import (
+ add_taxes_from_tax_template,
+ get_default_taxes_and_charges,
+ get_tax_rate,
+ get_taxes_and_charges,
+ merge_taxes,
+ set_balance_in_account_currency,
+ set_child_tax_template_and_map,
+ validate_account_head,
+ validate_conversion_rate,
+ validate_cost_center,
+ validate_inclusive_tax,
+ validate_taxes_and_charges,
+)
def update_invoice_status():
@@ -3563,97 +1589,12 @@ def update_invoice_status():
frappe.qb.update(invoice).set("status", status).where(conditions).run()
-@frappe.whitelist()
-def get_payment_terms(
- terms_template: str,
- posting_date: DateTimeLikeObject | None = None,
- grand_total: float | None = None,
- base_grand_total: float | None = None,
- bill_date: DateTimeLikeObject | None = None,
-):
- if not terms_template:
- return
-
- terms_doc = frappe.get_doc("Payment Terms Template", terms_template)
-
- schedule = []
- for d in terms_doc.get("terms"):
- d = frappe._dict(d.as_dict())
- term_details = get_payment_term_details(d, posting_date, grand_total, base_grand_total, bill_date)
- schedule.append(term_details)
-
- return schedule
-
-
-@frappe.whitelist()
-def get_payment_term_details(
- term: str | frappe._dict,
- posting_date: DateTimeLikeObject | None = None,
- grand_total: float | None = None,
- base_grand_total: float | None = None,
- bill_date: DateTimeLikeObject | None = None,
-):
- term_details = frappe._dict()
- if isinstance(term, str):
- term = frappe.get_doc("Payment Term", term)
- else:
- term_details.payment_term = term.payment_term
-
- fields_to_copy = [
- "description",
- "invoice_portion",
- "discount_type",
- "discount",
- "mode_of_payment",
- "due_date_based_on",
- "credit_days",
- "credit_months",
- "discount_validity_based_on",
- "discount_validity",
- ]
-
- for field in fields_to_copy:
- term_details[field] = term.get(field)
-
- term_details.payment_amount = flt(term.invoice_portion) * flt(grand_total) / 100
- term_details.base_payment_amount = flt(term.invoice_portion) * flt(base_grand_total) / 100
- term_details.outstanding = term_details.payment_amount
- term_details.base_outstanding = term_details.base_payment_amount
-
- has_discount = flt(term.get("discount"))
- date = bill_date or posting_date
- if date:
- term_details.due_date = get_due_date(term, date)
- term_details.discount_date = get_discount_date(term, date) if has_discount else None
-
- if posting_date and getdate(term_details.due_date) < getdate(posting_date):
- term_details.due_date = posting_date
-
- return term_details
-
-
-def get_due_date(term, posting_date=None, bill_date=None):
- due_date = None
- date = bill_date or posting_date
- if term.due_date_based_on == "Day(s) after invoice date":
- due_date = add_days(date, cint(term.credit_days))
- elif term.due_date_based_on == "Day(s) after the end of the invoice month":
- due_date = add_days(get_last_day(date), cint(term.credit_days))
- elif term.due_date_based_on == "Month(s) after the end of the invoice month":
- due_date = get_last_day(add_months(date, cint(term.credit_months)))
- return due_date
-
-
-def get_discount_date(term, posting_date=None, bill_date=None):
- discount_validity = None
- date = bill_date or posting_date
- if term.discount_validity_based_on == "Day(s) after invoice date":
- discount_validity = add_days(date, cint(term.discount_validity))
- elif term.discount_validity_based_on == "Day(s) after the end of the invoice month":
- discount_validity = add_days(get_last_day(date), cint(term.discount_validity))
- elif term.discount_validity_based_on == "Month(s) after the end of the invoice month":
- discount_validity = get_last_day(add_months(date, cint(term.discount_validity)))
- return discount_validity
+from erpnext.accounts.services.payment_schedule import (
+ get_discount_date,
+ get_due_date,
+ get_payment_term_details,
+ get_payment_terms,
+)
def get_supplier_block_status(party_name):
@@ -3670,667 +1611,7 @@ def get_supplier_block_status(party_name):
return info
-def set_child_tax_template_and_map(item, child_item, parent_doc):
- ctx = ItemDetailsCtx(
- {
- "item_code": item.item_code,
- "posting_date": parent_doc.transaction_date,
- "tax_category": parent_doc.get("tax_category"),
- "company": parent_doc.get("company"),
- "base_net_rate": item.get("base_net_rate"),
- }
- )
-
- item_tax_template = _get_item_tax_template(ctx, item.taxes)
-
- if not item_tax_template:
- item_tax_template = _get_item_tax_template_from_item_group(ctx, item.item_group)
-
- child_item.item_tax_template = item_tax_template
- child_item.item_tax_rate = get_item_tax_map(
- doc=parent_doc,
- tax_template=child_item.item_tax_template,
- as_json=True,
- )
-
-
-def _set_je_amounts(entry, amount, default_amount=None, is_credit=True):
- if is_credit:
- entry.credit_in_account_currency = amount
- if default_amount is not None:
- entry.credit = default_amount
- else:
- entry.debit_in_account_currency = amount
- if default_amount is not None:
- entry.debit = default_amount
-
-
-def add_taxes_from_tax_template(child_item, parent_doc, db_insert=True):
- add_taxes_from_item_tax_template = frappe.get_single_value(
- "Accounts Settings", "add_taxes_from_item_tax_template"
- )
-
- if child_item.get("item_tax_rate") and add_taxes_from_item_tax_template:
- tax_map = json.loads(child_item.get("item_tax_rate"))
- for tax_type, tax_rate in tax_map.items():
- if tax_rate == NOT_APPLICABLE_TAX:
- continue
-
- tax_rate = flt(tax_rate)
- taxes = parent_doc.get("taxes") or []
- # add new row for tax head only if missing
- found = any(tax.account_head == tax_type for tax in taxes)
- if not found:
- tax_row = parent_doc.append("taxes", {})
- tax_row.update(
- {
- "description": str(tax_type).split(" - ")[0],
- "charge_type": "On Net Total",
- "account_head": tax_type,
- "rate": tax_rate,
- "set_by_item_tax_template": 1,
- }
- )
- if parent_doc.doctype == "Purchase Order":
- tax_row.update({"category": "Total", "add_deduct_tax": "Add"})
- if db_insert:
- tax_row.db_insert()
-
-
-def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, trans_item):
- """
- Returns a Sales/Purchase Order Item child item containing the default values
- """
- p_doc = frappe.get_doc(parent_doctype, parent_doctype_name)
- child_item = frappe.new_doc(child_doctype, parent_doc=p_doc, parentfield=child_docname)
- item = frappe.get_doc("Item", trans_item.get("item_code"))
-
- for field in ("item_code", "item_name", "description", "item_group", "weight_per_unit", "weight_uom"):
- child_item.update({field: item.get(field)})
-
- date_fieldname = "delivery_date" if child_doctype == "Sales Order Item" else "schedule_date"
- child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)})
- child_item.stock_uom = item.stock_uom
- child_item.uom = trans_item.get("uom") or item.stock_uom
- child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
- conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
- child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
- child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
-
- if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]:
- # Initialized value will update in parent validation
- child_item.base_rate = 1
- child_item.base_amount = 1
- if child_doctype == "Sales Order Item":
- child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
- if not child_item.warehouse:
- frappe.throw(
- _(
- "Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings."
- ).format(frappe.bold(item.item_code))
- )
-
- set_child_tax_template_and_map(item, child_item, p_doc)
- add_taxes_from_tax_template(child_item, p_doc)
- return child_item
-
-
-def validate_child_on_delete(row, parent, ordered_item=None):
- """Check if partially transacted item (row) is being deleted."""
- if parent.doctype == "Sales Order":
- if flt(row.delivered_qty):
- frappe.throw(
- _("Row #{0}: Cannot delete item {1} which has already been delivered").format(
- row.idx, row.item_code
- )
- )
- if flt(row.work_order_qty):
- frappe.throw(
- _("Row #{0}: Cannot delete item {1} which has work order assigned to it.").format(
- row.idx, row.item_code
- )
- )
- if flt(row.ordered_qty):
- frappe.throw(
- _(
- "Row #{0}: Cannot delete item {1} which is already ordered against this Sales Order."
- ).format(row.idx, row.item_code)
- )
-
- if parent.doctype == "Purchase Order" and flt(row.received_qty):
- frappe.throw(
- _("Row #{0}: Cannot delete item {1} which has already been received").format(
- row.idx, row.item_code
- )
- )
- if parent.doctype in ["Purchase Order", "Sales Order"]:
- if flt(row.billed_amt):
- frappe.throw(
- _("Row #{0}: Cannot delete item {1} which has already been billed.").format(
- row.idx, row.item_code
- )
- )
-
- if parent.doctype == "Quotation":
- if ordered_item.get(row.name):
- frappe.throw(_("Cannot delete an item which has been ordered"))
-
-
-def update_bin_on_delete(row, doctype):
- """Update bin for deleted item (row)."""
- from erpnext.stock.stock_balance import (
- get_indented_qty,
- get_ordered_qty,
- get_reserved_qty,
- update_bin_qty,
- )
-
- qty_dict = {}
-
- if doctype == "Sales Order":
- qty_dict["reserved_qty"] = get_reserved_qty(row.item_code, row.warehouse)
- else:
- if row.material_request_item:
- qty_dict["indented_qty"] = get_indented_qty(row.item_code, row.warehouse)
-
- qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse)
-
- if row.warehouse:
- update_bin_qty(row.item_code, row.warehouse, qty_dict)
-
-
-def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
- deleted_children = []
- updated_item_names = [d.get("docname") for d in data]
- for item in parent.items:
- if item.name not in updated_item_names:
- deleted_children.append(item)
-
- for d in deleted_children:
- validate_child_on_delete(d, parent, ordered_item)
- d.cancel()
- d.delete()
-
- if parent.doctype == "Purchase Order":
- parent.update_ordered_qty_in_so_for_removed_items(deleted_children)
-
- # need to update ordered qty in Material Request first
- # bin uses Material Request Items to recalculate & update
- if parent.doctype not in ["Quotation", "Supplier Quotation"]:
- parent.update_prevdoc_status()
- for d in deleted_children:
- update_bin_on_delete(d, parent.doctype)
-
- return bool(deleted_children)
-
-
-def get_allow_zero_qty(parent_doctype: str) -> bool:
- if parent_doctype == "Sales Order":
- return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False
- if parent_doctype == "Purchase Order":
- return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
- return False
-
-
-def get_child_item_change_state(parent_doctype: str, child_item, new_data) -> frappe._dict:
- prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate"))
- prev_qty, new_qty = flt(child_item.get("qty")), flt(new_data.get("qty"))
- prev_fg_qty, new_fg_qty = flt(child_item.get("fg_item_qty")), flt(new_data.get("fg_item_qty"))
- prev_con_fac, new_con_fac = (
- flt(child_item.get("conversion_factor")),
- flt(new_data.get("conversion_factor")),
- )
-
- if parent_doctype == "Sales Order":
- prev_date, new_date = child_item.get("delivery_date"), new_data.get("delivery_date")
- elif parent_doctype == "Purchase Order":
- prev_date, new_date = child_item.get("schedule_date"), new_data.get("schedule_date")
- else:
- prev_date, new_date = None, None
-
- if parent_doctype in ["Quotation", "Supplier Quotation"]:
- date_unchanged = False
- else:
- prev_date = getdate(prev_date) if prev_date else None
- new_date = getdate(new_date) if new_date else None
- date_unchanged = prev_date == new_date
-
- return frappe._dict(
- rate_unchanged=prev_rate == new_rate,
- qty_unchanged=prev_qty == new_qty,
- fg_qty_unchanged=prev_fg_qty == new_fg_qty,
- uom_unchanged=child_item.get("uom") == new_data.get("uom"),
- conversion_factor_unchanged=prev_con_fac == new_con_fac,
- date_unchanged=date_unchanged,
- description_unchanged=child_item.get("description") == new_data.get("description"),
- )
-
-
-def is_child_item_unchanged(change_state: frappe._dict) -> bool:
- return (
- change_state.rate_unchanged
- and change_state.qty_unchanged
- and change_state.fg_qty_unchanged
- and change_state.conversion_factor_unchanged
- and change_state.uom_unchanged
- and change_state.date_unchanged
- and change_state.description_unchanged
- )
-
-
-def update_child_item_rate_and_discount(
- parent_doctype: str, child_item, new_data, allow_zero_qty: bool, rate_unchanged: bool | None = None
-) -> None:
- rate_precision = child_item.precision("rate") or 2
- qty_precision = child_item.precision("qty") or 2
-
- if rate_unchanged is None:
- prev_rate, new_rate = flt(child_item.get("rate")), flt(new_data.get("rate"))
- rate_unchanged = prev_rate == new_rate
-
- if not rate_unchanged and not child_item.get("qty") and allow_zero_qty:
- frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
-
- # Amount cannot be lesser than billed amount, except for negative amounts
- row_rate = flt(new_data.get("rate"), rate_precision)
-
- if parent_doctype in ["Purchase Order", "Sales Order"]:
- amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
- row_rate * flt(new_data.get("qty"), qty_precision), rate_precision
- )
- if amount_below_billed_amt and row_rate > 0.0:
- frappe.throw(
- _(
- "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}."
- ).format(child_item.idx, child_item.item_code)
- )
-
- child_item.rate = row_rate
-
- if parent_doctype not in ["Sales Order", "Purchase Order"] or not flt(child_item.price_list_rate):
- return
-
- if flt(child_item.rate) > flt(child_item.price_list_rate):
- # if rate is greater than price_list_rate, set margin or set discount
- child_item.discount_percentage = 0
- child_item.margin_type = "Amount"
- child_item.margin_rate_or_amount = flt(
- child_item.rate - child_item.price_list_rate,
- child_item.precision("margin_rate_or_amount"),
- )
- child_item.rate_with_margin = child_item.rate
- else:
- child_item.discount_percentage = flt(
- (1 - flt(child_item.rate) / flt(child_item.price_list_rate)) * 100.0,
- child_item.precision("discount_percentage"),
- )
- child_item.discount_amount = flt(child_item.price_list_rate) - flt(child_item.rate)
- child_item.margin_type = ""
- child_item.margin_rate_or_amount = 0
- child_item.rate_with_margin = 0
-
-
-def update_child_item_uom_and_weight(child_item, new_data) -> None:
- conv_fac_precision = child_item.precision("conversion_factor") or 2
-
- if new_data.get("conversion_factor"):
- if child_item.stock_uom == child_item.uom:
- child_item.conversion_factor = 1
- else:
- child_item.conversion_factor = flt(new_data.get("conversion_factor"), conv_fac_precision)
-
- if new_data.get("uom"):
- child_item.uom = new_data.get("uom")
- conversion_factor = flt(
- get_conversion_factor(child_item.item_code, child_item.uom).get("conversion_factor")
- )
- child_item.conversion_factor = (
- flt(new_data.get("conversion_factor"), conv_fac_precision) or conversion_factor
- )
-
- if child_item.get("weight_per_unit"):
- child_item.total_weight = flt(
- child_item.weight_per_unit * child_item.qty * child_item.conversion_factor,
- child_item.precision("total_weight"),
- )
-
-
-@frappe.whitelist()
-def update_child_qty_rate(
- parent_doctype: str, trans_items: str, parent_doctype_name: str, child_docname: str = "items"
-):
- from erpnext.buying.doctype.supplier_quotation.supplier_quotation import get_purchased_items
- from erpnext.selling.doctype.quotation.quotation import get_ordered_items
-
- def check_doc_permissions(doc, perm_type="create"):
- try:
- doc.check_permission(perm_type)
- except frappe.PermissionError:
- actions = {"create": "add", "write": "update"}
-
- frappe.throw(
- _("You do not have permissions to {} items in a {}.").format(
- actions[perm_type], parent_doctype
- ),
- title=_("Insufficient Permissions"),
- )
-
- def validate_workflow_conditions(doc):
- workflow = get_workflow_name(doc.doctype)
- if not workflow:
- return
-
- workflow_doc = frappe.get_doc("Workflow", workflow)
- current_state = doc.get(workflow_doc.workflow_state_field)
- roles = frappe.get_roles()
-
- transitions = []
- for transition in workflow_doc.transitions:
- if transition.next_state == current_state and transition.allowed in roles:
- if not is_transition_condition_satisfied(transition, doc):
- continue
- transitions.append(transition.as_dict())
-
- if not transitions:
- frappe.throw(
- _("You are not allowed to update as per the conditions set in {} Workflow.").format(
- get_link_to_form("Workflow", workflow)
- ),
- title=_("Insufficient Permissions"),
- )
-
- def get_new_child_item(item_row):
- child_doctype = parent_doctype + " Item"
- return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row)
-
- def validate_quantity_and_rate(child_item, new_data):
- if not flt(new_data.get("qty")) and not allow_zero_qty:
- frappe.throw(
- _("Row #{0}:Quantity for Item {1} cannot be zero.").format(
- new_data.get("idx"), frappe.bold(new_data.get("item_code"))
- ),
- title=_("Invalid Qty"),
- )
-
- qty_limits = {
- "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity.")),
- "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity.")),
- }
-
- if parent_doctype in qty_limits:
- qty_field, error_message = qty_limits[parent_doctype]
- if flt(new_data.get("qty")) < flt(child_item.get(qty_field)):
- frappe.throw(
- _("Row #{0}:").format(new_data.get("idx"))
- + error_message.format(frappe.bold(new_data.get("item_code"))),
- title=_("Invalid Qty"),
- )
-
- if parent_doctype in ["Quotation", "Supplier Quotation"]:
- if (parent_doctype == "Quotation" and not ordered_items) or (
- parent_doctype == "Supplier Quotation" and not purchased_items
- ):
- return
-
- qty_to_check = (
- ordered_items.get(child_item.name)
- if parent_doctype == "Quotation"
- else purchased_items.get(child_item.name)
- )
-
- if qty_to_check:
- if not rate_unchanged:
- frappe.throw(
- _(
- "Cannot update rate as item {0} is already ordered or purchased against this quotation"
- ).format(frappe.bold(new_data.get("item_code")))
- )
-
- if flt(new_data.get("qty")) < qty_to_check:
- frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity"))
-
- def validate_fg_item_for_subcontracting(new_data, is_new):
- if is_new:
- if not new_data.get("fg_item"):
- frappe.throw(
- _("Finished Good Item is not specified for service item {0}").format(
- new_data["item_code"]
- )
- )
- else:
- is_sub_contracted_item, default_bom = frappe.db.get_value(
- "Item", new_data["fg_item"], ["is_sub_contracted_item", "default_bom"]
- )
-
- if not is_sub_contracted_item:
- frappe.throw(
- _("Finished Good Item {0} must be a sub-contracted item").format(new_data["fg_item"])
- )
- elif not default_bom:
- frappe.throw(_("Default BOM not found for FG Item {0}").format(new_data["fg_item"]))
-
- if not new_data.get("fg_item_qty"):
- frappe.throw(_("Finished Good Item {0} Qty can not be zero").format(new_data["fg_item"]))
-
- data = json.loads(trans_items)
- any_qty_changed = False # updated to true if any item's qty changes
- items_added_or_removed = False # updated to true if any new item is added or removed
- any_conversion_factor_changed = False
-
- parent = frappe.get_doc(parent_doctype, parent_doctype_name)
- allow_zero_qty = get_allow_zero_qty(parent_doctype)
-
- check_doc_permissions(parent, "write")
-
- if parent_doctype == "Quotation":
- ordered_items = get_ordered_items(parent.name)
- _removed_items = validate_and_delete_children(parent, data, ordered_items)
- elif parent_doctype == "Supplier Quotation":
- purchased_items = get_purchased_items(parent.name)
- _removed_items = validate_and_delete_children(parent, data, purchased_items)
- else:
- _removed_items = validate_and_delete_children(parent, data)
-
- items_added_or_removed |= _removed_items
-
- for d in data:
- new_child_flag = False
- rate_unchanged = None
-
- if not d.get("item_code"):
- # ignore empty rows
- continue
-
- if not d.get("docname"):
- new_child_flag = True
- items_added_or_removed = True
- check_doc_permissions(parent, "create")
- child_item = get_new_child_item(d)
- else:
- check_doc_permissions(parent, "write")
- child_item = frappe.get_doc(parent_doctype + " Item", d.get("docname"))
-
- change_state = get_child_item_change_state(parent_doctype, child_item, d)
- rate_unchanged = change_state.rate_unchanged
- any_conversion_factor_changed |= not change_state.conversion_factor_unchanged
- if is_child_item_unchanged(change_state):
- continue
-
- validate_quantity_and_rate(child_item, d)
-
- if flt(child_item.get("qty")) != flt(d.get("qty")):
- any_qty_changed = True
-
- if parent.doctype in ["Sales Order", "Purchase Order"] and parent.is_subcontracted:
- validate_fg_item_for_subcontracting(d, new_child_flag)
- child_item.fg_item_qty = flt(d["fg_item_qty"])
-
- if new_child_flag:
- child_item.fg_item = d["fg_item"]
-
- child_item.qty = flt(d.get("qty"))
- child_item.description = d.get("description")
- update_child_item_rate_and_discount(
- parent_doctype, child_item, d, allow_zero_qty, rate_unchanged=rate_unchanged
- )
- update_child_item_uom_and_weight(child_item, d)
-
- if d.get("delivery_date") and parent_doctype == "Sales Order":
- child_item.delivery_date = d.get("delivery_date")
-
- if d.get("schedule_date") and parent_doctype == "Purchase Order":
- child_item.schedule_date = d.get("schedule_date")
-
- if d.get("bom_no") and parent_doctype == "Sales Order":
- child_item.bom_no = d.get("bom_no")
-
- child_item.flags.ignore_validate_update_after_submit = True
- if new_child_flag:
- parent.load_from_db()
- child_item.idx = len(parent.items) + 1
- child_item.insert()
- else:
- child_item.save(ignore_permissions=True)
-
- parent.reload()
- parent.flags.ignore_validate_update_after_submit = True
- parent.set_qty_as_per_stock_uom()
- parent.calculate_taxes_and_totals()
- parent.set_total_in_words()
- if parent_doctype == "Sales Order" and not parent.is_subcontracted:
- make_packing_list(parent)
- parent.set_gross_profit()
- frappe.get_cached_doc("Authorization Control").validate_approving_authority(
- parent.doctype, parent.company, parent.base_grand_total
- )
-
- if parent_doctype != "Supplier Quotation":
- parent.set_payment_schedule()
- if parent_doctype == "Purchase Order":
- parent.validate_minimum_order_qty()
- parent.validate_budget()
- if parent.is_against_so():
- parent.update_status_updater()
- elif parent_doctype == "Sales Order":
- parent.check_credit_limit()
-
- # reset index of child table
- for idx, row in enumerate(parent.get(child_docname), start=1):
- row.idx = idx
-
- parent.save()
-
- if parent_doctype == "Purchase Order":
- update_last_purchase_rate(parent, is_submit=1)
-
- if any_qty_changed or items_added_or_removed or any_conversion_factor_changed:
- parent.update_prevdoc_status()
-
- parent.update_requested_qty()
- parent.update_ordered_qty()
- parent.update_ordered_and_reserved_qty()
- parent.update_receiving_percentage()
-
- if parent.is_subcontracted:
- if not parent.can_update_items():
- frappe.throw(
- _(
- "Items cannot be updated as Subcontracting Order is created against the Purchase Order {0}."
- ).format(frappe.bold(parent.name))
- )
- elif parent_doctype == "Sales Order": # Sales Order
- if parent.is_subcontracted and not parent.can_update_items():
- frappe.throw(
- _(
- "Items cannot be updated as Subcontracting Inward Order(s) exist against this Subcontracted Sales Order."
- )
- )
- parent.validate_selling_price()
- parent.validate_for_duplicate_items()
- parent.validate_warehouse()
- parent.update_reserved_qty()
- parent.update_project()
- parent.update_prevdoc_status("submit")
- parent.update_delivery_status()
-
- parent.reload()
- validate_workflow_conditions(parent)
-
- if parent_doctype in ["Purchase Order", "Sales Order"]:
- parent.update_blanket_order()
- parent.update_billing_percentage()
- parent.set_status()
-
- parent.validate_uom_is_integer("uom", "qty")
- parent.validate_uom_is_integer("stock_uom", "stock_qty")
-
- # Cancel and Recreate Stock Reservation Entries.
- if parent_doctype == "Sales Order" and not parent.is_subcontracted:
- from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- cancel_stock_reservation_entries,
- has_reserved_stock,
- )
-
- if has_reserved_stock(parent.doctype, parent.name):
- cancel_stock_reservation_entries(parent.doctype, parent.name)
-
- if parent.per_picked == 0:
- parent.create_stock_reservation_entries()
-
-
-def check_if_child_table_updated(child_table_before_update, child_table_after_update, fields_to_check):
- fields_to_check = list(fields_to_check) + get_accounting_dimensions() + ["cost_center", "project"]
-
- # Check if any field affecting accounting entry is altered
- for index, item in enumerate(child_table_before_update):
- for field in fields_to_check:
- if child_table_after_update[index].get(field) != item.get(field):
- return True
-
- return False
-
-
-def merge_taxes(source_doc, target_doc):
- tax_map = {}
- for tax in source_doc.get("taxes") or []:
- found = False
- for t in target_doc.get("taxes") or []:
- if t.account_head == tax.account_head and t.cost_center == tax.cost_center:
- t.tax_amount = flt(t.tax_amount) + flt(tax.tax_amount_after_discount_amount)
- t.base_tax_amount = flt(t.base_tax_amount) + flt(tax.base_tax_amount_after_discount_amount)
- tax_map[tax.name] = t
- found = True
-
- if not found:
- tax.charge_type = "Actual"
- tax.included_in_print_rate = 0
- tax.dont_recompute_tax = 1
- tax.row_id = None
- tax.idx = None
- tax.tax_amount = tax.tax_amount_after_discount_amount
- tax.base_tax_amount = tax.base_tax_amount_after_discount_amount
- tax_map[tax.name] = target_doc.append("taxes", tax)
-
- item_map = {d._old_name: d for d in target_doc.get("items") if d.get("_old_name")}
-
- item_tax_details = target_doc.get("_item_wise_tax_details") or []
- for row in source_doc.get("item_wise_tax_details"):
- item = item_map.get(row.item_row)
- tax = tax_map.get(row.tax_row)
- if not (item and tax):
- continue
-
- item_tax_details.append(
- frappe._dict(
- item=item,
- tax=tax,
- amount=row.amount,
- rate=row.rate,
- taxable_amount=row.taxable_amount,
- )
- )
-
- target_doc._item_wise_tax_details = item_tax_details
+from erpnext.accounts.services.child_item_update import update_child_qty_rate
@erpnext.allow_regional
@@ -4343,14 +1624,10 @@ def validate_einvoice_fields(doc):
pass
-@erpnext.allow_regional
-def update_gl_dict_with_regional_fields(doc, gl_dict):
- pass
-
-
-def update_gl_dict_with_app_based_fields(doc, gl_dict):
- for method in frappe.get_hooks("update_gl_dict_with_app_based_fields", default=[]):
- frappe.get_attr(method)(doc, gl_dict)
+from erpnext.accounts.services.base_gl_composer import (
+ update_gl_dict_with_app_based_fields,
+ update_gl_dict_with_regional_fields,
+)
@frappe.whitelist()
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index bdfa5dc1ee4..ce7422ed2d5 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -13,7 +13,6 @@ import erpnext
from erpnext.accounts.general_ledger import (
make_gl_entries,
make_reverse_gl_entries,
- process_gl_map,
)
from erpnext.accounts.utils import cancel_exchange_gain_loss_journal, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
@@ -691,140 +690,10 @@ class StockController(AccountsController):
def get_gl_entries(
self, inventory_account_map=None, default_expense_account=None, default_cost_center=None
):
- if not inventory_account_map:
- inventory_account_map = self.get_inventory_account_map()
+ from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
- sle_map = self.get_stock_ledger_details()
- voucher_details = self.get_voucher_details(default_expense_account, default_cost_center, sle_map)
-
- gl_list = []
- warehouse_with_no_account = []
- precision = self.get_debit_field_precision()
- for item_row in voucher_details:
- sle_list = sle_map.get(item_row.name)
- sle_rounding_diff = 0.0
- if sle_list:
- for sle in sle_list:
- _inv_dict = self.get_inventory_account_dict(sle, inventory_account_map)
-
- if _inv_dict.get("account"):
- # from warehouse account
-
- sle_rounding_diff += flt(sle.stock_value_difference)
-
- self.check_expense_account(item_row)
-
- # expense account/ target_warehouse / source_warehouse
- if item_row.get("target_warehouse"):
- _target_wh_inv_dict = self.get_inventory_account_dict(
- item_row, inventory_account_map, warehouse_field="target_warehouse"
- )
- expense_account = _target_wh_inv_dict["account"]
- else:
- expense_account = item_row.expense_account
-
- gl_list.append(
- self.get_gl_dict(
- {
- "account": _inv_dict["account"],
- "against": expense_account,
- "cost_center": item_row.cost_center,
- "project": sle.get("project") or item_row.project or self.get("project"),
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "debit": flt(sle.stock_value_difference, precision),
- "is_opening": item_row.get("is_opening")
- or self.get("is_opening")
- or "No",
- },
- _inv_dict["account_currency"],
- item=item_row,
- )
- )
-
- gl_list.append(
- self.get_gl_dict(
- {
- "account": expense_account,
- "against": _inv_dict["account"],
- "cost_center": item_row.cost_center,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "debit": -1 * flt(sle.stock_value_difference, precision),
- "project": sle.get("project")
- or item_row.get("project")
- or self.get("project"),
- "is_opening": item_row.get("is_opening")
- or self.get("is_opening")
- or "No",
- },
- item=item_row,
- )
- )
- elif sle.warehouse not in warehouse_with_no_account:
- warehouse_with_no_account.append(sle.warehouse)
-
- if abs(sle_rounding_diff) > (1.0 / (10**precision)) and self.is_internal_transfer():
- warehouse_asset_account = ""
- if self.get("is_internal_customer"):
- _inv_dict = self.get_inventory_account_dict(
- item_row, inventory_account_map, warehouse_field="target_warehouse"
- )
-
- warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None
- elif self.get("is_internal_supplier"):
- _inv_dict = self.get_inventory_account_dict(item_row, inventory_account_map)
-
- warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None
-
- expense_account = frappe.get_cached_value("Company", self.company, "default_expense_account")
- if not expense_account:
- frappe.throw(
- _(
- "Please set default cost of goods sold account in company {0} for booking rounding gain and loss during stock transfer"
- ).format(frappe.bold(self.company))
- )
-
- gl_list.append(
- self.get_gl_dict(
- {
- "account": expense_account,
- "against": warehouse_asset_account,
- "cost_center": item_row.cost_center,
- "project": item_row.project or self.get("project"),
- "remarks": _("Rounding gain/loss Entry for Stock Transfer"),
- "debit": sle_rounding_diff,
- "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No",
- },
- _inv_dict["account_currency"],
- item=item_row,
- )
- )
-
- gl_list.append(
- self.get_gl_dict(
- {
- "account": warehouse_asset_account,
- "against": expense_account,
- "cost_center": item_row.cost_center,
- "remarks": _("Rounding gain/loss Entry for Stock Transfer"),
- "credit": sle_rounding_diff,
- "project": item_row.get("project") or self.get("project"),
- "is_opening": item_row.get("is_opening") or self.get("is_opening") or "No",
- },
- item=item_row,
- )
- )
-
- if warehouse_with_no_account:
- for wh in warehouse_with_no_account:
- if frappe.get_cached_value("Warehouse", wh, "company"):
- frappe.throw(
- _(
- "Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}."
- ).format(wh, self.company)
- )
-
- return process_gl_map(
- gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation
+ return BaseStockGLComposer(self).compose(
+ inventory_account_map, default_expense_account, default_cost_center
)
def get_debit_field_precision(self):
@@ -1792,28 +1661,25 @@ class StockController(AccountsController):
item=None,
posting_date=None,
):
- gl_entry = {
- "account": account,
- "cost_center": cost_center,
- "debit": debit,
- "credit": credit,
- "against": against_account,
- "remarks": remarks,
- }
+ from erpnext.accounts.services.base_gl_composer import add_gl_entry
- if voucher_detail_no:
- gl_entry.update({"voucher_detail_no": voucher_detail_no})
-
- if debit_in_account_currency:
- gl_entry.update({"debit_in_account_currency": debit_in_account_currency})
-
- if credit_in_account_currency:
- gl_entry.update({"credit_in_account_currency": credit_in_account_currency})
-
- if posting_date:
- gl_entry.update({"posting_date": posting_date})
-
- gl_entries.append(self.get_gl_dict(gl_entry, item=item))
+ add_gl_entry(
+ self,
+ gl_entries,
+ account,
+ cost_center,
+ debit,
+ credit,
+ remarks,
+ against_account,
+ debit_in_account_currency,
+ credit_in_account_currency,
+ account_currency,
+ project,
+ voucher_detail_no,
+ item,
+ posting_date,
+ )
def update_stock_reservation_entries(self):
def get_sre_list():
diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py
index 838c32c4276..3e0aa6f0c80 100644
--- a/erpnext/controllers/tests/test_accounts_controller.py
+++ b/erpnext/controllers/tests/test_accounts_controller.py
@@ -810,7 +810,7 @@ class TestAccountsController(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Stock Settings", {"allow_internal_transfer_at_arms_length_price": 1})
def test_16_internal_transfer_at_arms_length_price(self):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_purchase_invoice
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_purchase_invoice
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
prepare_data_for_internal_transfer()
@@ -2247,7 +2247,7 @@ class TestAccountsController(ERPNextTestSuite):
Test that additional discount amount is not copied repeatedly
when creating multiple delivery notes from a single sales order with discount_amount set
"""
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Create a sales order with discount amount
@@ -2283,7 +2283,7 @@ class TestAccountsController(ERPNextTestSuite):
Test that additional discount amount is not copied repeatedly
when creating multiple purchase receipts from a single purchase order with discount_amount set
"""
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
# Create a purchase order with discount amount
@@ -2319,7 +2319,7 @@ class TestAccountsController(ERPNextTestSuite):
Test that discount amount is partially applied when some discount
has already been used in previous mapped transactions
"""
- from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Create a sales order with discount amount
@@ -2357,7 +2357,7 @@ class TestAccountsController(ERPNextTestSuite):
Test that discount amount is not adjusted when additional_discount_percentage
is set in the source document (as it will be recalculated based on percentage)
"""
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
# Create a sales order with discount percentage instead of amount
@@ -2385,7 +2385,7 @@ class TestAccountsController(ERPNextTestSuite):
Test that discount amount is correctly adjusted when multiple return invoices
are created against the same original invoice to prevent over-returning discount
"""
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
# Create original sales invoice with discount
si = create_sales_invoice(qty=10, rate=100, do_not_submit=True)
diff --git a/erpnext/controllers/tests/test_item_wise_inventory_account.py b/erpnext/controllers/tests/test_item_wise_inventory_account.py
index 2f45ea31466..97d87f2e348 100644
--- a/erpnext/controllers/tests/test_item_wise_inventory_account.py
+++ b/erpnext/controllers/tests/test_item_wise_inventory_account.py
@@ -6,8 +6,8 @@ import frappe
from frappe.utils import add_days, today
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
-from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
diff --git a/erpnext/controllers/tests/test_mapper.py b/erpnext/controllers/tests/test_mapper.py
index 3a804d78b8f..fbd10274147 100644
--- a/erpnext/controllers/tests/test_mapper.py
+++ b/erpnext/controllers/tests/test_mapper.py
@@ -23,7 +23,7 @@ class TestMapper(ERPNextTestSuite):
so, item_list_3 = self.make_sales_order()
# Map source docs to target with corresponding mapper method
- method = "erpnext.selling.doctype.quotation.quotation.make_sales_order"
+ method = "erpnext.selling.doctype.quotation.mapper.make_sales_order"
updated_so = mapper.map_docs(method, json.dumps([qtn1.name, qtn2.name]), so)
# Assert that all inserted items are present in updated sales order
diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js
index 72356875eb2..42639a4ecec 100644
--- a/erpnext/crm/doctype/lead/lead.js
+++ b/erpnext/crm/doctype/lead/lead.js
@@ -88,14 +88,14 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
make_customer() {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.lead.lead.make_customer",
+ method: "erpnext.crm.doctype.lead.mapper.make_customer",
frm: this.frm,
});
}
make_quotation() {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.lead.lead.make_quotation",
+ method: "erpnext.crm.doctype.lead.mapper.make_quotation",
frm: this.frm,
});
}
@@ -171,7 +171,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
callback: function (r) {
if (!r.exc) {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.lead.lead.make_opportunity",
+ method: "erpnext.crm.doctype.lead.mapper.make_opportunity",
frm: frm,
});
}
@@ -184,7 +184,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
d.show();
} else {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.lead.lead.make_opportunity",
+ method: "erpnext.crm.doctype.lead.mapper.make_opportunity",
frm: frm,
});
}
diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py
index b4dbf719a12..d62e81271b4 100644
--- a/erpnext/crm/doctype/lead/lead.py
+++ b/erpnext/crm/doctype/lead/lead.py
@@ -7,18 +7,14 @@ from frappe.contacts.address_and_contact import (
delete_contact_and_address,
load_address_and_contact,
)
-from frappe.contacts.doctype.address.address import get_default_address
-from frappe.contacts.doctype.contact.contact import get_default_contact
-from frappe.email.inbox import link_communication_to_document
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.utils import comma_and, get_link_to_form, has_gravatar, validate_email_address
from frappe.utils.data import DateTimeLikeObject
from erpnext.accounts.party import set_taxes
from erpnext.controllers.selling_controller import SellingController
from erpnext.crm.utils import CRMNote, copy_comments, link_communications, link_open_events
-from erpnext.selling.doctype.customer.customer import parse_full_name
+from erpnext.selling.doctype.customer.mapper import parse_full_name
class Lead(SellingController, CRMNote):
@@ -322,134 +318,6 @@ class Lead(SellingController, CRMNote):
return None
-@frappe.whitelist()
-def make_customer(source_name: str, target_doc: str | Document | None = None):
- return _make_customer(source_name, target_doc)
-
-
-def _make_customer(source_name, target_doc=None, ignore_permissions=False):
- def set_missing_values(source, target):
- if source.company_name:
- target.customer_type = "Company"
- target.customer_name = source.company_name
- else:
- target.customer_type = "Individual"
- target.customer_name = source.lead_name
-
- if not target.customer_group:
- target.customer_group = frappe.db.get_default("Customer Group")
-
- address = get_default_address("Lead", source.name)
- contact = get_default_contact("Lead", source.name)
- if address:
- target.customer_primary_address = address
- if contact:
- target.customer_primary_contact = contact
-
- doclist = get_mapped_doc(
- "Lead",
- source_name,
- {
- "Lead": {
- "doctype": "Customer",
- "field_map": {
- "name": "lead_name",
- "company_name": "customer_name",
- "contact_no": "phone_1",
- "fax": "fax_1",
- },
- "field_no_map": ["disabled"],
- }
- },
- target_doc,
- set_missing_values,
- ignore_permissions=ignore_permissions,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_opportunity(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- _set_missing_values(source, target)
-
- target_doc = get_mapped_doc(
- "Lead",
- source_name,
- {
- "Lead": {
- "doctype": "Opportunity",
- "field_map": {
- "doctype": "opportunity_from",
- "name": "party_name",
- "lead_name": "contact_display",
- "company_name": "customer_name",
- "email_id": "contact_email",
- "mobile_no": "contact_mobile",
- "lead_owner": "opportunity_owner",
- "notes": "notes",
- },
- }
- },
- target_doc,
- set_missing_values,
- )
-
- return target_doc
-
-
-@frappe.whitelist()
-def make_quotation(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- _set_missing_values(source, target)
-
- target_doc = get_mapped_doc(
- "Lead",
- source_name,
- {"Lead": {"doctype": "Quotation", "field_map": {"name": "party_name"}}},
- target_doc,
- set_missing_values,
- )
-
- target_doc.quotation_to = "Lead"
- target_doc.run_method("set_missing_values")
- target_doc.run_method("set_other_charges")
- target_doc.run_method("calculate_taxes_and_totals")
-
- return target_doc
-
-
-def _set_missing_values(source, target):
- address = frappe.get_all(
- "Dynamic Link",
- {
- "link_doctype": source.doctype,
- "link_name": source.name,
- "parenttype": "Address",
- },
- ["parent"],
- limit=1,
- )
-
- contact = frappe.get_all(
- "Dynamic Link",
- {
- "link_doctype": source.doctype,
- "link_name": source.name,
- "parenttype": "Contact",
- },
- ["parent"],
- limit=1,
- )
-
- if address:
- target.customer_address = address[0].parent
-
- if contact:
- target.contact_person = contact[0].parent
-
-
@frappe.whitelist()
def get_lead_details(
lead: str,
@@ -494,35 +362,6 @@ def get_lead_details(
return out
-@frappe.whitelist()
-def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
- """raise a issue from email"""
-
- doc = frappe.get_doc("Communication", communication)
- lead_name = None
- if doc.sender:
- lead_name = frappe.db.get_value("Lead", {"email_id": doc.sender})
- if not lead_name and doc.phone_no:
- lead_name = frappe.db.get_value("Lead", {"mobile_no": doc.phone_no})
- if not lead_name:
- lead = frappe.get_doc(
- {
- "doctype": "Lead",
- "lead_name": doc.sender_full_name,
- "email_id": doc.sender,
- "mobile_no": doc.phone_no,
- }
- )
- lead.flags.ignore_mandatory = True
- lead.flags.ignore_permissions = True
- lead.insert()
-
- lead_name = lead.name
-
- link_communication_to_document(doc, "Lead", lead_name, ignore_communication_links)
- return lead_name
-
-
def get_lead_with_phone_number(number):
if not number:
return
diff --git a/erpnext/crm/doctype/lead/mapper.py b/erpnext/crm/doctype/lead/mapper.py
new file mode 100644
index 00000000000..b90fba19a79
--- /dev/null
+++ b/erpnext/crm/doctype/lead/mapper.py
@@ -0,0 +1,169 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.contacts.doctype.address.address import get_default_address
+from frappe.contacts.doctype.contact.contact import get_default_contact
+from frappe.email.inbox import link_communication_to_document
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+
+
+@frappe.whitelist()
+def make_customer(source_name: str, target_doc: str | Document | None = None):
+ return _make_customer(source_name, target_doc)
+
+
+def _make_customer(
+ source_name: str, target_doc: str | Document | None = None, ignore_permissions: bool = False
+):
+ def set_missing_values(source, target):
+ if source.company_name:
+ target.customer_type = "Company"
+ target.customer_name = source.company_name
+ else:
+ target.customer_type = "Individual"
+ target.customer_name = source.lead_name
+
+ if not target.customer_group:
+ target.customer_group = frappe.db.get_default("Customer Group")
+
+ address = get_default_address("Lead", source.name)
+ contact = get_default_contact("Lead", source.name)
+ if address:
+ target.customer_primary_address = address
+ if contact:
+ target.customer_primary_contact = contact
+
+ doclist = get_mapped_doc(
+ "Lead",
+ source_name,
+ {
+ "Lead": {
+ "doctype": "Customer",
+ "field_map": {
+ "name": "lead_name",
+ "company_name": "customer_name",
+ "contact_no": "phone_1",
+ "fax": "fax_1",
+ },
+ "field_no_map": ["disabled"],
+ }
+ },
+ target_doc,
+ set_missing_values,
+ ignore_permissions=ignore_permissions,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_opportunity(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ _set_missing_values(source, target)
+
+ target_doc = get_mapped_doc(
+ "Lead",
+ source_name,
+ {
+ "Lead": {
+ "doctype": "Opportunity",
+ "field_map": {
+ "doctype": "opportunity_from",
+ "name": "party_name",
+ "lead_name": "contact_display",
+ "company_name": "customer_name",
+ "email_id": "contact_email",
+ "mobile_no": "contact_mobile",
+ "lead_owner": "opportunity_owner",
+ "notes": "notes",
+ },
+ }
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return target_doc
+
+
+@frappe.whitelist()
+def make_quotation(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ _set_missing_values(source, target)
+
+ target_doc = get_mapped_doc(
+ "Lead",
+ source_name,
+ {"Lead": {"doctype": "Quotation", "field_map": {"name": "party_name"}}},
+ target_doc,
+ set_missing_values,
+ )
+
+ target_doc.quotation_to = "Lead"
+ target_doc.run_method("set_missing_values")
+ target_doc.run_method("set_other_charges")
+ target_doc.run_method("calculate_taxes_and_totals")
+
+ return target_doc
+
+
+@frappe.whitelist()
+def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
+ """raise a issue from email"""
+
+ doc = frappe.get_doc("Communication", communication)
+ lead_name = None
+ if doc.sender:
+ lead_name = frappe.db.get_value("Lead", {"email_id": doc.sender})
+ if not lead_name and doc.phone_no:
+ lead_name = frappe.db.get_value("Lead", {"mobile_no": doc.phone_no})
+ if not lead_name:
+ lead = frappe.get_doc(
+ {
+ "doctype": "Lead",
+ "lead_name": doc.sender_full_name,
+ "email_id": doc.sender,
+ "mobile_no": doc.phone_no,
+ }
+ )
+ lead.flags.ignore_mandatory = True
+ lead.flags.ignore_permissions = True
+ lead.insert()
+
+ lead_name = lead.name
+
+ link_communication_to_document(doc, "Lead", lead_name, ignore_communication_links)
+ return lead_name
+
+
+def _set_missing_values(source, target):
+ address = frappe.get_all(
+ "Dynamic Link",
+ {
+ "link_doctype": source.doctype,
+ "link_name": source.name,
+ "parenttype": "Address",
+ },
+ ["parent"],
+ limit=1,
+ )
+
+ contact = frappe.get_all(
+ "Dynamic Link",
+ {
+ "link_doctype": source.doctype,
+ "link_name": source.name,
+ "parenttype": "Contact",
+ },
+ ["parent"],
+ limit=1,
+ )
+
+ if address:
+ target.customer_address = address[0].parent
+
+ if contact:
+ target.contact_person = contact[0].parent
diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py
index 7f1ac27d5cd..01f3f117614 100644
--- a/erpnext/crm/doctype/lead/test_lead.py
+++ b/erpnext/crm/doctype/lead/test_lead.py
@@ -4,14 +4,14 @@
import frappe
from frappe.utils import random_string, today
-from erpnext.crm.doctype.lead.lead import make_opportunity
+from erpnext.crm.doctype.lead.mapper import make_opportunity
from erpnext.crm.utils import get_linked_prospect
from erpnext.tests.utils import ERPNextTestSuite
class TestLead(ERPNextTestSuite):
def test_make_customer(self):
- from erpnext.crm.doctype.lead.lead import make_customer
+ from erpnext.crm.doctype.lead.mapper import make_customer
lead = frappe.db.get_all("Lead", {"lead_name": "_Test Lead"})[0].name
@@ -41,7 +41,7 @@ class TestLead(ERPNextTestSuite):
self.assertEqual(contact_doc.has_link(customer.doctype, customer.name), True)
def test_make_customer_from_organization(self):
- from erpnext.crm.doctype.lead.lead import make_customer
+ from erpnext.crm.doctype.lead.mapper import make_customer
lead = frappe.db.get_all("Lead", {"lead_name": "_Test Lead 1"})[0].name
customer = make_customer(lead)
diff --git a/erpnext/crm/doctype/opportunity/mapper.py b/erpnext/crm/doctype/opportunity/mapper.py
new file mode 100644
index 00000000000..55e081cfb09
--- /dev/null
+++ b/erpnext/crm/doctype/opportunity/mapper.py
@@ -0,0 +1,152 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.email.inbox import link_communication_to_document
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+
+from erpnext.setup.utils import get_exchange_rate
+
+
+@frappe.whitelist()
+def make_quotation(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ from erpnext.controllers.accounts_controller import get_default_taxes_and_charges
+
+ quotation = frappe.get_doc(target)
+
+ company_currency = frappe.get_cached_value("Company", quotation.company, "default_currency")
+
+ if company_currency == quotation.currency:
+ exchange_rate = 1
+ else:
+ exchange_rate = get_exchange_rate(
+ quotation.currency, company_currency, quotation.transaction_date, args="for_selling"
+ )
+
+ quotation.conversion_rate = exchange_rate
+
+ # get default taxes
+ taxes = get_default_taxes_and_charges("Sales Taxes and Charges Template", company=quotation.company)
+ if taxes.get("taxes"):
+ quotation.update(taxes)
+
+ quotation.run_method("set_missing_values")
+ quotation.run_method("calculate_taxes_and_totals")
+ if not source.get("items", []):
+ quotation.opportunity = source.name
+
+ doclist = get_mapped_doc(
+ "Opportunity",
+ source_name,
+ {
+ "Opportunity": {
+ "doctype": "Quotation",
+ "field_map": {"opportunity_from": "quotation_to", "name": "enq_no"},
+ },
+ "Opportunity Item": {
+ "doctype": "Quotation Item",
+ "field_map": {
+ "parent": "prevdoc_docname",
+ "parenttype": "prevdoc_doctype",
+ "uom": "stock_uom",
+ },
+ "add_if_empty": True,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None):
+ def update_item(obj, target, source_parent):
+ target.conversion_factor = 1.0
+
+ doclist = get_mapped_doc(
+ "Opportunity",
+ source_name,
+ {
+ "Opportunity": {"doctype": "Request for Quotation"},
+ "Opportunity Item": {
+ "doctype": "Request for Quotation Item",
+ "field_map": [["name", "opportunity_item"], ["parent", "opportunity"], ["uom", "uom"]],
+ "postprocess": update_item,
+ },
+ },
+ target_doc,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_customer(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ target.opportunity_name = source.name
+
+ if source.opportunity_from == "Lead":
+ target.lead_name = source.party_name
+
+ doclist = get_mapped_doc(
+ "Opportunity",
+ source_name,
+ {
+ "Opportunity": {
+ "doctype": "Customer",
+ "field_map": {"currency": "default_currency", "customer_name": "customer_name"},
+ }
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None):
+ doclist = get_mapped_doc(
+ "Opportunity",
+ source_name,
+ {
+ "Opportunity": {"doctype": "Supplier Quotation", "field_map": {"name": "opportunity"}},
+ "Opportunity Item": {"doctype": "Supplier Quotation Item", "field_map": {"uom": "stock_uom"}},
+ },
+ target_doc,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_opportunity_from_communication(
+ communication: str, company: str, ignore_communication_links: bool = False
+):
+ from erpnext.crm.doctype.lead.mapper import make_lead_from_communication
+
+ doc = frappe.get_doc("Communication", communication)
+
+ lead = doc.reference_name if doc.reference_doctype == "Lead" else None
+ if not lead:
+ lead = make_lead_from_communication(communication, ignore_communication_links=True)
+
+ opportunity_from = "Lead"
+
+ opportunity = frappe.get_doc(
+ {
+ "doctype": "Opportunity",
+ "company": company,
+ "opportunity_from": opportunity_from,
+ "party_name": lead,
+ }
+ ).insert(ignore_permissions=True)
+
+ link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)
+
+ return opportunity.name
diff --git a/erpnext/crm/doctype/opportunity/opportunity.js b/erpnext/crm/doctype/opportunity/opportunity.js
index 1bda0e5568f..d2a1b5c504b 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.js
+++ b/erpnext/crm/doctype/opportunity/opportunity.js
@@ -40,7 +40,7 @@ frappe.ui.form.on("Opportunity", {
erpnext.utils.get_party_details(frm);
} else if (frm.doc.opportunity_from == "Lead") {
erpnext.utils.map_current_doc({
- method: "erpnext.crm.doctype.lead.lead.make_opportunity",
+ method: "erpnext.crm.doctype.lead.mapper.make_opportunity",
source_name: frm.doc.party_name,
frm: frm,
});
@@ -204,14 +204,14 @@ frappe.ui.form.on("Opportunity", {
make_supplier_quotation: function (frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.opportunity.opportunity.make_supplier_quotation",
+ method: "erpnext.crm.doctype.opportunity.mapper.make_supplier_quotation",
frm: frm,
});
},
make_request_for_quotation: function (frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.opportunity.opportunity.make_request_for_quotation",
+ method: "erpnext.crm.doctype.opportunity.mapper.make_request_for_quotation",
frm: frm,
});
},
@@ -341,14 +341,14 @@ erpnext.crm.Opportunity = class Opportunity extends frappe.ui.form.Controller {
create_quotation() {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.opportunity.opportunity.make_quotation",
+ method: "erpnext.crm.doctype.opportunity.mapper.make_quotation",
frm: this.frm,
});
}
make_customer() {
frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.opportunity.opportunity.make_customer",
+ method: "erpnext.crm.doctype.opportunity.mapper.make_customer",
frm: this.frm,
});
}
diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py
index 179f27bdede..6dc5f6a47b4 100644
--- a/erpnext/crm/doctype/opportunity/opportunity.py
+++ b/erpnext/crm/doctype/opportunity/opportunity.py
@@ -7,9 +7,7 @@ import json
import frappe
from frappe import _
from frappe.contacts.address_and_contact import load_address_and_contact
-from frappe.email.inbox import link_communication_to_document
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from frappe.utils import flt, get_fullname
@@ -389,120 +387,6 @@ def get_item_details(item_code: str):
}
-@frappe.whitelist()
-def make_quotation(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- from erpnext.controllers.accounts_controller import get_default_taxes_and_charges
-
- quotation = frappe.get_doc(target)
-
- company_currency = frappe.get_cached_value("Company", quotation.company, "default_currency")
-
- if company_currency == quotation.currency:
- exchange_rate = 1
- else:
- exchange_rate = get_exchange_rate(
- quotation.currency, company_currency, quotation.transaction_date, args="for_selling"
- )
-
- quotation.conversion_rate = exchange_rate
-
- # get default taxes
- taxes = get_default_taxes_and_charges("Sales Taxes and Charges Template", company=quotation.company)
- if taxes.get("taxes"):
- quotation.update(taxes)
-
- quotation.run_method("set_missing_values")
- quotation.run_method("calculate_taxes_and_totals")
- if not source.get("items", []):
- quotation.opportunity = source.name
-
- doclist = get_mapped_doc(
- "Opportunity",
- source_name,
- {
- "Opportunity": {
- "doctype": "Quotation",
- "field_map": {"opportunity_from": "quotation_to", "name": "enq_no"},
- },
- "Opportunity Item": {
- "doctype": "Quotation Item",
- "field_map": {
- "parent": "prevdoc_docname",
- "parenttype": "prevdoc_doctype",
- "uom": "stock_uom",
- },
- "add_if_empty": True,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None):
- def update_item(obj, target, source_parent):
- target.conversion_factor = 1.0
-
- doclist = get_mapped_doc(
- "Opportunity",
- source_name,
- {
- "Opportunity": {"doctype": "Request for Quotation"},
- "Opportunity Item": {
- "doctype": "Request for Quotation Item",
- "field_map": [["name", "opportunity_item"], ["parent", "opportunity"], ["uom", "uom"]],
- "postprocess": update_item,
- },
- },
- target_doc,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_customer(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- target.opportunity_name = source.name
-
- if source.opportunity_from == "Lead":
- target.lead_name = source.party_name
-
- doclist = get_mapped_doc(
- "Opportunity",
- source_name,
- {
- "Opportunity": {
- "doctype": "Customer",
- "field_map": {"currency": "default_currency", "customer_name": "customer_name"},
- }
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None):
- doclist = get_mapped_doc(
- "Opportunity",
- source_name,
- {
- "Opportunity": {"doctype": "Supplier Quotation", "field_map": {"name": "opportunity"}},
- "Opportunity Item": {"doctype": "Supplier Quotation Item", "field_map": {"uom": "stock_uom"}},
- },
- target_doc,
- )
-
- return doclist
-
-
@frappe.whitelist()
def set_multiple_status(names: str | list[str], status: str):
names = json.loads(names)
@@ -531,31 +415,3 @@ def auto_close_opportunity():
doc.flags.ignore_permissions = True
doc.flags.ignore_mandatory = True
doc.save()
-
-
-@frappe.whitelist()
-def make_opportunity_from_communication(
- communication: str, company: str, ignore_communication_links: bool = False
-):
- from erpnext.crm.doctype.lead.lead import make_lead_from_communication
-
- doc = frappe.get_doc("Communication", communication)
-
- lead = doc.reference_name if doc.reference_doctype == "Lead" else None
- if not lead:
- lead = make_lead_from_communication(communication, ignore_communication_links=True)
-
- opportunity_from = "Lead"
-
- opportunity = frappe.get_doc(
- {
- "doctype": "Opportunity",
- "company": company,
- "opportunity_from": opportunity_from,
- "party_name": lead,
- }
- ).insert(ignore_permissions=True)
-
- link_communication_to_document(doc, "Opportunity", opportunity.name, ignore_communication_links)
-
- return opportunity.name
diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py
index 7032d3882ac..62fad25a574 100644
--- a/erpnext/crm/doctype/opportunity/test_opportunity.py
+++ b/erpnext/crm/doctype/opportunity/test_opportunity.py
@@ -4,9 +4,9 @@
import frappe
from frappe.utils import now_datetime, random_string, today
-from erpnext.crm.doctype.lead.lead import make_customer
+from erpnext.crm.doctype.lead.mapper import make_customer
from erpnext.crm.doctype.lead.test_lead import make_lead
-from erpnext.crm.doctype.opportunity.opportunity import make_quotation
+from erpnext.crm.doctype.opportunity.mapper import make_quotation
from erpnext.crm.utils import get_linked_communication_list
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
index 43c7e1c0204..1720448bc9a 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
+++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
@@ -65,7 +65,7 @@ erpnext.maintenance.MaintenanceSchedule = class MaintenanceSchedule extends frap
__("Sales Order"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_schedule",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_schedule",
source_doctype: "Sales Order",
target: me.frm,
setters: {
diff --git a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
index 0a05791b1e9..fa583d8a45e 100644
--- a/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
+++ b/erpnext/maintenance/doctype/maintenance_visit/maintenance_visit.js
@@ -126,7 +126,7 @@ erpnext.maintenance.MaintenanceVisit = class MaintenanceVisit extends frappe.ui.
return;
}
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_visit",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_visit",
source_doctype: "Sales Order",
target: me.frm,
setters: {
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js
index 795136d2374..51441e87430 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card.js
@@ -368,7 +368,7 @@ frappe.ui.form.on("Job Card", {
if (frm.doc.docstatus === 1 && frm.doc.for_quantity > frm.doc.manufactured_qty) {
frm.add_custom_button(__("Make Subcontracting PO"), () => {
frappe.model.open_mapped_doc({
- method: "erpnext.manufacturing.doctype.job_card.job_card.make_subcontracting_po",
+ method: "erpnext.manufacturing.doctype.job_card.mapper.make_subcontracting_po",
frm: frm,
});
}).addClass("btn-primary");
@@ -483,7 +483,7 @@ frappe.ui.form.on("Job Card", {
make_corrective_job_card(frm, operation, for_operation) {
frappe.call({
- method: "erpnext.manufacturing.doctype.job_card.job_card.make_corrective_job_card",
+ method: "erpnext.manufacturing.doctype.job_card.mapper.make_corrective_job_card",
args: {
source_name: frm.doc.name,
operation: operation,
@@ -816,7 +816,7 @@ frappe.ui.form.on("Job Card", {
make_material_request(frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.manufacturing.doctype.job_card.job_card.make_material_request",
+ method: "erpnext.manufacturing.doctype.job_card.mapper.make_material_request",
frm: frm,
run_link_triggers: true,
});
@@ -824,7 +824,7 @@ frappe.ui.form.on("Job Card", {
make_stock_entry(frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry",
+ method: "erpnext.manufacturing.doctype.job_card.mapper.make_stock_entry",
frm: frm,
run_link_triggers: true,
});
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index 9d2270f2ce5..040c487e668 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -8,7 +8,6 @@ from typing import Any
import frappe
from frappe import _, bold
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Criterion
from frappe.query_builder.functions import IfNull, Max, Min, Sum
from frappe.utils import (
@@ -37,6 +36,10 @@ from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import
get_subcontracting_boms_for_finished_goods,
)
+from .mapper import (
+ make_stock_entry,
+)
+
class OverlapError(frappe.ValidationError):
pass
@@ -1547,47 +1550,6 @@ class JobCard(Document):
return ste.stock_entry.as_dict()
-@frappe.whitelist()
-def make_subcontracting_po(source_name: str, target_doc: Document | str | None = None):
- def set_missing_values(source, target):
- _item_details = get_subcontracting_boms_for_finished_goods(source.finished_good)
-
- pending_qty = source.for_quantity - source.manufactured_qty
- service_item_qty = flt(_item_details.service_item_qty) or 1.0
- fg_item_qty = flt(_item_details.finished_good_qty) or 1.0
-
- target.is_subcontracted = 1
- target.supplier_warehouse = source.wip_warehouse
- target.append(
- "items",
- {
- "item_code": _item_details.service_item,
- "fg_item": source.finished_good,
- "uom": _item_details.service_item_uom,
- "stock_uom": _item_details.service_item_uom,
- "conversion_factor": _item_details.conversion_factor or 1,
- "item_name": _item_details.service_item,
- "qty": pending_qty * service_item_qty / fg_item_qty,
- "fg_item_qty": pending_qty,
- "job_card": source.name,
- "bom": source.semi_fg_bom,
- "warehouse": source.target_warehouse,
- },
- )
-
- doclist = get_mapped_doc(
- "Job Card",
- source_name,
- {
- "Job Card": {"doctype": "Purchase Order", "field_no_map": ["naming_series"]},
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
@frappe.whitelist()
def make_time_log(kwargs: str | dict):
if isinstance(kwargs, str):
@@ -1631,105 +1593,6 @@ def get_operations(doctype: str, txt: str, searchfield: str, start: int, page_le
)
-@frappe.whitelist()
-def make_material_request(source_name: str, target_doc: Document | str | None = None):
- def update_item(obj, target, source_parent):
- target.warehouse = source_parent.wip_warehouse
-
- def set_missing_values(source, target):
- target.material_request_type = "Material Transfer"
-
- doclist = get_mapped_doc(
- "Job Card",
- source_name,
- {
- "Job Card": {
- "doctype": "Material Request",
- "field_map": {
- "name": "job_card",
- },
- },
- "Job Card Item": {
- "doctype": "Material Request Item",
- "field_map": {"required_qty": "qty", "uom": "stock_uom", "name": "job_card_item"},
- "postprocess": update_item,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
- def update_item(source, target, source_parent):
- target.t_warehouse = source_parent.wip_warehouse
-
- if not target.conversion_factor:
- target.conversion_factor = 1
-
- pending_rm_qty = flt(source.required_qty) - flt(source.transferred_qty)
- if pending_rm_qty > 0:
- target.qty = pending_rm_qty
-
- def set_missing_values(source, target):
- if source.finished_good and not source.target_warehouse:
- frappe.throw(_("Please set the Target Warehouse in the Job Card"))
-
- if not source.skip_material_transfer or source.backflush_from_wip_warehouse:
- if not source.wip_warehouse:
- frappe.throw(_("Please set the WIP Warehouse in the Job Card"))
-
- target.purpose = "Material Transfer for Manufacture"
- target.from_bom = 1
-
- if source.semi_fg_bom:
- target.bom_no = source.semi_fg_bom
-
- # avoid negative 'For Quantity'
- pending_fg_qty = flt(source.get("for_quantity", 0)) - flt(source.get("transferred_qty", 0))
- target.fg_completed_qty = pending_fg_qty if pending_fg_qty > 0 else 0
-
- target.set_missing_values()
- target.set_stock_entry_type()
-
- wo_allows_alternate_item = frappe.db.get_value(
- "Work Order", target.work_order, "allow_alternative_item"
- )
- for item in target.items:
- item.allow_alternative_item = int(
- wo_allows_alternate_item
- and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
- )
-
- doclist = get_mapped_doc(
- "Job Card",
- source_name,
- {
- "Job Card": {
- "doctype": "Stock Entry",
- "field_map": {"name": "job_card", "for_quantity": "fg_completed_qty"},
- },
- "Job Card Item": {
- "doctype": "Stock Entry Detail",
- "field_map": {
- "source_warehouse": "s_warehouse",
- "required_qty": "qty",
- "name": "job_card_item",
- },
- "postprocess": update_item,
- "condition": lambda doc: doc.required_qty > 0,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
def time_diff_in_minutes(string_ed_date, string_st_date):
return time_diff(string_ed_date, string_st_date).total_seconds() / 60
@@ -1780,40 +1643,3 @@ def get_job_details(start: Any, end: Any, filters: str | dict | None = None):
events.append(job_card_data)
return events
-
-
-@frappe.whitelist()
-def make_corrective_job_card(
- source_name: str,
- operation: str | None = None,
- for_operation: str | None = None,
- target_doc: Document | str | None = None,
-):
- def set_missing_values(source, target):
- target.is_corrective_job_card = 1
- target.operation = operation
- target.for_operation = for_operation
-
- target.set("time_logs", [])
- target.set("employee", [])
- target.set("items", [])
- target.set("sub_operations", [])
- target.set_sub_operations()
- target.get_required_items()
-
- doclist = get_mapped_doc(
- "Job Card",
- source_name,
- {
- "Job Card": {
- "doctype": "Job Card",
- "field_map": {
- "name": "for_job_card",
- },
- }
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
diff --git a/erpnext/manufacturing/doctype/job_card/mapper.py b/erpnext/manufacturing/doctype/job_card/mapper.py
new file mode 100644
index 00000000000..0155229df3a
--- /dev/null
+++ b/erpnext/manufacturing/doctype/job_card/mapper.py
@@ -0,0 +1,189 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import flt
+
+from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
+ get_subcontracting_boms_for_finished_goods,
+)
+
+
+@frappe.whitelist()
+def make_subcontracting_po(source_name: str, target_doc: Document | str | None = None):
+ def set_missing_values(source, target):
+ _item_details = get_subcontracting_boms_for_finished_goods(source.finished_good)
+
+ pending_qty = source.for_quantity - source.manufactured_qty
+ service_item_qty = flt(_item_details.service_item_qty) or 1.0
+ fg_item_qty = flt(_item_details.finished_good_qty) or 1.0
+
+ target.is_subcontracted = 1
+ target.supplier_warehouse = source.wip_warehouse
+ target.append(
+ "items",
+ {
+ "item_code": _item_details.service_item,
+ "fg_item": source.finished_good,
+ "uom": _item_details.service_item_uom,
+ "stock_uom": _item_details.service_item_uom,
+ "conversion_factor": _item_details.conversion_factor or 1,
+ "item_name": _item_details.service_item,
+ "qty": pending_qty * service_item_qty / fg_item_qty,
+ "fg_item_qty": pending_qty,
+ "job_card": source.name,
+ "bom": source.semi_fg_bom,
+ "warehouse": source.target_warehouse,
+ },
+ )
+
+ doclist = get_mapped_doc(
+ "Job Card",
+ source_name,
+ {
+ "Job Card": {"doctype": "Purchase Order", "field_no_map": ["naming_series"]},
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_material_request(source_name: str, target_doc: Document | str | None = None):
+ def update_item(obj, target, source_parent):
+ target.warehouse = source_parent.wip_warehouse
+
+ def set_missing_values(source, target):
+ target.material_request_type = "Material Transfer"
+
+ doclist = get_mapped_doc(
+ "Job Card",
+ source_name,
+ {
+ "Job Card": {
+ "doctype": "Material Request",
+ "field_map": {
+ "name": "job_card",
+ },
+ },
+ "Job Card Item": {
+ "doctype": "Material Request Item",
+ "field_map": {"required_qty": "qty", "uom": "stock_uom", "name": "job_card_item"},
+ "postprocess": update_item,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
+ def update_item(source, target, source_parent):
+ target.t_warehouse = source_parent.wip_warehouse
+
+ if not target.conversion_factor:
+ target.conversion_factor = 1
+
+ pending_rm_qty = flt(source.required_qty) - flt(source.transferred_qty)
+ if pending_rm_qty > 0:
+ target.qty = pending_rm_qty
+
+ def set_missing_values(source, target):
+ if source.finished_good and not source.target_warehouse:
+ frappe.throw(_("Please set the Target Warehouse in the Job Card"))
+
+ if not source.skip_material_transfer or source.backflush_from_wip_warehouse:
+ if not source.wip_warehouse:
+ frappe.throw(_("Please set the WIP Warehouse in the Job Card"))
+
+ target.purpose = "Material Transfer for Manufacture"
+ target.from_bom = 1
+
+ if source.semi_fg_bom:
+ target.bom_no = source.semi_fg_bom
+
+ # avoid negative 'For Quantity'
+ pending_fg_qty = flt(source.get("for_quantity", 0)) - flt(source.get("transferred_qty", 0))
+ target.fg_completed_qty = pending_fg_qty if pending_fg_qty > 0 else 0
+
+ target.set_missing_values()
+ target.set_stock_entry_type()
+
+ wo_allows_alternate_item = frappe.db.get_value(
+ "Work Order", target.work_order, "allow_alternative_item"
+ )
+ for item in target.items:
+ item.allow_alternative_item = int(
+ wo_allows_alternate_item
+ and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
+ )
+
+ doclist = get_mapped_doc(
+ "Job Card",
+ source_name,
+ {
+ "Job Card": {
+ "doctype": "Stock Entry",
+ "field_map": {"name": "job_card", "for_quantity": "fg_completed_qty"},
+ },
+ "Job Card Item": {
+ "doctype": "Stock Entry Detail",
+ "field_map": {
+ "source_warehouse": "s_warehouse",
+ "required_qty": "qty",
+ "name": "job_card_item",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: doc.required_qty > 0,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_corrective_job_card(
+ source_name: str,
+ operation: str | None = None,
+ for_operation: str | None = None,
+ target_doc: Document | str | None = None,
+):
+ def set_missing_values(source, target):
+ target.is_corrective_job_card = 1
+ target.operation = operation
+ target.for_operation = for_operation
+
+ target.set("time_logs", [])
+ target.set("employee", [])
+ target.set("items", [])
+ target.set("sub_operations", [])
+ target.set_sub_operations()
+ target.get_required_items()
+
+ doclist = get_mapped_doc(
+ "Job Card",
+ source_name,
+ {
+ "Job Card": {
+ "doctype": "Job Card",
+ "field_map": {
+ "name": "for_job_card",
+ },
+ }
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py
index e7316259d01..c44591d6ab3 100644
--- a/erpnext/manufacturing/doctype/job_card/test_job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py
@@ -12,10 +12,12 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
JobCardOverTransferError,
OperationMismatchError,
OverlapError,
+)
+from erpnext.manufacturing.doctype.job_card.mapper import (
make_corrective_job_card,
make_material_request,
)
-from erpnext.manufacturing.doctype.job_card.job_card import (
+from erpnext.manufacturing.doctype.job_card.mapper import (
make_stock_entry as make_stock_entry_from_jc,
)
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
@@ -552,7 +554,7 @@ class TestJobCard(ERPNextTestSuite):
corrective_job_card.submit()
wo.reload()
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_stock_entry_for_wo,
)
@@ -623,7 +625,7 @@ class TestJobCard(ERPNextTestSuite):
assertStatus("Cancelled")
def test_job_card_material_request_and_bom_details(self):
- from erpnext.stock.doctype.material_request.material_request import make_stock_entry
+ from erpnext.stock.doctype.material_request.mapper import make_stock_entry
create_bom_with_multiple_operations()
work_order = make_wo_with_transfer_against_jc()
@@ -647,7 +649,7 @@ class TestJobCard(ERPNextTestSuite):
setup_bom,
setup_operations,
)
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_stock_entry_for_wo,
)
from erpnext.stock.doctype.item.test_item import make_item
@@ -788,10 +790,10 @@ class TestJobCard(ERPNextTestSuite):
setup_bom,
setup_operations,
)
- from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_stock_entry_for_wo,
)
+ from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@@ -1075,7 +1077,7 @@ class TestJobCard(ERPNextTestSuite):
job_card.save()
job_card.submit()
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_stock_entry_for_wo,
)
@@ -1094,10 +1096,10 @@ class TestJobCard(ERPNextTestSuite):
setup_bom,
setup_operations,
)
- from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_stock_entry_for_wo,
)
+ from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index e7cab6deff5..a65e317904d 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -10,9 +10,9 @@ from erpnext.manufacturing.doctype.production_plan.production_plan import (
get_sales_orders,
get_warehouse_list,
)
+from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_se_from_wo
from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
-from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_se_from_wo
-from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
@@ -523,13 +523,13 @@ class TestProductionPlan(ERPNextTestSuite):
)
def make_purchase_receipt_from_po(po_doc):
- from erpnext.buying.doctype.purchase_order.purchase_order import make_subcontracting_order
+ from erpnext.buying.doctype.purchase_order.mapper import make_subcontracting_order
from erpnext.controllers.subcontracting_controller import make_rm_stock_entry
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
make_subcontracting_receipt,
)
- from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
+ from erpnext.subcontracting.doctype.subcontracting_receipt.mapper import (
make_purchase_receipt as scr_make_purchase_receipt,
)
@@ -2211,9 +2211,9 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(mr_items_dict["RM Item 2"], 80)
def test_stock_reservation_against_production_plan(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
- from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+ from erpnext.stock.doctype.material_request.mapper import make_purchase_order
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
@@ -2323,9 +2323,9 @@ class TestProductionPlan(ERPNextTestSuite):
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_of_serial_nos_against_production_plan(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
- from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+ from erpnext.stock.doctype.material_request.mapper import make_purchase_order
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
@@ -2470,9 +2470,9 @@ class TestProductionPlan(ERPNextTestSuite):
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 0)
def test_stock_reservation_of_batch_nos_against_production_plan(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
- from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+ from erpnext.stock.doctype.material_request.mapper import make_purchase_order
frappe.db.set_single_value("Stock Settings", "enable_stock_reservation", 1)
diff --git a/erpnext/manufacturing/doctype/work_order/mapper.py b/erpnext/manufacturing/doctype/work_order/mapper.py
new file mode 100644
index 00000000000..e2230a1367f
--- /dev/null
+++ b/erpnext/manufacturing/doctype/work_order/mapper.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import flt
+
+
+@frappe.whitelist()
+def make_stock_entry(
+ work_order_id: str,
+ purpose: str,
+ qty: float | None = None,
+ target_warehouse: str | None = None,
+ is_additional_transfer_entry: bool = False,
+ source_stock_entry: str | None = None,
+):
+ work_order = frappe.get_doc("Work Order", work_order_id)
+ if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
+ wip_warehouse = work_order.wip_warehouse
+ else:
+ wip_warehouse = None
+
+ stock_entry = frappe.new_doc("Stock Entry")
+ stock_entry.purpose = purpose
+ stock_entry.work_order = work_order_id
+ stock_entry.company = work_order.company
+ stock_entry.from_bom = 1
+ stock_entry.bom_no = work_order.bom_no
+ stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
+ if purpose in ["Material Transfer for Manufacture", "Manufacture"]:
+ stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order
+ # accept 0 qty as well
+ stock_entry.fg_completed_qty = (
+ qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty))
+ )
+
+ if purpose == "Material Transfer for Manufacture":
+ stock_entry.to_warehouse = wip_warehouse
+ stock_entry.project = work_order.project
+ else:
+ stock_entry.from_warehouse = (
+ work_order.source_warehouse
+ if work_order.skip_transfer and not work_order.from_wip_warehouse
+ else wip_warehouse
+ )
+ stock_entry.to_warehouse = work_order.fg_warehouse
+ stock_entry.project = work_order.project
+ if work_order.bom_no:
+ stock_entry.inspection_required = frappe.db.get_value(
+ "BOM", work_order.bom_no, "inspection_required"
+ )
+
+ if purpose == "Disassemble":
+ stock_entry.from_warehouse = work_order.fg_warehouse
+ stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
+ if source_stock_entry:
+ stock_entry.source_stock_entry = source_stock_entry
+
+ stock_entry.set_stock_entry_type()
+ stock_entry.is_additional_transfer_entry = is_additional_transfer_entry
+ stock_entry.get_items()
+
+ return stock_entry.as_dict()
+
+
+@frappe.whitelist()
+def create_pick_list(source_name: str, target_doc: str | None = None, for_qty: float | None = None):
+ for_qty = for_qty or json.loads(target_doc).get("for_qty")
+ max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
+
+ def update_item_quantity(source, target, source_parent):
+ pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
+ desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
+
+ qty = 0
+ if desire_to_transfer <= pending_to_issue:
+ qty = desire_to_transfer
+ elif pending_to_issue > 0:
+ qty = pending_to_issue
+
+ if qty:
+ target.qty = qty
+ target.stock_qty = qty
+ target.uom = frappe.get_value("Item", source.item_code, "stock_uom")
+ target.stock_uom = target.uom
+ target.conversion_factor = 1
+ else:
+ target.delete()
+
+ doc = get_mapped_doc(
+ "Work Order",
+ source_name,
+ {
+ "Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
+ "Work Order Item": {
+ "doctype": "Pick List Item",
+ "postprocess": update_item_quantity,
+ "condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
+ },
+ },
+ target_doc,
+ )
+
+ doc.purpose = "Material Transfer for Manufacture"
+ doc.for_qty = for_qty
+
+ doc.set_item_locations()
+
+ return doc
+
+
+@frappe.whitelist()
+def make_stock_return_entry(work_order: str):
+ from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
+ ManufactureStockEntry,
+ )
+
+ wo_doc = frappe.get_cached_doc("Work Order", work_order)
+
+ stock_entry = frappe.new_doc("Stock Entry")
+ stock_entry.from_bom = 1
+ stock_entry.is_return = 1
+ stock_entry.work_order = work_order
+ stock_entry.purpose = "Material Transfer for Manufacture"
+ stock_entry.bom_no = wo_doc.bom_no
+ stock_entry.set_stock_entry_type()
+
+ ste_cls = ManufactureStockEntry(stock_entry)
+ ste_cls.add_raw_materials_based_on_transfer()
+ ste_cls.return_available_materials_in_source_wh()
+ return stock_entry
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 6293aad86e5..524075c0a15 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -9,8 +9,12 @@ from frappe.tests import timeout
from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
-from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc
+from erpnext.manufacturing.doctype.job_card.mapper import make_stock_entry as make_stock_entry_from_jc
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+from erpnext.manufacturing.doctype.work_order.mapper import (
+ make_stock_entry,
+ make_stock_return_entry,
+)
from erpnext.manufacturing.doctype.work_order.work_order import (
CapacityError,
ItemHasVariantError,
@@ -18,8 +22,6 @@ from erpnext.manufacturing.doctype.work_order.work_order import (
StockOverProductionError,
close_work_order,
make_job_card,
- make_stock_entry,
- make_stock_return_entry,
stop_unstop,
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js
index 5131b30c889..7586b4ab956 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.js
+++ b/erpnext/manufacturing/doctype/work_order/work_order.js
@@ -289,7 +289,7 @@ frappe.ui.form.on("Work Order", {
create_stock_return_entry: function (frm) {
frappe.call({
- method: "erpnext.manufacturing.doctype.work_order.work_order.make_stock_return_entry",
+ method: "erpnext.manufacturing.doctype.work_order.mapper.make_stock_return_entry",
args: {
work_order: frm.doc.name,
},
@@ -445,7 +445,7 @@ frappe.ui.form.on("Work Order", {
frappe.msgprint(__("Disassemble Qty cannot be less than or equal to 0."));
return;
}
- return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", {
+ return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", {
work_order_id: frm.doc.name,
purpose: "Disassemble",
qty: data.qty,
@@ -822,7 +822,7 @@ erpnext.work_order = {
.show_prompt_for_qty_input(frm, purpose, qty, 1)
.then((data) => {
return frappe.xcall(
- "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry",
+ "erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
{
work_order_id: frm.doc.name,
purpose: purpose,
@@ -1110,7 +1110,7 @@ erpnext.work_order = {
make_se: function (frm, purpose, qty, is_additional_transfer_entry) {
if (qty) {
frappe
- .xcall("erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", {
+ .xcall("erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", {
work_order_id: frm.doc.name,
purpose: purpose,
qty: qty,
@@ -1123,14 +1123,11 @@ erpnext.work_order = {
} else {
this.show_prompt_for_qty_input(frm, purpose)
.then((data) => {
- return frappe.xcall(
- "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry",
- {
- work_order_id: frm.doc.name,
- purpose: purpose,
- qty: data.qty,
- }
- );
+ return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry", {
+ work_order_id: frm.doc.name,
+ purpose: purpose,
+ qty: data.qty,
+ });
})
.then((stock_entry) => {
frappe.model.sync(stock_entry);
@@ -1142,7 +1139,7 @@ erpnext.work_order = {
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
this.show_prompt_for_qty_input(frm, purpose)
.then((data) => {
- return frappe.xcall("erpnext.manufacturing.doctype.work_order.work_order.create_pick_list", {
+ return frappe.xcall("erpnext.manufacturing.doctype.work_order.mapper.create_pick_list", {
source_name: frm.doc.name,
for_qty: data.qty,
});
@@ -1166,7 +1163,7 @@ erpnext.work_order = {
}
frappe.call({
- method: "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry",
+ method: "erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
args: {
work_order_id: frm.doc.name,
purpose: "Material Consumption for Manufacture",
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 9c2ed1a2255..b9b35e5bdec 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -8,7 +8,6 @@ import frappe
from dateutil.relativedelta import relativedelta
from frappe import _
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Case
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import (
@@ -2400,64 +2399,6 @@ def set_work_order_ops(name: str):
po.save()
-@frappe.whitelist()
-def make_stock_entry(
- work_order_id: str,
- purpose: str,
- qty: float | None = None,
- target_warehouse: str | None = None,
- is_additional_transfer_entry: bool = False,
- source_stock_entry: str | None = None,
-):
- work_order = frappe.get_doc("Work Order", work_order_id)
- if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"):
- wip_warehouse = work_order.wip_warehouse
- else:
- wip_warehouse = None
-
- stock_entry = frappe.new_doc("Stock Entry")
- stock_entry.purpose = purpose
- stock_entry.work_order = work_order_id
- stock_entry.company = work_order.company
- stock_entry.from_bom = 1
- stock_entry.bom_no = work_order.bom_no
- stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
- if purpose in ["Material Transfer for Manufacture", "Manufacture"]:
- stock_entry.subcontracting_inward_order = work_order.subcontracting_inward_order
- # accept 0 qty as well
- stock_entry.fg_completed_qty = (
- qty if qty is not None else (flt(work_order.qty) - flt(work_order.produced_qty))
- )
-
- if purpose == "Material Transfer for Manufacture":
- stock_entry.to_warehouse = wip_warehouse
- stock_entry.project = work_order.project
- else:
- stock_entry.from_warehouse = (
- work_order.source_warehouse
- if work_order.skip_transfer and not work_order.from_wip_warehouse
- else wip_warehouse
- )
- stock_entry.to_warehouse = work_order.fg_warehouse
- stock_entry.project = work_order.project
- if work_order.bom_no:
- stock_entry.inspection_required = frappe.db.get_value(
- "BOM", work_order.bom_no, "inspection_required"
- )
-
- if purpose == "Disassemble":
- stock_entry.from_warehouse = work_order.fg_warehouse
- stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse
- if source_stock_entry:
- stock_entry.source_stock_entry = source_stock_entry
-
- stock_entry.set_stock_entry_type()
- stock_entry.is_additional_transfer_entry = is_additional_transfer_entry
- stock_entry.get_items()
-
- return stock_entry.as_dict()
-
-
@frappe.whitelist()
def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float:
se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True)
@@ -2717,52 +2658,6 @@ def get_work_order_operation_data(work_order, operation, workstation):
return d
-@frappe.whitelist()
-def create_pick_list(source_name: str, target_doc: str | None = None, for_qty: float | None = None):
- for_qty = for_qty or json.loads(target_doc).get("for_qty")
- max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
-
- def update_item_quantity(source, target, source_parent):
- pending_to_issue = flt(source.required_qty) - flt(source.transferred_qty)
- desire_to_transfer = flt(source.required_qty) / max_finished_goods_qty * flt(for_qty)
-
- qty = 0
- if desire_to_transfer <= pending_to_issue:
- qty = desire_to_transfer
- elif pending_to_issue > 0:
- qty = pending_to_issue
-
- if qty:
- target.qty = qty
- target.stock_qty = qty
- target.uom = frappe.get_value("Item", source.item_code, "stock_uom")
- target.stock_uom = target.uom
- target.conversion_factor = 1
- else:
- target.delete()
-
- doc = get_mapped_doc(
- "Work Order",
- source_name,
- {
- "Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
- "Work Order Item": {
- "doctype": "Pick List Item",
- "postprocess": update_item_quantity,
- "condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
- },
- },
- target_doc,
- )
-
- doc.purpose = "Material Transfer for Manufacture"
- doc.for_qty = for_qty
-
- doc.set_item_locations()
-
- return doc
-
-
def get_reserved_qty_for_production(
item_code: str,
warehouse: str,
@@ -2812,28 +2707,6 @@ def get_reserved_qty_for_production(
return query.run()[0][0] or 0.0
-@frappe.whitelist()
-def make_stock_return_entry(work_order: str):
- from erpnext.stock.doctype.stock_entry.stock_entry_handler.manufacturing import (
- ManufactureStockEntry,
- )
-
- wo_doc = frappe.get_cached_doc("Work Order", work_order)
-
- stock_entry = frappe.new_doc("Stock Entry")
- stock_entry.from_bom = 1
- stock_entry.is_return = 1
- stock_entry.work_order = work_order
- stock_entry.purpose = "Material Transfer for Manufacture"
- stock_entry.bom_no = wo_doc.bom_no
- stock_entry.set_stock_entry_type()
-
- ste_cls = ManufactureStockEntry(stock_entry)
- ste_cls.add_raw_materials_based_on_transfer()
- ste_cls.return_available_materials_in_source_wh()
- return stock_entry
-
-
def get_row_wise_serial_batch(work_order, purpose=None):
if not purpose:
purpose = "Material Transfer for Manufacture"
diff --git a/erpnext/manufacturing/doctype/workstation/workstation.js b/erpnext/manufacturing/doctype/workstation/workstation.js
index 4c1c9d2c976..dae339fd716 100644
--- a/erpnext/manufacturing/doctype/workstation/workstation.js
+++ b/erpnext/manufacturing/doctype/workstation/workstation.js
@@ -402,7 +402,7 @@ class WorkstationDashboard {
if (r.message) {
me.prepare_materials_modal(r.message, job_card, (job_card) => {
frappe.call({
- method: "erpnext.manufacturing.doctype.job_card.job_card.make_stock_entry",
+ method: "erpnext.manufacturing.doctype.job_card.mapper.make_stock_entry",
args: {
source_name: job_card,
},
diff --git a/erpnext/projects/doctype/project/test_project.py b/erpnext/projects/doctype/project/test_project.py
index 06f7c1c04b4..b24f0d13fe2 100644
--- a/erpnext/projects/doctype/project/test_project.py
+++ b/erpnext/projects/doctype/project/test_project.py
@@ -6,7 +6,7 @@ from frappe.utils import add_days, getdate, nowdate
from erpnext.projects.doctype.project_template.test_project_template import make_project_template
from erpnext.projects.doctype.task.test_task import create_task
-from erpnext.selling.doctype.sales_order.sales_order import make_project as make_project_from_so
+from erpnext.selling.doctype.sales_order.mapper import make_project as make_project_from_so
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index cf3d9a17b2d..9495e0ef0d9 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -5,7 +5,7 @@ import datetime
import frappe
from frappe.utils import add_to_date, now_datetime, nowdate
-from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.projects.doctype.task.test_task import create_task
from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice
diff --git a/erpnext/public/js/communication.js b/erpnext/public/js/communication.js
index c8905e14af2..0eb276e10f1 100644
--- a/erpnext/public/js/communication.js
+++ b/erpnext/public/js/communication.js
@@ -45,7 +45,7 @@ frappe.ui.form.on("Communication", {
make_lead_from_communication: (frm) => {
return frappe.call({
- method: "erpnext.crm.doctype.lead.lead.make_lead_from_communication",
+ method: "erpnext.crm.doctype.lead.mapper.make_lead_from_communication",
args: {
communication: frm.doc.name,
},
@@ -89,7 +89,7 @@ frappe.ui.form.on("Communication", {
fields,
(data) => {
frappe.call({
- method: "erpnext.crm.doctype.opportunity.opportunity.make_opportunity_from_communication",
+ method: "erpnext.crm.doctype.opportunity.mapper.make_opportunity_from_communication",
args: {
communication: frm.doc.name,
company: data.company,
diff --git a/erpnext/regional/united_arab_emirates/utils.py b/erpnext/regional/united_arab_emirates/utils.py
index 28997542393..671f726a740 100644
--- a/erpnext/regional/united_arab_emirates/utils.py
+++ b/erpnext/regional/united_arab_emirates/utils.py
@@ -140,7 +140,9 @@ def update_totals(vat_tax, base_vat_tax, doc):
doc.in_words = money_in_words(doc.grand_total, doc.currency)
doc.base_in_words = money_in_words(doc.base_grand_total, erpnext.get_company_currency(doc.company))
- doc.set_payment_schedule()
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ PaymentScheduleService(doc).set_payment_schedule()
def make_regional_gl_entries(gl_entries, doc):
diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js
index aac09bbd663..b4c5a7f3064 100644
--- a/erpnext/selling/doctype/customer/customer.js
+++ b/erpnext/selling/doctype/customer/customer.js
@@ -13,7 +13,7 @@ frappe.ui.form.on("Customer", {
frm.make_methods = {
Quotation: () =>
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.customer.customer.make_quotation",
+ method: "erpnext.selling.doctype.customer.mapper.make_quotation",
frm: frm,
}),
"Sales Order": () =>
@@ -24,12 +24,12 @@ frappe.ui.form.on("Customer", {
}),
Opportunity: () =>
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.customer.customer.make_opportunity",
+ method: "erpnext.selling.doctype.customer.mapper.make_opportunity",
frm: frm,
}),
"Payment Entry": () =>
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.customer.customer.make_payment_entry",
+ method: "erpnext.selling.doctype.customer.mapper.make_payment_entry",
frm: frm,
}),
"Pricing Rule": () => frm.trigger("make_pricing_rule"),
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index 8368fd90ee9..a79bc9e935a 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -12,7 +12,6 @@ from frappe.contacts.address_and_contact import (
load_address_and_contact,
)
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
from frappe.model.utils.rename_doc import update_linked_doctypes
from frappe.query_builder import CustomFunction, Field, functions
@@ -28,6 +27,11 @@ from erpnext.accounts.party import (
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
from erpnext.utilities.transaction_base import TransactionBase
+from .mapper import (
+ make_address,
+ make_contact,
+)
+
class Customer(TransactionBase):
# begin: auto-generated types
@@ -440,117 +444,6 @@ class Customer(TransactionBase):
return None
-@frappe.whitelist()
-def make_quotation(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- _set_missing_values(source, target)
-
- target_doc = get_mapped_doc(
- "Customer",
- source_name,
- {"Customer": {"doctype": "Quotation", "field_map": {"name": "party_name"}}},
- target_doc,
- set_missing_values,
- )
-
- target_doc.quotation_to = "Customer"
- target_doc.run_method("set_missing_values")
- target_doc.run_method("set_other_charges")
- target_doc.run_method("calculate_taxes_and_totals")
-
- price_list, currency = frappe.db.get_value(
- "Customer", {"name": source_name}, ["default_price_list", "default_currency"]
- )
- if price_list:
- target_doc.selling_price_list = price_list
- if currency:
- target_doc.currency = currency
-
- return target_doc
-
-
-@frappe.whitelist()
-def make_opportunity(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- _set_missing_values(source, target)
-
- target_doc = get_mapped_doc(
- "Customer",
- source_name,
- {
- "Customer": {
- "doctype": "Opportunity",
- "field_map": {
- "name": "party_name",
- "doctype": "opportunity_from",
- },
- }
- },
- target_doc,
- set_missing_values,
- )
-
- return target_doc
-
-
-@frappe.whitelist()
-def make_payment_entry(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- _set_missing_values(source, target)
-
- target_doc = get_mapped_doc(
- "Customer",
- source_name,
- {
- "Customer": {
- "doctype": "Payment Entry",
- "field_map": {
- "name": "party",
- },
- }
- },
- target_doc,
- set_missing_values,
- )
- target_doc.party_type = "Customer"
- target_doc.party_name = target_doc.party
-
- return target_doc
-
-
-def _set_missing_values(source, target):
- address = frappe.get_all(
- "Dynamic Link",
- {
- "link_doctype": source.doctype,
- "link_name": source.name,
- "parenttype": "Address",
- },
- ["parent"],
- limit=1,
- )
-
- contact = frappe.get_all(
- "Dynamic Link",
- {
- "link_doctype": source.doctype,
- "link_name": source.name,
- "parenttype": "Contact",
- },
- ["parent"],
- limit=1,
- )
-
- if address:
- target.customer_address = address[0].parent
-
- if contact:
- target.contact_person = contact[0].parent
- target.contact_display, target.contact_email, target.contact_mobile = frappe.get_value(
- "Contact", contact[0].parent, ["full_name", "email_id", "mobile_no"]
- )
-
-
@frappe.whitelist()
def get_loyalty_programs(doc: Document):
"""returns applicable loyalty programs for a customer"""
@@ -790,90 +683,6 @@ def get_credit_limit(customer, company):
return flt(credit_limit)
-def make_contact(args, is_primary_contact=1):
- values = {
- "doctype": "Contact",
- "is_primary_contact": is_primary_contact,
- "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}],
- }
-
- party_type = args.customer_type if args.doctype == "Customer" else args.supplier_type
- party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name"
-
- if party_type == "Individual":
- first, middle, last = parse_full_name(args.get(party_name_key))
- values.update(
- {
- "first_name": first,
- "middle_name": middle,
- "last_name": last,
- }
- )
- else:
- values.update(
- {
- "company_name": args.get(party_name_key),
- }
- )
-
- contact = frappe.get_doc(values)
-
- if args.get("email_id"):
- contact.add_email(args.get("email_id"), is_primary=True)
- if args.get("mobile_no"):
- contact.add_phone(args.get("mobile_no"), is_primary_mobile_no=True)
- if args.get("first_name"):
- contact.first_name = args.get("first_name")
- if args.get("last_name"):
- contact.last_name = args.get("last_name")
-
- if flags := args.get("flags"):
- contact.insert(ignore_permissions=flags.get("ignore_permissions"))
- else:
- contact.insert()
-
- return contact
-
-
-def make_address(args, is_primary_address=1, is_shipping_address=1):
- reqd_fields = []
- for field in ["city", "country"]:
- if not args.get(field):
- reqd_fields.append("" + field.title() + "")
-
- if reqd_fields:
- msg = _("Following fields are mandatory to create address:")
- frappe.throw(
- "{}
".format(msg, "\n".join(reqd_fields)),
- title=_("Missing Values Required"),
- )
-
- party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name"
-
- address = frappe.get_doc(
- {
- "doctype": "Address",
- "address_title": args.get(party_name_key),
- "address_line1": args.get("address_line1"),
- "address_line2": args.get("address_line2"),
- "city": args.get("city"),
- "state": args.get("state"),
- "pincode": args.get("pincode"),
- "country": args.get("country"),
- "is_primary_address": is_primary_address,
- "is_shipping_address": is_shipping_address,
- "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}],
- }
- )
-
- if flags := args.get("flags"):
- address.insert(ignore_permissions=flags.get("ignore_permissions"))
- else:
- address.insert()
-
- return address
-
-
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_customer_primary(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
@@ -898,13 +707,3 @@ def get_customer_primary(doctype: str, txt: str, searchfield: str, start: int, p
query = query.select(type_doctype.email_id)
return query.run()
-
-
-def parse_full_name(full_name: str) -> tuple[str, str | None, str | None]:
- """Parse full name into first name, middle name and last name"""
- names = full_name.split()
- first_name = names[0]
- middle_name = " ".join(names[1:-1]) if len(names) > 2 else None
- last_name = names[-1] if len(names) > 1 else None
-
- return first_name, middle_name, last_name
diff --git a/erpnext/selling/doctype/customer/mapper.py b/erpnext/selling/doctype/customer/mapper.py
new file mode 100644
index 00000000000..7f30aef8cc0
--- /dev/null
+++ b/erpnext/selling/doctype/customer/mapper.py
@@ -0,0 +1,212 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+
+
+@frappe.whitelist()
+def make_quotation(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ _set_missing_values(source, target)
+
+ target_doc = get_mapped_doc(
+ "Customer",
+ source_name,
+ {"Customer": {"doctype": "Quotation", "field_map": {"name": "party_name"}}},
+ target_doc,
+ set_missing_values,
+ )
+
+ target_doc.quotation_to = "Customer"
+ target_doc.run_method("set_missing_values")
+ target_doc.run_method("set_other_charges")
+ target_doc.run_method("calculate_taxes_and_totals")
+
+ price_list, currency = frappe.db.get_value(
+ "Customer", {"name": source_name}, ["default_price_list", "default_currency"]
+ )
+ if price_list:
+ target_doc.selling_price_list = price_list
+ if currency:
+ target_doc.currency = currency
+
+ return target_doc
+
+
+@frappe.whitelist()
+def make_opportunity(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ _set_missing_values(source, target)
+
+ target_doc = get_mapped_doc(
+ "Customer",
+ source_name,
+ {
+ "Customer": {
+ "doctype": "Opportunity",
+ "field_map": {
+ "name": "party_name",
+ "doctype": "opportunity_from",
+ },
+ }
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return target_doc
+
+
+@frappe.whitelist()
+def make_payment_entry(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ _set_missing_values(source, target)
+
+ target_doc = get_mapped_doc(
+ "Customer",
+ source_name,
+ {
+ "Customer": {
+ "doctype": "Payment Entry",
+ "field_map": {
+ "name": "party",
+ },
+ }
+ },
+ target_doc,
+ set_missing_values,
+ )
+ target_doc.party_type = "Customer"
+ target_doc.party_name = target_doc.party
+
+ return target_doc
+
+
+def _set_missing_values(source, target):
+ address = frappe.get_all(
+ "Dynamic Link",
+ {
+ "link_doctype": source.doctype,
+ "link_name": source.name,
+ "parenttype": "Address",
+ },
+ ["parent"],
+ limit=1,
+ )
+
+ contact = frappe.get_all(
+ "Dynamic Link",
+ {
+ "link_doctype": source.doctype,
+ "link_name": source.name,
+ "parenttype": "Contact",
+ },
+ ["parent"],
+ limit=1,
+ )
+
+ if address:
+ target.customer_address = address[0].parent
+
+ if contact:
+ target.contact_person = contact[0].parent
+ target.contact_display, target.contact_email, target.contact_mobile = frappe.get_value(
+ "Contact", contact[0].parent, ["full_name", "email_id", "mobile_no"]
+ )
+
+
+def make_contact(args, is_primary_contact=1):
+ values = {
+ "doctype": "Contact",
+ "is_primary_contact": is_primary_contact,
+ "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}],
+ }
+
+ party_type = args.customer_type if args.doctype == "Customer" else args.supplier_type
+ party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name"
+
+ if party_type == "Individual":
+ first, middle, last = parse_full_name(args.get(party_name_key))
+ values.update(
+ {
+ "first_name": first,
+ "middle_name": middle,
+ "last_name": last,
+ }
+ )
+ else:
+ values.update(
+ {
+ "company_name": args.get(party_name_key),
+ }
+ )
+
+ contact = frappe.get_doc(values)
+
+ if args.get("email_id"):
+ contact.add_email(args.get("email_id"), is_primary=True)
+ if args.get("mobile_no"):
+ contact.add_phone(args.get("mobile_no"), is_primary_mobile_no=True)
+ if args.get("first_name"):
+ contact.first_name = args.get("first_name")
+ if args.get("last_name"):
+ contact.last_name = args.get("last_name")
+
+ if flags := args.get("flags"):
+ contact.insert(ignore_permissions=flags.get("ignore_permissions"))
+ else:
+ contact.insert()
+
+ return contact
+
+
+def make_address(args, is_primary_address=1, is_shipping_address=1):
+ reqd_fields = []
+ for field in ["city", "country"]:
+ if not args.get(field):
+ reqd_fields.append("" + field.title() + "")
+
+ if reqd_fields:
+ msg = _("Following fields are mandatory to create address:")
+ frappe.throw(
+ "{}
".format(msg, "\n".join(reqd_fields)),
+ title=_("Missing Values Required"),
+ )
+
+ party_name_key = "customer_name" if args.doctype == "Customer" else "supplier_name"
+
+ address = frappe.get_doc(
+ {
+ "doctype": "Address",
+ "address_title": args.get(party_name_key),
+ "address_line1": args.get("address_line1"),
+ "address_line2": args.get("address_line2"),
+ "city": args.get("city"),
+ "state": args.get("state"),
+ "pincode": args.get("pincode"),
+ "country": args.get("country"),
+ "is_primary_address": is_primary_address,
+ "is_shipping_address": is_shipping_address,
+ "links": [{"link_doctype": args.get("doctype"), "link_name": args.get("name")}],
+ }
+ )
+
+ if flags := args.get("flags"):
+ address.insert(ignore_permissions=flags.get("ignore_permissions"))
+ else:
+ address.insert()
+
+ return address
+
+
+def parse_full_name(full_name: str) -> tuple[str, str | None, str | None]:
+ """Parse full name into first name, middle name and last name"""
+ names = full_name.split()
+ first_name = names[0]
+ middle_name = " ".join(names[1:-1]) if len(names) > 2 else None
+ last_name = names[-1] if len(names) > 1 else None
+
+ return first_name, middle_name, last_name
diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py
index ba17f9ed4a8..4ed94683bec 100644
--- a/erpnext/selling/doctype/customer/test_customer.py
+++ b/erpnext/selling/doctype/customer/test_customer.py
@@ -12,6 +12,8 @@ from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import (
get_credit_limit,
get_customer_outstanding,
+)
+from erpnext.selling.doctype.customer.mapper import (
parse_full_name,
)
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/selling/doctype/installation_note/installation_note.js b/erpnext/selling/doctype/installation_note/installation_note.js
index 4e13fa76d5c..43badd36c06 100644
--- a/erpnext/selling/doctype/installation_note/installation_note.js
+++ b/erpnext/selling/doctype/installation_note/installation_note.js
@@ -59,7 +59,7 @@ erpnext.selling.InstallationNote = class InstallationNote extends frappe.ui.form
__("From Delivery Note"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_installation_note",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_installation_note",
source_doctype: "Delivery Note",
target: me.frm,
date_field: "posting_date",
diff --git a/erpnext/selling/doctype/quotation/mapper.py b/erpnext/selling/doctype/quotation/mapper.py
new file mode 100644
index 00000000000..166bd5278ab
--- /dev/null
+++ b/erpnext/selling/doctype/quotation/mapper.py
@@ -0,0 +1,280 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import cint, flt, getdate, nowdate
+
+
+@frappe.whitelist()
+def make_sales_order(
+ source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
+):
+ if not frappe.db.get_singles_value(
+ "Selling Settings", "allow_sales_order_creation_for_expired_quotation"
+ ):
+ quotation = frappe.db.get_value(
+ "Quotation", source_name, ["transaction_date", "valid_till"], as_dict=1
+ )
+ if quotation.valid_till and (
+ quotation.valid_till < quotation.transaction_date or quotation.valid_till < getdate(nowdate())
+ ):
+ frappe.throw(_("Validity period of this quotation has ended."))
+
+ return _make_sales_order(source_name, target_doc, args=args)
+
+
+def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, args=None):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ customer = _make_customer(source_name, ignore_permissions)
+ ordered_items = get_ordered_items(source_name)
+
+ selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
+
+ # 0 qty is accepted, as the qty uncertain for some items
+ has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items")
+
+ def is_unit_price_row(source) -> bool:
+ return has_unit_price_items and source.qty == 0
+
+ def set_missing_values(source, target):
+ if customer:
+ target.customer = customer.name
+ target.customer_name = customer.customer_name
+
+ # sales team
+ if not target.get("sales_team"):
+ for d in customer.get("sales_team") or []:
+ target.append(
+ "sales_team",
+ {
+ "sales_person": d.sales_person,
+ "allocated_percentage": d.allocated_percentage or None,
+ "commission_rate": d.commission_rate,
+ },
+ )
+
+ if source.referral_sales_partner:
+ target.sales_partner = source.referral_sales_partner
+ target.commission_rate = frappe.get_value(
+ "Sales Partner", source.referral_sales_partner, "commission_rate"
+ )
+
+ target.flags.ignore_permissions = ignore_permissions
+ target.run_method("set_missing_values")
+ target.run_method("calculate_taxes_and_totals")
+
+ def update_item(obj, target, source_parent):
+ balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0)
+ target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0
+ target.qty = flt(target.stock_qty) / flt(obj.conversion_factor)
+
+ if obj.against_blanket_order:
+ target.against_blanket_order = obj.against_blanket_order
+ target.blanket_order = obj.blanket_order
+ target.blanket_order_rate = obj.blanket_order_rate
+
+ def can_map_row(item) -> bool:
+ """
+ Row mapping from Quotation to Sales order:
+ 1. If no selections, map all non-alternative rows (that sum up to the grand total)
+ 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
+ 3. If no selections: Simple row: Map if adequate qty
+ """
+ if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
+ return False
+
+ if not selected_rows:
+ return not item.is_alternative
+
+ if selected_rows and (item.is_alternative or item.has_alternative_item):
+ return item.name in selected_rows
+
+ # Simple row
+ return True
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ automatically_fetch_payment_terms = cint(
+ frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
+ )
+
+ doclist = get_mapped_doc(
+ "Quotation",
+ source_name,
+ {
+ "Quotation": {
+ "doctype": "Sales Order",
+ "validation": {"docstatus": ["=", 1]},
+ "field_no_map": ["payment_terms_template"],
+ },
+ "Quotation Item": {
+ "doctype": "Sales Order Item",
+ "field_map": {"parent": "prevdoc_docname", "name": "quotation_item"},
+ "postprocess": update_item,
+ "condition": lambda d: can_map_row(d) and select_item(d),
+ },
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
+ "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
+ },
+ target_doc,
+ set_missing_values,
+ ignore_permissions=ignore_permissions,
+ )
+
+ if automatically_fetch_payment_terms:
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ PaymentScheduleService(doclist).set_payment_schedule()
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_sales_invoice(
+ source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
+):
+ return _make_sales_invoice(source_name, target_doc, args=args)
+
+
+def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ customer = _make_customer(source_name, ignore_permissions)
+
+ def set_missing_values(source, target):
+ if customer:
+ target.customer = customer.name
+ target.customer_name = customer.customer_name
+
+ target.flags.ignore_permissions = ignore_permissions
+ target.run_method("set_missing_values")
+ target.run_method("calculate_taxes_and_totals")
+
+ def update_item(obj, target, source_parent):
+ target.cost_center = None
+ target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ doclist = get_mapped_doc(
+ "Quotation",
+ source_name,
+ {
+ "Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}},
+ "Quotation Item": {
+ "doctype": "Sales Invoice Item",
+ "postprocess": update_item,
+ "condition": lambda row: not row.is_alternative and select_item(row),
+ },
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
+ "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
+ },
+ target_doc,
+ set_missing_values,
+ ignore_permissions=ignore_permissions,
+ )
+
+ return doclist
+
+
+def _make_customer(source_name, ignore_permissions=False):
+ quotation = frappe.db.get_value(
+ "Quotation",
+ source_name,
+ ["order_type", "quotation_to", "party_name", "customer_name"],
+ as_dict=1,
+ )
+
+ if quotation.quotation_to == "Customer":
+ return frappe.get_doc("Customer", quotation.party_name)
+ elif quotation.quotation_to == "CRM Deal":
+ customer_name = frappe.get_value("Customer", {"crm_deal": quotation.party_name})
+ if customer_name:
+ return frappe.get_doc("Customer", customer_name)
+
+ # Check if a Customer already exists for the Lead or Prospect.
+ existing_customer = None
+ if quotation.quotation_to == "Lead":
+ existing_customer = frappe.db.get_value("Customer", {"lead_name": quotation.party_name})
+ elif quotation.quotation_to == "Prospect":
+ existing_customer = frappe.db.get_value("Customer", {"prospect_name": quotation.party_name})
+
+ if existing_customer:
+ return frappe.get_doc("Customer", existing_customer)
+
+ # If no Customer exists, create a new Customer or Prospect.
+ if quotation.quotation_to == "Lead":
+ return create_customer_from_lead(quotation.party_name, ignore_permissions=ignore_permissions)
+ elif quotation.quotation_to == "Prospect":
+ return create_customer_from_prospect(quotation.party_name, ignore_permissions=ignore_permissions)
+
+ return None
+
+
+def create_customer_from_lead(lead_name, ignore_permissions=False):
+ from erpnext.crm.doctype.lead.lead import _make_customer
+
+ customer = _make_customer(lead_name, ignore_permissions=ignore_permissions)
+ customer.flags.ignore_permissions = ignore_permissions
+
+ try:
+ customer.insert()
+ return customer
+ except frappe.MandatoryError as e:
+ handle_mandatory_error(e, customer, lead_name)
+
+
+def create_customer_from_prospect(prospect_name, ignore_permissions=False):
+ from erpnext.crm.doctype.prospect.prospect import make_customer as make_customer_from_prospect
+
+ customer = make_customer_from_prospect(prospect_name)
+ customer.flags.ignore_permissions = ignore_permissions
+
+ try:
+ customer.insert()
+ return customer
+ except frappe.MandatoryError as e:
+ handle_mandatory_error(e, customer, prospect_name)
+
+
+def handle_mandatory_error(e, customer, lead_name):
+ from frappe.utils import get_link_to_form
+
+ mandatory_fields = e.args[0].split(":")[1].split(",")
+ mandatory_fields = [_(customer.meta.get_label(field.strip())) for field in mandatory_fields]
+
+ frappe.local.message_log = []
+ message = _("Could not auto create Customer due to the following missing mandatory field(s):") + "
"
+ message += "
- " + "
- ".join(mandatory_fields) + "
"
+ message += _("Please create Customer from Lead {0}.").format(get_link_to_form("Lead", lead_name))
+
+ frappe.throw(message, title=_("Mandatory Missing"))
+
+
+def get_ordered_items(quotation: str) -> frappe._dict:
+ return frappe._dict(
+ frappe.get_all(
+ "Quotation Item",
+ {"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)},
+ ["name", "ordered_qty"],
+ as_list=True,
+ )
+ )
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index a692aa3e3ca..895e33415f1 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -154,7 +154,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
__("Opportunity"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.crm.doctype.opportunity.opportunity.make_quotation",
+ method: "erpnext.crm.doctype.opportunity.mapper.make_quotation",
source_doctype: "Opportunity",
target: me.frm,
setters: [
@@ -195,7 +195,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
this.show_alternative_items_dialog();
} else {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
+ method: "erpnext.selling.doctype.quotation.mapper.make_sales_order",
frm: me.frm,
});
}
@@ -362,7 +362,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
],
primary_action: function () {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
+ method: "erpnext.selling.doctype.quotation.mapper.make_sales_order",
frm: me.frm,
args: {
selected_items: dialog.fields_dict.alternative_items.grid.get_selected_children(),
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index 6d950c9f69f..b2d6e7838a1 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -2,16 +2,17 @@
# License: GNU General Public License v3. See license.txt
-import json
-
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
-from frappe.utils import cint, flt, getdate, nowdate
+from frappe.utils import getdate, nowdate
from erpnext.controllers.selling_controller import SellingController
+from .mapper import (
+ get_ordered_items,
+)
+
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -356,135 +357,6 @@ def get_list_context(context=None):
return list_context
-@frappe.whitelist()
-def make_sales_order(
- source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
-):
- if not frappe.db.get_singles_value(
- "Selling Settings", "allow_sales_order_creation_for_expired_quotation"
- ):
- quotation = frappe.db.get_value(
- "Quotation", source_name, ["transaction_date", "valid_till"], as_dict=1
- )
- if quotation.valid_till and (
- quotation.valid_till < quotation.transaction_date or quotation.valid_till < getdate(nowdate())
- ):
- frappe.throw(_("Validity period of this quotation has ended."))
-
- return _make_sales_order(source_name, target_doc, args=args)
-
-
-def _make_sales_order(source_name, target_doc=None, ignore_permissions=False, args=None):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- customer = _make_customer(source_name, ignore_permissions)
- ordered_items = get_ordered_items(source_name)
-
- selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
-
- # 0 qty is accepted, as the qty uncertain for some items
- has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items")
-
- def is_unit_price_row(source) -> bool:
- return has_unit_price_items and source.qty == 0
-
- def set_missing_values(source, target):
- if customer:
- target.customer = customer.name
- target.customer_name = customer.customer_name
-
- # sales team
- if not target.get("sales_team"):
- for d in customer.get("sales_team") or []:
- target.append(
- "sales_team",
- {
- "sales_person": d.sales_person,
- "allocated_percentage": d.allocated_percentage or None,
- "commission_rate": d.commission_rate,
- },
- )
-
- if source.referral_sales_partner:
- target.sales_partner = source.referral_sales_partner
- target.commission_rate = frappe.get_value(
- "Sales Partner", source.referral_sales_partner, "commission_rate"
- )
-
- target.flags.ignore_permissions = ignore_permissions
- target.run_method("set_missing_values")
- target.run_method("calculate_taxes_and_totals")
-
- def update_item(obj, target, source_parent):
- balance_stock_qty = obj.stock_qty - ordered_items.get(obj.name, 0.0)
- target.stock_qty = balance_stock_qty if balance_stock_qty > 0 else 0
- target.qty = flt(target.stock_qty) / flt(obj.conversion_factor)
-
- if obj.against_blanket_order:
- target.against_blanket_order = obj.against_blanket_order
- target.blanket_order = obj.blanket_order
- target.blanket_order_rate = obj.blanket_order_rate
-
- def can_map_row(item) -> bool:
- """
- Row mapping from Quotation to Sales order:
- 1. If no selections, map all non-alternative rows (that sum up to the grand total)
- 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
- 3. If no selections: Simple row: Map if adequate qty
- """
- if not ((item.stock_qty > ordered_items.get(item.name, 0.0)) or is_unit_price_row(item)):
- return False
-
- if not selected_rows:
- return not item.is_alternative
-
- if selected_rows and (item.is_alternative or item.has_alternative_item):
- return item.name in selected_rows
-
- # Simple row
- return True
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- automatically_fetch_payment_terms = cint(
- frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
- )
-
- doclist = get_mapped_doc(
- "Quotation",
- source_name,
- {
- "Quotation": {
- "doctype": "Sales Order",
- "validation": {"docstatus": ["=", 1]},
- "field_no_map": ["payment_terms_template"],
- },
- "Quotation Item": {
- "doctype": "Sales Order Item",
- "field_map": {"parent": "prevdoc_docname", "name": "quotation_item"},
- "postprocess": update_item,
- "condition": lambda d: can_map_row(d) and select_item(d),
- },
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
- "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
- },
- target_doc,
- set_missing_values,
- ignore_permissions=ignore_permissions,
- )
-
- if automatically_fetch_payment_terms:
- doclist.set_payment_schedule()
-
- return doclist
-
-
def set_expired_status():
# filter out submitted non expired quotations whose validity has been ended
cond = "`tabQuotation`.docstatus = 1 and `tabQuotation`.status NOT IN ('Expired', 'Lost') and `tabQuotation`.valid_till < %s"
@@ -505,142 +377,3 @@ def set_expired_status():
},
(nowdate()),
)
-
-
-@frappe.whitelist()
-def make_sales_invoice(
- source_name: str, target_doc: str | Document | None = None, args: str | dict | None = None
-):
- return _make_sales_invoice(source_name, target_doc, args=args)
-
-
-def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False, args=None):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- customer = _make_customer(source_name, ignore_permissions)
-
- def set_missing_values(source, target):
- if customer:
- target.customer = customer.name
- target.customer_name = customer.customer_name
-
- target.flags.ignore_permissions = ignore_permissions
- target.run_method("set_missing_values")
- target.run_method("calculate_taxes_and_totals")
-
- def update_item(obj, target, source_parent):
- target.cost_center = None
- target.stock_qty = flt(obj.qty) * flt(obj.conversion_factor)
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- doclist = get_mapped_doc(
- "Quotation",
- source_name,
- {
- "Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}},
- "Quotation Item": {
- "doctype": "Sales Invoice Item",
- "postprocess": update_item,
- "condition": lambda row: not row.is_alternative and select_item(row),
- },
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
- "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
- },
- target_doc,
- set_missing_values,
- ignore_permissions=ignore_permissions,
- )
-
- return doclist
-
-
-def _make_customer(source_name, ignore_permissions=False):
- quotation = frappe.db.get_value(
- "Quotation",
- source_name,
- ["order_type", "quotation_to", "party_name", "customer_name"],
- as_dict=1,
- )
-
- if quotation.quotation_to == "Customer":
- return frappe.get_doc("Customer", quotation.party_name)
- elif quotation.quotation_to == "CRM Deal":
- customer_name = frappe.get_value("Customer", {"crm_deal": quotation.party_name})
- if customer_name:
- return frappe.get_doc("Customer", customer_name)
-
- # Check if a Customer already exists for the Lead or Prospect.
- existing_customer = None
- if quotation.quotation_to == "Lead":
- existing_customer = frappe.db.get_value("Customer", {"lead_name": quotation.party_name})
- elif quotation.quotation_to == "Prospect":
- existing_customer = frappe.db.get_value("Customer", {"prospect_name": quotation.party_name})
-
- if existing_customer:
- return frappe.get_doc("Customer", existing_customer)
-
- # If no Customer exists, create a new Customer or Prospect.
- if quotation.quotation_to == "Lead":
- return create_customer_from_lead(quotation.party_name, ignore_permissions=ignore_permissions)
- elif quotation.quotation_to == "Prospect":
- return create_customer_from_prospect(quotation.party_name, ignore_permissions=ignore_permissions)
-
- return None
-
-
-def create_customer_from_lead(lead_name, ignore_permissions=False):
- from erpnext.crm.doctype.lead.lead import _make_customer
-
- customer = _make_customer(lead_name, ignore_permissions=ignore_permissions)
- customer.flags.ignore_permissions = ignore_permissions
-
- try:
- customer.insert()
- return customer
- except frappe.MandatoryError as e:
- handle_mandatory_error(e, customer, lead_name)
-
-
-def create_customer_from_prospect(prospect_name, ignore_permissions=False):
- from erpnext.crm.doctype.prospect.prospect import make_customer as make_customer_from_prospect
-
- customer = make_customer_from_prospect(prospect_name)
- customer.flags.ignore_permissions = ignore_permissions
-
- try:
- customer.insert()
- return customer
- except frappe.MandatoryError as e:
- handle_mandatory_error(e, customer, prospect_name)
-
-
-def handle_mandatory_error(e, customer, lead_name):
- from frappe.utils import get_link_to_form
-
- mandatory_fields = e.args[0].split(":")[1].split(",")
- mandatory_fields = [_(customer.meta.get_label(field.strip())) for field in mandatory_fields]
-
- frappe.local.message_log = []
- message = _("Could not auto create Customer due to the following missing mandatory field(s):") + "
"
- message += "
- " + "
- ".join(mandatory_fields) + "
"
- message += _("Please create Customer from Lead {0}.").format(get_link_to_form("Lead", lead_name))
-
- frappe.throw(message, title=_("Mandatory Missing"))
-
-
-def get_ordered_items(quotation: str):
- return frappe._dict(
- frappe.get_all(
- "Quotation Item",
- {"docstatus": 1, "parent": quotation, "ordered_qty": (">", 0)},
- ["name", "ordered_qty"],
- as_list=True,
- )
- )
diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py
index 2f25eb4ac75..fd2b40d3a18 100644
--- a/erpnext/selling/doctype/quotation/test_quotation.py
+++ b/erpnext/selling/doctype/quotation/test_quotation.py
@@ -8,7 +8,7 @@ from frappe.tests import change_settings
from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate
-from erpnext.selling.doctype.quotation.quotation import make_sales_order
+from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
@@ -243,7 +243,7 @@ class TestQuotation(ERPNextTestSuite):
{"automatically_fetch_payment_terms": 1},
)
def test_make_sales_order_terms_copied(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
quotation.transaction_date = nowdate()
@@ -256,7 +256,7 @@ class TestQuotation(ERPNextTestSuite):
self.assertTrue(sales_order.get("payment_schedule"))
def test_do_not_add_ordered_items_in_new_sales_order(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Item for Quotation for SO", {"is_stock_item": 1})
@@ -321,7 +321,7 @@ class TestQuotation(ERPNextTestSuite):
frappe.db.set_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing", 0)
def test_maintain_rate_in_sales_cycle_is_enforced(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
maintain_rate = frappe.db.get_single_value("Selling Settings", "maintain_same_sales_rate")
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", 1)
@@ -339,7 +339,7 @@ class TestQuotation(ERPNextTestSuite):
frappe.db.set_single_value("Selling Settings", "maintain_same_sales_rate", maintain_rate)
def test_make_sales_order_with_different_currency(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
quotation.transaction_date = nowdate()
@@ -359,7 +359,7 @@ class TestQuotation(ERPNextTestSuite):
self.assertNotEqual(sales_order.currency, quotation.currency)
def test_make_sales_order(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
quotation.transaction_date = nowdate()
@@ -391,7 +391,7 @@ class TestQuotation(ERPNextTestSuite):
},
)
def test_make_sales_order_with_terms(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
quotation = frappe.copy_doc(self.globalTestRecords["Quotation"][0])
quotation.transaction_date = nowdate()
@@ -441,7 +441,7 @@ class TestQuotation(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, quotation.validate)
def test_so_from_expired_quotation(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
frappe.db.set_single_value("Selling Settings", "allow_sales_order_creation_for_expired_quotation", 0)
@@ -457,8 +457,8 @@ class TestQuotation(ERPNextTestSuite):
make_sales_order(quotation.name)
def test_create_quotation_with_margin(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
- from erpnext.selling.doctype.sales_order.sales_order import (
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
+ from erpnext.selling.doctype.sales_order.mapper import (
make_delivery_note,
make_sales_invoice,
)
@@ -550,7 +550,7 @@ class TestQuotation(ERPNextTestSuite):
def test_product_bundle_mapping_on_creating_so(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
make_item("_Test Product Bundle", {"is_stock_item": 0})
@@ -877,7 +877,7 @@ class TestQuotation(ERPNextTestSuite):
self.assertEqual(quotation.items[1].amount, 240)
def test_alternative_items_sales_order_mapping_with_stock_items(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
frappe.flags.args = frappe._dict()
@@ -1002,7 +1002,7 @@ class TestQuotation(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1})
def test_so_from_zero_qty_quotation(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
make_item("_Test Item 2", {"is_stock_item": 1})
@@ -1035,7 +1035,7 @@ class TestQuotation(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_duplicate_items_in_quotation(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
# item code same but description different
@@ -1138,7 +1138,7 @@ class TestQuotation(ERPNextTestSuite):
{"automatically_fetch_payment_terms": 1},
)
def test_make_sales_order_with_payment_terms(self):
- from erpnext.selling.doctype.quotation.quotation import make_sales_order
+ from erpnext.selling.doctype.quotation.mapper import make_sales_order
template = frappe.get_doc(
{
diff --git a/erpnext/selling/doctype/sales_order/mapper.py b/erpnext/selling/doctype/sales_order/mapper.py
new file mode 100644
index 00000000000..aa3381ccfb8
--- /dev/null
+++ b/erpnext/selling/doctype/sales_order/mapper.py
@@ -0,0 +1,1091 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.contacts.doctype.address.address import get_company_address
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.model.utils import get_fetch_values
+from frappe.query_builder.functions import Sum
+from frappe.utils import add_days, cint, flt, nowdate, strip_html
+
+from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
+from erpnext.manufacturing.doctype.production_plan.production_plan import (
+ get_items_for_material_requests,
+ get_sales_orders,
+)
+from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
+from erpnext.stock.doctype.item.item import get_item_defaults
+from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle, make_packing_list
+from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
+ get_sre_details_for_voucher,
+ get_sre_reserved_qty_details_for_voucher,
+ get_ssb_bundle_for_voucher,
+)
+from erpnext.stock.get_item_details import ItemDetailsCtx, get_bin_details, get_price_list_rate
+
+
+def get_requested_item_qty(sales_order: str) -> dict:
+ result = {}
+
+ so = frappe.get_doc("Sales Order", sales_order)
+
+ for item in so.items:
+ if is_product_bundle(item.item_code):
+ for packed_item in so.get("packed_items"):
+ if (
+ packed_item.parent_item == item.item_code
+ and packed_item.parent_detail_docname == item.name
+ ):
+ result[packed_item.name] = frappe._dict({"qty": packed_item.requested_qty})
+ else:
+ result[item.name] = frappe._dict({"qty": item.requested_qty})
+
+ return result
+
+
+@frappe.whitelist()
+def make_material_request(source_name: str, target_doc: str | Document | None = None):
+ requested_item_qty = get_requested_item_qty(source_name)
+
+ def postprocess(source, target):
+ if source.tc_name and frappe.db.get_value("Terms and Conditions", source.tc_name, "buying") != 1:
+ target.tc_name = None
+ target.terms = None
+
+ def get_remaining_qty(so_item):
+ return flt(
+ flt(so_item.qty)
+ - flt(requested_item_qty.get(so_item.name, {}).get("qty"))
+ - max(
+ flt(so_item.get("delivered_qty")),
+ 0,
+ )
+ )
+
+ def get_remaining_packed_item_qty(so_item):
+ delivered_qty = frappe.db.get_value(
+ "Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"]
+ )
+
+ bundle_item_qty = frappe.db.get_value(
+ "Product Bundle Item", {"parent": so_item.parent_item, "item_code": so_item.item_code}, ["qty"]
+ )
+
+ return flt(
+ flt(so_item.qty)
+ - flt(requested_item_qty.get(so_item.name, {}).get("qty"))
+ - max(
+ flt(delivered_qty) * flt(bundle_item_qty),
+ 0,
+ )
+ )
+
+ def update_item(source, target, source_parent):
+ # qty is for packed items, because packed items don't have stock_qty field
+ target.project = source_parent.project
+ target.qty = (
+ get_remaining_packed_item_qty(source)
+ if source.parentfield == "packed_items"
+ else get_remaining_qty(source)
+ )
+ target.stock_qty = flt(target.qty) * flt(target.conversion_factor)
+ target.actual_qty = get_bin_details(
+ target.item_code, target.warehouse, source_parent.company, True
+ ).get("actual_qty", 0)
+
+ ctx = ItemDetailsCtx(target.as_dict().copy())
+ ctx.update(
+ {
+ "company": source_parent.get("company"),
+ "price_list": frappe.db.get_single_value("Buying Settings", "buying_price_list"),
+ "currency": source_parent.get("currency"),
+ "conversion_rate": source_parent.get("conversion_rate"),
+ }
+ )
+
+ target.rate = flt(
+ get_price_list_rate(ctx, item_doc=frappe.get_cached_doc("Item", target.item_code)).get(
+ "price_list_rate"
+ )
+ )
+ target.amount = target.qty * target.rate
+
+ doc = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {"doctype": "Material Request", "validation": {"docstatus": ["=", 1]}},
+ "Packed Item": {
+ "doctype": "Material Request Item",
+ "field_map": {"parent": "sales_order", "uom": "stock_uom", "name": "packed_item"},
+ "condition": lambda item: get_remaining_packed_item_qty(item) > 0,
+ "postprocess": update_item,
+ },
+ "Sales Order Item": {
+ "doctype": "Material Request Item",
+ "field_map": {
+ "name": "sales_order_item",
+ "parent": "sales_order",
+ "delivery_date": "schedule_date",
+ "bom_no": "bom_no",
+ },
+ "condition": lambda item: not frappe.db.exists(
+ "Product Bundle", {"name": item.item_code, "disabled": 0}
+ )
+ and get_remaining_qty(item) > 0,
+ "postprocess": update_item,
+ },
+ },
+ target_doc,
+ postprocess,
+ )
+ if doc and doc.items:
+ return doc
+ else:
+ frappe.throw(_("Material Request already created for the ordered quantity"))
+
+
+@frappe.whitelist()
+def make_project(source_name: str, target_doc: str | Document | None = None):
+ def postprocess(source, doc):
+ doc.project_type = "External"
+ doc.project_name = source.name
+
+ doc = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {
+ "doctype": "Project",
+ "validation": {"docstatus": ["=", 1]},
+ "field_map": {
+ "name": "sales_order",
+ "base_grand_total": "estimated_costing",
+ "net_total": "total_sales_amount",
+ },
+ },
+ },
+ target_doc,
+ postprocess,
+ )
+
+ return doc
+
+
+def set_serial_batch_for_bundle_reservation(source, target, use_serial_batch_fields, packed_sre):
+ for item in source.packed_items:
+ target_item = next(
+ (
+ d
+ for d in target.packed_items
+ if (d.parent_item, d.item_code, d.warehouse)
+ == (item.parent_item, item.item_code, item.warehouse)
+ ),
+ None,
+ )
+ if target_item and (sre := [sre for sre in packed_sre if sre.voucher_detail_no == item.name]):
+ if sre[0].reservation_based_on == "Serial and Batch":
+ qty = 0
+ serial_nos = []
+ batch_nos = []
+ if use_serial_batch_fields:
+ target_item.use_serial_batch_fields = 1
+ for item in sre:
+ qty += item.reserved_qty
+ if item.has_serial_no:
+ serial_nos.extend(
+ frappe.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": item.name},
+ pluck="serial_no",
+ )
+ )
+ if item.has_batch_no:
+ batch_nos.extend(
+ frappe.get_all(
+ "Serial and Batch Entry",
+ filters={"parent": item.name},
+ pluck="batch_no",
+ )
+ )
+
+ if len(batch_nos) == 1:
+ target_item.batch_no = batch_nos[0] if batch_nos else None
+ if serial_nos and len(batch_nos) < 2:
+ target_item.serial_no = "\n".join(serial_nos)
+
+ if not use_serial_batch_fields or len(batch_nos) > 1:
+ target_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre).name
+
+
+@frappe.whitelist()
+def make_delivery_note(
+ source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None
+):
+ if not kwargs:
+ kwargs = {
+ "for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock,
+ "skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping,
+ }
+
+ kwargs = frappe._dict(kwargs)
+
+ sre_details = {}
+ if kwargs.for_reserved_stock:
+ sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name)
+
+ mapper = {
+ "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
+ "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
+ }
+
+ # 0 qty is accepted, as the qty is uncertain for some items
+ has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
+ use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields")
+
+ def is_unit_price_row(source):
+ return has_unit_price_items and source.qty == 0
+
+ def select_item(d):
+ filtered_items = kwargs.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ def set_missing_values(source, target):
+ if kwargs.get("ignore_pricing_rule"):
+ # Skip pricing rule when the dn is creating from the pick list
+ target.ignore_pricing_rule = 1
+
+ target.run_method("set_missing_values")
+ target.run_method("set_po_nos")
+ target.run_method("calculate_taxes_and_totals")
+ target.run_method("set_use_serial_batch_fields")
+
+ if source.company_address:
+ target.update({"company_address": source.company_address})
+ else:
+ # set company address
+ target.update(get_company_address(target.company))
+
+ if target.company_address:
+ target.update(get_fetch_values("Delivery Note", "company_address", target.company_address))
+
+ # if invoked in bulk creation, validations are ignored and thus this method is nerver invoked
+ if frappe.flags.bulk_transaction:
+ # set target items names to ensure proper linking with packed_items
+ target.set_new_name()
+
+ make_packing_list(target)
+
+ def condition(doc):
+ if doc.name in sre_details:
+ del sre_details[doc.name]
+ return False
+
+ # make_mapped_doc sets js `args` into `frappe.flags.args`
+ if frappe.flags.args and frappe.flags.args.delivery_dates:
+ if frappe.utils.cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
+ return False
+ if frappe.flags.args and frappe.flags.args.until_delivery_date:
+ if frappe.utils.cstr(doc.delivery_date) > frappe.flags.args.until_delivery_date:
+ return False
+
+ return (
+ (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
+ ) and doc.delivered_by_supplier != 1
+
+ def update_item(source, target, source_parent):
+ target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
+ target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
+ target.qty = (
+ flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty)
+ )
+
+ item = get_item_defaults(target.item_code, source_parent.company)
+ item_group = get_item_group_defaults(target.item_code, source_parent.company)
+
+ if item:
+ target.cost_center = (
+ frappe.db.get_value("Project", source_parent.project, "cost_center")
+ or item.get("buying_cost_center")
+ or item_group.get("buying_cost_center")
+ )
+
+ if not kwargs.skip_item_mapping:
+ mapper["Sales Order Item"] = {
+ "doctype": "Delivery Note Item",
+ "field_map": {
+ "rate": "rate",
+ "name": "so_detail",
+ "parent": "against_sales_order",
+ },
+ "condition": lambda d: condition(d) and select_item(d),
+ "postprocess": update_item,
+ }
+
+ so = frappe.get_doc("Sales Order", source_name)
+ target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc)
+
+ packed_sre = []
+ if not kwargs.skip_item_mapping and kwargs.for_reserved_stock:
+ sre_list = get_sre_details_for_voucher("Sales Order", source_name)
+
+ if sre_list:
+
+ def update_dn_item(source, target, source_parent):
+ update_item(source, target, so)
+
+ so_items = {d.name: d for d in so.items if d.stock_reserved_qty}
+
+ for sre in sre_list:
+ if not so_items.get(sre.voucher_detail_no):
+ packed_sre.append(sre)
+ continue
+
+ if not condition(so_items[sre.voucher_detail_no]):
+ continue
+
+ dn_item = get_mapped_doc(
+ "Sales Order Item",
+ sre.voucher_detail_no,
+ {
+ "Sales Order Item": {
+ "doctype": "Delivery Note Item",
+ "field_map": {
+ "rate": "rate",
+ "name": "so_detail",
+ "parent": "against_sales_order",
+ },
+ "postprocess": update_dn_item,
+ }
+ },
+ ignore_permissions=True,
+ )
+
+ dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1))
+ dn_item.warehouse = sre.warehouse
+
+ if (
+ not use_serial_batch_fields
+ and sre.reservation_based_on == "Serial and Batch"
+ and (sre.has_serial_no or sre.has_batch_no)
+ ):
+ dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
+
+ target_doc.append("items", dn_item)
+ # Correct rows index.
+ for idx, item in enumerate(target_doc.items):
+ item.idx = idx + 1
+
+ if not kwargs.skip_item_mapping and frappe.flags.bulk_transaction and not target_doc.items:
+ # the (date) condition filter resulted in an unintendedly created empty DN; remove it
+ del target_doc
+ return
+
+ # Should be called after mapping items.
+ target_doc.packed_items = []
+ set_missing_values(so, target_doc)
+ set_serial_batch_for_bundle_reservation(so, target_doc, use_serial_batch_fields, packed_sre)
+
+ return target_doc
+
+
+@frappe.whitelist()
+def make_sales_invoice(
+ source_name: str,
+ target_doc: str | Document | None = None,
+ ignore_permissions: bool = False,
+ args: str | dict | None = None,
+):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ # 0 qty is accepted, as the qty is uncertain for some items
+ has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
+
+ def is_unit_price_row(source):
+ return has_unit_price_items and source.qty == 0
+
+ def postprocess(source, target):
+ set_missing_values(source, target)
+ # Get the advance paid Journal Entries in Sales Invoice Advance
+ if target.get("allocate_advances_automatically"):
+ target.set_advances()
+
+ make_packing_list(target)
+ set_serial_batch_for_bundle_reservation(
+ source,
+ target,
+ frappe.get_single_value("Stock Settings", "use_serial_batch_fields"),
+ get_sre_details_for_voucher("Sales Order", source_name),
+ )
+
+ def set_missing_values(source, target):
+ target.flags.ignore_permissions = True
+ target.run_method("set_missing_values")
+ target.run_method("set_po_nos")
+ target.run_method("calculate_taxes_and_totals")
+ target.run_method("set_use_serial_batch_fields")
+
+ if source.company_address:
+ target.update({"company_address": source.company_address})
+ else:
+ # set company address
+ target.update(get_company_address(target.company))
+
+ if target.company_address:
+ target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address))
+
+ # set the redeem loyalty points if provided via shopping cart
+ if source.loyalty_points and source.order_type == "Shopping Cart":
+ target.redeem_loyalty_points = 1
+ target.loyalty_points = source.loyalty_points
+
+ target.debit_to = get_party_account("Customer", source.customer, source.company)
+
+ def update_item(source, target, source_parent):
+ def get_billed_qty(so_item_name):
+ table = frappe.qb.DocType("Sales Invoice Item")
+ query = (
+ frappe.qb.from_(table)
+ .select(Sum(table.qty).as_("qty"))
+ .where((table.docstatus == 1) & (table.so_detail == so_item_name))
+ )
+ return query.run(pluck="qty")[0] or 0
+
+ if source_parent.has_unit_price_items:
+ # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
+ pending_amount = flt(source.amount) - flt(source.billed_amt)
+ target.amount = pending_amount if flt(source.amount) else 0
+ else:
+ target.amount = flt(source.amount) - flt(source.billed_amt)
+
+ target.base_amount = target.amount * flt(source_parent.conversion_rate)
+ target.qty = (
+ source.qty - get_billed_qty(source.name)
+ if (source.qty and source.billed_amt)
+ else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
+ )
+
+ if source_parent.project:
+ target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center")
+ if target.item_code:
+ item = get_item_defaults(target.item_code, source_parent.company)
+ item_group = get_item_group_defaults(target.item_code, source_parent.company)
+ cost_center = item.get("selling_cost_center") or item_group.get("selling_cost_center")
+
+ if cost_center:
+ target.cost_center = cost_center
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ def add_self_rm(doclist):
+ parent = frappe.qb.DocType("Subcontracting Inward Order")
+ child = frappe.qb.DocType("Subcontracting Inward Order Received Item")
+ query = (
+ frappe.qb.from_(parent)
+ .join(child)
+ .on(parent.name == child.parent)
+ .select(
+ child.required_qty,
+ child.consumed_qty,
+ child.billed_qty,
+ child.rm_item_code,
+ child.stock_uom,
+ child.name,
+ )
+ .where(
+ (parent.docstatus == 1)
+ & (parent.sales_order == source_name)
+ & (child.is_customer_provided_item == 0)
+ )
+ )
+ result = query.run(as_dict=True)
+
+ if result:
+ idx = len(doclist.items) + 1
+ for item in result:
+ if (qty := max(item.required_qty, item.consumed_qty) - item.billed_qty) > 0:
+ doclist.append(
+ "items",
+ {
+ "item_code": item.rm_item_code,
+ "qty": qty,
+ "uom": item.stock_uom,
+ "scio_detail": item.name,
+ },
+ )
+ doclist.process_item_selection(idx)
+ idx += 1
+ doclist.has_subcontracted = 1
+
+ doclist = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {
+ "doctype": "Sales Invoice",
+ "field_map": {
+ "party_account_currency": "party_account_currency",
+ },
+ "field_no_map": ["payment_terms_template"],
+ "validation": {"docstatus": ["=", 1]},
+ },
+ "Sales Order Item": {
+ "doctype": "Sales Invoice Item",
+ "field_map": {
+ "name": "so_detail",
+ "parent": "sales_order",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: (
+ True
+ if is_unit_price_row(doc)
+ else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
+ )
+ and select_item(doc),
+ },
+ "Sales Taxes and Charges": {
+ "doctype": "Sales Taxes and Charges",
+ "reset_value": True,
+ },
+ "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
+ },
+ target_doc,
+ postprocess,
+ ignore_permissions=ignore_permissions,
+ )
+
+ if frappe.get_cached_value("Sales Order", source_name, "is_subcontracted"):
+ add_self_rm(doclist)
+
+ automatically_fetch_payment_terms = cint(
+ frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
+ )
+ if automatically_fetch_payment_terms:
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ PaymentScheduleService(doclist).set_payment_schedule()
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None):
+ maint_schedule = frappe.db.exists(
+ "Maintenance Schedule Item", {"sales_order": source_name, "docstatus": 1}
+ )
+
+ if not maint_schedule:
+ doclist = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}},
+ "Sales Order Item": {
+ "doctype": "Maintenance Schedule Item",
+ "field_map": {"parent": "sales_order"},
+ },
+ },
+ target_doc,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_maintenance_visit(source_name: str, target_doc: str | Document | None = None):
+ MaintenanceVisit = frappe.qb.DocType("Maintenance Visit")
+ MaintenanceVisitPurpose = frappe.qb.DocType("Maintenance Visit Purpose")
+
+ query = (
+ frappe.qb.from_(MaintenanceVisit)
+ .join(MaintenanceVisitPurpose)
+ .on(MaintenanceVisitPurpose.parent == MaintenanceVisit.name)
+ .select(MaintenanceVisit.name)
+ .where(MaintenanceVisitPurpose.prevdoc_docname == source_name)
+ .where(MaintenanceVisit.docstatus == 1)
+ .where(MaintenanceVisit.completion_status == "Fully Completed")
+ )
+
+ if not query.run():
+ doclist = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {"doctype": "Maintenance Visit", "validation": {"docstatus": ["=", 1]}},
+ "Sales Order Item": {
+ "doctype": "Maintenance Visit Purpose",
+ "field_map": {"parent": "prevdoc_docname", "parenttype": "prevdoc_doctype"},
+ },
+ },
+ target_doc,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_purchase_order(
+ source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
+):
+ """Creates Purchase Order for each Supplier. Returns a list of doc objects."""
+
+ from erpnext.setup.utils import get_exchange_rate
+
+ if not selected_items:
+ return
+
+ if isinstance(selected_items, str):
+ selected_items = json.loads(selected_items)
+
+ def set_missing_values(source, target):
+ target.supplier = supplier
+ company_currency = frappe.db.get_value(
+ "Company", filters={"name": target.company}, fieldname=["default_currency"]
+ )
+ supplier_currency = frappe.db.get_value(
+ "Supplier", filters={"name": supplier}, fieldname=["default_currency"]
+ )
+
+ target.currency = supplier_currency if supplier_currency else company_currency
+
+ target.conversion_rate = get_exchange_rate(target.currency, company_currency, args="for_buying")
+
+ target.apply_discount_on = ""
+ target.additional_discount_percentage = 0.0
+ target.discount_amount = 0.0
+ target.inter_company_order_reference = ""
+ target.shipping_rule = ""
+ target.tc_name = ""
+ target.terms = ""
+ target.payment_schedule = []
+
+ default_price_list = frappe.get_value("Supplier", supplier, "default_price_list")
+ if default_price_list:
+ target.buying_price_list = default_price_list
+
+ default_payment_terms = frappe.get_value("Supplier", supplier, "payment_terms")
+ if default_payment_terms:
+ target.payment_terms_template = default_payment_terms
+
+ if any(item.delivered_by_supplier for item in target.items):
+ if source.shipping_address_name:
+ target.shipping_address = source.shipping_address_name
+ target.shipping_address_display = source.shipping_address
+ else:
+ target.shipping_address = source.customer_address
+ target.shipping_address_display = source.address_display
+
+ target.customer_contact_person = source.contact_person
+ target.customer_contact_display = source.contact_display
+ target.customer_contact_mobile = source.contact_mobile
+ target.customer_contact_email = source.contact_email
+
+ else:
+ target.customer = ""
+ target.customer_name = ""
+
+ target.run_method("set_missing_values")
+ target.run_method("calculate_taxes_and_totals")
+
+ def update_item(source, target, source_parent):
+ target.schedule_date = source.delivery_date
+ target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor))
+ target.stock_qty = flt(source.stock_qty) - flt(source.ordered_qty)
+ target.project = source_parent.project
+
+ def update_item_for_packed_item(source, target, _):
+ target.qty = flt(source.qty) - flt(source.ordered_qty)
+
+ def filter_items(item, supplier):
+ if (
+ item.ordered_qty < item.stock_qty
+ and not is_product_bundle(item.item_code)
+ and items_to_map.get(item.item_code) == supplier
+ ):
+ return True
+
+ return False
+
+ items_to_map = {
+ item.get("item_code"): item.get("supplier") for item in selected_items if item.get("item_code")
+ }
+ item_codes = list(set(items_to_map.keys()))
+ suppliers = list(set(items_to_map.values()))
+
+ if not suppliers:
+ suppliers = [None]
+
+ purchase_orders = []
+ for supplier in suppliers:
+ doc = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {
+ "doctype": "Purchase Order",
+ "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
+ "validation": {"docstatus": ["=", 1]},
+ },
+ "Sales Order Item": {
+ "doctype": "Purchase Order Item",
+ "field_map": [
+ ["name", "sales_order_item"],
+ ["parent", "sales_order"],
+ ["stock_uom", "stock_uom"],
+ ["uom", "uom"],
+ ["conversion_factor", "conversion_factor"],
+ ["delivery_date", "schedule_date"],
+ ],
+ "field_no_map": [
+ "rate",
+ "price_list_rate",
+ "item_tax_template",
+ "discount_percentage",
+ "discount_amount",
+ "pricing_rules",
+ "margin_type",
+ "margin_rate_or_amount",
+ ],
+ "postprocess": update_item,
+ "condition": lambda doc, s=supplier: filter_items(doc, s),
+ },
+ "Packed Item": {
+ "doctype": "Purchase Order Item",
+ "field_map": [
+ ["name", "sales_order_packed_item"],
+ ["parent", "sales_order"],
+ ["uom", "uom"],
+ ["conversion_factor", "conversion_factor"],
+ ["parent_item", "product_bundle"],
+ ["rate", "rate"],
+ ],
+ "field_no_map": [
+ "price_list_rate",
+ "item_tax_template",
+ "discount_percentage",
+ "discount_amount",
+ "supplier",
+ "pricing_rules",
+ ],
+ "postprocess": update_item_for_packed_item,
+ "condition": lambda doc: doc.parent_item in item_codes
+ and flt(doc.ordered_qty) < flt(doc.qty),
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ set_delivery_date(doc.items, source_name)
+ if doc.supplier:
+ doc.insert()
+ purchase_orders.append(doc)
+
+ return purchase_orders
+
+
+def set_delivery_date(items: list, sales_order: str) -> None:
+ delivery_dates = frappe.get_all(
+ "Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"]
+ )
+
+ delivery_by_item = frappe._dict()
+ for date in delivery_dates:
+ delivery_by_item[date.item_code] = date.delivery_date
+
+ for item in items:
+ if item.product_bundle:
+ item.schedule_date = delivery_by_item[item.product_bundle]
+
+
+@frappe.whitelist()
+def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None):
+ """Make Work Orders against the given Sales Order for the given `items`"""
+ items = json.loads(items).get("items")
+ out = []
+
+ for i in items:
+ if not i.get("bom"):
+ frappe.throw(_("Please select BOM against item {0}").format(i.get("item_code")))
+ if not i.get("pending_qty"):
+ frappe.throw(_("Please select Qty against item {0}").format(i.get("item_code")))
+
+ work_order = frappe.get_doc(
+ doctype="Work Order",
+ production_item=i["item_code"],
+ bom_no=i.get("bom"),
+ qty=i["pending_qty"],
+ company=company,
+ sales_order=sales_order,
+ sales_order_item=i["sales_order_item"],
+ project=project,
+ fg_warehouse=i["warehouse"],
+ description=i["description"],
+ ).insert()
+ work_order.set_work_order_operations()
+ work_order.flags.ignore_mandatory = True
+ work_order.save()
+ out.append(work_order)
+
+ return [p.name for p in out]
+
+
+@frappe.whitelist()
+def make_production_plan(source_name: str, target_doc: str | Document | None = None):
+ sales_order = frappe.get_doc("Sales Order", source_name)
+
+ production_plan = frappe.new_doc(
+ "Production Plan",
+ company=sales_order.company,
+ get_items_from="Sales Order",
+ posting_date=nowdate(),
+ )
+
+ open_so = [data.name for data in get_sales_orders(production_plan)]
+ if sales_order.name not in open_so:
+ frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name))
+
+ production_plan.append(
+ "sales_orders",
+ {
+ "sales_order": sales_order.name,
+ "sales_order_date": sales_order.transaction_date,
+ "customer": sales_order.customer,
+ "grand_total": sales_order.base_grand_total,
+ },
+ )
+ production_plan.get_items()
+ if not production_plan.get("po_items"):
+ frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name))
+
+ return production_plan
+
+
+@frappe.whitelist()
+def make_raw_material_request(
+ items: str | frappe._dict, company: str, sales_order: str, project: str | None = None
+):
+ if not frappe.has_permission("Sales Order", "write"):
+ frappe.throw(_("Not permitted"), frappe.PermissionError)
+
+ if isinstance(items, str):
+ items = frappe._dict(json.loads(items))
+
+ for item in items.get("items"):
+ item["include_exploded_items"] = items.get("include_exploded_items")
+ item["ignore_existing_ordered_qty"] = items.get("ignore_existing_ordered_qty")
+ item["include_raw_materials_from_sales_order"] = items.get("include_raw_materials_from_sales_order")
+
+ items.update({"company": company, "sales_order": sales_order})
+
+ item_wh = {}
+ for item in items.get("items"):
+ if item.get("warehouse"):
+ item_wh[item.get("item_code")] = item.get("warehouse")
+
+ raw_materials = get_items_for_material_requests(items)
+ if not raw_materials:
+ frappe.msgprint(_("Material Request not created, as quantity for Raw Materials already available."))
+ return
+
+ material_request = frappe.new_doc("Material Request")
+ material_request.update(
+ dict(
+ doctype="Material Request",
+ transaction_date=nowdate(),
+ company=company,
+ material_request_type="Purchase",
+ )
+ )
+ for item in raw_materials:
+ item_doc = frappe.get_cached_doc("Item", item.get("item_code"))
+
+ schedule_date = add_days(nowdate(), cint(item_doc.lead_time_days))
+ row = material_request.append(
+ "items",
+ {
+ "item_code": item.get("item_code"),
+ "qty": item.get("quantity"),
+ "schedule_date": schedule_date,
+ "warehouse": item_wh.get(item.get("main_bom_item")) or item.get("warehouse"),
+ "sales_order": sales_order,
+ "project": project,
+ },
+ )
+
+ if not (strip_html(item.get("description")) and strip_html(item_doc.description)):
+ row.description = item_doc.item_name or item.get("item_code")
+
+ material_request.insert()
+ material_request.flags.ignore_permissions = 1
+ material_request.run_method("set_missing_values")
+ material_request.submit()
+ return material_request
+
+
+@frappe.whitelist()
+def make_inter_company_purchase_order(source_name: str, target_doc: str | Document | None = None):
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_inter_company_transaction
+
+ return make_inter_company_transaction("Sales Order", source_name, target_doc)
+
+
+@frappe.whitelist()
+def create_pick_list(source_name: str, target_doc: str | Document | None = None):
+ def validate_sales_order():
+ so = frappe.get_doc("Sales Order", source_name)
+ for item in so.items:
+ if item.stock_reserved_qty > 0:
+ frappe.throw(
+ _(
+ "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list."
+ ).format(frappe.bold(source_name))
+ )
+
+ def update_item_quantity(source, target, source_parent) -> None:
+ picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1)
+ qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty))
+
+ target.qty = qty_to_be_picked
+ target.stock_qty = qty_to_be_picked * flt(source.conversion_factor)
+
+ # update available qty
+ bin_details = get_bin_details(source.item_code, source.warehouse, source_parent.company)
+ target.actual_qty = bin_details.get("actual_qty")
+ target.company_total_stock = bin_details.get("company_total_stock")
+
+ def update_packed_item_qty(source, target, source_parent) -> None:
+ qty = flt(source.qty)
+ for item in source_parent.items:
+ if source.parent_detail_docname == item.name:
+ picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1)
+ pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty
+ target.qty = target.stock_qty = qty * pending_percent
+ return
+
+ def should_pick_order_item(item) -> bool:
+ return (
+ abs(item.delivered_qty) < abs(item.qty)
+ and item.delivered_by_supplier != 1
+ and not is_product_bundle(item.item_code)
+ )
+
+ # Don't allow a Pick List to be created against a Sales Order that has reserved stock.
+ validate_sales_order()
+
+ doc = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {
+ "doctype": "Pick List",
+ "field_map": {"set_warehouse": "parent_warehouse"},
+ "validation": {"docstatus": ["=", 1]},
+ },
+ "Sales Order Item": {
+ "doctype": "Pick List Item",
+ "field_map": {"parent": "sales_order", "name": "sales_order_item"},
+ "postprocess": update_item_quantity,
+ "condition": should_pick_order_item,
+ },
+ "Packed Item": {
+ "doctype": "Pick List Item",
+ "field_map": {
+ "parent": "sales_order",
+ "parent_detail_docname": "sales_order_item",
+ "name": "product_bundle_item",
+ },
+ "field_no_map": ["picked_qty"],
+ "postprocess": update_packed_item_qty,
+ },
+ },
+ target_doc,
+ )
+
+ doc.purpose = "Delivery"
+
+ doc.set_item_locations()
+
+ return doc
+
+
+@frappe.whitelist()
+def make_subcontracting_inward_order(source_name: str, target_doc: str | Document | None = None):
+ if not is_so_fully_subcontracted(source_name):
+ return get_mapped_subcontracting_inward_order(source_name, target_doc)
+ else:
+ frappe.throw(_("This Sales Order has been fully subcontracted."))
+
+
+def is_so_fully_subcontracted(so_name: str) -> bool:
+ table = frappe.qb.DocType("Sales Order Item")
+ query = (
+ frappe.qb.from_(table)
+ .select(table.name)
+ .where((table.parent == so_name) & (table.qty != table.subcontracted_qty))
+ )
+ return not query.run(as_dict=True)
+
+
+def get_mapped_subcontracting_inward_order(
+ source_name: str, target_doc: str | Document | None = None
+) -> Document:
+ def post_process(source_doc, target_doc):
+ if (
+ frappe.db.count(
+ "Warehouse", {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0}
+ )
+ == 1
+ ):
+ target_doc.customer_warehouse = frappe.get_cached_value(
+ "Warehouse",
+ {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0},
+ "name",
+ )
+ target_doc.populate_items_table()
+
+ if target_doc and isinstance(target_doc, str):
+ target_doc = json.loads(target_doc)
+ for key in ["service_items", "items", "received_items"]:
+ if key in target_doc:
+ del target_doc[key]
+ target_doc = json.dumps(target_doc)
+
+ target_doc = get_mapped_doc(
+ "Sales Order",
+ source_name,
+ {
+ "Sales Order": {
+ "doctype": "Subcontracting Inward Order",
+ "field_map": {},
+ "field_no_map": ["total_qty", "total", "net_total"],
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Sales Order Item": {
+ "doctype": "Subcontracting Inward Order Service Item",
+ "field_map": {
+ "name": "sales_order_item",
+ },
+ "field_no_map": ["qty", "fg_item_qty", "amount"],
+ "condition": lambda item: item.qty != item.subcontracted_qty,
+ },
+ },
+ target_doc,
+ post_process,
+ )
+
+ return target_doc
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index f0e07d861da..1555983bf01 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -179,7 +179,7 @@ frappe.ui.form.on("Sales Order", {
__("Purchase Order"),
() => {
erpnext.utils.map_current_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_inter_company_sales_order",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_inter_company_sales_order",
source_doctype: "Purchase Order",
target: frm,
setters: [
@@ -1229,7 +1229,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
__("Quotation"),
function () {
let d = erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
+ method: "erpnext.selling.doctype.quotation.mapper.make_sales_order",
source_doctype: "Quotation",
target: me.frm,
setters: [
@@ -1278,7 +1278,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
create_pick_list() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list",
+ method: "erpnext.selling.doctype.sales_order.mapper.create_pick_list",
frm: this.frm,
});
}
@@ -1406,7 +1406,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
make_production_plan() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_production_plan",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_production_plan",
frm: this.frm,
});
}
@@ -1421,7 +1421,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
make_material_request() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_material_request",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_material_request",
frm: this.frm,
});
}
@@ -1519,7 +1519,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
primary_action: function () {
var data = d.get_values();
me.frm.call({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_raw_material_request",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_raw_material_request",
args: {
items: data,
company: me.frm.doc.company,
@@ -1614,7 +1614,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
make_delivery_note(delivery_dates, for_reserved_stock = false) {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_delivery_note",
frm: this.frm,
args: {
delivery_dates,
@@ -1627,35 +1627,35 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
make_sales_invoice() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_sales_invoice",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_sales_invoice",
frm: this.frm,
});
}
make_maintenance_schedule() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_schedule",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_schedule",
frm: this.frm,
});
}
make_project() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_project",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_project",
frm: this.frm,
});
}
make_inter_company_order() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_inter_company_purchase_order",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_inter_company_purchase_order",
frm: this.frm,
});
}
make_maintenance_visit() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_maintenance_visit",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_maintenance_visit",
frm: this.frm,
});
}
@@ -1769,7 +1769,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
dialog.hide();
return frappe.call({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_purchase_order",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_purchase_order",
freeze_message: __("Creating Purchase Order ..."),
args: {
source_name: me.frm.doc.name,
@@ -1889,7 +1889,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
make_subcontracting_inward_order() {
frappe.model.open_mapped_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_subcontracting_inward_order",
frm: this.frm,
freeze_message: __("Creating Subcontracting Inward Order ..."),
});
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 3b4160be17e..cb3de5e560d 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -8,44 +8,27 @@ from typing import Literal
import frappe
import frappe.utils
from frappe import _, qb
-from frappe.contacts.doctype.address.address import get_company_address
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
-from frappe.model.utils import get_fetch_values
from frappe.query_builder.functions import Sum
-from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, parse_json, strip_html
+from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, parse_json
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
unlink_inter_company_doc,
update_linked_doc,
validate_inter_company_party,
)
-from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
from erpnext.controllers.selling_controller import SellingController
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
validate_against_blanket_order,
)
-from erpnext.manufacturing.doctype.production_plan.production_plan import (
- get_items_for_material_requests,
- get_sales_orders,
-)
from erpnext.selling.doctype.customer.customer import check_credit_limit
-from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
-from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- get_sre_details_for_voucher,
get_sre_reserved_qty_details_for_voucher,
- get_ssb_bundle_for_voucher,
has_reserved_stock,
)
-from erpnext.stock.get_item_details import (
- ItemDetailsCtx,
- get_bin_details,
- get_default_bom,
- get_price_list_rate,
-)
+from erpnext.stock.get_item_details import get_default_bom
from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -1054,617 +1037,6 @@ def close_or_unclose_sales_orders(names: str, status: str):
frappe.local.message_log = []
-def get_requested_item_qty(sales_order):
- result = {}
-
- so = frappe.get_doc("Sales Order", sales_order)
-
- for item in so.items:
- if is_product_bundle(item.item_code):
- for packed_item in so.get("packed_items"):
- if (
- packed_item.parent_item == item.item_code
- and packed_item.parent_detail_docname == item.name
- ):
- result[packed_item.name] = frappe._dict({"qty": packed_item.requested_qty})
- else:
- result[item.name] = frappe._dict({"qty": item.requested_qty})
-
- return result
-
-
-@frappe.whitelist()
-def make_material_request(source_name: str, target_doc: str | Document | None = None):
- requested_item_qty = get_requested_item_qty(source_name)
-
- def postprocess(source, target):
- if source.tc_name and frappe.db.get_value("Terms and Conditions", source.tc_name, "buying") != 1:
- target.tc_name = None
- target.terms = None
-
- def get_remaining_qty(so_item):
- return flt(
- flt(so_item.qty)
- - flt(requested_item_qty.get(so_item.name, {}).get("qty"))
- - max(
- flt(so_item.get("delivered_qty")),
- 0,
- )
- )
-
- def get_remaining_packed_item_qty(so_item):
- delivered_qty = frappe.db.get_value(
- "Sales Order Item", {"name": so_item.parent_detail_docname}, ["delivered_qty"]
- )
-
- bundle_item_qty = frappe.db.get_value(
- "Product Bundle Item", {"parent": so_item.parent_item, "item_code": so_item.item_code}, ["qty"]
- )
-
- return flt(
- flt(so_item.qty)
- - flt(requested_item_qty.get(so_item.name, {}).get("qty"))
- - max(
- flt(delivered_qty) * flt(bundle_item_qty),
- 0,
- )
- )
-
- def update_item(source, target, source_parent):
- # qty is for packed items, because packed items don't have stock_qty field
- target.project = source_parent.project
- target.qty = (
- get_remaining_packed_item_qty(source)
- if source.parentfield == "packed_items"
- else get_remaining_qty(source)
- )
- target.stock_qty = flt(target.qty) * flt(target.conversion_factor)
- target.actual_qty = get_bin_details(
- target.item_code, target.warehouse, source_parent.company, True
- ).get("actual_qty", 0)
-
- ctx = ItemDetailsCtx(target.as_dict().copy())
- ctx.update(
- {
- "company": source_parent.get("company"),
- "price_list": frappe.db.get_single_value("Buying Settings", "buying_price_list"),
- "currency": source_parent.get("currency"),
- "conversion_rate": source_parent.get("conversion_rate"),
- }
- )
-
- target.rate = flt(
- get_price_list_rate(ctx, item_doc=frappe.get_cached_doc("Item", target.item_code)).get(
- "price_list_rate"
- )
- )
- target.amount = target.qty * target.rate
-
- doc = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {"doctype": "Material Request", "validation": {"docstatus": ["=", 1]}},
- "Packed Item": {
- "doctype": "Material Request Item",
- "field_map": {"parent": "sales_order", "uom": "stock_uom", "name": "packed_item"},
- "condition": lambda item: get_remaining_packed_item_qty(item) > 0,
- "postprocess": update_item,
- },
- "Sales Order Item": {
- "doctype": "Material Request Item",
- "field_map": {
- "name": "sales_order_item",
- "parent": "sales_order",
- "delivery_date": "schedule_date",
- "bom_no": "bom_no",
- },
- "condition": lambda item: not frappe.db.exists(
- "Product Bundle", {"name": item.item_code, "disabled": 0}
- )
- and get_remaining_qty(item) > 0,
- "postprocess": update_item,
- },
- },
- target_doc,
- postprocess,
- )
- if doc and doc.items:
- return doc
- else:
- frappe.throw(_("Material Request already created for the ordered quantity"))
-
-
-@frappe.whitelist()
-def make_project(source_name: str, target_doc: str | Document | None = None):
- def postprocess(source, doc):
- doc.project_type = "External"
- doc.project_name = source.name
-
- doc = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {
- "doctype": "Project",
- "validation": {"docstatus": ["=", 1]},
- "field_map": {
- "name": "sales_order",
- "base_grand_total": "estimated_costing",
- "net_total": "total_sales_amount",
- },
- },
- },
- target_doc,
- postprocess,
- )
-
- return doc
-
-
-def set_serial_batch_for_bundle_reservation(source, target, use_serial_batch_fields, packed_sre):
- for item in source.packed_items:
- target_item = next(
- (
- d
- for d in target.packed_items
- if (d.parent_item, d.item_code, d.warehouse)
- == (item.parent_item, item.item_code, item.warehouse)
- ),
- None,
- )
- if target_item and (sre := [sre for sre in packed_sre if sre.voucher_detail_no == item.name]):
- if sre[0].reservation_based_on == "Serial and Batch":
- qty = 0
- serial_nos = []
- batch_nos = []
- if use_serial_batch_fields:
- target_item.use_serial_batch_fields = 1
- for item in sre:
- qty += item.reserved_qty
- if item.has_serial_no:
- serial_nos.extend(
- frappe.get_all(
- "Serial and Batch Entry",
- filters={"parent": item.name},
- pluck="serial_no",
- )
- )
- if item.has_batch_no:
- batch_nos.extend(
- frappe.get_all(
- "Serial and Batch Entry",
- filters={"parent": item.name},
- pluck="batch_no",
- )
- )
-
- if len(batch_nos) == 1:
- target_item.batch_no = batch_nos[0] if batch_nos else None
- if serial_nos and len(batch_nos) < 2:
- target_item.serial_no = "\n".join(serial_nos)
-
- if not use_serial_batch_fields or len(batch_nos) > 1:
- target_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre).name
-
-
-@frappe.whitelist()
-def make_delivery_note(
- source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None
-):
- from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
- get_sre_reserved_qty_details_for_voucher,
- )
-
- if not kwargs:
- kwargs = {
- "for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock,
- "skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping,
- }
-
- kwargs = frappe._dict(kwargs)
-
- sre_details = {}
- if kwargs.for_reserved_stock:
- sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name)
-
- mapper = {
- "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
- "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
- }
-
- # 0 qty is accepted, as the qty is uncertain for some items
- has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
- use_serial_batch_fields = frappe.get_single_value("Stock Settings", "use_serial_batch_fields")
-
- def is_unit_price_row(source):
- return has_unit_price_items and source.qty == 0
-
- def select_item(d):
- filtered_items = kwargs.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- def set_missing_values(source, target):
- if kwargs.get("ignore_pricing_rule"):
- # Skip pricing rule when the dn is creating from the pick list
- target.ignore_pricing_rule = 1
-
- target.run_method("set_missing_values")
- target.run_method("set_po_nos")
- target.run_method("calculate_taxes_and_totals")
- target.run_method("set_use_serial_batch_fields")
-
- if source.company_address:
- target.update({"company_address": source.company_address})
- else:
- # set company address
- target.update(get_company_address(target.company))
-
- if target.company_address:
- target.update(get_fetch_values("Delivery Note", "company_address", target.company_address))
-
- # if invoked in bulk creation, validations are ignored and thus this method is nerver invoked
- if frappe.flags.bulk_transaction:
- # set target items names to ensure proper linking with packed_items
- target.set_new_name()
-
- make_packing_list(target)
-
- def condition(doc):
- if doc.name in sre_details:
- del sre_details[doc.name]
- return False
-
- # make_mapped_doc sets js `args` into `frappe.flags.args`
- if frappe.flags.args and frappe.flags.args.delivery_dates:
- if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates:
- return False
- if frappe.flags.args and frappe.flags.args.until_delivery_date:
- if cstr(doc.delivery_date) > frappe.flags.args.until_delivery_date:
- return False
-
- return (
- (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc)
- ) and doc.delivered_by_supplier != 1
-
- def update_item(source, target, source_parent):
- target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate)
- target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate)
- target.qty = (
- flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty)
- )
-
- item = get_item_defaults(target.item_code, source_parent.company)
- item_group = get_item_group_defaults(target.item_code, source_parent.company)
-
- if item:
- target.cost_center = (
- frappe.db.get_value("Project", source_parent.project, "cost_center")
- or item.get("buying_cost_center")
- or item_group.get("buying_cost_center")
- )
-
- if not kwargs.skip_item_mapping:
- mapper["Sales Order Item"] = {
- "doctype": "Delivery Note Item",
- "field_map": {
- "rate": "rate",
- "name": "so_detail",
- "parent": "against_sales_order",
- },
- "condition": lambda d: condition(d) and select_item(d),
- "postprocess": update_item,
- }
-
- so = frappe.get_doc("Sales Order", source_name)
- target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc)
-
- packed_sre = []
- if not kwargs.skip_item_mapping and kwargs.for_reserved_stock:
- sre_list = get_sre_details_for_voucher("Sales Order", source_name)
-
- if sre_list:
-
- def update_dn_item(source, target, source_parent):
- update_item(source, target, so)
-
- so_items = {d.name: d for d in so.items if d.stock_reserved_qty}
-
- for sre in sre_list:
- if not so_items.get(sre.voucher_detail_no):
- packed_sre.append(sre)
- continue
-
- if not condition(so_items[sre.voucher_detail_no]):
- continue
-
- dn_item = get_mapped_doc(
- "Sales Order Item",
- sre.voucher_detail_no,
- {
- "Sales Order Item": {
- "doctype": "Delivery Note Item",
- "field_map": {
- "rate": "rate",
- "name": "so_detail",
- "parent": "against_sales_order",
- },
- "postprocess": update_dn_item,
- }
- },
- ignore_permissions=True,
- )
-
- dn_item.qty = flt(sre.reserved_qty) / flt(dn_item.get("conversion_factor", 1))
- dn_item.warehouse = sre.warehouse
-
- if (
- not use_serial_batch_fields
- and sre.reservation_based_on == "Serial and Batch"
- and (sre.has_serial_no or sre.has_batch_no)
- ):
- dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher([sre]).name
-
- target_doc.append("items", dn_item)
- # Correct rows index.
- for idx, item in enumerate(target_doc.items):
- item.idx = idx + 1
-
- if not kwargs.skip_item_mapping and frappe.flags.bulk_transaction and not target_doc.items:
- # the (date) condition filter resulted in an unintendedly created empty DN; remove it
- del target_doc
- return
-
- # Should be called after mapping items.
- target_doc.packed_items = []
- set_missing_values(so, target_doc)
- set_serial_batch_for_bundle_reservation(so, target_doc, use_serial_batch_fields, packed_sre)
-
- return target_doc
-
-
-@frappe.whitelist()
-def make_sales_invoice(
- source_name: str,
- target_doc: str | Document | None = None,
- ignore_permissions: bool = False,
- args: str | dict | None = None,
-):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- # 0 qty is accepted, as the qty is uncertain for some items
- has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
-
- def is_unit_price_row(source):
- return has_unit_price_items and source.qty == 0
-
- def postprocess(source, target):
- set_missing_values(source, target)
- # Get the advance paid Journal Entries in Sales Invoice Advance
- if target.get("allocate_advances_automatically"):
- target.set_advances()
-
- make_packing_list(target)
- set_serial_batch_for_bundle_reservation(
- source,
- target,
- frappe.get_single_value("Stock Settings", "use_serial_batch_fields"),
- get_sre_details_for_voucher("Sales Order", source_name),
- )
-
- def set_missing_values(source, target):
- target.flags.ignore_permissions = True
- target.run_method("set_missing_values")
- target.run_method("set_po_nos")
- target.run_method("calculate_taxes_and_totals")
- target.run_method("set_use_serial_batch_fields")
-
- if source.company_address:
- target.update({"company_address": source.company_address})
- else:
- # set company address
- target.update(get_company_address(target.company))
-
- if target.company_address:
- target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address))
-
- # set the redeem loyalty points if provided via shopping cart
- if source.loyalty_points and source.order_type == "Shopping Cart":
- target.redeem_loyalty_points = 1
- target.loyalty_points = source.loyalty_points
-
- target.debit_to = get_party_account("Customer", source.customer, source.company)
-
- def update_item(source, target, source_parent):
- def get_billed_qty(so_item_name):
- from frappe.query_builder.functions import Sum
-
- table = frappe.qb.DocType("Sales Invoice Item")
- query = (
- frappe.qb.from_(table)
- .select(Sum(table.qty).as_("qty"))
- .where((table.docstatus == 1) & (table.so_detail == so_item_name))
- )
- return query.run(pluck="qty")[0] or 0
-
- if source_parent.has_unit_price_items:
- # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
- pending_amount = flt(source.amount) - flt(source.billed_amt)
- target.amount = pending_amount if flt(source.amount) else 0
- else:
- target.amount = flt(source.amount) - flt(source.billed_amt)
-
- target.base_amount = target.amount * flt(source_parent.conversion_rate)
- target.qty = (
- source.qty - get_billed_qty(source.name)
- if (source.qty and source.billed_amt)
- else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
- )
-
- if source_parent.project:
- target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center")
- if target.item_code:
- item = get_item_defaults(target.item_code, source_parent.company)
- item_group = get_item_group_defaults(target.item_code, source_parent.company)
- cost_center = item.get("selling_cost_center") or item_group.get("selling_cost_center")
-
- if cost_center:
- target.cost_center = cost_center
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- def add_self_rm(doclist):
- parent = frappe.qb.DocType("Subcontracting Inward Order")
- child = frappe.qb.DocType("Subcontracting Inward Order Received Item")
- query = (
- frappe.qb.from_(parent)
- .join(child)
- .on(parent.name == child.parent)
- .select(
- child.required_qty,
- child.consumed_qty,
- child.billed_qty,
- child.rm_item_code,
- child.stock_uom,
- child.name,
- )
- .where(
- (parent.docstatus == 1)
- & (parent.sales_order == source_name)
- & (child.is_customer_provided_item == 0)
- )
- )
- result = query.run(as_dict=True)
-
- if result:
- idx = len(doclist.items) + 1
- for item in result:
- if (qty := max(item.required_qty, item.consumed_qty) - item.billed_qty) > 0:
- doclist.append(
- "items",
- {
- "item_code": item.rm_item_code,
- "qty": qty,
- "uom": item.stock_uom,
- "scio_detail": item.name,
- },
- )
- doclist.process_item_selection(idx)
- idx += 1
- doclist.has_subcontracted = 1
-
- doclist = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {
- "doctype": "Sales Invoice",
- "field_map": {
- "party_account_currency": "party_account_currency",
- },
- "field_no_map": ["payment_terms_template"],
- "validation": {"docstatus": ["=", 1]},
- },
- "Sales Order Item": {
- "doctype": "Sales Invoice Item",
- "field_map": {
- "name": "so_detail",
- "parent": "sales_order",
- },
- "postprocess": update_item,
- "condition": lambda doc: (
- True
- if is_unit_price_row(doc)
- else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
- )
- and select_item(doc),
- },
- "Sales Taxes and Charges": {
- "doctype": "Sales Taxes and Charges",
- "reset_value": True,
- },
- "Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
- },
- target_doc,
- postprocess,
- ignore_permissions=ignore_permissions,
- )
-
- if frappe.get_cached_value("Sales Order", source_name, "is_subcontracted"):
- add_self_rm(doclist)
-
- automatically_fetch_payment_terms = cint(
- frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
- )
- if automatically_fetch_payment_terms:
- doclist.set_payment_schedule()
-
- return doclist
-
-
-@frappe.whitelist()
-def make_maintenance_schedule(source_name: str, target_doc: str | Document | None = None):
- maint_schedule = frappe.db.exists(
- "Maintenance Schedule Item", {"sales_order": source_name, "docstatus": 1}
- )
-
- if not maint_schedule:
- doclist = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {"doctype": "Maintenance Schedule", "validation": {"docstatus": ["=", 1]}},
- "Sales Order Item": {
- "doctype": "Maintenance Schedule Item",
- "field_map": {"parent": "sales_order"},
- },
- },
- target_doc,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_maintenance_visit(source_name: str, target_doc: str | Document | None = None):
- MaintenanceVisit = frappe.qb.DocType("Maintenance Visit")
- MaintenanceVisitPurpose = frappe.qb.DocType("Maintenance Visit Purpose")
-
- query = (
- frappe.qb.from_(MaintenanceVisit)
- .join(MaintenanceVisitPurpose)
- .on(MaintenanceVisitPurpose.parent == MaintenanceVisit.name)
- .select(MaintenanceVisit.name)
- .where(MaintenanceVisitPurpose.prevdoc_docname == source_name)
- .where(MaintenanceVisit.docstatus == 1)
- .where(MaintenanceVisit.completion_status == "Fully Completed")
- )
-
- if not query.run():
- doclist = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {"doctype": "Maintenance Visit", "validation": {"docstatus": ["=", 1]}},
- "Sales Order Item": {
- "doctype": "Maintenance Visit Purpose",
- "field_map": {"parent": "prevdoc_docname", "parenttype": "prevdoc_doctype"},
- },
- },
- target_doc,
- )
-
- return doclist
-
-
@frappe.whitelist()
def get_events(start: str, end: str, filters: str | dict | None = None):
"""Returns events for Gantt / Calendar view rendering.
@@ -1709,404 +1081,12 @@ def get_events(start: str, end: str, filters: str | dict | None = None):
return data
-@frappe.whitelist()
-def make_purchase_order(
- source_name: str, selected_items: str | list | None = None, target_doc: str | Document | None = None
-):
- """Creates Purchase Order for each Supplier. Returns a list of doc objects."""
-
- from erpnext.setup.utils import get_exchange_rate
-
- if not selected_items:
- return
-
- if isinstance(selected_items, str):
- selected_items = json.loads(selected_items)
-
- def set_missing_values(source, target):
- target.supplier = supplier
- company_currency = frappe.db.get_value(
- "Company", filters={"name": target.company}, fieldname=["default_currency"]
- )
- supplier_currency = frappe.db.get_value(
- "Supplier", filters={"name": supplier}, fieldname=["default_currency"]
- )
-
- target.currency = supplier_currency if supplier_currency else company_currency
-
- target.conversion_rate = get_exchange_rate(target.currency, company_currency, args="for_buying")
-
- target.apply_discount_on = ""
- target.additional_discount_percentage = 0.0
- target.discount_amount = 0.0
- target.inter_company_order_reference = ""
- target.shipping_rule = ""
- target.tc_name = ""
- target.terms = ""
- target.payment_schedule = []
-
- default_price_list = frappe.get_value("Supplier", supplier, "default_price_list")
- if default_price_list:
- target.buying_price_list = default_price_list
-
- default_payment_terms = frappe.get_value("Supplier", supplier, "payment_terms")
- if default_payment_terms:
- target.payment_terms_template = default_payment_terms
-
- if any(item.delivered_by_supplier for item in target.items):
- if source.shipping_address_name:
- target.shipping_address = source.shipping_address_name
- target.shipping_address_display = source.shipping_address
- else:
- target.shipping_address = source.customer_address
- target.shipping_address_display = source.address_display
-
- target.customer_contact_person = source.contact_person
- target.customer_contact_display = source.contact_display
- target.customer_contact_mobile = source.contact_mobile
- target.customer_contact_email = source.contact_email
-
- else:
- target.customer = ""
- target.customer_name = ""
-
- target.run_method("set_missing_values")
- target.run_method("calculate_taxes_and_totals")
-
- def update_item(source, target, source_parent):
- target.schedule_date = source.delivery_date
- target.qty = flt(source.qty) - (flt(source.ordered_qty) / flt(source.conversion_factor))
- target.stock_qty = flt(source.stock_qty) - flt(source.ordered_qty)
- target.project = source_parent.project
-
- def update_item_for_packed_item(source, target, _):
- target.qty = flt(source.qty) - flt(source.ordered_qty)
-
- def filter_items(item, supplier):
- if (
- item.ordered_qty < item.stock_qty
- and not is_product_bundle(item.item_code)
- and items_to_map.get(item.item_code) == supplier
- ):
- return True
-
- return False
-
- items_to_map = {
- item.get("item_code"): item.get("supplier") for item in selected_items if item.get("item_code")
- }
- item_codes = list(set(items_to_map.keys()))
- suppliers = list(set(items_to_map.values()))
-
- if not suppliers:
- suppliers = [None]
-
- purchase_orders = []
- for supplier in suppliers:
- doc = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {
- "doctype": "Purchase Order",
- "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP],
- "validation": {"docstatus": ["=", 1]},
- },
- "Sales Order Item": {
- "doctype": "Purchase Order Item",
- "field_map": [
- ["name", "sales_order_item"],
- ["parent", "sales_order"],
- ["stock_uom", "stock_uom"],
- ["uom", "uom"],
- ["conversion_factor", "conversion_factor"],
- ["delivery_date", "schedule_date"],
- ],
- "field_no_map": [
- "rate",
- "price_list_rate",
- "item_tax_template",
- "discount_percentage",
- "discount_amount",
- "pricing_rules",
- "margin_type",
- "margin_rate_or_amount",
- ],
- "postprocess": update_item,
- "condition": lambda doc, s=supplier: filter_items(doc, s),
- },
- "Packed Item": {
- "doctype": "Purchase Order Item",
- "field_map": [
- ["name", "sales_order_packed_item"],
- ["parent", "sales_order"],
- ["uom", "uom"],
- ["conversion_factor", "conversion_factor"],
- ["parent_item", "product_bundle"],
- ["rate", "rate"],
- ],
- "field_no_map": [
- "price_list_rate",
- "item_tax_template",
- "discount_percentage",
- "discount_amount",
- "supplier",
- "pricing_rules",
- ],
- "postprocess": update_item_for_packed_item,
- "condition": lambda doc: doc.parent_item in item_codes
- and flt(doc.ordered_qty) < flt(doc.qty),
- },
- },
- target_doc,
- set_missing_values,
- )
-
- set_delivery_date(doc.items, source_name)
- if doc.supplier:
- doc.insert()
- purchase_orders.append(doc)
-
- return purchase_orders
-
-
-def set_delivery_date(items, sales_order):
- delivery_dates = frappe.get_all(
- "Sales Order Item", filters={"parent": sales_order}, fields=["delivery_date", "item_code"]
- )
-
- delivery_by_item = frappe._dict()
- for date in delivery_dates:
- delivery_by_item[date.item_code] = date.delivery_date
-
- for item in items:
- if item.product_bundle:
- item.schedule_date = delivery_by_item[item.product_bundle]
-
-
-def is_product_bundle(item_code):
- return frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0})
-
-
-@frappe.whitelist()
-def make_work_orders(items: str, sales_order: str, company: str, project: str | None = None):
- """Make Work Orders against the given Sales Order for the given `items`"""
- items = json.loads(items).get("items")
- out = []
-
- for i in items:
- if not i.get("bom"):
- frappe.throw(_("Please select BOM against item {0}").format(i.get("item_code")))
- if not i.get("pending_qty"):
- frappe.throw(_("Please select Qty against item {0}").format(i.get("item_code")))
-
- work_order = frappe.get_doc(
- doctype="Work Order",
- production_item=i["item_code"],
- bom_no=i.get("bom"),
- qty=i["pending_qty"],
- company=company,
- sales_order=sales_order,
- sales_order_item=i["sales_order_item"],
- project=project,
- fg_warehouse=i["warehouse"],
- description=i["description"],
- ).insert()
- work_order.set_work_order_operations()
- work_order.flags.ignore_mandatory = True
- work_order.save()
- out.append(work_order)
-
- return [p.name for p in out]
-
-
-@frappe.whitelist()
-def make_production_plan(source_name: str, target_doc: str | Document | None = None):
- sales_order = frappe.get_doc("Sales Order", source_name)
-
- production_plan = frappe.new_doc(
- "Production Plan",
- company=sales_order.company,
- get_items_from="Sales Order",
- posting_date=nowdate(),
- )
-
- open_so = [data.name for data in get_sales_orders(production_plan)]
- if sales_order.name not in open_so:
- frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name))
-
- production_plan.append(
- "sales_orders",
- {
- "sales_order": sales_order.name,
- "sales_order_date": sales_order.transaction_date,
- "customer": sales_order.customer,
- "grand_total": sales_order.base_grand_total,
- },
- )
- production_plan.get_items()
- if not production_plan.get("po_items"):
- frappe.throw(_("Sales Order {0} is not available for production").format(sales_order.name))
-
- return production_plan
-
-
@frappe.whitelist()
def update_status(status: str, name: str):
so = frappe.get_doc("Sales Order", name, check_permission="submit")
so.update_status(status)
-@frappe.whitelist()
-def make_raw_material_request(
- items: str | frappe._dict, company: str, sales_order: str, project: str | None = None
-):
- if not frappe.has_permission("Sales Order", "write"):
- frappe.throw(_("Not permitted"), frappe.PermissionError)
-
- if isinstance(items, str):
- items = frappe._dict(json.loads(items))
-
- for item in items.get("items"):
- item["include_exploded_items"] = items.get("include_exploded_items")
- item["ignore_existing_ordered_qty"] = items.get("ignore_existing_ordered_qty")
- item["include_raw_materials_from_sales_order"] = items.get("include_raw_materials_from_sales_order")
-
- items.update({"company": company, "sales_order": sales_order})
-
- item_wh = {}
- for item in items.get("items"):
- if item.get("warehouse"):
- item_wh[item.get("item_code")] = item.get("warehouse")
-
- raw_materials = get_items_for_material_requests(items)
- if not raw_materials:
- frappe.msgprint(_("Material Request not created, as quantity for Raw Materials already available."))
- return
-
- material_request = frappe.new_doc("Material Request")
- material_request.update(
- dict(
- doctype="Material Request",
- transaction_date=nowdate(),
- company=company,
- material_request_type="Purchase",
- )
- )
- for item in raw_materials:
- item_doc = frappe.get_cached_doc("Item", item.get("item_code"))
-
- schedule_date = add_days(nowdate(), cint(item_doc.lead_time_days))
- row = material_request.append(
- "items",
- {
- "item_code": item.get("item_code"),
- "qty": item.get("quantity"),
- "schedule_date": schedule_date,
- "warehouse": item_wh.get(item.get("main_bom_item")) or item.get("warehouse"),
- "sales_order": sales_order,
- "project": project,
- },
- )
-
- if not (strip_html(item.get("description")) and strip_html(item_doc.description)):
- row.description = item_doc.item_name or item.get("item_code")
-
- material_request.insert()
- material_request.flags.ignore_permissions = 1
- material_request.run_method("set_missing_values")
- material_request.submit()
- return material_request
-
-
-@frappe.whitelist()
-def make_inter_company_purchase_order(source_name: str, target_doc: str | Document | None = None):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction
-
- return make_inter_company_transaction("Sales Order", source_name, target_doc)
-
-
-@frappe.whitelist()
-def create_pick_list(source_name: str, target_doc: str | Document | None = None):
- from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle
-
- def validate_sales_order():
- so = frappe.get_doc("Sales Order", source_name)
- for item in so.items:
- if item.stock_reserved_qty > 0:
- frappe.throw(
- _(
- "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list."
- ).format(frappe.bold(source_name))
- )
-
- def update_item_quantity(source, target, source_parent) -> None:
- picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1)
- qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty))
-
- target.qty = qty_to_be_picked
- target.stock_qty = qty_to_be_picked * flt(source.conversion_factor)
-
- # update available qty
- bin_details = get_bin_details(source.item_code, source.warehouse, source_parent.company)
- target.actual_qty = bin_details.get("actual_qty")
- target.company_total_stock = bin_details.get("company_total_stock")
-
- def update_packed_item_qty(source, target, source_parent) -> None:
- qty = flt(source.qty)
- for item in source_parent.items:
- if source.parent_detail_docname == item.name:
- picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1)
- pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty
- target.qty = target.stock_qty = qty * pending_percent
- return
-
- def should_pick_order_item(item) -> bool:
- return (
- abs(item.delivered_qty) < abs(item.qty)
- and item.delivered_by_supplier != 1
- and not is_product_bundle(item.item_code)
- )
-
- # Don't allow a Pick List to be created against a Sales Order that has reserved stock.
- validate_sales_order()
-
- doc = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {
- "doctype": "Pick List",
- "field_map": {"set_warehouse": "parent_warehouse"},
- "validation": {"docstatus": ["=", 1]},
- },
- "Sales Order Item": {
- "doctype": "Pick List Item",
- "field_map": {"parent": "sales_order", "name": "sales_order_item"},
- "postprocess": update_item_quantity,
- "condition": should_pick_order_item,
- },
- "Packed Item": {
- "doctype": "Pick List Item",
- "field_map": {
- "parent": "sales_order",
- "parent_detail_docname": "sales_order_item",
- "name": "product_bundle_item",
- },
- "field_no_map": ["picked_qty"],
- "postprocess": update_packed_item_qty,
- },
- },
- target_doc,
- )
-
- doc.purpose = "Delivery"
-
- doc.set_item_locations()
-
- return doc
-
-
def update_produced_qty_in_so_item(sales_order, sales_order_item):
# for multiple work orders against same sales order item
linked_wo_with_so_item = frappe.db.get_all(
@@ -2192,71 +1172,3 @@ def get_work_order_items(sales_order: str, for_raw_material_request: int = 0):
@frappe.whitelist()
def get_stock_reservation_status():
return frappe.get_single_value("Stock Settings", "enable_stock_reservation")
-
-
-@frappe.whitelist()
-def make_subcontracting_inward_order(source_name: str, target_doc: str | Document | None = None):
- if not is_so_fully_subcontracted(source_name):
- return get_mapped_subcontracting_inward_order(source_name, target_doc)
- else:
- frappe.throw(_("This Sales Order has been fully subcontracted."))
-
-
-def is_so_fully_subcontracted(so_name):
- table = frappe.qb.DocType("Sales Order Item")
- query = (
- frappe.qb.from_(table)
- .select(table.name)
- .where((table.parent == so_name) & (table.qty != table.subcontracted_qty))
- )
- return not query.run(as_dict=True)
-
-
-def get_mapped_subcontracting_inward_order(source_name, target_doc=None):
- def post_process(source_doc, target_doc):
- if (
- frappe.db.count(
- "Warehouse", {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0}
- )
- == 1
- ):
- target_doc.customer_warehouse = frappe.get_cached_value(
- "Warehouse",
- {"customer": source_doc.customer, "disabled": 0, "is_rejected_warehouse": 0},
- "name",
- )
- target_doc.populate_items_table()
-
- if target_doc and isinstance(target_doc, str):
- target_doc = json.loads(target_doc)
- for key in ["service_items", "items", "received_items"]:
- if key in target_doc:
- del target_doc[key]
- target_doc = json.dumps(target_doc)
-
- target_doc = get_mapped_doc(
- "Sales Order",
- source_name,
- {
- "Sales Order": {
- "doctype": "Subcontracting Inward Order",
- "field_map": {},
- "field_no_map": ["total_qty", "total", "net_total"],
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Sales Order Item": {
- "doctype": "Subcontracting Inward Order Service Item",
- "field_map": {
- "name": "sales_order_item",
- },
- "field_no_map": ["qty", "fg_item_qty", "amount"],
- "condition": lambda item: item.qty != item.subcontracted_qty,
- },
- },
- target_doc,
- post_process,
- )
-
- return target_doc
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 8000fb368bb..109716ab27e 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -19,8 +19,7 @@ from erpnext.maintenance.doctype.maintenance_visit.test_maintenance_visit import
)
from erpnext.manufacturing.doctype.blanket_order.test_blanket_order import make_blanket_order
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
-from erpnext.selling.doctype.sales_order.sales_order import (
- WarehouseRequired,
+from erpnext.selling.doctype.sales_order.mapper import (
create_pick_list,
make_delivery_note,
make_material_request,
@@ -30,6 +29,9 @@ from erpnext.selling.doctype.sales_order.sales_order import (
make_sales_invoice,
make_work_orders,
)
+from erpnext.selling.doctype.sales_order.sales_order import (
+ WarehouseRequired,
+)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.get_item_details import get_bin_details
@@ -253,7 +255,7 @@ class TestSalesOrder(ERPNextTestSuite):
self.assertEqual(len(si1.get("items")), 0)
def test_so_billed_amount_against_return_entry(self):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return
so = make_sales_order(do_not_submit=True)
so.submit()
@@ -1902,7 +1904,7 @@ class TestSalesOrder(ERPNextTestSuite):
def test_so_back_updated_from_wo_via_mr(self):
"SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_se_from_wo,
)
from erpnext.stock.doctype.material_request.material_request import raise_work_orders
@@ -2333,7 +2335,7 @@ class TestSalesOrder(ERPNextTestSuite):
self.assertEqual(row.warehouse, warehouse)
def test_pick_list_for_batch(self):
- from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note
+ from erpnext.stock.doctype.pick_list.mapper import create_delivery_note
batch_item = make_item(
"_Test Batch Item for Pick LIST",
diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js
index 070d4288147..4ae37052ec0 100644
--- a/erpnext/selling/page/point_of_sale/pos_controller.js
+++ b/erpnext/selling/page/point_of_sale/pos_controller.js
@@ -617,7 +617,7 @@ erpnext.PointOfSale.Controller = class {
method:
doc.doctype == "POS Invoice"
? "erpnext.accounts.doctype.pos_invoice.pos_invoice.make_sales_return"
- : "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_sales_return",
+ : "erpnext.accounts.doctype.sales_invoice.mapper.make_sales_return",
args: {
source_name: doc.name,
target_doc: this.frm.doc,
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
index 1b583967a47..ca7e338e936 100644
--- a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
@@ -3,7 +3,7 @@ import datetime
import frappe
from frappe.utils import add_days, add_months, nowdate
-from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import (
execute,
diff --git a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py
index 3f540a3b94a..166ff34b7a0 100644
--- a/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py
+++ b/erpnext/selling/report/pending_so_items_for_purchase_request/test_pending_so_items_for_purchase_request.py
@@ -4,7 +4,7 @@
from frappe.utils import add_months, nowdate
-from erpnext.selling.doctype.sales_order.sales_order import make_material_request
+from erpnext.selling.doctype.sales_order.mapper import make_material_request
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.pending_so_items_for_purchase_request.pending_so_items_for_purchase_request import (
execute,
diff --git a/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py
index 1a200c7eba3..4d351742ee6 100644
--- a/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py
+++ b/erpnext/selling/report/sales_order_analysis/test_sales_order_analysis.py
@@ -1,7 +1,7 @@
import frappe
from frappe.utils import add_days
-from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note, make_sales_invoice
+from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.selling.report.sales_order_analysis.sales_order_analysis import execute
from erpnext.stock.doctype.item.test_item import create_item
diff --git a/erpnext/setup/demo.py b/erpnext/setup/demo.py
index c460b1520c4..29049a54794 100644
--- a/erpnext/setup/demo.py
+++ b/erpnext/setup/demo.py
@@ -11,8 +11,8 @@ from frappe.utils import add_days, get_url_to_form, getdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.utils import get_fiscal_year
-from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
-from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+from erpnext.buying.doctype.purchase_order.mapper import make_purchase_invoice
+from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account
diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py
index ed4a8c5509c..284cabe8255 100644
--- a/erpnext/stock/doctype/batch/test_batch.py
+++ b/erpnext/stock/doctype/batch/test_batch.py
@@ -387,7 +387,7 @@ class TestBatch(ERPNextTestSuite):
self.assertEqual(get_batch_qty("batch a", "_Test Warehouse - _TC"), 90)
def test_ignore_reserved_qty(self):
- from erpnext.selling.doctype.sales_order.sales_order import create_pick_list
+ from erpnext.selling.doctype.sales_order.mapper import create_pick_list
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
batch_item_name = "Reserve Batch Item"
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js
index 1b7f147f30c..6c5e1fadf04 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.js
@@ -89,7 +89,7 @@ frappe.ui.form.on("Delivery Note", {
__("Credit Note"),
function () {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
frm: cur_frm,
});
},
@@ -114,7 +114,7 @@ frappe.ui.form.on("Delivery Note", {
__(button_label),
function () {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_inter_company_purchase_receipt",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_inter_company_purchase_receipt",
frm: frm,
});
},
@@ -163,7 +163,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
});
}
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_delivery_note",
args: {
for_reserved_stock: 1,
},
@@ -205,7 +205,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
});
}
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.pick_list.pick_list.create_dn_for_pick_lists",
+ method: "erpnext.stock.doctype.pick_list.mapper.create_dn_for_pick_lists",
source_doctype: "Pick List",
target: me.frm,
setters: [
@@ -296,7 +296,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
__("Packing Slip"),
function () {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_packing_slip",
frm: me.frm,
});
},
@@ -367,7 +367,7 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
make_shipment() {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_shipment",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_shipment",
frm: this.frm,
});
}
@@ -383,28 +383,28 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends (
make_sales_invoice() {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_invoice",
frm: this.frm,
});
}
make_installation_note() {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_installation_note",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_installation_note",
frm: this.frm,
});
}
make_sales_return() {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_return",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_sales_return",
frm: this.frm,
});
}
make_delivery_trip() {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_delivery_trip",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_delivery_trip",
frm: cur_frm,
});
}
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 1b51949a4c0..a86055692b6 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -2,25 +2,21 @@
# License: GNU General Public License v3. See license.txt
-import json
-
import frappe
from frappe import _
-from frappe.contacts.doctype.address.address import get_company_address
-from frappe.contacts.doctype.contact.contact import get_default_contact
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
-from frappe.model.utils import get_fetch_values
from frappe.query_builder import DocType
from frappe.query_builder.functions import Abs, Sum
from frappe.utils import cint, flt
-from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
-from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
+from .mapper import (
+ make_sales_invoice,
+)
+
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -754,8 +750,6 @@ class DeliveryNote(SellingController):
def update_billed_amount_based_on_so(so_detail, update_modified=True):
- from frappe.query_builder.functions import Sum
-
# Billed against Sales Order directly
si = frappe.qb.DocType("Sales Invoice").as_("si")
si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
@@ -850,565 +844,7 @@ def get_list_context(context=None):
return list_context
-def get_invoiced_qty_map(delivery_note):
- """returns a map: {dn_detail: invoiced_qty}"""
- sii = DocType("Sales Invoice Item")
-
- invoiced_qty_map = frappe._dict(
- (
- frappe.qb.from_(sii)
- .select(sii.dn_detail, Sum(sii.qty).as_("qty"))
- .where((sii.delivery_note == delivery_note) & (sii.docstatus == 1))
- .groupby(sii.dn_detail)
- ).run()
- )
-
- return invoiced_qty_map
-
-
-def get_returned_qty_map(delivery_note):
- """returns a map: {so_detail: returned_qty}"""
- dn = DocType("Delivery Note")
- dni = DocType("Delivery Note Item")
-
- returned_qty_map = frappe._dict(
- (
- frappe.qb.from_(dni)
- .join(dn)
- .on(dn.name == dni.parent)
- .select(dni.dn_detail, Sum(Abs(dni.qty)).as_("qty"))
- .where(
- (dn.docstatus == 1)
- & (dn.is_return == 1)
- & (dn.return_against == delivery_note)
- & (dni.qty <= 0)
- )
- .groupby(dni.dn_detail)
- ).run()
- )
-
- return returned_qty_map
-
-
-@frappe.whitelist()
-def make_sales_invoice(
- source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None
-):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- doc = frappe.get_doc("Delivery Note", source_name)
-
- to_make_invoice_qty_map = {}
- returned_qty_map = get_returned_qty_map(source_name)
- invoiced_qty_map = get_invoiced_qty_map(source_name)
-
- def set_missing_values(source, target):
- target.run_method("set_missing_values")
- target.run_method("set_po_nos")
-
- if len(target.get("items")) == 0:
- frappe.throw(_("All these items have already been Invoiced/Returned"))
-
- if args and args.get("merge_taxes"):
- merge_taxes(source, target)
-
- target.run_method("calculate_taxes_and_totals")
-
- # set company address
- if source.company_address:
- target.update({"company_address": source.company_address})
- else:
- # set company address
- target.update(get_company_address(target.company))
-
- if target.company_address:
- target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address))
-
- def update_item(source_doc, target_doc, source_parent):
- target_doc.qty = to_make_invoice_qty_map[source_doc.name]
- target_doc._old_name = source_doc.name
-
- def get_pending_qty(item_row):
- pending_qty = item_row.qty - invoiced_qty_map.get(item_row.name, 0)
-
- returned_qty = 0
- if returned_qty_map.get(item_row.name, 0) > 0:
- returned_qty = flt(returned_qty_map.get(item_row.name, 0))
- returned_qty_map[item_row.name] -= pending_qty
-
- if returned_qty:
- if returned_qty >= pending_qty:
- pending_qty = 0
- returned_qty -= pending_qty
- else:
- pending_qty -= returned_qty
- returned_qty = 0
-
- to_make_invoice_qty_map[item_row.name] = pending_qty
-
- return pending_qty
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- doc = get_mapped_doc(
- "Delivery Note",
- source_name,
- {
- "Delivery Note": {
- "doctype": "Sales Invoice",
- "field_map": {"is_return": "is_return"},
- "validation": {"docstatus": ["=", 1]},
- },
- "Delivery Note Item": {
- "doctype": "Sales Invoice Item",
- "field_map": {
- "name": "dn_detail",
- "parent": "delivery_note",
- "so_detail": "so_detail",
- "against_sales_order": "sales_order",
- "cost_center": "cost_center",
- },
- "postprocess": update_item,
- "filter": lambda d: get_pending_qty(d) <= 0
- if not doc.get("is_return")
- else get_pending_qty(d) > 0,
- "condition": select_item,
- },
- "Sales Taxes and Charges": {
- "doctype": "Sales Taxes and Charges",
- "reset_value": not (args and args.get("merge_taxes")),
- "ignore": args.get("merge_taxes") if args else 0,
- },
- "Sales Team": {
- "doctype": "Sales Team",
- "field_map": {"incentives": "incentives"},
- "add_if_empty": True,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- automatically_fetch_payment_terms = cint(
- frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
- )
-
- if not doc.is_return:
- so, doctype, fieldname = doc.get_order_details()
- if (
- doc.linked_order_has_payment_terms(so, fieldname, doctype)
- and not automatically_fetch_payment_terms
- ):
- payment_terms_template = frappe.db.get_value(doctype, so, "payment_terms_template")
- doc.payment_terms_template = payment_terms_template
- doc.due_date = get_due_date(
- doc.posting_date,
- "Customer",
- doc.customer,
- doc.company,
- template_name=doc.payment_terms_template,
- )
-
- elif automatically_fetch_payment_terms:
- doc.set_payment_schedule()
-
- return doc
-
-
-@frappe.whitelist()
-def make_delivery_trip(
- source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None
-):
- if not target_doc:
- target_doc = frappe.new_doc("Delivery Trip")
-
- def update_address(source_doc, target_doc, source_parent):
- target_doc.address = source_doc.shipping_address_name or source_doc.customer_address
- target_doc.customer_address = source_doc.shipping_address or source_doc.address_display
-
- doclist = get_mapped_doc(
- "Delivery Note",
- source_name,
- {
- "Delivery Note": {
- "doctype": "Delivery Stop",
- "on_parent": target_doc,
- "field_map": {
- "name": "delivery_note",
- "contact_person": "contact",
- "contact_display": "customer_contact",
- },
- "postprocess": update_address,
- },
- },
- ignore_child_tables=True,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_installation_note(
- source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None
-):
- def update_item(obj, target, source_parent):
- target.qty = flt(obj.qty) - flt(obj.installed_qty)
- target.serial_no = obj.serial_no
-
- doclist = get_mapped_doc(
- "Delivery Note",
- source_name,
- {
- "Delivery Note": {"doctype": "Installation Note", "validation": {"docstatus": ["=", 1]}},
- "Delivery Note Item": {
- "doctype": "Installation Note Item",
- "field_map": {
- "name": "prevdoc_detail_docname",
- "parent": "prevdoc_docname",
- "parenttype": "prevdoc_doctype",
- },
- "postprocess": update_item,
- "condition": lambda doc: doc.installed_qty < doc.qty,
- },
- },
- target_doc,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_packing_slip(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- target.run_method("set_missing_values")
-
- def update_item(obj, target, source_parent):
- target.qty = flt(obj.qty) - flt(obj.packed_qty)
-
- doclist = get_mapped_doc(
- "Delivery Note",
- source_name,
- {
- "Delivery Note": {
- "doctype": "Packing Slip",
- "field_map": {"name": "delivery_note", "letter_head": "letter_head"},
- "validation": {"docstatus": ["=", 0]},
- },
- "Delivery Note Item": {
- "doctype": "Packing Slip Item",
- "field_map": {
- "item_code": "item_code",
- "item_name": "item_name",
- "batch_no": "batch_no",
- "description": "description",
- "qty": "qty",
- "uom": "stock_uom",
- "name": "dn_detail",
- },
- "postprocess": update_item,
- "condition": lambda item: (
- not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0})
- and flt(item.packed_qty) < flt(item.qty)
- ),
- },
- "Packed Item": {
- "doctype": "Packing Slip Item",
- "field_map": {
- "item_code": "item_code",
- "item_name": "item_name",
- "batch_no": "batch_no",
- "description": "description",
- "qty": "qty",
- "name": "pi_detail",
- },
- "postprocess": update_item,
- "condition": lambda item: (flt(item.packed_qty) < flt(item.qty)),
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_shipment(source_name: str, target_doc: str | Document | None = None):
- def postprocess(source, target):
- user = frappe.db.get_value(
- "User", frappe.session.user, ["email", "full_name", "phone", "mobile_no"], as_dict=1
- )
- target.pickup_contact_email = user.email
- pickup_contact_display = f"{user.full_name}"
- if user:
- if user.email:
- pickup_contact_display += "
" + user.email
- if user.phone:
- pickup_contact_display += "
" + user.phone
- if user.mobile_no and not user.phone:
- pickup_contact_display += "
" + user.mobile_no
- target.pickup_contact = pickup_contact_display
-
- # As we are using session user details in the pickup_contact then pickup_contact_person will be session user
- target.pickup_contact_person = frappe.session.user
-
- contact_person = source.contact_person or get_default_contact("Customer", source.customer)
- if contact_person:
- contact = frappe.db.get_value(
- "Contact", contact_person, ["email_id", "phone", "mobile_no"], as_dict=1
- )
-
- delivery_contact_display = source.contact_display or contact_person or ""
- if contact and not source.contact_display:
- if contact.email_id:
- delivery_contact_display += "
" + contact.email_id
- if contact.phone:
- delivery_contact_display += "
" + contact.phone
- if contact.mobile_no and not contact.phone:
- delivery_contact_display += "
" + contact.mobile_no
-
- target.delivery_contact_name = contact_person
- if contact and contact.email_id and not target.delivery_contact_email:
- target.delivery_contact_email = contact.email_id
- target.delivery_contact = delivery_contact_display
-
- if source.shipping_address_name:
- target.delivery_address_name = source.shipping_address_name
- target.delivery_address = source.shipping_address
- elif source.customer_address:
- target.delivery_address_name = source.customer_address
- target.delivery_address = source.address_display
-
- doclist = get_mapped_doc(
- "Delivery Note",
- source_name,
- {
- "Delivery Note": {
- "doctype": "Shipment",
- "field_map": {
- "grand_total": "value_of_goods",
- "company": "pickup_company",
- "company_address": "pickup_address_name",
- "company_address_display": "pickup_address",
- "customer": "delivery_customer",
- "contact_person": "delivery_contact_name",
- "contact_email": "delivery_contact_email",
- },
- "validation": {"docstatus": ["=", 1]},
- },
- "Delivery Note Item": {
- "doctype": "Shipment Delivery Note",
- "field_map": {
- "name": "prevdoc_detail_docname",
- "parent": "prevdoc_docname",
- "parenttype": "prevdoc_doctype",
- "base_amount": "grand_total",
- },
- },
- },
- target_doc,
- postprocess,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_sales_return(source_name: str, target_doc: str | Document | None = None):
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
-
- return make_return_doc("Delivery Note", source_name, target_doc)
-
-
@frappe.whitelist()
def update_delivery_note_status(docname: str, status: str):
dn = frappe.get_lazy_doc("Delivery Note", docname)
dn.update_status(status)
-
-
-@frappe.whitelist()
-def make_inter_company_purchase_receipt(source_name: str, target_doc: str | Document | None = None):
- return make_inter_company_transaction("Delivery Note", source_name, target_doc)
-
-
-def make_inter_company_transaction(doctype, source_name, target_doc=None):
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
- get_inter_company_details,
- set_purchase_references,
- update_address,
- update_taxes,
- validate_inter_company_transaction,
- )
-
- if doctype == "Delivery Note":
- source_doc = frappe.get_doc(doctype, source_name)
- target_doctype = "Purchase Receipt"
- source_document_warehouse_field = "target_warehouse"
- target_document_warehouse_field = "from_warehouse"
- else:
- source_doc = frappe.get_doc(doctype, source_name)
- target_doctype = "Delivery Note"
- source_document_warehouse_field = "from_warehouse"
- target_document_warehouse_field = "target_warehouse"
-
- validate_inter_company_transaction(source_doc, doctype)
- details = get_inter_company_details(source_doc, doctype)
-
- def set_missing_values(source, target):
- target.run_method("set_missing_values")
- set_purchase_references(target)
-
- if target.doctype == "Purchase Receipt":
- master_doctype = "Purchase Taxes and Charges Template"
- else:
- master_doctype = "Sales Taxes and Charges Template"
-
- if not target.get("taxes") and target.get("taxes_and_charges"):
- for tax in get_taxes_and_charges(master_doctype, target.get("taxes_and_charges")):
- target.append("taxes", tax)
-
- if not target.get("items"):
- frappe.throw(_("All items have already been received"))
-
- def update_details(source_doc, target_doc, source_parent):
- def _validate_address_link(address, link_doctype, link_name):
- return frappe.db.get_value(
- "Dynamic Link",
- {
- "parent": address,
- "parenttype": "Address",
- "link_doctype": link_doctype,
- "link_name": link_name,
- },
- "parent",
- )
-
- target_doc.inter_company_invoice_reference = source_doc.name
- if target_doc.doctype == "Purchase Receipt":
- target_doc.company = details.get("company")
- target_doc.supplier = details.get("party")
- target_doc.buying_price_list = source_doc.selling_price_list
- target_doc.is_internal_supplier = 1
- target_doc.inter_company_reference = source_doc.name
-
- # Invert the address on target doc creation
- if source_doc.company_address and _validate_address_link(
- source_doc.company_address, "Supplier", details.get("party")
- ):
- update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
- if source_doc.dispatch_address_name and _validate_address_link(
- source_doc.dispatch_address_name, "Company", details.get("company")
- ):
- update_address(
- target_doc,
- "dispatch_address",
- "dispatch_address_display",
- source_doc.dispatch_address_name,
- )
- if source_doc.shipping_address_name and _validate_address_link(
- source_doc.shipping_address_name, "Company", details.get("company")
- ):
- update_address(
- target_doc,
- "shipping_address",
- "shipping_address_display",
- source_doc.shipping_address_name,
- )
- if source_doc.customer_address and _validate_address_link(
- source_doc.customer_address, "Company", details.get("company")
- ):
- update_address(
- target_doc, "billing_address", "billing_address_display", source_doc.customer_address
- )
-
- update_taxes(
- target_doc,
- party=target_doc.supplier,
- party_type="Supplier",
- company=target_doc.company,
- doctype=target_doc.doctype,
- party_address=target_doc.supplier_address,
- company_address=target_doc.shipping_address,
- )
- else:
- target_doc.company = details.get("company")
- target_doc.customer = details.get("party")
- target_doc.company_address = source_doc.supplier_address
- target_doc.selling_price_list = source_doc.buying_price_list
- target_doc.is_internal_customer = 1
- target_doc.inter_company_reference = source_doc.name
-
- # Invert the address on target doc creation
- if source_doc.supplier_address and _validate_address_link(
- source_doc.supplier_address, "Company", details.get("company")
- ):
- update_address(
- target_doc, "company_address", "company_address_display", source_doc.supplier_address
- )
- if source_doc.shipping_address and _validate_address_link(
- source_doc.shipping_address, "Customer", details.get("party")
- ):
- update_address(
- target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
- )
- if source_doc.shipping_address and _validate_address_link(
- source_doc.shipping_address, "Customer", details.get("party")
- ):
- update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
-
- update_taxes(
- target_doc,
- party=target_doc.customer,
- party_type="Customer",
- company=target_doc.company,
- doctype=target_doc.doctype,
- party_address=target_doc.customer_address,
- company_address=target_doc.company_address,
- shipping_address_name=target_doc.shipping_address_name,
- )
-
- def update_item(source, target, source_parent):
- if source_parent.doctype == "Delivery Note" and source.received_qty:
- target.qty = flt(source.qty) + flt(source.returned_qty) - flt(source.received_qty)
-
- if source.get("use_serial_batch_fields"):
- target.set("use_serial_batch_fields", 1)
-
- if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
- target.set("use_serial_batch_fields", 1)
-
- doclist = get_mapped_doc(
- doctype,
- source_name,
- {
- doctype: {
- "doctype": target_doctype,
- "postprocess": update_details,
- "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"],
- },
- doctype + " Item": {
- "doctype": target_doctype + " Item",
- "field_map": {
- source_document_warehouse_field: target_document_warehouse_field,
- "name": "delivery_note_item",
- "purchase_order": "purchase_order",
- "purchase_order_item": "purchase_order_item",
- "material_request": "material_request",
- "Material_request_item": "material_request_item",
- },
- "field_no_map": ["warehouse"],
- "condition": lambda item: item.received_qty < item.qty + item.returned_qty,
- "postprocess": update_item,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
index 56698ccf76b..bf55636133a 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
@@ -43,7 +43,7 @@ frappe.listview_settings["Delivery Note"] = {
type: "POST",
method: "frappe.model.mapper.map_docs",
args: {
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_delivery_trip",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_delivery_trip",
source_names: docnames,
target_doc: cur_frm.doc,
},
diff --git a/erpnext/stock/doctype/delivery_note/mapper.py b/erpnext/stock/doctype/delivery_note/mapper.py
new file mode 100644
index 00000000000..81fdf04f6fd
--- /dev/null
+++ b/erpnext/stock/doctype/delivery_note/mapper.py
@@ -0,0 +1,582 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.contacts.doctype.contact.contact import get_default_contact
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.model.utils import get_fetch_values
+from frappe.query_builder import DocType
+from frappe.query_builder.functions import Abs, Sum
+from frappe.utils import flt
+
+from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_due_date
+from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
+
+
+def get_invoiced_qty_map(delivery_note: str) -> dict:
+ """returns a map: {dn_detail: invoiced_qty}"""
+ sii = DocType("Sales Invoice Item")
+
+ invoiced_qty_map = frappe._dict(
+ (
+ frappe.qb.from_(sii)
+ .select(sii.dn_detail, Sum(sii.qty).as_("qty"))
+ .where((sii.delivery_note == delivery_note) & (sii.docstatus == 1))
+ .groupby(sii.dn_detail)
+ ).run()
+ )
+
+ return invoiced_qty_map
+
+
+def get_returned_qty_map(delivery_note: str) -> dict:
+ """returns a map: {so_detail: returned_qty}"""
+ dn = DocType("Delivery Note")
+ dni = DocType("Delivery Note Item")
+
+ returned_qty_map = frappe._dict(
+ (
+ frappe.qb.from_(dni)
+ .join(dn)
+ .on(dn.name == dni.parent)
+ .select(dni.dn_detail, Sum(Abs(dni.qty)).as_("qty"))
+ .where(
+ (dn.docstatus == 1)
+ & (dn.is_return == 1)
+ & (dn.return_against == delivery_note)
+ & (dni.qty <= 0)
+ )
+ .groupby(dni.dn_detail)
+ ).run()
+ )
+
+ return returned_qty_map
+
+
+@frappe.whitelist()
+def make_sales_invoice(
+ source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None
+):
+ from frappe.contacts.doctype.address.address import get_company_address
+
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ doc = frappe.get_doc("Delivery Note", source_name)
+
+ to_make_invoice_qty_map = {}
+ returned_qty_map = get_returned_qty_map(source_name)
+ invoiced_qty_map = get_invoiced_qty_map(source_name)
+
+ def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+ target.run_method("set_po_nos")
+
+ if len(target.get("items")) == 0:
+ frappe.throw(_("All these items have already been Invoiced/Returned"))
+
+ if args and args.get("merge_taxes"):
+ merge_taxes(source, target)
+
+ target.run_method("calculate_taxes_and_totals")
+
+ # set company address
+ if source.company_address:
+ target.update({"company_address": source.company_address})
+ else:
+ # set company address
+ target.update(get_company_address(target.company))
+
+ if target.company_address:
+ target.update(get_fetch_values("Sales Invoice", "company_address", target.company_address))
+
+ def update_item(source_doc, target_doc, source_parent):
+ target_doc.qty = to_make_invoice_qty_map[source_doc.name]
+ target_doc._old_name = source_doc.name
+
+ def get_pending_qty(item_row):
+ pending_qty = item_row.qty - invoiced_qty_map.get(item_row.name, 0)
+
+ returned_qty = 0
+ if returned_qty_map.get(item_row.name, 0) > 0:
+ returned_qty = flt(returned_qty_map.get(item_row.name, 0))
+ returned_qty_map[item_row.name] -= pending_qty
+
+ if returned_qty:
+ if returned_qty >= pending_qty:
+ pending_qty = 0
+ returned_qty -= pending_qty
+ else:
+ pending_qty -= returned_qty
+ returned_qty = 0
+
+ to_make_invoice_qty_map[item_row.name] = pending_qty
+
+ return pending_qty
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ doc = get_mapped_doc(
+ "Delivery Note",
+ source_name,
+ {
+ "Delivery Note": {
+ "doctype": "Sales Invoice",
+ "field_map": {"is_return": "is_return"},
+ "validation": {"docstatus": ["=", 1]},
+ },
+ "Delivery Note Item": {
+ "doctype": "Sales Invoice Item",
+ "field_map": {
+ "name": "dn_detail",
+ "parent": "delivery_note",
+ "so_detail": "so_detail",
+ "against_sales_order": "sales_order",
+ "cost_center": "cost_center",
+ },
+ "postprocess": update_item,
+ "filter": lambda d: get_pending_qty(d) <= 0
+ if not doc.get("is_return")
+ else get_pending_qty(d) > 0,
+ "condition": select_item,
+ },
+ "Sales Taxes and Charges": {
+ "doctype": "Sales Taxes and Charges",
+ "reset_value": not (args and args.get("merge_taxes")),
+ "ignore": args.get("merge_taxes") if args else 0,
+ },
+ "Sales Team": {
+ "doctype": "Sales Team",
+ "field_map": {"incentives": "incentives"},
+ "add_if_empty": True,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ from frappe.utils import cint
+
+ automatically_fetch_payment_terms = cint(
+ frappe.get_single_value("Accounts Settings", "automatically_fetch_payment_terms")
+ )
+
+ if not doc.is_return:
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ ps = PaymentScheduleService(doc)
+ so, doctype, fieldname = ps.get_order_details()
+ if (
+ ps.linked_order_has_payment_terms(so, fieldname, doctype)
+ and not automatically_fetch_payment_terms
+ ):
+ payment_terms_template = frappe.db.get_value(doctype, so, "payment_terms_template")
+ doc.payment_terms_template = payment_terms_template
+ doc.due_date = get_due_date(
+ doc.posting_date,
+ "Customer",
+ doc.customer,
+ doc.company,
+ template_name=doc.payment_terms_template,
+ )
+
+ elif automatically_fetch_payment_terms:
+ ps.set_payment_schedule()
+
+ return doc
+
+
+@frappe.whitelist()
+def make_delivery_trip(
+ source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None
+):
+ if not target_doc:
+ target_doc = frappe.new_doc("Delivery Trip")
+
+ def update_address(source_doc, target_doc, source_parent):
+ target_doc.address = source_doc.shipping_address_name or source_doc.customer_address
+ target_doc.customer_address = source_doc.shipping_address or source_doc.address_display
+
+ doclist = get_mapped_doc(
+ "Delivery Note",
+ source_name,
+ {
+ "Delivery Note": {
+ "doctype": "Delivery Stop",
+ "on_parent": target_doc,
+ "field_map": {
+ "name": "delivery_note",
+ "contact_person": "contact",
+ "contact_display": "customer_contact",
+ },
+ "postprocess": update_address,
+ },
+ },
+ ignore_child_tables=True,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_installation_note(
+ source_name: str, target_doc: str | Document | None = None, kwargs: dict | None = None
+):
+ def update_item(obj, target, source_parent):
+ target.qty = flt(obj.qty) - flt(obj.installed_qty)
+ target.serial_no = obj.serial_no
+
+ doclist = get_mapped_doc(
+ "Delivery Note",
+ source_name,
+ {
+ "Delivery Note": {"doctype": "Installation Note", "validation": {"docstatus": ["=", 1]}},
+ "Delivery Note Item": {
+ "doctype": "Installation Note Item",
+ "field_map": {
+ "name": "prevdoc_detail_docname",
+ "parent": "prevdoc_docname",
+ "parenttype": "prevdoc_doctype",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: doc.installed_qty < doc.qty,
+ },
+ },
+ target_doc,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_packing_slip(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+
+ def update_item(obj, target, source_parent):
+ target.qty = flt(obj.qty) - flt(obj.packed_qty)
+
+ doclist = get_mapped_doc(
+ "Delivery Note",
+ source_name,
+ {
+ "Delivery Note": {
+ "doctype": "Packing Slip",
+ "field_map": {"name": "delivery_note", "letter_head": "letter_head"},
+ "validation": {"docstatus": ["=", 0]},
+ },
+ "Delivery Note Item": {
+ "doctype": "Packing Slip Item",
+ "field_map": {
+ "item_code": "item_code",
+ "item_name": "item_name",
+ "batch_no": "batch_no",
+ "description": "description",
+ "qty": "qty",
+ "uom": "stock_uom",
+ "name": "dn_detail",
+ },
+ "postprocess": update_item,
+ "condition": lambda item: (
+ not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code, "disabled": 0})
+ and flt(item.packed_qty) < flt(item.qty)
+ ),
+ },
+ "Packed Item": {
+ "doctype": "Packing Slip Item",
+ "field_map": {
+ "item_code": "item_code",
+ "item_name": "item_name",
+ "batch_no": "batch_no",
+ "description": "description",
+ "qty": "qty",
+ "name": "pi_detail",
+ },
+ "postprocess": update_item,
+ "condition": lambda item: (flt(item.packed_qty) < flt(item.qty)),
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_shipment(source_name: str, target_doc: str | Document | None = None):
+ def postprocess(source, target):
+ user = frappe.db.get_value(
+ "User", frappe.session.user, ["email", "full_name", "phone", "mobile_no"], as_dict=1
+ )
+ target.pickup_contact_email = user.email
+ pickup_contact_display = f"{user.full_name}"
+ if user:
+ if user.email:
+ pickup_contact_display += "
" + user.email
+ if user.phone:
+ pickup_contact_display += "
" + user.phone
+ if user.mobile_no and not user.phone:
+ pickup_contact_display += "
" + user.mobile_no
+ target.pickup_contact = pickup_contact_display
+
+ # As we are using session user details in the pickup_contact then pickup_contact_person will be session user
+ target.pickup_contact_person = frappe.session.user
+
+ contact_person = source.contact_person or get_default_contact("Customer", source.customer)
+ if contact_person:
+ contact = frappe.db.get_value(
+ "Contact", contact_person, ["email_id", "phone", "mobile_no"], as_dict=1
+ )
+
+ delivery_contact_display = source.contact_display or contact_person or ""
+ if contact and not source.contact_display:
+ if contact.email_id:
+ delivery_contact_display += "
" + contact.email_id
+ if contact.phone:
+ delivery_contact_display += "
" + contact.phone
+ if contact.mobile_no and not contact.phone:
+ delivery_contact_display += "
" + contact.mobile_no
+
+ target.delivery_contact_name = contact_person
+ if contact and contact.email_id and not target.delivery_contact_email:
+ target.delivery_contact_email = contact.email_id
+ target.delivery_contact = delivery_contact_display
+
+ if source.shipping_address_name:
+ target.delivery_address_name = source.shipping_address_name
+ target.delivery_address = source.shipping_address
+ elif source.customer_address:
+ target.delivery_address_name = source.customer_address
+ target.delivery_address = source.address_display
+
+ doclist = get_mapped_doc(
+ "Delivery Note",
+ source_name,
+ {
+ "Delivery Note": {
+ "doctype": "Shipment",
+ "field_map": {
+ "grand_total": "value_of_goods",
+ "company": "pickup_company",
+ "company_address": "pickup_address_name",
+ "company_address_display": "pickup_address",
+ "customer": "delivery_customer",
+ "contact_person": "delivery_contact_name",
+ "contact_email": "delivery_contact_email",
+ },
+ "validation": {"docstatus": ["=", 1]},
+ },
+ "Delivery Note Item": {
+ "doctype": "Shipment Delivery Note",
+ "field_map": {
+ "name": "prevdoc_detail_docname",
+ "parent": "prevdoc_docname",
+ "parenttype": "prevdoc_doctype",
+ "base_amount": "grand_total",
+ },
+ },
+ },
+ target_doc,
+ postprocess,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_sales_return(source_name: str, target_doc: str | Document | None = None):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Delivery Note", source_name, target_doc)
+
+
+@frappe.whitelist()
+def make_inter_company_purchase_receipt(source_name: str, target_doc: str | Document | None = None):
+ return make_inter_company_transaction("Delivery Note", source_name, target_doc)
+
+
+def make_inter_company_transaction(doctype: str, source_name: str, target_doc=None):
+ from erpnext.accounts.doctype.sales_invoice.mapper import (
+ get_inter_company_details,
+ set_purchase_references,
+ update_address,
+ update_taxes,
+ validate_inter_company_transaction,
+ )
+
+ if doctype == "Delivery Note":
+ source_doc = frappe.get_doc(doctype, source_name)
+ target_doctype = "Purchase Receipt"
+ source_document_warehouse_field = "target_warehouse"
+ target_document_warehouse_field = "from_warehouse"
+ else:
+ source_doc = frappe.get_doc(doctype, source_name)
+ target_doctype = "Delivery Note"
+ source_document_warehouse_field = "from_warehouse"
+ target_document_warehouse_field = "target_warehouse"
+
+ validate_inter_company_transaction(source_doc, doctype)
+ details = get_inter_company_details(source_doc, doctype)
+
+ def set_missing_values(source, target):
+ target.run_method("set_missing_values")
+ set_purchase_references(target)
+
+ if target.doctype == "Purchase Receipt":
+ master_doctype = "Purchase Taxes and Charges Template"
+ else:
+ master_doctype = "Sales Taxes and Charges Template"
+
+ if not target.get("taxes") and target.get("taxes_and_charges"):
+ for tax in get_taxes_and_charges(master_doctype, target.get("taxes_and_charges")):
+ target.append("taxes", tax)
+
+ if not target.get("items"):
+ frappe.throw(_("All items have already been received"))
+
+ def update_details(source_doc, target_doc, source_parent):
+ def _validate_address_link(address, link_doctype, link_name):
+ return frappe.db.get_value(
+ "Dynamic Link",
+ {
+ "parent": address,
+ "parenttype": "Address",
+ "link_doctype": link_doctype,
+ "link_name": link_name,
+ },
+ "parent",
+ )
+
+ target_doc.inter_company_invoice_reference = source_doc.name
+ if target_doc.doctype == "Purchase Receipt":
+ target_doc.company = details.get("company")
+ target_doc.supplier = details.get("party")
+ target_doc.buying_price_list = source_doc.selling_price_list
+ target_doc.is_internal_supplier = 1
+ target_doc.inter_company_reference = source_doc.name
+
+ # Invert the address on target doc creation
+ if source_doc.company_address and _validate_address_link(
+ source_doc.company_address, "Supplier", details.get("party")
+ ):
+ update_address(target_doc, "supplier_address", "address_display", source_doc.company_address)
+ if source_doc.dispatch_address_name and _validate_address_link(
+ source_doc.dispatch_address_name, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc,
+ "dispatch_address",
+ "dispatch_address_display",
+ source_doc.dispatch_address_name,
+ )
+ if source_doc.shipping_address_name and _validate_address_link(
+ source_doc.shipping_address_name, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc,
+ "shipping_address",
+ "shipping_address_display",
+ source_doc.shipping_address_name,
+ )
+ if source_doc.customer_address and _validate_address_link(
+ source_doc.customer_address, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc, "billing_address", "billing_address_display", source_doc.customer_address
+ )
+
+ update_taxes(
+ target_doc,
+ party=target_doc.supplier,
+ party_type="Supplier",
+ company=target_doc.company,
+ doctype=target_doc.doctype,
+ party_address=target_doc.supplier_address,
+ company_address=target_doc.shipping_address,
+ )
+ else:
+ target_doc.company = details.get("company")
+ target_doc.customer = details.get("party")
+ target_doc.company_address = source_doc.supplier_address
+ target_doc.selling_price_list = source_doc.buying_price_list
+ target_doc.is_internal_customer = 1
+ target_doc.inter_company_reference = source_doc.name
+
+ # Invert the address on target doc creation
+ if source_doc.supplier_address and _validate_address_link(
+ source_doc.supplier_address, "Company", details.get("company")
+ ):
+ update_address(
+ target_doc, "company_address", "company_address_display", source_doc.supplier_address
+ )
+ if source_doc.shipping_address and _validate_address_link(
+ source_doc.shipping_address, "Customer", details.get("party")
+ ):
+ update_address(
+ target_doc, "shipping_address_name", "shipping_address", source_doc.shipping_address
+ )
+ if source_doc.shipping_address and _validate_address_link(
+ source_doc.shipping_address, "Customer", details.get("party")
+ ):
+ update_address(target_doc, "customer_address", "address_display", source_doc.shipping_address)
+
+ update_taxes(
+ target_doc,
+ party=target_doc.customer,
+ party_type="Customer",
+ company=target_doc.company,
+ doctype=target_doc.doctype,
+ party_address=target_doc.customer_address,
+ company_address=target_doc.company_address,
+ shipping_address_name=target_doc.shipping_address_name,
+ )
+
+ def update_item(source, target, source_parent):
+ if source_parent.doctype == "Delivery Note" and source.received_qty:
+ target.qty = flt(source.qty) + flt(source.returned_qty) - flt(source.received_qty)
+
+ if source.get("use_serial_batch_fields"):
+ target.set("use_serial_batch_fields", 1)
+
+ if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
+ target.set("use_serial_batch_fields", 1)
+
+ doclist = get_mapped_doc(
+ doctype,
+ source_name,
+ {
+ doctype: {
+ "doctype": target_doctype,
+ "postprocess": update_details,
+ "field_no_map": [*CROSS_PARTY_FIELD_NO_MAP, "set_warehouse"],
+ },
+ doctype + " Item": {
+ "doctype": target_doctype + " Item",
+ "field_map": {
+ source_document_warehouse_field: target_document_warehouse_field,
+ "name": "delivery_note_item",
+ "purchase_order": "purchase_order",
+ "purchase_order_item": "purchase_order_item",
+ "material_request": "material_request",
+ "Material_request_item": "material_request_item",
+ },
+ "field_no_map": ["warehouse"],
+ "condition": lambda item: item.received_qty < item.qty + item.returned_qty,
+ "postprocess": update_item,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index 1b4d32f89d8..5b3a9e1861a 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -19,7 +19,7 @@ from erpnext.selling.doctype.sales_order.test_sales_order import (
create_dn_against_so,
make_sales_order,
)
-from erpnext.stock.doctype.delivery_note.delivery_note import (
+from erpnext.stock.doctype.delivery_note.mapper import (
make_delivery_trip,
make_sales_invoice,
)
@@ -218,7 +218,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(cstr(serial_no.get(field)), value)
def test_delivery_note_return_against_denormalized_serial_no(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
frappe.flags.ignore_serial_batch_bundle_validation = True
@@ -1012,7 +1012,7 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_dn_billing_status_case2(self):
# SO -> SI and SO -> DN1, DN2
- from erpnext.selling.doctype.sales_order.sales_order import (
+ from erpnext.selling.doctype.sales_order.mapper import (
make_delivery_note,
make_sales_invoice,
)
@@ -1054,7 +1054,7 @@ class TestDeliveryNote(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": True})
def test_sales_invoice_qty_after_return(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
item = make_item(
"Test Sales Invoice Qty After Return",
@@ -1085,8 +1085,8 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_dn_billing_status_case3(self):
# SO -> DN1 -> SI and SO -> SI and SO -> DN2
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
- from erpnext.selling.doctype.sales_order.sales_order import (
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import (
make_sales_invoice as make_sales_invoice_from_so,
)
@@ -1136,8 +1136,8 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_dn_billing_status_case4(self):
# SO -> SI -> DN
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
- from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
so = make_sales_order(po_no="12345")
@@ -1160,7 +1160,7 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_dn_billing_status_case5(self):
# SO -> SI(with update stock partial invoice)
# SO -> DN
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note, make_sales_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note, make_sales_invoice
so = make_sales_order(po_no="12345")
@@ -1260,8 +1260,8 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(expected_values[gle.account]["cost_center"], gle.cost_center)
def test_make_sales_invoice_from_dn_for_returned_qty(self):
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
so = make_sales_order(qty=2)
so.submit()
@@ -1280,7 +1280,7 @@ class TestDeliveryNote(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_make_sales_invoice_from_dn_with_returned_qty_duplicate_items(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
dn = create_delivery_note(qty=8, do_not_submit=True)
dn.append(
@@ -1387,8 +1387,8 @@ class TestDeliveryNote(ERPNextTestSuite):
# |
# |---> DN(Partial Sales Return) ---> SI(Credit Note)
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note
- from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+ from erpnext.accounts.doctype.sales_invoice.mapper import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
so = make_sales_order(qty=10)
si = make_sales_invoice(so.name)
@@ -1400,7 +1400,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(dn.items[0].returned_qty, 0)
self.assertEqual(dn.per_billed, 100)
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
dn1 = create_delivery_note(is_return=1, return_against=dn.name, qty=-3)
si1 = make_sales_invoice(dn1.name)
@@ -1569,7 +1569,7 @@ class TestDeliveryNote(ERPNextTestSuite):
def reserved_qty_check(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
from erpnext.stock.stock_balance import get_reserved_qty
dont_reserve_qty = frappe.db.get_single_value(
@@ -1776,7 +1776,7 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_internal_transfer_for_non_stock_item(self):
from erpnext.selling.doctype.customer.test_customer import create_internal_customer
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
item = make_item(properties={"is_stock_item": 0}).name
warehouse = "_Test Warehouse - _TC"
@@ -1965,7 +1965,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(sle_data.stock_value_difference, 200.0 * -1)
def test_sales_return_batch_no_for_batched_item_in_dn(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
item_code = make_item(
"Test Batched Item for Sales Return 11",
@@ -1994,7 +1994,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(batch_no, returned_batch_no)
def test_partial_sales_return_batch_no_for_batched_item_in_dn(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
item_code = make_item(
"Test Partial Batched Item for Sales Return 11",
@@ -2041,7 +2041,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(sabb_qty, 2)
def test_sales_return_serial_no_for_serial_item_in_dn(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
item_code = make_item(
"Test Serial Item for Sales Return 11",
@@ -2190,7 +2190,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(sn.warranty_period, 100)
def test_batch_return_dn(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
item_code = make_item(
"Test Batch Return DN Item 1",
@@ -2231,7 +2231,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(stock_value_difference, 100.0 * 5)
def test_delivery_note_return_valuation_without_use_serial_batch_field(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
batch_item = make_item(
"_Test Delivery Note Return Valuation Batch Item",
@@ -2351,7 +2351,7 @@ class TestDeliveryNote(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_delivery_note_return_valuation_with_use_serial_batch_field(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
batch_item = make_item(
"_Test Delivery Note Return Valuation WITH Batch Item",
@@ -2561,7 +2561,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertTrue(row.serial_no)
def test_delivery_note_return_for_batch_item_with_different_warehouse(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
batch_item = make_item(
@@ -2631,7 +2631,7 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(d.incoming_rate, batch_no_valuation[d.batch_no])
def test_delivery_note_per_billed_after_return(self):
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
so = make_sales_order(qty=2)
dn = make_delivery_note(so.name)
@@ -2699,7 +2699,7 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_sales_return_for_product_bundle(self):
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
from erpnext.stock.doctype.item.test_item import make_item
rm_items = []
@@ -3150,7 +3150,7 @@ class TestDeliveryNote(ERPNextTestSuite):
def test_sdbnb_skip_for_dn_against_sales_invoice(self):
"""Test that DN items with against_sales_invoice reference skips SDBNB account assignment."""
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
+ from erpnext.accounts.doctype.sales_invoice.mapper import (
make_delivery_note as make_dn_from_si,
)
diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.js b/erpnext/stock/doctype/delivery_trip/delivery_trip.js
index 61c6743054f..9eb5b1f83c3 100755
--- a/erpnext/stock/doctype/delivery_trip/delivery_trip.js
+++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.js
@@ -54,7 +54,7 @@ frappe.ui.form.on("Delivery Trip", {
__("Delivery Note"),
() => {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_delivery_trip",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_delivery_trip",
source_doctype: "Delivery Note",
target: frm,
date_field: "posting_date",
diff --git a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py
index 3a054abf722..655d781126c 100644
--- a/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py
+++ b/erpnext/stock/doctype/inventory_dimension/test_inventory_dimension.py
@@ -364,7 +364,7 @@ class TestInventoryDimension(ERPNextTestSuite):
def test_inter_transfer_return_against_inventory_dimension(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
data = prepare_data_for_internal_transfer()
diff --git a/erpnext/stock/doctype/item_alternative/test_item_alternative.py b/erpnext/stock/doctype/item_alternative/test_item_alternative.py
index 0a2119af3ec..2be54c82036 100644
--- a/erpnext/stock/doctype/item_alternative/test_item_alternative.py
+++ b/erpnext/stock/doctype/item_alternative/test_item_alternative.py
@@ -11,8 +11,8 @@ from erpnext.controllers.tests.test_subcontracting_controller import (
set_backflush_based_on,
)
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
+from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
-from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
EmptyStockReconciliationItemsError,
diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
index 7fa2e0a2548..6c44cec46b4 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
@@ -1130,10 +1130,10 @@ class TestLandedCostVoucher(ERPNextTestSuite):
make_stock_transfer_entry,
)
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
- from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as make_stock_entry_for_wo,
)
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
diff --git a/erpnext/stock/doctype/material_request/mapper.py b/erpnext/stock/doctype/material_request/mapper.py
new file mode 100644
index 00000000000..1ecc842a7b4
--- /dev/null
+++ b/erpnext/stock/doctype/material_request/mapper.py
@@ -0,0 +1,370 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import cint, flt, getdate, nowdate
+
+from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
+ get_subcontracting_boms_for_finished_goods,
+)
+
+
+def set_missing_values(source, target_doc):
+ if target_doc.doctype == "Purchase Order" and getdate(target_doc.schedule_date) < getdate(nowdate()):
+ target_doc.schedule_date = None
+ target_doc.run_method("set_missing_values")
+ target_doc.run_method("calculate_taxes_and_totals")
+
+
+def update_item(obj, target, source_parent):
+ target.conversion_factor = obj.conversion_factor
+
+ qty = obj.ordered_qty or obj.received_qty
+ target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor
+ target.stock_qty = target.qty * target.conversion_factor
+ if getdate(target.schedule_date) < getdate(nowdate()):
+ target.schedule_date = None
+
+ if target.fg_item:
+ target.fg_item_qty = obj.stock_qty
+ if sc_bom := get_subcontracting_boms_for_finished_goods(target.fg_item):
+ target.item_code = sc_bom.service_item
+ target.uom = sc_bom.service_item_uom
+ target.conversion_factor = (
+ frappe.db.get_value(
+ "UOM Conversion Detail",
+ {"parent": sc_bom.service_item, "uom": sc_bom.service_item_uom},
+ "conversion_factor",
+ )
+ or 1
+ )
+ target.qty = target.fg_item_qty * sc_bom.conversion_factor
+ target.stock_qty = target.qty * target.conversion_factor
+
+
+@frappe.whitelist()
+def make_purchase_order(
+ source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None
+):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ is_subcontracted = (
+ frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting"
+ )
+
+ def postprocess(source, target_doc):
+ target_doc.is_subcontracted = is_subcontracted
+ set_missing_values(source, target_doc)
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+
+ qty = d.ordered_qty or d.received_qty
+
+ return qty < d.stock_qty and child_filter
+
+ def generate_field_map():
+ field_map = [
+ ["name", "material_request_item"],
+ ["parent", "material_request"],
+ ["sales_order", "sales_order"],
+ ["sales_order_item", "sales_order_item"],
+ ["wip_composite_asset", "wip_composite_asset"],
+ ]
+
+ if is_subcontracted:
+ field_map.extend([["item_code", "fg_item"], ["qty", "fg_item_qty"]])
+ else:
+ field_map.extend([["uom", "stock_uom"], ["uom", "uom"]])
+
+ return field_map
+
+ doclist = get_mapped_doc(
+ "Material Request",
+ source_name,
+ {
+ "Material Request": {
+ "doctype": "Purchase Order",
+ "validation": {
+ "docstatus": ["=", 1],
+ "material_request_type": ["in", ["Purchase", "Subcontracting"]],
+ },
+ },
+ "Material Request Item": {
+ "doctype": "Purchase Order Item",
+ "field_map": generate_field_map(),
+ "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [],
+ "postprocess": update_item,
+ "condition": select_item,
+ },
+ },
+ target_doc,
+ postprocess,
+ )
+
+ doclist.set_onload("load_after_mapping", False)
+ return doclist
+
+
+@frappe.whitelist()
+def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None):
+ doclist = get_mapped_doc(
+ "Material Request",
+ source_name,
+ {
+ "Material Request": {
+ "doctype": "Request for Quotation",
+ "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]},
+ },
+ "Material Request Item": {
+ "doctype": "Request for Quotation Item",
+ "field_map": [
+ ["name", "material_request_item"],
+ ["parent", "material_request"],
+ ["project", "project_name"],
+ ],
+ },
+ },
+ target_doc,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def get_items_based_on_default_supplier(supplier: str):
+ supplier_items = [
+ d.parent
+ for d in frappe.db.get_all(
+ "Item Default", {"default_supplier": supplier, "parenttype": "Item"}, "parent"
+ )
+ ]
+
+ return supplier_items
+
+
+@frappe.whitelist()
+def make_purchase_order_based_on_supplier(
+ source_name: str, target_doc: str | Document | None = None, args: dict | None = None
+):
+ mr = source_name
+
+ supplier_items = get_items_based_on_default_supplier(args.get("supplier"))
+
+ def postprocess(source, target_doc):
+ target_doc.supplier = args.get("supplier")
+ if getdate(target_doc.schedule_date) < getdate(nowdate()):
+ target_doc.schedule_date = None
+ target_doc.set(
+ "items",
+ [d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0],
+ )
+
+ set_missing_values(source, target_doc)
+
+ target_doc = get_mapped_doc(
+ "Material Request",
+ mr,
+ {
+ "Material Request": {
+ "doctype": "Purchase Order",
+ },
+ "Material Request Item": {
+ "doctype": "Purchase Order Item",
+ "field_map": [
+ ["name", "material_request_item"],
+ ["parent", "material_request"],
+ ["uom", "stock_uom"],
+ ["uom", "uom"],
+ ],
+ "postprocess": update_item,
+ "condition": lambda doc: doc.ordered_qty < doc.qty,
+ },
+ },
+ target_doc,
+ postprocess,
+ )
+
+ return target_doc
+
+
+@frappe.whitelist()
+def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None):
+ def postprocess(source, target_doc):
+ set_missing_values(source, target_doc)
+
+ doclist = get_mapped_doc(
+ "Material Request",
+ source_name,
+ {
+ "Material Request": {
+ "doctype": "Supplier Quotation",
+ "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]},
+ },
+ "Material Request Item": {
+ "doctype": "Supplier Quotation Item",
+ "field_map": {
+ "name": "material_request_item",
+ "parent": "material_request",
+ "sales_order": "sales_order",
+ },
+ },
+ },
+ target_doc,
+ postprocess,
+ )
+
+ doclist.set_onload("load_after_mapping", False)
+ return doclist
+
+
+@frappe.whitelist()
+def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
+ def update_item(obj, target, source_parent):
+ qty = (
+ flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor
+ if flt(obj.stock_qty) > flt(obj.ordered_qty)
+ else 0
+ )
+ target.qty = qty
+ target.transfer_qty = qty * obj.conversion_factor
+ target.conversion_factor = obj.conversion_factor
+
+ if (
+ source_parent.material_request_type == "Material Transfer"
+ or source_parent.material_request_type == "Customer Provided"
+ ):
+ target.t_warehouse = obj.warehouse
+ else:
+ target.s_warehouse = obj.warehouse
+
+ if source_parent.material_request_type == "Customer Provided":
+ target.allow_zero_valuation_rate = 1
+
+ if source_parent.material_request_type == "Material Transfer":
+ target.s_warehouse = obj.from_warehouse
+
+ def set_missing_values(source, target):
+ target.purpose = source.material_request_type
+ target.from_warehouse = source.set_from_warehouse
+ target.to_warehouse = source.set_warehouse
+ if source.material_request_type == "Material Issue":
+ target.from_warehouse = source.set_warehouse
+ target.to_warehouse = None
+
+ if source.job_card:
+ target.purpose = "Material Transfer for Manufacture"
+
+ if source.material_request_type == "Customer Provided":
+ target.purpose = "Material Receipt"
+
+ target.set_transfer_qty()
+ target.set_actual_qty()
+ target.calculate_rate_and_amount(raise_error_if_no_rate=False)
+ target.stock_entry_type = target.purpose
+
+ if source.job_card:
+ job_card_details = frappe.get_all(
+ "Job Card", filters={"name": source.job_card}, fields=["bom_no", "for_quantity"]
+ )
+
+ if job_card_details and job_card_details[0]:
+ target.bom_no = job_card_details[0].bom_no
+ target.fg_completed_qty = job_card_details[0].for_quantity
+ target.from_bom = 1
+
+ doclist = get_mapped_doc(
+ "Material Request",
+ source_name,
+ {
+ "Material Request": {
+ "doctype": "Stock Entry",
+ "validation": {
+ "docstatus": ["=", 1],
+ "material_request_type": [
+ "in",
+ ["Material Transfer", "Material Issue", "Customer Provided"],
+ ],
+ },
+ },
+ "Material Request Item": {
+ "doctype": "Stock Entry Detail",
+ "field_map": {
+ "name": "material_request_item",
+ "parent": "material_request",
+ "uom": "stock_uom",
+ "job_card_item": "job_card_item",
+ },
+ "field_no_map": ["expense_account"],
+ "postprocess": update_item,
+ "condition": lambda doc: (
+ flt(doc.ordered_qty, doc.precision("ordered_qty"))
+ < flt(doc.stock_qty, doc.precision("ordered_qty"))
+ ),
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def create_pick_list(source_name: str, target_doc: str | Document | None = None):
+ def update_item(obj, target, source_parent):
+ qty = flt((obj.stock_qty - obj.picked_qty) / target.conversion_factor, obj.precision("qty"))
+ target.qty = qty
+ target.stock_qty = qty * obj.conversion_factor
+ target.conversion_factor = obj.conversion_factor
+
+ doc = get_mapped_doc(
+ "Material Request",
+ source_name,
+ {
+ "Material Request": {
+ "doctype": "Pick List",
+ "field_map": {"material_request_type": "purpose"},
+ "validation": {"docstatus": ["=", 1]},
+ },
+ "Material Request Item": {
+ "doctype": "Pick List Item",
+ "field_map": {
+ "name": "material_request_item",
+ "stock_qty": "stock_qty",
+ "from_warehouse": "warehouse",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: (
+ flt(doc.picked_qty, doc.precision("picked_qty"))
+ < flt(doc.stock_qty, doc.precision("stock_qty"))
+ ),
+ },
+ },
+ target_doc,
+ )
+
+ doc.set_item_locations()
+
+ return doc
+
+
+@frappe.whitelist()
+def make_in_transit_stock_entry(source_name: str, in_transit_warehouse: str):
+ ste_doc = make_stock_entry(source_name)
+ ste_doc.add_to_transit = 1
+ ste_doc.to_warehouse = in_transit_warehouse
+
+ for row in ste_doc.items:
+ row.t_warehouse = in_transit_warehouse
+
+ return ste_doc
diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js
index e0bbff4cbda..0e48296323b 100644
--- a/erpnext/stock/doctype/material_request/material_request.js
+++ b/erpnext/stock/doctype/material_request/material_request.js
@@ -258,7 +258,7 @@ frappe.ui.form.on("Material Request", {
get_items_from_sales_order: function (frm) {
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_material_request",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_material_request",
source_doctype: "Sales Order",
target: frm,
setters: {
@@ -411,7 +411,7 @@ frappe.ui.form.on("Material Request", {
make_purchase_order: function (frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_purchase_order",
+ method: "erpnext.stock.doctype.material_request.mapper.make_purchase_order",
frm: frm,
run_link_triggers: true,
});
@@ -419,7 +419,7 @@ frappe.ui.form.on("Material Request", {
make_request_for_quotation: function (frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_request_for_quotation",
+ method: "erpnext.stock.doctype.material_request.mapper.make_request_for_quotation",
frm: frm,
run_link_triggers: true,
});
@@ -427,14 +427,14 @@ frappe.ui.form.on("Material Request", {
make_supplier_quotation: function (frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_supplier_quotation",
+ method: "erpnext.stock.doctype.material_request.mapper.make_supplier_quotation",
frm: frm,
});
},
make_stock_entry: function (frm) {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_stock_entry",
+ method: "erpnext.stock.doctype.material_request.mapper.make_stock_entry",
frm: frm,
});
},
@@ -461,7 +461,7 @@ frappe.ui.form.on("Material Request", {
],
(values) => {
frappe.call({
- method: "erpnext.stock.doctype.material_request.material_request.make_in_transit_stock_entry",
+ method: "erpnext.stock.doctype.material_request.mapper.make_in_transit_stock_entry",
args: {
source_name: frm.doc.name,
in_transit_warehouse: values.in_transit_warehouse,
@@ -481,7 +481,7 @@ frappe.ui.form.on("Material Request", {
create_pick_list: (frm) => {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.material_request.material_request.create_pick_list",
+ method: "erpnext.stock.doctype.material_request.mapper.create_pick_list",
frm: frm,
});
},
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index 40cd7df0a44..c020ab35f90 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -5,14 +5,12 @@
# For license information, please see license.txt
-import json
from typing import Any
import frappe
import frappe.defaults
from frappe import _, msgprint
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import Order
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, new_line_sep, nowdate
@@ -21,8 +19,9 @@ from erpnext.buying.utils import check_on_hold_or_closed_status, validate_for_it
from erpnext.controllers.buying_controller import BuyingController
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.stock.stock_balance import get_indented_qty, update_bin_qty
-from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import (
- get_subcontracting_boms_for_finished_goods,
+
+from .mapper import (
+ get_items_based_on_default_supplier,
)
form_grid_templates = {"items": "templates/form_grid/material_request_grid.html"}
@@ -441,39 +440,6 @@ def update_completed_and_requested_qty(stock_entry, method):
mr_obj.update_requested_qty(mr_item_rows)
-def set_missing_values(source, target_doc):
- if target_doc.doctype == "Purchase Order" and getdate(target_doc.schedule_date) < getdate(nowdate()):
- target_doc.schedule_date = None
- target_doc.run_method("set_missing_values")
- target_doc.run_method("calculate_taxes_and_totals")
-
-
-def update_item(obj, target, source_parent):
- target.conversion_factor = obj.conversion_factor
-
- qty = obj.ordered_qty or obj.received_qty
- target.qty = flt(flt(obj.stock_qty) - flt(qty)) / target.conversion_factor
- target.stock_qty = target.qty * target.conversion_factor
- if getdate(target.schedule_date) < getdate(nowdate()):
- target.schedule_date = None
-
- if target.fg_item:
- target.fg_item_qty = obj.stock_qty
- if sc_bom := get_subcontracting_boms_for_finished_goods(target.fg_item):
- target.item_code = sc_bom.service_item
- target.uom = sc_bom.service_item_uom
- target.conversion_factor = (
- frappe.db.get_value(
- "UOM Conversion Detail",
- {"parent": sc_bom.service_item, "uom": sc_bom.service_item_uom},
- "conversion_factor",
- )
- or 1
- )
- target.qty = target.fg_item_qty * sc_bom.conversion_factor
- target.stock_qty = target.qty * target.conversion_factor
-
-
def get_list_context(context=None):
from erpnext.controllers.website_list_for_contact import get_list_context
@@ -498,156 +464,6 @@ def update_status(name: str, status: str):
material_request.update_status(status)
-@frappe.whitelist()
-def make_purchase_order(
- source_name: str, target_doc: str | Document | None = None, args: dict | str | None = None
-):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- is_subcontracted = (
- frappe.db.get_value("Material Request", source_name, "material_request_type") == "Subcontracting"
- )
-
- def postprocess(source, target_doc):
- target_doc.is_subcontracted = is_subcontracted
- set_missing_values(source, target_doc)
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
-
- qty = d.ordered_qty or d.received_qty
-
- return qty < d.stock_qty and child_filter
-
- def generate_field_map():
- field_map = [
- ["name", "material_request_item"],
- ["parent", "material_request"],
- ["sales_order", "sales_order"],
- ["sales_order_item", "sales_order_item"],
- ["wip_composite_asset", "wip_composite_asset"],
- ]
-
- if is_subcontracted:
- field_map.extend([["item_code", "fg_item"], ["qty", "fg_item_qty"]])
- else:
- field_map.extend([["uom", "stock_uom"], ["uom", "uom"]])
-
- return field_map
-
- doclist = get_mapped_doc(
- "Material Request",
- source_name,
- {
- "Material Request": {
- "doctype": "Purchase Order",
- "validation": {
- "docstatus": ["=", 1],
- "material_request_type": ["in", ["Purchase", "Subcontracting"]],
- },
- },
- "Material Request Item": {
- "doctype": "Purchase Order Item",
- "field_map": generate_field_map(),
- "field_no_map": ["item_code", "item_name", "qty"] if is_subcontracted else [],
- "postprocess": update_item,
- "condition": select_item,
- },
- },
- target_doc,
- postprocess,
- )
-
- doclist.set_onload("load_after_mapping", False)
- return doclist
-
-
-@frappe.whitelist()
-def make_request_for_quotation(source_name: str, target_doc: str | Document | None = None):
- doclist = get_mapped_doc(
- "Material Request",
- source_name,
- {
- "Material Request": {
- "doctype": "Request for Quotation",
- "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]},
- },
- "Material Request Item": {
- "doctype": "Request for Quotation Item",
- "field_map": [
- ["name", "material_request_item"],
- ["parent", "material_request"],
- ["project", "project_name"],
- ],
- },
- },
- target_doc,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_purchase_order_based_on_supplier(
- source_name: str, target_doc: str | Document | None = None, args: dict | None = None
-):
- mr = source_name
-
- supplier_items = get_items_based_on_default_supplier(args.get("supplier"))
-
- def postprocess(source, target_doc):
- target_doc.supplier = args.get("supplier")
- if getdate(target_doc.schedule_date) < getdate(nowdate()):
- target_doc.schedule_date = None
- target_doc.set(
- "items",
- [d for d in target_doc.get("items") if d.get("item_code") in supplier_items and d.get("qty") > 0],
- )
-
- set_missing_values(source, target_doc)
-
- target_doc = get_mapped_doc(
- "Material Request",
- mr,
- {
- "Material Request": {
- "doctype": "Purchase Order",
- },
- "Material Request Item": {
- "doctype": "Purchase Order Item",
- "field_map": [
- ["name", "material_request_item"],
- ["parent", "material_request"],
- ["uom", "stock_uom"],
- ["uom", "uom"],
- ],
- "postprocess": update_item,
- "condition": lambda doc: doc.ordered_qty < doc.qty,
- },
- },
- target_doc,
- postprocess,
- )
-
- return target_doc
-
-
-@frappe.whitelist()
-def get_items_based_on_default_supplier(supplier: str):
- supplier_items = [
- d.parent
- for d in frappe.db.get_all(
- "Item Default", {"default_supplier": supplier, "parenttype": "Item"}, "parent"
- )
- ]
-
- return supplier_items
-
-
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_material_requests_based_on_supplier(
@@ -694,128 +510,6 @@ def get_material_requests_based_on_supplier(
return material_requests
-@frappe.whitelist()
-def make_supplier_quotation(source_name: str, target_doc: str | Document | None = None):
- def postprocess(source, target_doc):
- set_missing_values(source, target_doc)
-
- doclist = get_mapped_doc(
- "Material Request",
- source_name,
- {
- "Material Request": {
- "doctype": "Supplier Quotation",
- "validation": {"docstatus": ["=", 1], "material_request_type": ["=", "Purchase"]},
- },
- "Material Request Item": {
- "doctype": "Supplier Quotation Item",
- "field_map": {
- "name": "material_request_item",
- "parent": "material_request",
- "sales_order": "sales_order",
- },
- },
- },
- target_doc,
- postprocess,
- )
-
- doclist.set_onload("load_after_mapping", False)
- return doclist
-
-
-@frappe.whitelist()
-def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
- def update_item(obj, target, source_parent):
- qty = (
- flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor
- if flt(obj.stock_qty) > flt(obj.ordered_qty)
- else 0
- )
- target.qty = qty
- target.transfer_qty = qty * obj.conversion_factor
- target.conversion_factor = obj.conversion_factor
-
- if (
- source_parent.material_request_type == "Material Transfer"
- or source_parent.material_request_type == "Customer Provided"
- ):
- target.t_warehouse = obj.warehouse
- else:
- target.s_warehouse = obj.warehouse
-
- if source_parent.material_request_type == "Customer Provided":
- target.allow_zero_valuation_rate = 1
-
- if source_parent.material_request_type == "Material Transfer":
- target.s_warehouse = obj.from_warehouse
-
- def set_missing_values(source, target):
- target.purpose = source.material_request_type
- target.from_warehouse = source.set_from_warehouse
- target.to_warehouse = source.set_warehouse
- if source.material_request_type == "Material Issue":
- target.from_warehouse = source.set_warehouse
- target.to_warehouse = None
-
- if source.job_card:
- target.purpose = "Material Transfer for Manufacture"
-
- if source.material_request_type == "Customer Provided":
- target.purpose = "Material Receipt"
-
- target.set_transfer_qty()
- target.set_actual_qty()
- target.calculate_rate_and_amount(raise_error_if_no_rate=False)
- target.stock_entry_type = target.purpose
-
- if source.job_card:
- job_card_details = frappe.get_all(
- "Job Card", filters={"name": source.job_card}, fields=["bom_no", "for_quantity"]
- )
-
- if job_card_details and job_card_details[0]:
- target.bom_no = job_card_details[0].bom_no
- target.fg_completed_qty = job_card_details[0].for_quantity
- target.from_bom = 1
-
- doclist = get_mapped_doc(
- "Material Request",
- source_name,
- {
- "Material Request": {
- "doctype": "Stock Entry",
- "validation": {
- "docstatus": ["=", 1],
- "material_request_type": [
- "in",
- ["Material Transfer", "Material Issue", "Customer Provided"],
- ],
- },
- },
- "Material Request Item": {
- "doctype": "Stock Entry Detail",
- "field_map": {
- "name": "material_request_item",
- "parent": "material_request",
- "uom": "stock_uom",
- "job_card_item": "job_card_item",
- },
- "field_no_map": ["expense_account"],
- "postprocess": update_item,
- "condition": lambda doc: (
- flt(doc.ordered_qty, doc.precision("ordered_qty"))
- < flt(doc.stock_qty, doc.precision("ordered_qty"))
- ),
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
@frappe.whitelist()
def raise_work_orders(material_request: str, company: str):
mr = frappe.get_doc("Material Request", material_request)
@@ -885,54 +579,3 @@ def raise_work_orders(material_request: str, company: str):
)
return work_orders
-
-
-@frappe.whitelist()
-def create_pick_list(source_name: str, target_doc: str | Document | None = None):
- def update_item(obj, target, source_parent):
- qty = flt((obj.stock_qty - obj.picked_qty) / target.conversion_factor, obj.precision("qty"))
- target.qty = qty
- target.stock_qty = qty * obj.conversion_factor
- target.conversion_factor = obj.conversion_factor
-
- doc = get_mapped_doc(
- "Material Request",
- source_name,
- {
- "Material Request": {
- "doctype": "Pick List",
- "field_map": {"material_request_type": "purpose"},
- "validation": {"docstatus": ["=", 1]},
- },
- "Material Request Item": {
- "doctype": "Pick List Item",
- "field_map": {
- "name": "material_request_item",
- "stock_qty": "stock_qty",
- "from_warehouse": "warehouse",
- },
- "postprocess": update_item,
- "condition": lambda doc: (
- flt(doc.picked_qty, doc.precision("picked_qty"))
- < flt(doc.stock_qty, doc.precision("stock_qty"))
- ),
- },
- },
- target_doc,
- )
-
- doc.set_item_locations()
-
- return doc
-
-
-@frappe.whitelist()
-def make_in_transit_stock_entry(source_name: str, in_transit_warehouse: str):
- ste_doc = make_stock_entry(source_name)
- ste_doc.add_to_transit = 1
- ste_doc.to_warehouse = in_transit_warehouse
-
- for row in ste_doc.items:
- row.t_warehouse = in_transit_warehouse
-
- return ste_doc
diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py
index 66a627d05da..180ab3ae3bb 100644
--- a/erpnext/stock/doctype/material_request/test_material_request.py
+++ b/erpnext/stock/doctype/material_request/test_material_request.py
@@ -10,12 +10,14 @@ from frappe.utils import flt, today
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.stock.doctype.item.test_item import create_item
-from erpnext.stock.doctype.material_request.material_request import (
+from erpnext.stock.doctype.material_request.mapper import (
create_pick_list,
make_in_transit_stock_entry,
make_purchase_order,
make_stock_entry,
make_supplier_quotation,
+)
+from erpnext.stock.doctype.material_request.material_request import (
raise_work_orders,
)
from erpnext.stock.doctype.stock_entry.stock_entry import make_stock_in_entry
@@ -980,7 +982,7 @@ class TestMaterialRequest(ERPNextTestSuite):
from frappe.utils import add_to_date, today
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
- from erpnext.selling.doctype.sales_order.sales_order import make_material_request
+ from erpnext.selling.doctype.sales_order.mapper import make_material_request
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
sub_item_a = "_Test Bundle ItemA"
@@ -1019,7 +1021,7 @@ class TestMaterialRequest(ERPNextTestSuite):
"""Test for pick list mapped doc qty from partially received Material Request Transfer"""
import json
- from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
+ from erpnext.stock.doctype.pick_list.mapper import create_stock_entry
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
new_item = create_item("_Test Pick List Item", is_stock_item=1)
diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py
index e7b22d04033..8189343a820 100644
--- a/erpnext/stock/doctype/packed_item/test_packed_item.py
+++ b/erpnext/stock/doctype/packed_item/test_packed_item.py
@@ -5,7 +5,7 @@
import frappe
from frappe.utils import add_to_date, nowdate
-from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
@@ -190,7 +190,7 @@ class TestPackedItem(ERPNextTestSuite):
self.assertEqual(sent_item.qty, -1 * returned_item.qty)
def test_returning_full_bundles(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
item_list = [
{
@@ -219,7 +219,7 @@ class TestPackedItem(ERPNextTestSuite):
self.assertReturns(dn.packed_items, dn_ret.packed_items)
def test_returning_partial_bundles(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
item_list = [
{
@@ -256,7 +256,7 @@ class TestPackedItem(ERPNextTestSuite):
self.assertReturns(expected_returns, dn_ret.packed_items)
def test_returning_partial_bundle_qty(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+ from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
so = make_sales_order(item_code=self.bundle, warehouse=self.warehouse, qty=2)
diff --git a/erpnext/stock/doctype/packing_slip/packing_slip.js b/erpnext/stock/doctype/packing_slip/packing_slip.js
index 682631f1b74..45f84a39598 100644
--- a/erpnext/stock/doctype/packing_slip/packing_slip.js
+++ b/erpnext/stock/doctype/packing_slip/packing_slip.js
@@ -35,7 +35,7 @@ frappe.ui.form.on("Packing Slip", {
if (frm.doc.delivery_note) {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.delivery_note.delivery_note.make_packing_slip",
+ method: "erpnext.stock.doctype.delivery_note.mapper.make_packing_slip",
source_name: frm.doc.delivery_note,
target_doc: frm,
freeze: true,
diff --git a/erpnext/stock/doctype/packing_slip/test_packing_slip.py b/erpnext/stock/doctype/packing_slip/test_packing_slip.py
index 19e6c976edc..55a51f847e3 100644
--- a/erpnext/stock/doctype/packing_slip/test_packing_slip.py
+++ b/erpnext/stock/doctype/packing_slip/test_packing_slip.py
@@ -5,7 +5,7 @@
import frappe
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
-from erpnext.stock.doctype.delivery_note.delivery_note import make_packing_slip
+from erpnext.stock.doctype.delivery_note.mapper import make_packing_slip
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/stock/doctype/pick_list/mapper.py b/erpnext/stock/doctype/pick_list/mapper.py
new file mode 100644
index 00000000000..cb7a1d7af22
--- /dev/null
+++ b/erpnext/stock/doctype/pick_list/mapper.py
@@ -0,0 +1,365 @@
+# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import json
+from itertools import groupby
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import map_child_doc
+from frappe.utils import flt, get_link_to_form
+
+from erpnext.selling.doctype.sales_order.mapper import (
+ make_delivery_note as create_delivery_note_from_sales_order,
+)
+
+
+def validate_item_locations(pick_list):
+ if not pick_list.locations:
+ frappe.throw(_("Add items in the Item Locations table"))
+
+
+@frappe.whitelist()
+def create_delivery_note(source_name: str, target_doc: str | Document | None = None):
+ pick_list = frappe.get_doc("Pick List", source_name)
+ validate_item_locations(pick_list)
+ sales_dict = dict()
+ sales_orders = []
+ delivery_notes = []
+ for location in pick_list.locations:
+ if location.sales_order:
+ sales_orders.append(
+ frappe.db.get_value(
+ "Sales Order",
+ location.sales_order,
+ [
+ "customer",
+ "name as sales_order",
+ "company_address",
+ "dispatch_address_name",
+ "shipping_address_name",
+ "customer_address",
+ ],
+ as_dict=True,
+ )
+ )
+
+ group_key = lambda so: ( # noqa
+ so["customer"],
+ so["company_address"] or "",
+ so["dispatch_address_name"] or "",
+ so["shipping_address_name"] or "",
+ so["customer_address"] or "",
+ )
+ for key, rows in groupby(sorted(sales_orders, key=group_key), key=group_key):
+ sales_dict[key] = {row.sales_order for row in rows}
+
+ if sales_dict:
+ delivery_notes.extend(create_dn_with_so(sales_dict, pick_list))
+
+ if not all(item.sales_order for item in pick_list.locations):
+ delivery_notes.append(create_dn_wo_so(pick_list))
+
+ if len(delivery_notes) == 1:
+ return delivery_notes[0]
+ else:
+ from frappe.utils import comma_and
+
+ doc_list = [get_link_to_form("Delivery Note", p.name) for p in delivery_notes]
+ frappe.msgprint(_("{0} created").format(comma_and(doc_list)))
+
+
+def create_dn_wo_so(pick_list, delivery_note=None):
+ if not delivery_note:
+ delivery_note = frappe.new_doc("Delivery Note")
+
+ delivery_note.company = pick_list.company
+
+ item_table_mapper_without_so = {
+ "doctype": "Delivery Note Item",
+ "field_map": {
+ "rate": "rate",
+ "name": "name",
+ "parent": "",
+ },
+ }
+ map_pl_locations(pick_list, item_table_mapper_without_so, delivery_note)
+ delivery_note.flags.ignore_mandatory = True
+ delivery_note.save()
+
+ return delivery_note
+
+
+@frappe.whitelist()
+def create_dn_for_pick_lists(
+ source_name: str, target_doc: str | Document | None = None, kwargs: dict | str | None = None
+):
+ """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer"""
+ if kwargs is None:
+ kwargs = {}
+ if isinstance(kwargs, str):
+ kwargs = json.loads(kwargs)
+
+ pick_list = frappe.get_doc("Pick List", source_name)
+ validate_item_locations(pick_list)
+
+ sales_order_arg = kwargs.get("sales_order")
+ customer_arg = kwargs.get("customer")
+
+ if sales_order_arg:
+ sales_orders = {sales_order_arg}
+ else:
+ sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order}
+
+ if customer_arg:
+ sales_orders = frappe.get_all(
+ "Sales Order",
+ filters={"customer": customer_arg, "name": ["in", list(sales_orders)]},
+ pluck="name",
+ )
+
+ delivery_note = create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc, kwargs=kwargs)
+
+ if not sales_order_arg and not all(item.sales_order for item in pick_list.locations):
+ if isinstance(delivery_note, str):
+ delivery_note = frappe.get_doc(frappe.parse_json(delivery_note))
+
+ delivery_note = create_dn_wo_so(pick_list, delivery_note)
+
+ return delivery_note
+
+
+def create_dn_with_so(sales_dict, pick_list):
+ """Create Delivery Note for each customer (based on SO) in a Pick List."""
+ delivery_notes = []
+
+ for key in sales_dict:
+ delivery_note = create_dn_from_so(pick_list, sales_dict[key], None)
+ if delivery_note:
+ delivery_note.flags.ignore_mandatory = True
+ # updates packed_items on save
+ # save as multiple customers are possible
+ delivery_note.save()
+ delivery_notes.append(delivery_note)
+
+ return delivery_notes
+
+
+def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=None):
+ if not sales_order_list:
+ return delivery_note
+
+ def select_item(d):
+ filtered_items = kwargs.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ item_table_mapper = {
+ "doctype": "Delivery Note Item",
+ "field_map": {
+ "rate": "rate",
+ "name": "so_detail",
+ "parent": "against_sales_order",
+ },
+ "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
+ and doc.delivered_by_supplier != 1
+ and select_item(doc),
+ }
+
+ kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule}
+
+ delivery_note = create_delivery_note_from_sales_order(
+ next(iter(sales_order_list)), delivery_note, kwargs=kwargs
+ )
+
+ if not delivery_note:
+ return
+
+ for so in sales_order_list:
+ map_pl_locations(pick_list, item_table_mapper, delivery_note, so)
+
+ return delivery_note
+
+
+def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
+ for location in pick_list.locations:
+ if location.sales_order != sales_order or location.product_bundle_item:
+ continue
+
+ if location.sales_order_item:
+ sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
+ else:
+ sales_order_item = None
+
+ source_doc = sales_order_item or location
+
+ dn_item = map_child_doc(source_doc, delivery_note, item_mapper)
+
+ if dn_item:
+ dn_item.against_pick_list = pick_list.name
+ dn_item.pick_list_item = location.name
+ dn_item.warehouse = location.warehouse
+ dn_item.qty = flt(location.picked_qty - location.delivered_qty) / (
+ flt(dn_item.conversion_factor) or 1
+ )
+ dn_item.batch_no = location.batch_no
+ dn_item.serial_no = location.serial_no
+ dn_item.use_serial_batch_fields = location.use_serial_batch_fields
+
+ update_delivery_note_item(source_doc, dn_item, delivery_note)
+
+ add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order)
+ set_delivery_note_missing_values(delivery_note)
+
+ delivery_note.company = pick_list.company
+ if sales_order:
+ delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer")
+
+
+def add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order=None) -> None:
+ """Add product bundles found in pick list to delivery note.
+
+ When mapping pick list items, the bundle item itself isn't part of the
+ locations. Dynamically fetch and add parent bundle item into DN."""
+ product_bundles = pick_list._get_product_bundles()
+ product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values())
+
+ for so_row, value in product_bundles.items():
+ sales_order_item = frappe.get_doc("Sales Order Item", so_row)
+ if sales_order and sales_order_item.parent != sales_order:
+ continue
+
+ dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper)
+ dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle(
+ so_row, product_bundle_qty_map[value.item_code]
+ )
+ dn_bundle_item.pick_list_item = value.pick_list_item
+ dn_bundle_item.against_pick_list = pick_list.name
+ update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note)
+
+
+@frappe.whitelist()
+def create_stock_entry(pick_list: str):
+ pick_list = frappe.get_doc(json.loads(pick_list))
+ validate_item_locations(pick_list)
+
+ if stock_entry_exists(pick_list.get("name")):
+ return frappe.msgprint(_("Stock Entry has been already created against this Pick List"))
+
+ stock_entry = frappe.new_doc("Stock Entry")
+ stock_entry.pick_list = pick_list.get("name")
+ stock_entry.purpose = pick_list.get("purpose")
+ stock_entry.company = pick_list.get("company")
+ stock_entry.set_stock_entry_type()
+
+ if pick_list.get("work_order"):
+ stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry)
+ elif pick_list.get("material_request"):
+ stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry)
+ else:
+ stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
+
+ stock_entry.set_missing_values()
+
+ return stock_entry.as_dict()
+
+
+def update_delivery_note_item(source, target, delivery_note):
+ cost_center = frappe.db.get_value("Project", delivery_note.project, "cost_center")
+ if not cost_center:
+ cost_center = get_cost_center(source.item_code, "Item", delivery_note.company)
+
+ if not cost_center:
+ cost_center = get_cost_center(source.item_group, "Item Group", delivery_note.company)
+
+ target.cost_center = cost_center
+
+
+def get_cost_center(for_item, from_doctype, company):
+ """Returns Cost Center for Item or Item Group"""
+ return frappe.db.get_value(
+ "Item Default",
+ fieldname=["buying_cost_center"],
+ filters={"parent": for_item, "parenttype": from_doctype, "company": company},
+ )
+
+
+def set_delivery_note_missing_values(target):
+ target.run_method("set_missing_values")
+ target.run_method("set_po_nos")
+ target.run_method("calculate_taxes_and_totals")
+
+
+def stock_entry_exists(pick_list_name):
+ return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name})
+
+
+def update_stock_entry_based_on_work_order(pick_list, stock_entry):
+ work_order = frappe.get_doc("Work Order", pick_list.get("work_order"))
+
+ stock_entry.work_order = work_order.name
+ stock_entry.company = work_order.company
+ stock_entry.from_bom = 1
+ stock_entry.bom_no = work_order.bom_no
+ stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
+ stock_entry.fg_completed_qty = pick_list.for_qty
+ if work_order.bom_no:
+ stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")
+
+ is_wip_warehouse_group = frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group")
+ if not (is_wip_warehouse_group and work_order.skip_transfer):
+ wip_warehouse = work_order.wip_warehouse
+ else:
+ wip_warehouse = None
+ stock_entry.to_warehouse = wip_warehouse
+
+ stock_entry.project = work_order.project
+
+ for location in pick_list.locations:
+ item = frappe._dict()
+ update_common_item_properties(item, location)
+ item.t_warehouse = wip_warehouse
+
+ stock_entry.append("items", item)
+
+ return stock_entry
+
+
+def update_stock_entry_based_on_material_request(pick_list, stock_entry):
+ for location in pick_list.locations:
+ target_warehouse = None
+ if location.material_request_item:
+ target_warehouse = frappe.get_value(
+ "Material Request Item", location.material_request_item, "warehouse"
+ )
+ item = frappe._dict()
+ update_common_item_properties(item, location)
+ item.t_warehouse = target_warehouse
+ stock_entry.append("items", item)
+
+ return stock_entry
+
+
+def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
+ for location in pick_list.locations:
+ item = frappe._dict()
+ update_common_item_properties(item, location)
+
+ stock_entry.append("items", item)
+
+ return stock_entry
+
+
+def update_common_item_properties(item, location):
+ item.item_code = location.item_code
+ item.s_warehouse = location.warehouse
+ item.transfer_qty = location.picked_qty
+ item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
+ item.uom = location.uom
+ item.conversion_factor = location.conversion_factor
+ item.stock_uom = location.stock_uom
+ item.material_request = location.material_request
+ item.serial_no = location.serial_no
+ item.batch_no = location.batch_no
+ item.material_request_item = location.material_request_item
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index 750466a4a40..ee83a303791 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -211,7 +211,7 @@ frappe.ui.form.on("Pick List", {
}
frm.clear_table("locations");
erpnext.utils.map_current_doc({
- method: "erpnext.manufacturing.doctype.work_order.work_order.create_pick_list",
+ method: "erpnext.manufacturing.doctype.work_order.mapper.create_pick_list",
target: frm,
source_name: frm.doc.work_order,
});
@@ -223,7 +223,7 @@ frappe.ui.form.on("Pick List", {
},
material_request: (frm) => {
erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.material_request.material_request.create_pick_list",
+ method: "erpnext.stock.doctype.material_request.mapper.create_pick_list",
target: frm,
source_name: frm.doc.material_request,
});
@@ -234,13 +234,13 @@ frappe.ui.form.on("Pick List", {
},
create_delivery_note: (frm) => {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.pick_list.pick_list.create_delivery_note",
+ method: "erpnext.stock.doctype.pick_list.mapper.create_delivery_note",
frm: frm,
});
},
create_stock_entry: (frm) => {
frappe
- .xcall("erpnext.stock.doctype.pick_list.pick_list.create_stock_entry", {
+ .xcall("erpnext.stock.doctype.pick_list.mapper.create_stock_entry", {
pick_list: frm.doc,
})
.then((stock_entry) => {
@@ -262,7 +262,7 @@ frappe.ui.form.on("Pick List", {
};
frm.get_items_btn = frm.add_custom_button(__("Get Items"), () => {
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.create_pick_list",
+ method: "erpnext.selling.doctype.sales_order.mapper.create_pick_list",
source_doctype: "Sales Order",
target: frm,
setters: {
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index 7f6a6421c69..535a20ec0f3 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -3,22 +3,17 @@
import json
from collections import OrderedDict, defaultdict
-from itertools import groupby
from typing import Any
import frappe
from frappe import _, bold
from frappe.model.document import Document
-from frappe.model.mapper import map_child_doc
from frappe.query_builder import Case
from frappe.query_builder.custom import GROUP_CONCAT
from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum
from frappe.utils import cint, floor, flt, get_link_to_form
from frappe.utils.nestedset import get_descendants_of
-from erpnext.selling.doctype.sales_order.sales_order import (
- make_delivery_note as create_delivery_note_from_sales_order,
-)
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
@@ -30,6 +25,10 @@ from erpnext.stock.serial_batch_bundle import (
)
from erpnext.utilities.transaction_base import TransactionBase
+from .mapper import (
+ stock_entry_exists,
+)
+
class MissingWarehouseValidationError(frappe.ValidationError):
pass
@@ -931,11 +930,6 @@ def get_picked_items_qty(items, contains_packed_items=False) -> list[dict]:
return query.run(as_dict=True)
-def validate_item_locations(pick_list):
- if not pick_list.locations:
- frappe.throw(_("Add items in the Item Locations table"))
-
-
def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus):
available_locations = item_location_map.get(item_doc.item_code)
locations = []
@@ -1284,253 +1278,6 @@ def get_available_item_locations_for_other_item(
return item_locations
-@frappe.whitelist()
-def create_delivery_note(source_name: str, target_doc: str | Document | None = None):
- pick_list = frappe.get_doc("Pick List", source_name)
- validate_item_locations(pick_list)
- sales_dict = dict()
- sales_orders = []
- delivery_notes = []
- for location in pick_list.locations:
- if location.sales_order:
- sales_orders.append(
- frappe.db.get_value(
- "Sales Order",
- location.sales_order,
- [
- "customer",
- "name as sales_order",
- "company_address",
- "dispatch_address_name",
- "shipping_address_name",
- "customer_address",
- ],
- as_dict=True,
- )
- )
-
- group_key = lambda so: ( # noqa
- so["customer"],
- so["company_address"] or "",
- so["dispatch_address_name"] or "",
- so["shipping_address_name"] or "",
- so["customer_address"] or "",
- )
- for key, rows in groupby(sorted(sales_orders, key=group_key), key=group_key):
- sales_dict[key] = {row.sales_order for row in rows}
-
- if sales_dict:
- delivery_notes.extend(create_dn_with_so(sales_dict, pick_list))
-
- if not all(item.sales_order for item in pick_list.locations):
- delivery_notes.append(create_dn_wo_so(pick_list))
-
- if len(delivery_notes) == 1:
- return delivery_notes[0]
- else:
- from frappe.utils import comma_and
-
- doc_list = [get_link_to_form("Delivery Note", p.name) for p in delivery_notes]
- frappe.msgprint(_("{0} created").format(comma_and(doc_list)))
-
-
-def create_dn_wo_so(pick_list, delivery_note=None):
- if not delivery_note:
- delivery_note = frappe.new_doc("Delivery Note")
-
- delivery_note.company = pick_list.company
-
- item_table_mapper_without_so = {
- "doctype": "Delivery Note Item",
- "field_map": {
- "rate": "rate",
- "name": "name",
- "parent": "",
- },
- }
- map_pl_locations(pick_list, item_table_mapper_without_so, delivery_note)
- delivery_note.flags.ignore_mandatory = True
- delivery_note.save()
-
- return delivery_note
-
-
-@frappe.whitelist()
-def create_dn_for_pick_lists(
- source_name: str, target_doc: str | Document | None = None, kwargs: dict | str | None = None
-):
- """Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer"""
- if kwargs is None:
- kwargs = {}
- if isinstance(kwargs, str):
- kwargs = json.loads(kwargs)
-
- pick_list = frappe.get_doc("Pick List", source_name)
- validate_item_locations(pick_list)
-
- sales_order_arg = kwargs.get("sales_order")
- customer_arg = kwargs.get("customer")
-
- if sales_order_arg:
- sales_orders = {sales_order_arg}
- else:
- sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order}
-
- if customer_arg:
- sales_orders = frappe.get_all(
- "Sales Order",
- filters={"customer": customer_arg, "name": ["in", list(sales_orders)]},
- pluck="name",
- )
-
- delivery_note = create_dn_from_so(pick_list, sales_orders, delivery_note=target_doc, kwargs=kwargs)
-
- if not sales_order_arg and not all(item.sales_order for item in pick_list.locations):
- if isinstance(delivery_note, str):
- delivery_note = frappe.get_doc(frappe.parse_json(delivery_note))
-
- delivery_note = create_dn_wo_so(pick_list, delivery_note)
-
- return delivery_note
-
-
-def create_dn_with_so(sales_dict, pick_list):
- """Create Delivery Note for each customer (based on SO) in a Pick List."""
- delivery_notes = []
-
- for key in sales_dict:
- delivery_note = create_dn_from_so(pick_list, sales_dict[key], None)
- if delivery_note:
- delivery_note.flags.ignore_mandatory = True
- # updates packed_items on save
- # save as multiple customers are possible
- delivery_note.save()
- delivery_notes.append(delivery_note)
-
- return delivery_notes
-
-
-def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=None):
- if not sales_order_list:
- return delivery_note
-
- def select_item(d):
- filtered_items = kwargs.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- item_table_mapper = {
- "doctype": "Delivery Note Item",
- "field_map": {
- "rate": "rate",
- "name": "so_detail",
- "parent": "against_sales_order",
- },
- "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
- and doc.delivered_by_supplier != 1
- and select_item(doc),
- }
-
- kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule}
-
- delivery_note = create_delivery_note_from_sales_order(
- next(iter(sales_order_list)), delivery_note, kwargs=kwargs
- )
-
- if not delivery_note:
- return
-
- for so in sales_order_list:
- map_pl_locations(pick_list, item_table_mapper, delivery_note, so)
-
- return delivery_note
-
-
-def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
- for location in pick_list.locations:
- if location.sales_order != sales_order or location.product_bundle_item:
- continue
-
- if location.sales_order_item:
- sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
- else:
- sales_order_item = None
-
- source_doc = sales_order_item or location
-
- dn_item = map_child_doc(source_doc, delivery_note, item_mapper)
-
- if dn_item:
- dn_item.against_pick_list = pick_list.name
- dn_item.pick_list_item = location.name
- dn_item.warehouse = location.warehouse
- dn_item.qty = flt(location.picked_qty - location.delivered_qty) / (
- flt(dn_item.conversion_factor) or 1
- )
- dn_item.batch_no = location.batch_no
- dn_item.serial_no = location.serial_no
- dn_item.use_serial_batch_fields = location.use_serial_batch_fields
-
- update_delivery_note_item(source_doc, dn_item, delivery_note)
-
- add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order)
- set_delivery_note_missing_values(delivery_note)
-
- delivery_note.company = pick_list.company
- if sales_order:
- delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer")
-
-
-def add_product_bundles_to_delivery_note(
- pick_list: "PickList", delivery_note, item_mapper, sales_order=None
-) -> None:
- """Add product bundles found in pick list to delivery note.
-
- When mapping pick list items, the bundle item itself isn't part of the
- locations. Dynamically fetch and add parent bundle item into DN."""
- product_bundles = pick_list._get_product_bundles()
- product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values())
-
- for so_row, value in product_bundles.items():
- sales_order_item = frappe.get_doc("Sales Order Item", so_row)
- if sales_order and sales_order_item.parent != sales_order:
- continue
-
- dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper)
- dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle(
- so_row, product_bundle_qty_map[value.item_code]
- )
- dn_bundle_item.pick_list_item = value.pick_list_item
- dn_bundle_item.against_pick_list = pick_list.name
- update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note)
-
-
-@frappe.whitelist()
-def create_stock_entry(pick_list: str):
- pick_list = frappe.get_doc(json.loads(pick_list))
- validate_item_locations(pick_list)
-
- if stock_entry_exists(pick_list.get("name")):
- return frappe.msgprint(_("Stock Entry has been already created against this Pick List"))
-
- stock_entry = frappe.new_doc("Stock Entry")
- stock_entry.pick_list = pick_list.get("name")
- stock_entry.purpose = pick_list.get("purpose")
- stock_entry.company = pick_list.get("company")
- stock_entry.set_stock_entry_type()
-
- if pick_list.get("work_order"):
- stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry)
- elif pick_list.get("material_request"):
- stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry)
- else:
- stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
-
- stock_entry.set_missing_values()
-
- return stock_entry.as_dict()
-
-
@frappe.whitelist()
def get_pending_work_orders(
doctype: Any,
@@ -1585,106 +1332,6 @@ def get_actual_qty(item_code, warehouse):
)
-def update_delivery_note_item(source, target, delivery_note):
- cost_center = frappe.db.get_value("Project", delivery_note.project, "cost_center")
- if not cost_center:
- cost_center = get_cost_center(source.item_code, "Item", delivery_note.company)
-
- if not cost_center:
- cost_center = get_cost_center(source.item_group, "Item Group", delivery_note.company)
-
- target.cost_center = cost_center
-
-
-def get_cost_center(for_item, from_doctype, company):
- """Returns Cost Center for Item or Item Group"""
- return frappe.db.get_value(
- "Item Default",
- fieldname=["buying_cost_center"],
- filters={"parent": for_item, "parenttype": from_doctype, "company": company},
- )
-
-
-def set_delivery_note_missing_values(target):
- target.run_method("set_missing_values")
- target.run_method("set_po_nos")
- target.run_method("calculate_taxes_and_totals")
-
-
-def stock_entry_exists(pick_list_name):
- return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name})
-
-
-def update_stock_entry_based_on_work_order(pick_list, stock_entry):
- work_order = frappe.get_doc("Work Order", pick_list.get("work_order"))
-
- stock_entry.work_order = work_order.name
- stock_entry.company = work_order.company
- stock_entry.from_bom = 1
- stock_entry.bom_no = work_order.bom_no
- stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
- stock_entry.fg_completed_qty = pick_list.for_qty
- if work_order.bom_no:
- stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")
-
- is_wip_warehouse_group = frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group")
- if not (is_wip_warehouse_group and work_order.skip_transfer):
- wip_warehouse = work_order.wip_warehouse
- else:
- wip_warehouse = None
- stock_entry.to_warehouse = wip_warehouse
-
- stock_entry.project = work_order.project
-
- for location in pick_list.locations:
- item = frappe._dict()
- update_common_item_properties(item, location)
- item.t_warehouse = wip_warehouse
-
- stock_entry.append("items", item)
-
- return stock_entry
-
-
-def update_stock_entry_based_on_material_request(pick_list, stock_entry):
- for location in pick_list.locations:
- target_warehouse = None
- if location.material_request_item:
- target_warehouse = frappe.get_value(
- "Material Request Item", location.material_request_item, "warehouse"
- )
- item = frappe._dict()
- update_common_item_properties(item, location)
- item.t_warehouse = target_warehouse
- stock_entry.append("items", item)
-
- return stock_entry
-
-
-def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
- for location in pick_list.locations:
- item = frappe._dict()
- update_common_item_properties(item, location)
-
- stock_entry.append("items", item)
-
- return stock_entry
-
-
-def update_common_item_properties(item, location):
- item.item_code = location.item_code
- item.s_warehouse = location.warehouse
- item.transfer_qty = location.picked_qty
- item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
- item.uom = location.uom
- item.conversion_factor = location.conversion_factor
- item.stock_uom = location.stock_uom
- item.material_request = location.material_request
- item.serial_no = location.serial_no
- item.batch_no = location.batch_no
- item.material_request_item = location.material_request_item
-
-
def get_rejected_warehouses():
if not hasattr(frappe.local, "rejected_warehouses"):
frappe.local.rejected_warehouses = []
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index dfc81d5c9cf..d6f15dffddc 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -5,11 +5,11 @@ import frappe
from frappe import _dict
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
-from erpnext.selling.doctype.sales_order.sales_order import create_pick_list
+from erpnext.selling.doctype.sales_order.mapper import create_pick_list
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle
-from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists
+from erpnext.stock.doctype.pick_list.mapper import create_delivery_note, create_dn_for_pick_lists
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
@@ -1052,7 +1052,8 @@ class TestPickList(ERPNextTestSuite):
def test_pick_list_warehouse_for_work_order(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
- from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list, make_work_order
+ from erpnext.manufacturing.doctype.work_order.mapper import create_pick_list
+ from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
# Create Warehouses for Work Order
@@ -1536,7 +1537,7 @@ class TestPickList(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_multiple_pick_lists_delivery_note(self):
- from erpnext.stock.doctype.pick_list.pick_list import create_dn_for_pick_lists
+ from erpnext.stock.doctype.pick_list.mapper import create_dn_for_pick_lists
item_code = make_item().name
warehouse = "_Test Warehouse - _TC"
@@ -1745,7 +1746,7 @@ class TestPickList(ERPNextTestSuite):
pick_list = frappe.new_doc("Pick List")
map_docs(
- "erpnext.selling.doctype.sales_order.sales_order.create_pick_list",
+ "erpnext.selling.doctype.sales_order.mapper.create_pick_list",
dumps([sales_order1.name, sales_order2.name, sales_order3.name]),
pick_list,
)
diff --git a/erpnext/stock/doctype/purchase_receipt/mapper.py b/erpnext/stock/doctype/purchase_receipt/mapper.py
new file mode 100644
index 00000000000..9ea7371554a
--- /dev/null
+++ b/erpnext/stock/doctype/purchase_receipt/mapper.py
@@ -0,0 +1,254 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.query_builder.functions import Abs, Sum
+from frappe.utils import flt
+
+from erpnext.controllers.accounts_controller import merge_taxes
+from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_transaction
+from erpnext.stock.serial_batch_bundle import (
+ SerialBatchCreation,
+ get_batches_from_bundle,
+ get_serial_nos_from_bundle,
+)
+
+
+def get_invoiced_qty_map(purchase_receipt: str) -> dict:
+ """returns a map: {pr_detail: invoiced_qty}"""
+ invoiced_qty_map = {}
+
+ for pr_detail, qty in frappe.db.sql(
+ """select pr_detail, qty from `tabPurchase Invoice Item`
+ where purchase_receipt=%s and docstatus=1""",
+ purchase_receipt,
+ ):
+ if not invoiced_qty_map.get(pr_detail):
+ invoiced_qty_map[pr_detail] = 0
+ invoiced_qty_map[pr_detail] += qty
+
+ return invoiced_qty_map
+
+
+def get_returned_qty_map(purchase_receipt: str) -> dict:
+ """returns a map: {pr_detail: returned_qty}"""
+ pr = frappe.qb.DocType("Purchase Receipt")
+ pr_item = frappe.qb.DocType("Purchase Receipt Item")
+
+ query = (
+ frappe.qb.from_(pr)
+ .inner_join(pr_item)
+ .on(pr.name == pr_item.parent)
+ .select(pr_item.purchase_receipt_item, Sum(Abs(pr_item.qty)).as_("qty"))
+ .where(
+ (pr.docstatus == 1)
+ & (pr.is_return == 1)
+ & (pr.return_against == purchase_receipt)
+ & (pr_item.purchase_receipt_item.isnotnull())
+ )
+ .groupby(pr_item.purchase_receipt_item)
+ ).run(as_list=1)
+
+ return frappe._dict(query) if query else frappe._dict()
+
+
+@frappe.whitelist()
+def make_purchase_invoice(
+ source_name: str | None, target_doc: str | Document | None = None, args: dict | str | None = None
+):
+ if args is None:
+ args = {}
+ if isinstance(args, str):
+ args = json.loads(args)
+
+ from erpnext.accounts.party import get_payment_terms_template
+
+ doc = frappe.get_doc("Purchase Receipt", source_name)
+ returned_qty_map = get_returned_qty_map(source_name)
+ invoiced_qty_map = get_invoiced_qty_map(source_name)
+
+ def set_missing_values(source, target):
+ if len(target.get("items")) == 0:
+ frappe.throw(_("All items have already been Invoiced/Returned"))
+
+ doc = frappe.get_doc(target)
+ doc.payment_terms_template = get_payment_terms_template(source.supplier, "Supplier", source.company)
+ doc.run_method("onload")
+ doc.run_method("set_missing_values")
+
+ if args and args.get("merge_taxes"):
+ merge_taxes(source, doc)
+
+ doc.run_method("calculate_taxes_and_totals")
+ from erpnext.accounts.services.payment_schedule import PaymentScheduleService
+
+ PaymentScheduleService(doc).set_payment_schedule()
+
+ def update_item(source_doc, target_doc, source_parent):
+ target_doc.qty, returned_qty = get_pending_qty(source_doc)
+ if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
+ target_doc.rejected_qty = 0
+ target_doc.stock_qty = flt(target_doc.qty) * flt(
+ target_doc.conversion_factor, target_doc.precision("conversion_factor")
+ )
+ returned_qty_map[source_doc.name] = returned_qty
+ target_doc._old_name = source_doc.name
+
+ def get_pending_qty(item_row):
+ qty = item_row.qty
+ if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
+ qty = item_row.received_qty
+
+ pending_qty = qty - invoiced_qty_map.get(item_row.name, 0)
+
+ if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
+ return pending_qty, 0
+
+ returned_qty = flt(returned_qty_map.get(item_row.name, 0))
+ if item_row.rejected_qty and returned_qty:
+ returned_qty -= item_row.rejected_qty
+
+ if returned_qty:
+ if returned_qty >= pending_qty:
+ pending_qty = 0
+ returned_qty -= pending_qty
+ else:
+ pending_qty -= returned_qty
+ returned_qty = 0
+
+ return pending_qty, returned_qty
+
+ def select_item(d):
+ filtered_items = args.get("filtered_children", [])
+ child_filter = d.name in filtered_items if filtered_items else True
+ return child_filter
+
+ doclist = get_mapped_doc(
+ "Purchase Receipt",
+ source_name,
+ {
+ "Purchase Receipt": {
+ "doctype": "Purchase Invoice",
+ "field_map": {
+ "supplier_warehouse": "supplier_warehouse",
+ "is_return": "is_return",
+ "bill_date": "bill_date",
+ },
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Purchase Receipt Item": {
+ "doctype": "Purchase Invoice Item",
+ "field_map": {
+ "name": "pr_detail",
+ "parent": "purchase_receipt",
+ "qty": "received_qty",
+ "purchase_order_item": "po_detail",
+ "purchase_order": "purchase_order",
+ "is_fixed_asset": "is_fixed_asset",
+ "asset_location": "asset_location",
+ "asset_category": "asset_category",
+ "wip_composite_asset": "wip_composite_asset",
+ },
+ "postprocess": update_item,
+ "filter": lambda d: (
+ get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0
+ ),
+ "condition": select_item,
+ },
+ "Purchase Taxes and Charges": {
+ "doctype": "Purchase Taxes and Charges",
+ "reset_value": not (args and args.get("merge_taxes")),
+ "ignore": args.get("merge_taxes") if args else 0,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_purchase_return_against_rejected_warehouse(source_name: str):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Purchase Receipt", source_name, return_against_rejected_qty=True)
+
+
+@frappe.whitelist()
+def make_purchase_return(source_name: str, target_doc: str | Document | None = None):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Purchase Receipt", source_name, target_doc)
+
+
+@frappe.whitelist()
+def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
+ def set_missing_values(source, target):
+ target.stock_entry_type = "Material Transfer"
+ target.purpose = "Material Transfer"
+ target.set_missing_values()
+
+ def update_item(source_doc, target_doc, source_parent):
+ if source_doc.serial_and_batch_bundle:
+ serial_nos = get_serial_nos_from_bundle(source_doc.serial_and_batch_bundle)
+ if serial_nos:
+ serial_nos = "\n".join(serial_nos)
+
+ batches = get_batches_from_bundle(source_doc.serial_and_batch_bundle)
+ if batches:
+ if len(batches) == 1:
+ target_doc.use_serial_batch_fields = 1
+ target_doc.batch_no = next(iter(batches))
+ elif not serial_nos:
+ cls_obj = SerialBatchCreation(
+ {
+ "type_of_transaction": "Outward",
+ "serial_and_batch_bundle": source_doc.serial_and_batch_bundle,
+ "item_code": source_doc.item_code,
+ "warehouse": source_doc.warehouse,
+ }
+ )
+
+ cls_obj.duplicate_package()
+
+ target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle
+
+ if serial_nos:
+ target_doc.use_serial_batch_fields = 1
+ target_doc.serial_no = serial_nos
+
+ doclist = get_mapped_doc(
+ "Purchase Receipt",
+ source_name,
+ {
+ "Purchase Receipt": {
+ "doctype": "Stock Entry",
+ },
+ "Purchase Receipt Item": {
+ "doctype": "Stock Entry Detail",
+ "field_map": {
+ "warehouse": "s_warehouse",
+ "parent": "reference_purchase_receipt",
+ "batch_no": "batch_no",
+ },
+ "postprocess": update_item,
+ },
+ },
+ target_doc,
+ set_missing_values,
+ )
+
+ return doclist
+
+
+@frappe.whitelist()
+def make_inter_company_delivery_note(source_name: str, target_doc: str | Document | None = None):
+ return make_inter_company_transaction("Purchase Receipt", source_name, target_doc)
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
index 4e959229e15..6524bd30265 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
@@ -45,7 +45,7 @@ frappe.ui.form.on("Purchase Receipt", {
__("Debit Note"),
function () {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
+ method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice",
frm: cur_frm,
});
},
@@ -59,7 +59,7 @@ frappe.ui.form.on("Purchase Receipt", {
__("Delivery Note"),
function () {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_inter_company_delivery_note",
+ method: "erpnext.stock.doctype.purchase_receipt.mapper.make_inter_company_delivery_note",
frm: cur_frm,
});
},
@@ -124,7 +124,7 @@ frappe.ui.form.on("Purchase Receipt", {
});
}
erpnext.utils.map_current_doc({
- method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_purchase_receipt",
+ method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_purchase_receipt",
source_doctype: "Purchase Invoice",
target: frm,
setters: {
@@ -223,7 +223,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
});
}
erpnext.utils.map_current_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_receipt",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_purchase_receipt",
source_doctype: "Purchase Order",
target: me.frm,
setters: {
@@ -282,7 +282,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
make_purchase_invoice() {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_invoice",
+ method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_invoice",
frm: cur_frm,
});
}
@@ -309,7 +309,7 @@ erpnext.stock.PurchaseReceiptController = class PurchaseReceiptController extend
function (values) {
if (values.return_for_rejected_warehouse) {
frappe.call({
- method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_return_against_rejected_warehouse",
+ method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_return_against_rejected_warehouse",
args: {
source_name: cur_frm.doc.name,
},
@@ -439,14 +439,14 @@ frappe.ui.form.on("Purchase Receipt Item", {
cur_frm.cscript._make_purchase_return = function () {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_purchase_return",
+ method: "erpnext.stock.doctype.purchase_receipt.mapper.make_purchase_return",
frm: cur_frm,
});
};
cur_frm.cscript["Make Stock Entry"] = function () {
frappe.model.open_mapped_doc({
- method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_stock_entry",
+ method: "erpnext.stock.doctype.purchase_receipt.mapper.make_stock_entry",
frm: cur_frm,
});
};
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 8f3df98bd7d..0ec7cfbac16 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -2,29 +2,19 @@
# License: GNU General Public License v3. See license.txt
-import json
-
import frappe
from frappe import _, throw
from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
-from frappe.query_builder.functions import Abs, CombineDatetime, Sum
+from frappe.query_builder.functions import CombineDatetime
from frappe.utils import cint, flt, get_datetime, getdate, nowdate
from pypika import functions as fn
import erpnext
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
-from erpnext.controllers.accounts_controller import merge_taxes
from erpnext.controllers.buying_controller import BuyingController
-from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_transaction
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
-from erpnext.stock.serial_batch_bundle import (
- SerialBatchCreation,
- get_batches_from_bundle,
- get_serial_nos_from_bundle,
-)
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -485,350 +475,11 @@ class PurchaseReceipt(BuyingController):
item.amount_difference_with_purchase_invoice = 0
def get_gl_entries(self, inventory_account_map=None, via_landed_cost_voucher=False):
- from erpnext.accounts.general_ledger import process_gl_map
-
- gl_entries = []
-
- self.make_item_gl_entries(gl_entries, inventory_account_map=inventory_account_map)
- self.make_tax_gl_entries(gl_entries, via_landed_cost_voucher)
- self.set_gl_entry_for_purchase_expense(gl_entries)
- update_regional_gl_entries(gl_entries, self)
-
- return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
-
- def make_item_gl_entries(self, gl_entries, inventory_account_map=None):
- from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
- get_purchase_document_details,
+ from erpnext.stock.doctype.purchase_receipt.services.gl_composer import (
+ PurchaseReceiptGLComposer,
)
- provisional_accounting_for_non_stock_items = cint(
- frappe.db.get_value("Company", self.company, "enable_provisional_accounting_for_non_stock_items")
- )
-
- exchange_rate_map, net_rate_map = get_purchase_document_details(self)
-
- def validate_account(account_type):
- frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type))
-
- def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name):
- account_currency = get_account_currency(stock_asset_account_name)
-
- if not stock_asset_account_name:
- validate_account("Asset or warehouse account")
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=stock_asset_account_name,
- cost_center=d.cost_center,
- debit=stock_value_diff,
- credit=0.0,
- remarks=remarks,
- against_account=stock_asset_rbnb,
- account_currency=account_currency,
- item=item,
- )
-
- def make_stock_received_but_not_billed_entry(item):
- if (
- self.get("is_return")
- and item.return_qty_from_rejected_warehouse
- and not frappe.db.get_single_value(
- "Buying Settings", "set_valuation_rate_for_rejected_materials"
- )
- ):
- return 0.0
-
- account = stock_asset_rbnb
- if item.from_warehouse:
- _inv_dict = self.get_inventory_account_dict(item, inventory_account_map, "from_warehouse")
- account = _inv_dict["account"]
-
- account_currency = get_account_currency(account)
-
- # GL Entry for from warehouse or Stock Received but not billed
- # Intentionally passed negative debit amount to avoid incorrect GL Entry validation
- credit_amount = (
- flt(item.base_net_amount, item.precision("base_net_amount"))
- if account_currency == self.company_currency
- else flt(item.net_amount, item.precision("net_amount"))
- )
-
- outgoing_amount = item.base_net_amount
- if self.is_internal_transfer() and item.valuation_rate:
- outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse))
- credit_amount = outgoing_amount
-
- if item.get("rejected_qty") and frappe.db.get_single_value(
- "Buying Settings", "set_valuation_rate_for_rejected_materials"
- ):
- outgoing_amount += get_stock_value_difference(self.name, item.name, item.rejected_warehouse)
- credit_amount = outgoing_amount
-
- if credit_amount:
- if not account:
- validate_account("Stock or Asset Received But Not Billed")
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=account,
- cost_center=item.cost_center,
- debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
- credit=0.0,
- remarks=remarks,
- against_account=stock_asset_account_name,
- debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
- account_currency=account_currency,
- item=item,
- )
-
- # check if the exchange rate has changed
- if d.get("purchase_invoice"):
- if (
- exchange_rate_map[item.purchase_invoice]
- and self.conversion_rate != exchange_rate_map[item.purchase_invoice]
- and item.net_rate == net_rate_map[item.purchase_invoice_item]
- ):
- discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * (
- exchange_rate_map[item.purchase_invoice] - self.conversion_rate
- )
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=discrepancy_caused_by_exchange_rate_difference,
- remarks=remarks,
- against_account=self.supplier,
- debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
- account_currency=account_currency,
- item=item,
- )
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=self.get_company_default("exchange_gain_loss_account"),
- cost_center=d.cost_center,
- debit=discrepancy_caused_by_exchange_rate_difference,
- credit=0.0,
- remarks=remarks,
- against_account=self.supplier,
- debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
- account_currency=account_currency,
- item=item,
- )
-
- return outgoing_amount
-
- def make_landed_cost_gl_entries(item):
- # Amount added through landed-cost-voucher
- if item.landed_cost_voucher_amount and landed_cost_entries:
- if (item.item_code, item.name) in landed_cost_entries:
- for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
- account_currency = get_account_currency(account)
- credit_amount = (
- flt(amount["base_amount"])
- if (amount["base_amount"] or account_currency != self.company_currency)
- else flt(amount["amount"])
- )
-
- if not account:
- validate_account("Landed Cost Account")
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=credit_amount,
- remarks=remarks,
- against_account=stock_asset_account_name,
- credit_in_account_currency=flt(amount["amount"]),
- account_currency=account_currency,
- project=item.project,
- item=item,
- )
-
- def make_amount_difference_entry(item):
- if item.amount_difference_with_purchase_invoice and stock_asset_rbnb:
- account_currency = get_account_currency(stock_asset_rbnb)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=stock_asset_rbnb,
- cost_center=item.cost_center,
- debit=0.0,
- credit=flt(item.amount_difference_with_purchase_invoice),
- remarks=_("Adjustment based on Purchase Invoice rate"),
- against_account=stock_asset_account_name,
- account_currency=account_currency,
- project=item.project,
- item=item,
- )
-
- def make_sub_contracting_gl_entries(item):
- # sub-contracting warehouse
- if flt(item.rm_supp_cost) and supplier_warehouse_account:
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=supplier_warehouse_account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=flt(item.rm_supp_cost),
- remarks=remarks,
- against_account=stock_asset_account_name,
- account_currency=supplier_warehouse_account_currency,
- item=item,
- )
-
- def make_divisional_loss_gl_entry(item, outgoing_amount):
- if item.is_fixed_asset:
- return
-
- # divisional loss adjustment
- valuation_amount_as_per_doc = (
- flt(outgoing_amount, d.precision("base_net_amount"))
- + flt(item.landed_cost_voucher_amount)
- + flt(item.rm_supp_cost)
- + flt(item.item_tax_amount)
- + flt(item.amount_difference_with_purchase_invoice)
- )
-
- divisional_loss = flt(
- valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
- )
-
- if item.get("rejected_qty") and frappe.db.get_single_value(
- "Buying Settings", "set_valuation_rate_for_rejected_materials"
- ):
- rejected_item_cost = get_stock_value_difference(self.name, item.name, item.rejected_warehouse)
- divisional_loss -= rejected_item_cost
-
- if divisional_loss:
- loss_account = (
- self.get_company_default("default_expense_account", ignore_validation=True)
- or stock_asset_rbnb
- )
-
- if self.is_return and item.expense_account:
- loss_account = item.expense_account
-
- cost_center = item.cost_center or frappe.get_cached_value(
- "Company", self.company, "cost_center"
- )
- account_currency = get_account_currency(loss_account)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=loss_account,
- cost_center=cost_center,
- debit=divisional_loss,
- credit=0.0,
- remarks=remarks,
- against_account=stock_asset_account_name,
- account_currency=account_currency,
- project=item.project,
- item=item,
- )
-
- stock_items = self.get_stock_items()
- warehouse_with_no_account = []
-
- for d in self.get("items"):
- remarks = self.get("remarks") or _("Accounting Entry for {0}").format(
- "Asset" if d.is_fixed_asset else "Stock"
- )
-
- if (
- provisional_accounting_for_non_stock_items
- and d.item_code not in stock_items
- and flt(d.qty)
- and d.get("provisional_expense_account")
- and not d.is_fixed_asset
- ):
- self.add_provisional_gl_entry(
- d, gl_entries, self.posting_date, d.get("provisional_expense_account")
- )
- elif flt(d.qty) and (flt(d.valuation_rate) or self.is_return):
- if not (
- (erpnext.is_perpetual_inventory_enabled(self.company) and d.item_code in stock_items)
- or (d.is_fixed_asset and not d.purchase_invoice)
- ):
- continue
-
- stock_asset_rbnb = (
- self.get_company_default("asset_received_but_not_billed")
- if d.is_fixed_asset
- else self.get_company_default("stock_received_but_not_billed")
- )
- landed_cost_entries = self.get_item_account_wise_lcv_entries()
- if d.is_fixed_asset:
- stock_asset_account_name = d.expense_account
- stock_value_diff = (
- flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount)
- )
- elif inventory_account := self.get_inventory_account_dict(d, inventory_account_map):
- stock_value_diff = get_stock_value_difference(self.name, d.name, d.warehouse)
- stock_asset_account_name = inventory_account["account"]
-
- supplier_warehouse_account = None
- supplier_warehouse_account_currency = None
- if self.supplier_warehouse:
- if _inv_dict := self.get_inventory_account_dict(
- d, inventory_account_map, "supplier_warehouse"
- ):
- supplier_warehouse_account = _inv_dict["account"]
- supplier_warehouse_account_currency = _inv_dict["account_currency"]
-
- # If PR is sub-contracted and fg item rate is zero
- # in that case if account for source and target warehouse are same,
- # then GL entries should not be posted
- if (
- flt(stock_value_diff) == flt(d.rm_supp_cost)
- and supplier_warehouse_account
- and stock_asset_account_name == supplier_warehouse_account
- ):
- continue
-
- if (flt(d.valuation_rate) or self.is_return or d.is_fixed_asset) and flt(d.qty):
- make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
- outgoing_amount = make_stock_received_but_not_billed_entry(d)
- make_landed_cost_gl_entries(d)
- make_amount_difference_entry(d)
- make_sub_contracting_gl_entries(d)
- make_divisional_loss_gl_entry(d, outgoing_amount)
- elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
- not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
- and d.rejected_warehouse
- and d.rejected_warehouse not in warehouse_with_no_account
- ):
- warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse)
-
- if d.is_fixed_asset and d.landed_cost_voucher_amount:
- self.update_assets(d, d.valuation_rate)
-
- if d.rejected_qty and frappe.db.get_single_value(
- "Buying Settings", "set_valuation_rate_for_rejected_materials"
- ):
- stock_asset_rbnb = (
- self.get_company_default("asset_received_but_not_billed")
- if d.is_fixed_asset
- else self.get_company_default("stock_received_but_not_billed")
- )
-
- stock_value_diff = get_stock_value_difference(self.name, d.name, d.rejected_warehouse)
- _inv_dict = self.get_inventory_account_dict(d, inventory_account_map, "rejected_warehouse")
-
- stock_asset_account_name = _inv_dict["account"]
-
- make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
- if not d.qty:
- make_stock_received_but_not_billed_entry(d)
-
- if warehouse_with_no_account:
- frappe.msgprint(
- _("No accounting entries for the following warehouses")
- + ": \n"
- + "\n".join(warehouse_with_no_account)
- )
+ return PurchaseReceiptGLComposer(self).compose(inventory_account_map, via_landed_cost_voucher)
def add_provisional_gl_entry(
self, item, gl_entries, posting_date, provisional_account, reverse=0, item_amount=None
@@ -885,57 +536,6 @@ class PurchaseReceipt(BuyingController):
return False
- def make_tax_gl_entries(self, gl_entries, via_landed_cost_voucher=False):
- negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get("items")])
- # Cost center-wise amount breakup for other charges included for valuation
- valuation_tax = {}
- for tax in self.get("taxes"):
- if tax.category in ("Valuation", "Valuation and Total") and flt(
- tax.base_tax_amount_after_discount_amount
- ):
- if not tax.cost_center:
- frappe.throw(
- _("Cost Center is required in row {0} in Taxes table for type {1}").format(
- tax.idx, _(tax.category)
- )
- )
- valuation_tax.setdefault(tax.name, 0)
- valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(
- tax.base_tax_amount_after_discount_amount
- )
-
- if negative_expense_to_be_booked and valuation_tax:
- # Backward compatibility:
- # and charges added via Landed Cost Voucher,
- # post valuation related charges on "Stock Received But Not Billed"
- against_accounts = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0])
- total_valuation_amount = sum(valuation_tax.values())
- amount_including_divisional_loss = negative_expense_to_be_booked
- i = 1
- for tax in self.get("taxes"):
- if valuation_tax.get(tax.name):
- account = tax.account_head
- if i == len(valuation_tax):
- applicable_amount = amount_including_divisional_loss
- else:
- applicable_amount = negative_expense_to_be_booked * (
- valuation_tax[tax.name] / total_valuation_amount
- )
- amount_including_divisional_loss -= applicable_amount
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=account,
- cost_center=tax.cost_center,
- debit=0.0,
- credit=applicable_amount,
- remarks=self.remarks or _("Accounting Entry for Stock"),
- against_account=against_accounts,
- item=tax,
- )
-
- i += 1
-
def update_assets(self, item, valuation_rate):
assets = frappe.db.get_all(
"Asset",
@@ -1465,246 +1065,12 @@ def get_item_wise_returned_qty(pr_doc):
)
-@frappe.whitelist()
-def make_purchase_invoice(
- source_name: str | None, target_doc: str | Document | None = None, args: dict | str | None = None
-):
- if args is None:
- args = {}
- if isinstance(args, str):
- args = json.loads(args)
-
- from erpnext.accounts.party import get_payment_terms_template
-
- doc = frappe.get_doc("Purchase Receipt", source_name)
- returned_qty_map = get_returned_qty_map(source_name)
- invoiced_qty_map = get_invoiced_qty_map(source_name)
-
- def set_missing_values(source, target):
- if len(target.get("items")) == 0:
- frappe.throw(_("All items have already been Invoiced/Returned"))
-
- doc = frappe.get_doc(target)
- doc.payment_terms_template = get_payment_terms_template(source.supplier, "Supplier", source.company)
- doc.run_method("onload")
- doc.run_method("set_missing_values")
-
- if args and args.get("merge_taxes"):
- merge_taxes(source, doc)
-
- doc.run_method("calculate_taxes_and_totals")
- doc.set_payment_schedule()
-
- def update_item(source_doc, target_doc, source_parent):
- target_doc.qty, returned_qty = get_pending_qty(source_doc)
- if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
- target_doc.rejected_qty = 0
- target_doc.stock_qty = flt(target_doc.qty) * flt(
- target_doc.conversion_factor, target_doc.precision("conversion_factor")
- )
- returned_qty_map[source_doc.name] = returned_qty
- target_doc._old_name = source_doc.name
-
- def get_pending_qty(item_row):
- qty = item_row.qty
- if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
- qty = item_row.received_qty
-
- pending_qty = qty - invoiced_qty_map.get(item_row.name, 0)
-
- if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"):
- return pending_qty, 0
-
- returned_qty = flt(returned_qty_map.get(item_row.name, 0))
- if item_row.rejected_qty and returned_qty:
- returned_qty -= item_row.rejected_qty
-
- if returned_qty:
- if returned_qty >= pending_qty:
- pending_qty = 0
- returned_qty -= pending_qty
- else:
- pending_qty -= returned_qty
- returned_qty = 0
-
- return pending_qty, returned_qty
-
- def select_item(d):
- filtered_items = args.get("filtered_children", [])
- child_filter = d.name in filtered_items if filtered_items else True
- return child_filter
-
- doclist = get_mapped_doc(
- "Purchase Receipt",
- source_name,
- {
- "Purchase Receipt": {
- "doctype": "Purchase Invoice",
- "field_map": {
- "supplier_warehouse": "supplier_warehouse",
- "is_return": "is_return",
- "bill_date": "bill_date",
- },
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Purchase Receipt Item": {
- "doctype": "Purchase Invoice Item",
- "field_map": {
- "name": "pr_detail",
- "parent": "purchase_receipt",
- "qty": "received_qty",
- "purchase_order_item": "po_detail",
- "purchase_order": "purchase_order",
- "is_fixed_asset": "is_fixed_asset",
- "asset_location": "asset_location",
- "asset_category": "asset_category",
- "wip_composite_asset": "wip_composite_asset",
- },
- "postprocess": update_item,
- "filter": lambda d: (
- get_pending_qty(d)[0] <= 0 if not doc.get("is_return") else get_pending_qty(d)[0] > 0
- ),
- "condition": select_item,
- },
- "Purchase Taxes and Charges": {
- "doctype": "Purchase Taxes and Charges",
- "reset_value": not (args and args.get("merge_taxes")),
- "ignore": args.get("merge_taxes") if args else 0,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-def get_invoiced_qty_map(purchase_receipt):
- """returns a map: {pr_detail: invoiced_qty}"""
- invoiced_qty_map = {}
-
- for pr_detail, qty in frappe.db.sql(
- """select pr_detail, qty from `tabPurchase Invoice Item`
- where purchase_receipt=%s and docstatus=1""",
- purchase_receipt,
- ):
- if not invoiced_qty_map.get(pr_detail):
- invoiced_qty_map[pr_detail] = 0
- invoiced_qty_map[pr_detail] += qty
-
- return invoiced_qty_map
-
-
-def get_returned_qty_map(purchase_receipt):
- """returns a map: {pr_detail: returned_qty}"""
-
- pr = frappe.qb.DocType("Purchase Receipt")
- pr_item = frappe.qb.DocType("Purchase Receipt Item")
-
- query = (
- frappe.qb.from_(pr)
- .inner_join(pr_item)
- .on(pr.name == pr_item.parent)
- .select(pr_item.purchase_receipt_item, Sum(Abs(pr_item.qty)).as_("qty"))
- .where(
- (pr.docstatus == 1)
- & (pr.is_return == 1)
- & (pr.return_against == purchase_receipt)
- & (pr_item.purchase_receipt_item.isnotnull())
- )
- .groupby(pr_item.purchase_receipt_item)
- ).run(as_list=1)
-
- return frappe._dict(query) if query else frappe._dict()
-
-
-@frappe.whitelist()
-def make_purchase_return_against_rejected_warehouse(source_name: str):
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
-
- return make_return_doc("Purchase Receipt", source_name, return_against_rejected_qty=True)
-
-
-@frappe.whitelist()
-def make_purchase_return(source_name: str, target_doc: str | Document | None = None):
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
-
- return make_return_doc("Purchase Receipt", source_name, target_doc)
-
-
@frappe.whitelist()
def update_purchase_receipt_status(docname: str, status: str):
pr = frappe.get_lazy_doc("Purchase Receipt", docname, check_permission="submit")
pr.update_status(status)
-@frappe.whitelist()
-def make_stock_entry(source_name: str, target_doc: str | Document | None = None):
- def set_missing_values(source, target):
- target.stock_entry_type = "Material Transfer"
- target.purpose = "Material Transfer"
- target.set_missing_values()
-
- def update_item(source_doc, target_doc, source_parent):
- if source_doc.serial_and_batch_bundle:
- serial_nos = get_serial_nos_from_bundle(source_doc.serial_and_batch_bundle)
- if serial_nos:
- serial_nos = "\n".join(serial_nos)
-
- batches = get_batches_from_bundle(source_doc.serial_and_batch_bundle)
- if batches:
- if len(batches) == 1:
- target_doc.use_serial_batch_fields = 1
- target_doc.batch_no = next(iter(batches))
- elif not serial_nos:
- cls_obj = SerialBatchCreation(
- {
- "type_of_transaction": "Outward",
- "serial_and_batch_bundle": source_doc.serial_and_batch_bundle,
- "item_code": source_doc.item_code,
- "warehouse": source_doc.warehouse,
- }
- )
-
- cls_obj.duplicate_package()
-
- target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle
-
- if serial_nos:
- target_doc.use_serial_batch_fields = 1
- target_doc.serial_no = serial_nos
-
- doclist = get_mapped_doc(
- "Purchase Receipt",
- source_name,
- {
- "Purchase Receipt": {
- "doctype": "Stock Entry",
- },
- "Purchase Receipt Item": {
- "doctype": "Stock Entry Detail",
- "field_map": {
- "warehouse": "s_warehouse",
- "parent": "reference_purchase_receipt",
- "batch_no": "batch_no",
- },
- "postprocess": update_item,
- },
- },
- target_doc,
- set_missing_values,
- )
-
- return doclist
-
-
-@frappe.whitelist()
-def make_inter_company_delivery_note(source_name: str, target_doc: str | Document | None = None):
- return make_inter_company_transaction("Purchase Receipt", source_name, target_doc)
-
-
@erpnext.allow_regional
def update_regional_gl_entries(gl_list, doc):
return
diff --git a/erpnext/stock/doctype/purchase_receipt/services/__init__.py b/erpnext/stock/doctype/purchase_receipt/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
new file mode 100644
index 00000000000..6a01a0484ce
--- /dev/null
+++ b/erpnext/stock/doctype/purchase_receipt/services/gl_composer.py
@@ -0,0 +1,408 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import cint, flt
+
+import erpnext
+from erpnext.accounts.general_ledger import process_gl_map
+from erpnext.accounts.utils import get_account_currency
+from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
+
+
+class PurchaseReceiptGLComposer(BaseStockGLComposer):
+ """GL composer for Purchase Receipt.
+
+ Builds GL entries for stock/asset inward, taxes, purchase expense, and
+ regional adjustments. Does not delegate to the base stock GL loop —
+ PR has its own per-item logic (provisional accounting, fixed assets, LCV,
+ sub-contracting, divisional loss).
+ """
+
+ def compose(
+ self,
+ inventory_account_map: dict | None = None,
+ via_landed_cost_voucher: bool = False,
+ ) -> list:
+ gl_entries = []
+ self._make_item_gl_entries(gl_entries, inventory_account_map)
+ self._make_tax_gl_entries(gl_entries, via_landed_cost_voucher)
+ self.doc.set_gl_entry_for_purchase_expense(gl_entries)
+
+ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_regional_gl_entries
+
+ update_regional_gl_entries(gl_entries, self.doc)
+
+ return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
+
+ def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None:
+ from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
+ get_purchase_document_details,
+ )
+ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import get_stock_value_difference
+
+ doc = self.doc
+ provisional_accounting_for_non_stock_items = cint(
+ frappe.db.get_value("Company", doc.company, "enable_provisional_accounting_for_non_stock_items")
+ )
+
+ exchange_rate_map, net_rate_map = get_purchase_document_details(doc)
+ stock_items = doc.get_stock_items()
+ warehouse_with_no_account = []
+
+ def validate_account(account_type):
+ frappe.throw(_("{0} account not found while submitting purchase receipt").format(account_type))
+
+ def make_item_asset_inward_gl_entry(item, stock_value_diff, stock_asset_account_name):
+ account_currency = get_account_currency(stock_asset_account_name)
+ if not stock_asset_account_name:
+ validate_account("Asset or warehouse account")
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=stock_asset_account_name,
+ cost_center=d.cost_center,
+ debit=stock_value_diff,
+ credit=0.0,
+ remarks=remarks,
+ against_account=stock_asset_rbnb,
+ account_currency=account_currency,
+ item=item,
+ )
+
+ def make_stock_received_but_not_billed_entry(item):
+ if (
+ doc.get("is_return")
+ and item.return_qty_from_rejected_warehouse
+ and not frappe.db.get_single_value(
+ "Buying Settings", "set_valuation_rate_for_rejected_materials"
+ )
+ ):
+ return 0.0
+
+ account = stock_asset_rbnb
+ if item.from_warehouse:
+ _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "from_warehouse")
+ account = _inv_dict["account"]
+
+ account_currency = get_account_currency(account)
+
+ credit_amount = (
+ flt(item.base_net_amount, item.precision("base_net_amount"))
+ if account_currency == doc.company_currency
+ else flt(item.net_amount, item.precision("net_amount"))
+ )
+
+ outgoing_amount = item.base_net_amount
+ if doc.is_internal_transfer() and item.valuation_rate:
+ outgoing_amount = abs(get_stock_value_difference(doc.name, item.name, item.from_warehouse))
+ credit_amount = outgoing_amount
+
+ if item.get("rejected_qty") and frappe.db.get_single_value(
+ "Buying Settings", "set_valuation_rate_for_rejected_materials"
+ ):
+ outgoing_amount += get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
+ credit_amount = outgoing_amount
+
+ if credit_amount:
+ if not account:
+ validate_account("Stock or Asset Received But Not Billed")
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=account,
+ cost_center=item.cost_center,
+ debit=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
+ credit=0.0,
+ remarks=remarks,
+ against_account=stock_asset_account_name,
+ debit_in_account_currency=-1 * flt(outgoing_amount, item.precision("base_net_amount")),
+ account_currency=account_currency,
+ item=item,
+ )
+
+ if d.get("purchase_invoice"):
+ if (
+ exchange_rate_map[item.purchase_invoice]
+ and doc.conversion_rate != exchange_rate_map[item.purchase_invoice]
+ and item.net_rate == net_rate_map[item.purchase_invoice_item]
+ ):
+ discrepancy_caused_by_exchange_rate_difference = (item.qty * item.net_rate) * (
+ exchange_rate_map[item.purchase_invoice] - doc.conversion_rate
+ )
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=discrepancy_caused_by_exchange_rate_difference,
+ remarks=remarks,
+ against_account=doc.supplier,
+ debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
+ account_currency=account_currency,
+ item=item,
+ )
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=doc.get_company_default("exchange_gain_loss_account"),
+ cost_center=d.cost_center,
+ debit=discrepancy_caused_by_exchange_rate_difference,
+ credit=0.0,
+ remarks=remarks,
+ against_account=doc.supplier,
+ debit_in_account_currency=-1 * discrepancy_caused_by_exchange_rate_difference,
+ account_currency=account_currency,
+ item=item,
+ )
+
+ return outgoing_amount
+
+ def make_landed_cost_gl_entries(item):
+ if item.landed_cost_voucher_amount and landed_cost_entries:
+ if (item.item_code, item.name) in landed_cost_entries:
+ for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
+ account_currency = get_account_currency(account)
+ credit_amount = (
+ flt(amount["base_amount"])
+ if (amount["base_amount"] or account_currency != doc.company_currency)
+ else flt(amount["amount"])
+ )
+
+ if not account:
+ validate_account("Landed Cost Account")
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=credit_amount,
+ remarks=remarks,
+ against_account=stock_asset_account_name,
+ credit_in_account_currency=flt(amount["amount"]),
+ account_currency=account_currency,
+ project=item.project,
+ item=item,
+ )
+
+ def make_amount_difference_entry(item):
+ if item.amount_difference_with_purchase_invoice and stock_asset_rbnb:
+ account_currency = get_account_currency(stock_asset_rbnb)
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=stock_asset_rbnb,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=flt(item.amount_difference_with_purchase_invoice),
+ remarks=_("Adjustment based on Purchase Invoice rate"),
+ against_account=stock_asset_account_name,
+ account_currency=account_currency,
+ project=item.project,
+ item=item,
+ )
+
+ def make_sub_contracting_gl_entries(item):
+ if flt(item.rm_supp_cost) and supplier_warehouse_account:
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=supplier_warehouse_account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=flt(item.rm_supp_cost),
+ remarks=remarks,
+ against_account=stock_asset_account_name,
+ account_currency=supplier_warehouse_account_currency,
+ item=item,
+ )
+
+ def make_divisional_loss_gl_entry(item, outgoing_amount):
+ if item.is_fixed_asset:
+ return
+
+ valuation_amount_as_per_doc = (
+ flt(outgoing_amount, d.precision("base_net_amount"))
+ + flt(item.landed_cost_voucher_amount)
+ + flt(item.rm_supp_cost)
+ + flt(item.item_tax_amount)
+ + flt(item.amount_difference_with_purchase_invoice)
+ )
+
+ divisional_loss = flt(
+ valuation_amount_as_per_doc - flt(stock_value_diff), item.precision("base_net_amount")
+ )
+
+ if item.get("rejected_qty") and frappe.db.get_single_value(
+ "Buying Settings", "set_valuation_rate_for_rejected_materials"
+ ):
+ rejected_item_cost = get_stock_value_difference(doc.name, item.name, item.rejected_warehouse)
+ divisional_loss -= rejected_item_cost
+
+ if divisional_loss:
+ loss_account = (
+ doc.get_company_default("default_expense_account", ignore_validation=True)
+ or stock_asset_rbnb
+ )
+
+ if doc.is_return and item.expense_account:
+ loss_account = item.expense_account
+
+ cost_center = item.cost_center or frappe.get_cached_value(
+ "Company", doc.company, "cost_center"
+ )
+ account_currency = get_account_currency(loss_account)
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=loss_account,
+ cost_center=cost_center,
+ debit=divisional_loss,
+ credit=0.0,
+ remarks=remarks,
+ against_account=stock_asset_account_name,
+ account_currency=account_currency,
+ project=item.project,
+ item=item,
+ )
+
+ for d in doc.get("items"):
+ remarks = doc.get("remarks") or _("Accounting Entry for {0}").format(
+ "Asset" if d.is_fixed_asset else "Stock"
+ )
+
+ if (
+ provisional_accounting_for_non_stock_items
+ and d.item_code not in stock_items
+ and flt(d.qty)
+ and d.get("provisional_expense_account")
+ and not d.is_fixed_asset
+ ):
+ doc.add_provisional_gl_entry(
+ d, gl_entries, doc.posting_date, d.get("provisional_expense_account")
+ )
+ elif flt(d.qty) and (flt(d.valuation_rate) or doc.is_return):
+ if not (
+ (erpnext.is_perpetual_inventory_enabled(doc.company) and d.item_code in stock_items)
+ or (d.is_fixed_asset and not d.purchase_invoice)
+ ):
+ continue
+
+ stock_asset_rbnb = (
+ doc.get_company_default("asset_received_but_not_billed")
+ if d.is_fixed_asset
+ else doc.get_company_default("stock_received_but_not_billed")
+ )
+ landed_cost_entries = doc.get_item_account_wise_lcv_entries()
+ if d.is_fixed_asset:
+ stock_asset_account_name = d.expense_account
+ stock_value_diff = (
+ flt(d.base_net_amount) + flt(d.item_tax_amount) + flt(d.landed_cost_voucher_amount)
+ )
+ elif inventory_account := doc.get_inventory_account_dict(d, inventory_account_map):
+ stock_value_diff = get_stock_value_difference(doc.name, d.name, d.warehouse)
+ stock_asset_account_name = inventory_account["account"]
+
+ supplier_warehouse_account = None
+ supplier_warehouse_account_currency = None
+ if doc.supplier_warehouse:
+ if _inv_dict := doc.get_inventory_account_dict(
+ d, inventory_account_map, "supplier_warehouse"
+ ):
+ supplier_warehouse_account = _inv_dict["account"]
+ supplier_warehouse_account_currency = _inv_dict["account_currency"]
+
+ if (
+ flt(stock_value_diff) == flt(d.rm_supp_cost)
+ and supplier_warehouse_account
+ and stock_asset_account_name == supplier_warehouse_account
+ ):
+ continue
+
+ if (flt(d.valuation_rate) or doc.is_return or d.is_fixed_asset) and flt(d.qty):
+ make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
+ outgoing_amount = make_stock_received_but_not_billed_entry(d)
+ make_landed_cost_gl_entries(d)
+ make_amount_difference_entry(d)
+ make_sub_contracting_gl_entries(d)
+ make_divisional_loss_gl_entry(d, outgoing_amount)
+ elif (d.warehouse and d.qty and d.warehouse not in warehouse_with_no_account) or (
+ not frappe.db.get_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials")
+ and d.rejected_warehouse
+ and d.rejected_warehouse not in warehouse_with_no_account
+ ):
+ warehouse_with_no_account.append(d.warehouse or d.rejected_warehouse)
+
+ if d.is_fixed_asset and d.landed_cost_voucher_amount:
+ doc.update_assets(d, d.valuation_rate)
+
+ if d.rejected_qty and frappe.db.get_single_value(
+ "Buying Settings", "set_valuation_rate_for_rejected_materials"
+ ):
+ stock_asset_rbnb = (
+ doc.get_company_default("asset_received_but_not_billed")
+ if d.is_fixed_asset
+ else doc.get_company_default("stock_received_but_not_billed")
+ )
+
+ stock_value_diff = get_stock_value_difference(doc.name, d.name, d.rejected_warehouse)
+ _inv_dict = doc.get_inventory_account_dict(d, inventory_account_map, "rejected_warehouse")
+ stock_asset_account_name = _inv_dict["account"]
+
+ make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
+ if not d.qty:
+ make_stock_received_but_not_billed_entry(d)
+
+ if warehouse_with_no_account:
+ frappe.msgprint(
+ _("No accounting entries for the following warehouses")
+ + ": \n"
+ + "\n".join(warehouse_with_no_account)
+ )
+
+ def _make_tax_gl_entries(self, gl_entries: list, via_landed_cost_voucher: bool = False) -> None:
+ doc = self.doc
+ negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in doc.get("items")])
+ valuation_tax = {}
+ for tax in doc.get("taxes"):
+ if tax.category in ("Valuation", "Valuation and Total") and flt(
+ tax.base_tax_amount_after_discount_amount
+ ):
+ if not tax.cost_center:
+ frappe.throw(
+ _("Cost Center is required in row {0} in Taxes table for type {1}").format(
+ tax.idx, _(tax.category)
+ )
+ )
+ valuation_tax.setdefault(tax.name, 0)
+ valuation_tax[tax.name] += (tax.add_deduct_tax == "Add" and 1 or -1) * flt(
+ tax.base_tax_amount_after_discount_amount
+ )
+
+ if negative_expense_to_be_booked and valuation_tax:
+ against_accounts = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0])
+ total_valuation_amount = sum(valuation_tax.values())
+ amount_including_divisional_loss = negative_expense_to_be_booked
+ i = 1
+ for tax in doc.get("taxes"):
+ if valuation_tax.get(tax.name):
+ account = tax.account_head
+ if i == len(valuation_tax):
+ applicable_amount = amount_including_divisional_loss
+ else:
+ applicable_amount = negative_expense_to_be_booked * (
+ valuation_tax[tax.name] / total_valuation_amount
+ )
+ amount_including_divisional_loss -= applicable_amount
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=account,
+ cost_center=tax.cost_center,
+ debit=0.0,
+ credit=applicable_amount,
+ remarks=doc.remarks or _("Accounting Entry for Stock"),
+ against_account=against_accounts,
+ item=tax,
+ )
+
+ i += 1
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 6cc1bbc7c86..5f54cab0d2f 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -14,8 +14,8 @@ from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.doctype.item.test_item import create_item, make_item
-from erpnext.stock.doctype.material_request.material_request import make_purchase_order
-from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
+from erpnext.stock.doctype.material_request.mapper import make_purchase_order
+from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
SerialNoDuplicateError,
SerialNoExistsInFutureTransactionError,
@@ -707,10 +707,10 @@ class TestPurchaseReceipt(ERPNextTestSuite):
2. PO -> PI
3. PO -> PR2.
"""
- from erpnext.buying.doctype.purchase_order.purchase_order import (
+ from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_invoice as make_purchase_invoice_from_po,
)
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
frappe.flags.print_test_messages = False
@@ -861,7 +861,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
pr.cancel()
def test_purchase_return_with_submitted_asset(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return
+ from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
pr = make_purchase_receipt(item_code="Test Asset Item", qty=1)
@@ -1010,7 +1010,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
pr1.cancel()
def test_stock_transfer_from_purchase_receipt(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
@@ -1055,7 +1055,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"""
Party-derived fields on DN (from Customer) must not leak into the mapped PR.
"""
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
@@ -1086,7 +1086,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(pr.language or None, supplier.language or None)
def test_lcv_for_internal_transfer(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
make_landed_cost_voucher,
@@ -1182,7 +1182,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(new_inward_sabb[0], inward_sabb[0])
def test_stock_transfer_from_purchase_receipt_with_valuation(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
@@ -1339,7 +1339,8 @@ class TestPurchaseReceipt(ERPNextTestSuite):
- Create PI from PO and submit
- Create PR from PO and submit
"""
- from erpnext.buying.doctype.purchase_order import purchase_order, test_purchase_order
+ from erpnext.buying.doctype.purchase_order import mapper as purchase_order
+ from erpnext.buying.doctype.purchase_order import test_purchase_order
po = test_purchase_order.create_purchase_order()
@@ -1360,7 +1361,8 @@ class TestPurchaseReceipt(ERPNextTestSuite):
- Create partial PI from PO and submit
- Create PR from PO and submit
"""
- from erpnext.buying.doctype.purchase_order import purchase_order, test_purchase_order
+ from erpnext.buying.doctype.purchase_order import mapper as purchase_order
+ from erpnext.buying.doctype.purchase_order import test_purchase_order
po = test_purchase_order.create_purchase_order()
@@ -1382,7 +1384,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertAlmostEqual(pr.per_billed, 50.0, places=2)
def test_purchase_receipt_with_exchange_rate_difference(self):
- from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
+ from erpnext.accounts.doctype.purchase_invoice.mapper import (
make_purchase_receipt as create_purchase_receipt,
)
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
@@ -1489,7 +1491,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(gle.credit, 50)
def test_backdated_transaction_for_internal_transfer(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
@@ -1577,7 +1579,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
def test_backdated_transaction_for_internal_transfer_in_trasit_warehouse_for_purchase_receipt(
self,
):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
@@ -1687,7 +1689,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import (
make_purchase_invoice as make_purchase_invoice_for_si,
)
- from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
+ from erpnext.accounts.doctype.sales_invoice.mapper import (
make_inter_company_purchase_invoice,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -1915,7 +1917,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
)
# Step 4: Create Internal Purchase Receipt
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
pr = make_inter_company_purchase_receipt(dn.name)
pr.set_posting_time = 1
@@ -1947,7 +1949,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
def test_internal_pr_gl_entries(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
@@ -2063,7 +2065,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
)
# Step 4: Create Internal Purchase Receipt
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
pr = make_inter_company_purchase_receipt(dn.name)
pr.inter_company_reference = ""
@@ -2113,7 +2115,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
)
# Step 3: Create Purchase Return for 2 qty
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return
+ from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
pr_return = make_purchase_return(pr.name)
pr_return.items[0].qty = 2 * -1
@@ -2132,7 +2134,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(abs(data["stock_value_difference"]), 400.00)
def test_return_from_rejected_warehouse(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return_against_rejected_warehouse,
)
@@ -2712,8 +2714,8 @@ class TestPurchaseReceipt(ERPNextTestSuite):
)
def test_pr_billed_amount_against_return_entry(self):
- from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import make_debit_note
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.accounts.doctype.purchase_invoice.mapper import make_debit_note
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_invoice as make_pi_from_pr,
)
@@ -2879,7 +2881,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
def test_internal_transfer_with_serial_batch_items_and_their_valuation(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
@@ -3016,7 +3018,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
def test_internal_transfer_with_serial_batch_items_without_use_serial_batch_fields(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0)
@@ -3234,7 +3236,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(row.incoming_rate, 0)
def test_purchase_return_from_accepted_and_rejected_warehouse(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return,
)
@@ -3312,7 +3314,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(batch.expiry_date, getdate(add_days(today(), 5)))
def test_purchase_return_from_rejected_warehouse(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return_against_rejected_warehouse,
)
@@ -3352,7 +3354,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
create_purchase_order,
make_pr_against_po,
)
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
+ from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
stock_rbnb = "Stock Received But Not Billed - _TC"
stock_in_hand = "Stock In Hand - _TC"
@@ -3502,7 +3504,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
create_purchase_order,
make_pr_against_po,
)
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
+ from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
stock_rbnb = "Stock Received But Not Billed - _TC"
stock_in_hand = "Stock In Hand - _TC"
@@ -3671,7 +3673,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(pr.status, "Completed")
def test_internal_transfer_for_batch_items_with_cancel(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 0)
@@ -3786,7 +3788,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
def test_internal_transfer_for_batch_items_with_cancel_use_serial_batch_fields(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
@@ -3981,7 +3983,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(pr.items[0].conversion_factor, 1.0)
def test_purchase_receipt_return_valuation_without_use_serial_batch_field(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return
+ from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
batch_item = make_item(
"_Test Purchase Receipt Return Valuation Batch Item",
@@ -4085,7 +4087,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(incoming_rate, 0)
def test_purchase_receipt_return_valuation_with_use_serial_batch_field(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_return
+ from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_return
batch_item = make_item(
"_Test Purchase Receipt Return Valuation With Batch Item",
@@ -4294,7 +4296,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
frappe.db.set_single_value("Stock Settings", "allow_existing_serial_no", 1)
def test_seral_no_return_validation(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return,
)
@@ -4326,7 +4328,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
sn_return.submit()
def test_batch_no_return_validation(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return,
)
@@ -4359,10 +4361,10 @@ class TestPurchaseReceipt(ERPNextTestSuite):
batch_return.submit()
def test_pr_status_based_on_invoices_with_update_stock(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import (
+ from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_invoice as _make_purchase_invoice,
)
- from erpnext.buying.doctype.purchase_order.purchase_order import (
+ from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_receipt as _make_purchase_receipt,
)
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
@@ -4465,7 +4467,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, repost_doc.save)
def test_internal_pr_qty_change_only_single_batch(self):
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
@@ -4982,10 +4984,10 @@ class TestPurchaseReceipt(ERPNextTestSuite):
)
@ERPNextTestSuite.change_settings("Accounts Settings", {"over_billing_allowance": 100})
def test_set_lcv_from_pi_created_against_po(self):
- from erpnext.buying.doctype.purchase_order.purchase_order import (
+ from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_invoice as make_pi_against_po,
)
- from erpnext.buying.doctype.purchase_order.purchase_order import (
+ from erpnext.buying.doctype.purchase_order.mapper import (
make_purchase_receipt as make_pr_against_po,
)
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
@@ -5015,10 +5017,10 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertEqual(row.amount_difference_with_purchase_invoice, amt_diff)
def test_purchase_return_with_and_without_return_against_rejected_qty(self):
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return as _make_purchase_return,
)
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ from erpnext.stock.doctype.purchase_receipt.mapper import (
make_purchase_return_against_rejected_warehouse,
)
@@ -5257,7 +5259,7 @@ class TestPurchaseReceipt(ERPNextTestSuite):
"""
To test inter branch transaction incoming rate calculation with lcv after item reposting
"""
- from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt
+ from erpnext.stock.doctype.delivery_note.mapper import make_inter_company_purchase_receipt
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
prepare_data_for_internal_transfer()
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index 6a8d3cc7ffa..1828528866f 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -15,7 +15,7 @@ from frappe.utils.user import get_users_with_role
from rq.timeouts import JobTimeoutException
import erpnext
-from erpnext.accounts.general_ledger import validate_accounting_period
+from erpnext.accounts.services.gl_validator import validate_accounting_period
from erpnext.accounts.utils import get_future_stock_vouchers, repost_gle_for_stock_vouchers
from erpnext.stock.stock_ledger import (
get_affected_transactions,
diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py
index 0ee89f62ee8..045df06880f 100644
--- a/erpnext/stock/doctype/shipment/test_shipment.py
+++ b/erpnext/stock/doctype/shipment/test_shipment.py
@@ -5,7 +5,7 @@ from datetime import date, timedelta
import frappe
-from erpnext.stock.doctype.delivery_note.delivery_note import make_shipment
+from erpnext.stock.doctype.delivery_note.mapper import make_shipment
from erpnext.tests.utils import ERPNextTestSuite
diff --git a/erpnext/stock/doctype/stock_entry/services/__init__.py b/erpnext/stock/doctype/stock_entry/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/stock/doctype/stock_entry/services/gl_composer.py b/erpnext/stock/doctype/stock_entry/services/gl_composer.py
new file mode 100644
index 00000000000..4788346833f
--- /dev/null
+++ b/erpnext/stock/doctype/stock_entry/services/gl_composer.py
@@ -0,0 +1,157 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+from erpnext.accounts.general_ledger import process_gl_map
+from erpnext.accounts.utils import get_account_currency
+from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
+
+
+class StockEntryGLComposer(BaseStockGLComposer):
+ """GL composer for Stock Entry.
+
+ Extends the base stock GL loop with additional-cost entries (from the
+ ``additional_costs`` child table) and landed-cost voucher adjustments.
+ """
+
+ def compose(self, inventory_account_map: dict | None = None) -> list:
+ doc = self.doc
+ gl_entries = super().compose(inventory_account_map)
+
+ if doc.purpose in ("Repack", "Manufacture"):
+ total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item)
+ else:
+ total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse)
+
+ divide_based_on = total_basic_amount
+ if doc.get("additional_costs") and not total_basic_amount:
+ divide_based_on = sum(item.qty for item in doc.get("items"))
+
+ item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
+ total_basic_amount, divide_based_on
+ )
+ if item_account_wise_additional_cost:
+ self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
+
+ self._append_lcv_gl_entries(gl_entries, inventory_account_map)
+
+ return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
+
+ def _build_additional_cost_per_item_account(
+ self, total_basic_amount: float, divide_based_on: float
+ ) -> dict:
+ doc = self.doc
+ item_account_wise_additional_cost = {}
+
+ for t in doc.get("additional_costs"):
+ for d in doc.get("items"):
+ if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
+ continue
+ elif not d.t_warehouse:
+ continue
+
+ item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
+ item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
+ t.expense_account, {"amount": 0.0, "base_amount": 0.0}
+ )
+
+ multiply_based_on = d.basic_amount if total_basic_amount else d.qty
+ entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
+ entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
+ entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on
+
+ return item_account_wise_additional_cost
+
+ def _append_additional_cost_gl_entries(
+ self, gl_entries: list, item_account_wise_additional_cost: dict
+ ) -> None:
+ doc = self.doc
+ for d in doc.get("items"):
+ for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items():
+ if not amount:
+ continue
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": account,
+ "against": d.expense_account,
+ "cost_center": d.cost_center,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "credit_in_account_currency": flt(amount["amount"]),
+ "credit": flt(amount["base_amount"]),
+ },
+ item=d,
+ )
+ )
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": d.expense_account,
+ "against": account,
+ "cost_center": d.cost_center,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "credit": -1 * amount["base_amount"],
+ },
+ item=d,
+ )
+ )
+
+ def _append_lcv_gl_entries(self, gl_entries: list, inventory_account_map: dict) -> None:
+ doc = self.doc
+ landed_cost_entries = doc.get_item_account_wise_lcv_entries()
+ if not landed_cost_entries:
+ return
+
+ for item in doc.get("items"):
+ if item.s_warehouse:
+ continue
+
+ if (item.item_code, item.name) in landed_cost_entries:
+ for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
+ account_currency = get_account_currency(account)
+ credit_amount = (
+ flt(amount["base_amount"])
+ if (amount["base_amount"] or account_currency != doc.company_currency)
+ else flt(amount["amount"])
+ )
+
+ _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map, "t_warehouse")
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": account,
+ "against": _inv_dict["account"],
+ "cost_center": item.cost_center,
+ "debit": 0.0,
+ "credit": credit_amount,
+ "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name),
+ "credit_in_account_currency": flt(amount["amount"]),
+ "account_currency": account_currency,
+ "project": item.project,
+ },
+ item=item,
+ )
+ )
+
+ account_currency = get_account_currency(item.expense_account)
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": item.expense_account,
+ "against": _inv_dict["account"],
+ "cost_center": item.cost_center,
+ "debit": 0.0,
+ "credit": credit_amount * -1,
+ "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(doc.name),
+ "debit_in_account_currency": flt(amount["amount"]),
+ "account_currency": account_currency,
+ "project": item.project,
+ },
+ item=item,
+ )
+ )
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index 3e5a8a10a8d..e8c341a3c3b 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -384,7 +384,7 @@ frappe.ui.form.on("Stock Entry", {
async (data) => {
if (frm.doc.work_order) {
let stock_entry = await frappe.xcall(
- "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry",
+ "erpnext.manufacturing.doctype.work_order.mapper.make_stock_entry",
{
work_order_id: frm.doc.work_order,
purpose: "Disassemble",
@@ -424,7 +424,7 @@ frappe.ui.form.on("Stock Entry", {
__("Purchase Invoice"),
function () {
erpnext.utils.map_current_doc({
- method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.make_stock_entry",
+ method: "erpnext.accounts.doctype.purchase_invoice.mapper.make_stock_entry",
source_doctype: "Purchase Invoice",
target: frm,
date_field: "posting_date",
@@ -449,7 +449,7 @@ frappe.ui.form.on("Stock Entry", {
];
const depends_on_condition = "eval:doc.material_request_type==='Customer Provided'";
const d = erpnext.utils.map_current_doc({
- method: "erpnext.stock.doctype.material_request.material_request.make_stock_entry",
+ method: "erpnext.stock.doctype.material_request.mapper.make_stock_entry",
source_doctype: "Material Request",
target: frm,
date_field: "schedule_date",
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index e080365fe38..6f6bac58b0e 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -19,8 +19,6 @@ from frappe.utils import (
)
import erpnext
-from erpnext.accounts.general_ledger import process_gl_map
-from erpnext.accounts.utils import get_account_currency
from erpnext.buying.utils import check_on_hold_or_closed_status
from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.manufacturing.doctype.bom.bom import (
@@ -1035,137 +1033,9 @@ class StockEntry(StockController, SubcontractingInwardController):
sl_entries.append(sle)
def get_gl_entries(self, inventory_account_map):
- gl_entries = super().get_gl_entries(inventory_account_map)
+ from erpnext.stock.doctype.stock_entry.services.gl_composer import StockEntryGLComposer
- if self.purpose in ("Repack", "Manufacture"):
- total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item)
- else:
- total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse)
-
- divide_based_on = total_basic_amount
- if self.get("additional_costs") and not total_basic_amount:
- divide_based_on = sum(item.qty for item in self.get("items"))
-
- item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
- total_basic_amount, divide_based_on
- )
-
- if item_account_wise_additional_cost:
- self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
-
- self.set_gl_entries_for_landed_cost_voucher(gl_entries, inventory_account_map)
- return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
-
- def _build_additional_cost_per_item_account(self, total_basic_amount, divide_based_on):
- item_account_wise_additional_cost = {}
-
- for t in self.get("additional_costs"):
- for d in self.get("items"):
- if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
- continue
- elif not d.t_warehouse:
- continue
-
- item_account_wise_additional_cost.setdefault((d.item_code, d.name), {})
- item_account_wise_additional_cost[(d.item_code, d.name)].setdefault(
- t.expense_account, {"amount": 0.0, "base_amount": 0.0}
- )
-
- multiply_based_on = d.basic_amount if total_basic_amount else d.qty
- entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
- entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
- entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on
-
- return item_account_wise_additional_cost
-
- def _append_additional_cost_gl_entries(self, gl_entries, item_account_wise_additional_cost):
- for d in self.get("items"):
- for account, amount in item_account_wise_additional_cost.get((d.item_code, d.name), {}).items():
- if not amount:
- continue
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": account,
- "against": d.expense_account,
- "cost_center": d.cost_center,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "credit_in_account_currency": flt(amount["amount"]),
- "credit": flt(amount["base_amount"]),
- },
- item=d,
- )
- )
-
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": d.expense_account,
- "against": account,
- "cost_center": d.cost_center,
- "remarks": self.get("remarks") or _("Accounting Entry for Stock"),
- "credit": -1 * amount["base_amount"], # negative credit instead of debit
- },
- item=d,
- )
- )
-
- def set_gl_entries_for_landed_cost_voucher(self, gl_entries, inventory_account_map):
- landed_cost_entries = self.get_item_account_wise_lcv_entries()
- if not landed_cost_entries:
- return
-
- for item in self.get("items"):
- if item.s_warehouse:
- continue
-
- if (item.item_code, item.name) in landed_cost_entries:
- for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
- account_currency = get_account_currency(account)
- credit_amount = (
- flt(amount["base_amount"])
- if (amount["base_amount"] or account_currency != self.company_currency)
- else flt(amount["amount"])
- )
-
- _inv_dict = self.get_inventory_account_dict(item, inventory_account_map, "t_warehouse")
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": account,
- "against": _inv_dict["account"],
- "cost_center": item.cost_center,
- "debit": 0.0,
- "credit": credit_amount,
- "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(self.name),
- "credit_in_account_currency": flt(amount["amount"]),
- "account_currency": account_currency,
- "project": item.project,
- },
- item=item,
- )
- )
-
- account_currency = get_account_currency(item.expense_account)
-
- # credit amount in negative to knock off the debit entry
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": item.expense_account,
- "against": _inv_dict["account"],
- "cost_center": item.cost_center,
- "debit": 0.0,
- "credit": credit_amount * -1,
- "remarks": _("Accounting Entry for LCV in Stock Entry {0}").format(self.name),
- "debit_in_account_currency": flt(amount["amount"]),
- "account_currency": account_currency,
- "project": item.project,
- },
- item=item,
- )
- )
+ return StockEntryGLComposer(self).compose(inventory_account_map)
@property
def pro_doc(self):
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index e42641cdfa8..f8a5d6bda32 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -13,7 +13,7 @@ from erpnext.stock.doctype.item.test_item import (
make_item_variant,
set_item_variant_settings,
)
-from erpnext.stock.doctype.material_request.material_request import (
+from erpnext.stock.doctype.material_request.mapper import (
make_in_transit_stock_entry,
)
from erpnext.stock.doctype.material_request.test_material_request import (
@@ -830,7 +830,7 @@ class TestStockEntry(ERPNextTestSuite):
frappe.db.set_single_value("Stock Settings", "stock_frozen_upto_days", 0)
def test_work_order(self):
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
@@ -868,7 +868,7 @@ class TestStockEntry(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
def test_work_order_manufacture_with_material_consumption(self):
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
@@ -949,7 +949,7 @@ class TestStockEntry(ERPNextTestSuite):
work_order.insert()
work_order.submit()
- from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry
+ from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry
stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
stock_entry.insert()
@@ -992,7 +992,7 @@ class TestStockEntry(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, ste.submit)
def test_quality_check_for_secondary_item(self):
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
@@ -1441,7 +1441,7 @@ class TestStockEntry(ERPNextTestSuite):
def test_mapped_stock_entry(self):
"Check if rate and stock details are populated in mapped SE given warehouse."
- from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_stock_entry
+ from erpnext.stock.doctype.purchase_receipt.mapper import make_stock_entry
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
item_code = "_TestMappedItem"
@@ -2405,7 +2405,7 @@ class TestStockEntry(ERPNextTestSuite):
)
def test_validation_as_per_bom_with_continuous_raw_material_consumption(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
- from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as _make_stock_entry
+ from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as _make_stock_entry
from erpnext.manufacturing.doctype.work_order.work_order import make_work_order
fg_item = make_item("_Mobiles", properties={"is_stock_item": 1}).name
@@ -2796,7 +2796,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
def test_get_available_materials_tracks_transferred_qty(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import (
@@ -2841,7 +2841,7 @@ class TestStockEntryCoverage(ERPNextTestSuite):
def test_get_available_materials_reduces_qty_after_consumption(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
from erpnext.stock.doctype.stock_entry.stock_entry_handler.disassemble import (
@@ -2890,10 +2890,10 @@ class TestStockEntryCoverage(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"})
def test_validate_fg_resets_invalid_serial_no_on_manufacture(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
- from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
fg_item = "_FG Serial No Item"
rm_item = "RM for serial item"
@@ -2930,10 +2930,10 @@ class TestStockEntryCoverage(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Global Defaults", {"default_company": "_Test Company"})
def test_validate_fg_resets_invalid_batch_no_on_manufacture(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
- from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
- from erpnext.manufacturing.doctype.work_order.work_order import (
+ from erpnext.manufacturing.doctype.work_order.mapper import (
make_stock_entry as _make_stock_entry,
)
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
fg_item = "_FG Batch No Item"
diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
index a92e6401be4..f63b7de01c2 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
@@ -1494,7 +1494,7 @@ def create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_l
def create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list):
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
dns = []
diff --git a/erpnext/stock/doctype/stock_reconciliation/services/__init__.py b/erpnext/stock/doctype/stock_reconciliation/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py
new file mode 100644
index 00000000000..0ca408729f0
--- /dev/null
+++ b/erpnext/stock/doctype/stock_reconciliation/services/gl_composer.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+from frappe import _, msgprint
+
+from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
+
+
+class StockReconciliationGLComposer(BaseStockGLComposer):
+ """GL composer for Stock Reconciliation.
+
+ SR carries its own expense_account and cost_center which are passed as
+ defaults into the base stock GL composition loop.
+ """
+
+ def compose(self, inventory_account_map: dict | None = None) -> list:
+ doc = self.doc
+ if not doc.cost_center:
+ msgprint(_("Please enter Cost Center"), raise_exception=1)
+ return super().compose(inventory_account_map, doc.expense_account, doc.cost_center)
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index f21f4da7174..388ab6793b8 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -975,10 +975,11 @@ class StockReconciliation(StockController):
return new_sl_entries
def get_gl_entries(self, inventory_account_map=None):
- if not self.cost_center:
- msgprint(_("Please enter Cost Center"), raise_exception=1)
+ from erpnext.stock.doctype.stock_reconciliation.services.gl_composer import (
+ StockReconciliationGLComposer,
+ )
- return super().get_gl_entries(inventory_account_map, self.expense_account, self.cost_center)
+ return StockReconciliationGLComposer(self).compose(inventory_account_map)
def validate_expense_account(self):
if not cint(erpnext.is_perpetual_inventory_enabled(self.company)):
diff --git a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
index d11f33992ea..cc125ea3b8e 100644
--- a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
+++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py
@@ -6,7 +6,7 @@ from random import randint
import frappe
from frappe.utils import today
-from erpnext.selling.doctype.sales_order.sales_order import create_pick_list, make_delivery_note
+from erpnext.selling.doctype.sales_order.mapper import create_pick_list, make_delivery_note
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry
@@ -525,9 +525,9 @@ class TestStockReservationEntry(ERPNextTestSuite):
},
)
def test_stock_reservation_from_purchase_receipt(self) -> None:
- from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_receipt
- from erpnext.selling.doctype.sales_order.sales_order import make_material_request
- from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+ from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
+ from erpnext.selling.doctype.sales_order.mapper import make_material_request
+ from erpnext.stock.doctype.material_request.mapper import make_purchase_order
items_details = create_items()
create_material_receipt(items_details, self.warehouse, qty=10)
diff --git a/erpnext/stock/services/__init__.py b/erpnext/stock/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/stock/services/base_stock_gl_composer.py b/erpnext/stock/services/base_stock_gl_composer.py
new file mode 100644
index 00000000000..89837db9909
--- /dev/null
+++ b/erpnext/stock/services/base_stock_gl_composer.py
@@ -0,0 +1,154 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+from erpnext.accounts.general_ledger import process_gl_map
+from erpnext.accounts.services.base_gl_composer import BaseGLComposer
+
+
+class BaseStockGLComposer(BaseGLComposer):
+ """Shared GL composition logic for stock vouchers.
+
+ Subclasses override ``compose()`` and call ``super().compose()`` to get the
+ warehouse ↔ expense-account GL pairs, then append any doctype-specific
+ entries on top.
+ """
+
+ def compose(
+ self,
+ inventory_account_map: dict | None = None,
+ default_expense_account: str | None = None,
+ default_cost_center: str | None = None,
+ ) -> list:
+ doc = self.doc
+
+ if not inventory_account_map:
+ inventory_account_map = doc.get_inventory_account_map()
+
+ sle_map = doc.get_stock_ledger_details()
+ voucher_details = doc.get_voucher_details(default_expense_account, default_cost_center, sle_map)
+
+ gl_list = []
+ warehouse_with_no_account = []
+ precision = doc.get_debit_field_precision()
+
+ for item_row in voucher_details:
+ sle_list = sle_map.get(item_row.name)
+ sle_rounding_diff = 0.0
+ if sle_list:
+ for sle in sle_list:
+ _inv_dict = doc.get_inventory_account_dict(sle, inventory_account_map)
+
+ if _inv_dict.get("account"):
+ sle_rounding_diff += flt(sle.stock_value_difference)
+
+ doc.check_expense_account(item_row)
+
+ if item_row.get("target_warehouse"):
+ _target_wh_inv_dict = doc.get_inventory_account_dict(
+ item_row, inventory_account_map, warehouse_field="target_warehouse"
+ )
+ expense_account = _target_wh_inv_dict["account"]
+ else:
+ expense_account = item_row.expense_account
+
+ gl_list.append(
+ self.get_gl_dict(
+ {
+ "account": _inv_dict["account"],
+ "against": expense_account,
+ "cost_center": item_row.cost_center,
+ "project": sle.get("project") or item_row.project or doc.get("project"),
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "debit": flt(sle.stock_value_difference, precision),
+ "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No",
+ },
+ _inv_dict["account_currency"],
+ item=item_row,
+ )
+ )
+
+ gl_list.append(
+ self.get_gl_dict(
+ {
+ "account": expense_account,
+ "against": _inv_dict["account"],
+ "cost_center": item_row.cost_center,
+ "remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
+ "debit": -1 * flt(sle.stock_value_difference, precision),
+ "project": sle.get("project")
+ or item_row.get("project")
+ or doc.get("project"),
+ "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No",
+ },
+ item=item_row,
+ )
+ )
+ elif sle.warehouse not in warehouse_with_no_account:
+ warehouse_with_no_account.append(sle.warehouse)
+
+ if abs(sle_rounding_diff) > (1.0 / (10**precision)) and doc.is_internal_transfer():
+ warehouse_asset_account = ""
+ if doc.get("is_internal_customer"):
+ _inv_dict = doc.get_inventory_account_dict(
+ item_row, inventory_account_map, warehouse_field="target_warehouse"
+ )
+ warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None
+ elif doc.get("is_internal_supplier"):
+ _inv_dict = doc.get_inventory_account_dict(item_row, inventory_account_map)
+ warehouse_asset_account = _inv_dict.get("account") if _inv_dict else None
+
+ expense_account = frappe.get_cached_value("Company", doc.company, "default_expense_account")
+ if not expense_account:
+ frappe.throw(
+ _(
+ "Please set default cost of goods sold account in company {0} for booking rounding gain and loss during stock transfer"
+ ).format(frappe.bold(doc.company))
+ )
+
+ gl_list.append(
+ self.get_gl_dict(
+ {
+ "account": expense_account,
+ "against": warehouse_asset_account,
+ "cost_center": item_row.cost_center,
+ "project": item_row.project or doc.get("project"),
+ "remarks": _("Rounding gain/loss Entry for Stock Transfer"),
+ "debit": sle_rounding_diff,
+ "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No",
+ },
+ _inv_dict["account_currency"],
+ item=item_row,
+ )
+ )
+
+ gl_list.append(
+ self.get_gl_dict(
+ {
+ "account": warehouse_asset_account,
+ "against": expense_account,
+ "cost_center": item_row.cost_center,
+ "remarks": _("Rounding gain/loss Entry for Stock Transfer"),
+ "credit": sle_rounding_diff,
+ "project": item_row.get("project") or doc.get("project"),
+ "is_opening": item_row.get("is_opening") or doc.get("is_opening") or "No",
+ },
+ item=item_row,
+ )
+ )
+
+ if warehouse_with_no_account:
+ for wh in warehouse_with_no_account:
+ if frappe.get_cached_value("Warehouse", wh, "company"):
+ frappe.throw(
+ _(
+ "Warehouse {0} is not linked to any account, please mention the account in the warehouse record or set default inventory account in company {1}."
+ ).format(wh, doc.company)
+ )
+
+ return process_gl_map(
+ gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation
+ )
diff --git a/erpnext/stock/tests/test_get_item_details.py b/erpnext/stock/tests/test_get_item_details.py
index 7eadf125d0e..c1026eb9b65 100644
--- a/erpnext/stock/tests/test_get_item_details.py
+++ b/erpnext/stock/tests/test_get_item_details.py
@@ -78,7 +78,7 @@ class TestGetItemDetail(ERPNextTestSuite):
so = make_sales_order(item_code=item.item_code, qty=2, rate=75)
- from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.mapper import make_delivery_note
dn = make_delivery_note(so.name)
diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js
index 20481791e38..eb3938430f0 100644
--- a/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js
+++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/subcontracting_inward_order.js
@@ -99,7 +99,7 @@ frappe.ui.form.on("Subcontracting Inward Order", {
if (frm.doc.sales_order) {
erpnext.utils.map_current_doc({
- method: "erpnext.selling.doctype.sales_order.sales_order.make_subcontracting_inward_order",
+ method: "erpnext.selling.doctype.sales_order.mapper.make_subcontracting_inward_order",
source_name: frm.doc.sales_order,
target_doc: frm,
freeze: true,
diff --git a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py
index a8aeca2f423..dc60423074c 100644
--- a/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py
+++ b/erpnext/subcontracting/doctype/subcontracting_inward_order/test_subcontracting_inward_order.py
@@ -3,8 +3,8 @@
import frappe
-from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_stock_entry_from_wo
-from erpnext.selling.doctype.sales_order.sales_order import make_subcontracting_inward_order
+from erpnext.manufacturing.doctype.work_order.mapper import make_stock_entry as make_stock_entry_from_wo
+from erpnext.selling.doctype.sales_order.mapper import make_subcontracting_inward_order
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -368,7 +368,7 @@ class IntegrationTestSubcontractingInwardOrder(ERPNextTestSuite):
frappe.new_doc("Stock Entry").update(scio.make_subcontracting_delivery()).submit()
scio.reload()
- from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+ from erpnext.selling.doctype.sales_order.mapper import make_sales_invoice
si = make_sales_invoice(so.name)
self.assertEqual(si.items[-1].item_code, "Self RM")
diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js
index 3e0d15cf553..5f319d9dde9 100644
--- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js
+++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.js
@@ -145,7 +145,7 @@ frappe.ui.form.on("Subcontracting Order", {
if (frm.doc.purchase_order) {
erpnext.utils.map_current_doc({
- method: "erpnext.buying.doctype.purchase_order.purchase_order.make_subcontracting_order",
+ method: "erpnext.buying.doctype.purchase_order.mapper.make_subcontracting_order",
source_name: frm.doc.purchase_order,
target_doc: frm,
freeze: true,
diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
index 68af5555d28..9b6185d6f14 100644
--- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
+++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py
@@ -7,7 +7,7 @@ from collections import defaultdict
import frappe
from frappe.utils import flt
-from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_subcontracting_order
+from erpnext.buying.doctype.purchase_order.mapper import get_mapped_subcontracting_order
from erpnext.controllers.subcontracting_controller import (
get_materials_from_supplier,
make_rm_stock_entry,
@@ -629,7 +629,7 @@ class TestSubcontractingOrder(ERPNextTestSuite):
self.assertEqual(ordered_qty + 10, new_ordered_qty)
def test_requested_qty_for_subcontracting_order(self):
- from erpnext.stock.doctype.material_request.material_request import make_purchase_order
+ from erpnext.stock.doctype.material_request.mapper import make_purchase_order
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
requested_qty = frappe.db.get_value(
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py b/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py
new file mode 100644
index 00000000000..4927d6723c0
--- /dev/null
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/mapper.py
@@ -0,0 +1,168 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe import _
+from frappe.model.document import Document
+from frappe.model.mapper import get_mapped_doc
+from frappe.utils import flt, get_link_to_form
+
+
+@frappe.whitelist()
+def make_subcontract_return_against_rejected_warehouse(source_name: str):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True)
+
+
+@frappe.whitelist()
+def make_subcontract_return(source_name: str, target_doc: Document | str | None = None):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+
+ return make_return_doc("Subcontracting Receipt", source_name, target_doc)
+
+
+@frappe.whitelist()
+def make_purchase_receipt(
+ source_name: Document | str,
+ target_doc: Document | str | None = None,
+ save: bool = False,
+ submit: bool = False,
+ notify: bool = False,
+):
+ if isinstance(source_name, str):
+ source_doc = frappe.get_doc("Subcontracting Receipt", source_name)
+ else:
+ source_doc = source_name
+
+ if source_doc.is_return:
+ return
+
+ po_sr_item_dict = {}
+ po_name = None
+ for item in source_doc.items:
+ if not item.purchase_order:
+ continue
+
+ if not po_name:
+ po_name = item.purchase_order
+
+ po_sr_item_dict[item.purchase_order_item] = {
+ "qty": flt(item.qty),
+ "rejected_qty": flt(item.rejected_qty),
+ "warehouse": item.warehouse,
+ "rejected_warehouse": item.rejected_warehouse,
+ "subcontracting_receipt_item": item.name,
+ }
+
+ if not po_name:
+ frappe.throw(
+ _("Purchase Order Item reference is missing in Subcontracting Receipt {0}").format(
+ source_doc.name
+ )
+ )
+
+ def update_item(obj, target, source_parent):
+ sr_item_details = po_sr_item_dict.get(obj.name)
+ ratio = flt(obj.qty) / flt(obj.fg_item_qty)
+
+ target.update(
+ {
+ "qty": ratio * sr_item_details["qty"],
+ "rejected_qty": ratio * sr_item_details["rejected_qty"],
+ "warehouse": sr_item_details["warehouse"],
+ "rejected_warehouse": sr_item_details["rejected_warehouse"],
+ "subcontracting_receipt_item": sr_item_details["subcontracting_receipt_item"],
+ }
+ )
+
+ def post_process(source, target):
+ target.set_missing_values()
+ target.update(
+ {
+ "posting_date": source_doc.posting_date,
+ "posting_time": source_doc.posting_time,
+ "subcontracting_receipt": source_doc.name,
+ "supplier_warehouse": source_doc.supplier_warehouse,
+ "is_subcontracted": 1,
+ "currency": frappe.get_cached_value("Company", target.company, "default_currency"),
+ }
+ )
+
+ target_doc = get_mapped_doc(
+ "Purchase Order",
+ po_name,
+ {
+ "Purchase Order": {
+ "doctype": "Purchase Receipt",
+ "field_map": {"supplier_warehouse": "supplier_warehouse"},
+ "validation": {
+ "docstatus": ["=", 1],
+ },
+ },
+ "Purchase Order Item": {
+ "doctype": "Purchase Receipt Item",
+ "field_map": {
+ "name": "purchase_order_item",
+ "parent": "purchase_order",
+ "bom": "bom",
+ },
+ "postprocess": update_item,
+ "condition": lambda doc: doc.name in po_sr_item_dict,
+ },
+ "Purchase Taxes and Charges": {
+ "doctype": "Purchase Taxes and Charges",
+ "reset_value": True,
+ },
+ },
+ postprocess=post_process,
+ )
+
+ if not target_doc.get("items"):
+ add_po_items_to_pr(source_doc, target_doc)
+
+ if (save or submit) and frappe.has_permission(target_doc.doctype, "create"):
+ target_doc.save()
+
+ if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
+ try:
+ target_doc.submit()
+ except Exception as e:
+ target_doc.add_comment("Comment", _("Submit Action Failed") + "
" + str(e))
+
+ if notify:
+ frappe.msgprint(
+ _("Purchase Receipt {0} created.").format(
+ get_link_to_form(target_doc.doctype, target_doc.name)
+ ),
+ indicator="green",
+ alert=True,
+ )
+
+ return target_doc
+
+
+def add_po_items_to_pr(scr_doc, target_doc):
+ fg_items = {(item.item_code, item.purchase_order): item.qty for item in scr_doc.items}
+
+ for (item_code, po_name), fg_qty in fg_items.items():
+ po_doc = frappe.get_doc("Purchase Order", po_name)
+ for item in po_doc.items:
+ if item.fg_item != item_code:
+ continue
+
+ qty = (item.stock_qty - item.received_qty) * fg_qty / item.fg_item_qty
+ if qty:
+ target_doc.append(
+ "items",
+ {
+ "item_code": item.item_code,
+ "item_name": item.item_name,
+ "description": item.description,
+ "qty": qty,
+ "rate": item.rate,
+ "warehouse": item.warehouse,
+ "purchase_order": item.parent,
+ "purchase_order_item": item.name,
+ },
+ )
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/__init__.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py
new file mode 100644
index 00000000000..7e31454ab23
--- /dev/null
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/services/gl_composer.py
@@ -0,0 +1,265 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+from erpnext.accounts.general_ledger import process_gl_map
+from erpnext.accounts.utils import get_account_currency
+from erpnext.stock.services.base_stock_gl_composer import BaseStockGLComposer
+
+
+class SubcontractingReceiptGLComposer(BaseStockGLComposer):
+ """GL composer for Subcontracting Receipt.
+
+ Builds GL entries for accepted stock, service cost, supplier warehouse
+ (raw materials), additional costs, LCV, and divisional loss.
+ """
+
+ def compose(self, inventory_account_map: dict | None = None) -> list:
+ import erpnext
+
+ doc = self.doc
+ if not erpnext.is_perpetual_inventory_enabled(doc.company):
+ return []
+
+ gl_entries = []
+ self._make_item_gl_entries(gl_entries, inventory_account_map)
+ self._make_item_gl_entries_for_lcv(gl_entries, inventory_account_map)
+
+ return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
+
+ def _make_item_gl_entries(self, gl_entries: list, inventory_account_map: dict | None) -> None:
+ doc = self.doc
+ warehouse_with_no_account = []
+
+ supplied_items_details = frappe._dict()
+ for item in doc.supplied_items:
+ supplied_items_details.setdefault(item.reference_name, []).append(
+ frappe._dict(
+ {
+ "item_code": item.rm_item_code,
+ "amount": item.amount,
+ "expense_account": item.expense_account,
+ "cost_center": item.cost_center,
+ }
+ )
+ )
+
+ for item in doc.items:
+ if flt(item.rate) and flt(item.qty):
+ _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
+
+ if _inv_dict.get("account"):
+ stock_value_diff = frappe.db.get_value(
+ "Stock Ledger Entry",
+ {
+ "voucher_type": "Subcontracting Receipt",
+ "voucher_no": doc.name,
+ "voucher_detail_no": item.name,
+ "warehouse": item.warehouse,
+ "is_cancelled": 0,
+ },
+ "stock_value_difference",
+ )
+
+ remarks = doc.get("remarks") or _("Accounting Entry for Stock")
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=_inv_dict["account"],
+ cost_center=item.cost_center,
+ debit=stock_value_diff,
+ credit=0.0,
+ remarks=remarks,
+ against_account=item.expense_account,
+ account_currency=_inv_dict["account_currency"],
+ project=item.project,
+ item=item,
+ )
+
+ service_cost = flt(
+ item.service_cost_per_qty, item.precision("service_cost_per_qty")
+ ) * flt(item.qty, item.precision("qty"))
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=item.expense_account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=flt(stock_value_diff) - service_cost,
+ remarks=remarks,
+ against_account=_inv_dict["account"],
+ account_currency=get_account_currency(item.expense_account),
+ project=item.project,
+ item=item,
+ )
+
+ service_account = item.service_expense_account or item.expense_account
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=service_account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=service_cost,
+ remarks=remarks,
+ against_account=_inv_dict["account"],
+ account_currency=get_account_currency(service_account),
+ project=item.project,
+ item=item,
+ )
+
+ if flt(item.rm_supp_cost):
+ for rm_item in supplied_items_details.get(item.name):
+ _inv_dict = doc.get_inventory_account_dict(
+ rm_item, inventory_account_map, "supplier_warehouse"
+ )
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=_inv_dict.get("account"),
+ cost_center=rm_item.cost_center or item.cost_center,
+ debit=0.0,
+ credit=flt(rm_item.amount),
+ remarks=remarks,
+ against_account=rm_item.expense_account or item.expense_account,
+ account_currency=_inv_dict.get("account_currency"),
+ project=item.project,
+ item=item,
+ )
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=rm_item.expense_account or item.expense_account,
+ cost_center=rm_item.cost_center or item.cost_center,
+ debit=flt(rm_item.amount),
+ credit=0.0,
+ remarks=remarks,
+ against_account=_inv_dict.get("account"),
+ account_currency=get_account_currency(item.expense_account),
+ project=item.project,
+ item=item,
+ )
+
+ if item.additional_cost_per_qty:
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=item.expense_account,
+ cost_center=doc.cost_center or doc.get_company_default("cost_center"),
+ debit=item.qty * item.additional_cost_per_qty,
+ credit=0.0,
+ remarks=remarks,
+ against_account=None,
+ account_currency=get_account_currency(item.expense_account),
+ )
+
+ if divisional_loss := flt(item.amount - stock_value_diff, item.precision("amount")):
+ loss_account = doc.get_company_default(
+ "stock_adjustment_account", ignore_validation=True
+ )
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=loss_account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=divisional_loss,
+ remarks=remarks,
+ against_account=item.expense_account,
+ account_currency=get_account_currency(loss_account),
+ project=item.project,
+ item=item,
+ )
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=item.expense_account,
+ cost_center=item.cost_center,
+ debit=divisional_loss,
+ credit=0.0,
+ remarks=remarks,
+ against_account=loss_account,
+ account_currency=get_account_currency(item.expense_account),
+ project=item.project,
+ item=item,
+ )
+ elif (
+ item.warehouse not in warehouse_with_no_account
+ or item.rejected_warehouse not in warehouse_with_no_account
+ ):
+ warehouse_with_no_account.append(item.warehouse)
+
+ for row in doc.additional_costs:
+ credit_amount = (
+ flt(row.base_amount)
+ if (row.base_amount or row.account_currency != doc.company_currency)
+ else flt(row.amount)
+ )
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=row.expense_account,
+ cost_center=doc.cost_center or doc.get_company_default("cost_center"),
+ debit=0.0,
+ credit=credit_amount,
+ remarks=remarks,
+ against_account=None,
+ account_currency=get_account_currency(row.expense_account),
+ )
+
+ if warehouse_with_no_account:
+ frappe.msgprint(
+ _("No accounting entries for the following warehouses")
+ + ": \n"
+ + "\n".join(warehouse_with_no_account)
+ )
+
+ def _make_item_gl_entries_for_lcv(self, gl_entries: list, inventory_account_map: dict | None) -> None:
+ doc = self.doc
+ landed_cost_entries = doc.get_item_account_wise_lcv_entries()
+
+ if not landed_cost_entries:
+ return
+
+ for item in doc.items:
+ if item.landed_cost_voucher_amount and landed_cost_entries:
+ remarks = _("Accounting Entry for Landed Cost Voucher for SCR {0}").format(doc.name)
+ if (item.item_code, item.name) in landed_cost_entries:
+ _inv_dict = doc.get_inventory_account_dict(item, inventory_account_map)
+
+ for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
+ account_currency = get_account_currency(account)
+ credit_amount = (
+ flt(amount["base_amount"])
+ if (amount["base_amount"] or account_currency != doc.company_currency)
+ else flt(amount["amount"])
+ )
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=credit_amount,
+ remarks=remarks,
+ against_account=_inv_dict["account"],
+ credit_in_account_currency=flt(amount["amount"]),
+ account_currency=account_currency,
+ project=item.project,
+ item=item,
+ )
+
+ account_currency = get_account_currency(item.expense_account)
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=item.expense_account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=credit_amount * -1,
+ remarks=remarks,
+ against_account=_inv_dict["account"],
+ debit_in_account_currency=flt(amount["amount"]),
+ account_currency=account_currency,
+ project=item.project,
+ item=item,
+ )
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
index 0c2a10705c4..b8b357627e5 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
@@ -68,7 +68,7 @@ frappe.ui.form.on("Subcontracting Receipt", {
__("Purchase Receipt"),
() => {
frappe.model.open_mapped_doc({
- method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_purchase_receipt",
+ method: "erpnext.subcontracting.doctype.subcontracting_receipt.mapper.make_purchase_receipt",
frm: frm,
freeze: true,
freeze_message: __("Creating Purchase Receipt ..."),
@@ -85,7 +85,7 @@ frappe.ui.form.on("Subcontracting Receipt", {
() => {
const make_standard_return = () => {
frappe.model.open_mapped_doc({
- method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return",
+ method: "erpnext.subcontracting.doctype.subcontracting_receipt.mapper.make_subcontract_return",
frm: frm,
});
};
@@ -109,7 +109,7 @@ frappe.ui.form.on("Subcontracting Receipt", {
function (values) {
if (values.return_for_rejected_warehouse) {
frappe.call({
- method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return_against_rejected_warehouse",
+ method: "erpnext.subcontracting.doctype.subcontracting_receipt.mapper.make_subcontract_return_against_rejected_warehouse",
args: {
source_name: frm.doc.name,
},
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
index f9646699ca2..a998b611df9 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
@@ -6,12 +6,10 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
import erpnext
-from erpnext.accounts.utils import get_account_currency
from erpnext.controllers.subcontracting_controller import SubcontractingController
from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
@@ -20,6 +18,10 @@ from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.get_item_details import get_default_cost_center, get_default_expense_account
from erpnext.stock.stock_ledger import get_valuation_rate
+from .mapper import (
+ make_purchase_receipt,
+)
+
class BOMQuantityError(frappe.ValidationError):
pass
@@ -726,257 +728,11 @@ class SubcontractingReceipt(SubcontractingController):
)
def get_gl_entries(self, inventory_account_map=None):
- from erpnext.accounts.general_ledger import process_gl_map
+ from erpnext.subcontracting.doctype.subcontracting_receipt.services.gl_composer import (
+ SubcontractingReceiptGLComposer,
+ )
- if not erpnext.is_perpetual_inventory_enabled(self.company):
- return []
-
- gl_entries = []
- self.make_item_gl_entries(gl_entries, inventory_account_map)
- self.make_item_gl_entries_for_lcv(gl_entries, inventory_account_map)
-
- return process_gl_map(gl_entries, from_repost=frappe.flags.through_repost_item_valuation)
-
- def make_item_gl_entries(self, gl_entries, inventory_account_map=None):
- warehouse_with_no_account = []
-
- supplied_items_details = frappe._dict()
- for item in self.supplied_items:
- supplied_items_details.setdefault(item.reference_name, []).append(
- frappe._dict(
- {
- "item_code": item.rm_item_code,
- "amount": item.amount,
- "expense_account": item.expense_account,
- "cost_center": item.cost_center,
- }
- )
- )
-
- for item in self.items:
- if flt(item.rate) and flt(item.qty):
- _inv_dict = self.get_inventory_account_dict(item, inventory_account_map)
-
- if _inv_dict.get("account"):
- stock_value_diff = frappe.db.get_value(
- "Stock Ledger Entry",
- {
- "voucher_type": "Subcontracting Receipt",
- "voucher_no": self.name,
- "voucher_detail_no": item.name,
- "warehouse": item.warehouse,
- "is_cancelled": 0,
- },
- "stock_value_difference",
- )
-
- remarks = self.get("remarks") or _("Accounting Entry for Stock")
-
- # Accepted Warehouse Account (Debit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=_inv_dict["account"],
- cost_center=item.cost_center,
- debit=stock_value_diff,
- credit=0.0,
- remarks=remarks,
- against_account=item.expense_account,
- account_currency=_inv_dict["account_currency"],
- project=item.project,
- item=item,
- )
-
- service_cost = flt(
- item.service_cost_per_qty, item.precision("service_cost_per_qty")
- ) * flt(item.qty, item.precision("qty"))
- # Expense Account (Credit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=item.expense_account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=flt(stock_value_diff) - service_cost,
- remarks=remarks,
- against_account=_inv_dict["account"],
- account_currency=get_account_currency(item.expense_account),
- project=item.project,
- item=item,
- )
-
- service_account = item.service_expense_account or item.expense_account
- # Expense Account (Credit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=service_account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=service_cost,
- remarks=remarks,
- against_account=_inv_dict["account"],
- account_currency=get_account_currency(service_account),
- project=item.project,
- item=item,
- )
-
- if flt(item.rm_supp_cost):
- for rm_item in supplied_items_details.get(item.name):
- _inv_dict = self.get_inventory_account_dict(
- rm_item, inventory_account_map, "supplier_warehouse"
- )
-
- # Supplier Warehouse Account (Credit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=_inv_dict.get("account"),
- cost_center=rm_item.cost_center or item.cost_center,
- debit=0.0,
- credit=flt(rm_item.amount),
- remarks=remarks,
- against_account=rm_item.expense_account or item.expense_account,
- account_currency=_inv_dict.get("account_currency"),
- project=item.project,
- item=item,
- )
- # Expense Account (Debit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=rm_item.expense_account or item.expense_account,
- cost_center=rm_item.cost_center or item.cost_center,
- debit=flt(rm_item.amount),
- credit=0.0,
- remarks=remarks,
- against_account=_inv_dict.get("account"),
- account_currency=get_account_currency(item.expense_account),
- project=item.project,
- item=item,
- )
-
- # Expense Account (Debit)
- if item.additional_cost_per_qty:
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=item.expense_account,
- cost_center=self.cost_center or self.get_company_default("cost_center"),
- debit=item.qty * item.additional_cost_per_qty,
- credit=0.0,
- remarks=remarks,
- against_account=None,
- account_currency=get_account_currency(item.expense_account),
- )
-
- if divisional_loss := flt(item.amount - stock_value_diff, item.precision("amount")):
- loss_account = self.get_company_default(
- "stock_adjustment_account", ignore_validation=True
- )
-
- # Loss Account (Credit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=loss_account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=divisional_loss,
- remarks=remarks,
- against_account=item.expense_account,
- account_currency=get_account_currency(loss_account),
- project=item.project,
- item=item,
- )
- # Expense Account (Debit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=item.expense_account,
- cost_center=item.cost_center,
- debit=divisional_loss,
- credit=0.0,
- remarks=remarks,
- against_account=loss_account,
- account_currency=get_account_currency(item.expense_account),
- project=item.project,
- item=item,
- )
- elif (
- item.warehouse not in warehouse_with_no_account
- or item.rejected_warehouse not in warehouse_with_no_account
- ):
- warehouse_with_no_account.append(item.warehouse)
-
- for row in self.additional_costs:
- credit_amount = (
- flt(row.base_amount)
- if (row.base_amount or row.account_currency != self.company_currency)
- else flt(row.amount)
- )
-
- # Additional Cost Expense Account (Credit)
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=row.expense_account,
- cost_center=self.cost_center or self.get_company_default("cost_center"),
- debit=0.0,
- credit=credit_amount,
- remarks=remarks,
- against_account=None,
- account_currency=get_account_currency(row.expense_account),
- )
-
- if warehouse_with_no_account:
- frappe.msgprint(
- _("No accounting entries for the following warehouses")
- + ": \n"
- + "\n".join(warehouse_with_no_account)
- )
-
- def make_item_gl_entries_for_lcv(self, gl_entries, inventory_account_map):
- landed_cost_entries = self.get_item_account_wise_lcv_entries()
-
- if not landed_cost_entries:
- return
-
- for item in self.items:
- if item.landed_cost_voucher_amount and landed_cost_entries:
- remarks = _("Accounting Entry for Landed Cost Voucher for SCR {0}").format(self.name)
- if (item.item_code, item.name) in landed_cost_entries:
- _inv_dict = self.get_inventory_account_dict(item, inventory_account_map)
-
- for account, amount in landed_cost_entries[(item.item_code, item.name)].items():
- account_currency = get_account_currency(account)
- credit_amount = (
- flt(amount["base_amount"])
- if (amount["base_amount"] or account_currency != self.company_currency)
- else flt(amount["amount"])
- )
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=credit_amount,
- remarks=remarks,
- against_account=_inv_dict["account"],
- credit_in_account_currency=flt(amount["amount"]),
- account_currency=account_currency,
- project=item.project,
- item=item,
- )
-
- account_currency = get_account_currency(item.expense_account)
-
- # credit amount in negative to knock off the debit entry
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=item.expense_account,
- cost_center=item.cost_center,
- debit=0.0,
- credit=credit_amount * -1,
- remarks=remarks,
- against_account=_inv_dict["account"],
- debit_in_account_currency=flt(amount["amount"]),
- account_currency=account_currency,
- project=item.project,
- item=item,
- )
+ return SubcontractingReceiptGLComposer(self).compose(inventory_account_map)
def auto_create_purchase_receipt(self):
if frappe.db.get_single_value("Buying Settings", "auto_create_purchase_receipt"):
@@ -992,163 +748,3 @@ class SubcontractingReceipt(SubcontractingController):
return True
return False
-
-
-@frappe.whitelist()
-def make_subcontract_return_against_rejected_warehouse(source_name: str):
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
-
- return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True)
-
-
-@frappe.whitelist()
-def make_subcontract_return(source_name: str, target_doc: Document | str | None = None):
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
-
- return make_return_doc("Subcontracting Receipt", source_name, target_doc)
-
-
-@frappe.whitelist()
-def make_purchase_receipt(
- source_name: Document | str,
- target_doc: Document | str | None = None,
- save: bool = False,
- submit: bool = False,
- notify: bool = False,
-):
- if isinstance(source_name, str):
- source_doc = frappe.get_doc("Subcontracting Receipt", source_name)
- else:
- source_doc = source_name
-
- if source_doc.is_return:
- return
-
- po_sr_item_dict = {}
- po_name = None
- for item in source_doc.items:
- if not item.purchase_order:
- continue
-
- if not po_name:
- po_name = item.purchase_order
-
- po_sr_item_dict[item.purchase_order_item] = {
- "qty": flt(item.qty),
- "rejected_qty": flt(item.rejected_qty),
- "warehouse": item.warehouse,
- "rejected_warehouse": item.rejected_warehouse,
- "subcontracting_receipt_item": item.name,
- }
-
- if not po_name:
- frappe.throw(
- _("Purchase Order Item reference is missing in Subcontracting Receipt {0}").format(
- source_doc.name
- )
- )
-
- def update_item(obj, target, source_parent):
- sr_item_details = po_sr_item_dict.get(obj.name)
- ratio = flt(obj.qty) / flt(obj.fg_item_qty)
-
- target.update(
- {
- "qty": ratio * sr_item_details["qty"],
- "rejected_qty": ratio * sr_item_details["rejected_qty"],
- "warehouse": sr_item_details["warehouse"],
- "rejected_warehouse": sr_item_details["rejected_warehouse"],
- "subcontracting_receipt_item": sr_item_details["subcontracting_receipt_item"],
- }
- )
-
- def post_process(source, target):
- target.set_missing_values()
- target.update(
- {
- "posting_date": source_doc.posting_date,
- "posting_time": source_doc.posting_time,
- "subcontracting_receipt": source_doc.name,
- "supplier_warehouse": source_doc.supplier_warehouse,
- "is_subcontracted": 1,
- "currency": frappe.get_cached_value("Company", target.company, "default_currency"),
- }
- )
-
- target_doc = get_mapped_doc(
- "Purchase Order",
- po_name,
- {
- "Purchase Order": {
- "doctype": "Purchase Receipt",
- "field_map": {"supplier_warehouse": "supplier_warehouse"},
- "validation": {
- "docstatus": ["=", 1],
- },
- },
- "Purchase Order Item": {
- "doctype": "Purchase Receipt Item",
- "field_map": {
- "name": "purchase_order_item",
- "parent": "purchase_order",
- "bom": "bom",
- },
- "postprocess": update_item,
- "condition": lambda doc: doc.name in po_sr_item_dict,
- },
- "Purchase Taxes and Charges": {
- "doctype": "Purchase Taxes and Charges",
- "reset_value": True,
- },
- },
- postprocess=post_process,
- )
-
- if not target_doc.get("items"):
- add_po_items_to_pr(source_doc, target_doc)
-
- if (save or submit) and frappe.has_permission(target_doc.doctype, "create"):
- target_doc.save()
-
- if submit and frappe.has_permission(target_doc.doctype, "submit", target_doc):
- try:
- target_doc.submit()
- except Exception as e:
- target_doc.add_comment("Comment", _("Submit Action Failed") + "
" + str(e))
-
- if notify:
- frappe.msgprint(
- _("Purchase Receipt {0} created.").format(
- get_link_to_form(target_doc.doctype, target_doc.name)
- ),
- indicator="green",
- alert=True,
- )
-
- return target_doc
-
-
-def add_po_items_to_pr(scr_doc, target_doc):
- fg_items = {(item.item_code, item.purchase_order): item.qty for item in scr_doc.items}
-
- for (item_code, po_name), fg_qty in fg_items.items():
- po_doc = frappe.get_doc("Purchase Order", po_name)
- for item in po_doc.items:
- if item.fg_item != item_code:
- continue
-
- qty = (item.stock_qty - item.received_qty) * fg_qty / item.fg_item_qty
- if qty:
- target_doc.append(
- "items",
- {
- "item_code": item.item_code,
- "item_name": item.item_name,
- "description": item.description,
- "qty": qty,
- "rate": item.rate,
- "warehouse": item.warehouse,
- "purchase_order": item.parent,
- "purchase_order_item": item.name,
- },
- )
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
index 7154619f382..b480a726b47 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
@@ -1315,7 +1315,7 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
def test_subcontract_return_from_rejected_warehouse(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
- from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
+ from erpnext.subcontracting.doctype.subcontracting_receipt.mapper import (
make_subcontract_return_against_rejected_warehouse,
)
diff --git a/erpnext/templates/includes/rfq.js b/erpnext/templates/includes/rfq.js
index cc998a90030..4570a7445ff 100644
--- a/erpnext/templates/includes/rfq.js
+++ b/erpnext/templates/includes/rfq.js
@@ -76,7 +76,7 @@ rfq = class rfq {
frappe.freeze();
frappe.call({
type: "POST",
- method: "erpnext.buying.doctype.request_for_quotation.request_for_quotation.create_supplier_quotation",
+ method: "erpnext.buying.doctype.request_for_quotation.mapper.create_supplier_quotation",
args: {
doc: doc
},
diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html
index 5563a58b730..853c2566969 100644
--- a/erpnext/templates/pages/order.html
+++ b/erpnext/templates/pages/order.html
@@ -23,7 +23,7 @@