Merge branch 'develop' into fix/available-batch-report-company-filter

This commit is contained in:
Krishna Pramod Shirsath
2026-08-12 12:23:04 +05:30
committed by GitHub
119 changed files with 90207 additions and 98805 deletions

View File

@@ -91,3 +91,5 @@ pull_request_rules:
commit_message_format:
title: pr-title
body: pr-body
merge_queue:
queue_controls_comment: false

View File

@@ -727,6 +727,7 @@ def get_company_default_account_fields():
"stock_delivered_but_not_billed": "Stock Delivered But Not Billed Account",
"stock_adjustment_account": "Stock Adjustment Account",
"write_off_account": "Write Off Account",
"bank_charges_account": "Bank Charges Account",
"default_discount_account": "Default Payment Discount Account",
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
"exchange_gain_loss_account": "Exchange Gain / Loss Account",

View File

@@ -110,18 +110,6 @@ frappe.ui.form.on("Chart of Accounts Importer", {
args: {
company: frm.doc.company,
},
callback: function (r) {
if (r.message === false) {
frm.set_value("company", "");
frappe.throw(
__(
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
)
);
} else {
frm.trigger("refresh");
}
},
});
}
},

View File

@@ -70,7 +70,13 @@ def validate_company(company: str):
frappe.throw(msg, title=_("Wrong Company"))
if frappe.db.get_all("GL Entry", {"company": company}, "name", limit=1):
return False
frappe.throw(
_(
"Transactions against the Company already exist! Chart of Accounts can only be imported for a Company with no transactions."
)
)
validate_user_perms(company)
@frappe.whitelist()
@@ -79,16 +85,22 @@ def import_coa(file_name: str, company: str):
# delete existing data for accounts
frappe.has_permission("Company", "write", company, throw=True)
unset_existing_data(company)
# create accounts
file_doc, extension = get_file(file_name)
validate_accounts(file_doc, extension)
if extension == "csv":
data = generate_data_from_csv(file_doc)
else:
data = generate_data_from_excel(file_doc, extension)
validate_columns(data)
validate_company(company)
unset_existing_data(company)
frappe.local.flags.ignore_root_company_validation = True
forest = build_forest(data)
create_charts(company, custom_chart=forest, from_coa_importer=True)
@@ -453,7 +465,6 @@ def get_mandatory_account_types():
def unset_existing_data(company):
# remove accounts data from company
fieldnames = get_linked_fields("Account").get("Company", {}).get("fieldname", [])
linked = [{"fieldname": name} for name in fieldnames]
update_values = {d.get("fieldname"): "" for d in linked}
@@ -463,13 +474,30 @@ def unset_existing_data(company):
# remove accounts data from various doctypes
for doctype in [
"Account",
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
"Party Account",
"Mode of Payment Account",
"Tax Withholding Account",
"Sales Taxes and Charges Template",
"Purchase Taxes and Charges Template",
]:
frappe.get_query(doctype, delete=True, filters={"company": company}, ignore_permissions=False).run()
frappe.get_query(doctype, delete=True, filters={"company": company}).run()
def validate_user_perms(company):
# User Permission Check for Account Deletion
company_accounts_count = frappe.get_query(
"Account", fields=[{"COUNT": "name"}], filters={"company": company}
).run()[0][0]
company_accounts_user_has_access_to = frappe.get_query(
"Account", fields=[{"COUNT": "name"}], filters={"company": company}, ignore_permissions=False
).run()[0][0]
if company_accounts_count != company_accounts_user_has_access_to:
frappe.throw(
_("Accounts cannot be removed, as user doesn't have access to all the accounts of {0}").format(
frappe.bold(company)
)
)
def set_default_accounts(company):

View File

@@ -1292,7 +1292,10 @@ frappe.ui.form.on("Payment Entry", {
if (!row) {
const company_defaults = frappe.get_doc(":Company", frm.doc.company);
const is_single_currency =
frm.doc.paid_from_account_currency === frm.doc.paid_to_account_currency;
const account =
(is_single_currency && company_defaults?.bank_charges_account) ||
company_defaults?.[account_fieldname] ||
(await prompt_for_missing_account(frm, account_fieldname));
@@ -1847,7 +1850,7 @@ frappe.ui.form.on("Payment Entry Deduction", {
before_deductions_remove: function (doc, cdt, cdn) {
const row = frappe.get_doc(cdt, cdn);
if (row.is_exchange_gain_loss && row.amount) {
frappe.throw(__("Cannot delete Exchange Gain/Loss row"));
frappe.throw(__("Cannot delete a system-generated deduction row"));
}
},

View File

@@ -1135,10 +1135,18 @@ class PaymentEntry(AccountsController):
if not exchange_gain_loss_row:
values = frappe.get_cached_value(
"Company", self.company, ("exchange_gain_loss_account", "cost_center"), as_dict=True
"Company",
self.company,
("bank_charges_account", "exchange_gain_loss_account", "cost_center"),
as_dict=True,
)
is_single_currency = self.paid_from_account_currency == self.paid_to_account_currency
account = (
is_single_currency and values.bank_charges_account
) or values.exchange_gain_loss_account
for fieldname, value in values.items():
missing_fields = {"exchange_gain_loss_account": account, "cost_center": values.cost_center}
for fieldname, value in missing_fields.items():
if value:
continue
@@ -1155,7 +1163,7 @@ class PaymentEntry(AccountsController):
exchange_gain_loss_row = self.append(
"deductions",
{
"account": values.exchange_gain_loss_account,
"account": account,
"cost_center": values.cost_center,
"is_exchange_gain_loss": 1,
},

View File

@@ -782,6 +782,94 @@ class TestPaymentEntry(ERPNextTestSuite):
self.validate_gl_entries(pe.name, expected_gle)
def test_bank_charges_deduction(self):
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",
account_name="_Test Bank Charges",
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
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 Cash - _TC"
pe.paid_amount = 1000
pe.received_amount = 990
pe.reference_no = "4"
pe.reference_date = nowdate()
pe.setup_party_account_field()
pe.set_missing_values()
pe.set_exchange_rate()
pe.set_amounts()
self.assertEqual(pe.deductions[0].account, bank_charges_account)
self.assertEqual(pe.deductions[0].amount, 10)
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
expected_gle = dict(
(d[0], d)
for d in [
["_Test Bank - _TC", 0, 1000, None],
["_Test Cash - _TC", 990, 0, None],
[bank_charges_account, 10, 0, None],
]
)
self.validate_gl_entries(pe.name, expected_gle)
def test_cross_currency_transfer_ignores_bank_charges_account(self):
exchange_gain_loss_account = frappe.db.get_value(
"Company", "_Test Company", "exchange_gain_loss_account"
)
bank_charges_account = create_account(
parent_account="Indirect Expenses - _TC",
account_name="_Test Bank Charges",
company="_Test Company",
)
frappe.db.set_value("Company", "_Test Company", "bank_charges_account", bank_charges_account)
self.addCleanup(frappe.db.set_value, "Company", "_Test Company", "bank_charges_account", "")
pe = frappe.new_doc("Payment Entry")
pe.payment_type = "Internal Transfer"
pe.company = "_Test Company"
pe.paid_from = "_Test Bank USD - _TC"
pe.paid_to = "_Test Bank - _TC"
pe.paid_amount = 100
pe.source_exchange_rate = 50
pe.received_amount = 4500
pe.reference_no = "5"
pe.reference_date = nowdate()
pe.setup_party_account_field()
pe.set_missing_values()
pe.set_exchange_rate()
pe.set_amounts()
self.assertEqual(pe.deductions[0].account, exchange_gain_loss_account)
self.assertEqual(pe.deductions[0].amount, 500)
pe.deductions[0].cost_center = "_Test Cost Center - _TC"
pe.insert()
pe.submit()
expected_gle = dict(
(d[0], d)
for d in [
["_Test Bank USD - _TC", 0, 5000, None],
["_Test Bank - _TC", 4500, 0, None],
[exchange_gain_loss_account, 500.0, 0, None],
]
)
self.validate_gl_entries(pe.name, expected_gle)
def test_payment_against_negative_sales_invoice(self):
si1 = create_sales_invoice()

View File

@@ -53,7 +53,7 @@
"depends_on": "eval:doc.is_exchange_gain_loss",
"fieldname": "is_exchange_gain_loss",
"fieldtype": "Check",
"label": "Is Exchange Gain / Loss?",
"label": "System Generated",
"read_only": 1
}
],

View File

@@ -297,7 +297,6 @@
"hide_days": 1,
"hide_seconds": 1,
"label": "Tax Id",
"print_hide": 1,
"read_only": 1
},
{
@@ -1940,6 +1939,7 @@
"allow_on_submit": 1,
"fieldname": "additional_discount_account",
"fieldtype": "Link",
"hidden": 1,
"label": "Discount Account",
"options": "Account"
},
@@ -2360,7 +2360,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-06-21 12:46:13.250145",
"modified": "2026-08-11 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -29,6 +29,7 @@ from erpnext.setup.doctype.company.company import update_company_current_month_s
from erpnext.stock.doctype.delivery_note.services.billing_status import (
update_billed_amount_based_on_so,
)
from erpnext.stock.utils import get_bin_qty_map
from .services.fixed_assets import FixedAssetService
from .services.inter_company import (
@@ -991,11 +992,17 @@ class SalesInvoice(SellingController):
)
def update_current_stock(self):
bin_qty_map = get_bin_qty_map(self.items + self.packed_items)
for item in self.items:
item.set_actual_qty()
if item.item_code and item.warehouse:
bin_data = bin_qty_map.get((item.item_code, item.warehouse))
item.actual_qty = bin_data.actual_qty if bin_data else 0
for packed_item in self.packed_items:
packed_item.set_actual_and_projected_qty()
bin_data = bin_qty_map.get((packed_item.item_code, packed_item.warehouse))
packed_item.actual_qty = bin_data.actual_qty if bin_data else 0
packed_item.projected_qty = bin_data.projected_qty if bin_data else 0
def update_packing_list(self):
if cint(self.update_stock) == 1:

View File

@@ -887,6 +887,7 @@
"allow_on_submit": 1,
"fieldname": "discount_account",
"fieldtype": "Link",
"hidden": 1,
"label": "Discount Account",
"options": "Account"
},
@@ -1067,7 +1068,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-08-07 17:31:31.732720",
"modified": "2026-08-11 12:00:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -114,15 +114,6 @@ class SalesInvoiceItem(Document):
)
)
def set_actual_qty(self):
if self.item_code and self.warehouse:
self.actual_qty = (
frappe.db.get_value(
"Bin", {"item_code": self.item_code, "warehouse": self.warehouse}, "actual_qty"
)
or 0
)
def set_income_account_for_fixed_asset(self, company: str):
"""Set income account for fixed asset item based on company's disposal account and cost center."""
if not self.is_fixed_asset:

