Merge pull request #55327 from nabinhait/erpnext-refactoring

refactor: ERPNext file structure refactoring [WIP]
This commit is contained in:
Nabin Hait
2026-06-03 21:53:18 +05:30
committed by GitHub
176 changed files with 13953 additions and 12925 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)
)

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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()

View File

@@ -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(

View File

@@ -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

View File

@@ -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 ..."),
});

View File

@@ -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}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> 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

View File

@@ -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}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> 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,
)
)

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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,
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -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)

View File

@@ -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}.<br><br> '{1}' account is required to post these values. Please set it in Company: {2}.<br><br> 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,
)
)

View File

@@ -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, "")

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 = "<br>"
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: <b>{0}</b> 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 += "</br >"
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,
)

View File

@@ -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

View File

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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 = (
_("<p>Cannot overbill for the following Items:</p>")
+ "<ul>"
+ "".join(
_("<li>Item {0} in row(s) {1} billed more than {2}</li>").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
)
+ "</ul>"
)
message += _("<p>To allow over-billing, please set allowance in Accounts Settings.</p>")
frappe.throw(_(message))

View File

@@ -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

View File

@@ -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

View File

@@ -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 = "<br>"
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: <b>{0}</b> 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 += "</br >"
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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(
"<br>" + "<br>".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

View File

@@ -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

View File

@@ -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 }],

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
)
)

View File

@@ -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:

View File

@@ -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,
)
)

View File

@@ -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 (

View File

@@ -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") + "<br><br>" + 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

View File

@@ -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: {

View File

@@ -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") + "<br><br>" + 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

View File

@@ -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()

View File

@@ -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

View File

@@ -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: {

View File

@@ -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"}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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():

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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,
});

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 <b>0</b>."));
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",

View File

@@ -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"

View File

@@ -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,
},

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More