diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py index c8e4adc5be4..55787ec1981 100644 --- a/erpnext/accounts/doctype/dunning/dunning.py +++ b/erpnext/accounts/doctype/dunning/dunning.py @@ -17,7 +17,8 @@ import json import frappe from frappe import _ from frappe.contacts.doctype.address.address import get_address_display -from frappe.utils import getdate +from frappe.query_builder.functions import Sum +from frappe.utils import flt, getdate from erpnext.controllers.accounts_controller import AccountsController @@ -140,6 +141,31 @@ class Dunning(AccountsController): ) row.dunning_level = len(past_dunnings) + 1 + def get_unpaid_base_dunning_amount(self): + """Interest and dunning fee that is still to be collected, in company currency.""" + if not self.base_dunning_amount: + return 0.0 + + return flt( + flt(self.base_dunning_amount) - get_paid_dunning_amount(self.name), + self.precision("base_dunning_amount"), + ) + + def get_unpaid_dunning_amount(self): + """Interest and dunning fee that is still to be collected, in the dunning currency.""" + return flt( + self.get_unpaid_base_dunning_amount() / (flt(self.conversion_rate) or 1), + self.precision("dunning_amount"), + ) + + def get_unpaid_overdue_payments(self): + """Overdue payments with their outstanding as of now, not as of dunning creation.""" + return [ + (row, outstanding) + for row in self.overdue_payments + if (outstanding := get_current_outstanding(row)) > 0 + ] + def on_cancel(self): super().on_cancel() self.ignore_linked_doctypes = [ @@ -154,6 +180,7 @@ class Dunning(AccountsController): "Unreconcile Payment Entries", "Payment Ledger Entry", "Serial and Batch Bundle", + "Payment Entry", ] @frappe.whitelist() @@ -252,11 +279,73 @@ def update_linked_dunnings(doc, previous_outstanding_amount): if has_outstanding: break - new_status = "Resolved" if not has_outstanding else "Unresolved" + set_dunning_status(dunning, has_outstanding, respect_manual_resolution=True) - if dunning.status != new_status: - dunning.status = new_status - dunning.save() + +def update_dunnings_linked_to_payment(payment_entry): + """Refresh dunnings whose interest and fee are settled by this payment.""" + dunnings = {row.dunning for row in payment_entry.get("deductions") if row.dunning} + + for name in dunnings: + dunning = frappe.get_doc("Dunning", name) + if dunning.docstatus != 1: + continue + + set_dunning_status(dunning, bool(dunning.get_unpaid_overdue_payments())) + + +def set_dunning_status(dunning, has_outstanding_payments: bool, respect_manual_resolution: bool = False): + """A dunning is only resolved once the invoiced sum *and* its interest and fee are paid.""" + has_unpaid_dunning_amount = dunning.get_unpaid_dunning_amount() > 0 + new_status = "Unresolved" if has_outstanding_payments or has_unpaid_dunning_amount else "Resolved" + + # resolving by hand waives the interest, only an invoice that is owed again reopens it + if respect_manual_resolution and dunning.status == "Resolved" and not has_outstanding_payments: + return + + if dunning.status != new_status: + dunning.db_set("status", new_status, notify=True) + + +def get_paid_dunning_amount(dunning: str) -> float: + """Interest and fee collected for this dunning, in company currency.""" + deduction = frappe.qb.DocType("Payment Entry Deduction") + payment_entry = frappe.qb.DocType("Payment Entry") + + paid = ( + frappe.qb.from_(deduction) + .join(payment_entry) + .on(payment_entry.name == deduction.parent) + .select(Sum(deduction.amount)) + .where((deduction.dunning == dunning) & (payment_entry.docstatus == 1)) + ).run() + + # the dunning amount is booked as a negative deduction, against the income account + return -flt(paid[0][0]) if paid else 0.0 + + +def get_current_outstanding(overdue_payment) -> float: + """Outstanding of an overdue payment as of now, in the invoice's transaction currency.""" + invoice = frappe.db.get_value( + "Sales Invoice", + overdue_payment.sales_invoice, + ["outstanding_amount", "currency", "party_account_currency"], + as_dict=True, + ) + schedule_outstanding = ( + flt(frappe.db.get_value("Payment Schedule", overdue_payment.payment_schedule, "outstanding")) + if overdue_payment.payment_schedule + else flt(overdue_payment.outstanding) + ) + + if flt(invoice.outstanding_amount) <= 0 or schedule_outstanding <= 0: + return 0.0 + + outstanding = min(schedule_outstanding, flt(overdue_payment.outstanding)) + if invoice.currency == invoice.party_account_currency: + outstanding = min(outstanding, flt(invoice.outstanding_amount)) + + return outstanding def get_linked_dunnings_as_per_state(sales_invoice, state): diff --git a/erpnext/accounts/doctype/dunning/test_dunning.py b/erpnext/accounts/doctype/dunning/test_dunning.py index 4fe8e7bf9f8..44c26c64301 100644 --- a/erpnext/accounts/doctype/dunning/test_dunning.py +++ b/erpnext/accounts/doctype/dunning/test_dunning.py @@ -16,6 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( create_dunning as create_dunning_from_sales_invoice, ) from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import ( + create_sales_invoice, create_sales_invoice_against_cost_center, ) @@ -71,6 +72,123 @@ class TestDunning(FrappeTestCase): dunning.reload() self.assertEqual(dunning.status, "Resolved") + def test_dunning_not_resolved_by_payment_of_invoiced_sum_only(self): + """ + Regression for #58220: paying the invoice without the interest and fee must not + resolve the dunning, the interest is still owed and has to stay claimable. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + sales_invoice = dunning.overdue_payments[0].sales_invoice + + pe = get_payment_entry("Sales Invoice", sales_invoice) + pe.reference_no, pe.reference_date = "4", nowdate() + pe.insert() + pe.submit() + + self.assertEqual(frappe.get_value("Sales Invoice", sales_invoice, "outstanding_amount"), 0) + + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + # the interest and fee can still be collected on their own + pe = get_payment_entry("Dunning", dunning.name) + pe.reference_no, pe.reference_date = "5", nowdate() + self.assertEqual(pe.references, []) + self.assertEqual(round(pe.paid_amount, 2), 10.41) + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + self.assertEqual(dunning.get_unpaid_dunning_amount(), 0) + + # cancelling the interest payment makes the dunning claimable again + pe.cancel() + dunning.reload() + self.assertEqual(dunning.status, "Unresolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + def test_dunning_can_be_cancelled_after_its_interest_was_paid(self): + """ + The payment collecting the interest links back to the dunning, which must not stand in + the way of cancelling it. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + + pe = get_payment_entry("Dunning", dunning.name) + pe.reference_no, pe.reference_date = "6", nowdate() + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + + dunning.cancel() + self.assertEqual(dunning.docstatus, 2) + + def test_waived_interest_keeps_a_manually_resolved_dunning_resolved(self): + """ + Resolving a dunning by hand waives its interest, so a later payment of the invoice + must not reopen it. + """ + dunning = create_dunning(overdue_days=15, dunning_type_name="Second Notice - _TC") + dunning.submit() + sales_invoice = dunning.overdue_payments[0].sales_invoice + + # what the "Resolve" button does + dunning.reload() + dunning.status = "Resolved" + dunning.save() + + pe = get_payment_entry("Sales Invoice", sales_invoice) + pe.reference_no, pe.reference_date = "7", nowdate() + pe.insert() + pe.submit() + + dunning.reload() + self.assertEqual(dunning.status, "Resolved") + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + def test_unpaid_dunning_amount_is_tracked_in_company_currency(self): + """ + The interest and fee are collected as a Payment Entry deduction, a company currency + field, so what is left to collect has to be measured in the same currency. + """ + si = create_sales_invoice( + posting_date=add_days(today(), -15), + customer="_Test Customer USD", + currency="USD", + conversion_rate=50, + rate=100, + debit_to="_Test Receivable USD - _TC", + ) + + dunning = create_dunning_from_sales_invoice(si.name) + dunning_type = frappe.get_doc("Dunning Type", "Second Notice - _TC") + dunning.dunning_type = dunning_type.name + dunning.rate_of_interest = dunning_type.rate_of_interest + dunning.dunning_fee = dunning_type.dunning_fee + dunning.income_account = dunning_type.income_account + dunning.cost_center = dunning_type.cost_center + dunning.save() + + self.assertEqual(dunning.currency, "USD") + self.assertEqual(dunning.conversion_rate, 50) + self.assertEqual(round(dunning.dunning_amount, 2), 10.41) + self.assertEqual(round(dunning.base_dunning_amount, 2), 520.55) + + # nothing collected yet, in either currency + self.assertEqual(round(dunning.get_unpaid_base_dunning_amount(), 2), 520.55) + self.assertEqual(round(dunning.get_unpaid_dunning_amount(), 2), 10.41) + + # the deduction booking the interest is in company currency + dunning.submit() + pe = get_payment_entry("Dunning", dunning.name) + self.assertEqual(round(pe.deductions[0].amount, 2), -520.55) + def test_fetch_overdue_payments(self): """ Create SI with overdue payment. Check if overdue payment is fetched in Dunning. diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js index 44aa2eac98d..cb5a83050b7 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.js @@ -136,6 +136,7 @@ frappe.ui.form.on("Invoice Discounting", { ], primary_action: function () { var data = d.get_values(); + data.company = frm.doc.company; frappe.call({ method: "erpnext.accounts.doctype.invoice_discounting.invoice_discounting.get_invoices", diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json index 3bfe2594326..63b24d2c636 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.json @@ -168,7 +168,7 @@ } ], "is_submittable": 1, - "modified": "2019-05-30 19:08:21.199759", + "modified": "2026-09-09 17:04:59.512294", "modified_by": "Administrator", "module": "Accounts", "name": "Invoice Discounting", @@ -185,7 +185,7 @@ "print": 1, "read": 1, "report": 1, - "role": "System Manager", + "role": "Accounts Manager", "share": 1, "submit": 1, "write": 1 @@ -194,4 +194,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py index 5d3c2b987ba..a10ba7377f6 100644 --- a/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py +++ b/erpnext/accounts/doctype/invoice_discounting/invoice_discounting.py @@ -319,6 +319,13 @@ class InvoiceDiscounting(AccountsController): @frappe.whitelist() def get_invoices(filters): filters = frappe._dict(json.loads(filters)) + + if not filters.get("company"): + frappe.throw(_("Please set company on the Document before requesting for invoices.")) + + frappe.has_permission("Company", doc=filters.get("company"), throw=True) + frappe.has_permission("Invoice Discounting", throw=True) + cond = [] if filters.customer: cond.append("customer=%(customer)s") diff --git a/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py b/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py index e8638bb355d..231de8e42aa 100644 --- a/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py +++ b/erpnext/accounts/doctype/item_tax_template/test_item_tax_template.py @@ -3,6 +3,142 @@ import unittest +import frappe +from frappe.tests.utils import FrappeTestCase + +from erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates import ( + execute as backfill_not_applicable, +) + class TestItemTaxTemplate(unittest.TestCase): pass + + +class TestGermanNotApplicableBackfill(FrappeTestCase): + """Run the `not_applicable` backfill patch against a seeded German company. + + The company is created from the shipped German defaults, so the templates the + patch has to recognise are the ones a real site got. Each test resets the flag + to its pre-patch state (`not_applicable = 0`) and runs the patch. + """ + + TITLES = ("19 %", "7 %", "0%") + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company = "_Test German Item Tax Templates" + if not frappe.db.exists("Company", cls.company): + frappe.get_doc( + { + "doctype": "Company", + "company_name": cls.company, + "abbr": "_TGITT", + "country": "Germany", + "default_currency": "EUR", + "create_chart_of_accounts_based_on": "Standard Template", + "chart_of_accounts": "Standard", + } + ).insert() + + cls.templates = { + title: frappe.db.get_value("Item Tax Template", {"company": cls.company, "title": title}, "name") + for title in cls.TITLES + } + assert all(cls.templates.values()), f"German defaults not seeded: {cls.templates}" + + def setUp(self): + frappe.db.savepoint("before_backfill_test") + self.addCleanup(frappe.db.rollback, save_point="before_backfill_test") + self.seeded_flags = self.flagged_rows() + # every default template ships not-applicable rows, otherwise the patch + # would be tested against effectively empty data + for title in self.TITLES: + self.assertTrue(self.seeded_flags[title], f"no not-applicable rows seeded in {title}") + + def flagged_rows(self, title=None) -> dict[str, set]: + """Detail rows currently marked as not applicable, per template title.""" + return { + t: { + d.name + for d in frappe.get_all( + "Item Tax Template Detail", + filters={"parent": name, "not_applicable": 1}, + fields=["name"], + ) + } + for t, name in self.templates.items() + if title in (None, t) + } + + def clear_flags(self): + """Restore the pre-patch state: zero rate, no flag.""" + for name in self.templates.values(): + frappe.db.set_value( + "Item Tax Template Detail", + {"parent": name}, + "not_applicable", + 0, + update_modified=False, + ) + self.assertEqual(self.flagged_rows(), {t: set() for t in self.TITLES}) + + def add_zero_rate_row(self, title, account_name, account_number): + """Add a user-defined zero-rate row, as a customised site would have.""" + like_account = frappe.db.get_value( + "Account", {"company": self.company, "account_name": "Umsatzsteuer 19 %"}, "name" + ) + account = frappe.get_doc( + { + "doctype": "Account", + "company": self.company, + "account_name": account_name, + "account_number": account_number, + "account_type": "Tax", + "parent_account": frappe.db.get_value("Account", like_account, "parent_account"), + } + ).insert() + + template = frappe.get_doc("Item Tax Template", self.templates[title]) + template.append("taxes", {"tax_type": account.name, "tax_rate": 0}) + template.save() + + def test_backfills_unmodified_defaults(self): + self.clear_flags() + backfill_not_applicable() + self.assertEqual(self.flagged_rows(), self.seeded_flags) + + def test_keeps_customised_template_untouched(self): + self.clear_flags() + self.add_zero_rate_row("19 %", "Sonstige Umsatzsteuer", "9998") + backfill_not_applicable() + + self.assertEqual(self.flagged_rows("19 %"), {"19 %": set()}) + self.assertEqual(self.flagged_rows("7 %"), {"7 %": self.seeded_flags["7 %"]}) + + def test_keeps_duplicate_account_name_untouched(self): + """A numbered account can share `account_name` with a default one. + + Its identifier collapses onto the default's, so only the row count tells + the customised template apart from an untouched one. + """ + self.clear_flags() + self.add_zero_rate_row("7 %", "Umsatzsteuer 19 %", "9999") + backfill_not_applicable() + + self.assertEqual(self.flagged_rows("7 %"), {"7 %": set()}) + self.assertEqual(self.flagged_rows("19 %"), {"19 %": self.seeded_flags["19 %"]}) + + def test_rerun_changes_nothing(self): + def snapshot(): + return frappe.get_all( + "Item Tax Template Detail", + filters={"parent": ("in", tuple(self.templates.values()))}, + fields=["name", "not_applicable", "tax_rate", "modified"], + order_by="name", + ) + + before = snapshot() + backfill_not_applicable() + self.assertEqual(snapshot(), before) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 31abb641760..3ca9639a8b3 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -3,6 +3,7 @@ import json +from datetime import date from functools import reduce import frappe @@ -122,8 +123,14 @@ class PaymentEntry(AccountsController): self.update_payment_schedule() self.make_gl_entries() self.update_outstanding_amounts() + self.update_linked_dunnings() self.set_status() + def update_linked_dunnings(self): + from erpnext.accounts.doctype.dunning.dunning import update_dunnings_linked_to_payment + + update_dunnings_linked_to_payment(self) + def validate_for_repost(self): validate_docs_for_voucher_types(["Payment Entry"]) validate_docs_for_deferred_accounting([self.name], []) @@ -225,6 +232,7 @@ class PaymentEntry(AccountsController): self.update_payment_schedule(cancel=1) self.make_gl_entries(cancel=1) self.update_outstanding_amounts() + self.update_linked_dunnings() self.delink_advance_entry_references() self.set_status() @@ -2891,15 +2899,15 @@ def get_reference_details( @frappe.whitelist() def get_payment_entry( - dt, - dn, - party_amount=None, - bank_account=None, - bank_amount=None, - party_type=None, - payment_type=None, - reference_date=None, - created_from_payment_request=False, + dt: str, + dn: str, + party_amount: int | float | None = None, + bank_account: str | None = None, + bank_amount: int | float | None = None, + party_type: str | None = None, + payment_type: str | None = None, + reference_date: str | date | None = None, + created_from_payment_request: bool | None = False, ): frappe.has_permission("Payment Entry", ptype="create", throw=True) @@ -3000,7 +3008,7 @@ def get_payment_entry( pe.append("references", reference) else: if dt == "Dunning": - for overdue_payment in doc.overdue_payments: + for overdue_payment, outstanding in doc.get_unpaid_overdue_payments(): pe.append( "references", { @@ -3008,21 +3016,23 @@ def get_payment_entry( "reference_name": overdue_payment.sales_invoice, "payment_term": overdue_payment.payment_term, "due_date": overdue_payment.due_date, - "total_amount": overdue_payment.outstanding, - "outstanding_amount": overdue_payment.outstanding, - "allocated_amount": overdue_payment.outstanding, + "total_amount": outstanding, + "outstanding_amount": outstanding, + "allocated_amount": outstanding, }, ) - pe.append( - "deductions", - { - "account": doc.income_account, - "cost_center": doc.cost_center, - "amount": -1 * doc.dunning_amount, - "description": _("Interest and/or dunning fee"), - }, - ) + if (unpaid_dunning_amount := doc.get_unpaid_base_dunning_amount()) > 0: + pe.append( + "deductions", + { + "account": doc.income_account, + "cost_center": doc.cost_center, + "amount": -1 * unpaid_dunning_amount, + "description": _("Interest and/or dunning fee"), + "dunning": doc.name, + }, + ) else: pe.append( "references", @@ -3304,8 +3314,10 @@ def set_grand_total_and_outstanding_amount(party_amount, dt, party_account_curre grand_total = doc.rounded_total or doc.grand_total outstanding_amount = doc.outstanding_amount elif dt == "Dunning": - grand_total = doc.grand_total - outstanding_amount = doc.grand_total + # only what is left to collect, the totals on the dunning are the ones it was raised with + grand_total = sum(outstanding for _row, outstanding in doc.get_unpaid_overdue_payments()) + grand_total += doc.get_unpaid_dunning_amount() + outstanding_amount = grand_total else: if party_account_currency == doc.company_currency: grand_total = flt(doc.get("base_rounded_total") or doc.get("base_grand_total")) diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json index 735d6b02857..2c3463914a5 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.json @@ -10,7 +10,8 @@ "amount", "column_break_2", "is_exchange_gain_loss", - "description" + "description", + "dunning" ], "fields": [ { @@ -55,12 +56,21 @@ "fieldtype": "Check", "label": "Is Exchange Gain / Loss?", "read_only": 1 + }, + { + "fieldname": "dunning", + "fieldtype": "Link", + "label": "Dunning", + "no_copy": 1, + "options": "Dunning", + "print_hide": 1, + "read_only": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-03-11 14:26:11.312950", + "modified": "2026-08-17 11:20:35.482913", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry Deduction", diff --git a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py index ae4134fc27a..af29bcda4a8 100644 --- a/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py +++ b/erpnext/accounts/doctype/payment_entry_deduction/payment_entry_deduction.py @@ -18,6 +18,7 @@ class PaymentEntryDeduction(Document): amount: DF.Currency cost_center: DF.Link description: DF.SmallText | None + dunning: DF.Link | None is_exchange_gain_loss: DF.Check parent: DF.Data parentfield: DF.Data diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index cb7f9d6af76..fa254f5f356 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -78,7 +78,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. const me = this; super.refresh(); - hide_fields(this.frm.doc); + hide_fields(this.frm); // Show / Hide button this.show_general_ledger(); erpnext.accounts.ledger_preview.show_accounting_ledger_preview(this.frm); @@ -435,7 +435,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. } is_paid() { - hide_fields(this.frm.doc); + hide_fields(this.frm); if (cint(this.frm.doc.is_paid)) { this.frm.set_value("allocate_advances_automatically", 0); this.frm.set_value("payment_terms_template", ""); @@ -499,28 +499,26 @@ cur_frm.script_manager.make(erpnext.accounts.PurchaseInvoice); // Hide Fields // ------------ -function hide_fields(doc) { - var parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"]; +function hide_fields(frm) { + const doc = frm.doc; + const parent_fields = ["due_date", "is_opening", "advances_section", "from_date", "to_date"]; if (cint(doc.is_paid) == 1) { - hide_field(parent_fields); + frm.toggle_display(parent_fields, false); } else { - for (var i in parent_fields) { - var docfield = frappe.meta.docfield_map[doc.doctype][parent_fields[i]]; - if (!docfield.hidden) unhide_field(parent_fields[i]); + for (const fieldname of parent_fields) { + const docfield = frappe.meta.docfield_map[doc.doctype][fieldname]; + if (!docfield.hidden) frm.toggle_display(fieldname, true); } } - var item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"]; + const item_fields_stock = ["warehouse_section", "received_qty", "rejected_qty"]; - if (cur_frm.fields_dict["items"]) { - cur_frm.fields_dict["items"].grid.set_column_disp( - item_fields_stock, - cint(doc.update_stock) == 1 || cint(doc.is_return) == 1 ? true : false - ); + if (frm.fields_dict["items"]) { + frm.fields_dict["items"].grid.set_column_disp(item_fields_stock, cint(doc.update_stock) == 1); } - cur_frm.refresh_fields(); + frm.refresh_fields(); } cur_frm.fields_dict.cash_bank_account.get_query = function (doc) { @@ -736,7 +734,7 @@ frappe.ui.form.on("Purchase Invoice", { }, update_stock: function (frm) { - hide_fields(frm.doc); + hide_fields(frm); frm.fields_dict.items.grid.toggle_reqd("item_code", frm.doc.update_stock ? true : false); }, diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 44d94499516..e918d84b0ca 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -3081,6 +3081,23 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): self.assertRaises(StockOverReturnError, return_doc.save) + def test_partial_returns_ignore_received_qty_without_update_stock(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + + invoice = make_purchase_invoice(qty=10, received_qty=10) + + first_return = make_return_doc(invoice.doctype, invoice.name) + first_return.items[0].qty = -4 + first_return.save().submit() + + self.assertEqual(first_return.items[0].received_qty, -10) + + second_return = make_return_doc(invoice.doctype, invoice.name) + second_return.items[0].qty = -6 + second_return.save().submit() + + self.assertEqual(second_return.docstatus, 1) + def test_apply_discount_on_grand_total(self): """ To test if after applying discount on grand total, diff --git a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py index be896b6f143..53549cdd6c4 100644 --- a/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py +++ b/erpnext/accounts/doctype/repost_payment_ledger/repost_payment_ledger.py @@ -123,6 +123,8 @@ class RepostPaymentLedger(Document): def execute_repost_payment_ledger(docname): """Repost Payment Ledger Entries by background job.""" + frappe.has_permission("Repost Payment Ledger", ptype="submit", doc=docname, throw=True) + job_name = "payment_ledger_repost_" + docname frappe.enqueue( diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index ba4786cddaa..f73eb3b20db 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -253,6 +253,8 @@ class AccountsController(TransactionBase): if self.get("_action") and self._action != "update_after_submit": self.set_missing_values(for_validate=True) + self.validate_price_list() + if self.get("_action") == "submit": self.remove_bundle_for_non_stock_invoices() @@ -384,6 +386,28 @@ class AccountsController(TransactionBase): def is_drop_ship(items): return any(item.delivered_by_supplier for item in items) + def validate_price_list(self): + price_list_field = "selling_price_list" if self.get("selling_price_list") else "buying_price_list" + price_list = self.get(price_list_field) + if not price_list or frappe.db.get_value("Price List", price_list, "enabled"): + return + + # Returns retain a submitted voucher's pricing even if its price list is now disabled. + if ( + self.get("is_return") + and self.get("return_against") + and price_list + == frappe.db.get_value( + self.doctype, {"name": self.return_against, "docstatus": 1}, price_list_field + ) + ): + return + + frappe.throw( + _("Price List {0} is disabled").format(get_link_to_form("Price List", price_list)), + title=_("Disabled Price List"), + ) + def set_default_letter_head(self): if hasattr(self, "letter_head") and not self.letter_head: self.letter_head = frappe.db.get_value("Company", self.company, "default_letter_head") diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 4d348112446..5bd9c535919 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -190,7 +190,12 @@ def validate_quantity(doc, key, args, ref, valid_items, already_returned_items): if (doc.doctype == "Purchase Invoice" or doc.doctype == "Sales Invoice") and not doc.update_stock: fields = ["qty"] - if doc.doctype in ["Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt"]: + tracks_accepted_rejected_split = doc.doctype in ( + "Purchase Receipt", + "Subcontracting Receipt", + ) or (doc.doctype == "Purchase Invoice" and doc.update_stock) + + if tracks_accepted_rejected_split: if not args.get("return_qty_from_rejected_warehouse"): fields.extend(["received_qty", "rejected_qty"]) else: diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index f5e344a4045..bb5e21d8c7b 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1533,7 +1533,7 @@ def show_accounting_ledger_preview(company: str, doctype: str, docname: str): @frappe.whitelist() def show_stock_ledger_preview(company: str, doctype: str, docname: str): - filters = frappe._dict(company=company) + filters = frappe._dict(company=company, valuation_field_type="Currency") doc = frappe.get_doc(doctype, docname) doc.check_permission("read") doc.run_method("before_sl_preview") @@ -1574,7 +1574,7 @@ def get_accounting_ledger_preview(doc, filters): columns = get_gl_columns(filters) gl_entries = get_gl_entries_for_preview(doc.doctype, doc.name, fields) - gl_columns = get_columns(columns, fields) + gl_columns = get_columns(columns, fields, erpnext.get_company_currency(filters.company)) gl_data = get_data(fields, gl_entries) return gl_columns, gl_data @@ -1616,7 +1616,7 @@ def get_stock_ledger_preview(doc, filters): columns = get_sl_columns(filters) sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields) - sl_columns = get_columns(columns, columns_fields) + sl_columns = get_columns(columns, columns_fields, erpnext.get_company_currency(filters.company)) sl_data = get_data(columns_fields, sl_entries) return sl_columns, sl_data @@ -1635,7 +1635,8 @@ def get_sl_entries_for_preview(doctype, docname, fields): entry["out_qty"] = abs(entry.actual_qty) entry["in_qty"] = 0 - entry["in_out_rate"] = entry["valuation_rate"] + if entry.actual_qty < 0: + entry["in_out_rate"] = entry.stock_value_difference / entry.actual_qty return sl_entries @@ -1644,12 +1645,23 @@ def get_gl_entries_for_preview(doctype, docname, fields): return frappe.get_all("GL Entry", filters={"voucher_type": doctype, "voucher_no": docname}, fields=fields) -def get_columns(raw_columns, fields): - return [ - {"name": d.get("label"), "editable": False, "width": 110, "fieldtype": d.get("fieldtype")} - for d in raw_columns - if not d.get("hidden") and d.get("fieldname") in fields - ] +def get_columns(raw_columns, fields, currency): + columns = [] + for source_column in raw_columns: + if source_column.get("hidden") or source_column.get("fieldname") not in fields: + continue + + column = { + "name": source_column.get("label"), + "editable": False, + "width": 110, + "fieldtype": source_column.get("fieldtype"), + } + if column["fieldtype"] == "Currency": + column["options"] = currency + columns.append(column) + + return columns def get_data(raw_columns, raw_data): diff --git a/erpnext/controllers/tests/test_ledger_preview.py b/erpnext/controllers/tests/test_ledger_preview.py new file mode 100644 index 00000000000..3aa5dd5ef53 --- /dev/null +++ b/erpnext/controllers/tests/test_ledger_preview.py @@ -0,0 +1,23 @@ +# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +from unittest import TestCase +from unittest.mock import patch + +import frappe + +from erpnext.controllers.stock_controller import get_sl_entries_for_preview + + +class TestLedgerPreview(TestCase): + def test_in_out_rate_is_only_set_for_outgoing_entries(self): + stock_ledger_entries = [ + frappe._dict(actual_qty=5, stock_value_difference=10), + frappe._dict(actual_qty=-5, stock_value_difference=-15), + ] + + with patch("frappe.get_all", return_value=stock_ledger_entries): + entries = get_sl_entries_for_preview("Delivery Note", "DN-0001", []) + + self.assertIsNone(entries[0].get("in_out_rate")) + self.assertEqual(entries[1].in_out_rate, 3) diff --git a/erpnext/crm/doctype/appointment/test_appointment.py b/erpnext/crm/doctype/appointment/test_appointment.py index 10813811664..d4236f2a566 100644 --- a/erpnext/crm/doctype/appointment/test_appointment.py +++ b/erpnext/crm/doctype/appointment/test_appointment.py @@ -53,7 +53,7 @@ def set_booking_setting(field, value): def slot_on(days_from_now, hour, minute=0): - day = datetime.date.today() + datetime.timedelta(days=days_from_now) + day = getdate() + datetime.timedelta(days=days_from_now) return datetime.datetime.combine(day, datetime.time(hour, minute)) @@ -136,7 +136,7 @@ class TestAppointment(FrappeTestCase): with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send: appointment = create_appointment( - date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)), + date=str(getdate() + datetime.timedelta(days=days_from_now)), time=time, tz=get_system_timezone(), contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""}, @@ -280,7 +280,7 @@ class TestAppointment(FrappeTestCase): with self.set_user("Guest"), self.assertRaises(frappe.Redirect): create_appointment( - date=str(datetime.date.today() + datetime.timedelta(days=3)), + date=str(getdate() + datetime.timedelta(days=3)), time="10:00:00", tz="UTC", contact={ @@ -295,7 +295,7 @@ class TestAppointment(FrappeTestCase): def test_booked_slot_unavailable_on_portal(self): self._configure_booking_settings() tz = get_system_timezone() - day = datetime.date.today() + datetime.timedelta(days=2) + day = getdate() + datetime.timedelta(days=2) def get_availability(): with self.set_user("Guest"): diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 3a799a508dd..051554f3e0a 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -10,7 +10,7 @@ import frappe from frappe import _, bold from frappe.core.doctype.version.version import get_diff from frappe.model.mapper import get_mapped_doc -from frappe.utils import cint, cstr, flt, today +from frappe.utils import cint, cstr, flt, get_link_to_form, today from frappe.website.website_generator import WebsiteGenerator import erpnext @@ -653,6 +653,19 @@ class BOM(WebsiteGenerator): frappe.throw(_("Quantity required for Item {0} in row {1}").format(m.item_code, m.idx)) check_list.append(m) + bom_items = {self.item} + bom_items.update(d.item_code for d in self.get("items")) + bom_items.update(d.item_code for d in self.get("scrap_items")) + + if disabled_items := frappe.db.get_all( + "Item", filters={"item_code": ("in", list(bom_items)), "disabled": 1}, pluck="name" + ): + frappe.throw( + _("Disabled Item {0} cannot be used in BOMs.").format( + ", ".join(get_link_to_form("Item", item) for item in disabled_items) + ) + ) + def check_recursion(self, bom_list=None): """Check whether recursion occurs in any bom""" diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index b1854088975..16a454a984b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1182,7 +1182,7 @@ def make_material_request(source_name, target_doc=None): @frappe.whitelist() -def make_stock_entry(source_name, target_doc=None): +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 @@ -1194,6 +1194,9 @@ def make_stock_entry(source_name, target_doc=None): target.qty = pending_rm_qty def set_missing_values(source, target): + if not source.items: + frappe.throw(_("This Job Card has no raw materials to transfer.")) + target.purpose = "Material Transfer for Manufacture" target.from_bom = 1 diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index 406d08f2e11..10d53049045 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -4,20 +4,66 @@ import frappe from frappe.test_runner import make_test_records from frappe.tests.utils import FrappeTestCase +from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.manufacturing.doctype.routing.test_routing import create_routing, setup_bom from erpnext.manufacturing.doctype.workstation.workstation import ( NotInWorkingHoursError, WorkstationHolidayError, check_if_within_operating_hours, + get_raw_materials, ) -test_dependencies = ["Warehouse"] +test_dependencies = ["Warehouse", "Item"] test_records = frappe.get_test_records("Workstation") make_test_records("Workstation") class TestWorkstation(FrappeTestCase): + def test_get_raw_materials_without_items(self): + job_card = frappe.get_doc( + { + "doctype": "Job Card", + "company": "_Test Company", + "wip_warehouse": "_Test Warehouse 1 - _TC", + } + ).insert(ignore_mandatory=True) + + self.assertEqual(get_raw_materials([job_card.name]), {}) + with self.assertRaisesRegex(frappe.ValidationError, "This Job Card has no raw materials to transfer"): + make_stock_entry(job_card.name) + + job_card.reload() + self.assertFalse(job_card.items) + self.assertFalse(frappe.db.exists("Stock Entry", {"job_card": job_card.name})) + + def test_get_raw_materials_with_items(self): + job_card = frappe.get_doc( + { + "doctype": "Job Card", + "company": "_Test Company", + "wip_warehouse": "_Test Warehouse 1 - _TC", + "items": [ + { + "item_code": "_Test Item", + "source_warehouse": "_Test Warehouse - _TC", + "required_qty": 5, + "transferred_qty": 2, + } + ], + } + ).insert(ignore_mandatory=True) + + materials = get_raw_materials([job_card.name]) + + self.assertEqual(list(materials), [job_card.name]) + self.assertEqual(len(materials[job_card.name]), 1) + material = materials[job_card.name][0] + self.assertEqual(material.item_code, "_Test Item") + self.assertEqual(material.required_qty, 5) + self.assertEqual(material.transferred_qty, 2) + self.assertEqual(material.source_warehouse, "_Test Warehouse - _TC") + def test_validate_timings(self): check_if_within_operating_hours( "_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00" diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 755d274227f..b0528ce7bfa 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -449,4 +449,6 @@ erpnext.patches.v16_0.backfill_repost_accounting_ledger_status erpnext.patches.v16_0.merge_seeded_item_group_root erpnext.patches.v16_0.remove_frappe_crm_custom_fields erpnext.patches.v16_0.append_fieldname_to_pos_search_fields -erpnext.patches.v16_0.add_transaction_roles_to_sms_settings \ No newline at end of file +erpnext.patches.v16_0.add_transaction_roles_to_sms_settings +erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status +erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates diff --git a/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py b/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py new file mode 100644 index 00000000000..c0ba3ac252d --- /dev/null +++ b/erpnext/patches/v16_0/recalculate_returned_delivery_note_billing_status.py @@ -0,0 +1,32 @@ +import frappe + + +def execute(): + """Recalculate billing status of Delivery Notes left open by a return. + + Returning the uninvoiced qty of a Delivery Note did not recalculate the original + Delivery Note, so it stayed "To Bill" / "Partially Billed" with nothing left to invoice. + """ + dn = frappe.qb.DocType("Delivery Note") + dn_item = frappe.qb.DocType("Delivery Note Item") + + delivery_notes = ( + frappe.qb.from_(dn) + .inner_join(dn_item) + .on(dn_item.parent == dn.name) + .select(dn.name) + .distinct() + .where( + (dn.docstatus == 1) + & (dn.is_return == 0) + & dn.status.isin(["To Bill", "Partially Billed"]) + & (dn_item.returned_qty > 0) + ) + .run(pluck=True) + ) + + for name in delivery_notes: + doc = frappe.get_doc("Delivery Note", name) + doc.update_billing_percentage(update_modified=False) + doc.load_from_db() + doc.set_status(update=True, update_modified=False) diff --git a/erpnext/patches/v16_0/set_not_applicable_on_german_item_tax_templates.py b/erpnext/patches/v16_0/set_not_applicable_on_german_item_tax_templates.py new file mode 100644 index 00000000000..13c03edb8b7 --- /dev/null +++ b/erpnext/patches/v16_0/set_not_applicable_on_german_item_tax_templates.py @@ -0,0 +1,226 @@ +import frappe + +# Snapshot of the relevant German defaults when this migration was written. +# Migration patches must not read mutable setup data, otherwise future edits to +# country_wise_tax.json would change what this patch does on sites that have not +# run it yet. +# +# For numbered charts, compare account_number + root_type because Account.account_name +# is not unique within a company. +SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset( + { + ("3801", "Liability"), + ("3802", "Liability"), + ("3835", "Liability"), + ("1401", "Asset"), + ("1402", "Asset"), + ("1541", "Asset"), + } +) + +SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset( + { + ("3806", "Liability"), + ("3804", "Liability"), + ("3837", "Liability"), + ("1406", "Asset"), + ("1404", "Asset"), + ("1540", "Asset"), + } +) + +SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset( + { + ("1771", "Liability"), + ("1772", "Liability"), + ("1785", "Liability"), + ("1571", "Asset"), + ("1572", "Asset"), + ("1541", "Asset"), + } +) + +SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset( + { + ("1776", "Liability"), + ("1774", "Liability"), + ("1787", "Liability"), + ("1576", "Asset"), + ("1574", "Asset"), + ("1540", "Asset"), + } +) + +STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS = frozenset( + { + ("Umsatzsteuer 7 %", "Liability"), + ("Umsatzsteuer aus innergemeinschaftlichem Erwerb", "Liability"), + ("Umsatzsteuer nach § 13b UStG", "Liability"), + ("Abziehbare Vorsteuer 7 %", "Asset"), + ("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb", "Asset"), + ("Abziehbare Vorsteuer nach § 13b UStG", "Asset"), + } +) + +STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS = frozenset( + { + ("Umsatzsteuer 19 %", "Liability"), + ("Umsatzsteuer aus innergemeinschaftlichem Erwerb 19 %", "Liability"), + ("Umsatzsteuer nach § 13b UStG 19 %", "Liability"), + ("Abziehbare Vorsteuer 19 %", "Asset"), + ("Abziehbare Vorsteuer aus innergemeinschaftlichem Erwerb 19 %", "Asset"), + ("Abziehbare Vorsteuer nach § 13b UStG 19 %", "Asset"), + } +) + +STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS = frozenset( + { + ("2321", "Liability"), + ("2331", "Liability"), + ("2341", "Liability"), + ("1521", "Asset"), + ("1531", "Asset"), + ("1541", "Asset"), + } +) + +STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS = frozenset( + { + ("2320", "Liability"), + ("2330", "Liability"), + ("2340", "Liability"), + ("1520", "Asset"), + ("1530", "Asset"), + ("1540", "Asset"), + } +) + +GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS = { + "SKR03 mit Kontonummern": { + "identifier_field": "account_number", + "templates": { + "19 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS, + "7 %": SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS, + "0 %": SKR03_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS + | SKR03_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS + | frozenset({("1588", "Asset")}), + }, + }, + "SKR04 mit Kontonummern": { + "identifier_field": "account_number", + "templates": { + "19 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS, + "7 %": SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS, + "0 %": SKR04_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS + | SKR04_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS + | frozenset({("1433", "Asset")}), + }, + }, + "Standard": { + "identifier_field": "account_name", + "templates": { + "19 %": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS, + "7 %": STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS, + "0%": STANDARD_NOT_APPLICABLE_7_PERCENT_ACCOUNT_LABELS + | STANDARD_NOT_APPLICABLE_19_PERCENT_ACCOUNT_LABELS + | frozenset({("Entstandene Einfuhrumsatzsteuer", "Asset")}), + }, + }, + "Standard with Numbers": { + "identifier_field": "account_number", + "templates": { + "19%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS, + "7%": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS, + "0 %": STANDARD_WITH_NUMBERS_NOT_APPLICABLE_7_PERCENT_ACCOUNT_IDS + | STANDARD_WITH_NUMBERS_NOT_APPLICABLE_19_PERCENT_ACCOUNT_IDS + | frozenset({("1550", "Asset")}), + }, + }, +} + + +def update_account_cache(accounts, account_cache): + missing_accounts = set(accounts) - set(account_cache) + if not missing_accounts: + return + + for account in frappe.get_all( + "Account", + filters={"name": ("in", tuple(sorted(missing_accounts)))}, + fields=["name", "account_name", "account_number", "root_type"], + ): + account_cache[account.name] = account + + +def get_account_identifier(account, identifier_field, account_cache): + cached_account = account_cache.get(account) + if not cached_account: + return None + + return cached_account.get(identifier_field), cached_account.root_type + + +def execute(): + """Backfill `not_applicable` on Item Tax Template Details for German companies. + + Before the `not_applicable` flag existed, German default templates used + `tax_rate: 0` to mean "this tax does not apply to the item" (as opposed to + an explicit 0% rate). For each German company, this patch looks up the + historical defaults for its Chart of Accounts and sets + `not_applicable = 1` on detail rows that still match those defaults + (same template title, same zero-rate tax account identifier set, flag still unset), + leaving any user-customised rows untouched. + """ + companies = frappe.get_all( + "Company", + filters={"country": "Germany"}, + fields=["name", "chart_of_accounts"], + ) + account_cache = {} + + for company in companies: + chart = GERMAN_ITEM_TAX_TEMPLATE_NOT_APPLICABLE_ACCOUNTS.get(company.chart_of_accounts) + if not chart: + continue + + identifier_field = chart["identifier_field"] + for template_title, target_accounts in chart["templates"].items(): + itt_names = frappe.get_all( + "Item Tax Template", + filters={"company": company.name, "title": template_title}, + pluck="name", + ) + for itt_name in itt_names: + zero_rate_details = frappe.get_all( + "Item Tax Template Detail", + filters={"parent": itt_name, "tax_rate": 0}, + fields=["name", "tax_type", "not_applicable"], + ) + update_account_cache((d.tax_type for d in zero_rate_details), account_cache) + zero_rate_accounts_by_detail = { + d.name: get_account_identifier(d.tax_type, identifier_field, account_cache) + for d in zero_rate_details + } + if any(identifier is None for identifier in zero_rate_accounts_by_detail.values()): + continue + + # Compare the row count as well. Account names are only implicitly unique + # among number-less accounts (`Account.name` is `[number - ]account_name - abbr`), + # so on a mixed chart a numbered account can share `account_name` with a + # default one. Without this, such a user-added zero-rate row collapses onto a + # default identifier and makes a customised template look untouched. + if len(zero_rate_accounts_by_detail) != len(target_accounts): + continue + + if set(zero_rate_accounts_by_detail.values()) != target_accounts: + continue + + for d in zero_rate_details: + if not d.not_applicable: + frappe.db.set_value( + "Item Tax Template Detail", + d.name, + "not_applicable", + 1, + update_modified=False, + ) diff --git a/erpnext/public/js/utils/ledger_preview.js b/erpnext/public/js/utils/ledger_preview.js index 9a5c8d3217f..9609a69d6d6 100644 --- a/erpnext/public/js/utils/ledger_preview.js +++ b/erpnext/public/js/utils/ledger_preview.js @@ -83,7 +83,7 @@ erpnext.accounts.ledger_preview = { columns.forEach((col) => { if (col.fieldtype === "Currency") { col.format = (value) => { - return format_currency(value); + return format_currency(value, col.options); }; } }); diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 66fd0eeec8e..767074b9104 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -24,14 +24,14 @@ erpnext.utils.get_party_details = function (frm, method, args, callback) { args = { party: frm.doc.customer || frm.doc.party_name, party_type: party_type, - price_list: frm.doc.selling_price_list, + price_list: frappe.defaults.get_default("selling_price_list"), }; } else if (frm.doc.supplier) { args = { party: frm.doc.supplier, party_type: "Supplier", bill_date: frm.doc.bill_date, - price_list: frm.doc.buying_price_list, + price_list: frappe.defaults.get_default("buying_price_list"), }; } diff --git a/erpnext/regional/report/irs_1099/irs_1099.js b/erpnext/regional/report/irs_1099/irs_1099.js index 385468b58aa..17002420cfc 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.js +++ b/erpnext/regional/report/irs_1099/irs_1099.js @@ -31,20 +31,4 @@ frappe.query_reports["IRS 1099"] = { width: 80, }, ], - - onload: function (query_report) { - query_report.page.add_inner_button(__("Print IRS 1099 Forms"), () => { - build_1099_print(query_report); - }); - }, }; - -function build_1099_print(query_report) { - let filters = JSON.stringify(query_report.get_values()); - let w = window.open( - "/api/method/erpnext.regional.report.irs_1099.irs_1099.irs_1099_print?" + - "&filters=" + - encodeURIComponent(filters) - ); - // w.print(); -} diff --git a/erpnext/regional/report/irs_1099/irs_1099.py b/erpnext/regional/report/irs_1099/irs_1099.py index 41e3761f9f1..27e55c6d732 100644 --- a/erpnext/regional/report/irs_1099/irs_1099.py +++ b/erpnext/regional/report/irs_1099/irs_1099.py @@ -84,46 +84,6 @@ def get_columns(): ] -@frappe.whitelist() -def irs_1099_print(filters): - if not filters: - frappe._dict( - { - "company": frappe.db.get_default("Company"), - "fiscal_year": frappe.db.get_default("Fiscal Year"), - } - ) - else: - filters = frappe._dict(json.loads(filters)) - - fiscal_year_doc = get_fiscal_year(fiscal_year=filters.fiscal_year, as_dict=True) - fiscal_year = cstr(fiscal_year_doc.year_start_date.year) - - company_address = get_payer_address_html(filters.company) - company_tin = frappe.db.get_value("Company", filters.company, "tax_id") - - columns, data = execute(filters) - template = frappe.get_doc("Print Format", "IRS 1099 Form").html - output = PdfWriter() - - for row in data: - row["fiscal_year"] = fiscal_year - row["company"] = filters.company - row["company_tin"] = company_tin - row["payer_street_address"] = company_address - row["recipient_street_address"], row["recipient_city_state"] = get_street_address_html( - "Supplier", row.supplier - ) - row["payments"] = fmt_money(row["payments"], precision=0, currency="USD") - get_pdf(render_template(template, row), output=output if output else None) - - frappe.local.response.filename = ( - f"{filters.fiscal_year} {filters.company} IRS 1099 Forms{IRS_1099_FORMS_FILE_EXTENSION}" - ) - frappe.local.response.filecontent = read_multi_pdf(output) - frappe.local.response.type = "download" - - def get_payer_address_html(company): address_list = frappe.db.sql( """ diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 8245c75770a..fe06db02c55 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -609,11 +609,8 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, # if the current user does not have permissions to override credit limit, # prompt them to send out an email to the controller users - frappe.msgprint( - message, - title=_("Credit Limit Crossed"), - raise_exception=1, - primary_action={ + primary_action = ( + { "label": "Send Email", "server_action": "erpnext.selling.doctype.customer.customer.send_emails", "hide_on_success": True, @@ -623,7 +620,16 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, "credit_limit": credit_limit, "credit_controller_users_list": credit_controller_users, }, - }, + } + if frappe.has_permission("Customer", ptype="email", doc=customer) + else None + ) + + frappe.msgprint( + message, + title=_("Credit Limit Crossed"), + raise_exception=1, + primary_action=primary_action, ) @@ -631,6 +637,7 @@ def check_credit_limit(customer, company, ignore_outstanding_sales_order=False, def send_emails(args): args = json.loads(args) subject = _("Credit limit reached for customer {0}").format(args.get("customer")) + frappe.has_permission("Customer", ptype="email", doc=args.get("customer"), throw=True) message = _("Credit limit has been crossed for customer {0} ({1}/{2})").format( args.get("customer"), args.get("customer_outstanding"), args.get("credit_limit") ) diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 7710bb8c434..d2349f4965a 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -14,7 +14,7 @@ from frappe.model.document import Document from frappe.model.mapper import get_mapped_doc from frappe.model.utils import get_fetch_values from frappe.query_builder import Case, Criterion -from frappe.query_builder.functions import Abs, Sum +from frappe.query_builder.functions import Abs, IfNull, Round, Sum from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html from pypika import Order @@ -1973,8 +1973,26 @@ def get_stock_reservation_status(): return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation") +def get_pending_qty_criterion(sales_order_item): + """Mirror the mapper's pending quantity check.""" + invoice_item = qb.DocType("Sales Invoice Item") + billed_qty = ( + qb.from_(invoice_item) + .select(IfNull(Sum(invoice_item.qty), 0)) + .where((invoice_item.docstatus == 1) & (invoice_item.so_detail == sales_order_item.name)) + ) + + qty_precision = frappe.get_precision("Sales Order Item", "qty") + has_unbilled_ordered_qty = Round(sales_order_item.qty - billed_qty, qty_precision) > 0 + has_unbilled_delivered_qty = ( + Round(sales_order_item.qty - sales_order_item.returned_qty - billed_qty, qty_precision) > 0 + ) | (Round(sales_order_item.delivered_qty - billed_qty, qty_precision) > 0) + + return has_unbilled_ordered_qty & has_unbilled_delivered_qty + + def get_potentially_billable_item_criterion(sales_order, sales_order_item, item): - """Return the amount check for UI candidates. The mapper checks pending quantity.""" + """Return the row level checks the Sales Invoice mapper applies.""" global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance")) allowance = ( Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance) @@ -1984,8 +2002,11 @@ def get_potentially_billable_item_criterion(sales_order, sales_order_item, item) Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100) ) is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0) + is_billable_row = ( + (sales_order_item.qty != 0) & has_amount_headroom & get_pending_qty_criterion(sales_order_item) + ) - return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom) + return is_unit_price_row | is_billable_row def has_potentially_billable_items(sales_order: str) -> bool: diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 58df9766cc5..9605a92d43a 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -290,6 +290,48 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): self.assertEqual(len(make_sales_invoice(so.name).items), 0) + def test_fully_billed_order_is_not_offered_within_billing_allowance(self): + item = make_item( + "_Test Fully Billed Allowance Item", + {"is_stock_item": 1, "over_billing_allowance": 0}, + ).name + so = make_sales_order(item_code=item, qty=10, rate=100) + + si = make_sales_invoice(so.name) + si.insert() + si.submit() + + so.load_from_db() + self.assertEqual(flt(so.per_billed), 100) + + filters = {"docstatus": 1, "company": so.company, "customer": so.customer} + + with change_settings("Accounts Settings", {"over_billing_allowance": 100}): + self.assertFalse(has_potentially_billable_items(so.name)) + + rows = get_potentially_billable_sales_orders("Sales Order", "", "name", 0, 50, filters) + self.assertNotIn(so.name, [row.name for row in rows]) + + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + + def test_order_with_sub_precision_pending_qty_is_not_offered(self): + item = make_item("_Test Sub Precision Qty Item", {"is_stock_item": 1}).name + so = make_sales_order(item_code=item, qty=10, rate=100) + + si = make_sales_invoice(so.name) + si.get("items")[0].rate = 90 + si.insert() + si.submit() + + qty_precision = frappe.get_precision("Sales Order Item", "qty") + billed_qty = 10 - 10 ** -(qty_precision + 1) + frappe.db.set_value( + "Sales Invoice Item", si.get("items")[0].name, "qty", billed_qty, update_modified=False + ) + + self.assertFalse(has_potentially_billable_items(so.name)) + self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0) + def test_make_sales_invoice_after_return_and_redelivery(self): from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return diff --git a/erpnext/stock/__init__.py b/erpnext/stock/__init__.py index 19d8bcd21e3..4d7f54ff39e 100644 --- a/erpnext/stock/__init__.py +++ b/erpnext/stock/__init__.py @@ -68,12 +68,8 @@ def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True account = warehouse.account if not account and warehouse.parent_warehouse: if warehouse_account: - if warehouse_account.get(warehouse.parent_warehouse): - account = warehouse_account.get(warehouse.parent_warehouse).account - else: - from frappe.utils.nestedset import rebuild_tree - - rebuild_tree("Warehouse", "parent_warehouse") + if parent := warehouse_account.get(warehouse.parent_warehouse): + account = parent.account else: account = frappe.db.sql( """ diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 4ee8f3289af..458e22fb0bd 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -1099,6 +1099,67 @@ class TestDeliveryNote(FrappeTestCase): self.assertEqual(dn.per_billed, 50) self.assertEqual(dn.status, "Partially Billed") + def test_billing_status_repair_patch(self): + """Returns submitted before #58869 left the original Delivery Note's per_billed stale. + + The repair patch recalculates such notes: a directly invoiced one whose remaining + qty was returned becomes Completed, an uninvoiced Sales Order linked one goes back + to To Bill. + """ + from erpnext.patches.v16_0 import recalculate_returned_delivery_note_billing_status as patch + from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return + + # Delivery Note invoiced for 2 of 5 qty, the remaining 3 returned -> fully billed + make_stock_entry(target="_Test Warehouse - _TC", qty=5, basic_rate=100) + dn = create_delivery_note(qty=5) + + si = make_sales_invoice(dn.name) + si.items[0].qty = 2 + si.insert() + si.submit() + + dn_return = make_sales_return(dn.name) + dn_return.items[0].qty = -3 + dn_return.insert() + # Mimic the submit request, which reconstructs the document from client data. + frappe.get_doc(dn_return.as_dict()).submit() + + dn.load_from_db() + self.assertEqual(dn.items[0].returned_qty, 3) + self.assertEqual(dn.per_billed, 100) + + # Sales Order linked Delivery Note, nothing invoiced, partly returned -> unbilled + so = make_sales_order(qty=10) + so_dn = create_dn_against_so(so.name, delivered_qty=5) + + so_dn_return = make_sales_return(so_dn.name) + so_dn_return.items[0].qty = -2 + so_dn_return.insert() + frappe.get_doc(so_dn_return.as_dict()).submit() + + so_dn.load_from_db() + self.assertEqual(so_dn.items[0].returned_qty, 2) + self.assertEqual(so_dn.per_billed, 0) + + # Mimic the state left behind by a return submitted before the fix + for name, per_billed in ((dn.name, 40), (so_dn.name, 50)): + frappe.db.set_value( + "Delivery Note", + name, + {"per_billed": per_billed, "status": "Partially Billed"}, + update_modified=False, + ) + + patch.execute() + + dn.load_from_db() + self.assertEqual(dn.per_billed, 100) + self.assertEqual(dn.status, "Completed") + + so_dn.load_from_db() + self.assertEqual(so_dn.per_billed, 0) + self.assertEqual(so_dn.status, "To Bill") + def test_dn_billing_status_case2(self): # SO -> SI and SO -> DN1, DN2 from erpnext.selling.doctype.sales_order.sales_order import ( diff --git a/erpnext/stock/doctype/delivery_trip/delivery_trip.py b/erpnext/stock/doctype/delivery_trip/delivery_trip.py index fbaafbe73fd..1e8c624f472 100644 --- a/erpnext/stock/doctype/delivery_trip/delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/delivery_trip.py @@ -50,6 +50,11 @@ class DeliveryTrip(Document): "UOM Conversion Factor", {"from_uom": "Meter", "to_uom": self.default_distance_uom}, "value" ) + def after_mapping(self, source_doc): + for stop in self.delivery_stops[:]: + if not any(stop.get(df.fieldname) for df in stop.meta.fields): + self.remove(stop) + def validate(self): if self._action == "submit" and not self.driver: frappe.throw(_("A driver must be set to submit.")) @@ -69,7 +74,7 @@ class DeliveryTrip(Document): def validate_stop_addresses(self): for stop in self.delivery_stops: - if not stop.customer_address: + if stop.address and not stop.customer_address: stop.customer_address = get_address_display(frappe.get_doc("Address", stop.address).as_dict()) def update_status(self): diff --git a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py index 09f5b2c2c89..c9075f30fdd 100644 --- a/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py +++ b/erpnext/stock/doctype/delivery_trip/test_delivery_trip.py @@ -7,6 +7,7 @@ from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, flt, now_datetime, nowdate import erpnext +from erpnext.stock.doctype.delivery_note.delivery_note import make_delivery_trip from erpnext.stock.doctype.delivery_trip.delivery_trip import ( get_contact_and_address, notify_customers, @@ -100,6 +101,32 @@ class TestDeliveryTrip(FrappeTestCase): self.delivery_trip.save() self.assertEqual(self.delivery_trip.status, "Completed") + def map_delivery_note_onto_trip(self, existing_stop): + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + delivery_note = create_delivery_note() + trip = frappe.new_doc("Delivery Trip") + trip.append("delivery_stops", existing_stop) + + return delivery_note, make_delivery_trip(delivery_note.name, trip) + + def test_mapping_drops_placeholder_stop(self): + delivery_note, trip = self.map_delivery_note_onto_trip({}) + + self.assertEqual(len(trip.delivery_stops), 1) + self.assertEqual(trip.delivery_stops[0].delivery_note, delivery_note.name) + + def test_mapping_keeps_partially_filled_stop(self): + _, trip = self.map_delivery_note_onto_trip({"customer": "_Test Customer"}) + + self.assertEqual(len(trip.delivery_stops), 2) + self.assertIsNone(trip.delivery_stops[0].delivery_note) + + def test_stop_without_address_throws_mandatory_error(self): + self.delivery_trip.append("delivery_stops", {"customer": "_Test Customer"}) + + self.assertRaises(frappe.MandatoryError, self.delivery_trip.save) + def create_address(driver): if not frappe.db.exists("Address", {"address_title": "_Test Address for Driver"}): diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js index 1b5f4a5743f..2b9b08b4aef 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.js @@ -73,24 +73,44 @@ frappe.ui.form.on("Inventory Dimension", { frm.trigger("set_parent_fields"); }, - set_parent_fields(frm) { - if (frm.doc.apply_to_all_doctypes) { - let options = ["\n", frm.doc.reference_document]; + istable(frm) { + frm.trigger("set_parent_fields"); + }, - frm.set_df_property("fetch_from_parent", "options", options); - } else if (frm.doc.document_type && frm.doc.istable) { + reference_document(frm) { + frm.trigger("set_parent_fields"); + }, + + apply_to_all_doctypes(frm) { + frm.trigger("set_parent_fields"); + }, + + set_parent_fields(frm) { + const { reference_document, document_type } = frm.doc; + if (!reference_document || (!frm.doc.apply_to_all_doctypes && (!document_type || !frm.doc.istable))) { + return set_parent_field_options(frm, []); + } + + if (frm.doc.apply_to_all_doctypes) { + return set_parent_field_options(frm, [{ value: reference_document, label: reference_document }]); + } else if (document_type && frm.doc.istable) { frappe.call({ method: "erpnext.stock.doctype.inventory_dimension.inventory_dimension.get_parent_fields", args: { - child_doctype: frm.doc.document_type, - dimension_name: frm.doc.reference_document, + child_doctype: document_type, + dimension_name: reference_document, }, callback: (r) => { - if (r.message && r.message.length) { - frm.set_df_property("fetch_from_parent", "options", ["\n"].concat(r.message)); - } else { - frm.set_df_property("fetch_from_parent", "hidden", 1); + if ( + frm.doc.reference_document !== reference_document || + frm.doc.document_type !== document_type || + frm.doc.apply_to_all_doctypes || + !frm.doc.istable + ) { + return; } + + return set_parent_field_options(frm, r.message || []); }, }); } @@ -115,3 +135,12 @@ frappe.ui.form.on("Inventory Dimension", { }); }, }); + +function set_parent_field_options(frm, fields) { + frm.set_df_property("fetch_from_parent", "options", ["", ...fields]); + frm.set_df_property("fetch_from_parent", "hidden", !fields.length); + + if (frm.doc.fetch_from_parent && !fields.some((field) => field.value === frm.doc.fetch_from_parent)) { + return frm.set_value("fetch_from_parent", ""); + } +} diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index fb2a7ee95f8..cdeedae35bd 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -194,6 +194,7 @@ class Item(Document): self.validate_conversion_factor() self.validate_item_type() self.validate_naming_series() + self.validate_shelf_life() self.check_for_active_boms() self.fill_customer_code() self.check_item_tax() @@ -344,6 +345,19 @@ class Item(Document): ).format(self.item_code) ) + def validate_shelf_life(self): + if ( + self.has_batch_no + and self.has_expiry_date + and self.create_new_batch + and cint(self.shelf_life_in_days) <= 0 + ): + frappe.throw( + _("{0} must be greater than zero.").format( + self.get_label_from_fieldname("shelf_life_in_days") + ) + ) + def clear_retain_sample(self): if not self.has_batch_no: self.retain_sample = False diff --git a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json index 4bd90355acb..d00c4d914a3 100644 --- a/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json +++ b/erpnext/stock/doctype/packing_slip_item/packing_slip_item.json @@ -41,6 +41,7 @@ }, { "fetch_from": "item_code.item_name", + "fetch_if_empty": 1, "fieldname": "item_name", "fieldtype": "Data", "in_list_view": 1, @@ -138,7 +139,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-04-28 15:00:14.079306", + "modified": "2026-09-09 13:04:53.623636", "modified_by": "Administrator", "module": "Stock", "name": "Packing Slip Item", @@ -149,4 +150,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 00145ffa47c..681314dbd5f 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1138,10 +1138,14 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate returned_qty = flt(item_wise_returned_qty.get(item.name)) returned_amount = flt(returned_qty) * flt(item.rate) pending_amount = flt(item.amount) - returned_amount - if buying_settings.bill_for_rejected_quantity_in_purchase_invoice: - pending_amount = flt(item.amount) - total_billable_amount = abs(flt(item.amount)) + # When rejected qty is billable, its value is part of the billable base too + rejected_amount = 0.0 + if buying_settings.bill_for_rejected_quantity_in_purchase_invoice: + rejected_amount = flt(item.rejected_qty * item.rate, item.precision("amount")) + pending_amount = flt(item.amount) + rejected_amount + + total_billable_amount = abs(flt(item.amount) + rejected_amount) if pending_amount > 0: total_billable_amount = pending_amount if item.billed_amt <= pending_amount else item.billed_amt @@ -1151,9 +1155,7 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate if pr_doc.get("is_return") and not total_amount and total_billed_amount: total_amount = total_billed_amount - amount = item.amount - if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): - amount += flt(item.rejected_qty * item.rate, item.precision("amount")) + amount = flt(item.amount) + rejected_amount if adjust_incoming_rate: adjusted_amt = 0.0 diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index f30158b4f51..d8bb216a430 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -605,6 +605,42 @@ class TestPurchaseReceipt(FrappeTestCase): return_pr.cancel() pr.cancel() + def test_per_billed_for_fully_rejected_receipt(self): + from erpnext.stock.doctype.purchase_receipt.purchase_receipt import update_billing_percentage + + bill_rejected = frappe.db.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) + frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1) + + try: + # Fully rejected receipt: accepted qty 0, whole qty in rejected warehouse + pr = make_purchase_receipt( + received_qty=10, + qty=0, + rejected_qty=10, + rate=9.5, + rejected_warehouse="_Test Warehouse 1 - _TC", + do_not_save=True, + ) + pr.items[0].warehouse = "" + pr.submit() + + # Bill the rejected qty (10 x 9.5) directly against the receipt item + pr.items[0].db_set("billed_amt", 95) + update_billing_percentage(pr) + + pr.load_from_db() + # Billing the rejected qty must not push per_billed above 100 + self.assertEqual(pr.per_billed, 100) + self.assertEqual(pr.status, "Completed") + + pr.cancel() + finally: + frappe.db.set_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", bill_rejected + ) + def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self): from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index fb0fff2a601..b34d5799e8f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1534,22 +1534,28 @@ class StockEntry(StockController): self.total_additional_costs = sum(flt(t.base_amount) for t in self.get("additional_costs")) - if self.purpose in ("Repack", "Manufacture"): - incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item) - else: - incoming_items_cost = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse) - - if not incoming_items_cost: - return + incoming_items, basis, total_basis = self.get_additional_cost_allocation() for d in self.get("items"): - if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item: - d.additional_cost = 0 - continue - elif not d.t_warehouse: - d.additional_cost = 0 - continue - d.additional_cost = (flt(d.basic_amount) / incoming_items_cost) * self.total_additional_costs + d.additional_cost = 0 + + if not total_basis: + return + + for d in incoming_items: + d.additional_cost = (flt(d.get(basis)) / total_basis) * self.total_additional_costs + + def get_additional_cost_allocation(self): + if self.purpose in ("Repack", "Manufacture"): + incoming_items = [d for d in self.get("items") if d.is_finished_item] + else: + incoming_items = [d for d in self.get("items") if d.t_warehouse] + + total_basic_amount = sum(flt(d.basic_amount) for d in incoming_items) + if total_basic_amount: + return incoming_items, "basic_amount", total_basic_amount + + return incoming_items, "transfer_qty", sum(flt(d.transfer_qty) for d in incoming_items) def update_valuation_rate(self): for d in self.get("items"): @@ -2066,32 +2072,20 @@ class StockEntry(StockController): def get_gl_entries(self, warehouse_account): gl_entries = super().get_gl_entries(warehouse_account) - if self.purpose in ("Repack", "Manufacture"): - total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.is_finished_item) - else: - total_basic_amount = sum(flt(t.basic_amount) for t in self.get("items") if t.t_warehouse) - - divide_based_on = total_basic_amount - - if self.get("additional_costs") and not total_basic_amount: - # if total_basic_amount is 0, distribute additional charges based on qty - divide_based_on = sum(item.qty for item in list(self.get("items"))) + incoming_items, basis, divide_based_on = self.get_additional_cost_allocation() item_account_wise_additional_cost = {} for t in self.get("additional_costs"): - for d in self.get("items"): - if self.purpose in ("Repack", "Manufacture") and not d.is_finished_item: - continue - elif not d.t_warehouse: - continue - + if not divide_based_on: + continue + for d in incoming_items: item_account_wise_additional_cost.setdefault((d.item_code, d.name), {}) item_account_wise_additional_cost[(d.item_code, d.name)].setdefault( t.expense_account, {"amount": 0.0, "base_amount": 0.0} ) - multiply_based_on = d.basic_amount if total_basic_amount else d.qty + multiply_based_on = flt(d.get(basis)) item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]["amount"] += ( flt(t.amount * multiply_based_on) / divide_based_on diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py index f9e1f415789..4841e879eb9 100644 --- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py @@ -4,7 +4,7 @@ from frappe.permissions import add_user_permission, remove_user_permission from frappe.tests.utils import FrappeTestCase, change_settings -from frappe.utils import add_days, cstr, flt, get_time, getdate, nowtime, today +from frappe.utils import add_days, cstr, flt, get_time, getdate, nowdate, nowtime, today from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.controllers.accounts_controller import InvalidQtyError @@ -1614,10 +1614,12 @@ class TestStockEntry(FrappeTestCase): se.insert() se.submit() + self.assertEqual([33.33, 66.67], [flt(d.additional_cost, 2) for d in se.items]) + self.check_gl_entries( "Stock Entry", se.name, - sorted([["Stock Adjustment - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]), + sorted([["Stock In Hand - TCP1", 100.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 100.0]]), ) def test_conversion_factor_change(self): @@ -1663,6 +1665,184 @@ class TestStockEntry(FrappeTestCase): distributed_costs = [d.additional_cost for d in se.items] self.assertEqual([0.0, 100.0, 0.0], distributed_costs) + def test_additional_cost_distribution_manufacture_zero_valued_items(self): + se = frappe.get_doc( + doctype="Stock Entry", + purpose="Manufacture", + additional_costs=[frappe._dict(base_amount=100)], + items=[ + frappe._dict(item_code="RM", basic_amount=0, transfer_qty=10), + frappe._dict( + item_code="FG", basic_amount=0, transfer_qty=5, t_warehouse="X", is_finished_item=1 + ), + frappe._dict(item_code="scrap", basic_amount=0, transfer_qty=2, t_warehouse="X"), + ], + ) + + se.distribute_additional_costs() + + distributed_costs = [d.additional_cost for d in se.items] + self.assertEqual([0.0, 100.0, 0.0], distributed_costs) + + def test_additional_cost_distribution_zero_valued_items(self): + se = frappe.get_doc( + doctype="Stock Entry", + purpose="Material Receipt", + additional_costs=[frappe._dict(base_amount=100)], + items=[ + frappe._dict(item_code="RECEIVED_1", basic_amount=0, transfer_qty=20, t_warehouse="X"), + frappe._dict(item_code="RECEIVED_2", basic_amount=0, transfer_qty=30, t_warehouse="X"), + ], + ) + + se.distribute_additional_costs() + + distributed_costs = [d.additional_cost for d in se.items] + self.assertEqual([40.0, 60.0], distributed_costs) + + def test_additional_cost_gl_for_zero_valued_manufacture(self): + company = "_Test Company with perpetual inventory" + rm = make_item("_Test Zero Rate RM", {"is_stock_item": 1}).name + fg = make_item("_Test Zero Rate FG", {"is_stock_item": 1}).name + + receipt = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "stock_entry_type": "Material Receipt", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": rm, + "qty": 5, + "basic_rate": 0, + "uom": "Nos", + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1", + } + ], + } + ) + receipt.insert() + receipt.submit() + + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Manufacture", + "stock_entry_type": "Manufacture", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": rm, + "qty": 5, + "uom": "Nos", + "s_warehouse": "Stores - TCP1", + "cost_center": "Main - TCP1", + }, + { + "item_code": fg, + "qty": 5, + "uom": "Nos", + "t_warehouse": "Finished Goods - TCP1", + "is_finished_item": 1, + "cost_center": "Main - TCP1", + }, + ], + "additional_costs": [ + { + "expense_account": "Miscellaneous Expenses - TCP1", + "amount": 500, + "description": "freight", + } + ], + } + ) + se.insert() + se.submit() + + self.assertEqual(500.0, se.items[1].additional_cost) + self.check_gl_entries( + "Stock Entry", + se.name, + sorted([["Stock In Hand - TCP1", 500.0, 0.0], ["Miscellaneous Expenses - TCP1", 0.0, 500.0]]), + ) + + def test_additional_cost_gl_matches_valuation_split(self): + company = "_Test Company with perpetual inventory" + cost_center = "_Test Additional Cost CC - TCP1" + if not frappe.db.exists("Cost Center", cost_center): + frappe.get_doc( + { + "doctype": "Cost Center", + "cost_center_name": "_Test Additional Cost CC", + "company": company, + "is_group": 0, + "parent_cost_center": "_Test Company with perpetual inventory - TCP1", + } + ).insert() + + uoms = [{"uom": "Nos", "conversion_factor": 1}, {"uom": "Box", "conversion_factor": 2}] + item_a = make_item("_Test Addl Cost CF A", {"is_stock_item": 1, "uoms": uoms}).name + uoms[1]["conversion_factor"] = 3 + item_b = make_item("_Test Addl Cost CF B", {"is_stock_item": 1, "uoms": uoms}).name + + se = frappe.get_doc( + { + "doctype": "Stock Entry", + "purpose": "Material Receipt", + "stock_entry_type": "Material Receipt", + "posting_date": nowdate(), + "company": company, + "items": [ + { + "item_code": item_a, + "qty": 1, + "basic_rate": 0, + "uom": "Box", + "conversion_factor": 2, + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": "Main - TCP1", + }, + { + "item_code": item_b, + "qty": 1, + "basic_rate": 0, + "uom": "Box", + "conversion_factor": 3, + "t_warehouse": "Stores - TCP1", + "allow_zero_valuation_rate": 1, + "cost_center": cost_center, + }, + ], + "additional_costs": [ + { + "expense_account": "Miscellaneous Expenses - TCP1", + "amount": 100, + "description": "misc", + } + ], + } + ) + se.insert() + se.submit() + + self.assertEqual([40.0, 60.0], [flt(d.additional_cost, 2) for d in se.items]) + + expense_by_cost_center = frappe.get_all( + "GL Entry", + filters={"voucher_no": se.name, "account": "Miscellaneous Expenses - TCP1"}, + fields=["cost_center", "credit"], + ) + self.assertEqual( + {"Main - TCP1": 40.0, cost_center: 60.0}, + {d.cost_center: d.credit for d in expense_by_cost_center}, + ) + def test_additional_cost_distribution_non_manufacture(self): se = frappe.get_doc( doctype="Stock Entry", diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 9470795f13f..f51b573aeb6 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -525,7 +525,10 @@ class StockReconciliation(StockController): rate_precision = item.precision("valuation_rate") rate = flt(item_dict.get("rate"), rate_precision) - valuation_rate = flt(item.valuation_rate, rate_precision) if item.valuation_rate else None + # an unset rate means "keep the current one", an explicit zero is a real revaluation + valuation_rate = ( + flt(item.valuation_rate, rate_precision) if item.valuation_rate not in ("", None) else None + ) if ( (item.qty is None or item.qty == item_dict.get("qty")) and (valuation_rate is None or valuation_rate == rate) @@ -568,7 +571,10 @@ class StockReconciliation(StockController): amount_precision = item.precision("amount") new_qty = flt(item.qty, qty_precision) - new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate")) + # an explicitly set zero rate is a real revaluation, don't fall back to the current rate + new_valuation_rate = flt( + item.valuation_rate if item.valuation_rate not in ("", None) else item_dict.get("rate") + ) current_qty = flt(item_dict.get("qty"), qty_precision) current_valuation_rate = flt(item_dict.get("rate")) diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 835f2a25f9f..1b9fe018312 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -25,7 +25,12 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.tests.test_utils import StockTestMixin -from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method +from erpnext.stock.utils import ( + get_incoming_rate, + get_stock_balance, + get_stock_value_on, + get_valuation_method, +) class TestStockReconciliation(FrappeTestCase, StockTestMixin): @@ -1583,6 +1588,85 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(sr.difference_amount, 100 * -1) self.assertTrue(sr.items[0].qty == 0) + def test_difference_amount_for_zero_valuation_rate(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item("Test Item Stock Reco Zero Valuation Rate").name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + sr = create_stock_reconciliation( + item_code=item_code, warehouse=warehouse, qty=5, rate=0, do_not_save=1 + ) + sr.items[0].allow_zero_valuation_rate = 1 + sr.save() + + # qty is unchanged, the stock is revalued from 5 x 100 to 5 x 0 + self.assertEqual(sr.items[0].current_valuation_rate, 100) + self.assertEqual(sr.items[0].valuation_rate, 0) + self.assertEqual(sr.difference_amount, -500) + + sr.submit() + sr.reload() + + self.assertEqual(sr.difference_amount, -500) + self.assertEqual( + frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": sr.name, "is_cancelled": 0}, + "stock_value_difference", + ), + -500, + ) + + def test_no_change_row_removed_when_valuation_rate_is_blank(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item("Test Item Stock Reco Blank Valuation Rate").name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=item_code, target=warehouse, qty=5, basic_rate=100) + + sr = create_stock_reconciliation( + item_code=item_code, warehouse=warehouse, qty=5, rate=None, do_not_save=1 + ) + + # a blank rate means "keep the current rate", so nothing changed on this row + self.assertRaises(EmptyStockReconciliationItemsError, sr.save) + + def test_set_existing_stock_valuation_to_zero(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry + + item_code = self.make_item("Test Item Stock Reco Set Valuation Zero").name + warehouse = "_Test Warehouse - _TC" + + make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=50) + + sr = create_stock_reconciliation( + item_code=item_code, warehouse=warehouse, qty=10, rate=0, do_not_save=1 + ) + sr.items[0].allow_zero_valuation_rate = 1 + + # only the rate changes, the row must not be dropped as "no change" + sr.save() + self.assertEqual(len(sr.items), 1) + + sr.submit() + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"voucher_no": sr.name, "is_cancelled": 0}, + ["qty_after_transaction", "valuation_rate", "stock_value"], + as_dict=True, + ) + + self.assertEqual(sle.qty_after_transaction, 10) + self.assertEqual(sle.valuation_rate, 0) + self.assertEqual(sle.stock_value, 0) + + self.assertEqual(get_stock_balance(item_code, warehouse, with_valuation_rate=True), (10, 0.0)) + def test_stock_reco_recalculate_qty_for_backdated_entry(self): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index 2bc39655a97..2b8c01ceaca 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -98,6 +98,7 @@ class SubcontractingOrder(SubcontractingController): self.validate_service_items() self.validate_supplied_items() self.set_missing_values() + self.validate_with_previous_doc() self.reset_default_field_value("set_warehouse", "items", "warehouse") def on_submit(self): @@ -108,6 +109,18 @@ class SubcontractingOrder(SubcontractingController): self.update_status() self.update_subcontracted_quantity_in_po(cancel=True) + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Purchase Order Item": { + "ref_dn_field": "purchase_order_item", + "compare_fields": [["project", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + } + ) + def validate_purchase_order_for_subcontracting(self): if self.purchase_order: po = frappe.get_doc("Purchase Order", self.purchase_order) @@ -211,10 +224,10 @@ class SubcontractingOrder(SubcontractingController): if si.fg_item: item = frappe.get_doc("Item", si.fg_item) - qty, subcontracted_quantity, fg_item_qty = frappe.db.get_value( + qty, subcontracted_quantity, fg_item_qty, project = frappe.db.get_value( "Purchase Order Item", si.purchase_order_item, - ["qty", "subcontracted_quantity", "fg_item_qty"], + ["qty", "subcontracted_quantity", "fg_item_qty", "project"], ) available_qty = flt(qty) - flt(subcontracted_quantity) @@ -250,6 +263,7 @@ class SubcontractingOrder(SubcontractingController): "purchase_order_item": si.purchase_order_item, "material_request": si.material_request, "material_request_item": si.material_request_item, + "project": project, } ) else: diff --git a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py index 1a54343a1b3..335f8f7c99e 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/test_subcontracting_order.py @@ -26,6 +26,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import ( set_backflush_based_on, ) from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.projects.doctype.project.test_project import make_project from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( @@ -112,6 +113,24 @@ class TestSubcontractingOrder(FrappeTestCase): sco.load_from_db() self.assertEqual(sco.status, "Partially Received") + def test_project_is_carried_over_from_purchase_order(self): + project = make_project({"project_name": "_Test SCO Project"}).name + po = make_subcontracted_purchase_order(project) + + sco = get_mapped_subcontracting_order(source_name=po.name) + + self.assertEqual(sco.project, project) + self.assertEqual(sco.items[0].project, project) + + def test_project_cannot_differ_from_purchase_order(self): + project = make_project({"project_name": "_Test SCO Project"}).name + other_project = make_project({"project_name": "_Test SCO Project 2"}).name + po = make_subcontracted_purchase_order(project) + + sco = get_mapped_subcontracting_order(source_name=po.name) + sco.items[0].project = other_project + self.assertRaises(frappe.ValidationError, sco.save) + def test_make_rm_stock_entry(self): sco = get_subcontracting_order() rm_items = get_rm_items(sco.supplied_items) @@ -873,3 +892,31 @@ def create_subcontracting_order(**args): sco.submit() return sco + + +def make_subcontracted_purchase_order(project): + from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order + + service_items = [ + { + "warehouse": "_Test Warehouse - _TC", + "item_code": "Subcontracted Service Item 7", + "qty": 10, + "rate": 100, + "fg_item": "Subcontracted Item SA7", + "fg_item_qty": 10, + }, + ] + po = create_purchase_order( + rm_items=service_items, + is_subcontracted=1, + supplier_warehouse="_Test Warehouse 1 - _TC", + do_not_submit=1, + ) + po.project = project + for item in po.items: + item.project = project + po.save() + po.submit() + + return po diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 501fde5b3dd..89be4fcb344 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -146,6 +146,7 @@ class SubcontractingReceipt(SubcontractingController): self.get_scrap_items() self.set_missing_values() + self.validate_with_previous_doc() if self.get("_action") == "submit": self.validate_scrap_items() @@ -159,6 +160,24 @@ class SubcontractingReceipt(SubcontractingController): self.set_supplied_items_expense_account() self.set_supplied_items_cost_center() + def validate_with_previous_doc(self): + super().validate_with_previous_doc( + { + "Subcontracting Order Item": { + "ref_dn_field": "subcontracting_order_item", + "compare_fields": [["project", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + "Purchase Order Item": { + "ref_dn_field": "purchase_order_item", + "compare_fields": [["project", "="]], + "is_child_table": True, + "allow_duplicate_prev_row_id": True, + }, + } + ) + def on_submit(self): self.validate_closed_subcontracting_order() self.validate_bom_required_qty() @@ -962,5 +981,7 @@ def add_po_items_to_pr(scr_doc, target_doc): "warehouse": item.warehouse, "purchase_order": item.parent, "purchase_order_item": item.name, + "project": item.project, + "cost_center": item.cost_center, }, ) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py index 26025169979..d79b6b1bfbc 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py @@ -25,6 +25,7 @@ from erpnext.controllers.tests.test_subcontracting_controller import ( set_backflush_based_on, ) from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom +from erpnext.projects.doctype.project.test_project import make_project from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( @@ -38,6 +39,9 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( make_subcontracting_receipt, ) +from erpnext.subcontracting.doctype.subcontracting_order.test_subcontracting_order import ( + make_subcontracted_purchase_order, +) from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import ( BOMQuantityError, ) @@ -50,6 +54,26 @@ class TestSubcontractingReceipt(FrappeTestCase): make_service_items() make_bom_for_subcontracted_items() + def test_project_is_carried_over_from_subcontracting_order(self): + project = make_project({"project_name": "_Test SCR Project"}).name + po = make_subcontracted_purchase_order(project) + sco = get_subcontracting_order(po_name=po.name) + + scr = make_subcontracting_receipt(sco.name) + + self.assertEqual(scr.project, project) + self.assertEqual(scr.items[0].project, project) + + def test_project_cannot_differ_from_subcontracting_order(self): + project = make_project({"project_name": "_Test SCR Project"}).name + other_project = make_project({"project_name": "_Test SCR Project 2"}).name + po = make_subcontracted_purchase_order(project) + sco = get_subcontracting_order(po_name=po.name) + + scr = make_subcontracting_receipt(sco.name) + scr.items[0].project = other_project + self.assertRaises(frappe.ValidationError, scr.save) + def test_subcontracting(self): set_backflush_based_on("BOM") make_stock_entry(item_code="_Test Item", qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)