View File

@@ -323,9 +323,13 @@ def add_total_row_account(
consolidated=False,
add_blank_row=True,
):
name_key = "account" if consolidated else "section"
parent_key = "parent_account" if consolidated else "parent_section"
label_str = "'" + str(label) + "'"
total_row = {
"section_name": "'" + _("{0}").format(label) + "'",
"section": "'" + _("{0}").format(label) + "'",
f"{name_key}_name": label_str,
name_key: label_str,
"currency": currency,
}
@@ -336,15 +340,15 @@ def add_total_row_account(
period_list = get_filtered_list_for_consolidated_report(filters, period_list)
for row in data:
if row.get("parent_section"):
if row.get(parent_key):
for period in period_list:
key = period if consolidated else period["key"]
total_row.setdefault(key, 0.0)
total_row[key] += row.get(key, 0.0)
summary_data[label] += row.get(key)
summary_data[label] += row.get(key) or 0.0
total_row.setdefault("total", 0.0)
total_row["total"] += row["total"]
total_row["total"] += row.get("total", 0.0)
out.append(total_row)
@@ -484,7 +488,6 @@ def get_opening_range_using_fiscal_year(company, period_list):
def get_report_summary(summary_data, currency):
report_summary = []
for label, value in summary_data.items():
report_summary.append({"value": value, "label": label, "datatype": "Currency", "currency": currency})

View File

@@ -53,6 +53,15 @@ class BuyingSettings(Document):
for key in ["supplier_group", "supp_master_name", "maintain_same_rate", "buying_price_list"]:
frappe.db.set_default(key, self.get(key, ""))
self.update_supplier_naming_settings()
if not self.bill_for_rejected_quantity_in_purchase_invoice:
self.set_valuation_rate_for_rejected_materials = 0
def update_supplier_naming_settings(self):
if not self.has_value_changed("supp_master_name"):
return
from erpnext.utilities.naming import set_by_naming_series
set_by_naming_series(
@@ -62,9 +71,6 @@ class BuyingSettings(Document):
hide_name_field=False,
)
if not self.bill_for_rejected_quantity_in_purchase_invoice:
self.set_valuation_rate_for_rejected_materials = 0
def before_save(self):
self.check_maintain_same_rate()

View File

@@ -1,9 +1,30 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from unittest.mock import patch
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestBuyingSettings(ERPNextTestSuite):
pass
def test_unrelated_change_does_not_update_supplier_metadata(self):
settings = frappe.get_single("Buying Settings")
settings.allow_multiple_items = not settings.allow_multiple_items
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
settings.save()
set_by_naming_series.assert_not_called()
def test_supplier_metadata_updates_when_related_settings_change(self):
settings = frappe.get_single("Buying Settings")
settings.supp_master_name = (
"Supplier Name" if settings.supp_master_name == "Naming Series" else "Naming Series"
)
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
settings.save()
set_by_naming_series.assert_called_once()

View File

@@ -51,9 +51,9 @@ class SubcontractingService:
if not doc.is_subcontracted:
return
finished_goods_without_service_item = {
d.fg_item for d in doc.items if (not d.item_code and d.fg_item)
}
finished_goods_without_service_item = list(
{d.fg_item for d in doc.items if (not d.item_code and d.fg_item)}
)
if subcontracting_boms := get_subcontracting_boms_for_finished_goods(
finished_goods_without_service_item

View File

@@ -13,6 +13,7 @@ from frappe.utils import get_url
from frappe.utils.print_format import download_pdf
from frappe.utils.user import get_user_fullname
from erpnext.accounts.party import validate_party_frozen_disabled
from erpnext.buying.utils import validate_for_items
from erpnext.controllers.buying_controller import BuyingController
@@ -122,6 +123,8 @@ class RequestforQuotation(BuyingController):
def validate_supplier_list(self):
for d in self.suppliers:
validate_party_frozen_disabled(self.company, "Supplier", d.supplier)
prevent_rfqs = frappe.db.get_value("Supplier", d.supplier, "prevent_rfqs")
if prevent_rfqs:
standing = frappe.db.get_value("Supplier Scorecard", d.supplier, "status")

View File

@@ -18,6 +18,7 @@ from erpnext.buying.doctype.request_for_quotation.request_for_quotation import (
from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.crm.doctype.opportunity.mapper import make_request_for_quotation as make_rfq
from erpnext.crm.doctype.opportunity.test_opportunity import make_opportunity
from erpnext.exceptions import PartyDisabled
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.material_request.test_material_request import make_material_request
from erpnext.templates.pages.rfq import check_supplier_has_docname_access
@@ -89,6 +90,17 @@ class TestRequestforQuotation(ERPNextTestSuite):
)
self.assertRaises(frappe.ValidationError, rfq.save)
def test_rfq_blocked_for_disabled_supplier(self):
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
rfq = make_request_for_quotation(
supplier_data=[{"supplier": "_Test Supplier", "supplier_name": "_Test Supplier"}],
do_not_save=True,
)
self.assertRaises(PartyDisabled, rfq.save)
frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
rfq.save()
def test_rfq_status_lifecycle(self):
rfq = make_request_for_quotation()
self.assertEqual(rfq.status, "Submitted")

View File

@@ -40,6 +40,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Supplier",
"link_filters": "[[\"Supplier\",\"disabled\",\"=\",0]]",
"options": "Supplier",
"reqd": 1
},

View File

@@ -31,7 +31,7 @@ from erpnext.exceptions import (
)
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 import get_warehouse_account_map
from erpnext.stock import get_warehouse_account, get_warehouse_account_map
from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.services.internal_transfer import StockInternalTransferService
from erpnext.stock.stock_ledger import get_items_to_be_repost
@@ -135,7 +135,9 @@ class StockController(AccountsController):
def use_item_inventory_account(self):
return frappe.get_cached_value("Company", self.company, "enable_item_wise_inventory_account")
def get_inventory_account_dict(self, row, inventory_account_map, warehouse_field=None):
def get_inventory_account_dict(
self, row, inventory_account_map, warehouse_field=None, *, raise_error=True
):
account_dict = frappe._dict()
if isinstance(row, dict):
@@ -164,8 +166,15 @@ class StockController(AccountsController):
if not warehouse:
warehouse = self.get(warehouse_field)
if warehouse and warehouse in inventory_account_map:
account_dict = inventory_account_map[warehouse]
if warehouse:
account_dict = inventory_account_map.get(warehouse)
if not account_dict and raise_error:
account = get_warehouse_account(frappe.get_cached_doc("Warehouse", warehouse))
account_dict = frappe._dict(
account=account,
account_currency=frappe.get_cached_value("Account", account, "account_currency"),
)
inventory_account_map[warehouse] = account_dict
return account_dict

View File

@@ -938,8 +938,9 @@ class calculate_taxes_and_totals:
item.net_amount = flt(
item.net_amount + rounding_difference, item.precision("net_amount")
)
# net_amount went up by rounding_difference, so its discount share goes down
item.distributed_discount_amount = flt(
distributed_amount + rounding_difference,
distributed_amount - rounding_difference,
item.precision("distributed_discount_amount"),
)
net_total += rounding_difference

View File

@@ -60,6 +60,30 @@ class TestTaxesAndTotals(ERPNextTestSuite):
self.assertAlmostEqual(so.net_total, 1272.73, places=2)
self.assertEqual(so.grand_total, 1400)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": 1})
def test_distributed_discount_amount_with_rounding_adjustment(self):
so = make_sales_order(do_not_save=1)
so.apply_discount_on = "Net Total"
so.discount_amount = 10
so.items[0].qty = 1
so.items[0].rate = 100
so.append("items", so.items[0].as_dict())
so.append("items", so.items[0].as_dict())
so.save()
calculate_taxes_and_totals(so)
# the rounding adjustment lands on the second line
self.assertAlmostEqual(so.items[1].net_amount, 96.66, places=2)
self.assertAlmostEqual(so.items[1].distributed_discount_amount, 3.34, places=2)
for item in so.items:
self.assertAlmostEqual(item.amount - item.distributed_discount_amount, item.net_amount, places=2)
self.assertAlmostEqual(
sum(i.distributed_discount_amount for i in so.items), so.discount_amount, places=2
)
self.assertEqual(so.net_total, 290)
def test_100_percent_discount_with_inclusive_tax(self):
"""Test that 100% discount with inclusive taxes results in zero net_total"""
so = make_sales_order(do_not_save=1)

View File

@@ -46,3 +46,61 @@ class TestReactivity(ERPNextTestSuite):
with self.subTest(field=field):
self.assertIsNotNone(itm.get(field[0]))
si.save().submit()
def test_item_change_clears_stale_item_details(self):
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
old_item = make_item(properties={"is_stock_item": 0, "stock_uom": "Nos"})
new_item = make_item(
properties={
"is_stock_item": 0,
"stock_uom": "Kg",
"weight_per_unit": 2,
"weight_uom": "Kg",
}
)
sales_order = make_sales_order(item_code=old_item.name, do_not_submit=True)
item = sales_order.items[0]
self.assertEqual(item.uom, "Nos")
row_state = (item.qty, item.warehouse, item.delivery_date)
sales_order.ignore_pricing_rule = 1
item.weight_per_unit = 10
item.weight_uom = "Nos"
item.barcode = "OLD-BARCODE"
item.pricing_rules = "OLD-PRICING-RULE"
item.item_code = new_item.name
sales_order.process_item_selection(item.idx, reset_item_details=True)
self.assertEqual(item.uom, "Kg")
self.assertEqual(item.stock_uom, "Kg")
self.assertEqual(item.conversion_factor, 1)
self.assertEqual(item.weight_per_unit, 2)
self.assertEqual(item.weight_uom, "Kg")
self.assertIsNone(item.barcode)
self.assertFalse(item.pricing_rules)
self.assertEqual((item.qty, item.warehouse, item.delivery_date), row_state)
def test_programmatic_item_selection_preserves_explicit_uom(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item
item = make_item(
properties={
"is_stock_item": 0,
"stock_uom": "Kg",
"sales_uom": "Nos",
"weight_per_unit": 2,
"weight_uom": "Kg",
},
uoms=[{"uom": "Nos", "conversion_factor": 10}],
)
sales_invoice = create_sales_invoice(item_code=item.name, uom="Kg", do_not_save=True)
sales_invoice.process_item_selection(sales_invoice.items[0].idx)
self.assertEqual(sales_invoice.items[0].uom, "Kg")
self.assertEqual(sales_invoice.items[0].conversion_factor, 1)
self.assertEqual(sales_invoice.items[0].stock_qty, sales_invoice.items[0].qty)

View File

@@ -12,6 +12,7 @@ from frappe.query_builder import DocType, Interval
from frappe.query_builder.functions import Now
from frappe.utils import flt, get_fullname
from erpnext.accounts.party import validate_party_frozen_disabled
from erpnext.crm.utils import (
CRMNote,
copy_comments,
@@ -132,6 +133,7 @@ class Opportunity(TransactionBase, CRMNote):
self.validate_item_details()
self.validate_uom_is_integer("uom", "qty")
self.validate_cust_name()
self.validate_party()
self.map_fields()
self.validate_qty()
self.set_exchange_rate()
@@ -355,6 +357,10 @@ class Opportunity(TransactionBase, CRMNote):
return False
return True
def validate_party(self) -> None:
if self.opportunity_from == "Customer":
validate_party_frozen_disabled(self.company, "Customer", self.party_name)
def validate_cust_name(self):
if self.party_name:
if self.opportunity_from == "Customer":

View File

@@ -9,6 +9,7 @@ from erpnext.crm.doctype.lead.test_lead import make_lead
from erpnext.crm.doctype.opportunity.mapper import make_quotation
from erpnext.crm.doctype.opportunity.opportunity import auto_close_opportunity, get_item_details
from erpnext.crm.utils import get_linked_communication_list
from erpnext.exceptions import PartyDisabled
from erpnext.tests.utils import ERPNextTestSuite
@@ -71,6 +72,23 @@ class TestOpportunity(ERPNextTestSuite):
opportunity_doc = make_opportunity(with_items=1, rate=1100, qty=2)
self.assertEqual(opportunity_doc.total, 2200)
def test_disabled_customer_not_allowed(self):
frappe.db.set_value("Customer", "_Test Customer", "disabled", 1)
self.assertRaises(PartyDisabled, make_opportunity, with_items=0)
frappe.db.set_value("Customer", "_Test Customer", "disabled", 0)
make_opportunity(with_items=0)
def test_disabled_lead_not_blocked(self):
# Lead.disabled isn't enforced anywhere else (e.g. the Lead picker query only
# excludes Converted leads), so it shouldn't block Opportunity creation either.
lead_doc = make_lead()
frappe.db.set_value("Lead", lead_doc.name, "disabled", 1)
opp_doc = make_opportunity(opportunity_from="Lead", lead=lead_doc.name)
self.assertEqual(opp_doc.party_name, lead_doc.name)
def test_carry_forward_of_email_and_comments(self):
frappe.db.set_single_value("CRM Settings", "carry_forward_communication_and_comments", 1)
lead_doc = make_lead()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -158,9 +158,9 @@ def _item_query_filters(filters):
def _item_query_or_filters(txt, searchfields, query_filters):
if not txt:
return {}
return []
or_filters = {s_field: ("like", f"%{txt}%") for s_field in searchfields}
or_filters = [[s_field, "like", f"%{txt}%"] for s_field in searchfields]
barcodes = frappe.get_all(
"Item Barcode",
fields=["parent as item_code"],
@@ -169,7 +169,7 @@ def _item_query_or_filters(txt, searchfields, query_filters):
)
barcode_codes = [d.item_code for d in barcodes]
if barcode_codes:
or_filters["name"] = ("in", barcode_codes)
or_filters.append(["name", "in", barcode_codes])
return or_filters

View File

@@ -594,6 +594,29 @@ class TestBOM(ERPNextTestSuite):
self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results")
self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results")
@timeout
def test_bom_item_query_matches_item_code_colliding_with_another_barcode(self):
item = make_item(
"_Test BOM Query 2.5MM",
{"is_stock_item": 1, "item_name": "_Test BOM Query Sheet", "description": "sheet"},
)
make_item(
"_Test BOM Query Barcode Holder",
{"is_stock_item": 1},
barcode=f"90{item.name}90",
)
results = item_query(
doctype="Item",
txt=item.name,
searchfield="name",
start=0,
page_len=20,
filters={"is_stock_item": 1},
)
self.assertIn(item.name, [d[0] for d in results])
@timeout
def test_exclude_exploded_items_from_bom(self):
bom_no = get_default_bom()

View File

@@ -208,13 +208,6 @@ frappe.ui.form.on("BOM Creator", {
});
frappe.ui.form.on("BOM Creator Item", {
item_code(frm, cdt, cdn) {
let item = frappe.get_doc(cdt, cdn);
if (item.item_code && item.is_root) {
frappe.model.set_value(cdt, cdn, "fg_item", item.item_code);
}
},
do_not_explode(frm, cdt, cdn) {
let item = frappe.get_doc(cdt, cdn);
if (!item.do_not_explode) {
@@ -237,6 +230,20 @@ frappe.ui.form.on("BOM Creator Item", {
});
erpnext.bom.BomConfigurator = class BomConfigurator extends erpnext.TransactionController {
item_code(doc, cdt, cdn) {
if (cdt !== "BOM Creator Item") {
return;
}
let item = frappe.get_doc(cdt, cdn);
if (item.item_code && item.is_root) {
frappe.model.set_value(cdt, cdn, "fg_item", item.item_code);
}
// BOM Creator does not support TransactionController's server-side item selection.
return this.process_item_selection(doc, cdt, cdn);
}
conversion_rate(doc) {
if (this.frm.doc.currency === this.get_company_currency()) {
this.frm.set_value("conversion_rate", 1.0);

View File

@@ -597,8 +597,7 @@ frappe.ui.form.on("Job Card", {
const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty;
const pending_transfer =
has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
const materials_ready =
doc.skip_material_transfer || !pending_transfer || !doc.finished_good || !has_items;
const materials_ready = doc.skip_material_transfer || doc.is_corrective_job_card || !pending_transfer;
let last_row = {};
const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0;

View File

@@ -1692,6 +1692,7 @@ class JobCard(Document):
frappe.has_permission("Job Card", "write", doc=self, throw=True)
self.validate_docstatus()
self.validate_transfer_qty()
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -1707,6 +1708,7 @@ class JobCard(Document):
frappe.has_permission("Job Card", "write", doc=self, throw=True)
self.validate_docstatus()
self.validate_transfer_qty()
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)

View File

@@ -383,6 +383,31 @@ class TestJobCard(ERPNextTestSuite):
# JC is Completed with excess transfer
self.assertEqual(job_card.status, "Completed")
def test_job_card_actions_blocked_until_material_transfer(self):
"Start and Complete must wait for the transfer when RMs move against Job Card."
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
self.generate_required_stock(self.work_order)
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
self.assertRaises(frappe.ValidationError, job_card.start_timer, start_time=now())
self.assertRaises(frappe.ValidationError, job_card.complete_job_card, qty=2, for_quantity=2)
transfer_entry = make_stock_entry_from_jc(job_card.name)
transfer_entry.insert()
transfer_entry.submit()
job_card.reload()
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=2, for_quantity=2, pending_qty=0, process_loss_qty=0, end_time="2024-03-01 09:00:00"
)
job_card.reload()
self.assertEqual(flt(job_card.total_completed_qty), 2)
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
def test_job_card_excess_material_transfer_block(self):
self.transfer_material_against = "Job Card"

View File

@@ -312,6 +312,7 @@ def get_operation_details(name, work_order, parent_bom):
for row in work_order.operations:
if row.name == name:
return {
"idx": row.idx,
"workstation": row.workstation,
"workstation_type": row.workstation_type,
"source_warehouse": row.source_warehouse,

View File

@@ -34,6 +34,7 @@ from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.stock.doctype.stock_entry.stock_entry import OperationsNotCompleteError
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin
from erpnext.tests.utils import ERPNextTestSuite
@@ -509,6 +510,18 @@ class TestWorkOrder(ERPNextTestSuite):
for stock_entry in stock_entries:
stock_entry.cancel()
@timeout(seconds=60)
def test_manufacture_blocked_until_operations_completed(self):
bom = frappe.get_doc(
"BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company", "has_variants": 0}
)
work_order = make_wo_order_test_record(
item=bom.item, qty=1, bom_no=bom.name, source_warehouse="_Test Warehouse - _TC", skip_transfer=1
)
stock_entry = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1))
self.assertRaises(OperationsNotCompleteError, stock_entry.insert)
def test_work_order_material_transferred_qty_with_process_loss(self):
stock_entries = []
item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name

View File

@@ -242,7 +242,6 @@ class Workstation(Document):
for row in doc.time_logs:
if not row.to_time:
row.to_time = to_time
row.time_in_mins = time_diff_in_hours(row.to_time, row.from_time) / 60
row.completed_qty = qty
doc.save()

View File

@@ -25,10 +25,12 @@ def fetch_exploded_bom_items(root_bom):
recursive CTE -- replaces a query-per-node walk with a single query. UNION keeps it cycle-safe
and fetches each sub-BOM's items only once even when it is reused across the tree."""
bom_item = frappe.qb.DocType("BOM Item")
child_bom = frappe.qb.DocType("BOM").as_("child_bom")
tree = frappe.qb.Table("exploded_bom")
fields = [
bom_item.parent,
bom_item.qty,
bom_item.stock_qty,
bom_item.bom_no,
bom_item.item_code,
bom_item.item_name,
@@ -46,7 +48,11 @@ def fetch_exploded_bom_items(root_bom):
.where(tree.bom_no != "")
)
rows = (
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True).from_(tree).select(tree.star)
frappe.qb.with_(seed + recursion, "exploded_bom", recursive=True)
.from_(tree)
.left_join(child_bom)
.on(tree.bom_no == child_bom.name)
.select(tree.star, child_bom.quantity.as_("child_bom_qty"))
).run(as_dict=True)
children_map = defaultdict(list)
@@ -71,7 +77,13 @@ def build_exploded_rows(bom, children_map, data, indent=0, qty=1):
}
)
if item.bom_no:
build_exploded_rows(item.bom_no, children_map, data, indent + 1, item.qty)
build_exploded_rows(
item.bom_no,
children_map,
data,
indent + 1,
qty * item.stock_qty / item.child_bom_qty,
)
def get_columns():

View File

@@ -4,7 +4,7 @@
import frappe
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.manufacturing.report.bom_explorer.bom_explorer import execute
from erpnext.manufacturing.report.bom_explorer.bom_explorer import build_exploded_rows, execute
from erpnext.tests.utils import ERPNextTestSuite
@@ -78,3 +78,55 @@ class TestBOMExplorer(ERPNextTestSuite):
# The leaf belongs to the sub-assembly, so it is exploded one level deeper.
self.assertEqual(rows_by_item[leaf_item]["indent"], 1)
self.assertEqual(rows_by_item[leaf_item]["bom_level"], 1)
def test_nested_bom_uses_stock_qty_for_output_normalization(self):
parent_bom = create_nested_bom(
{"parent": {"sub": {"leaf": {}}}},
prefix="_Test explorer converted quantity ",
)
sub_bom = frappe.get_doc("BOM", parent_bom.items[0].bom_no)
# The parent needs two boxes (20 units). The child BOM produces five units per batch.
frappe.db.set_value("BOM", sub_bom.name, "quantity", 5)
frappe.db.set_value("BOM Item", sub_bom.items[0].name, {"qty": 3, "stock_qty": 3})
frappe.db.set_value(
"BOM Item",
parent_bom.items[0].name,
{"qty": 2, "uom": "Box", "conversion_factor": 10, "stock_qty": 20},
)
data = self.run_report(parent_bom.name)
rows_by_item = {row["item_code"]: row for row in data}
self.assertEqual(rows_by_item["_Test explorer converted quantity sub"]["qty"], 2)
self.assertEqual(rows_by_item["_Test explorer converted quantity leaf"]["qty"], 12)
def test_nested_bom_multiplies_qty_at_every_level(self):
children_map = {
"root": [
frappe._dict(
item_code="parent",
idx=1,
bom_no="parent-bom",
child_bom_qty=1,
qty=8,
stock_qty=8,
)
],
"parent-bom": [
frappe._dict(
item_code="child",
idx=1,
bom_no="child-bom",
child_bom_qty=1,
qty=4,
stock_qty=4,
)
],
"child-bom": [frappe._dict(item_code="raw-material", idx=1, bom_no="", qty=2, stock_qty=2)],
}
data = []
build_exploded_rows("root", children_map, data)
self.assertEqual([row["qty"] for row in data], [8, 32, 64])

View File

@@ -1326,13 +1326,7 @@ def make_order(selected_rows: str | list, company: str, warehouse: str | None =
def make_purchase_orders(purchase_orders, company, warehouse=None, mps=None):
for (supplier, release_date), items in purchase_orders.items():
po = frappe.new_doc("Purchase Order")
po.supplier = supplier
po.company = company
po.mps = mps
po.transaction_date = release_date
po.set("items", [])
po_items = []
for item in items:
uom = item.purchase_uom or item.uom
if not uom:
@@ -1345,23 +1339,33 @@ def make_purchase_orders(purchase_orders, company, warehouse=None, mps=None):
if flt(item.required_qty) < flt(item.min_order_qty):
item.required_qty = item.min_order_qty
po.append(
"items",
po_items.append(
{
"item_code": item.item_code,
"qty": item.required_qty,
"uom": uom,
"schedule_date": item.delivery_date if item.delivery_date else today(),
"warehouse": warehouse or item.default_warehouse,
},
}
)
if len(po.items) > 0:
po.insert()
frappe.msgprint(
_("Purchase Order {0} created").format(frappe.bold(po.name)),
alert=True,
)
if not po_items:
continue
po = frappe.new_doc("Purchase Order")
po.supplier = supplier
po.company = company
po.mps = mps
po.transaction_date = release_date
po.set("items", po_items)
po.run_method("set_missing_values")
po.insert()
frappe.msgprint(
_("Purchase Order {0} created").format(frappe.bold(po.name)),
alert=True,
)
def make_work_orders(work_orders, company, warehouse=None, mps=None):

View File

@@ -2,13 +2,23 @@
# See license.txt
import frappe
from frappe.utils import add_days, flt, today
from erpnext.accounts.doctype.tax_rule.test_tax_rule import make_tax_rule
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.report.material_requirements_planning_report.material_requirements_planning_report import (
execute,
get_item_lead_time,
make_order,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
WAREHOUSE = "_Test Warehouse - _TC"
SUPPLIER = "_Test Supplier"
TAX_TEMPLATE = "_Test Purchase Taxes and Charges Template - _TC"
class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
def test_manufacture_lead_time_is_not_int_truncated(self):
@@ -28,3 +38,126 @@ class TestMaterialRequirementsPlanningReport(ERPNextTestSuite):
lead_time = get_item_lead_time(item, "Manufacture")
# 1440 / 7 + 2 = 207.714...; a truncating integer division on Postgres would give 207.
self.assertAlmostEqual(float(lead_time), 1440 / 7 + 2, places=2)
def test_make_order_creates_draft_purchase_and_work_orders(self):
plan = make_mrp_plan(self)
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
purchase_order = get_created_order(plan.mps, "Purchase Order")
self.assertEqual(purchase_order.docstatus, 0)
self.assertEqual(purchase_order.supplier, SUPPLIER)
self.assertEqual([d.item_code for d in purchase_order.items], [plan.rm_item])
self.assertEqual(purchase_order.items[0].qty, plan.planned_qty * plan.rm_qty)
work_order = get_created_order(plan.mps, "Work Order")
self.assertEqual(work_order.docstatus, 0)
self.assertEqual(work_order.production_item, plan.fg_item)
self.assertEqual(work_order.bom_no, plan.bom)
self.assertEqual(work_order.qty, plan.planned_qty)
def test_purchase_order_gets_defaults_from_set_missing_values(self):
plan = make_mrp_plan(self)
make_tax_rule(tax_type="Purchase", purchase_tax_template=TAX_TEMPLATE, priority=1, save=1)
frappe.get_doc(
{
"doctype": "Item Price",
"item_code": plan.rm_item,
"price_list": "Standard Buying",
"price_list_rate": 100,
}
).insert()
make_order(plan.rows, COMPANY, warehouse=WAREHOUSE, mps=plan.mps)
purchase_order = get_created_order(plan.mps, "Purchase Order")
self.assertEqual(purchase_order.buying_price_list, "Standard Buying")
self.assertEqual(purchase_order.items[0].rate, 100)
template = frappe.get_doc("Purchase Taxes and Charges Template", TAX_TEMPLATE)
self.assertEqual(purchase_order.taxes_and_charges, TAX_TEMPLATE)
self.assertEqual([d.rate for d in purchase_order.taxes], [d.rate for d in template.taxes])
net_total = flt(purchase_order.net_total)
self.assertEqual(
purchase_order.grand_total, net_total + net_total * flt(template.taxes[0].rate) / 100
)
def make_mrp_plan(test_case, planned_qty=10, rm_qty=2):
"""Build a finished good with a submitted BOM and an MPS demanding it, then return the
report's own output rows -- the same payload the report's client sends to `make_order`."""
rm_item = make_item(
properties={
"is_stock_item": 1,
"is_purchase_item": 1,
"item_defaults": [
{"company": COMPANY, "default_warehouse": WAREHOUSE, "default_supplier": SUPPLIER}
],
}
).name
fg_item = make_item(
properties={
"is_stock_item": 1,
"item_defaults": [{"company": COMPANY, "default_warehouse": WAREHOUSE}],
}
).name
# on_submit sets Item.default_bom, which is how the report finds the raw materials
bom = make_bom(item=fg_item, raw_materials=[rm_item], rm_qty=rm_qty, rate=100).name
mps = frappe.get_doc(
{
"doctype": "Master Production Schedule",
"company": COMPANY,
"posting_date": today(),
"from_date": today(),
"parent_warehouse": WAREHOUSE,
"items": [
{
"item_code": fg_item,
"warehouse": WAREHOUSE,
"delivery_date": add_days(today(), 30),
"planned_qty": planned_qty,
"uom": frappe.get_cached_value("Item", fg_item, "stock_uom"),
}
],
}
)
# left in draft: on_submit enqueues MRP Log creation in a background job
mps.insert()
_, data, _, _ = execute(
frappe._dict(
{
"company": COMPANY,
"from_date": today(),
"to_date": add_days(today(), 90),
"warehouse": WAREHOUSE,
"mps": mps.name,
"type_of_material": "All",
"add_safety_stock": 0,
}
)
)
# the report separates each finished good with a blank row
rows = [row for row in data if row.get("item_code")]
test_case.assertTrue(rows, msg="the report returned no rows to create orders from")
return frappe._dict(
rm_item=rm_item,
fg_item=fg_item,
bom=bom,
mps=mps.name,
planned_qty=planned_qty,
rm_qty=rm_qty,
rows=rows,
)
def get_created_order(mps, doctype):
names = frappe.get_all(doctype, filters={"mps": mps}, pluck="name")
if len(names) != 1:
frappe.throw(f"Expected exactly one {doctype} for {mps}, got {names}")
return frappe.get_doc(doctype, names[0])

View File

@@ -510,3 +510,5 @@ erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.set_stock_uom_in_job_card
erpnext.patches.v16_0.set_work_order_requested_and_picked_qty
erpnext.patches.v16_0.rename_italy_customer_name_fields
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status
erpnext.patches.v16_0.recalculate_mixed_purchase_receipt_billing_status

View File

@@ -0,0 +1,25 @@
import frappe
from erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status import (
exclude_purchase_order_items_with_invoice_created_receipts,
get_candidate_purchase_order_items,
)
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
update_billed_amount_based_on_po,
update_billing_percentage,
)
def execute():
purchase_order_items = get_candidate_purchase_order_items()
if purchase_order_items:
purchase_order_items = exclude_purchase_order_items_with_invoice_created_receipts(
purchase_order_items
)
if not purchase_order_items:
return
updated_purchase_receipts = update_billed_amount_based_on_po(purchase_order_items)
for purchase_receipt in set(updated_purchase_receipts):
update_billing_percentage(frappe.get_doc("Purchase Receipt", purchase_receipt))

View File

@@ -0,0 +1,110 @@
from collections import defaultdict
import frappe
from frappe.query_builder.functions import Count
from frappe.utils import flt
from erpnext.stock.doctype.purchase_receipt.services.billing_status import (
get_billed_amount_against_po,
get_billed_amount_against_pr,
get_purchase_receipts_against_po_details,
update_billed_amount_based_on_po,
update_billing_percentage,
)
def execute():
purchase_order_items = get_affected_purchase_order_items()
if not purchase_order_items:
return
updated_purchase_receipts = update_billed_amount_based_on_po(purchase_order_items)
for purchase_receipt in set(updated_purchase_receipts):
update_billing_percentage(frappe.get_doc("Purchase Receipt", purchase_receipt))
def get_affected_purchase_order_items() -> list[str]:
purchase_order_items = get_candidate_purchase_order_items()
if purchase_order_items:
purchase_order_items = exclude_purchase_order_items_with_invoice_created_receipts(
purchase_order_items
)
if not purchase_order_items:
return []
purchase_receipt_items = get_purchase_receipts_against_po_details(purchase_order_items)
direct_billed_amounts = get_billed_amount_against_pr([item.name for item in purchase_receipt_items])
po_billed_amounts = get_billed_amount_against_po(purchase_order_items)
current_billed_amounts = defaultdict(float)
available_billed_amounts = defaultdict(float)
for purchase_order_item, billed_details in po_billed_amounts.items():
available_billed_amounts[purchase_order_item] = flt(billed_details["billed_amt"])
for item in purchase_receipt_items:
current_billed_amounts[item.purchase_order_item] += flt(item.billed_amt)
available_billed_amounts[item.purchase_order_item] += flt(direct_billed_amounts.get(item.name))
precision = frappe.get_precision("Purchase Receipt Item", "billed_amt") or 2
return [
purchase_order_item
for purchase_order_item in purchase_order_items
if flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_amt")) > 0
and flt(po_billed_amounts.get(purchase_order_item, {}).get("billed_qty")) > 0
and flt(
current_billed_amounts[purchase_order_item] - available_billed_amounts[purchase_order_item],
precision,
)
> 0
]
def exclude_purchase_order_items_with_invoice_created_receipts(purchase_order_items: list[str]) -> list[str]:
invoice_created_receipt_items = set(
frappe.get_all(
"Purchase Receipt Item",
filters={
"purchase_order_item": ("in", purchase_order_items),
"purchase_invoice_item": ("is", "set"),
"docstatus": 1,
},
pluck="purchase_order_item",
)
)
return [item for item in purchase_order_items if item not in invoice_created_receipt_items]
def get_candidate_purchase_order_items() -> list[str]:
purchase_receipt = frappe.qb.DocType("Purchase Receipt")
purchase_receipt_item = frappe.qb.DocType("Purchase Receipt Item")
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
purchase_order_items_with_multiple_receipts = (
frappe.qb.from_(purchase_receipt_item)
.inner_join(purchase_receipt)
.on(purchase_receipt_item.parent == purchase_receipt.name)
.select(purchase_receipt_item.purchase_order_item)
.where(
(purchase_receipt.docstatus == 1)
& (purchase_receipt.is_return == 0)
& purchase_receipt_item.purchase_order_item.isnotnull()
)
.groupby(purchase_receipt_item.purchase_order_item)
.having(Count(purchase_receipt_item.name) > 1)
)
return (
frappe.qb.from_(purchase_invoice_item)
.inner_join(purchase_invoice)
.on(purchase_invoice_item.parent == purchase_invoice.name)
.select(purchase_invoice_item.po_detail)
.distinct()
.where(
(purchase_invoice.docstatus == 1)
& (purchase_invoice.update_stock == 0)
& purchase_invoice_item.pr_detail.isnull()
& purchase_invoice_item.po_detail.isin(purchase_order_items_with_multiple_receipts)
)
).run(pluck=True)

View File

@@ -783,6 +783,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
method: "process_item_selection",
args: {
item_idx: item.idx,
reset_item_details: true,
},
callback: function (r) {
if (!r.exc) {

View File

@@ -12,6 +12,10 @@ $.extend(erpnext.queries, {
return { query: "erpnext.controllers.queries.lead_query" };
},
customer: function () {
return { filters: { disabled: 0 } };
},
item: function (filters) {
var args = { query: "erpnext.controllers.queries.item_query" };
if (filters) args["filters"] = filters;

View File

@@ -837,6 +837,7 @@
flex-direction: column;
padding: var(--padding-lg);
padding-top: var(--padding-md);
overflow-y: auto;
> .item-details-header {
display: flex;

View File

@@ -632,8 +632,8 @@ def get_overdue_billing_threshold(customer: str, company: str) -> float:
def get_customer_overdue_amount(customer: str, company: str) -> float:
"""Amount the customer owes past its due date, in company currency.
Follows the same rule as the Overdue invoice status, so a customer is only
blocked for what the invoice list already shows as overdue.
Reads the Payment Ledger, the same source as `outstanding_amount`, so this agrees
with the Overdue status the invoice list already shows.
"""
invoices = get_outstanding_invoices_for_customer(customer, company)
if not invoices:
@@ -646,27 +646,28 @@ def get_customer_overdue_amount(customer: str, company: str) -> float:
def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]:
from frappe.query_builder.functions import Sum
gl_entry = frappe.qb.DocType("GL Entry")
ple = frappe.qb.DocType("Payment Ledger Entry")
sales_invoice = frappe.qb.DocType("Sales Invoice")
# debit - credit is always booked in company currency, so this is comparable to the overdue limit
outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit)
# the Payment Ledger, not the GL, carries allocations made after submit (reconciled advances).
# `amount` is booked in company currency, so this is comparable to the overdue limit.
outstanding = Sum(ple.amount)
return (
frappe.qb.from_(gl_entry)
frappe.qb.from_(ple)
.inner_join(sales_invoice)
.on(sales_invoice.name == gl_entry.against_voucher)
.on(sales_invoice.name == ple.against_voucher_no)
.select(
sales_invoice.name,
sales_invoice.due_date,
sales_invoice.base_grand_total,
outstanding.as_("outstanding"),
)
.where(gl_entry.party_type == "Customer")
.where(gl_entry.party == customer)
.where(gl_entry.company == company)
.where(gl_entry.is_cancelled == 0)
.where(gl_entry.against_voucher_type == "Sales Invoice")
.where(ple.party_type == "Customer")
.where(ple.party == customer)
.where(ple.company == company)
.where(ple.delinked == 0)
.where(ple.against_voucher_type == "Sales Invoice")
.groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total)
.having(outstanding > 0)
).run(as_dict=True)

View File

@@ -438,6 +438,37 @@ class TestCustomer(ERPNextTestSuite):
pe.submit()
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
def test_get_customer_overdue_amount_ignores_advance_reconciled_after_submit(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
# advance received before the invoice exists, so it carries no reference row
pe = create_payment_entry(
company="_Test Company",
party_type="Customer",
party="_Test Customer",
payment_type="Receive",
paid_from="Debtors - _TC",
paid_to="Cash - _TC",
paid_amount=800,
)
pe.posting_date = add_days(nowdate(), -60)
pe.submit()
si = create_sales_invoice(qty=1, rate=800, posting_date=add_days(nowdate(), -30))
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 800)
reconcile_payment_against_invoice(pe, si)
# reconciliation settles the invoice without re-tagging the payment's GL entries, so an
# overdue amount read off the GL would still count the full 800 here
si.reload()
self.assertEqual(si.outstanding_amount, 0)
self.assertEqual(si.status, "Paid")
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
def test_overdue_billing_threshold_on_submit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -632,6 +663,27 @@ def set_credit_limit(customer, company, credit_limit):
customer.credit_limits[-1].db_insert()
def reconcile_payment_against_invoice(payment_entry, sales_invoice):
"""Allocate an unlinked payment against an invoice through the reconciliation tool."""
pr = frappe.get_doc(
doctype="Payment Reconciliation",
company=sales_invoice.company,
party_type="Customer",
party=sales_invoice.customer,
receivable_payable_account=sales_invoice.debit_to,
)
pr.get_unreconciled_entries()
pr.allocate_entries(
frappe._dict(
{
"invoices": [d.as_dict() for d in pr.invoices if d.invoice_number == sales_invoice.name],
"payments": [d.as_dict() for d in pr.payments if d.reference_name == payment_entry.name],
}
)
)
pr.reconcile()
def set_overdue_billing_threshold(customer, company, threshold):
customer = frappe.get_doc("Customer", customer)
for d in customer.credit_limits:

View File

@@ -421,6 +421,13 @@ def make_delivery_note(
return target_doc
def get_qty_net_of_returns(so_item) -> float:
"""Return the ordered quantity billable after returns and re-deliveries."""
qty = flt(so_item.qty)
return min(qty, max(qty - flt(so_item.returned_qty), flt(so_item.delivered_qty)))
@frappe.whitelist()
def make_sales_invoice(
source_name: str,
@@ -434,10 +441,40 @@ def make_sales_invoice(
# 0 qty is accepted, as the qty is uncertain for some items
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 = {}
def is_unit_price_row(source):
return has_unit_price_items and source.qty == 0
def get_billed_qty_by_item():
nonlocal billed_qty_by_item
if billed_qty_by_item is None:
invoice_item = frappe.qb.DocType("Sales Invoice Item")
sales_order_item = frappe.qb.DocType("Sales Order Item")
rows = (
frappe.qb.from_(invoice_item)
.inner_join(sales_order_item)
.on(invoice_item.so_detail == sales_order_item.name)
.select(invoice_item.so_detail, Sum(invoice_item.qty).as_("qty"))
.where((invoice_item.docstatus == 1) & (sales_order_item.parent == source_name))
.groupby(invoice_item.so_detail)
).run(as_dict=True)
billed_qty_by_item = {row.so_detail: flt(row.qty) for row in rows}
return billed_qty_by_item
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)
pending_qty_by_item[source.name] = max(flt(billable_qty, source.precision("qty")), 0)
return pending_qty_by_item[source.name]
def postprocess(source, target):
set_missing_values(source, target)
# Get the advance paid Journal Entries in Sales Invoice Advance
@@ -476,15 +513,6 @@ def make_sales_invoice(
target.debit_to = get_party_account("Customer", source.customer, source.company)
def update_item(source, target, source_parent):
def get_billed_qty(so_item_name):
table = frappe.qb.DocType("Sales Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.so_detail == so_item_name))
)
return query.run(pluck="qty")[0] or 0
if source_parent.has_unit_price_items:
# 0 Amount rows (as seen in Unit Price Items) should be mapped as it is
pending_amount = flt(source.amount) - flt(source.billed_amt)
@@ -493,11 +521,7 @@ def make_sales_invoice(
target.amount = flt(source.amount) - flt(source.billed_amt)
target.base_amount = target.amount * flt(source_parent.conversion_rate)
target.qty = (
source.qty - get_billed_qty(source.name)
if (source.qty and source.billed_amt)
else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty)
)
target.qty = source.qty if is_unit_price_row(source) else get_pending_qty(source)
if source_parent.project:
target.cost_center = frappe.db.get_value("Project", source_parent.project, "cost_center")
@@ -575,13 +599,17 @@ def make_sales_invoice(
"parent": "sales_order",
},
"postprocess": update_item,
"condition": lambda doc: (
"condition": lambda doc: not args.get("skip_item_mapping")
and select_item(doc)
and (
True
if is_unit_price_row(doc)
else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)))
)
and select_item(doc)
and not args.get("skip_item_mapping"),
else (
doc.qty
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))
and get_pending_qty(doc) > 0
)
),
},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",

View File

@@ -238,6 +238,9 @@ class SalesOrder(SellingController):
validate_coupon_code(self.coupon_code)
if not self.get("is_subcontracted"):
SalesOrderStockReservation(self).enable_auto_reserve_stock()
make_packing_list(self)
self.validate_with_previous_doc()
@@ -247,8 +250,6 @@ class SalesOrder(SellingController):
StatusService(self).set_default_statuses()
self.reset_default_field_value("set_warehouse", "items", "warehouse")
if not self.get("is_subcontracted"):
SalesOrderStockReservation(self).enable_auto_reserve_stock()
def set_has_unit_price_items(self):
"""

View File

@@ -288,6 +288,95 @@ class TestSalesOrder(ERPNextTestSuite):
si1 = make_sales_invoice(so.name)
self.assertEqual(len(si1.get("items")), 0)
def test_make_sales_invoice_after_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
so = make_sales_order(qty=10, rate=100)
dn = create_dn_against_so(so.name, 10)
dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict())
dn_return.insert()
dn_return.submit()
self.assertEqual(len(make_sales_invoice(so.name).get("items")), 0)
create_dn_against_so(so.name, 10)
so.load_from_db()
item = so.get("items")[0]
self.assertEqual(item.delivered_qty, 10)
self.assertEqual(item.returned_qty, 10)
si = make_sales_invoice(so.name)
self.assertEqual(si.get("items")[0].qty, 10)
def test_make_sales_invoice_bills_ordered_qty_for_partial_delivery(self):
so = make_sales_order(qty=10, rate=100)
create_dn_against_so(so.name, 4)
si = make_sales_invoice(so.name)
self.assertEqual(si.get("items")[0].qty, 10)
def test_make_sales_invoice_after_partial_billing_return_and_redelivery(self):
from erpnext.stock.doctype.delivery_note.mapper import make_sales_return
so = make_sales_order(qty=10, rate=100)
dn = create_dn_against_so(so.name, 10)
si = make_sales_invoice(so.name)
si.get("items")[0].qty = 4
si.insert()
si.submit()
dn_return = frappe.get_doc(make_sales_return(dn.name).as_dict())
dn_return.insert()
dn_return.submit()
create_dn_against_so(so.name, 5)
so.load_from_db()
item = so.get("items")[0]
self.assertEqual(item.delivered_qty, 5)
self.assertEqual(item.returned_qty, 10)
self.assertEqual(item.billed_amt, 400)
pending_invoice = make_sales_invoice(so.name)
self.assertEqual(pending_invoice.get("items")[0].qty, 1)
pending_invoice.insert()
pending_invoice.submit()
so.load_from_db()
self.assertEqual(so.get("items")[0].billed_amt, 500)
def test_make_sales_invoice_after_partial_billing_multiple_items(self):
so = make_sales_order(
item_list=[
{
"item_code": "_Test Item",
"warehouse": "_Test Warehouse - _TC",
"qty": 10,
"rate": 100,
},
{
"item_code": "_Test FG Item",
"warehouse": "_Test Warehouse - _TC",
"qty": 10,
"rate": 100,
},
]
)
si = make_sales_invoice(so.name)
si.get("items")[0].qty = 4
si.get("items")[1].qty = 6
si.insert()
si.submit()
pending_invoice = make_sales_invoice(so.name)
self.assertEqual(
{item.so_detail: item.qty for item in pending_invoice.get("items")},
{so.get("items")[0].name: 6, so.get("items")[1].name: 4},
)
def test_so_billed_amount_against_return_entry(self):
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return

View File

@@ -84,14 +84,7 @@ class SellingSettings(Document):
]:
frappe.db.set_default(key, self.get(key, ""))
from erpnext.utilities.naming import set_by_naming_series
set_by_naming_series(
"Customer",
"customer_name",
self.get("cust_master_name") == "Naming Series",
hide_name_field=False,
)
self.update_customer_naming_settings()
self.validate_fallback_to_default_price_list()
@@ -101,6 +94,19 @@ class SellingSettings(Document):
if old_doc and old_doc.enable_utm != self.enable_utm:
toggle_utm_analytics_section(not self.enable_utm)
def update_customer_naming_settings(self):
if not self.has_value_changed("cust_master_name"):
return
from erpnext.utilities.naming import set_by_naming_series
set_by_naming_series(
"Customer",
"customer_name",
self.get("cust_master_name") == "Naming Series",
hide_name_field=False,
)
def validate_fallback_to_default_price_list(self):
if (
self.fallback_to_default_price_list
@@ -119,6 +125,9 @@ class SellingSettings(Document):
)
def toggle_hide_tax_id(self):
if not self.has_value_changed("hide_tax_id"):
return
_hide_tax_id = cint(self.hide_tax_id)
# Make property setters to hide tax_id fields
@@ -131,6 +140,9 @@ class SellingSettings(Document):
)
def toggle_editable_rate_for_bundle_items(self):
if not self.has_value_changed("editable_bundle_item_rates"):
return
editable_bundle_item_rates = cint(self.editable_bundle_item_rates)
make_property_setter(
@@ -143,6 +155,9 @@ class SellingSettings(Document):
)
def toggle_discount_accounting_fields(self):
if not self.has_value_changed("enable_discount_accounting"):
return
enable_discount_accounting = cint(self.enable_discount_accounting)
make_property_setter(

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
import frappe
from erpnext.tests.utils import ERPNextTestSuite
@@ -12,3 +14,47 @@ class TestSellingSettings(ERPNextTestSuite):
# if setup was completed correctly
default = frappe.db.get_single_value("Selling Settings", "maintain_same_rate_action")
self.assertEqual("Stop", default)
def test_unrelated_change_does_not_update_customer_metadata(self):
settings = frappe.get_single("Selling Settings")
settings.allow_multiple_items = not settings.allow_multiple_items
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
settings.save()
set_by_naming_series.assert_not_called()
def test_customer_metadata_updates_when_related_settings_change(self):
settings = frappe.get_single("Selling Settings")
settings.cust_master_name = (
"Customer Name" if settings.cust_master_name == "Naming Series" else "Naming Series"
)
with patch("erpnext.utilities.naming.set_by_naming_series") as set_by_naming_series:
settings.save()
set_by_naming_series.assert_called_once()
def test_unrelated_change_does_not_rewrite_toggle_setters(self):
settings = frappe.get_single("Selling Settings")
settings.allow_multiple_items = not settings.allow_multiple_items
with patch(
"erpnext.selling.doctype.selling_settings.selling_settings.make_property_setter"
) as make_property_setter:
settings.save()
make_property_setter.assert_not_called()
def test_toggle_setters_rewritten_when_related_settings_change(self):
settings = frappe.get_single("Selling Settings")
settings.hide_tax_id = not settings.hide_tax_id
settings.editable_bundle_item_rates = not settings.editable_bundle_item_rates
settings.enable_discount_accounting = not settings.enable_discount_accounting
with patch(
"erpnext.selling.doctype.selling_settings.selling_settings.make_property_setter"
) as make_property_setter:
settings.save()
self.assertEqual(make_property_setter.call_count, 11)

View File

@@ -84,6 +84,17 @@ erpnext.PointOfSale.ItemDetails = class {
this.item_row = item;
this.currency = this.events.get_frm().doc.currency;
if (item.has_serial_no == null || item.has_batch_no == null) {
const r = await frappe.db.get_value("Item", item.item_code, [
"has_serial_no",
"has_batch_no",
]);
if (r && r.message) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
}
}
this.current_item = item;
this.render_dom(item);

View File

@@ -9,6 +9,9 @@ from frappe.utils.data import comma_or
from erpnext.selling.report.sales_partner_commission_summary.sales_partner_commission_summary import (
SALES_TRANSACTION_DOCTYPES,
)
from erpnext.selling.report.sales_partner_transaction_summary.test_utils import (
SalesPartnerTransactionSummaryAssertions,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
@@ -63,14 +66,15 @@ class SalesPartnerSummaryReportTestMixin(ERPNextTestSuite):
self.make_transaction_func = make_transaction_funcs[doctype]
make_stock_entry(
item_code="_Test Item 2",
qty=10,
company="_Test Company",
to_warehouse="_Test Warehouse - _TC",
purpose="Material Receipt",
posting_date="2026-01-01",
)
if doctype in {"Delivery Note", "POS Invoice"}:
make_stock_entry(
item_code="_Test Item 2",
qty=10,
company="_Test Company",
to_warehouse="_Test Warehouse - _TC",
purpose="Material Receipt",
posting_date="2026-01-01",
)
if doctype == "POS Invoice":
POSInvoiceTestMixin.setUp(self)
@@ -246,7 +250,9 @@ class SalesPartnerSummaryReportTestMixin(ERPNextTestSuite):
self.returned_doc.submit()
class TestSalesPartnerCommissionSummary(SalesPartnerSummaryReportTestMixin):
class TestSalesPartnerSummaryReports(
SalesPartnerSummaryReportTestMixin, SalesPartnerTransactionSummaryAssertions
):
def setUp(self):
self.filters = {
"company": "_Test Company",
@@ -262,29 +268,33 @@ class TestSalesPartnerCommissionSummary(SalesPartnerSummaryReportTestMixin):
def test_posting_date_column_label(self):
self.assert_posting_date_label()
def test_sales_order_sp_commission_summary(self):
def test_sales_order_sp_summaries(self):
self.filters["doctype"] = "Sales Order"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_commission_summary_report()
self.assert_sales_partner_transaction_summary_report()
def test_sales_invoice_sp_commission_summary(self):
def test_sales_invoice_sp_summaries(self):
self.filters["doctype"] = "Sales Invoice"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_commission_summary_report()
self.assert_sales_partner_transaction_summary_report()
def test_delivery_note_sp_commission_summary(self):
def test_delivery_note_sp_summaries(self):
self.filters["doctype"] = "Delivery Note"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_commission_summary_report()
self.assert_sales_partner_transaction_summary_report()
def test_pos_invoice_sp_commission_summary(self):
def test_pos_invoice_sp_summaries(self):
self.filters["doctype"] = "POS Invoice"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_commission_summary_report()
self.assert_sales_partner_transaction_summary_report()
def assert_sales_partner_commission_summary_report(self):
report_data = run(self.report_name, self.filters)

View File

@@ -1,14 +1,17 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from frappe.desk.query_report import run
from erpnext.selling.report.sales_partner_commission_summary.test_sales_partner_commission_summary import (
SalesPartnerSummaryReportTestMixin,
)
from erpnext.selling.report.sales_partner_transaction_summary.test_utils import (
SalesPartnerTransactionSummaryAssertions,
)
class TestSalesPartnerTransactionSummary(SalesPartnerSummaryReportTestMixin):
class TestSalesPartnerTransactionSummary(
SalesPartnerSummaryReportTestMixin, SalesPartnerTransactionSummaryAssertions
):
def setUp(self):
self.filters = {
"company": "_Test Company",
@@ -25,159 +28,8 @@ class TestSalesPartnerTransactionSummary(SalesPartnerSummaryReportTestMixin):
def test_posting_date_column_label(self):
self.assert_posting_date_label()
def test_sales_order_sp_transaction_summary(self):
self.filters["doctype"] = "Sales Order"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_transaction_summary_report()
def test_sales_invoice_sp_transaction_summary(self):
self.filters["doctype"] = "Sales Invoice"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_transaction_summary_report()
def test_delivery_note_sp_transaction_summary(self):
self.filters["doctype"] = "Delivery Note"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_transaction_summary_report()
def test_pos_invoice_sp_transaction_summary(self):
self.filters["doctype"] = "POS Invoice"
self.create_transactions(self.filters["doctype"])
self.assert_sales_partner_transaction_summary_report()
def assert_sales_partner_transaction_summary_report(self):
report_data = run(self.report_name, self.filters)
self.report_result = report_data.get("result")
self.report_result_without_total_row = self.report_result[:-1]
self.assertIsNotNone(self.report_result_without_total_row)
self.assert_7pc_commission()
self.assert_5pc_commission_with_multiple_items()
self.assert_doc_with_no_sp()
self.assert_doc_with_posting_date_out_of_range()
self.assert_doc_with_revoked_commission()
self.assert_doc_not_submitted()
self.assert_doc_cancelled()
self.assert_commission()
if self.filters["doctype"] != "Sales Order":
self.assert_returned_doc()
def assert_7pc_commission(self):
doc_name = self.seven_pc_doc.name
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
self.assertIsNotNone(row)
self.assertEqual(row["customer"], "_Test Customer")
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["item_group"], "_Test Item Group")
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["commission_rate"], 7)
self.assertEqual(row["commission"], 70)
def assert_5pc_commission_with_multiple_items(self):
doc_name = self.five_pc_doc.name
row1 = next(
(
row
for row in self.report_result_without_total_row
if row.get("name") == doc_name and row.get("item_code") == "_Test Item"
),
None,
)
self.assertIsNotNone(row1)
row2 = next(
(
row
for row in self.report_result_without_total_row
if row.get("name") == doc_name and row.get("item_code") == "_Test Item 2"
),
None,
)
self.assertIsNotNone(row2)
self.assertEqual(row1["amount"], 120)
self.assertEqual(row1["commission_rate"], 5)
self.assertEqual(row1["commission"], 6)
self.assertEqual(row2["amount"], 120)
self.assertEqual(row2["commission_rate"], 5)
self.assertEqual(row2["commission"], 6)
def assert_doc_with_no_sp(self):
doc_name = self.no_sp_doc.name
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
self.assertIsNone(row)
def assert_doc_with_posting_date_out_of_range(self):
doc_name = self.date_out_of_range_doc.name
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
self.assertIsNone(row)
def assert_doc_with_revoked_commission(self):
doc_name = self.revoked_comm_doc.name
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
self.assertIsNotNone(row)
self.assertEqual(row["amount"], 800)
self.assertEqual(row["commission_rate"], 7)
self.assertEqual(row["commission"], 0)
def assert_doc_not_submitted(self):
doc_name = self.doc_not_submitted.name
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
self.assertIsNone(row)
def assert_doc_cancelled(self):
doc_name = self.cancelled_doc.name
row = next((row for row in self.report_result_without_total_row if row.get("name") == doc_name), None)
self.assertIsNone(row)
def assert_commission(self):
total_row = self.report_result[-1]
# Total Amount
self.assertEqual(total_row[-4], 2040)
# Total Commission
self.assertEqual(total_row[-1], 82)
def assert_returned_doc(self):
doc_name = self.to_be_returned_doc.name
returned_doc_name = self.returned_doc.name
outward_row = next(
(row for row in self.report_result_without_total_row if row.get("name") == doc_name), None
)
inward_row = next(
(row for row in self.report_result_without_total_row if row.get("name") == returned_doc_name),
None,
)
self.assertIsNotNone(outward_row)
self.assertIsNotNone(inward_row)
self.assertEqual(outward_row["amount"], 900)
self.assertEqual(outward_row["commission"], 45)
self.assertEqual(inward_row["amount"], -900)
self.assertEqual(inward_row["commission"], -45)

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
from frappe.desk.query_report import run
class SalesPartnerTransactionSummaryAssertions:
def assert_sales_partner_transaction_summary_report(self):
filters = self.filters.copy()
filters["show_return_entries"] = 1
report_data = run("Sales Partner Transaction Summary", filters)
self.transaction_report_result = report_data.get("result")
self.transaction_report_result_without_total_row = self.transaction_report_result[:-1]
self.assertIsNotNone(self.transaction_report_result_without_total_row)
self.assert_transaction_7pc_commission()
self.assert_transaction_5pc_commission_with_multiple_items()
self.assert_transaction_doc_with_no_sp()
self.assert_transaction_doc_with_posting_date_out_of_range()
self.assert_transaction_doc_with_revoked_commission()
self.assert_transaction_doc_not_submitted()
self.assert_transaction_doc_cancelled()
self.assert_transaction_commission()
if self.filters["doctype"] != "Sales Order":
self.assert_transaction_returned_doc()
def assert_transaction_7pc_commission(self):
row = self._get_transaction_report_row(self.seven_pc_doc.name)
self.assertIsNotNone(row)
self.assertEqual(row["customer"], "_Test Customer")
self.assertEqual(row["item_code"], "_Test Item")
self.assertEqual(row["item_group"], "_Test Item Group")
self.assertEqual(row["amount"], 1000)
self.assertEqual(row["commission_rate"], 7)
self.assertEqual(row["commission"], 70)
def assert_transaction_5pc_commission_with_multiple_items(self):
row1 = self._get_transaction_report_row(self.five_pc_doc.name, "_Test Item")
self.assertIsNotNone(row1)
row2 = self._get_transaction_report_row(self.five_pc_doc.name, "_Test Item 2")
self.assertIsNotNone(row2)
self.assertEqual(row1["amount"], 120)
self.assertEqual(row1["commission_rate"], 5)
self.assertEqual(row1["commission"], 6)
self.assertEqual(row2["amount"], 120)
self.assertEqual(row2["commission_rate"], 5)
self.assertEqual(row2["commission"], 6)
def assert_transaction_doc_with_no_sp(self):
row = self._get_transaction_report_row(self.no_sp_doc.name)
self.assertIsNone(row)
def assert_transaction_doc_with_posting_date_out_of_range(self):
row = self._get_transaction_report_row(self.date_out_of_range_doc.name)
self.assertIsNone(row)
def assert_transaction_doc_with_revoked_commission(self):
row = self._get_transaction_report_row(self.revoked_comm_doc.name)
self.assertIsNotNone(row)
self.assertEqual(row["amount"], 800)
self.assertEqual(row["commission_rate"], 7)
self.assertEqual(row["commission"], 0)
def assert_transaction_doc_not_submitted(self):
row = self._get_transaction_report_row(self.doc_not_submitted.name)
self.assertIsNone(row)
def assert_transaction_doc_cancelled(self):
row = self._get_transaction_report_row(self.cancelled_doc.name)
self.assertIsNone(row)
def assert_transaction_commission(self):
total_row = self.transaction_report_result[-1]
self.assertEqual(total_row[-4], 2040)
self.assertEqual(total_row[-1], 82)
def assert_transaction_returned_doc(self):
outward_row = self._get_transaction_report_row(self.to_be_returned_doc.name)
inward_row = self._get_transaction_report_row(self.returned_doc.name)
self.assertIsNotNone(outward_row)
self.assertIsNotNone(inward_row)
self.assertEqual(outward_row["amount"], 900)
self.assertEqual(outward_row["commission"], 45)
self.assertEqual(inward_row["amount"], -900)
self.assertEqual(inward_row["commission"], -45)
def _get_transaction_report_row(self, doc_name, item_code=None):
return next(
(
row
for row in self.transaction_report_result_without_total_row
if row.get("name") == doc_name and (not item_code or row.get("item_code") == item_code)
),
None,
)

View File

@@ -303,6 +303,7 @@ erpnext.company.setup_queries = function (frm) {
["round_off_account", { root_type: ["in", ["Expense", "Income"]] }],
["round_off_for_opening", { root_type: "Liability", account_type: "Round Off for Opening" }],
["write_off_account", { root_type: "Expense" }],
["bank_charges_account", { root_type: "Expense" }],
["default_deferred_expense_account", {}],
["default_deferred_revenue_account", {}],
["default_discount_account", {}],

View File

@@ -54,6 +54,7 @@
"default_receivable_account",
"default_payable_account",
"write_off_account",
"bank_charges_account",
"unrealized_profit_loss_account",
"column_break0",
"allow_account_creation_against_child_company",
@@ -390,6 +391,15 @@
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "bank_charges_account",
"fieldtype": "Link",
"ignore_user_permissions": 1,
"label": "Bank Charges Account",
"no_copy": 1,
"options": "Account"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "exchange_gain_loss_account",

View File

@@ -49,6 +49,7 @@ class Company(NestedSet):
asset_received_but_not_billed: DF.Link | None
auto_err_frequency: DF.Literal["Daily", "Weekly", "Monthly"]
auto_exchange_rate_revaluation: DF.Check
bank_charges_account: DF.Link | None
book_advance_payments_in_separate_party_account: DF.Check
capital_work_in_progress_account: DF.Link | None
chart_of_accounts: DF.Literal[None]
@@ -368,6 +369,7 @@ class Company(NestedSet):
["Stock Delivered But Not Billed Account", "stock_delivered_but_not_billed"],
["Stock Adjustment Account", "stock_adjustment_account"],
["Write Off Account", "write_off_account"],
["Bank Charges Account", "bank_charges_account"],
["Default Payment Discount Account", "default_discount_account"],
["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"],
["Exchange Gain / Loss Account", "exchange_gain_loss_account"],
@@ -520,6 +522,7 @@ class Company(NestedSet):
)
warehouse.flags.ignore_permissions = True
warehouse.flags.ignore_mandatory = True
warehouse.flags.ignore_inventory_account_validation = True
warehouse.insert()
if wh_detail["is_group"]:
@@ -789,6 +792,13 @@ class Company(NestedSet):
self.db_set("write_off_account", write_off_acct)
if not self.bank_charges_account:
bank_charges_acct = frappe.db.get_value(
"Account", {"account_name": _("Bank Charges"), "company": self.name, "is_group": 0}
)
self.db_set("bank_charges_account", bank_charges_acct)
if not self.exchange_gain_loss_account:
exchange_gain_loss_acct = frappe.db.get_value(
"Account", {"account_name": _("Exchange Gain/Loss"), "company": self.name, "is_group": 0}

View File

@@ -23,15 +23,20 @@ frappe.ui.form.on("Driver", {
},
transporter: function (frm, cdt, cdn) {
// this assumes that supplier's address has same title as supplier's name
if (!frm.doc.transporter) return;
frappe.db
.get_doc("Address", null, { address_title: frm.doc.transporter })
.then((r) => {
frappe.model.set_value(cdt, cdn, "address", r.name);
})
.catch((err) => {
console.log(err);
});
const transporter = frm.doc.transporter;
frappe.call({
method: "frappe.contacts.doctype.address.address.get_default_address",
args: {
doctype: "Supplier",
name: transporter,
},
callback: function (r) {
if (frm.doc.transporter === transporter) {
frappe.model.set_value(cdt, cdn, "address", r.message);
}
},
});
},
});

View File

@@ -53,6 +53,7 @@ def boot_session(bootinfo):
"enable_perpetual_inventory",
"country",
"exchange_gain_loss_account",
"bank_charges_account",
],
limit_page_length=0, # intentionally unbounded: all companies are needed for boot
)

View File

@@ -37,7 +37,7 @@ def get_warehouse_account_map(company=None):
order_by="lft, rgt",
):
if not d.account:
d.account = get_warehouse_account(d, warehouse_account)
d.account = get_warehouse_account(d, warehouse_account, raise_error=False)
if d.account:
d.account_currency = frappe.db.get_value("Account", d.account, "account_currency", cache=True)
@@ -47,10 +47,13 @@ def get_warehouse_account_map(company=None):
else:
frappe.flags.warehouse_account_map = warehouse_account
return frappe.flags.warehouse_account_map.get(company) or frappe.flags.warehouse_account_map
if company:
return frappe.flags.warehouse_account_map.get(company, frappe._dict())
return frappe.flags.warehouse_account_map
def get_warehouse_account(warehouse, warehouse_account=None):
def get_warehouse_account(warehouse, warehouse_account=None, *, raise_error=True):
account = warehouse.account
if not account and warehouse.parent_warehouse:
if warehouse_account:
@@ -87,7 +90,7 @@ def get_warehouse_account(warehouse, warehouse_account=None):
if len(inventory_accounts) == 1:
account = inventory_accounts[0]
if not account and warehouse.company and not warehouse.is_group:
if raise_error and not account and warehouse.company and not warehouse.is_group:
frappe.throw(
_("Please set Account in Warehouse {0} or Default Inventory Account in Company {1}").format(
warehouse.name, warehouse.company

View File

@@ -296,7 +296,18 @@ def get_batch_qty(
def get_batches_by_oldest(item_code: str, warehouse: str):
"""Returns the oldest batch and qty for the given item_code and warehouse"""
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
if not batches:
return []
expiry_dates = dict(
frappe.get_all(
"Batch",
filters={"name": ["in", {batch.batch_no for batch in batches}]},
fields=["name", "expiry_date"],
as_list=True,
)
)
batches_dates = [[batch, expiry_dates.get(batch.batch_no)] for batch in batches]
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
return batches_dates

View File

@@ -258,7 +258,13 @@ def get_bin_details(bin_name):
)
def update_qty(bin_name, args):
def update_qty_from_sle(bin_name, args):
"""Refresh the Bin's quantity fields after an SLE has been processed.
Distinct from ``stock_balance.update_bin_qty``, which writes caller-supplied
absolute values; this recomputes every quantity from the ledger and open
documents.
"""
from erpnext.controllers.stock_controller import future_sle_exists
bin_details = get_bin_details(bin_name)

View File

@@ -10,6 +10,7 @@ from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.delivery_note.services.billing_status import BillingStatusService
from erpnext.stock.doctype.delivery_note.services.packing import PackingService
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.utils import get_bin_qty_map
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@@ -258,14 +259,6 @@ class DeliveryNote(SellingController):
super().before_print(settings)
def set_actual_qty(self):
for d in self.get("items"):
if d.item_code and d.warehouse:
actual_qty = frappe.db.get_value(
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
)
d.actual_qty = flt(actual_qty) or 0
def so_required(self):
"""check in manage account if sales order required or not"""
if frappe.get_single_value("Selling Settings", "so_required") == "Yes":
@@ -404,28 +397,14 @@ class DeliveryNote(SellingController):
if not (self.get("_action") and self._action != "update_after_submit"):
return
warehouse_item_codes = {}
for d in self.get("items") + self.get("packed_items"):
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
if not warehouse_item_codes:
return
bin_map = {}
for warehouse, item_codes in warehouse_item_codes.items():
for b in frappe.get_all(
"Bin",
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
fields=["item_code", "actual_qty", "projected_qty"],
):
bin_map[(b.item_code, warehouse)] = b
bin_qty_map = get_bin_qty_map(self.get("items") + self.get("packed_items"))
for d in self.get("items"):
bin_data = bin_map.get((d.item_code, d.warehouse))
bin_data = bin_qty_map.get((d.item_code, d.warehouse))
d.actual_qty = bin_data.actual_qty if bin_data else None
for d in self.get("packed_items"):
bin_data = bin_map.get((d.item_code, d.warehouse))
bin_data = bin_qty_map.get((d.item_code, d.warehouse))
if bin_data:
d.actual_qty = flt(bin_data.actual_qty)
d.projected_qty = flt(bin_data.projected_qty)

View File

@@ -56,16 +56,7 @@ class PackedItem(Document):
warehouse: DF.Link | None
# end: auto-generated types
def set_actual_and_projected_qty(self):
"Set actual and projected qty based on warehouse and item_code"
_bin = frappe.db.get_value(
"Bin",
{"item_code": self.item_code, "warehouse": self.warehouse},
["actual_qty", "projected_qty"],
as_dict=True,
)
self.actual_qty = _bin.actual_qty if _bin else 0
self.projected_qty = _bin.projected_qty if _bin else 0
pass
def make_packing_list(doc):

View File

@@ -61,16 +61,26 @@ def update_billed_amount_based_on_po(po_details: list, update_modified: bool = T
billed_amt_against_pr = flt(flt(billed_amt_against_po) * flt(pr_item.qty)) / flt(
billed_qty_against_po
)
# Deduct the amount and qty consumed by this PR so that the next PR
# against the same PO Item does not get billed for the same amount again.
po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = (
billed_amt_against_po - billed_amt_against_pr
)
po_billed_amt_details[pr_item.purchase_order_item]["billed_qty"] = (
billed_qty_against_po - pr_item.qty
)
else:
pending_to_bill = flt(pr_item.amount) - billed_amt_against_pr
if pending_to_bill <= billed_amt_against_po:
billed_amt_against_pr += pending_to_bill
billed_amt_against_po -= pending_to_bill
else:
billed_amt_against_pr += billed_amt_against_po
billed_amt_against_po = 0
consumed_amt_against_po = min(pending_to_bill, billed_amt_against_po)
billed_amt_against_pr += consumed_amt_against_po
po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = billed_amt_against_po
po_billed_amt_details[pr_item.purchase_order_item]["billed_amt"] = (
billed_amt_against_po - consumed_amt_against_po
)
po_billed_amt_details[pr_item.purchase_order_item]["billed_qty"] = billed_qty_against_po * (
1 - consumed_amt_against_po / billed_amt_against_po
)
if pr_item.billed_amt != billed_amt_against_pr:
# update existing doc if possible

Some files were not shown because too many files have changed in this diff Show More