diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
index 595cc029e39..2bace6f90ee 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -223,7 +223,8 @@
"description": "The percentage you are allowed to bill more against the amount ordered. For example, if the order value is $100 for an item and tolerance is set as 10%, then you are allowed to bill up to $110 ",
"fieldname": "over_billing_allowance",
"fieldtype": "Currency",
- "label": "Over Billing Allowance (%)"
+ "label": "Over Billing Allowance (%)",
+ "non_negative": 1
},
{
"default": "1",
@@ -679,7 +680,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2026-08-14 13:12:47.895908",
+ "modified": "2026-09-04 10:08:30.115003",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index b4f7bfeefba..1c784bd864e 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -421,8 +421,8 @@ cur_frm.cscript.update_totals = function (doc) {
tc += flt(accounts[i].credit, precision("credit", accounts[i]));
}
doc = locals[doc.doctype][doc.name];
- doc.total_debit = td;
- doc.total_credit = tc;
+ doc.total_debit = flt(td, precision("total_debit"));
+ doc.total_credit = flt(tc, precision("total_credit"));
doc.difference = flt(td - tc, precision("difference"));
refresh_many(["total_debit", "total_credit", "difference"]);
};
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py
index 2069593992d..0f7ab4e5a23 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py
@@ -861,12 +861,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 = []
diff --git a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
index 61ab04d2a8b..60b569f67f0 100644
--- a/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
+++ b/erpnext/accounts/doctype/journal_entry/test_journal_entry.py
@@ -410,6 +410,59 @@ class TestJournalEntry(unittest.TestCase):
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
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index 5a5815ae4c7..50f50b553cc 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -45,23 +45,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,
};
});
@@ -105,21 +109,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,
};
});
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index a16e6d01639..31abb641760 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -90,6 +90,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()
@@ -531,6 +532,10 @@ class PaymentEntry(AccountsController):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay and 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))
diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
index 1c010e7d74b..a274975b736 100644
--- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py
@@ -733,6 +733,23 @@ class TestPaymentEntry(FrappeTestCase):
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_payment_against_negative_sales_invoice(self):
si1 = create_sales_invoice()
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index 259cd970941..3f90575ab4c 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -375,9 +375,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
get_query_filters: {
docstatus: 1,
status: ["not in", ["Closed", "On Hold"]],
- per_billed: ["<", 99.99],
company: me.frm.doc.company,
},
+ get_query_method:
+ "erpnext.selling.doctype.sales_order.sales_order.get_potentially_billable_sales_orders",
allow_child_item_selection: true,
child_fieldname: "items",
child_columns: ["item_code", "item_name", "qty", "amount", "billed_amt"],
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 59e72ced503..8e03802f54b 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -1637,7 +1637,7 @@ class SalesInvoice(SellingController):
for payment_mode in self.payments:
if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount:
- payment_mode.base_amount -= flt(self.change_amount)
+ payment_mode.base_amount -= flt(self.base_change_amount)
against_voucher = self.name
if self.is_return and self.return_against and not self.update_outstanding_for_self:
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index bca0d58a57d..e0da8487485 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -1235,6 +1235,33 @@ class TestSalesInvoice(FrappeTestCase):
frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1)
+ def test_pos_change_amount_multi_currency_gl_entry(self):
+ frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 0)
+
+ si = create_sales_invoice(do_not_save=True)
+ si.is_pos = 1
+ si.currency = "USD"
+ si.conversion_rate = 50
+ si.party_account_currency = "USD"
+ si.account_for_change_amount = "Cash - _TC"
+ si.change_amount = 50
+ si.base_change_amount = 2500
+ si.append(
+ "payments",
+ {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 150, "base_amount": 7500},
+ )
+
+ gl_entries = []
+ si.make_pos_gl_entries(gl_entries)
+
+ debtors_entry = next(entry for entry in gl_entries if entry["account"] == si.debit_to)
+ cash_entry = next(entry for entry in gl_entries if entry["account"] == "Cash - _TC")
+
+ self.assertEqual(flt(debtors_entry["credit"]), 5000.0)
+ self.assertEqual(flt(cash_entry["debit"]), 5000.0)
+
+ frappe.db.set_single_value("Accounts Settings", "post_change_gl_entries", 1)
+
def validate_pos_gl_entry(self, si, pos, cash_amount, validate_without_change_gle=False):
if validate_without_change_gle:
cash_amount -= pos.change_amount
diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
index ef8c9a193c4..61a6930af55 100644
--- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py
@@ -167,6 +167,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,
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 3395ad3a34a..1771b7c4ae1 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -1175,6 +1175,28 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
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
diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py
index d476fd4b3ef..de39f5c1424 100644
--- a/erpnext/accounts/report/cash_flow/cash_flow.py
+++ b/erpnext/accounts/report/cash_flow/cash_flow.py
@@ -99,6 +99,7 @@ def execute(filters=None):
filters={
"account_type": row["account_type"],
"is_group": 0,
+ "company": filters.company,
},
pluck="name",
)
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index f557635aac1..35b8606d501 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -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
@@ -510,6 +512,7 @@ class GrossProfitGenerator:
self.average_buying_rate = {}
self.filters = frappe._dict(filters)
self.load_invoice_items()
+ self.load_drop_ship_buying_rates()
self.get_delivery_notes()
self.load_product_bundle()
@@ -518,6 +521,7 @@ class GrossProfitGenerator:
self.load_non_stock_items()
self.get_returned_invoice_items()
+ self.allocate_legacy_return_items()
self.process()
def process(self):
@@ -534,6 +538,8 @@ class GrossProfitGenerator:
base_amount = 0
for row in reversed(self.si_list):
+ sales_invoice_item = row.item_row
+
if self.filters.get("group_by") == "Monthly":
row.monthly = formatdate(row.posting_date, "MMM YYYY")
@@ -585,14 +591,18 @@ class GrossProfitGenerator:
# get buying rate
if flt(row.qty):
- row.buying_rate = flt(row.buying_amount / flt(row.qty), self.float_precision)
+ row.buying_rate = (
+ flt(row.buying_amount / flt(row.qty), self.float_precision)
+ if not row.delivered_by_supplier
+ else None
+ )
row.base_rate = flt(row.base_amount / flt(row.qty), self.float_precision)
else:
if self.is_not_invoice_row(row):
row.buying_rate, row.base_rate = 0.0, 0.0
if self.is_not_invoice_row(row):
- self.update_return_invoices(row)
+ self.update_return_invoices(row, sales_invoice_item)
if grouped_by_invoice and row.indent == 1.0:
buying_amount += row.buying_amount
@@ -619,24 +629,31 @@ class GrossProfitGenerator:
if self.grouped:
self.get_average_rate_based_on_group_by()
- def update_return_invoices(self, row):
- if row.parent in self.returned_invoices and row.item_code in self.returned_invoices[row.parent]:
- returned_item_rows = self.returned_invoices[row.parent][row.item_code]
- for returned_item_row in returned_item_rows:
- # returned_items 'qty' should be stateful
- if returned_item_row.qty != 0:
- if row.qty >= abs(returned_item_row.qty):
- row.qty += returned_item_row.qty
- row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
- returned_item_row.qty = 0
- returned_item_row.base_amount = 0
+ def update_return_invoices(self, row, sales_invoice_item):
+ returned_item_rows = self.returned_invoices.get(row.parent, {}).get(sales_invoice_item)
+ if not returned_item_rows:
+ return
- else:
- row.qty = 0
- row.base_amount = 0
- returned_item_row.qty += row.qty
- returned_item_row.base_amount += row.base_amount
+ for returned_item_row in returned_item_rows:
+ # returned_items 'qty' should be stateful
+ if returned_item_row.qty != 0:
+ if row.qty >= abs(returned_item_row.qty):
+ row.qty += returned_item_row.qty
+ row.base_amount += flt(returned_item_row.base_amount, self.currency_precision)
+ returned_item_row.qty = 0
+ returned_item_row.base_amount = 0
+ else:
+ returned_item_row.qty += row.qty
+ returned_item_row.base_amount += row.base_amount
+ row.qty = 0
+ row.base_amount = 0
+
+ if row.delivered_by_supplier:
+ buying_amount = self.get_drop_ship_buying_amount(row)
+ if buying_amount is not None:
+ row.buying_amount = flt(buying_amount, self.currency_precision)
+ else:
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
def get_average_rate_based_on_group_by(self):
@@ -718,7 +735,8 @@ class GrossProfitGenerator:
returned_invoices = frappe.db.sql(
"""
select
- si.name, si_item.item_code, si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against
+ si.name, si_item.item_code, si_item.sales_invoice_item,
+ si_item.stock_qty as qty, si_item.base_net_amount as base_amount, si.return_against
from
`tabSales Invoice` si, `tabSales Invoice Item` si_item
where
@@ -732,17 +750,73 @@ class GrossProfitGenerator:
)
self.returned_invoices = frappe._dict()
+ self.legacy_returned_invoices = frappe._dict()
for inv in returned_invoices:
- self.returned_invoices.setdefault(inv.return_against, frappe._dict()).setdefault(
- inv.item_code, []
+ invoice_returns = (
+ self.returned_invoices if inv.sales_invoice_item else self.legacy_returned_invoices
+ )
+ invoice_returns.setdefault(inv.return_against, frappe._dict()).setdefault(
+ inv.sales_invoice_item or inv.item_code, []
).append(inv)
- def skip_row(self, row):
- if self.filters.get("group_by") != "Invoice":
- if not row.get(scrub(self.filters.get("group_by", ""))):
- return True
+ def allocate_legacy_return_items(self):
+ source_invoice_items = {}
+ for row in reversed(self.si_list):
+ if row.is_return or not row.parent or self.skip_row(row):
+ continue
- return False
+ source_invoice_items.setdefault((row.parent, row.item_code), {}).setdefault(row.item_row, row.qty)
+
+ for invoice, legacy_invoice_items in self.legacy_returned_invoices.items():
+ returned_invoice_items = self.returned_invoices.setdefault(invoice, frappe._dict())
+ for item_code, legacy_item_rows in legacy_invoice_items.items():
+ targets = self.get_legacy_return_targets(
+ source_invoice_items.get((invoice, item_code), {}), returned_invoice_items
+ )
+ for legacy_item_row in legacy_item_rows:
+ self.allocate_legacy_return_item(legacy_item_row, targets, returned_invoice_items)
+
+ def get_legacy_return_targets(self, source_invoice_items, returned_invoice_items):
+ targets = []
+ for item_row, qty in source_invoice_items.items():
+ linked_return_qty = sum(
+ flt(returned_item.qty) for returned_item in returned_invoice_items.get(item_row, [])
+ )
+ if available_qty := max(flt(qty) + linked_return_qty, 0):
+ targets.append(frappe._dict(item_row=item_row, available_qty=available_qty))
+
+ targets.sort(key=lambda target: bool(returned_invoice_items.get(target.item_row)))
+ return targets
+
+ def allocate_legacy_return_item(self, legacy_item_row, targets, returned_invoice_items):
+ remaining_qty = abs(flt(legacy_item_row.qty))
+ remaining_base_amount = flt(legacy_item_row.base_amount)
+ if not remaining_qty:
+ return
+
+ qty_sign = -1 if legacy_item_row.qty < 0 else 1
+ for target in targets:
+ if not target.available_qty:
+ continue
+
+ allocated_qty = min(target.available_qty, remaining_qty)
+ allocated_item_row = frappe._dict(legacy_item_row.copy())
+ allocated_item_row.qty = qty_sign * allocated_qty
+ allocated_item_row.base_amount = remaining_base_amount * allocated_qty / remaining_qty
+ returned_invoice_items.setdefault(target.item_row, []).append(allocated_item_row)
+
+ target.available_qty -= allocated_qty
+ remaining_qty -= allocated_qty
+ remaining_base_amount -= allocated_item_row.base_amount
+ if not remaining_qty:
+ break
+
+ def skip_row(self, row):
+ group_by = self.filters.get("group_by")
+ if group_by in {"Invoice", "Monthly"}:
+ return False
+
+ return not row.get(scrub(group_by))
def get_buying_amount_from_product_bundle(self, row, product_bundle):
buying_amount = 0.0
@@ -776,6 +850,11 @@ class GrossProfitGenerator:
# IMP NOTE
# stock_ledger_entries should already be filtered by item_code and warehouse and
# sorted by posting_date desc, posting_time desc
+ if row.delivered_by_supplier:
+ buying_amount = self.get_drop_ship_buying_amount(row)
+ if buying_amount is not None:
+ return buying_amount
+
if item_code in self.non_stock_items and (row.project or row.cost_center):
# Issue 6089-Get last purchasing rate for non-stock item
item_rate = self.get_last_purchase_rate(item_code, row)
@@ -807,6 +886,49 @@ class GrossProfitGenerator:
return flt(row.qty) * self.get_average_buying_rate(row, item_code)
+ def load_drop_ship_buying_rates(self):
+ self.drop_ship_buying_rates = {}
+ sales_order_items = {
+ row.so_detail for row in self.si_list if row.delivered_by_supplier and row.so_detail
+ }
+ if not sales_order_items:
+ return
+
+ from frappe.query_builder.functions import Sum
+
+ purchase_order_item = frappe.qb.DocType("Purchase Order Item")
+ purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
+ buying_amounts = (
+ frappe.qb.from_(purchase_order_item)
+ .left_join(purchase_invoice_item)
+ .on(
+ (purchase_invoice_item.po_detail == purchase_order_item.name)
+ & (purchase_invoice_item.docstatus == 1)
+ )
+ .select(
+ purchase_order_item.sales_order_item,
+ Sum(purchase_invoice_item.qty * purchase_invoice_item.base_net_rate).as_("buying_amount"),
+ Sum(purchase_invoice_item.stock_qty).as_("stock_qty"),
+ )
+ .where(
+ (purchase_order_item.sales_order_item.isin(sales_order_items))
+ & (purchase_order_item.docstatus == 1)
+ )
+ .groupby(purchase_order_item.sales_order_item)
+ .run(as_dict=True)
+ )
+
+ for row in buying_amounts:
+ self.drop_ship_buying_rates[row.sales_order_item] = (
+ flt(row.buying_amount) / flt(row.stock_qty) if flt(row.stock_qty) else 0
+ )
+
+ def get_drop_ship_buying_amount(self, row):
+ if row.so_detail not in self.drop_ship_buying_rates:
+ return
+
+ return flt(row.qty) * self.drop_ship_buying_rates[row.so_detail]
+
def get_buying_amount_from_so_dn(self, sales_order, so_detail, item_code):
from frappe.query_builder.functions import Avg
@@ -951,6 +1073,7 @@ class GrossProfitGenerator:
SalesInvoice.is_return,
SalesInvoiceItem.cost_center,
SalesInvoiceItem.serial_and_batch_bundle,
+ SalesInvoiceItem.delivered_by_supplier,
)
if self.filters.group_by == "Sales Person":
diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py
index d24d472710d..c384e966897 100644
--- a/erpnext/accounts/report/gross_profit/test_gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py
@@ -1,16 +1,18 @@
import frappe
from frappe import qb
-from frappe.tests.utils import FrappeTestCase
+from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, get_first_day, get_last_day, nowdate
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_delivery_note, make_sales_return
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
-from erpnext.accounts.report.gross_profit.gross_profit import execute
+from erpnext.accounts.report.gross_profit.gross_profit import GrossProfitGenerator, execute
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+test_dependencies = ["Purchase Invoice", "Sales Invoice"]
+
class TestGrossProfit(FrappeTestCase):
def setUp(self):
@@ -881,6 +883,377 @@ class TestGrossProfit(FrappeTestCase):
self.assertEqual(gross_profit, 120.0) # 220 - 100
self.assertAlmostEqual(gp_percent, 54.545, places=2) # 120/220 * 100
+ def test_drop_ship(self):
+ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+
+ so = self.create_drop_ship_order()
+ si = make_sales_invoice(so.name).submit()
+
+ filters = frappe._dict(
+ company=si.company, from_date=si.posting_date, to_date=si.posting_date, group_by="Invoice"
+ )
+
+ _, data = execute(filters=filters)
+ self.assertEqual(data[1].buying_amount, 800)
+ self.assertIsNone(data[1].buying_rate)
+ self.assertEqual(data[1]["gross_profit_%"], 20)
+
+ def test_drop_ship_partial_billing_and_return(self):
+ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+
+ so = self.create_drop_ship_order()
+ first_invoice = make_sales_invoice(so.name)
+ first_invoice.items[0].qty = 4
+ first_invoice.submit()
+ second_invoice = make_sales_invoice(so.name).submit()
+
+ filters = frappe._dict(
+ company=first_invoice.company,
+ from_date=first_invoice.posting_date,
+ to_date=first_invoice.posting_date,
+ group_by="Invoice",
+ )
+ _, data = execute(filters=filters)
+ invoice_rows = {
+ row.parent_invoice: row
+ for row in data
+ if row.parent_invoice in {first_invoice.name, second_invoice.name} and row.indent == 1
+ }
+ self.assertEqual(invoice_rows[first_invoice.name].buying_amount, 320)
+ self.assertEqual(invoice_rows[second_invoice.name].buying_amount, 480)
+
+ sales_return = make_sales_return(first_invoice.name)
+ sales_return.items[0].qty = -2
+ sales_return.submit()
+
+ _, data = execute(filters=filters)
+ first_invoice_row = next(
+ row for row in data if row.parent_invoice == first_invoice.name and row.indent == 1
+ )
+ self.assertEqual(first_invoice_row.qty, 2)
+ self.assertEqual(first_invoice_row.buying_amount, 160)
+ self.assertEqual(first_invoice_row.gross_profit, 40)
+
+ def test_drop_ship_return_matches_sales_invoice_item(self):
+ from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
+ from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice
+ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ item = make_item(
+ "_Test Drop Ship Consolidated Return Item",
+ properties={"is_stock_item": 1, "delivered_by_supplier": 1},
+ )
+ sales_orders = []
+ for qty, selling_rate, buying_rate in [(4, 100, 50), (6, 200, 80)]:
+ sales_order = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True)
+ sales_order.items[0].delivered_by_supplier = 1
+ sales_order.items[0].supplier = "_Test Supplier"
+ sales_order.submit()
+ sales_orders.append(sales_order)
+
+ purchase_order = make_purchase_order(sales_order.name, selected_items=[sales_order.items[0]])
+ purchase_order.items[0].rate = buying_rate
+ purchase_order.supplier = "_Test Supplier"
+ purchase_order.submit()
+ make_purchase_invoice(purchase_order.name).submit()
+
+ sales_invoice = make_sales_invoice(sales_orders[0].name)
+ sales_invoice = make_sales_invoice(sales_orders[1].name, target_doc=sales_invoice).submit()
+ sales_return = make_sales_return(sales_invoice.name)
+ sales_return.set("items", [sales_return.items[0]])
+ sales_return.items[0].qty = -1
+ sales_return.submit()
+
+ filters = frappe._dict(
+ company=sales_invoice.company,
+ from_date=sales_invoice.posting_date,
+ to_date=sales_invoice.posting_date,
+ group_by="Invoice",
+ )
+ _, data = execute(filters=filters)
+ invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
+ invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
+ self.assertEqual([row.qty for row in invoice_rows], [3, 6])
+ self.assertEqual([row.buying_amount for row in invoice_rows], [150, 480])
+
+ def test_return_matches_sales_invoice_item_for_delivery_note(self):
+ make_stock_entry(
+ company=self.company,
+ item_code=self.item,
+ target=self.warehouse,
+ qty=4,
+ basic_rate=50,
+ )
+ delivery_note = self.create_delivery_note(qty=4, rate=100)
+ sales_invoice = make_sales_invoice(delivery_note.name).submit()
+ sales_return = make_sales_return(sales_invoice.name)
+ sales_return.items[0].qty = -1
+ sales_return.submit()
+
+ filters = frappe._dict(
+ company=sales_invoice.company,
+ from_date=sales_invoice.posting_date,
+ to_date=sales_invoice.posting_date,
+ group_by="Invoice",
+ )
+ _, data = execute(filters=filters)
+ invoice_row = next(
+ row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
+ )
+ self.assertEqual(invoice_row.qty, 3)
+ self.assertEqual(invoice_row.selling_amount, 300)
+
+ def test_return_combines_linked_and_legacy_item_buckets(self):
+ sales_invoice = self.create_sales_invoice(qty=4, rate=100)
+ linked_return = make_sales_return(sales_invoice.name)
+ linked_return.items[0].qty = -1
+ linked_return.submit()
+
+ legacy_return = make_sales_return(sales_invoice.name)
+ legacy_return.items[0].qty = -1
+ legacy_return.submit()
+ frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
+
+ filters = frappe._dict(
+ company=sales_invoice.company,
+ from_date=sales_invoice.posting_date,
+ to_date=sales_invoice.posting_date,
+ group_by="Invoice",
+ )
+ _, data = execute(filters=filters)
+ invoice_row = next(
+ row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1
+ )
+ self.assertEqual(invoice_row.qty, 2)
+ self.assertEqual(invoice_row.selling_amount, 200)
+
+ @change_settings("Selling Settings", {"allow_multiple_items": True})
+ def test_legacy_return_prefers_item_without_linked_return(self):
+ sales_invoice = self.create_sales_invoice(qty=2, rate=100, do_not_submit=True)
+ second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
+ second_item.idx = None
+ second_item.rate = 200
+ sales_invoice.append("items", second_item)
+ sales_invoice.submit()
+
+ linked_return = make_sales_return(sales_invoice.name)
+ linked_return.set("items", [linked_return.items[0]])
+ linked_return.items[0].qty = -1
+ linked_return.submit()
+
+ legacy_return = make_sales_return(sales_invoice.name)
+ legacy_return.set("items", [legacy_return.items[1]])
+ legacy_return.items[0].qty = -1
+ legacy_return.submit()
+ frappe.db.set_value("Sales Invoice Item", legacy_return.items[0].name, "sales_invoice_item", None)
+
+ filters = frappe._dict(
+ company=sales_invoice.company,
+ from_date=sales_invoice.posting_date,
+ to_date=sales_invoice.posting_date,
+ group_by="Invoice",
+ )
+ _, data = execute(filters=filters)
+ invoice_rows = [row for row in data if row.parent_invoice == sales_invoice.name and row.indent == 1]
+ invoice_rows.sort(key=lambda row: row["avg._selling_rate"])
+ self.assertEqual([row.qty for row in invoice_rows], [1, 1])
+ self.assertEqual([row.selling_amount for row in invoice_rows], [100, 200])
+
+ def test_legacy_return_remainder_spills_into_linked_item(self):
+ invoice = "SINV-TEST-RETURN-ALLOCATION"
+ linked_item = "SINV-ITEM-LINKED"
+ unlinked_item = "SINV-ITEM-LEGACY"
+ generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
+ generator.currency_precision = 3
+ generator.filters = frappe._dict(group_by="Invoice")
+ generator.returned_invoices = frappe._dict(
+ {invoice: frappe._dict({linked_item: [frappe._dict(qty=-1, base_amount=-100)]})}
+ )
+ generator.legacy_returned_invoices = frappe._dict(
+ {invoice: frappe._dict({self.item: [frappe._dict(qty=-2, base_amount=-200)]})}
+ )
+ linked_row = frappe._dict(
+ parent=invoice,
+ item_code=self.item,
+ item_row=linked_item,
+ is_return=False,
+ qty=3,
+ base_amount=300,
+ buying_rate=50,
+ delivered_by_supplier=False,
+ )
+ unlinked_row = frappe._dict(
+ parent=invoice,
+ item_code=self.item,
+ item_row=unlinked_item,
+ is_return=False,
+ qty=1,
+ base_amount=100,
+ buying_rate=50,
+ delivered_by_supplier=False,
+ )
+
+ generator.si_list = [unlinked_row, linked_row]
+ generator.allocate_legacy_return_items()
+ generator.update_return_invoices(linked_row, linked_item)
+ generator.update_return_invoices(unlinked_row, unlinked_item)
+
+ self.assertEqual((linked_row.qty, linked_row.base_amount), (1, 100))
+ self.assertEqual((unlinked_row.qty, unlinked_row.base_amount), (0, 0))
+
+ def test_legacy_return_ignores_skipped_group_rows(self):
+ invoice = "SINV-TEST-SKIPPED-RETURN-ALLOCATION"
+ visible_item = "SINV-ITEM-WITH-PROJECT"
+ skipped_item = "SINV-ITEM-WITHOUT-PROJECT"
+ generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
+ generator.currency_precision = 3
+ generator.filters = frappe._dict(group_by="Project")
+ generator.returned_invoices = frappe._dict(
+ {invoice: frappe._dict({visible_item: [frappe._dict(qty=-1, base_amount=-100)]})}
+ )
+ generator.legacy_returned_invoices = frappe._dict(
+ {invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
+ )
+ visible_row = frappe._dict(
+ parent=invoice,
+ item_code=self.item,
+ item_row=visible_item,
+ is_return=False,
+ project="_Test Project",
+ qty=2,
+ base_amount=200,
+ buying_rate=50,
+ delivered_by_supplier=False,
+ )
+ skipped_row = frappe._dict(
+ parent=invoice,
+ item_code=self.item,
+ item_row=skipped_item,
+ is_return=False,
+ project=None,
+ qty=1,
+ )
+
+ generator.si_list = [visible_row, skipped_row]
+ generator.allocate_legacy_return_items()
+ generator.update_return_invoices(visible_row, visible_item)
+
+ self.assertNotIn(skipped_item, generator.returned_invoices[invoice])
+ self.assertEqual((visible_row.qty, visible_row.base_amount), (0, 0))
+
+ def test_monthly_group_allocates_legacy_return(self):
+ invoice = "SINV-TEST-MONTHLY-RETURN-ALLOCATION"
+ item_row = "SINV-ITEM-MONTHLY-RETURN"
+ generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
+ generator.currency_precision = 3
+ generator.filters = frappe._dict(group_by="Monthly")
+ generator.returned_invoices = frappe._dict()
+ generator.legacy_returned_invoices = frappe._dict(
+ {invoice: frappe._dict({self.item: [frappe._dict(qty=-1, base_amount=-100)]})}
+ )
+ invoice_row = frappe._dict(
+ parent=invoice,
+ item_code=self.item,
+ item_row=item_row,
+ is_return=False,
+ posting_date=nowdate(),
+ qty=1,
+ base_amount=100,
+ buying_rate=50,
+ delivered_by_supplier=False,
+ )
+
+ generator.si_list = [invoice_row]
+ generator.allocate_legacy_return_items()
+ generator.update_return_invoices(invoice_row, item_row)
+
+ self.assertEqual((invoice_row.qty, invoice_row.base_amount), (0, 0))
+
+ def test_return_remainder_stays_available_for_next_row(self):
+ invoice = "SINV-TEST-RETURN-REMAINDER"
+ item_row = "SINV-ITEM-RETURN-REMAINDER"
+ returned_item = frappe._dict(qty=-2, base_amount=-200)
+ generator = GrossProfitGenerator.__new__(GrossProfitGenerator)
+ generator.currency_precision = 3
+ generator.returned_invoices = frappe._dict({invoice: frappe._dict({item_row: [returned_item]})})
+ first_row = frappe._dict(
+ parent=invoice,
+ item_code=self.item,
+ qty=1,
+ base_amount=100,
+ buying_rate=50,
+ delivered_by_supplier=False,
+ )
+ second_row = first_row.copy()
+
+ generator.update_return_invoices(first_row, item_row)
+ self.assertEqual((returned_item.qty, returned_item.base_amount), (-1, -100))
+
+ generator.update_return_invoices(second_row, item_row)
+ self.assertEqual((returned_item.qty, returned_item.base_amount), (0, 0))
+ self.assertEqual((first_row.qty, second_row.qty), (0, 0))
+
+ @change_settings("Selling Settings", {"allow_multiple_items": True})
+ def test_return_keeps_buying_amount_of_unreturned_row(self):
+ unreturned_item = create_item(
+ "_Test Gross Profit Unreturned Item", warehouse=self.warehouse, company=self.company
+ )
+ make_stock_entry(
+ company=self.company,
+ item_code=unreturned_item.name,
+ target=self.warehouse,
+ qty=40000,
+ basic_rate=33.33333,
+ )
+ sales_invoice = self.create_sales_invoice(qty=1, rate=100, do_not_submit=True)
+ second_item = frappe.copy_doc(sales_invoice.items[0], ignore_no_copy=False)
+ second_item.idx = None
+ second_item.item_code = unreturned_item.name
+ second_item.item_name = unreturned_item.name
+ second_item.qty = 30000
+ sales_invoice.append("items", second_item)
+ sales_invoice.submit()
+
+ sales_return = make_sales_return(sales_invoice.name)
+ sales_return.set("items", [sales_return.items[0]])
+ sales_return.items[0].qty = -1
+ sales_return.submit()
+
+ filters = frappe._dict(
+ company=sales_invoice.company,
+ from_date=sales_invoice.posting_date,
+ to_date=sales_invoice.posting_date,
+ group_by="Invoice",
+ )
+ _, data = execute(filters=filters)
+ invoice_row = next(
+ row
+ for row in data
+ if row.parent_invoice == sales_invoice.name and row.item_code == unreturned_item.name
+ )
+ self.assertEqual(invoice_row.qty, 30000)
+ self.assertEqual(invoice_row.buying_amount, 999999.9)
+
+ def create_drop_ship_order(self, qty=10, selling_rate=100, buying_rate=80):
+ from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
+ from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order
+ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
+ so = make_sales_order(item=item.name, qty=qty, rate=selling_rate, do_not_submit=True)
+ so.items[0].delivered_by_supplier = 1
+ so.items[0].supplier = "_Test Supplier"
+ so.submit()
+ purchase_order = make_purchase_order(so.name, selected_items=[so.items[0]])
+ purchase_order.items[0].rate = buying_rate
+ purchase_order.supplier = "_Test Supplier"
+ purchase_order.submit()
+ make_purchase_invoice(purchase_order.name).submit()
+
+ return so
+
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):
diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py
index 3661e787f41..189ac56c874 100644
--- a/erpnext/accounts/report/utils.py
+++ b/erpnext/accounts/report/utils.py
@@ -304,6 +304,9 @@ def get_payment_entries(filters, args):
pe.mode_of_payment,
pe.project,
pe.cost_center,
+ pe.payment_type,
+ pe.source_exchange_rate,
+ pe.target_exchange_rate,
)
.where(
(pe.docstatus == 1)
@@ -314,6 +317,22 @@ def get_payment_entries(filters, args):
)
query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True)
payment_entries = query.run(as_dict=True)
+
+ if payment_entries:
+ ded = frappe.qb.DocType("Payment Entry Deduction")
+ deduction_totals = frappe._dict(
+ frappe.qb.from_(ded)
+ .select(ded.parent, Sum(ded.amount))
+ .where(ded.parent.isin([d.name for d in payment_entries]) & (ded.is_exchange_gain_loss == 0))
+ .groupby(ded.parent)
+ .run()
+ )
+ for d in payment_entries:
+ exchange_rate = (
+ d.source_exchange_rate if d.payment_type == "Receive" else d.target_exchange_rate
+ ) or 1
+ d.base_grand_total = flt(d.base_grand_total) + flt(deduction_totals.get(d.name)) / exchange_rate
+
return payment_entries
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 171a89e826c..7b76613d33a 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -231,6 +231,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):
"""
diff --git a/erpnext/crm/doctype/email_campaign/email_campaign.py b/erpnext/crm/doctype/email_campaign/email_campaign.py
index dbc4382a041..3dadc830d0a 100644
--- a/erpnext/crm/doctype/email_campaign/email_campaign.py
+++ b/erpnext/crm/doctype/email_campaign/email_campaign.py
@@ -29,12 +29,19 @@ class EmailCampaign(Document):
def validate(self):
self.set_date()
- # checking if email is set for lead. Not checking for contact as email is a mandatory field for contact.
- if self.email_campaign_for == "Lead":
- self.validate_lead()
+ self.validate_recipient_email()
self.validate_email_campaign_already_exists()
self.update_status()
+ def validate_recipient_email(self):
+ if not self.recipient:
+ return
+
+ if self.email_campaign_for == "Lead":
+ self.validate_lead()
+ elif self.email_campaign_for == "Contact":
+ self.validate_contact()
+
def set_date(self):
if getdate(self.start_date) < getdate(today()):
frappe.throw(_("Start Date cannot be before the current date"))
@@ -56,6 +63,13 @@ class EmailCampaign(Document):
lead_name = frappe.db.get_value("Lead", self.recipient, "lead_name")
frappe.throw(_("Please set an email id for the Lead {0}").format(lead_name))
+ def validate_contact(self):
+ contact = frappe.db.get_value("Contact", self.recipient, ["email_id", "full_name"], as_dict=True)
+ if contact and not contact.email_id:
+ frappe.throw(
+ _("Please set a primary email ID for the Contact {0}").format(frappe.bold(contact.full_name))
+ )
+
def validate_email_campaign_already_exists(self):
email_campaign_exists = frappe.db.exists(
"Email Campaign",
diff --git a/erpnext/crm/doctype/email_campaign/test_email_campaign.py b/erpnext/crm/doctype/email_campaign/test_email_campaign.py
index 997d903c578..184df7ae69f 100644
--- a/erpnext/crm/doctype/email_campaign/test_email_campaign.py
+++ b/erpnext/crm/doctype/email_campaign/test_email_campaign.py
@@ -1,9 +1,84 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-# import frappe
import unittest
+import frappe
+from frappe.utils import add_days, getdate, today
+
class TestEmailCampaign(unittest.TestCase):
- pass
+ """Email Campaign derives its window from the linked Campaign schedule and
+ guards the start date and the recipient's email."""
+
+ def setUp(self):
+ frappe.set_user("Administrator")
+
+ def make_email_template(self):
+ name = "_Test EC Email Template"
+ if not frappe.db.exists("Email Template", name):
+ frappe.get_doc(
+ {"doctype": "Email Template", "name": name, "subject": "Test", "response": "Hello"}
+ ).insert()
+ return name
+
+ def make_campaign(self, schedules):
+ campaign = frappe.new_doc("Campaign")
+ campaign.campaign_name = f"_Test EC Campaign {frappe.generate_hash(length=6)}"
+ for days in schedules:
+ campaign.append(
+ "campaign_schedules",
+ {"send_after_days": days, "email_template": self.make_email_template()},
+ )
+ return campaign.insert()
+
+ def make_email_campaign(self, campaign_name, start_date=None):
+ doc = frappe.new_doc("Email Campaign")
+ doc.campaign_name = campaign_name
+ doc.start_date = start_date or today()
+ return doc
+
+ def test_start_date_cannot_be_in_the_past(self):
+ doc = self.make_email_campaign("irrelevant", start_date=add_days(today(), -1))
+ self.assertRaises(frappe.ValidationError, doc.set_date)
+
+ def test_end_date_is_start_plus_max_send_after_days(self):
+ campaign = self.make_campaign(schedules=[0, 5])
+ doc = self.make_email_campaign(campaign.name)
+ doc.set_date()
+ self.assertEqual(getdate(doc.end_date), add_days(getdate(today()), 5))
+
+ def test_campaign_without_a_schedule_is_rejected(self):
+ campaign = self.make_campaign(schedules=[])
+ doc = self.make_email_campaign(campaign.name)
+ self.assertRaises(frappe.ValidationError, doc.set_date)
+
+ def test_lead_without_an_email_is_rejected(self):
+ lead = frappe.get_doc({"doctype": "Lead", "lead_name": "_Test Lead No Email"}).insert()
+ doc = frappe.new_doc("Email Campaign")
+ doc.email_campaign_for = "Lead"
+ doc.recipient = lead.name
+ self.assertRaises(frappe.ValidationError, doc.validate_lead)
+
+ def test_contact_without_an_email_is_rejected(self):
+ contact = frappe.get_doc({"doctype": "Contact", "first_name": "_Test Contact No Email"}).insert()
+ campaign = self.make_campaign(schedules=[0])
+ doc = self.make_email_campaign(campaign.name)
+ doc.email_campaign_for = "Contact"
+ doc.recipient = contact.name
+ self.assertRaisesRegex(frappe.ValidationError, "primary email ID", doc.insert)
+
+ def test_contact_with_an_email_is_accepted(self):
+ contact = frappe.get_doc(
+ {
+ "doctype": "Contact",
+ "first_name": "_Test Contact With Email",
+ "email_ids": [{"email_id": "_test_email_campaign@example.com", "is_primary": 1}],
+ }
+ ).insert()
+ campaign = self.make_campaign(schedules=[0])
+ doc = self.make_email_campaign(campaign.name)
+ doc.email_campaign_for = "Contact"
+ doc.recipient = contact.name
+ doc.insert()
+ self.assertEqual(doc.status, "In Progress")
diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py
index 42b82395719..6b4c6c936b9 100644
--- a/erpnext/crm/doctype/lead/lead.py
+++ b/erpnext/crm/doctype/lead/lead.py
@@ -233,7 +233,9 @@ class Lead(SellingController, CRMNote):
return frappe.db.get_value("Quotation", {"party_name": self.name, "docstatus": 1, "status": "Lost"})
@frappe.whitelist()
- def create_prospect_and_contact(self, data):
+ def create_prospect_and_contact(self, data: dict):
+ self.check_permission("write")
+
data = frappe._dict(data)
if data.create_contact:
self.create_contact()
@@ -516,8 +518,11 @@ def get_lead_with_phone_number(number):
return lead
-@frappe.whitelist()
-def add_lead_to_prospect(lead, prospect):
+@frappe.whitelist(methods=["POST"])
+def add_lead_to_prospect(lead: str, prospect: str):
+ if lead:
+ frappe.has_permission("Lead", "read", lead, throw=True)
+
prospect = frappe.get_doc("Prospect", prospect)
prospect.append("leads", {"lead": lead})
prospect.save()
diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py
index 9d50a57b8be..ceb5ce299eb 100644
--- a/erpnext/manufacturing/doctype/job_card/test_job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py
@@ -206,43 +206,6 @@ class TestJobCard(FrappeTestCase):
# transfer was made for 2 fg qty in first transfer Stock Entry
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
- def test_material_request_stock_entry_uses_job_card_coverage(self):
- from erpnext.stock.doctype.material_request.material_request import make_stock_entry
-
- self.transfer_material_against = "Job Card"
- self.source_warehouse = "Stores - _TC"
- job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
- mr = make_material_request(job_card.name)
- mr.schedule_date = today()
- for row in mr.items:
- row.qty = flt(row.qty) / 2
- row.stock_qty = flt(row.stock_qty) / 2
- mr.submit()
-
- stock_entry = make_stock_entry(mr.name)
- self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2)
-
- selected_row = mr.items[0]
- try:
- frappe.flags.selected_children = {"items": [selected_row.name]}
- selected_stock_entry = make_stock_entry(mr.name)
- finally:
- frappe.flags.selected_children = None
-
- self.assertEqual(
- [row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item]
- )
- self.assertEqual(selected_stock_entry.fg_completed_qty, 0)
-
- for row in mr.items:
- transferred_qty = flt(row.stock_qty) / 2
- frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty)
- frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty)
- mr.reload()
-
- repeated_stock_entry = make_stock_entry(mr.name)
- self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4)
-
@change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
def test_job_card_excess_material_transfer(self):
"Test transferring more than required RM against Job Card."
@@ -653,7 +616,6 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(ste.job_card, job_card_name)
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
- self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity"))
def test_job_card_proccess_qty_and_completed_qty(self):
from erpnext.manufacturing.doctype.routing.test_routing import (
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 0382d0e201a..5a67725966d 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -636,7 +636,9 @@ class ProductionPlan(Document):
frappe.delete_doc("Work Order", d.name)
@frappe.whitelist()
- def set_status(self, close=None, update_bin=False):
+ def set_status(self, close: bool | None = None, update_bin: bool = False):
+ self.check_permission("write")
+
self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus)
if close:
@@ -887,6 +889,10 @@ class ProductionPlan(Document):
material_request_map = {}
for item in self.mr_items:
+ qty_to_request = flt(item.quantity, item.precision("quantity"))
+ if qty_to_request <= 0:
+ continue
+
item_doc = frappe.get_cached_doc("Item", item.item_code)
material_request_type = item.material_request_type or item_doc.default_material_request_type
@@ -920,7 +926,7 @@ class ProductionPlan(Document):
"from_warehouse": item.from_warehouse
if material_request_type == "Material Transfer"
else None,
- "qty": item.quantity,
+ "qty": qty_to_request,
"schedule_date": schedule_date,
"warehouse": item.warehouse,
"sales_order": item.sales_order,
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 72fb5debb97..3cd459cb574 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -107,6 +107,36 @@ class TestProductionPlan(FrappeTestCase):
pln = frappe.get_doc("Production Plan", pln.name)
pln.cancel()
+ def test_production_plan_mr_creation_skips_zero_qty(self):
+ pln = create_production_plan(item_code="Test Production Item 1", do_not_submit=1)
+ pln.mr_items[0].quantity = 0
+ pln.save().submit()
+
+ pln.make_material_request()
+
+ quantities = frappe.get_all(
+ "Material Request Item", filters={"production_plan": pln.name}, pluck="qty"
+ )
+ self.assertEqual(len(quantities), len(pln.mr_items) - 1)
+ self.assertNotIn(0, quantities)
+
+ 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)
@@ -1389,9 +1419,12 @@ class TestProductionPlan(FrappeTestCase):
self.assertEqual(after_qty, before_qty)
- completed_plans = get_non_completed_production_plans()
+ # Plan submission cached this list before the Work Orders updated ordered quantities.
+ frappe.local.request_cache.clear()
+ non_completed_plans = get_non_completed_production_plans()
+
for plan in plans:
- self.assertFalse(plan in completed_plans)
+ self.assertNotIn(plan, non_completed_plans)
def test_resered_qty_for_production_plan_for_material_requests_with_multi_UOM(self):
from erpnext.stock.utils import get_or_make_bin
@@ -2184,6 +2217,46 @@ class TestProductionPlan(FrappeTestCase):
for row in plan.sub_assembly_items:
self.assertEqual(row.ordered_qty, 10.0)
+ def test_set_status_requires_write_permission(self):
+ pln = create_production_plan(item_code="Test Production Item 1")
+
+ with self.set_user(create_user_without_production_plan_access()):
+ doc = frappe.get_doc("Production Plan", pln.name)
+ self.assertRaises(frappe.PermissionError, doc.set_status)
+
+ def test_work_order_status_rollup_without_production_plan_permission(self):
+ pln = create_production_plan(item_code="Test Production Item 1")
+ pln.make_work_order()
+
+ wo_name = frappe.db.get_value("Work Order", {"production_plan": pln.name}, "name")
+ frappe.db.set_value("Production Plan Item", pln.po_items[0].name, "ordered_qty", 99)
+
+ with self.set_user(create_user_without_production_plan_access()):
+ frappe.get_doc("Work Order", wo_name).update_ordered_qty()
+
+ pln.reload()
+ self.assertEqual(pln.po_items[0].ordered_qty, 0.0)
+ self.assertEqual(pln.status, "Submitted")
+
+ def test_material_request_status_rollup_without_production_plan_permission(self):
+ pln = create_production_plan(item_code="Test Production Item 1")
+ pln.make_material_request()
+
+ plan_item = pln.mr_items[0].name
+ mr_name = frappe.db.get_value(
+ "Material Request Item", {"material_request_plan_item": plan_item}, "parent"
+ )
+ frappe.get_doc("Material Request", mr_name).submit()
+ frappe.db.set_value("Material Request Plan Item", plan_item, "requested_qty", 0)
+
+ with self.set_user(create_user_without_production_plan_access()):
+ frappe.get_doc("Material Request", mr_name).update_requested_qty_in_production_plan()
+
+ pln.reload()
+ requested_qty = frappe.db.get_value("Material Request Plan Item", plan_item, "requested_qty")
+ self.assertGreater(requested_qty, 0)
+ self.assertEqual(pln.status, "Material Requested")
+
def create_production_plan(**args):
"""
@@ -2289,3 +2362,19 @@ def make_bom(**args):
bom.submit()
return bom
+
+
+def create_user_without_production_plan_access():
+ user = "test_production_plan_no_access@example.com"
+ if not frappe.db.exists("User", user):
+ frappe.get_doc(
+ {
+ "doctype": "User",
+ "email": user,
+ "first_name": "Production Plan No Access",
+ "send_welcome_email": 0,
+ "roles": [{"doctype": "Has Role", "role": "Stock User"}],
+ }
+ ).insert(ignore_permissions=True)
+
+ return user
diff --git a/erpnext/manufacturing/doctype/work_order/services/__init__.py b/erpnext/manufacturing/doctype/work_order/services/__init__.py
deleted file mode 100644
index 8b137891791..00000000000
--- a/erpnext/manufacturing/doctype/work_order/services/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/erpnext/manufacturing/doctype/work_order/services/material_coverage.py b/erpnext/manufacturing/doctype/work_order/services/material_coverage.py
deleted file mode 100644
index 8363e0c1284..00000000000
--- a/erpnext/manufacturing/doctype/work_order/services/material_coverage.py
+++ /dev/null
@@ -1,22 +0,0 @@
-# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-from collections.abc import Mapping
-
-from frappe.utils import flt
-
-
-def get_minimum_material_coverage_fraction(
- required_qty: Mapping[str, float], transferred_qty: Mapping[str, float], precision: int
-) -> float:
- """Return the least-covered component ratio at the configured quantity precision."""
- coverage = []
- for item_code, required in required_qty.items():
- transferred = flt(transferred_qty.get(item_code))
- # Stored values can differ after the digits that the user can enter or see.
- if flt(transferred, precision) == flt(required, precision):
- coverage.append(1.0)
- else:
- coverage.append(transferred / required)
-
- return min(coverage, default=0.0)
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index 069907c1b05..43475b7fdb2 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -1461,11 +1461,9 @@ class TestWorkOrder(FrappeTestCase):
del transfer_entry.get("items")[0] # transfer only one RM
transfer_entry.submit()
- # One required item is still missing, so no finished-good quantity is covered yet.
+ # WO's "Material Transferred for Mfg" shows all is transferred, one RM is pending
work_order.reload()
- self.assertEqual(transfer_entry.fg_completed_qty, 0)
- self.assertEqual(work_order.material_transferred_for_manufacturing, 0)
- self.assertEqual(work_order.status, "In Process")
+ self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
self.assertEqual(work_order.required_items[0].transferred_qty, 0)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
@@ -1485,47 +1483,6 @@ class TestWorkOrder(FrappeTestCase):
self.assertEqual(work_order.required_items[0].transferred_qty, 1)
self.assertEqual(work_order.required_items[1].transferred_qty, 2)
- def test_material_transfer_claim_follows_actual_coverage(self):
- work_order = make_wo_order_test_record(planned_start_date=now(), qty=4)
- test_stock_entry.make_stock_entry(
- item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
- )
- test_stock_entry.make_stock_entry(
- item_code="_Test Item Home Desktop 100",
- target="_Test Warehouse - _TC",
- qty=20,
- basic_rate=1000.0,
- )
-
- transfer_entry = frappe.get_doc(
- make_stock_entry(work_order.name, "Material Transfer for Manufacture", 4)
- )
- for row in transfer_entry.items:
- if row.item_code == "_Test Item":
- row.qty = 1
- transfer_entry.submit()
-
- work_order.reload()
- self.assertEqual(transfer_entry.fg_completed_qty, 1)
- self.assertEqual(work_order.material_transferred_for_manufacturing, 1)
-
- remainder_entry = frappe.get_doc(
- make_stock_entry(work_order.name, "Material Transfer for Manufacture", 3)
- )
- remainder_entry.submit()
-
- work_order.reload()
- self.assertEqual(remainder_entry.fg_completed_qty, 3)
- self.assertEqual(work_order.material_transferred_for_manufacturing, 4)
-
- def test_material_coverage_cap_skips_manufacture_entry(self):
- work_order = make_wo_order_test_record(planned_start_date=now(), qty=1)
- manufacture_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
- manufacture_entry.pro_doc = work_order
- manufacture_entry._action = "submit"
-
- self.assertFalse(manufacture_entry._should_cap_completed_qty())
-
def test_material_transferred_min_fraction_on_partial_pick_list(self):
"""Pick-list flow (fg_completed_qty = 0): 'Material Transferred for Manufacturing'
must reflect the least-transferred required item (the bottleneck), instead of being
@@ -1588,97 +1545,6 @@ class TestWorkOrder(FrappeTestCase):
work_order.reload()
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
- def test_material_transferred_ignores_hidden_precision_difference(self):
- work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
- test_stock_entry.make_stock_entry(
- item_code="_Test Item", target="_Test Warehouse - _TC", qty=10, basic_rate=5000.0
- )
- test_stock_entry.make_stock_entry(
- item_code="_Test Item Home Desktop 100",
- target="_Test Warehouse - _TC",
- qty=10,
- basic_rate=1000.0,
- )
-
- precision = work_order.precision("required_qty", "required_items")
- hidden_difference = 4 / (10 ** (precision + 1))
- row = work_order.required_items[0]
- row.db_set("required_qty", flt(row.required_qty) + hidden_difference, update_modified=False)
- work_order.reload()
- required_qty = {row.item_code: flt(row.required_qty) for row in work_order.required_items}
-
- transfer_entry = frappe.get_doc(
- make_stock_entry(work_order.name, "Material Transfer for Manufacture", 0)
- )
- for item in transfer_entry.items:
- item.qty = flt(required_qty[item.item_code], precision)
- item.transfer_qty = item.qty
- transfer_entry.submit()
-
- work_order.reload()
- self.assertEqual(
- flt(work_order.required_items[0].required_qty, precision),
- flt(work_order.required_items[0].transferred_qty, precision),
- )
- self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
-
- def test_repair_material_transfer_precision_patch(self):
- from erpnext.patches.v16_0.repair_work_order_material_transfer import (
- execute,
- get_precision_affected_work_orders,
- )
-
- precision = frappe.get_precision("Work Order Item", "required_qty")
- hidden_difference = 4 / (10 ** (precision + 1))
- work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
- for index, row in enumerate(work_order.required_items):
- required_qty = flt(row.required_qty) + (hidden_difference if index == 0 else 0)
- row.db_set(
- {
- "required_qty": required_qty,
- "transferred_qty": flt(required_qty, precision),
- },
- update_modified=False,
- )
- work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
-
- partial_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
- for row in partial_work_order.required_items:
- row.db_set("transferred_qty", row.required_qty, update_modified=False)
- partial_row = partial_work_order.required_items[0]
- partial_row.db_set(
- "transferred_qty",
- flt(partial_row.required_qty, precision) - (1 / (10**precision)),
- update_modified=False,
- )
- partial_work_order.db_set("material_transferred_for_manufacturing", 1.99, update_modified=False)
-
- terminal_work_orders = []
- for status in ("Stopped", "Closed", "Completed"):
- terminal_work_order = make_wo_order_test_record(planned_start_date=now(), qty=2)
- for row in terminal_work_order.required_items:
- row.db_set("transferred_qty", row.required_qty, update_modified=False)
- terminal_work_order.db_set(
- {"material_transferred_for_manufacturing": 1.99, "status": status},
- update_modified=False,
- )
- terminal_work_orders.append(terminal_work_order)
-
- updates = get_precision_affected_work_orders()
- self.assertIn(work_order.name, updates)
- self.assertNotIn(partial_work_order.name, updates)
- for terminal_work_order in terminal_work_orders:
- self.assertNotIn(terminal_work_order.name, updates)
-
- execute()
- work_order.reload()
- partial_work_order.reload()
- self.assertEqual(work_order.material_transferred_for_manufacturing, work_order.qty)
- self.assertEqual(partial_work_order.material_transferred_for_manufacturing, 1.99)
- for terminal_work_order in terminal_work_orders:
- terminal_work_order.reload()
- self.assertEqual(terminal_work_order.material_transferred_for_manufacturing, 1.99)
-
def test_status_in_process_when_only_one_required_item_transferred(self):
"""Stock Entry created from a Pick List that picked only one of the required items:
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index dd5a66e096c..dfddb857aab 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -31,9 +31,6 @@ from erpnext.manufacturing.doctype.bom.bom import (
from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import (
get_mins_between_operations,
)
-from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
- get_minimum_material_coverage_fraction,
-)
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
@@ -461,7 +458,8 @@ class WorkOrder(Document):
return status
def has_transferred_material(self):
- """True if any raw material was transferred against this work order."""
+ """True if any raw material was transferred against this work order via a pick list
+ (these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
ste = frappe.qb.DocType("Stock Entry")
ste_child = frappe.qb.DocType("Stock Entry Detail")
qty = (
@@ -474,6 +472,7 @@ class WorkOrder(Document):
& (ste.docstatus == 1)
& (ste.purpose == "Material Transfer for Manufacture")
& (ste.is_return == 0)
+ & (ste.pick_list.isnotnull())
)
).run()[0][0]
return flt(qty) > 0
@@ -559,6 +558,7 @@ class WorkOrder(Document):
def update_production_plan_status(self):
production_plan = frappe.get_doc("Production Plan", self.production_plan)
+ production_plan.flags.ignore_permissions = True
produced_qty = 0
if self.production_plan_item:
total_qty = frappe.get_all(
@@ -901,6 +901,7 @@ class WorkOrder(Document):
)
doc = frappe.get_doc("Production Plan", self.production_plan)
+ doc.flags.ignore_permissions = True
doc.set_status()
doc.db_set("status", doc.status)
@@ -1304,13 +1305,20 @@ class WorkOrder(Document):
self.recompute_material_transferred_for_manufacturing(transferred_items)
def recompute_material_transferred_for_manufacturing(self, transferred_items):
- """Set transferred quantity from the raw materials that have actually moved."""
+ """Set material_transferred_for_manufacturing based on actual item-level transfers, not fg_completed_qty."""
# Job Card transfers use the minimum completed quantity across operations.
if self.operations and self.transfer_material_against == "Job Card":
return
- claimed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
+ # When fg_completed_qty > 0 (direct stock entries, excess transfer), preserve the
+ # SUM(fg_completed_qty) approach so excess-transfer tracking works correctly.
+ sum_fg_completed_qty = self.get_transferred_or_manufactured_qty("Material Transfer for Manufacture")
+ if sum_fg_completed_qty:
+ self.db_set("material_transferred_for_manufacturing", sum_fg_completed_qty)
+ return
+ # Pick list flow sets fg_completed_qty=0; use min-fraction of actual item transfers
+ # so partial availability does not prematurely mark the work order as fully transferred.
required_by_item = {}
for row in self.required_items:
if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
@@ -1320,13 +1328,12 @@ class WorkOrder(Document):
if not required_by_item:
return
- min_fraction = get_minimum_material_coverage_fraction(
- required_by_item,
- transferred_items,
- self.precision("required_qty", "required_items"),
+ min_fraction = min(
+ flt(transferred_items.get(item_code) or 0) / required_qty
+ for item_code, required_qty in required_by_item.items()
)
- covered_qty = min_fraction * flt(self.qty)
- material_transferred = min(covered_qty, max(flt(self.qty), claimed_qty))
+ min_fraction = min(min_fraction, 1.0)
+ material_transferred = min_fraction * flt(self.qty)
self.db_set("material_transferred_for_manufacturing", material_transferred)
def update_returned_qty(self):
diff --git a/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js
new file mode 100644
index 00000000000..3d11a7d7263
--- /dev/null
+++ b/erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js
@@ -0,0 +1,60 @@
+// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["BOM Stock Analysis"] = {
+ filters: [
+ {
+ fieldname: "bom",
+ label: __("BOM"),
+ fieldtype: "Link",
+ options: "BOM",
+ reqd: 1,
+ get_query: () => ({ filters: { docstatus: 1 } }),
+ },
+ {
+ fieldname: "warehouse",
+ label: __("Warehouse"),
+ fieldtype: "Link",
+ options: "Warehouse",
+ },
+ {
+ fieldname: "qty_to_make",
+ label: __("FG Items to Make"),
+ fieldtype: "Float",
+ },
+ {
+ fieldname: "show_exploded_view",
+ label: __("Show availability of exploded items"),
+ fieldtype: "Check",
+ default: false,
+ },
+ ],
+ formatter(value, row, column, data, default_formatter) {
+ if (data && data.bold && column.fieldname === "item") {
+ return value ? `${value}` : "";
+ }
+
+ value = default_formatter(value, row, column, data);
+
+ if (column.fieldname === "difference_qty" && value !== "" && value !== undefined) {
+ const numeric = parseFloat(value.replace(/,/g, "")) || 0;
+ if (numeric < 0) {
+ value = `${value}`;
+ } else if (numeric > 0) {
+ value = `${value}`;
+ }
+ }
+
+ if (data && data.bold) {
+ if (column.fieldname === "description") {
+ const qty_to_make = Number(frappe.query_report.get_filter_value("qty_to_make")) || 0;
+ const producible = Number(String(data.description ?? "").replace(/,/g, "")) || 0;
+ const colour = qty_to_make && producible < qty_to_make ? "red" : "green";
+ return `${value}`;
+ }
+ return `${value}`;
+ }
+
+ return value;
+ },
+};
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index d5a7ff156d3..755d274227f 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -447,7 +447,6 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v15_0.fix_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
-erpnext.patches.v16_0.repair_work_order_material_transfer
erpnext.patches.v16_0.remove_frappe_crm_custom_fields
erpnext.patches.v16_0.append_fieldname_to_pos_search_fields
erpnext.patches.v16_0.add_transaction_roles_to_sms_settings
\ No newline at end of file
diff --git a/erpnext/patches/v16_0/repair_work_order_material_transfer.py b/erpnext/patches/v16_0/repair_work_order_material_transfer.py
deleted file mode 100644
index 31e119f8442..00000000000
--- a/erpnext/patches/v16_0/repair_work_order_material_transfer.py
+++ /dev/null
@@ -1,65 +0,0 @@
-import frappe
-from frappe.utils import flt
-from pypika import functions as fn
-
-from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
- get_minimum_material_coverage_fraction,
-)
-
-
-def execute():
- updates = get_precision_affected_work_orders()
- frappe.db.bulk_update("Work Order", updates, update_modified=False)
-
-
-def get_precision_affected_work_orders():
- """Return Work Orders whose components cover the plan at quantity precision."""
- work_orders = {}
- for row in _get_candidate_rows():
- work_order = work_orders.setdefault(
- row.work_order,
- {"qty": flt(row.qty), "required_qty": {}, "transferred_qty": {}},
- )
- item_code = row.item_code
- work_order["required_qty"][item_code] = work_order["required_qty"].get(item_code, 0.0) + flt(
- row.required_qty
- )
- work_order["transferred_qty"][item_code] = max(
- work_order["transferred_qty"].get(item_code, 0.0), flt(row.transferred_qty)
- )
-
- precision = frappe.get_precision("Work Order Item", "required_qty")
- return {
- name: {"material_transferred_for_manufacturing": values["qty"]}
- for name, values in work_orders.items()
- if get_minimum_material_coverage_fraction(
- values["required_qty"], values["transferred_qty"], precision
- )
- >= 1.0
- }
-
-
-def _get_candidate_rows():
- work_order = frappe.qb.DocType("Work Order")
- required_item = frappe.qb.DocType("Work Order Item")
- return (
- frappe.qb.from_(work_order)
- .inner_join(required_item)
- .on(required_item.parent == work_order.name)
- .select(
- work_order.name.as_("work_order"),
- work_order.qty,
- required_item.item_code,
- required_item.required_qty,
- required_item.transferred_qty,
- )
- .where(
- (work_order.docstatus == 1)
- & (work_order.status.notin(["Stopped", "Closed", "Completed"]))
- & (fn.Coalesce(work_order.skip_transfer, 0) == 0)
- & (fn.Coalesce(work_order.material_transferred_for_manufacturing, 0) < work_order.qty)
- & (fn.Coalesce(work_order.transfer_material_against, "") != "Job Card")
- & (required_item.include_item_in_manufacturing == 1)
- & (required_item.required_qty > 0)
- )
- ).run(as_dict=True)
diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
index 16d4e9971d8..5606f991924 100644
--- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
+++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
@@ -602,6 +602,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
},
callback: (r) => {
const doc = frappe.model.sync(r.message);
+ track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
@@ -622,9 +623,77 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
},
callback: (r) => {
var doc = frappe.model.sync(r.message);
+ track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
}
}
};
+
+const pending_reconciliations = new Map();
+
+const voucher_key = (doctype, docname) => `${doctype}:${docname}`;
+
+const track_voucher = (doctype, docname, bank_transaction_name) => {
+ pending_reconciliations.set(voucher_key(doctype, docname), bank_transaction_name);
+};
+
+for (const voucher_doctype of ["Payment Entry", "Journal Entry"]) {
+ frappe.ui.form.on(voucher_doctype, {
+ before_save(frm) {
+ frm.__pending_reconciliation_key = voucher_key(frm.doctype, frm.doc.name);
+ },
+
+ after_save(frm) {
+ const old_key = frm.__pending_reconciliation_key;
+ delete frm.__pending_reconciliation_key;
+
+ const new_key = voucher_key(frm.doctype, frm.doc.name);
+ if (!old_key || old_key === new_key || !pending_reconciliations.has(old_key)) return;
+
+ // Follow the rename so the voucher stays identifiable on submit
+ pending_reconciliations.set(new_key, pending_reconciliations.get(old_key));
+ pending_reconciliations.delete(old_key);
+ },
+
+ on_submit(frm) {
+ const key = voucher_key(frm.doctype, frm.doc.name);
+ const bank_transaction_name = pending_reconciliations.get(key);
+ if (!bank_transaction_name) return;
+
+ pending_reconciliations.delete(key);
+
+ frappe.call({
+ method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers",
+ args: {
+ bank_transaction_name: bank_transaction_name,
+ vouchers: [
+ {
+ payment_doctype: frm.doctype,
+ payment_name: frm.doc.name,
+ },
+ ],
+ is_new_voucher: true,
+ },
+ callback: (r) => {
+ if (r.exc) return;
+ frappe.show_alert({
+ message: __("Bank Transaction {0} Matched", [bank_transaction_name]),
+ indicator: "green",
+ });
+ },
+ error: () => {
+ frappe.msgprint({
+ title: __("Reconciliation Failed"),
+ indicator: "red",
+ message: __(
+ "{0} {1} was submitted but could not be reconciled against Bank Transaction {2}. Match it manually from the Bank Reconciliation Tool.",
+ [__(frm.doctype), frm.doc.name, bank_transaction_name]
+ ),
+ });
+ },
+ });
+ },
+ });
+}
diff --git a/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py b/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py
index fc2dc964c9d..2f947eb39f0 100644
--- a/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py
+++ b/erpnext/regional/doctype/import_supplier_invoice/import_supplier_invoice.py
@@ -159,6 +159,8 @@ class ImportSupplierInvoice(Document):
@frappe.whitelist()
def process_file_data(self):
+ self.check_permission("write")
+
self.db_set("status", "Processing File Data", notify=True, commit=True)
frappe.enqueue_doc(self.doctype, self.name, "import_xml_data", queue="long", timeout=3600)
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index 635a43c3113..166acc58bfd 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -695,7 +695,10 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex
}
// sales invoice
- if (flt(doc.per_billed) < 100 && frappe.model.can_create("Sales Invoice")) {
+ if (
+ doc.__onload?.has_potentially_billable_items &&
+ frappe.model.can_create("Sales Invoice")
+ ) {
this.frm.add_custom_button(
__("Sales Invoice"),
() => me.make_sales_invoice(),
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index e664f69e14b..7710bb8c434 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -13,8 +13,10 @@ from frappe.desk.notifications import clear_doctype_notifications
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.model.utils import get_fetch_values
-from frappe.query_builder.functions import Sum
+from frappe.query_builder import Case, Criterion
+from frappe.query_builder.functions import Abs, Sum
from frappe.utils import add_days, cint, cstr, flt, get_link_to_form, getdate, nowdate, strip_html
+from pypika import Order
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
unlink_inter_company_doc,
@@ -22,6 +24,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
validate_inter_company_party,
)
from erpnext.accounts.party import CROSS_PARTY_FIELD_NO_MAP, get_party_account
+from erpnext.accounts.utils import build_qb_match_conditions
from erpnext.controllers.mapper import get_qty_already_mapped
from erpnext.controllers.selling_controller import SellingController
from erpnext.manufacturing.doctype.blanket_order.blanket_order import (
@@ -42,6 +45,18 @@ from erpnext.stock.stock_balance import get_reserved_qty, update_bin_qty
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
+LINK_SEARCH_FIELDTYPES = {
+ "Autocomplete",
+ "Data",
+ "Link",
+ "Long Text",
+ "Read Only",
+ "Select",
+ "Small Text",
+ "Text",
+ "Text Editor",
+}
+
class WarehouseRequired(frappe.ValidationError):
pass
@@ -207,6 +222,12 @@ class SalesOrder(SellingController):
if has_reserved_stock(self.doctype, self.name):
self.set_onload("has_reserved_stock", True)
+ if self.docstatus == 1 and self.status not in {"Closed", "On Hold"}:
+ self.set_onload(
+ "has_potentially_billable_items",
+ has_potentially_billable_items(self.name),
+ )
+
def before_validate(self):
self.set_has_unit_price_items()
self.flags.allow_zero_qty = self.has_unit_price_items
@@ -1142,11 +1163,23 @@ def make_sales_invoice(
has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items")
billed_qty_by_item = None
pending_qty_by_item = {}
+ amount_allowance_by_item = {}
mapped_qty_by_item = get_qty_already_mapped(target_doc, "so_detail")
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
+ def is_amount_billable(source):
+ from erpnext.controllers.status_updater import get_allowance_for
+
+ if source.item_code not in amount_allowance_by_item:
+ amount_allowance_by_item[source.item_code] = flt(
+ get_allowance_for(source.item_code, qty_or_amount="amount")[0]
+ )
+
+ allowance = amount_allowance_by_item[source.item_code]
+ return abs(flt(source.billed_amt)) < abs(flt(source.amount)) * (1 + allowance / 100)
+
def get_billed_qty_by_item():
nonlocal billed_qty_by_item
@@ -1168,9 +1201,7 @@ def make_sales_invoice(
def get_pending_qty(source):
if source.name not in pending_qty_by_item:
billable_qty = get_qty_net_of_returns(source)
- if source.qty and source.billed_amt:
- billable_qty -= get_billed_qty_by_item().get(source.name, 0)
-
+ billable_qty -= get_billed_qty_by_item().get(source.name, 0)
billable_qty -= mapped_qty_by_item.get(source.name, 0)
pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0)
@@ -1255,7 +1286,7 @@ def make_sales_invoice(
if is_unit_price_row(doc)
else (
doc.qty
- and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
+ and (doc.base_amount == 0 or is_amount_billable(doc))
and get_pending_qty(doc) > 0
)
),
@@ -1940,3 +1971,87 @@ def get_work_order_items(sales_order, for_raw_material_request=0):
@frappe.whitelist()
def get_stock_reservation_status():
return frappe.db.get_single_value("Stock Settings", "enable_stock_reservation")
+
+
+def get_potentially_billable_item_criterion(sales_order, sales_order_item, item):
+ """Return the amount check for UI candidates. The mapper checks pending quantity."""
+ global_allowance = flt(frappe.get_cached_value("Accounts Settings", None, "over_billing_allowance"))
+ allowance = (
+ Case().when(item.over_billing_allowance != 0, item.over_billing_allowance).else_(global_allowance)
+ )
+
+ has_amount_headroom = (sales_order_item.base_amount == 0) | (
+ Abs(sales_order_item.billed_amt) < Abs(sales_order_item.amount) * (1 + allowance / 100)
+ )
+ is_unit_price_row = (sales_order.has_unit_price_items == 1) & (sales_order_item.qty == 0)
+
+ return is_unit_price_row | ((sales_order_item.qty != 0) & has_amount_headroom)
+
+
+def has_potentially_billable_items(sales_order: str) -> bool:
+ """Return whether a Sales Order has an item with billing amount headroom."""
+ so = qb.DocType("Sales Order")
+ so_item = qb.DocType("Sales Order Item")
+ item = qb.DocType("Item")
+
+ return bool(
+ qb.from_(so_item)
+ .inner_join(so)
+ .on(so.name == so_item.parent)
+ .left_join(item)
+ .on(item.name == so_item.item_code)
+ .select(so_item.name)
+ .where((so_item.parent == sales_order) & get_potentially_billable_item_criterion(so, so_item, item))
+ .limit(1)
+ .run()
+ )
+
+
+def get_text_search_criterion(sales_order, txt: str):
+ """Match the search text the way the Sales Order link search does."""
+ meta = frappe.get_meta("Sales Order")
+ conditions = []
+
+ for fieldname in dict.fromkeys(["name", meta.title_field, *meta.get_search_fields()]):
+ if not fieldname:
+ continue
+
+ field = meta.get_field(fieldname)
+ if fieldname == "name" or (field and field.fieldtype in LINK_SEARCH_FIELDTYPES):
+ conditions.append(sales_order[fieldname].like(f"%{txt}%"))
+
+ return Criterion.any(conditions)
+
+
+@frappe.whitelist(methods=["GET"])
+@frappe.validate_and_sanitize_search_inputs
+def get_potentially_billable_sales_orders(
+ doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict
+):
+ """Return Sales Orders that have an item with billing amount headroom."""
+ permission_type = "select" if frappe.only_has_select_perm("Sales Order") else "read"
+ frappe.has_permission("Sales Order", permission_type, throw=True)
+
+ so = qb.DocType("Sales Order")
+ so_item = qb.DocType("Sales Order Item")
+ item = qb.DocType("Item")
+
+ query = frappe.qb.get_query(
+ "Sales Order", fields=["name", "customer", "transaction_date"], filters=filters
+ )
+ if txt:
+ query = query.where(get_text_search_criterion(so, txt))
+
+ return (
+ query.inner_join(so_item)
+ .on(so_item.parent == so.name)
+ .left_join(item)
+ .on(item.name == so_item.item_code)
+ .where(get_potentially_billable_item_criterion(so, so_item, item))
+ .where(Criterion.all(build_qb_match_conditions("Sales Order")))
+ .distinct()
+ .orderby(so.transaction_date, order=Order.desc)
+ .limit(cint(page_len))
+ .offset(cint(start))
+ .run(as_dict=True)
+ )
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 8c0631c1b52..58df9766cc5 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -22,6 +22,8 @@ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_prod
from erpnext.selling.doctype.sales_order.sales_order import (
WarehouseRequired,
create_pick_list,
+ get_potentially_billable_sales_orders,
+ has_potentially_billable_items,
make_delivery_note,
make_material_request,
make_purchase_order,
@@ -226,6 +228,68 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
si1 = make_sales_invoice(so.name)
self.assertEqual(len(si1.get("items")), 0)
+ def test_make_sales_invoice_for_pending_qty_with_item_billing_allowance(self):
+ item = make_item(
+ "_Test Over Billed Pending Qty Item",
+ {"is_stock_item": 1, "over_billing_allowance": 0},
+ ).name
+ so = make_sales_order(item_code=item, qty=390, rate=100)
+
+ for _ in range(2):
+ si = make_sales_invoice(so.name)
+ si.get("items")[0].qty = 120
+ si.get("items")[0].rate = 162.50
+ si.insert()
+ si.submit()
+
+ so.load_from_db()
+ self.assertEqual(flt(so.per_billed), 100)
+ self.assertEqual(so.get("items")[0].billed_amt, so.get("items")[0].amount)
+
+ filters = {"docstatus": 1, "company": so.company, "customer": so.customer}
+
+ def is_offered(txt=""):
+ rows = get_potentially_billable_sales_orders("Sales Order", txt, "name", 0, 50, filters)
+ return so.name in [row.name for row in rows]
+
+ def forget_cached_allowances():
+ frappe.local.request_cache.clear()
+
+ with change_settings("Accounts Settings", {"over_billing_allowance": 100}):
+ forget_cached_allowances()
+ self.assertTrue(has_potentially_billable_items(so.name))
+ self.assertTrue(is_offered())
+ self.assertEqual(make_sales_invoice(so.name).get("items")[0].qty, 150)
+
+ with change_settings("Accounts Settings", {"over_billing_allowance": 0}):
+ forget_cached_allowances()
+ self.assertFalse(has_potentially_billable_items(so.name))
+ self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
+
+ frappe.db.set_value("Item", item, "over_billing_allowance", 100)
+ forget_cached_allowances()
+
+ so.run_method("onload")
+ self.assertTrue(so.get_onload("has_potentially_billable_items"))
+ self.assertTrue(is_offered(so.customer))
+
+ si = make_sales_invoice(so.name)
+ self.assertEqual(len(si.get("items")), 1)
+ self.assertEqual(si.get("items")[0].qty, 150)
+
+ def test_make_sales_invoice_skips_fully_invoiced_free_item(self):
+ free_item = make_item("_Test Free Item", {"is_stock_item": 1}).name
+ so = make_sales_order(qty=10, rate=100, do_not_submit=True)
+ so.append("items", {"item_code": free_item, "qty": 5, "rate": 0, "warehouse": so.items[0].warehouse})
+ so.submit()
+
+ si = make_sales_invoice(so.name)
+ self.assertEqual([row.qty for row in si.items], [10, 5])
+ si.insert()
+ si.submit()
+
+ self.assertEqual(len(make_sales_invoice(so.name).items), 0)
+
def test_make_sales_invoice_after_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index 569d06349f4..fd4cbdbef34 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -986,8 +986,12 @@ def get_billing_shipping_address(name, billing_address=None, shipping_address=No
return {"primary_address": primary_address, "shipping_address": shipping_address}
-@frappe.whitelist()
+@frappe.whitelist(methods=["POST"])
def create_transaction_deletion_request(company):
+ frappe.only_for("System Manager")
+ # User Permission check
+ frappe.has_permission("Company", ptype="delete", doc=company, throw=True)
+
from erpnext.setup.doctype.transaction_deletion_record.transaction_deletion_record import (
is_deletion_doc_running,
)
@@ -995,6 +999,7 @@ def create_transaction_deletion_request(company):
is_deletion_doc_running(company)
tdr = frappe.get_doc({"doctype": "Transaction Deletion Record", "company": company})
+
tdr.submit()
tdr.start_deletion_tasks()
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
index b9f911dbe8c..8df377aab00 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.json
@@ -1,5 +1,6 @@
{
"actions": [],
+ "allow_bulk_edit": 1,
"autoname": "TDL.####",
"creation": "2021-04-06 20:17:18.404716",
"doctype": "DocType",
@@ -141,19 +142,18 @@
"read_only": 1
}
],
+ "in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-03-21 10:29:19.456413",
+ "modified": "2026-09-02 20:32:19.679290",
"modified_by": "Administrator",
"module": "Setup",
"name": "Transaction Deletion Record",
- "naming_rule": "Expression (old style)",
+ "naming_rule": "Expression",
"owner": "Administrator",
"permissions": [
{
- "create": 1,
- "delete": 1,
"email": 1,
"export": 1,
"print": 1,
@@ -161,7 +161,6 @@
"report": 1,
"role": "System Manager",
"share": 1,
- "submit": 1,
"write": 1
}
],
@@ -169,4 +168,4 @@
"sort_order": "DESC",
"states": [],
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index f7147f0fa59..36c30b933fe 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -159,6 +159,8 @@ class Batch(Document):
@frappe.whitelist()
def recalculate_batch_qty(self):
+ self.check_permission("write")
+
batches = get_batch_qty(
batch_no=self.name,
item_code=self.item,
diff --git a/erpnext/stock/doctype/batch/batch_list.js b/erpnext/stock/doctype/batch/batch_list.js
index 644ef131399..a64aff903c3 100644
--- a/erpnext/stock/doctype/batch/batch_list.js
+++ b/erpnext/stock/doctype/batch/batch_list.js
@@ -5,12 +5,12 @@ frappe.listview_settings["Batch"] = {
return [__("Disabled"), "gray", "disabled,=,1"];
} else if (
doc.expiry_date &&
- frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) <= 0
+ frappe.datetime.get_diff(doc.expiry_date, frappe.datetime.nowdate()) < 0
) {
return [
__("Expired"),
"red",
- "expiry_date,not in,|expiry_date,<=,Today|batch_qty,>,0|disabled,=,0",
+ "expiry_date,not in,|expiry_date,<,Today|batch_qty,>,0|disabled,=,0",
];
} else if (!doc.batch_qty) {
return [__("Empty"), "gray", "batch_qty,=,0|disabled,=,0"];
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index fe132d589d4..92c4dc38386 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -740,6 +740,9 @@ class DeliveryNote(SellingController):
def update_billing_status(self, update_modified=True):
updated_delivery_notes = [self.name]
+ if self.is_return and self.return_against:
+ updated_delivery_notes.append(self.return_against)
+
for d in self.get("items"):
if d.si_detail and not d.so_detail:
d.db_set("billed_amt", d.amount, update_modified=update_modified)
@@ -748,7 +751,12 @@ class DeliveryNote(SellingController):
for dn in set(updated_delivery_notes):
dn_doc = self if (dn == self.name) else frappe.get_doc("Delivery Note", dn)
- dn_doc.update_billing_percentage(update_modified=update_modified)
+ update_dn_modified = update_modified and dn != self.return_against
+ dn_doc.update_billing_percentage(update_modified=update_dn_modified)
+ if dn == self.return_against:
+ dn_doc.load_from_db()
+ dn_doc.set_status(update=True, update_modified=False)
+ dn_doc.notify_update()
self.load_from_db()
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index 56622562d7a..4ee8f3289af 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -1049,6 +1049,56 @@ class TestDeliveryNote(FrappeTestCase):
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.delivery_note 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.sales_order import (
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index e8280bacf4a..bbbb4038be6 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -796,6 +796,7 @@
"fieldname": "over_delivery_receipt_allowance",
"fieldtype": "Float",
"label": "Over Delivery/Receipt Allowance (%)",
+ "non_negative": 1,
"oldfieldname": "tolerance",
"oldfieldtype": "Currency"
},
@@ -803,7 +804,8 @@
"depends_on": "eval:!doc.__islocal && !doc.is_fixed_asset",
"fieldname": "over_billing_allowance",
"fieldtype": "Float",
- "label": "Over Billing Allowance (%)"
+ "label": "Over Billing Allowance (%)",
+ "non_negative": 1
},
{
"default": "0",
@@ -898,7 +900,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
- "modified": "2026-07-05 23:24:45.734144",
+ "modified": "2026-09-04 10:08:30.115003",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js
index 1a868dc546a..cdf6b8cba7a 100644
--- a/erpnext/stock/doctype/material_request/material_request.js
+++ b/erpnext/stock/doctype/material_request/material_request.js
@@ -93,8 +93,27 @@ frappe.ui.form.on("Material Request", {
erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
if (!frm.doc.buying_price_list) {
const buying_price_list = frappe.defaults.get_default("buying_price_list");
- if (frappe.has_permission("Price List", "read", buying_price_list)) {
- frm.set_value("buying_price_list", buying_price_list);
+ if (buying_price_list) {
+ const docname = frm.doc.name;
+ frappe.call({
+ type: "GET",
+ method: "frappe.client.has_permission",
+ no_spinner: true,
+ args: {
+ doctype: "Price List",
+ docname: buying_price_list,
+ perm_type: "read",
+ },
+ callback: ({ message }) => {
+ if (
+ message?.has_permission &&
+ frm.doc.name === docname &&
+ !frm.doc.buying_price_list
+ ) {
+ frm.set_value("buying_price_list", buying_price_list);
+ }
+ },
+ });
}
}
},
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index e5d8dc0d474..77a0b06d267 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -428,6 +428,7 @@ class MaterialRequest(BuyingController):
for production_plan in production_plans:
doc = frappe.get_doc("Production Plan", production_plan)
+ doc.flags.ignore_permissions = True
doc.set_status()
doc.db_set("status", doc.status)
@@ -806,8 +807,6 @@ def make_stock_entry(source_name, target_doc=None):
target.fg_completed_qty = job_card_details[0].for_quantity
target.from_bom = 1
- target.cap_completed_qty_to_material_coverage()
-
doclist = get_mapped_doc(
"Material Request",
source_name,
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index d777e0862e7..93e824b30d4 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -1734,7 +1734,7 @@ class TestPickList(FrappeTestCase):
stock_entry.cancel()
def test_packed_item_in_pick_list(self):
- warehouse_1 = "RJ Warehouse - _TC"
+ warehouse_1 = "_Test Warehouse - _TC"
warehouse_2 = "_Test Warehouse 2 - _TC"
item_1 = make_item(properties={"is_stock_item": 0}).name
item_2 = make_item().name
@@ -1765,7 +1765,7 @@ class TestPickList(FrappeTestCase):
def test_packed_item_multiple_times_in_so(self):
frappe.db.delete("Item Price")
- warehouse_1 = "RJ Warehouse - _TC"
+ warehouse_1 = "_Test Warehouse - _TC"
warehouse_2 = "_Test Warehouse 2 - _TC"
warehouse = "_Test Warehouse - _TC"
item_1 = make_item(properties={"is_stock_item": 0}).name
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index f7cc4b90c36..20c9b58e09a 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -265,6 +265,8 @@ class RepostItemValuation(Document):
@frappe.whitelist()
def restart_reposting(self):
+ self.check_permission("write")
+
self.set_status("Queued", write=False)
self.current_index = 0
self.distinct_item_and_warehouse = None
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index c2f991321c2..fb0fff2a601 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -32,9 +32,6 @@ from erpnext.manufacturing.doctype.bom.bom import (
get_scrap_items_from_sub_assemblies,
validate_bom_no,
)
-from erpnext.manufacturing.doctype.work_order.services.material_coverage import (
- get_minimum_material_coverage_fraction,
-)
from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.batch.batch import get_batch_qty
@@ -265,7 +262,6 @@ class StockEntry(StockController):
self.calculate_rate_and_amount()
self.validate_putaway_capacity()
self.validate_component_and_quantities()
- self._cap_completed_qty_to_material_coverage()
self.validate_finished_good_serial_batch_for_work_order()
if not self.get("purpose") == "Manufacture":
@@ -1190,124 +1186,6 @@ class StockEntry(StockController):
title=_("Missing Item"),
)
- def _cap_completed_qty_to_material_coverage(self):
- if not self._should_cap_completed_qty():
- return
- # Keep an excessive claim intact so the Work Order allowance check can reject it.
- max_qty = flt(self.pro_doc.qty)
- overproduction_percentage = flt(
- frappe.db.get_single_value("Manufacturing Settings", "overproduction_percentage_for_work_order")
- )
- to_transfer_qty = flt(self.pro_doc.material_transferred_for_manufacturing) + flt(
- self.fg_completed_qty
- )
- transfer_limit_qty = max_qty + (max_qty * overproduction_percentage / 100)
- if transfer_limit_qty < to_transfer_qty:
- return
-
- self.cap_completed_qty_to_material_coverage()
-
- def cap_completed_qty_to_material_coverage(self):
- required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data()
- if not required_qty:
- return
-
- covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
- for row in self.items:
- if self.job_card:
- material_reference = row.job_card_item
- transferred = flt(row.qty)
- else:
- material_reference = row.original_item or row.item_code
- transferred = flt(row.qty) * flt(row.conversion_factor or 1)
-
- if material_reference in required_qty and (self.job_card or row.s_warehouse):
- transferred_qty[material_reference] += transferred
-
- covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
- covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty"))
- self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry)
-
- def _should_cap_completed_qty(self):
- if self.get("_action") != "submit":
- return False
- if self.purpose != "Material Transfer for Manufacture":
- return False
- if not self.pro_doc or not self.fg_completed_qty:
- return False
- if self.is_return or self.get("is_additional_transfer_entry"):
- return False
- return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card")
-
- def _get_material_coverage_data(self):
- if self.job_card:
- return self._get_job_card_material_qty()
- return self._get_work_order_material_qty()
-
- def _get_job_card_material_qty(self):
- job_card = frappe.get_doc("Job Card", self.job_card)
- required_qty = {}
- transferred_qty = {}
- for row in job_card.items:
- if flt(row.required_qty) <= 0:
- continue
- required_qty[row.name] = flt(row.required_qty)
- transferred_qty[row.name] = flt(row.transferred_qty)
-
- return (
- required_qty,
- transferred_qty,
- self._get_job_card_target_qty(job_card),
- job_card.precision("required_qty", "items"),
- )
-
- def _get_job_card_target_qty(self, job_card):
- required_by_item = {}
- for row in job_card.items:
- required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
-
- work_order_required_by_item = {}
- work_order = frappe.get_doc("Work Order", job_card.work_order)
- for row in work_order.required_items:
- if job_card.operation != row.operation:
- continue
- work_order_required_by_item[row.item_code] = work_order_required_by_item.get(
- row.item_code, 0.0
- ) + flt(row.required_qty)
-
- target_qty = [
- item_required * flt(work_order.qty) / work_order_required_by_item[item_code]
- for item_code, item_required in required_by_item.items()
- if work_order_required_by_item.get(item_code)
- ]
- return min(target_qty) if target_qty else job_card.for_quantity
-
- def _get_work_order_material_qty(self):
- required_qty = {}
- transferred_qty = {}
- for row in self.pro_doc.required_items:
- if not row.include_item_in_manufacturing or flt(row.required_qty) <= 0:
- continue
- required_qty[row.item_code] = required_qty.get(row.item_code, 0.0) + flt(row.required_qty)
- # Duplicate required-item rows each hold the aggregate transferred quantity.
- transferred_qty[row.item_code] = max(
- transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty)
- )
- return (
- required_qty,
- transferred_qty,
- self.pro_doc.qty,
- self.pro_doc.precision("required_qty", "required_items"),
- )
-
- def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision):
- min_fraction = get_minimum_material_coverage_fraction(
- required_qty,
- transferred_qty,
- precision,
- )
- return min_fraction * flt(target_qty)
-
def _validate_no_excess_transfer(self):
if self.is_return:
return
diff --git a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py
index c8c97116e20..1f1671004fd 100644
--- a/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py
+++ b/erpnext/stock/doctype/stock_reposting_settings/stock_reposting_settings.py
@@ -50,6 +50,8 @@ class StockRepostingSettings(Document):
def convert_to_item_wh_reposting(self):
"""Convert Transaction reposting to Item Warehouse based reposting if Item Based Reposting has enabled."""
+ self.check_permission("write")
+
reposting_data = get_reposting_entries()
vouchers = [d.voucher_no for d in reposting_data]