Compare commits

...

24 Commits

Author SHA1 Message Date
Pandiyan P
b2bdeaa672 fix: validate shelf life for automatically created expiring batches (#58911)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
2026-09-09 07:07:03 +00:00
Pandiyan P
4c32acf300 fix: reset price list fallback when changing parties (#58893) 2026-09-09 12:26:05 +05:30
Khushi Rawat
e93ca84398 Merge pull request #58227 from aerele/fix/dunning-interest-lost-after-payment
fix: keep a dunning claimable until its interest is paid too
2026-09-09 10:47:07 +05:30
Pandiyan P
afd93cf867 fix(stock): guard inventory dimension lookup without reference document (#58889)
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:42:40 +05:30
Vishnu Priya Baskaran
51fb261b6b fix: calculate holiday list totals for half days (#58792)
Co-authored-by: nareshkannasln <nareshkannashanmugam@gmail.com>
Co-authored-by: Mihir Kandoi <kandoimihir@gmail.com>
2026-09-09 07:38:23 +05:30
Pandiyan P
f8c2f3440b fix(accounts): reject disabled price lists in buying and selling (#58891) 2026-09-09 06:57:48 +05:30
Diptanil Saha
991cea5ae2 fix(accounts): add permission checks to Chart of Accounts Importer methods (#58892) 2026-09-09 01:14:27 +05:30
Diptanil Saha
11d2847d3e fix(support): bind issue split to its own timeline (#58890) 2026-09-09 00:26:39 +05:30
Krishna Pramod Shirsath
e0d6d797d7 fix(stock): preserve rates for unsaved mapped rows (#58852)
* fix(stock): preserve rates for unsaved mapped rows

* test(stock): cover mapped delivery note item rates
2026-09-08 18:45:02 +05:30
pandiyan
d5a9d158f9 fix: keep a dunning claimable until its interest is paid too
a dunning was resolved as soon as the invoiced sum was settled, because the
status was derived from the invoice outstanding alone. paying an invoice
without the interest and fee therefore closed the dunning and lost the
interest: a fresh dunning finds nothing overdue to charge it on.

the dunning amount is never a receivable, it only reaches the ledger as a
negative deduction on a payment entry made from the dunning. link that row
to the dunning so what has been collected is known, and resolve a dunning
only once the invoiced sum and the dunning amount are both paid. a dunning
resolved by hand keeps its status, so waiving the interest stays possible.

the deduction is a company currency field, so book and measure the dunning
amount through base_dunning_amount instead of the transaction currency one.

an interest-only payment leaves every invoice outstanding untouched, so
update the linked dunnings from the payment entry itself instead of relying
on the outstanding amount to change. such a payment also has to be built
from what is left to collect, not from the totals the dunning was raised
with, which are stale by then.
2026-09-08 17:49:36 +05:30
pandiyan
fa89552d10 test: dunning must stay unpaid when only the invoiced sum is paid
paying a dunned invoice without its interest and fee marks the dunning
resolved, and the interest can no longer be collected.
2026-09-08 17:48:15 +05:30
Krishna Pramod Shirsath
cee9f4949a fix: show cost center for unmatched payments in receivables (#58453) 2026-09-08 16:52:53 +05:30
Vishnu Priya Baskaran
218e7927ff fix: fetch accounts by company (#58523) 2026-09-08 16:47:46 +05:30
Pandiyan P
36a4dfe797 fix(accounts): reject same-account internal transfers (#58529) 2026-09-08 16:29:20 +05:30
Sudharsanan Ashok
e825bb2f74 fix(accounts): round journal entry totals to field precision (#58629) 2026-09-08 16:26:04 +05:30
Pandiyan P
f864333afa fix(stock): recalculate delivery note billing after return (#58869) 2026-09-08 10:51:55 +00:00
Krishna Pramod Shirsath
c412310eb5 fix: use account currency in Bank Reconciliation Statement print (#58692) 2026-09-08 16:14:09 +05:30
Sudharsanan Ashok
1728d1b0f5 fix(stock): distribute additional costs when incoming items have no value (#58842)
* fix(stock): distribute additional costs when incoming items have no value

when every incoming row has zero basic amount, for example a raw material
purchased at zero rate, distribute_additional_costs returned early and left
additional_cost at 0 on the finished item. the charges were never capitalised
into valuation and the balancing debit stayed in stock adjustment instead of
reaching stock in hand.

the gl composer had its own quantity fallback for the same case, but it divided
by the qty of every row rather than the incoming ones, so a manufacture entry
booked only a fraction of the cost to the expense account, and it apportioned
by qty while valuation apportions by stock qty, which split rows of differing
conversion factors two different ways.

both sides now take the rows, the basis and its total from a single
get_additional_cost_allocation, falling back to transfer_qty so valuation and gl
cannot disagree. the basis is unchanged whenever the incoming rows carry value.

* test(stock): cover additional cost distribution for zero valued items

adds qty based distribution cases for manufacture and material receipt, a
manufacture entry asserting the whole cost reaches its expense account, and a
conversion factor case asserting the gl split matches the valuation split.

updates test_total_basic_amount_zero, which asserted the cost landing in stock
adjustment rather than being capitalised.
2026-09-08 16:13:12 +05:30
Lakshit Jain
79fc039092 Merge pull request #58682 from ljain112/fix-tds-subcontracting
fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping
2026-09-08 14:53:58 +05:30
Vishnu Priya Baskaran
467f54162f fix(gross-profit): include item_name in export (#58631) 2026-09-08 14:43:42 +05:30
Diptanil Saha
f60c349794 fix(buying): restrict linked material requests to permitted documents (#58855) 2026-09-08 14:23:03 +05:30
Pandiyan P
cadc0ca86d fix: persist rendered terms on transaction save (#58858) 2026-09-08 13:28:30 +05:30
Sudharsanan Ashok
2f572b1624 fix(stock): distribute additional costs when incoming items have no value (#58841) 2026-09-08 13:27:41 +05:30
ljain112
13031d6d5d fix(subcontracting): add condition to exclude tax withholding accounts in purchase receipt mapping 2026-09-02 13:25:23 +05:30
45 changed files with 1488 additions and 135 deletions

View File

@@ -8,6 +8,7 @@ from functools import reduce
import frappe
from frappe import _
from frappe.core.doctype.file.utils import find_file_by_url
from frappe.desk.form.linked_with import get_linked_fields
from frappe.model.document import Document
from frappe.utils import cint, cstr
@@ -58,6 +59,8 @@ def validate_columns(data):
@frappe.whitelist()
def validate_company(company: str):
frappe.has_permission("Chart of Accounts Importer", throw=True)
parent_company, allow_account_creation_against_child_company = frappe.get_cached_value(
"Company", company, ["parent_company", "allow_account_creation_against_child_company"]
)
@@ -110,7 +113,10 @@ def import_coa(file_name: str, company: str):
def get_file(file_name):
file_doc = frappe.get_doc("File", {"file_url": file_name})
file_doc = find_file_by_url(file_name)
if not file_doc:
raise frappe.PermissionError
parts = file_doc.get_extension()
extension = parts[1]
extension = extension.lstrip(".")
@@ -179,6 +185,8 @@ def get_coa(
):
"""called by tree view (to fetch node's children)"""
frappe.has_permission("Chart of Accounts Importer", throw=True)
file_doc, extension = get_file(file_name)
parent = None if parent == _("All Accounts") else parent
@@ -326,6 +334,8 @@ def build_response_as_excel(writer):
@frappe.whitelist()
def download_template(file_type: str, template_type: str, company: str):
frappe.has_permission("Chart of Accounts Importer", throw=True)
writer = get_template(template_type, company)
if file_type == "CSV":
@@ -378,7 +388,6 @@ def get_sample_template(writer, company):
return writer
@frappe.whitelist()
def validate_accounts(file_doc: Document, extension: str):
if extension == "csv":
accounts = generate_data_from_csv(file_doc, as_dict=True)

View File

@@ -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
@@ -147,6 +148,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 = [
@@ -161,6 +187,7 @@ class Dunning(AccountsController):
"Unreconcile Payment Entries",
"Payment Ledger Entry",
"Serial and Batch Bundle",
"Payment Entry",
]
@frappe.whitelist()
@@ -259,11 +286,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):

View File

@@ -55,6 +55,125 @@ class TestDunning(ERPNextTestSuite):
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)
@ERPNextTestSuite.change_settings(
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
)
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),
currency="USD",
conversion_rate=50,
rate=100,
debit_to="Debtors - _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.

View File

@@ -624,8 +624,8 @@ Object.assign(erpnext.journal_entry, {
total_credit += flt(row.credit, precision("credit", row));
});
frm.doc.total_debit = total_debit;
frm.doc.total_credit = total_credit;
frm.doc.total_debit = flt(total_debit, precision("total_debit"));
frm.doc.total_credit = flt(total_credit, precision("total_credit"));
frm.doc.difference = flt(total_debit - total_credit, precision("difference"));
["total_debit", "total_credit", "difference"].forEach((field) => frm.refresh_field(field));
},

View File

@@ -674,12 +674,14 @@ class JournalEntry(AccountsController):
if d.debit and d.credit:
frappe.throw(_("You cannot credit and debit same account at the same time"))
self.total_debit = flt(self.total_debit) + flt(d.debit, d.precision("debit"))
self.total_credit = flt(self.total_credit) + flt(d.credit, d.precision("credit"))
self.total_debit = flt(
self.total_debit + flt(d.debit, d.precision("debit")), self.precision("total_debit")
)
self.total_credit = flt(
self.total_credit + flt(d.credit, d.precision("credit")), self.precision("total_credit")
)
self.difference = flt(self.total_debit, self.precision("total_debit")) - flt(
self.total_credit, self.precision("total_credit")
)
self.difference = flt(self.total_debit - self.total_credit, self.precision("difference"))
def validate_multi_currency(self):
alternate_currency = []

View File

@@ -461,6 +461,59 @@ class TestJournalEntry(ERPNextTestSuite):
self.check_gl_entries()
def make_jv_with_fractional_totals(self):
"""0.10 + 0.20 sums to 0.30000000000000004, the residue this guards against."""
jv = frappe.new_doc("Journal Entry")
jv.posting_date = nowdate()
jv.company = "_Test Company"
jv.voucher_type = "Journal Entry"
jv.remark = "test"
for amount in (0.10, 0.20):
jv.append(
"accounts",
{
"account": "_Test Cash - _TC",
"cost_center": "_Test Cost Center - _TC",
"debit_in_account_currency": amount,
},
)
jv.append(
"accounts",
{
"account": "_Test Bank - _TC",
"cost_center": "_Test Cost Center - _TC",
"credit_in_account_currency": 0.30,
},
)
jv.insert()
return jv
def test_totals_are_rounded_to_precision(self):
jv = self.make_jv_with_fractional_totals()
jv.submit()
stored = frappe.db.get_value(
"Journal Entry", jv.name, ["total_debit", "total_credit", "difference"], as_dict=True
)
self.assertEqual(jv.total_debit, flt(jv.total_debit, jv.precision("total_debit")))
self.assertEqual(jv.total_credit, flt(jv.total_credit, jv.precision("total_credit")))
self.assertEqual(jv.total_debit, stored.total_debit)
self.assertEqual(jv.total_credit, stored.total_credit)
self.assertEqual(jv.difference, stored.difference)
def test_update_after_submit_with_fractional_totals(self):
"""An unrounded total is stored rounded, so updating a submitted entry used to throw."""
jv = self.make_jv_with_fractional_totals()
jv.submit()
jv.pay_to_recd_from = "_Test Supplier"
jv.save()
self.assertEqual(jv.docstatus, 1)
self.assertEqual(
jv.pay_to_recd_from, frappe.db.get_value("Journal Entry", jv.name, "pay_to_recd_from")
)
def test_jv_account_and_party_balance_with_cost_centre(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.utils import get_balance_on

View File

@@ -46,23 +46,27 @@ frappe.ui.form.on("Payment Entry", {
},
setup: function (frm) {
frm.set_query("paid_from", function () {
frm.set_query("paid_from", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Pay", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_to) {
filters.name = ["!=", doc.paid_to];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});
@@ -106,21 +110,25 @@ frappe.ui.form.on("Payment Entry", {
}
});
frm.set_query("paid_to", function () {
frm.set_query("paid_to", function (doc) {
frm.events.validate_company(frm);
var account_types = ["Receive", "Internal Transfer"].includes(frm.doc.payment_type)
? ["Bank", "Cash"]
: [frappe.boot.party_account_types[frm.doc.party_type]];
let filters = {
account_type: ["in", account_types],
is_group: 0,
company: doc.company,
};
if (frm.doc.party_type == "Shareholder") {
account_types.push("Equity");
}
if (doc.payment_type == "Internal Transfer" && doc.paid_from) {
filters.name = ["!=", doc.paid_from];
}
return {
filters: {
account_type: ["in", account_types],
is_group: 0,
company: frm.doc.company,
},
filters,
};
});

View File

@@ -176,6 +176,7 @@ class PaymentEntry(AccountsController):
self.set_liability_account()
self.set_missing_ref_details(force=True)
self.validate_payment_type()
self.validate_internal_transfer_accounts()
self.validate_party_details()
self.set_exchange_rate()
self.validate_mandatory()
@@ -208,9 +209,15 @@ class PaymentEntry(AccountsController):
self.update_payment_schedule()
self.make_gl_entries()
self.update_outstanding_amounts()
self.update_linked_dunnings()
self.set_status()
self.trigger_invoice_update_for_subscriptions()
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], [])
@@ -315,6 +322,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()
self.trigger_invoice_update_for_subscriptions()
@@ -627,6 +635,10 @@ class PaymentEntry(AccountsController):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay, or Internal Transfer"))
def validate_internal_transfer_accounts(self):
if self.payment_type == "Internal Transfer" and self.paid_from and self.paid_from == self.paid_to:
frappe.throw(_("Paid From and Paid To accounts must be different for an Internal Transfer."))
def validate_party_details(self):
if self.party and not frappe.db.exists(self.party_type, self.party):
frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
@@ -2725,7 +2737,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",
{
@@ -2733,21 +2745,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",
@@ -3040,8 +3054,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"))

View File

@@ -782,6 +782,23 @@ class TestPaymentEntry(ERPNextTestSuite):
self.validate_gl_entries(pe.name, expected_gle)
def test_internal_transfer_rejects_same_account(self):
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank - _TC"
pe.paid_to = "_Test Bank - _TC"
pe.paid_amount = 100
pe.received_amount = 100
pe.reference_no = "same-account-transfer"
pe.reference_date = nowdate()
self.assertRaisesRegex(
frappe.ValidationError,
"Paid From and Paid To accounts must be different",
pe.insert,
)
def test_bank_charges_deduction(self):
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",

View File

@@ -10,7 +10,8 @@
"amount",
"column_break_2",
"is_exchange_gain_loss",
"description"
"description",
"dunning"
],
"fields": [
{
@@ -55,12 +56,21 @@
"fieldtype": "Check",
"label": "System Generated",
"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",

View File

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

View File

@@ -171,6 +171,7 @@ class ReceivablePayableReport:
party_account=ple.account,
posting_date=ple.posting_date,
account_currency=ple.account_currency,
cost_center=ple.cost_center,
remarks=ple.remarks,
invoiced=0.0,
paid=0.0,

View File

@@ -1337,6 +1337,28 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
row = report[1][0]
self.assertEqual(expected_data_after_payment, [row.voucher_no, row.cost_center, row.outstanding])
def test_cost_center_on_payment_before_invoice(self):
filters = {
"company": self.company,
"party_type": "Customer",
"party": [self.customer],
"report_date": today(),
"range": "30, 60, 90, 120",
}
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
si.posting_date = add_days(today(), 1)
si.due_date = si.posting_date
si.payment_schedule[0].due_date = si.posting_date
si.save().submit()
pe = self.create_payment_entry(si.name, do_not_submit=True)
pe.cost_center = self.cost_center
pe.save().submit()
row = next(row for row in execute(filters)[1] if row.voucher_no == pe.name)
self.assertEqual(row.cost_center, pe.cost_center)
def test_payment_terms_template_filters(self):
from erpnext.controllers.accounts_controller import get_payment_terms

View File

@@ -28,16 +28,16 @@
<br>{%= __("Clearance Date") %}: {%= frappe.datetime.str_to_user(data[i]["clearance_date"]) %}
{% } %}
</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } else { %}
<tr>
<td></td>
<td></td>
<td>{%= data[i]["payment_entry"] %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["debit"], data[i]["account_currency"]) %}</td>
<td style="text-align: right">{%= format_currency(data[i]["credit"], data[i]["account_currency"]) %}</td>
</tr>
{% } %}
{% } %}

View File

@@ -114,6 +114,7 @@ def execute(filters=None):
filters={
"account_type": row["account_type"],
"is_group": 0,
"company": filters.company,
},
pluck="name",
)

View File

@@ -180,13 +180,15 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
columns[0]["fieldname"] = "sales_invoice"
columns[0]["options"] = "Item"
columns[0]["width"] = 300
# removing Item Code and Item Name columns
# removing the duplicate Item Code column and moving Item Name before Customer
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
del columns[4:6]
del columns[4]
columns.insert(1, columns.pop(4))
else:
del columns[5:7]
del columns[5]
columns.insert(1, columns.pop(5))
total_base_amount = 0
total_buying_amount = 0

View File

@@ -581,7 +581,7 @@ erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends (
var item_length = me.frm.doc.items.length;
while (i < item_length) {
var qty = me.frm.doc.items[i].qty;
(r.message[0] || []).forEach(function (d) {
(r.message || []).forEach(function (d) {
if (
d.qty > 0 &&
qty > 0 &&

View File

@@ -226,6 +226,7 @@ class PurchaseOrder(BuyingController):
self.doctype, self.supplier, self.company, self.inter_company_order_reference
)
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.set_missing_terms()
def set_has_unit_price_items(self):
"""

View File

@@ -0,0 +1,139 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import json
import frappe
import frappe.permissions
from erpnext.buying.utils import get_linked_material_requests
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.tests.utils import ERPNextTestSuite
def create_user_with_roles(email, *roles):
if frappe.db.exists("User", email):
user = frappe.get_doc("User", email)
else:
user = frappe.new_doc("User")
user.email = email
user.first_name = email.split("@", 1)[0]
user.insert(ignore_permissions=True)
user.set("roles", [])
for role in roles:
user.append("roles", {"role": role})
user.save(ignore_permissions=True)
# a user left without roles is downgraded to a Website User on save
frappe.db.set_value("User", email, "user_type", "System User")
return user
class TestGetLinkedMaterialRequests(ERPNextTestSuite):
def setUp(self):
self.material_request = make_material_request(item_code="_Test Item")
def test_permitted_role_can_fetch_linked_material_requests(self):
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_populated_result_is_a_flat_list_of_rows(self):
"""Both callers iterate the response directly, so it has to stay a flat list of rows
rather than a list of lists."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIsInstance(rows, list)
self.assertTrue(rows)
for row in rows:
self.assertNotIsInstance(row, list | tuple)
self.assertIsInstance(row, dict)
for fieldname in ("mr_name", "mr_item", "item_code", "qty"):
self.assertIn(fieldname, row)
def test_empty_result_is_a_flat_empty_list(self):
item_without_request = make_item("_Test Item Without Material Request").name
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests([item_without_request])
self.assertEqual(rows, [])
def test_a_single_item_code_is_treated_as_one_code(self):
"""A lone code must be read as one item code, not iterated character by character."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
with self.set_user("test_buying_purchase_user@example.com"):
rows = get_linked_material_requests(json.dumps("_Test Item"))
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_items_that_are_not_item_codes_are_rejected(self):
"""Anything that is not a `str` or a `list` is already refused by the type annotation,
so these are the malformed inputs that reach the method."""
create_user_with_roles("test_buying_purchase_user@example.com", "Purchase User")
bad_inputs = (
"not json at all",
[{"item_code": "_Test Item"}],
[["_Test Item"]],
[None],
)
with self.set_user("test_buying_purchase_user@example.com"):
for bad_items in bad_inputs:
with self.subTest(items=bad_items):
self.assertRaises(frappe.ValidationError, get_linked_material_requests, bad_items)
def test_manufacturing_manager_can_fetch_linked_material_requests(self):
"""Manufacturing Manager holds write on Supplier Quotation and Request for Quotation,
both of which call this method, so it must hold Material Request read as well."""
create_user_with_roles("test_buying_mfg_manager@example.com", "Manufacturing Manager")
with self.set_user("test_buying_mfg_manager@example.com"):
rows = get_linked_material_requests(["_Test Item"])
self.assertIn(self.material_request.name, {row.mr_name for row in rows})
def test_unpermitted_role_cannot_fetch_linked_material_requests(self):
create_user_with_roles("test_buying_sales_user@example.com", "Sales User")
with self.set_user("test_buying_sales_user@example.com"):
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
def test_role_with_only_select_permission_cannot_fetch_linked_material_requests(self):
"""Material Request grants Delivery and Maintenance roles `select` and nothing else.
`select` is enough to list names, so the permitted set must be resolved through a
filter on the child table, which requires `read`."""
create_user_with_roles("test_buying_delivery_user@example.com", "Delivery User")
with self.set_user("test_buying_delivery_user@example.com"):
self.assertRaises(frappe.PermissionError, get_linked_material_requests, ["_Test Item"])
def test_results_are_restricted_by_user_permissions(self):
other_company_request = make_material_request(
item_code="_Test Item",
company="_Test Company 1",
warehouse="_Test Warehouse 2 - _TC1",
cost_center="Main - _TC1",
)
user = create_user_with_roles("test_buying_restricted_user@example.com", "Purchase User")
frappe.permissions.add_user_permission("Company", "_Test Company", user.name)
try:
with self.set_user(user.name):
mr_names = {row.mr_name for row in get_linked_material_requests(["_Test Item"])}
finally:
frappe.permissions.remove_user_permission("Company", "_Test Company", user.name)
self.assertIn(self.material_request.name, mr_names)
self.assertNotIn(other_company_request.name, mr_names)

View File

@@ -129,7 +129,33 @@ def get_linked_material_requests(items: str | list):
Retrieve Material Requests linked to a list of items.
"""
items = frappe.parse_json(items)
try:
items = frappe.parse_json(items)
except (TypeError, ValueError):
frappe.throw(_("Items must be a list of Item codes"))
if isinstance(items, str):
items = [items]
if not isinstance(items, list | tuple) or any(not isinstance(item, str) for item in items):
frappe.throw(_("Items must be a list of Item codes"))
permitted_material_requests = frappe.get_list(
"Material Request",
filters=[
["material_request_type", "=", "Purchase"],
["docstatus", "=", 1],
["status", "!=", "Stopped"],
["per_ordered", "<", 99.99],
["Material Request Item", "item_code", "in", items],
],
pluck="name",
distinct=True,
)
if not permitted_material_requests:
return []
mr_list = []
mr = frappe.qb.DocType("Material Request")
@@ -146,6 +172,7 @@ def get_linked_material_requests(items: str | list):
mr_item.item_code,
mr_item.name.as_("mr_item"),
)
.where(mr.name.isin(permitted_material_requests))
.where(mr_item.item_code == item)
.where(mr.material_request_type == "Purchase")
.where(mr.per_ordered < 99.99)

View File

@@ -258,6 +258,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()
@@ -346,6 +348,28 @@ class AccountsController(TransactionBase):
self.set_default_letter_head()
self.validate_company_in_accounting_dimension()
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")

View File

@@ -57,20 +57,25 @@ class MaterialRequestService:
"""Create Material Requests grouped by Sales Order and Material Request Type"""
self.validate_mr_subcontracted()
if all(item.requested_qty == item.quantity for item in self.doc.mr_items):
msgprint(_("All items are already requested"))
return
material_request_map = {}
material_request_list = []
for item in self.doc.mr_items:
if item.quantity == item.requested_qty:
qty_to_request = flt(flt(item.quantity) - flt(item.requested_qty), item.precision("quantity"))
if qty_to_request <= 0:
continue
self._add_item_to_material_request(item, material_request_map, material_request_list)
self._add_item_to_material_request(
item, qty_to_request, material_request_map, material_request_list
)
if not material_request_list:
msgprint(_("All items are already requested"))
return
self._submit_material_requests(material_request_list)
def _add_item_to_material_request(self, item, material_request_map, material_request_list):
def _add_item_to_material_request(
self, item, qty_to_request, material_request_map, material_request_list
):
item_doc = frappe.get_cached_doc("Item", item.item_code)
material_request_type = item.material_request_type or item_doc.default_material_request_type
@@ -81,7 +86,7 @@ class MaterialRequestService:
material_request_list.append(material_request_map[key])
schedule_date = item.schedule_date or add_days(nowdate(), cint(item_doc.lead_time_days))
row = self._material_request_item(item, material_request_type, schedule_date)
row = self._material_request_item(item, material_request_type, schedule_date, qty_to_request)
material_request_map[key].append("items", row)
def _new_material_request(self, material_request_type):
@@ -96,7 +101,7 @@ class MaterialRequestService:
)
return mr
def _material_request_item(self, item, material_request_type, schedule_date):
def _material_request_item(self, item, material_request_type, schedule_date, qty_to_request):
from_warehouse = item.from_warehouse if material_request_type == "Material Transfer" else None
# a group warehouse cannot receive stock; it must never reach a Material Request line
if item.warehouse and frappe.get_cached_value("Warehouse", item.warehouse, "is_group"):
@@ -111,7 +116,7 @@ class MaterialRequestService:
return {
"item_code": item.item_code,
"from_warehouse": from_warehouse,
"qty": item.quantity - item.requested_qty,
"qty": qty_to_request,
"uom": item.uom,
"schedule_date": schedule_date,
"warehouse": item.warehouse,

View File

@@ -110,6 +110,23 @@ class TestProductionPlan(ERPNextTestSuite):
pln = frappe.get_doc("Production Plan", pln.name)
pln.cancel()
def test_production_plan_material_request_skips_zero_qty_items(self):
pln = create_production_plan(item_code="Test Production Item 1")
zero_qty_item, requested_item = pln.mr_items
zero_qty_item.quantity = "0"
pln.make_material_request()
material_request_items = frappe.get_all(
"Material Request Item",
filters={"production_plan": pln.name},
fields=["item_code", "qty"],
)
self.assertEqual(
material_request_items,
[{"item_code": requested_item.item_code, "qty": requested_item.quantity}],
)
def test_production_plan_start_date(self):
"Test if Work Order has same Planned Start Date as Prod Plan."
planned_date = add_to_date(date=None, days=3)

View File

@@ -520,3 +520,4 @@ erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
erpnext.patches.v16_0.set_secondary_item_valuation_type
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.set_supplier_quotation_order_status
erpnext.patches.v16_0.recalculate_holiday_list_totals

View File

@@ -0,0 +1,18 @@
import frappe
from frappe.query_builder import Case
from frappe.query_builder.functions import Coalesce, Sum
def execute():
holiday_list = frappe.qb.DocType("Holiday List")
holiday = frappe.qb.DocType("Holiday")
total_holidays = (
frappe.qb.from_(holiday)
.select(Sum(Case().when(holiday.is_half_day == 1, 0.5).else_(1)))
.where(
(holiday.parent == holiday_list.name)
& (holiday.parenttype == "Holiday List")
& (holiday.parentfield == "holidays")
)
)
frappe.qb.update(holiday_list).set(holiday_list.total_holidays, Coalesce(total_holidays, 0)).run()

View File

@@ -541,7 +541,7 @@ erpnext.buying.link_to_mrs = function (frm) {
var item_length = frm.doc.items.length;
for (let item of frm.doc.items) {
var qty = item.qty;
(r.message[0] || []).forEach(function (d) {
(r.message || []).forEach(function (d) {
if (
d.qty > 0 &&
qty > 0 &&

View File

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

View File

@@ -1,11 +1,18 @@
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
function update_total_holidays(frm) {
let total_holidays = 0;
for (const holiday of frm.doc.holidays || []) {
total_holidays += holiday.is_half_day ? 0.5 : 1;
}
frm.doc.total_holidays = total_holidays;
frm.refresh_field("total_holidays");
}
frappe.ui.form.on("Holiday List", {
refresh: function (frm) {
if (frm.doc.holidays) {
frm.set_value("total_holidays", frm.doc.holidays.length);
}
update_total_holidays(frm);
frm.call("get_supported_countries").then((r) => {
frm.subdivisions_by_country = r.message.subdivisions_by_country;
@@ -43,6 +50,18 @@ frappe.ui.form.on("Holiday List", {
},
});
frappe.ui.form.on("Holiday", {
holidays_add: function (frm) {
update_total_holidays(frm);
},
holidays_remove: function (frm) {
update_total_holidays(frm);
},
is_half_day: function (frm) {
update_total_holidays(frm);
},
});
frappe.tour["Holiday List"] = [
{
fieldname: "holiday_list_name",

View File

@@ -58,9 +58,10 @@
},
{
"fieldname": "total_holidays",
"fieldtype": "Int",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Total Holidays",
"precision": "1",
"read_only": 1
},
{

View File

@@ -8,7 +8,7 @@ from datetime import date
import frappe
from frappe import _, throw
from frappe.model.document import Document
from frappe.utils import DateTimeLikeObject, formatdate, getdate, today
from frappe.utils import DateTimeLikeObject, cint, formatdate, getdate, today
class OverlapError(frappe.ValidationError):
@@ -34,7 +34,7 @@ class HolidayList(Document):
is_half_day: DF.Check
subdivision: DF.Autocomplete | None
to_date: DF.Date
total_holidays: DF.Int
total_holidays: DF.Float
weekly_off: DF.Literal[
"", "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"
]
@@ -42,10 +42,13 @@ class HolidayList(Document):
def validate(self):
self.validate_days()
self.total_holidays = len(self.holidays)
self.update_total_holidays()
self.validate_duplicate_date()
self.sort_holidays()
def update_total_holidays(self):
self.total_holidays = sum(0.5 if cint(holiday.is_half_day) else 1 for holiday in self.holidays)
@frappe.whitelist()
def get_weekly_off_dates(self):
if not self.weekly_off:
@@ -67,6 +70,8 @@ class HolidayList(Document):
},
)
self.update_total_holidays()
@frappe.whitelist()
def get_supported_countries(self):
from holidays.utils import list_supported_countries
@@ -108,6 +113,8 @@ class HolidayList(Document):
"holidays", {"description": holiday_name, "holiday_date": holiday_date, "weekly_off": 0}
)
self.update_total_holidays()
def sort_holidays(self):
self.holidays.sort(key=lambda x: (x.weekly_off, getdate(x.holiday_date)))
for i in range(len(self.holidays)):
@@ -153,6 +160,7 @@ class HolidayList(Document):
@frappe.whitelist()
def clear_table(self):
self.set("holidays", [])
self.update_total_holidays()
def validate_duplicate_date(self):
unique_dates = []

View File

@@ -4,7 +4,7 @@ from contextlib import contextmanager
from datetime import date, timedelta
import frappe
from frappe.utils import getdate
from frappe.utils import get_datetime, getdate
from erpnext.setup.doctype.holiday_list.holiday_list import local_country_name
from erpnext.tests.utils import ERPNextTestSuite
@@ -45,6 +45,94 @@ class TestHolidayList(ERPNextTestSuite):
self.assertIn(date(2023, 2, 26), holidays)
self.assertNotIn(date(2023, 3, 5), holidays)
def test_total_holidays_includes_half_days(self):
holiday_list = make_holiday_list(
"test_half_day_holiday_list",
from_date="2023-01-01",
to_date="2023-01-03",
holiday_dates=[
{"holiday_date": "2023-01-01", "description": "Full-day holiday"},
{
"holiday_date": "2023-01-02",
"description": "Half-day holiday",
"is_half_day": 1,
},
],
)
self.assertEqual(holiday_list.total_holidays, 1.5)
self.assertEqual(frappe.db.get_value("Holiday List", holiday_list.name, "total_holidays"), 1.5)
def test_weekly_off_updates_total_without_saving(self):
holiday_list = frappe.new_doc("Holiday List")
holiday_list.from_date = "2023-01-01"
holiday_list.to_date = "2023-01-14"
holiday_list.weekly_off = "Saturday"
holiday_list.is_half_day = 1
holiday_list.append("holidays", {"holiday_date": "2023-01-01", "description": "Full day"})
holiday_list.get_weekly_off_dates()
self.assertEqual(len(holiday_list.holidays), 3)
self.assertEqual(holiday_list.total_holidays, 2)
holiday_list.get_weekly_off_dates()
self.assertEqual(len(holiday_list.holidays), 3)
self.assertEqual(holiday_list.total_holidays, 2)
holiday_list.clear_table()
self.assertEqual(holiday_list.holidays, [])
self.assertEqual(holiday_list.total_holidays, 0)
def test_local_holidays_updates_total_without_saving(self):
holiday_list = frappe.new_doc("Holiday List")
holiday_list.from_date = "2023-01-01"
holiday_list.to_date = "2023-01-02"
holiday_list.country = "DE"
holiday_list.append(
"holidays", {"holiday_date": "2023-01-02", "description": "Half day", "is_half_day": 1}
)
holiday_list.get_local_holidays()
self.assertEqual(len(holiday_list.holidays), 2)
self.assertEqual(holiday_list.total_holidays, 1.5)
holiday_list.get_local_holidays()
self.assertEqual(len(holiday_list.holidays), 2)
self.assertEqual(holiday_list.total_holidays, 1.5)
def test_recalculate_existing_holiday_list_totals(self):
from erpnext.patches.v16_0.recalculate_holiday_list_totals import execute
cases = (("mixed", [0, 1], 1.5), ("half", [1, 1, 1], 1.5), ("full", [0, 0], 2), ("empty", [], 0))
holiday_lists = []
for name, half_days, expected in cases:
holiday_list = make_holiday_list(
f"test_backfill_holidays_{name}",
from_date="2023-01-01",
to_date="2023-01-03",
holiday_dates=[
{
"holiday_date": date(2023, 1, idx),
"description": "Test holiday",
"is_half_day": is_half_day,
}
for idx, is_half_day in enumerate(half_days, start=1)
],
)
# Simulate totals persisted by the old controller, including a stale empty list.
holiday_list.db_set("total_holidays", len(half_days) or 1, update_modified=False)
holiday_lists.append((holiday_list, expected))
for _ in range(2):
execute()
for holiday_list, expected in holiday_lists:
with self.subTest(holiday_list=holiday_list.name):
total, modified = frappe.db.get_value(
"Holiday List", holiday_list.name, ["total_holidays", "modified"]
)
self.assertEqual(total, expected)
self.assertEqual(modified, get_datetime(holiday_list.modified))
def test_local_holidays(self):
holiday_list = frappe.new_doc("Holiday List")
holiday_list.from_date = "2022-01-01"

View File

@@ -29,6 +29,9 @@ class BillingStatusService:
def update_billing_status(self, update_modified: bool = True) -> None:
doc = self.doc
updated_delivery_notes = [doc.name]
if doc.is_return and doc.return_against:
updated_delivery_notes.append(doc.return_against)
for d in doc.get("items"):
if d.si_detail and not d.so_detail:
d.db_set("billed_amt", d.amount, update_modified=update_modified)
@@ -37,7 +40,8 @@ class BillingStatusService:
for dn in set(updated_delivery_notes):
dn_doc = doc if (dn == doc.name) else frappe.get_lazy_doc("Delivery Note", dn)
dn_doc.update_billing_percentage(update_modified=update_modified)
update_dn_modified = update_modified and dn != doc.return_against
dn_doc.update_billing_percentage(update_modified=update_dn_modified)
doc.load_from_db()

View File

@@ -1060,6 +1060,56 @@ class TestDeliveryNote(ERPNextTestSuite):
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.status, "Completed")
def test_dn_is_completed_when_unbilled_item_is_returned(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
make_stock_entry(target="_Test Warehouse - _TC", qty=1, basic_rate=100)
make_stock_entry(item_code="_Test Item 2", target="_Test Warehouse - _TC", qty=1, basic_rate=100)
dn = create_delivery_note(do_not_submit=True)
dn.append(
"items",
{
"item_code": "_Test Item 2",
"warehouse": "_Test Warehouse - _TC",
"qty": 1,
"rate": 100,
"conversion_factor": 1,
"allow_zero_valuation_rate": 1,
"expense_account": "Cost of Goods Sold - _TC",
"cost_center": "_Test Cost Center - _TC",
},
)
dn.submit()
si = make_sales_invoice(dn.name)
si.set("items", [item for item in si.items if item.item_code == "_Test Item"])
si.insert()
si.submit()
dn.reload()
self.assertEqual(dn.per_billed, 50)
self.assertEqual(dn.status, "Partially Billed")
return_dn = make_sales_return(dn.name)
return_dn.set("items", [item for item in return_dn.items if item.item_code == "_Test Item 2"])
return_dn.insert()
# Mimic the submit request, which reconstructs the document from client data.
return_dn = frappe.get_doc(return_dn.as_dict())
return_dn.submit()
dn.reload()
self.assertEqual(dn.items[1].returned_qty, 1)
self.assertEqual(dn.per_billed, 100)
self.assertEqual(dn.status, "Completed")
return_dn.cancel()
dn.reload()
self.assertEqual(dn.items[1].returned_qty, 0)
self.assertEqual(dn.per_billed, 50)
self.assertEqual(dn.status, "Partially Billed")
def test_dn_billing_status_case2(self):
# SO -> SI and SO -> DN1, DN2
from erpnext.selling.doctype.sales_order.mapper import (

View File

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

View File

@@ -219,6 +219,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()
@@ -398,6 +399,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

View File

@@ -377,7 +377,7 @@
"idx": 70,
"is_submittable": 1,
"links": [],
"modified": "2026-08-21 23:11:44.554719",
"modified": "2026-09-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Stock",
"name": "Material Request",
@@ -442,6 +442,11 @@
"submit": 1,
"write": 1
},
{
"read": 1,
"report": 1,
"role": "Manufacturing Manager"
},
{
"role": "Delivery Manager",
"select": 1

View File

@@ -26,17 +26,10 @@ class StockEntryGLComposer(BaseStockGLComposer):
doc = self.doc
gl_entries = super().compose(inventory_account_map)
if doc.purpose in ("Repack", "Manufacture"):
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.is_finished_item)
else:
total_basic_amount = sum(flt(t.basic_amount) for t in doc.get("items") if t.t_warehouse)
divide_based_on = total_basic_amount
if doc.get("additional_costs") and not total_basic_amount:
divide_based_on = sum(item.qty for item in doc.get("items"))
incoming_items, basis, divide_based_on = doc.get_additional_cost_allocation()
item_account_wise_additional_cost = self._build_additional_cost_per_item_account(
total_basic_amount, divide_based_on
incoming_items, basis, divide_based_on
)
if item_account_wise_additional_cost:
self._append_additional_cost_gl_entries(gl_entries, item_account_wise_additional_cost)
@@ -183,24 +176,20 @@ class StockEntryGLComposer(BaseStockGLComposer):
)
def _build_additional_cost_per_item_account(
self, total_basic_amount: float, divide_based_on: float
self, incoming_items: list, basis: str, divide_based_on: float
) -> dict:
doc = self.doc
item_account_wise_additional_cost = {}
if not divide_based_on:
return item_account_wise_additional_cost
for t in doc.get("additional_costs"):
for d in doc.get("items"):
if doc.purpose in ("Repack", "Manufacture") and not d.is_finished_item:
continue
elif not d.t_warehouse:
continue
for t in self.doc.get("additional_costs"):
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))
entry = item_account_wise_additional_cost[(d.item_code, d.name)][t.expense_account]
entry["amount"] += flt(t.amount * multiply_based_on) / divide_based_on
entry["base_amount"] += flt(t.base_amount * multiply_based_on) / divide_based_on

View File

@@ -920,22 +920,28 @@ class StockEntry(StockController, SubcontractingInwardController):
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, reset_outgoing_rate=True):
for d in self.get("items"):

View File

@@ -2233,10 +2233,12 @@ class TestStockEntry(ERPNextTestSuite):
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):
@@ -2282,6 +2284,184 @@ class TestStockEntry(ERPNextTestSuite):
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",

View File

@@ -228,7 +228,11 @@ def get_rate_locked_source_row(ctx: ItemDetailsCtx, doc) -> frappe._dict | None:
if not source_fields or not doc or ctx.get("is_return") or not maintain_same_rate_enabled(ctx):
return None
row = next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None)
row = (
next((d for d in doc.get("items") or [] if d.get("name") == ctx.child_docname), None)
if ctx.child_docname
else ctx
)
if not row:
return None

View File

@@ -458,6 +458,69 @@ class TestGetItemDetail(ERPNextTestSuite):
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")
def test_rate_lock_matches_unsaved_mapped_row(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.stock.get_item_details import get_rate_locked_source_row
original = frappe.db.get_single_value("Buying Settings", "maintain_same_rate")
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", 1)
frappe.clear_cache(doctype="Buying Settings")
try:
first_po = create_purchase_order(rate=100)
second_po = create_purchase_order(rate=200)
pr_doc = {
"doctype": "Purchase Receipt",
"items": [
{"name": None, "purchase_order_item": first_po.items[0].name},
{"name": None, "purchase_order_item": second_po.items[0].name},
],
}
ctx = frappe._dict(
doctype="Purchase Receipt",
child_docname=None,
purchase_order_item=second_po.items[0].name,
)
source_row = get_rate_locked_source_row(ctx, pr_doc)
self.assertEqual(source_row.rate, 200)
finally:
frappe.db.set_single_value("Buying Settings", "maintain_same_rate", original)
frappe.clear_cache(doctype="Buying Settings")
@ERPNextTestSuite.change_settings("Selling Settings", {"maintain_same_sales_rate": 1})
def test_delivery_note_to_sales_invoice_keeps_item_rates(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
first_item, first_batches = self.make_batched_item_with_stock([1])
second_item, second_batches = self.make_batched_item_with_stock([1])
dn = create_delivery_note(
item_code=first_item,
qty=1,
rate=100,
batch_no=first_batches[0],
use_serial_batch_fields=1,
do_not_save=True,
)
dn.append(
"items",
{
"item_code": second_item,
"warehouse": "_Test Warehouse - _TC",
"qty": 1,
"rate": 200,
"conversion_factor": 1,
"batch_no": second_batches[0],
"use_serial_batch_fields": 1,
},
)
dn.insert()
dn.submit()
si = make_sales_invoice(dn.name)
self.assertEqual([item.rate for item in si.items], [100, 200])
def make_batched_item_with_stock(self, quantities, uoms=None, **properties):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (

View File

@@ -113,6 +113,8 @@ def make_purchase_receipt(
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",
"reset_value": True,
# for POs created in earlier version with tax_withholding_row
"condition": lambda doc: not doc.is_tax_withholding_account,
},
},
postprocess=post_process,

View File

@@ -1627,6 +1627,73 @@ class TestSubcontractingReceipt(ERPNextTestSuite):
self.assertEqual(pr_details[0]["total_taxes_and_charges"], 60)
@ERPNextTestSuite.change_settings("Buying Settings", {"auto_create_purchase_receipt": 1})
def test_auto_create_purchase_receipt_with_tax_withholding_row(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
fg_item = "Subcontracted Item SA1"
service_items = [
{
"warehouse": "_Test Warehouse - _TC",
"item_code": "Subcontracted Service Item 1",
"qty": 10,
"rate": 100,
"fg_item": fg_item,
"fg_item_qty": 5,
},
]
po = create_purchase_order(
rm_items=service_items,
is_subcontracted=1,
supplier_warehouse="_Test Warehouse 1 - _TC",
do_not_submit=True,
)
# withheld against the full PO value, and not recomputed on a partial receipt
po.append(
"taxes",
{
"account_head": "_Test Account Excise Duty - _TC",
"charge_type": "Actual",
"add_deduct_tax": "Deduct",
"cost_center": "_Test Cost Center - _TC",
"description": "TDS on Contract",
"doctype": "Purchase Taxes and Charges",
"tax_amount": 800,
"is_tax_withholding_account": 1,
},
)
po.save()
po.submit()
self.assertEqual(po.grand_total, 200)
sco = get_subcontracting_order(po_name=po.name)
rm_items = get_rm_items(sco.supplied_items)
itemwise_details = make_stock_in_entry(rm_items=rm_items)
make_stock_transfer_entry(
sco_no=sco.name,
rm_items=rm_items,
itemwise_details=copy.deepcopy(itemwise_details),
)
scr = make_subcontracting_receipt(sco.name)
scr.items[0].qty = 3
scr.save()
# carrying the withholding row over would deduct 800 from a 600 receipt,
# and Purchase Receipt rejects the resulting negative Grand Total
scr.submit()
pr_name = frappe.db.get_value("Purchase Receipt", {"subcontracting_receipt": scr.name})
self.assertTrue(pr_name)
pr = frappe.get_doc("Purchase Receipt", pr_name)
self.assertEqual(pr.items[0].qty, 6)
self.assertEqual(pr.net_total, 600)
self.assertFalse([row for row in pr.taxes if row.is_tax_withholding_account])
self.assertEqual(pr.grand_total, 600)
@ERPNextTestSuite.change_settings("Buying Settings", {"auto_create_purchase_receipt": 1})
def test_auto_create_purchase_receipt_with_no_reference_of_po_item(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order

View File

@@ -117,12 +117,55 @@ class Issue(Document):
communication.flags.ignore_mandatory = True
communication.save()
def get_timeline_communications(self, after=None) -> tuple[set[str], set[str]]:
"""Return the Communications on this Issue's timeline, split by how they are attached.
Mirrors the two sources `frappe.desk.form.load.get_communication_data` reads, since
Split is offered on every timeline item. `after` matches `communication_date`, what
the timeline is ordered by, not `creation`: a pulled email is created when fetched.
"""
date_filter = {"communication_date": (">=", after)} if after else {}
referenced = frappe.get_all(
"Communication",
filters={"reference_doctype": "Issue", "reference_name": self.name, **date_filter},
pluck="name",
)
link_parents = frappe.get_all(
"Communication Link",
filters={"link_doctype": "Issue", "link_name": self.name},
pluck="parent",
)
linked = (
frappe.get_all(
"Communication",
filters={"name": ("in", link_parents), **date_filter},
pluck="name",
)
if link_parents
else []
)
return set(referenced), set(linked)
@frappe.whitelist(methods=["POST"])
def split_issue(self, subject: str, communication_id: str):
from copy import deepcopy
self.check_permission("write")
referenced, linked = self.get_timeline_communications()
if communication_id not in referenced | linked:
frappe.throw(
_("Communication {0} is not on the timeline of Issue {1}").format(
communication_id, self.name
),
frappe.PermissionError,
)
comm_to_split_from = frappe.get_doc("Communication", communication_id)
replicated_issue = deepcopy(self)
replicated_issue.subject = subject
replicated_issue.issue_split_from = self.name
@@ -141,21 +184,22 @@ class Issue(Document):
frappe.get_doc(replicated_issue).insert()
# Replicate linked Communications
# TODO: get all communications in timeline before this, and modify them to append them to new doc
comm_to_split_from = frappe.get_doc("Communication", communication_id)
communications = frappe.get_all(
"Communication",
filters={
"reference_doctype": "Issue",
"reference_name": comm_to_split_from.reference_name,
"creation": (">=", comm_to_split_from.creation),
},
)
# Move the whole timeline from the split point onwards, both the Communications that
# reference this Issue and the ones only joined to it through a Timeline Link.
referenced, linked = self.get_timeline_communications(after=comm_to_split_from.communication_date)
for name in sorted(referenced | linked):
doc = frappe.get_doc("Communication", name)
if name in referenced:
doc.reference_name = replicated_issue.name
# A Timeline Link is this Issue's own handle on the Communication, so it moves with
# the split. Its reference belongs to some other document and is left alone.
for link in doc.timeline_links:
if link.link_doctype == "Issue" and link.link_name == self.name:
link.link_name = replicated_issue.name
for communication in communications:
doc = frappe.get_doc("Communication", communication.name)
doc.reference_name = replicated_issue.name
doc.save(ignore_permissions=True)
frappe.get_doc(

View File

@@ -641,3 +641,201 @@ def create_communication(reference_name, sender, sent_or_received, creation):
}
)
communication.save()
class TestSplitIssue(ERPNextTestSuite):
def setUp(self):
frappe.set_user("Administrator")
frappe.db.set_single_value("Support Settings", "track_service_level_agreement", 0)
def tearDown(self):
frappe.set_user("Administrator")
super().tearDown()
def make_issue(self, subject):
issue = frappe.get_doc(
{
"doctype": "Issue",
"subject": subject,
"raised_by": "split-test@example.com",
"status": "Open",
}
).insert(ignore_permissions=True)
# split_issue() deepcopies self, so it has to start from a document loaded off the
# database the way the desk caller hands it one, not from the freshly inserted object
return frappe.get_doc("Issue", issue.name)
def make_communication(self, subject, communication_date, reference=None, link_to=None, creation=None):
"""A Communication attached to an Issue by reference, by Timeline Link, or by both."""
communication = frappe.get_doc(
{
"doctype": "Communication",
"communication_type": "Communication",
"communication_medium": "Email",
"sent_or_received": "Received",
"subject": subject,
"content": subject,
"sender": "split-test@example.com",
"status": "Linked",
"communication_date": get_datetime(communication_date) if communication_date else None,
"reference_doctype": "Issue" if reference else None,
"reference_name": reference,
}
).insert(ignore_permissions=True)
if link_to:
communication.add_link("Issue", link_to, autosave=True)
if not communication_date:
# the field defaults to Now whenever it is unset, so leaving it genuinely empty --
# as an import or an API caller can -- means blanking it after the insert
frappe.db.set_value(
"Communication", communication.name, "communication_date", None, update_modified=False
)
communication.reload()
if creation:
# insert() always stamps creation with the current time, so a test that needs it to
# disagree with communication_date has to write it afterwards
frappe.db.set_value(
"Communication",
communication.name,
"creation",
get_datetime(creation),
update_modified=False,
)
communication.reload()
return communication
def linked_issues(self, communication):
return [
link.link_name
for link in frappe.get_doc("Communication", communication).timeline_links
if link.link_doctype == "Issue"
]
def test_split_moves_referenced_communications_from_the_split_point(self):
issue = self.make_issue("Split source")
first = self.make_communication("First", "2024-01-01 10:00:00", reference=issue.name)
second = self.make_communication("Second", "2024-01-01 11:00:00", reference=issue.name)
third = self.make_communication("Third", "2024-01-01 12:00:00", reference=issue.name)
split = issue.split_issue(subject="Split target", communication_id=second.name)
self.assertEqual(frappe.db.get_value("Communication", first.name, "reference_name"), issue.name)
self.assertEqual(frappe.db.get_value("Communication", second.name, "reference_name"), split)
self.assertEqual(frappe.db.get_value("Communication", third.name, "reference_name"), split)
self.assertEqual(frappe.db.get_value("Issue", split, "issue_split_from"), issue.name)
def test_split_follows_the_timeline_order_not_the_insertion_order(self):
"""The split point is read off the timeline, which is ordered by communication_date.
A pulled email is created when it is fetched, so creation can run the other way.
"""
issue = self.make_issue("Split source")
first = self.make_communication(
"Sent first, fetched last", "2024-01-01 10:00:00", reference=issue.name, creation="2024-06-03"
)
second = self.make_communication(
"Sent second", "2024-01-01 11:00:00", reference=issue.name, creation="2024-06-02"
)
third = self.make_communication(
"Sent last, fetched first", "2024-01-01 12:00:00", reference=issue.name, creation="2024-06-01"
)
split = issue.split_issue(subject="Split target", communication_id=second.name)
self.assertEqual(frappe.db.get_value("Communication", first.name, "reference_name"), issue.name)
self.assertEqual(frappe.db.get_value("Communication", second.name, "reference_name"), split)
self.assertEqual(frappe.db.get_value("Communication", third.name, "reference_name"), split)
def test_split_treats_an_undated_communication_as_the_bottom_of_the_timeline(self):
"""communication_date is not mandatory, and an undated item sits below every split point."""
# Split from a dated item: the undated one is below the split point, so it stays behind.
issue = self.make_issue("Split source")
undated = self.make_communication("Undated", None, reference=issue.name)
first = self.make_communication("First", "2024-01-01 10:00:00", reference=issue.name)
second = self.make_communication("Second", "2024-01-01 11:00:00", reference=issue.name)
split = issue.split_issue(subject="Split target", communication_id=first.name)
self.assertEqual(frappe.db.get_value("Communication", undated.name, "reference_name"), issue.name)
self.assertEqual(frappe.db.get_value("Communication", first.name, "reference_name"), split)
self.assertEqual(frappe.db.get_value("Communication", second.name, "reference_name"), split)
# Split from the undated item itself: a null split point drops the date filter, so the
# whole timeline above it -- which is everything -- moves.
source = self.make_issue("Undated split source")
from_undated = self.make_communication("Undated", None, reference=source.name)
dated = self.make_communication("Dated", "2024-01-01 10:00:00", reference=source.name)
whole = source.split_issue(subject="Split target", communication_id=from_undated.name)
self.assertEqual(frappe.db.get_value("Communication", from_undated.name, "reference_name"), whole)
self.assertEqual(frappe.db.get_value("Communication", dated.name, "reference_name"), whole)
def test_split_moves_timeline_linked_communications(self):
"""A Communication on the timeline only through a Timeline Link moves with the split."""
issue = self.make_issue("Split source")
referenced = self.make_communication("Referenced", "2024-01-01 10:00:00", reference=issue.name)
linked = self.make_communication("Linked only", "2024-01-01 11:00:00", link_to=issue.name)
split = issue.split_issue(subject="Split target", communication_id=referenced.name)
self.assertEqual(frappe.db.get_value("Communication", referenced.name, "reference_name"), split)
self.assertEqual(self.linked_issues(linked.name), [split])
def test_split_from_a_timeline_linked_communication(self):
"""The Split button is offered on link-only timeline items, so they are valid split points."""
issue = self.make_issue("Split source")
linked = self.make_communication("Linked only", "2024-01-01 10:00:00", link_to=issue.name)
later = self.make_communication("Later", "2024-01-01 11:00:00", reference=issue.name)
split = issue.split_issue(subject="Split target", communication_id=linked.name)
self.assertEqual(self.linked_issues(linked.name), [split])
self.assertEqual(frappe.db.get_value("Communication", later.name, "reference_name"), split)
def test_split_leaves_the_reference_of_a_linked_communication_alone(self):
"""Moving a Timeline Link must not rewrite a reference that belongs to another Issue."""
issue = self.make_issue("Split source")
other = self.make_issue("Unrelated issue")
shared = self.make_communication(
"Shared", "2024-01-01 10:00:00", reference=other.name, link_to=issue.name
)
split = issue.split_issue(subject="Split target", communication_id=shared.name)
self.assertEqual(self.linked_issues(shared.name), [split])
self.assertEqual(frappe.db.get_value("Communication", shared.name, "reference_name"), other.name)
def test_split_rejects_a_communication_from_another_issue(self):
issue = self.make_issue("Split source")
self.make_communication("Own", "2024-01-01 10:00:00", reference=issue.name)
other = self.make_issue("Other issue")
theirs = self.make_communication("Theirs", "2024-01-01 09:00:00", reference=other.name)
also_theirs = self.make_communication("Also theirs", "2024-01-01 10:30:00", reference=other.name)
self.assertRaises(frappe.PermissionError, issue.split_issue, "Split target", theirs.name)
self.assertEqual(frappe.db.get_value("Communication", theirs.name, "reference_name"), other.name)
self.assertEqual(frappe.db.get_value("Communication", also_theirs.name, "reference_name"), other.name)
def test_split_rejects_a_communication_that_is_not_on_the_timeline(self):
issue = self.make_issue("Split source")
unattached = self.make_communication("Unattached", "2024-01-01 10:00:00")
self.assertRaises(frappe.PermissionError, issue.split_issue, "Split target", unattached.name)
def test_split_requires_write_permission_on_the_issue(self):
issue = self.make_issue("Split source")
own = self.make_communication("Own", "2024-01-01 10:00:00", reference=issue.name)
frappe.set_user(create_user("split-no-roles@example.com").email)
self.assertRaises(
frappe.PermissionError, frappe.get_doc("Issue", issue.name).split_issue, "Split target", own.name
)