mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-09 07:19:31 +00:00
Compare commits
24 Commits
codex/seri
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bdeaa672 | ||
|
|
4c32acf300 | ||
|
|
e93ca84398 | ||
|
|
afd93cf867 | ||
|
|
51fb261b6b | ||
|
|
f8c2f3440b | ||
|
|
991cea5ae2 | ||
|
|
11d2847d3e | ||
|
|
e0d6d797d7 | ||
|
|
d5a9d158f9 | ||
|
|
fa89552d10 | ||
|
|
cee9f4949a | ||
|
|
218e7927ff | ||
|
|
36a4dfe797 | ||
|
|
e825bb2f74 | ||
|
|
f864333afa | ||
|
|
c412310eb5 | ||
|
|
1728d1b0f5 | ||
|
|
79fc039092 | ||
|
|
467f54162f | ||
|
|
f60c349794 | ||
|
|
cadc0ca86d | ||
|
|
2f572b1624 | ||
|
|
13031d6d5d |
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
{% } %}
|
||||
{% } %}
|
||||
|
||||
@@ -114,6 +114,7 @@ def execute(filters=None):
|
||||
filters={
|
||||
"account_type": row["account_type"],
|
||||
"is_group": 0,
|
||||
"company": filters.company,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
139
erpnext/buying/test_utils.py
Normal file
139
erpnext/buying/test_utils.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
18
erpnext/patches/v16_0/recalculate_holiday_list_totals.py
Normal file
18
erpnext/patches/v16_0/recalculate_holiday_list_totals.py
Normal 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()
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -58,9 +58,10 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "total_holidays",
|
||||
"fieldtype": "Int",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Total Holidays",
|
||||
"precision": "1",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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", "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user