Compare commits

..

17 Commits

Author SHA1 Message Date
Mihir Kandoi
4a3a2cbdc4 test(stock): use existing warehouse fixtures in Pick List tests (v15) (#58840) 2026-09-07 15:22:12 +00:00
Mihir Kandoi
6c6ee7c524 fix(accounts): support drop-shipped items in Gross Profit report (#58686)
* fix(reports): apportion drop-ship buying cost and fix return-matching bug in Gross Profit report

* test(accounts): fix Gross Profit test setup

---------

Co-authored-by: ervishnucs <ervishnucs369@gmail.com>
2026-09-07 15:52:57 +05:30
rohitwaghchaure
baa30e703d fix: check write permission in whitelisted document methods (backport #58689) (#58702)
* fix: check write permission in whitelisted document methods

* test: permission coverage for production plan status roll-ups

* fix: add type hints to whitelisted arguments and submit MR in test

* test: clear request cache before reading non-completed production plans
2026-09-07 15:20:22 +05:30
Mihir Kandoi
684def8818 test(manufacturing): refresh cached production plans before assertion (#58801) 2026-09-07 09:13:45 +00:00
Mihir Kandoi
7cbf8b8660 revert(manufacturing): remove material coverage changes on version 15 (#58715) 2026-09-04 14:32:57 +05:30
Sudharsanan Ashok
0611a6a12b Merge pull request #58753 from frappe/mergify/bp/version-15-hotfix/pr-58740
fix: check material request price list permission (backport #58740)
2026-09-04 13:07:44 +05:30
Pandiyan P
a348d413d0 fix: check material request price list permission (#58740)
(cherry picked from commit 0b1f1d6851)
2026-09-04 07:33:07 +00:00
Sudharsanan Ashok
452db26ccb Merge pull request #58748 from frappe/mergify/bp/version-15-hotfix/pr-58736
fix(batch): show Expired status only after expiry date has passed (backport #58736)
2026-09-04 13:00:37 +05:30
mergify[bot]
934c7d833c fix(setup): strict permissions for transaction deletion record (backport #58687) (#58722)
Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-04 06:53:44 +00:00
Pandiyan P
cd93007780 fix(batch): show Expired status only after expiry date has passed (#58736)
Co-authored-by: Ajish18 <ajishiyappan1@gmail.com>
(cherry picked from commit 00f04fc084)
2026-09-04 06:51:10 +00:00
mergify[bot]
24e7ff55de fix: include payment deductions in sales/purchase register ledger bal… (backport #58437) (#58679)
Co-authored-by: Vishnu Priya Baskaran <145791817+ervishnucs@users.noreply.github.com>
2026-09-02 17:01:20 +05:30
Shllokkk
5f647f0702 Merge pull request #58690 from frappe/mergify/bp/version-15-hotfix/pr-58684
fix(crm): check read permission on lead in add_lead_to_prospect (backport #58684)
2026-09-02 16:01:56 +05:30
Krishna Pramod Shirsath
7ab4092561 Merge pull request #58695 from frappe/mergify/bp/version-15-hotfix/pr-58647
fix: filter cancelled BOMs in BOM Stock Analysis (backport #58647)
2026-09-02 15:31:03 +05:30
Krishna Pramod Shirsath
fff261680c fix: filter cancelled BOMs in BOM Stock Analysis (#58647)
(cherry picked from commit a2071a6fdd)

# Conflicts:
#	erpnext/manufacturing/report/bom_stock_analysis/bom_stock_analysis.js
2026-09-02 09:59:16 +00:00
mergify[bot]
1ea597d6b5 fix(crm): validate contact email before saving an email campaign (backport #58667) (#58671)
Co-authored-by: kaulith <64089478+kaulith@users.noreply.github.com>
2026-09-02 15:12:57 +05:30
Shllokkk
4dd56ec4e6 fix(crm): check read permission on lead in add_lead_to_prospect
(cherry picked from commit 02fcdc0337)

# Conflicts:
#	erpnext/crm/doctype/lead/lead.py
2026-09-02 15:01:41 +05:30
Krishna Pramod Shirsath
3217a69fc2 fix: skip zero quantity items in production plan material requests (#58642) 2026-09-02 13:32:41 +05:30
28 changed files with 850 additions and 452 deletions

View File

@@ -4,7 +4,7 @@ import inspect
import frappe
from frappe.utils.user import is_website_user
__version__ = "15.121.0"
__version__ = "15.112.0"
def get_default_company(user=None):

View File

@@ -510,6 +510,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 +519,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 +536,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 +589,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 +627,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 +733,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 +748,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 +848,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 +884,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 +1071,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":

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,9 @@ class ProductionPlan(Document):
material_request_map = {}
for item in self.mr_items:
if not item.quantity:
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

View File

@@ -107,6 +107,19 @@ 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_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 +1402,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 +2200,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 +2345,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

View File

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

View File

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

View File

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

View File

@@ -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 ? `<b>${value}</b>` : "";
}
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 = `<span style="color: red">${value}</span>`;
} else if (numeric > 0) {
value = `<span style="color: green">${value}</span>`;
}
}
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 `<b style="color: ${colour}">${value}</b>`;
}
return `<b>${value}</b>`;
}
return value;
},
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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