Merge pull request #55316 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
Diptanil Saha
2026-05-27 05:51:33 +05:30
committed by GitHub
50 changed files with 2437 additions and 662 deletions

View File

@@ -1,126 +0,0 @@
{
"custom_fields": [
{
"_assign": null,
"_comments": null,
"_liked_by": null,
"_user_tags": null,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"creation": "2018-12-28 22:29:21.828090",
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"dt": "Address",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "tax_category",
"fieldtype": "Link",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"idx": 15,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "fax",
"label": "Tax Category",
"length": 0,
"mandatory_depends_on": null,
"modified": "2018-12-28 22:29:21.828090",
"modified_by": "Administrator",
"name": "Address-tax_category",
"no_copy": 0,
"options": "Tax Category",
"owner": "Administrator",
"parent": null,
"parentfield": null,
"parenttype": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"translatable": 0,
"unique": 0,
"width": null
},
{
"_assign": null,
"_comments": null,
"_liked_by": null,
"_user_tags": null,
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"creation": "2020-10-14 17:41:40.878179",
"default": "0",
"depends_on": null,
"description": null,
"docstatus": 0,
"dt": "Address",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "is_your_company_address",
"fieldtype": "Check",
"hidden": 0,
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
"idx": 20,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
"insert_after": "linked_with",
"label": "Is Your Company Address",
"length": 0,
"mandatory_depends_on": null,
"modified": "2020-10-14 17:41:40.878179",
"modified_by": "Administrator",
"name": "Address-is_your_company_address",
"no_copy": 0,
"options": null,
"owner": "Administrator",
"parent": null,
"parentfield": null,
"parenttype": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"read_only_depends_on": null,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"translatable": 0,
"unique": 0,
"width": null
}
],
"custom_perms": [],
"doctype": "Address",
"property_setters": [],
"sync_on_migrate": 1
}

View File

@@ -822,11 +822,14 @@ frappe.ui.form.on("Payment Entry", {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (!frm.doc.received_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) {
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
if (company_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.base_paid_amount);
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
} else if (frm.doc.target_exchange_rate) {
frm.set_value(
"received_amount",
flt(frm.doc.base_paid_amount) / flt(frm.doc.target_exchange_rate)
);
}
}
frm.trigger("reset_received_amount");
@@ -843,15 +846,14 @@ frappe.ui.form.on("Payment Entry", {
);
if (!frm.doc.paid_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount);
if (frm.doc.target_exchange_rate) {
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
}
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
} else if (company_currency == frm.doc.paid_from_account_currency) {
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
if (company_currency == frm.doc.paid_from_account_currency) {
frm.set_value("paid_amount", frm.doc.base_received_amount);
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
} else if (frm.doc.source_exchange_rate) {
frm.set_value(
"paid_amount",
flt(frm.doc.base_received_amount) / flt(frm.doc.source_exchange_rate)
);
}
}

View File

@@ -3,8 +3,9 @@
import frappe
from frappe import qb
from frappe.query_builder.functions import Count, Sum
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import nowdate
from frappe.utils import add_days, nowdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
@@ -94,6 +95,7 @@ class TestPaymentLedgerEntry(FrappeTestCase):
posting_date = nowdate()
sinv = create_sales_invoice(
posting_date=posting_date,
qty=qty,
rate=rate,
company=self.company,
@@ -535,3 +537,82 @@ class TestPaymentLedgerEntry(FrappeTestCase):
# with references removed, deletion should be possible
so.delete()
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
@change_settings(
"Accounts Settings",
{"enable_immutable_ledger": 1},
)
def test_reverse_entries_on_cancel_for_immutable_ledger(self):
invoice_posting_date = add_days(nowdate(), -5)
gle = qb.DocType("GL Entry")
ple = qb.DocType("Payment Ledger Entry")
si = self.create_sales_invoice(qty=1, rate=100, posting_date=invoice_posting_date)
gles_before = (
qb.from_(gle)
.select(
Count(gle.name),
)
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
.run()[0][0]
)
ples_before = (
qb.from_(ple)
.select(
Count(ple.name),
)
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
.run()[0][0]
)
si.cancel()
gles_after = (
qb.from_(gle)
.select(Count(gle.account))
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
.run()[0][0]
)
self.assertEqual(gles_after, gles_before * 2)
ples_after = (
qb.from_(ple)
.select(
Count(ple.name),
)
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
.run()[0][0]
)
self.assertEqual(ples_after, ples_before * 2)
# assert debit/credit are reversed
gl_entries = (
qb.from_(gle)
.select(gle.account, Sum(gle.debit).as_("total_debit"), Sum(gle.credit).as_("total_credit"))
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
.groupby(gle.account)
.run(as_dict=True)
)
for gl in gl_entries:
with self.subTest(gl=gl):
self.assertEqual(gl.total_debit, gl.total_credit)
# assert amounts are reversed
pl_entries = (
qb.from_(ple)
.select(ple.account, Sum(ple.amount).as_("total_amount"))
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked == 0))
.groupby(ple.account)
.run(as_dict=True)
)
for pl in pl_entries:
with self.subTest(pl=pl):
self.assertEqual(pl.total_amount, 0)
self.assertFalse(
frappe.db.exists(
"Payment Ledger Entry",
{"voucher_type": si.doctype, "voucher_no": si.name, "delinked": 1},
)
)

View File

@@ -5,11 +5,13 @@
import unittest
import frappe
from frappe.tests.utils import change_settings
from frappe.utils import today
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.general_ledger import make_reverse_gl_entries
from erpnext.accounts.utils import get_fiscal_year
@@ -351,6 +353,51 @@ class TestPeriodClosingVoucher(unittest.TestCase):
return pcv
@change_settings(
"Accounts Settings",
{"enable_immutable_ledger": 1},
)
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'")
frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'")
company = create_company()
cost_center = create_cost_center("Test Cost Center 1")
jv = make_journal_entry(
posting_date="2021-03-15",
amount=400,
account1="Cash - TPC",
account2="Sales - TPC",
cost_center=cost_center,
company=company,
save=False,
)
jv.company = company
jv.save()
jv.submit()
self.make_period_closing_voucher(posting_date="2021-03-31")
# Passed posting_date is after PCV end date, so cancellation should not fail.
make_reverse_gl_entries(
voucher_type="Journal Entry",
voucher_no=jv.name,
posting_date="2022-01-01",
)
totals_after_cancel = frappe.db.sql(
"""
select sum(debit) as total_debit, sum(credit) as total_credit
from `tabGL Entry`
where voucher_type=%s and voucher_no=%s and is_cancelled=0
""",
("Journal Entry", jv.name),
as_dict=True,
)[0]
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
def create_company():
company = frappe.get_doc(

View File

@@ -134,9 +134,10 @@ def pause_pcv_processing(docname: str):
ppcv = qb.DocType("Process Period Closing Voucher")
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
# If a date is stuck in 'Running' state, this will allow it to procced.
if queued_dates := frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": docname, "status": "Queued"},
filters={"parent": docname, "status": ["in", ["Queued", "Running"]]},
pluck="name",
):
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
@@ -170,6 +171,9 @@ def resume_pcv_processing(docname: str):
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
start_pcv_processing(docname)
else:
# If a parent doc is stuck in 'Running' state, will allow it to proceed.
schedule_next_date(docname)
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
@@ -285,7 +289,21 @@ def schedule_next_date(docname: str):
)
# Ensure both normal and opening balances are processed for all dates
if total_no_of_dates == completed:
summarize_and_post_ledger_entries(docname)
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
is_job_running,
)
job_name = f"summarize_{docname}"
if not is_job_running(job_name):
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
queue="long",
timeout="3600",
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
docname=docname,
)
def make_dict_json_compliant(dimension_wise_balance) -> dict:

View File

@@ -8,10 +8,13 @@ import frappe
from frappe import _
from frappe.contacts.doctype.address.address import get_default_address
from frappe.model.document import Document
from frappe.query_builder import DocType
from frappe.query_builder.functions import IfNull
from frappe.utils import cstr
from frappe.utils.nestedset import get_root_of
from erpnext.setup.doctype.customer_group.customer_group import get_parent_customer_groups
from erpnext.setup.doctype.supplier_group.supplier_group import get_parent_supplier_groups
class IncorrectCustomerGroup(frappe.ValidationError):
@@ -174,38 +177,44 @@ def get_party_details(party, party_type, args=None):
def get_tax_template(posting_date, args):
"""Get matching tax rule"""
args = frappe._dict(args)
conditions = []
TaxRule = DocType("Tax Rule")
query = frappe.qb.from_(TaxRule).select("*")
if posting_date:
conditions.append(
f"""(from_date is null or from_date <= '{posting_date}')
and (to_date is null or to_date >= '{posting_date}')"""
query = query.where(
(TaxRule.from_date.isnull() | (TaxRule.from_date <= posting_date))
& (TaxRule.to_date.isnull() | (TaxRule.to_date >= posting_date))
)
else:
conditions.append("(from_date is null) and (to_date is null)")
query = query.where(TaxRule.from_date.isnull() & TaxRule.to_date.isnull())
conditions.append(
"ifnull(tax_category, '') = {}".format(frappe.db.escape(cstr(args.get("tax_category")), False))
)
if "tax_category" in args.keys():
del args["tax_category"]
def get_group_ancestors(doctype, get_parents, value):
if not value:
value = get_root_of(doctype)
return [""] + [d.name for d in get_parents(value)]
group_fields = {
"customer_group": ("Customer Group", get_parent_customer_groups),
"supplier_group": ("Supplier Group", get_parent_supplier_groups),
}
args.setdefault("tax_category", "")
for key, value in args.items():
if key == "use_for_shopping_cart":
conditions.append(f"use_for_shopping_cart = {1 if value else 0}")
elif key == "customer_group":
if not value:
value = get_root_of("Customer Group")
customer_group_condition = get_customer_group_condition(value)
conditions.append(f"ifnull({key}, '') in ('', {customer_group_condition})")
query = query.where(TaxRule.use_for_shopping_cart == value)
elif key == "tax_category":
query = query.where(IfNull(TaxRule.tax_category, "") == (value or ""))
elif key in group_fields:
doctype, get_parents = group_fields[key]
query = query.where(
IfNull(TaxRule[key], "").isin(get_group_ancestors(doctype, get_parents, value))
)
else:
conditions.append(f"ifnull({key}, '') in ('', {frappe.db.escape(cstr(value))})")
query = query.where(IfNull(TaxRule[key], "").isin(["", value or ""]))
tax_rule = frappe.db.sql(
"""select * from `tabTax Rule`
where {}""".format(" and ".join(conditions)),
as_dict=True,
)
tax_rule = query.run(as_dict=True)
if not tax_rule:
return None
@@ -234,11 +243,3 @@ def get_tax_template(posting_date, args):
return None
return tax_template
def get_customer_group_condition(customer_group):
condition = ""
customer_groups = ["%s" % (frappe.db.escape(d.name)) for d in get_parent_customer_groups(customer_group)]
if customer_groups:
condition = ",".join(["%s"] * len(customer_groups)) % (tuple(customer_groups))
return condition

View File

@@ -72,6 +72,117 @@ class TestTaxRule(unittest.TestCase):
"_Test Sales Taxes and Charges Template - _TC",
)
def test_for_parent_supplier_group(self):
purchase_template = "_Test Purchase Taxes and Charges Template - _TC"
if not frappe.db.exists("Purchase Taxes and Charges Template", purchase_template):
frappe.get_doc(
{
"doctype": "Purchase Taxes and Charges Template",
"title": "_Test Purchase Taxes and Charges Template",
"company": "_Test Company",
"taxes": [
{
"account_head": "_Test Account VAT - _TC",
"charge_type": "On Net Total",
"description": "VAT",
"doctype": "Purchase Taxes and Charges",
"cost_center": "Main - _TC",
"rate": 6,
}
],
}
).insert()
make_tax_rule(
supplier_group="All Supplier Groups",
tax_type="Purchase",
purchase_tax_template=purchase_template,
priority=1,
use_for_shopping_cart=0,
from_date="2015-01-01",
save=1,
)
# "_Test Supplier Group" has "All Supplier Groups" as its parent — should match hierarchically
self.assertEqual(
get_tax_template(
"2015-01-01",
{
"supplier_group": "_Test Supplier Group",
"tax_type": "Purchase",
"use_for_shopping_cart": 0,
},
),
purchase_template,
)
def test_use_for_shopping_cart_filter(self):
city = "Test Cart City"
# higher priority ensures this rule wins when use_for_shopping_cart is not filtered
make_tax_rule(
customer="_Test Customer",
billing_city=city,
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
use_for_shopping_cart=0,
priority=2,
save=1,
)
make_tax_rule(
customer="_Test Customer",
billing_city=city,
sales_tax_template="_Test Sales Taxes and Charges Template 1 - _TC",
use_for_shopping_cart=1,
priority=1,
save=1,
)
# Cart request (use_for_shopping_cart=1) filters to cart rules only
self.assertEqual(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
),
"_Test Sales Taxes and Charges Template 1 - _TC",
)
# Non-cart request omits use_for_shopping_cart — no filter is applied, both rules
# are candidates; non-cart rule wins by higher priority
self.assertEqual(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city},
),
"_Test Sales Taxes and Charges Template - _TC",
)
def test_use_for_shopping_cart_default(self):
city = "Test Default Cart City"
# use_for_shopping_cart not set — Check field defaults to 0
make_tax_rule(
customer="_Test Customer",
billing_city=city,
sales_tax_template="_Test Sales Taxes and Charges Template - _TC",
use_for_shopping_cart=0, # Default is set to 1.
save=1,
)
# Non-cart request (no use_for_shopping_cart in args) matches the rule
self.assertEqual(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city},
),
"_Test Sales Taxes and Charges Template - _TC",
)
# Cart request (use_for_shopping_cart=1) does not match — rule has default 0
self.assertIsNone(
get_tax_template(
"2015-01-01",
{"customer": "_Test Customer", "billing_city": city, "use_for_shopping_cart": 1},
)
)
def test_conflict_with_overlapping_dates(self):
tax_rule1 = make_tax_rule(
customer="_Test Customer",

View File

@@ -700,7 +700,12 @@ def make_reverse_gl_entries(
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"])
# For reverse entries, use the posting_date parameter if provided and valid
# Otherwise fall back to original posting_date
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
if partial_cancel:
# Partial cancel is only used by `Advance` in separate account feature.
# Only cancel GL entries for unlinked reference using `voucher_detail_no`

View File

@@ -743,7 +743,7 @@ def set_taxes(
args.update({"tax_type": "Purchase"})
if use_for_shopping_cart:
args.update({"use_for_shopping_cart": use_for_shopping_cart})
args.update({"use_for_shopping_cart": cint(use_for_shopping_cart)})
return get_tax_template(posting_date, args)

View File

@@ -406,7 +406,13 @@ def get_data_with_opening_closing(filters, account_details, accounting_dimension
# Opening for filtered account
data.append(totals.opening)
if filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
if not filters.get("categorize_by"):
all_entries = []
for acc_dict in gle_map.values():
all_entries.extend(acc_dict.entries)
data += all_entries
elif filters.get("categorize_by") != "Categorize by Voucher (Consolidated)":
for _acc, acc_dict in gle_map.items():
# acc
if acc_dict.entries:

View File

@@ -1934,8 +1934,9 @@ def create_payment_ledger_entry(
ple = frappe.get_doc(entry)
if cancel:
delink_original_entry(ple, partial_cancel=partial_cancel)
if is_immutable_ledger_enabled():
if not is_immutable_ledger_enabled():
delink_original_entry(ple, partial_cancel=partial_cancel)
else:
ple.delinked = 0
ple.posting_date = frappe.form_dict.get("posting_date") or getdate()
@@ -2027,6 +2028,7 @@ def delink_original_entry(pl_entry, partial_cancel=False):
qb.update(ple)
.set(ple.modified, now())
.set(ple.modified_by, frappe.session.user)
.set(ple.delinked, True)
.where(
(ple.company == pl_entry.company)
& (ple.account_type == pl_entry.account_type)
@@ -2043,9 +2045,6 @@ def delink_original_entry(pl_entry, partial_cancel=False):
if partial_cancel:
query = query.where(ple.voucher_detail_no == pl_entry.voucher_detail_no)
if not is_immutable_ledger_enabled():
query = query.set(ple.delinked, True)
query.run()

View File

@@ -69,6 +69,7 @@ from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
from erpnext.stock.get_item_details import (
_get_item_tax_template,
_get_item_tax_template_from_item_group,
get_bin_details,
get_conversion_factor,
get_item_details,
get_item_tax_map,
@@ -3704,6 +3705,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
child_item.warehouse = get_item_warehouse(item, p_doc, overwrite_warehouse=True)
conversion_factor = flt(get_conversion_factor(item.item_code, child_item.uom).get("conversion_factor"))
child_item.conversion_factor = flt(trans_item.get("conversion_factor")) or conversion_factor
child_item.update(get_bin_details(child_item.item_code, child_item.warehouse, p_doc.get("company")))
if child_doctype in ["Purchase Order Item", "Supplier Quotation Item"]:
# Initialized value will update in parent validation

View File

@@ -209,7 +209,9 @@ def create_variant(item, args, use_template_image=False):
variant_attributes = []
for d in template.attributes:
variant_attributes.append({"attribute": d.attribute, "attribute_value": args.get(d.attribute)})
attribute_value = args.get(_(d.attribute)) or args.get(d.attribute)
if attribute_value:
variant_attributes.append({"attribute": d.attribute, "attribute_value": attribute_value})
variant.set("attributes", variant_attributes)
copy_attributes_to_variant(template, variant)
@@ -228,6 +230,12 @@ def enqueue_multiple_variant_creation(item, args, use_template_image=False):
# There can be innumerable attribute combinations, enqueue
if isinstance(args, str):
variants = json.loads(args)
else:
variants = args
variants = {key: values for key, values in variants.items() if values}
if not variants:
frappe.throw(_("Please select at least one attribute value"))
total_variants = 1
for key in variants:
total_variants *= len(variants[key])
@@ -251,6 +259,7 @@ def create_multiple_variants(item, args, use_template_image=False):
count = 0
if isinstance(args, str):
args = json.loads(args)
args = {key: values for key, values in args.items() if values}
template_item = frappe.get_doc("Item", item)
args_set = generate_keyed_value_combinations(args)
@@ -285,6 +294,9 @@ def generate_keyed_value_combinations(args):
"""
# Return empty list if empty
if not args:
return []
args = {key: values for key, values in args.items() if values}
if not args:
return []

View File

@@ -17,6 +17,7 @@ from pypika import Order
import erpnext
from erpnext.accounts.utils import build_qb_match_conditions
from erpnext.stock.get_item_details import _get_item_tax_template
from erpnext.stock.utils import get_combine_datetime
# searches for active employees
@@ -476,6 +477,13 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
.limit(page_len)
)
if not filters.get("is_inward"):
if filters.get("posting_date") and filters.get("posting_time"):
query = query.where(
stock_ledger_entry.posting_datetime
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
)
if not filters.get("include_expired_batches"):
query = query.where((batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull()))
@@ -529,6 +537,13 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
.limit(page_len)
)
if not filters.get("is_inward"):
if filters.get("posting_date") and filters.get("posting_time"):
bundle_query = bundle_query.where(
stock_ledger_entry.posting_datetime
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
)
if not filters.get("include_expired_batches"):
bundle_query = bundle_query.where(
(batch_table.expiry_date >= expiry_date) | (batch_table.expiry_date.isnull())

View File

@@ -240,10 +240,10 @@ class StatusUpdater(Document):
# get unique transactions to update
for d in self.get_all_children():
if hasattr(d, "qty") and d.qty < 0 and not self.get("is_return"):
if hasattr(d, "qty") and flt(d.qty) < 0 and not self.get("is_return"):
frappe.throw(_("For an item {0}, quantity must be positive number").format(d.item_code))
if hasattr(d, "qty") and d.qty > 0 and self.get("is_return"):
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code))
if not frappe.db.get_single_value("Selling Settings", "allow_negative_rates_for_items"):

View File

@@ -3,7 +3,11 @@ import unittest
import frappe
from erpnext.controllers.item_variant import copy_attributes_to_variant, make_variant_item_code
from erpnext.controllers.item_variant import (
copy_attributes_to_variant,
generate_keyed_value_combinations,
make_variant_item_code,
)
from erpnext.stock.doctype.item.test_item import set_item_variant_settings
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
create_quality_inspection_parameter,
@@ -17,6 +21,19 @@ class TestItemVariant(unittest.TestCase):
variant = make_item_variant()
self.assertEqual(variant.get("quality_inspection_template"), "_Test QC Template")
def test_generate_keyed_value_combinations_ignores_empty_attributes(self):
combinations = generate_keyed_value_combinations(
{"Test Colour": ["Red", "Blue"], "Test Size": ["Small", "Large"], "Test Fit": []}
)
self.assertEqual(len(combinations), 4)
self.assertNotIn("Test Fit", combinations[0])
single_attribute_combinations = generate_keyed_value_combinations(
{"Test Colour": ["Red", "Blue"], "Test Size": []}
)
self.assertEqual(single_attribute_combinations, [{"Test Colour": "Red"}, {"Test Colour": "Blue"}])
def create_variant_with_tables(item, args):
if isinstance(args, str):

View File

@@ -1,60 +0,0 @@
{
"custom_fields": [
{
"_assign": null,
"_comments": null,
"_liked_by": null,
"_user_tags": null,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
"creation": "2019-12-02 11:00:03.432994",
"default": null,
"depends_on": null,
"description": null,
"docstatus": 0,
"dt": "Contact",
"fetch_from": null,
"fetch_if_empty": 0,
"fieldname": "is_billing_contact",
"fieldtype": "Check",
"hidden": 0,
"idx": 27,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"insert_after": "is_primary_contact",
"label": "Is Billing Contact",
"length": 0,
"modified": "2019-12-02 11:00:03.432994",
"modified_by": "Administrator",
"name": "Contact-is_billing_contact",
"no_copy": 0,
"options": null,
"owner": "Administrator",
"parent": null,
"parentfield": null,
"parenttype": null,
"permlevel": 0,
"precision": "",
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": null,
"read_only": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"translatable": 0,
"unique": 0,
"width": null
}
],
"custom_perms": [],
"doctype": "Contact",
"property_setters": [],
"sync_on_migrate": 1
}

View File

@@ -1367,18 +1367,71 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account):
def add_operations_cost(stock_entry, work_order=None, expense_account=None):
from erpnext.stock.doctype.stock_entry.stock_entry import get_operating_cost_per_unit
from erpnext.stock.doctype.stock_entry.stock_entry import (
get_consumed_operating_cost,
get_operating_cost_per_unit,
)
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
if operating_cost_per_unit:
stock_entry.append(
"additional_costs",
{
def append_operating_cost(amount, operation=None, qty=None):
if amount:
row = {
"expense_account": expense_account,
"description": _("Operating Cost as per Work Order / BOM"),
"amount": operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
},
"amount": flt(
amount,
frappe.get_precision("Landed Cost Taxes and Charges", "amount"),
),
"has_operating_cost": 1,
}
if operation:
row["operation_id"] = operation.name
if qty is not None:
row["qty"] = qty
stock_entry.append(
"additional_costs",
row,
)
if (
work_order
and stock_entry.bom_no
and frappe.db.get_single_value("Manufacturing Settings", "set_op_cost_and_scrap_from_sub_assemblies")
and work_order.get("use_multi_level_bom")
):
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
append_operating_cost(
operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
qty=flt(stock_entry.fg_completed_qty),
)
elif work_order and work_order.get("operations"):
for operation in work_order.get("operations"):
qty = flt(stock_entry.fg_completed_qty)
amount = 0
if flt(operation.completed_qty):
consumed_cost = get_consumed_operating_cost(
work_order.name, stock_entry.bom_no, operation.name
)
remaining_cost = flt(
flt(operation.actual_operating_cost) - flt(consumed_cost.get("consumed_cost")),
operation.precision("actual_operating_cost"),
)
remaining_qty = flt(operation.completed_qty) - flt(consumed_cost.get("consumed_qty"))
if remaining_cost <= 0 or remaining_qty <= 0:
continue
qty = min(remaining_qty, flt(stock_entry.fg_completed_qty))
amount = remaining_cost / remaining_qty * qty
elif work_order.qty:
amount = flt(operation.planned_operating_cost) / flt(work_order.qty) * qty
append_operating_cost(amount, operation=operation, qty=qty)
else:
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
append_operating_cost(
operating_cost_per_unit * flt(stock_entry.fg_completed_qty),
qty=flt(stock_entry.fg_completed_qty),
)
if work_order and work_order.additional_operating_cost and work_order.qty:

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "naming_series:",
"creation": "2018-07-09 17:23:29.518745",
"doctype": "DocType",
@@ -135,6 +136,7 @@
"fieldname": "wip_warehouse",
"fieldtype": "Link",
"label": "WIP Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse",
"reqd": 1
},
@@ -511,7 +513,7 @@
],
"is_submittable": 1,
"links": [],
"modified": "2025-08-04 15:47:54.514290",
"modified": "2026-05-12 12:17:17.750857",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -7,7 +7,7 @@ from typing import Literal
import frappe
from frappe.test_runner import make_test_records
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import random_string
from frappe.utils import flt, random_string
from frappe.utils.data import add_to_date, now, today
from erpnext.manufacturing.doctype.job_card.job_card import (
@@ -697,6 +697,403 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(wo_doc.process_loss_qty, 2)
self.assertEqual(wo_doc.status, "Completed")
def test_op_cost_calculation(self):
from erpnext.manufacturing.doctype.routing.test_routing import (
create_routing,
setup_bom,
setup_operations,
)
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_for_wo,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
suffix = random_string(5)
workstation = make_workstation(
workstation_name=f"Test Workstation Z {suffix}", hour_rate_rent=240, hour_rate_labour=0
)
workstation.update(
{
"hour_rate_rent": 240,
"hour_rate_labour": 0,
"hour_rate_electricity": 0,
"hour_rate_consumable": 0,
}
)
workstation.save()
operations = [
{
"operation": f"Test Operation A1 {suffix}",
"workstation": workstation.name,
"time_in_mins": 30,
},
]
warehouse = create_warehouse(f"Test Warehouse 123 for Job Card {suffix}")
setup_operations(operations)
item_code = f"Test Job Card Process Qty Item {suffix}"
for item in [item_code, item_code + "RM 1", item_code + "RM 2"]:
if not frappe.db.exists("Item", item):
make_item(
item,
{
"item_name": item,
"stock_uom": "Nos",
"is_stock_item": 1,
},
)
routing_doc = create_routing(routing_name="Testing Route", operations=operations)
bom_doc = setup_bom(
item_code=item_code,
routing=routing_doc.name,
raw_materials=[item_code + "RM 1", item_code + "RM 2"],
source_warehouse=warehouse,
)
for row in bom_doc.items:
make_stock_entry(
item_code=row.item_code,
target=row.source_warehouse,
qty=10,
basic_rate=100,
)
wo_doc = make_wo_order_test_record(
production_item=item_code,
bom_no=bom_doc.name,
qty=10,
skip_transfer=1,
wip_warehouse=warehouse,
source_warehouse=warehouse,
)
first_job_card = frappe.get_all(
"Job Card",
filters={"work_order": wo_doc.name, "sequence_id": 1},
fields=["name"],
order_by="sequence_id",
limit=1,
)[0].name
jc = frappe.get_doc("Job Card", first_job_card)
from_time = "2025-01-01 09:00:00"
for _ in jc.scheduled_time_logs:
jc.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, minutes=1),
"completed_qty": 4,
},
)
jc.for_quantity = 4
jc.save()
jc.submit()
s1 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 4))
s1.submit()
wo_doc.reload()
precision = s1.additional_costs[0].precision("amount")
self.assertEqual(
flt(s1.additional_costs[0].amount, precision),
flt(wo_doc.operations[0].actual_operating_cost, precision),
)
make_job_card(
wo_doc.name,
[
{
"name": wo_doc.operations[0].name,
"operation": operations[0]["operation"],
"workstation": wo_doc.operations[0].workstation,
"qty": 6,
"pending_qty": 6,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = "2025-01-01 10:00:00"
job_card.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, minutes=2),
"completed_qty": 6,
},
)
job_card.for_quantity = 6
job_card.save()
job_card.submit()
s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6))
wo_doc.reload()
precision = s2.additional_costs[0].precision("amount")
self.assertEqual(
flt(s2.additional_costs[0].amount, precision),
flt(wo_doc.operations[0].actual_operating_cost - s1.additional_costs[0].amount, precision),
)
@change_settings("Manufacturing Settings", {"overproduction_percentage_for_work_order": 100})
def test_operating_cost_with_overproduction(self):
from erpnext.manufacturing.doctype.routing.test_routing import (
create_routing,
setup_bom,
setup_operations,
)
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_stock_entry_for_wo,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
suffix = random_string(5)
workstation = make_workstation(
workstation_name=f"Test Workstation for Overproduction {suffix}",
hour_rate_rent=10,
hour_rate_labour=10,
)
workstation.update(
{
"hour_rate_rent": 10,
"hour_rate_labour": 10,
"hour_rate_electricity": 0,
"hour_rate_consumable": 0,
}
)
workstation.save()
operations = [
{"operation": f"Test Operation 1 {suffix}", "workstation": workstation.name, "time_in_mins": 30},
{"operation": f"Test Operation 2 {suffix}", "workstation": workstation.name, "time_in_mins": 30},
]
warehouse = create_warehouse(f"Test Warehouse for Overproduction {suffix}")
setup_operations(operations)
fg = make_item(f"Test FG for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1})
rm = make_item(f"Test RM for Overproduction {suffix}", {"stock_uom": "Nos", "is_stock_item": 1})
routing_doc = create_routing(routing_name=f"Testing Route {suffix}", operations=operations)
bom_doc = setup_bom(
item_code=fg.name,
routing=routing_doc.name,
raw_materials=[rm.name],
source_warehouse=warehouse,
)
for row in bom_doc.items:
make_stock_entry(
item_code=row.item_code,
target=row.source_warehouse,
qty=100,
basic_rate=100,
)
wo_doc = make_wo_order_test_record(
production_item=fg.name,
bom_no=bom_doc.name,
qty=10,
skip_transfer=1,
source_warehouse=warehouse,
)
first_operation = frappe.get_all(
"Job Card",
filters={"work_order": wo_doc.name, "sequence_id": 1},
fields=["name"],
order_by="sequence_id",
limit=1,
)[0].name
jc = frappe.get_doc("Job Card", first_operation)
from_time = "2025-01-02 09:00:00"
for _ in jc.scheduled_time_logs:
jc.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, days=1),
"completed_qty": 4,
},
)
jc.for_quantity = 4
jc.save()
jc.submit()
second_operation = frappe.get_all(
"Job Card",
filters={"work_order": wo_doc.name, "sequence_id": 2},
fields=["name"],
order_by="sequence_id",
limit=1,
)[0].name
jc = frappe.get_doc("Job Card", second_operation)
from_time = "2025-01-05 09:00:00"
for _ in jc.scheduled_time_logs:
jc.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, days=2),
"completed_qty": 4,
},
)
jc.for_quantity = 4
jc.save()
jc.submit()
s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 6)) # overproduction
s.submit()
def assert_operating_costs(stock_entry, qty, previous_entries):
wo_doc.reload()
for idx, operation in enumerate(wo_doc.operations):
consumed_cost = sum(
entry.additional_costs[idx].amount for entry in previous_entries if entry.docstatus == 1
)
consumed_qty = sum(
entry.additional_costs[idx].qty for entry in previous_entries if entry.docstatus == 1
)
remaining_cost = operation.actual_operating_cost - consumed_cost
remaining_qty = operation.completed_qty - consumed_qty
precision = stock_entry.additional_costs[idx].precision("amount")
expected_cost = flt(remaining_cost / remaining_qty * min(remaining_qty, qty), precision)
self.assertEqual(flt(stock_entry.additional_costs[idx].amount, precision), expected_cost)
assert_operating_costs(s, 6, [])
make_job_card(
wo_doc.name,
[
{
"name": wo_doc.operations[0].name,
"operation": operations[0]["operation"],
"workstation": wo_doc.operations[0].workstation,
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = "2025-01-09 09:00:00"
job_card.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, days=1),
"completed_qty": 2,
},
)
job_card.for_quantity = 2
job_card.save()
job_card.submit()
make_job_card(
wo_doc.name,
[
{
"name": wo_doc.operations[1].name,
"operation": operations[1]["operation"],
"workstation": wo_doc.operations[1].workstation,
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = "2025-01-12 09:00:00"
job_card.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, days=2),
"completed_qty": 2,
},
)
job_card.for_quantity = 2
job_card.save()
job_card.submit()
s2 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 1))
s2.submit()
assert_operating_costs(s2, 1, [s])
make_job_card(
wo_doc.name,
[
{
"name": wo_doc.operations[0].name,
"operation": operations[0]["operation"],
"workstation": wo_doc.operations[0].workstation,
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = "2025-01-16 09:00:00"
job_card.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, days=1),
"completed_qty": 2,
},
)
job_card.for_quantity = 2
job_card.save()
job_card.submit()
make_job_card(
wo_doc.name,
[
{
"name": wo_doc.operations[1].name,
"operation": operations[1]["operation"],
"workstation": wo_doc.operations[1].workstation,
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = "2025-01-19 09:00:00"
job_card.append(
"time_logs",
{
"from_time": from_time,
"to_time": add_to_date(from_time, days=2),
"completed_qty": 2,
},
)
job_card.for_quantity = 2
job_card.save()
job_card.submit()
s3 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 2))
s3.submit()
assert_operating_costs(s3, 2, [s, s2])
s2.cancel()
s4 = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 3))
s4.submit()
assert_operating_costs(s4, 3, [s, s3])
def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2018-07-09 17:20:44.737289",
"doctype": "DocType",
"editable_grid": 1,
@@ -33,6 +34,7 @@
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse"
},
{
@@ -105,7 +107,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-04-22 18:50:00.003444",
"modified": "2026-05-12 12:22:18.506904",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Item",
@@ -115,4 +117,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
}
}

View File

@@ -12,21 +12,10 @@ frappe.ui.form.on("Work Order", {
frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"];
// Set query for warehouses
frm.set_query("wip_warehouse", function () {
return {
filters: {
company: frm.doc.company,
},
};
});
frm.set_query("source_warehouse", function () {
return {
filters: {
company: frm.doc.company,
},
};
});
frm.events.set_company_filters(frm, "wip_warehouse");
frm.events.set_company_filters(frm, "source_warehouse");
frm.events.set_company_filters(frm, "fg_warehouse");
frm.events.set_company_filters(frm, "scrap_warehouse");
frm.set_query("source_warehouse", "required_items", function () {
return {
@@ -44,24 +33,6 @@ frappe.ui.form.on("Work Order", {
};
});
frm.set_query("fg_warehouse", function () {
return {
filters: {
company: frm.doc.company,
is_group: 0,
},
};
});
frm.set_query("scrap_warehouse", function () {
return {
filters: {
company: frm.doc.company,
is_group: 0,
},
};
});
// Set query for BOM
frm.set_query("bom_no", function () {
if (frm.doc.production_item) {
@@ -118,6 +89,16 @@ frappe.ui.form.on("Work Order", {
});
},
set_company_filters(frm, fieldname) {
frm.set_query(fieldname, () => {
return {
filters: {
company: frm.doc.company,
},
};
});
},
onload: function (frm) {
if (!frm.doc.status) frm.doc.status = "Draft";
@@ -315,7 +296,7 @@ frappe.ui.form.on("Work Order", {
{
fieldtype: "Data",
fieldname: "name",
label: __("Operation Id"),
label: __("Operation ID"),
},
{
fieldtype: "Float",
@@ -385,6 +366,7 @@ frappe.ui.form.on("Work Order", {
if (pending_qty) {
dialog.fields_dict.operations.df.data.push({
__checked: 1,
name: data.name,
operation: data.operation,
workstation: data.workstation,

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_import": 1,
"autoname": "naming_series:",
"creation": "2025-04-09 12:09:40.634472",
@@ -249,6 +250,7 @@
"fieldname": "wip_warehouse",
"fieldtype": "Link",
"label": "Work-in-Progress Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
"options": "Warehouse"
},
@@ -257,6 +259,7 @@
"fieldname": "fg_warehouse",
"fieldtype": "Link",
"label": "Target Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse",
"reqd": 1
},
@@ -269,6 +272,7 @@
"fieldname": "scrap_warehouse",
"fieldtype": "Link",
"label": "Scrap Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse"
},
{
@@ -498,6 +502,7 @@
"fieldname": "source_warehouse",
"fieldtype": "Link",
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse"
},
{
@@ -602,7 +607,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2025-06-21 00:55:45.916224",
"modified": "2026-05-19 12:20:38.102403",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2016-04-18 07:38:26.314642",
"doctype": "DocType",
"editable_grid": 1,
@@ -46,6 +47,7 @@
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse"
},
{
@@ -151,7 +153,7 @@
],
"istable": 1,
"links": [],
"modified": "2025-12-02 11:16:05.081613",
"modified": "2026-05-12 12:05:16.687866",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Item",

View File

@@ -434,3 +434,4 @@ erpnext.patches.v16_0.add_portal_redirects
erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po
erpnext.patches.v16_0.depends_on_inv_dimensions
erpnext.patches.v16_0.clear_procedures_from_receivable_report
erpnext.patches.v16_0.migrate_address_contact_custom_fields

View File

@@ -0,0 +1,16 @@
import frappe
from erpnext.setup.install import create_address_and_contact_custom_fields
def execute():
"""Replace fixture-based custom fields on Address and Contact with programmatic ones."""
for custom_field in (
"Address-tax_category",
"Address-is_your_company_address",
"Contact-is_billing_contact",
):
if frappe.db.exists("Custom Field", custom_field):
frappe.delete_doc("Custom Field", custom_field, ignore_missing=True, force=True)
create_address_and_contact_custom_fields()

View File

@@ -176,6 +176,7 @@
"fieldtype": "Link",
"in_global_search": 1,
"label": "Customer",
"no_copy": 1,
"oldfieldname": "customer",
"oldfieldtype": "Link",
"options": "Customer",
@@ -190,6 +191,7 @@
"fieldname": "sales_order",
"fieldtype": "Link",
"label": "Sales Order",
"no_copy": 1,
"options": "Sales Order"
},
{
@@ -462,7 +464,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2025-08-21 17:57:58.314809",
"modified": "2026-05-22 16:45:50.762759",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",

View File

@@ -472,6 +472,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
warehouse:
this.item.s_warehouse || this.item.t_warehouse || this.item.warehouse,
is_inward: is_inward,
posting_date: this.frm.doc.posting_date,
posting_time: this.frm.doc.posting_time,
include_expired_batches: include_expired_batches,
},
};

View File

@@ -385,7 +385,7 @@ class SalesOrder(SellingController):
and not cint(d.delivered_by_supplier)
):
frappe.throw(
_("Delivery warehouse required for stock item {0}").format(d.item_code), WarehouseRequired
_("Source warehouse required for stock item {0}").format(d.item_code), WarehouseRequired
)
def validate_with_previous_doc(self):

View File

@@ -77,13 +77,11 @@ class CustomerGroup(NestedSet):
def get_parent_customer_groups(customer_group):
lft, rgt = frappe.db.get_value("Customer Group", customer_group, ["lft", "rgt"])
return frappe.db.sql(
"""select name from `tabCustomer Group`
where lft <= %s and rgt >= %s
order by lft asc""",
(lft, rgt),
as_dict=True,
return frappe.get_all(
"Customer Group",
filters=[["lft", "<=", lft], ["rgt", ">=", rgt]],
fields=["name"],
order_by="lft asc",
)

View File

@@ -70,3 +70,13 @@ class SupplierGroup(NestedSet):
def on_trash(self):
NestedSet.validate_if_child_exists(self)
frappe.utils.nestedset.update_nsm(self)
def get_parent_supplier_groups(supplier_group):
lft, rgt = frappe.db.get_value("Supplier Group", supplier_group, ["lft", "rgt"])
return frappe.get_all(
"Supplier Group",
filters=[["lft", "<=", lft], ["rgt", ">=", rgt]],
fields=["name"],
order_by="lft asc",
)

View File

@@ -24,6 +24,7 @@ def after_install():
set_single_defaults()
create_print_setting_custom_fields()
create_address_and_contact_custom_fields()
create_custom_company_links()
add_all_roles_to("Administrator")
create_default_success_action()
@@ -132,6 +133,37 @@ def create_print_setting_custom_fields():
)
def create_address_and_contact_custom_fields():
create_custom_fields(
{
"Address": [
{
"label": _("Tax Category"),
"fieldname": "tax_category",
"fieldtype": "Link",
"options": "Tax Category",
"insert_after": "fax",
},
{
"label": _("Is Your Company Address"),
"fieldname": "is_your_company_address",
"fieldtype": "Check",
"default": "0",
"insert_after": "linked_with",
},
],
"Contact": [
{
"label": _("Is Billing Contact"),
"fieldname": "is_billing_contact",
"fieldtype": "Check",
"insert_after": "is_primary_contact",
},
],
}
)
def create_custom_company_links():
"""Add link fields to Company in Email Account and Communication.

View File

@@ -30,11 +30,20 @@ class DeprecatedSerialNoValuation:
def get_incoming_value_for_serial_nos(self, serial_nos):
from erpnext.stock.utils import get_combine_datetime
do_not_fetch_rate = frappe.db.get_single_value(
"Stock Reposting Settings", "do_not_fetch_incoming_rate_from_serial_no"
)
# get rate from serial nos within same company
incoming_values = 0.0
for serial_no in serial_nos:
sn_details = frappe.db.get_value("Serial No", serial_no, ["purchase_rate", "company"], as_dict=1)
if sn_details and sn_details.purchase_rate and sn_details.company == self.sle.company:
if (
sn_details
and sn_details.purchase_rate
and sn_details.company == self.sle.company
and (not frappe.flags.through_repost_item_valuation or not do_not_fetch_rate)
):
self.serial_no_incoming_rate[serial_no] += flt(sn_details.purchase_rate)
incoming_values += self.serial_no_incoming_rate[serial_no]
continue

View File

@@ -226,13 +226,6 @@ frappe.ui.form.on("Item", {
});
frm.set_df_property("is_fixed_asset", "read_only", frm.doc.__onload?.asset_exists ? 1 : 0);
frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0);
frm.set_query("item_group", () => {
return {
filters: {
is_group: 0,
},
};
});
},
validate: function (frm) {
@@ -411,12 +404,6 @@ $.extend(erpnext.item, {
};
};
frm.fields_dict["item_group"].get_query = function (doc, cdt, cdn) {
return {
filters: [["Item Group", "docstatus", "!=", 2]],
};
};
frm.fields_dict["item_defaults"].grid.get_field("deferred_revenue_account").get_query = function (
doc,
cdt,
@@ -594,11 +581,10 @@ $.extend(erpnext.item, {
default: 0,
onchange: function () {
let selected_attributes = get_selected_attributes();
let lengths = [];
Object.keys(selected_attributes).map((key) => {
lengths.push(selected_attributes[key].length);
let lengths = Object.keys(selected_attributes).map((key) => {
return selected_attributes[key].length;
});
if (lengths.includes(0)) {
if (!lengths.length) {
me.multiple_variant_dialog.get_primary_btn().html(__("Create Variants"));
me.multiple_variant_dialog.disable_primary_action();
} else {
@@ -635,7 +621,7 @@ $.extend(erpnext.item, {
fieldtype: "HTML",
fieldname: "help",
options: `<label class="control-label">
${__("Select at least one value from each of the attributes.")}
${__("Select at least one attribute value.")}
</label>`,
},
]
@@ -693,6 +679,9 @@ $.extend(erpnext.item, {
selected_attributes[attribute_name].push($(opt).attr("data-fieldname"));
}
});
if (!selected_attributes[attribute_name].length) {
delete selected_attributes[attribute_name];
}
});
return selected_attributes;

View File

@@ -855,8 +855,13 @@ class Item(Document):
if disabled:
frappe.throw(_("Attribute {0} is disabled.").format(frappe.bold(d.attribute)))
if not numeric_values and not frappe.db.exists(
"Item Attribute Value", {"parent": d.attribute, "attribute_value": d.attribute_value}
if (
not numeric_values
and d.attribute_value
and not frappe.db.exists(
"Item Attribute Value",
{"parent": d.attribute, "attribute_value": d.attribute_value},
)
):
frappe.throw(
_("Attribute Value {0} is not valid for the selected attribute {1}.").format(

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2014-07-11 11:51:00.453717",
"doctype": "DocType",
"editable_grid": 1,
@@ -12,7 +13,10 @@
"col_break3",
"amount",
"base_amount",
"has_corrective_cost"
"has_corrective_cost",
"has_operating_cost",
"operation_id",
"qty"
],
"fields": [
{
@@ -70,12 +74,36 @@
"fieldtype": "Check",
"label": "Has Corrective Cost",
"read_only": 1
},
{
"default": "0",
"fieldname": "has_operating_cost",
"fieldtype": "Check",
"label": "Has Operating Cost",
"read_only": 1
},
{
"fieldname": "operation_id",
"fieldtype": "Data",
"hidden": 1,
"label": "Operation ID",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "qty",
"fieldtype": "Float",
"hidden": 1,
"label": "Qty",
"no_copy": 1,
"non_negative": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-01-20 12:22:03.455762",
"modified": "2026-05-19 12:21:07.953801",
"modified_by": "Administrator",
"module": "Stock",
"name": "Landed Cost Taxes and Charges",
@@ -83,4 +111,4 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC"
}
}

View File

@@ -21,9 +21,12 @@ class LandedCostTaxesandCharges(Document):
exchange_rate: DF.Float
expense_account: DF.Link | None
has_corrective_cost: DF.Check
has_operating_cost: DF.Check
operation_id: DF.Data | None
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
qty: DF.Float
# end: auto-generated types
pass

View File

@@ -1250,7 +1250,7 @@ def get_billed_qty_amount_against_purchase_receipt(pr_doc):
.on(parent_table.name == table.parent)
.select(
table.pr_detail,
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
fn.Sum(table.base_net_amount).as_("amount"),
fn.Sum(table.qty).as_("qty"),
)
.where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1))
@@ -1296,7 +1296,7 @@ def get_billed_qty_amount_against_purchase_order(pr_doc):
.select(
table.po_detail,
fn.Sum(table.qty).as_("qty"),
fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"),
fn.Sum(table.base_net_amount).as_("amount"),
)
.where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull()))
.groupby(table.po_detail)

View File

@@ -5170,6 +5170,157 @@ class TestPurchaseReceipt(FrappeTestCase):
"Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value
)
@change_settings(
"Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0}
)
def test_srbnb_with_inclusive_tax_and_rate_change_in_pi(self):
"""
When 'Set Landed Cost Based on PI Rate' is enabled and PI has an inclusive tax:
- PR: qty=2, rate=1000 INR → base_net_amount=2000
- PI: rate changed to 2000, 5% tax included in basic rate
→ PI base_net_amount = 2 * 2000 / 1.05 ≈ 3809.52
The system must use PI's base_net_amount (not amount=4000) so that
SRBNB credit on PR = 3809.52, not 4000.
"""
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
cost_center = "Main - TCP1"
item_code = make_item(
"Test Item for SRBNB Inclusive Tax Rate Change",
{"is_stock_item": 1},
).name
pr = make_purchase_receipt(
item_code=item_code,
qty=2,
rate=1000,
company=company,
warehouse=warehouse,
cost_center=cost_center,
)
pi = make_purchase_invoice(pr.name)
pi.items[0].rate = 2000
pi.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - TCP1",
"category": "Total",
"add_deduct_tax": "Add",
"included_in_print_rate": 1,
"rate": 5,
"description": "Test Inclusive Tax",
"cost_center": cost_center,
},
)
pi.save()
pi.submit()
pr.reload()
# PI base_net_amount = qty * (rate / (1 + tax_rate/100)) = 2 * (2000 / 1.05)
pi_base_net_amount = flt(2 * 2000 / 1.05, 2)
pr_base_net_amount = flt(pr.items[0].amount, 2) # 2 * 1000 = 2000
expected_diff = flt(pi_base_net_amount - pr_base_net_amount, 2)
self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2)
# Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount
srbnb_account = "Stock Received But Not Billed - TCP1"
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
@change_settings(
"Buying Settings", {"set_landed_cost_based_on_purchase_invoice_rate": 1, "maintain_same_rate": 0}
)
def test_srbnb_with_inclusive_tax_and_exchange_rate_change_in_pi(self):
"""
When 'Set Landed Cost Based on PI Rate' is enabled, PI has an inclusive tax, and only
the exchange rate changes on the PI (rate stays the same):
- PR: qty=2, rate=100 USD, conversion_rate=70 → base_net_amount=14000 INR
- PI: same rate=100 USD, conversion_rate changed to 90, 5% tax included in basic rate
→ PI base_net_amount = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR
The system must use PI's base_net_amount (not amount = 2*100*90 = 18000) so that
SRBNB credit on PR = 17142.86, not 18000.
"""
from erpnext.accounts.doctype.account.test_account import create_account
company = "_Test Company with perpetual inventory"
warehouse = "Stores - TCP1"
cost_center = "Main - TCP1"
party_account = create_account(
account_name="USD Payable For SRBNB Exchange Rate Test",
parent_account="Accounts Payable - TCP1",
account_type="Payable",
company=company,
account_currency="USD",
)
supplier = create_supplier(
supplier_name="_Test USD Supplier for SRBNB Exchange Rate",
default_currency="USD",
party_account=party_account,
).name
item_code = make_item(
"Test Item for SRBNB Inclusive Tax Exchange Rate Change",
{"is_stock_item": 1},
).name
pr = make_purchase_receipt(
item_code=item_code,
qty=2,
rate=100,
currency="USD",
conversion_rate=70,
company=company,
warehouse=warehouse,
supplier=supplier,
)
pi = make_purchase_invoice(pr.name)
pi.conversion_rate = 90
pi.append(
"taxes",
{
"charge_type": "On Net Total",
"account_head": "_Test Account VAT - TCP1",
"category": "Total",
"add_deduct_tax": "Add",
"included_in_print_rate": 1,
"rate": 5,
"description": "Test Inclusive Tax",
"cost_center": cost_center,
},
)
pi.save()
pi.submit()
pr.reload()
# PI base_net_amount = qty * (rate / (1 + tax_rate/100)) * new_conversion_rate
# = 2 * (100 / 1.05) * 90 ≈ 17142.86 INR
# PR base_net_amount = qty * rate * pr_conversion_rate = 2 * 100 * 70 = 14000 INR
tax_amount_pr = (200 - flt(200 / 1.05, 2)) * 90
pi_base_net_amount = flt(2 * 100 * 90) - flt(tax_amount_pr)
pr_base_net_amount = flt(2 * 100 * 70)
expected_diff = flt(pi_base_net_amount - pr_base_net_amount)
self.assertAlmostEqual(pr.items[0].amount_difference_with_purchase_invoice, expected_diff, places=2)
# Total SRBNB credit = PR base_net_amount + amount_difference = PI base_net_amount
srbnb_account = "Stock Received But Not Billed - TCP1"
gl_entries = get_gl_entries("Purchase Receipt", pr.name, skip_cancelled=True)
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier

View File

@@ -1174,13 +1174,21 @@ class StockEntry(StockController):
)
def get_basic_rate_for_repacked_items(self, finished_item_qty, outgoing_items_cost):
finished_items = [d.item_code for d in self.get("items") if d.is_finished_item]
finished_items = [
d.item_code for d in self.get("items") if d.is_finished_item and not d.set_basic_rate_manually
]
if len(finished_items) == 1:
return flt(outgoing_items_cost / finished_item_qty)
else:
unique_finished_items = set(finished_items)
if len(unique_finished_items) == 1:
total_fg_qty = sum([flt(d.transfer_qty) for d in self.items if d.is_finished_item])
if unique_finished_items:
total_fg_qty = sum(
[
flt(d.transfer_qty)
for d in self.items
if d.is_finished_item and not d.set_basic_rate_manually
]
)
return flt(outgoing_items_cost / total_fg_qty)
def get_basic_rate_for_manufactured_item(self, finished_item_qty, outgoing_items_cost=0) -> float:
@@ -3362,6 +3370,33 @@ def get_work_order_details(work_order, company):
}
def get_consumed_operating_cost(work_order, bom_no, operation_id=None):
table = frappe.qb.DocType("Stock Entry")
child_table = frappe.qb.DocType("Landed Cost Taxes and Charges")
query = (
frappe.qb.from_(child_table)
.join(table)
.on(child_table.parent == table.name)
.select(
Sum(child_table.amount).as_("consumed_cost"),
Sum(child_table.qty).as_("consumed_qty"),
)
.where(
(table.docstatus == 1)
& (table.work_order == work_order)
& (table.purpose == "Manufacture")
& (table.bom_no == bom_no)
& (child_table.has_operating_cost == 1)
)
)
if operation_id:
query = query.where(child_table.operation_id == operation_id)
data = query.run(as_dict=True)
return data[0] if data else frappe._dict()
def get_operating_cost_per_unit(work_order=None, bom_no=None):
operating_cost_per_unit = 0
if work_order:

View File

@@ -567,15 +567,18 @@ class StockReconciliation(StockController):
def calculate_difference_amount(self, item, item_dict):
qty_precision = item.precision("qty")
val_precision = item.precision("valuation_rate")
amount_precision = item.precision("amount")
new_qty = flt(item.qty, qty_precision)
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"), val_precision)
new_valuation_rate = flt(item.valuation_rate or item_dict.get("rate"))
current_qty = flt(item_dict.get("qty"), qty_precision)
current_valuation_rate = flt(item_dict.get("rate"), val_precision)
current_valuation_rate = flt(item_dict.get("rate"))
self.difference_amount += (new_qty * new_valuation_rate) - (current_qty * current_valuation_rate)
new_amount = flt(new_qty * new_valuation_rate, amount_precision)
current_amount = flt(current_qty * current_valuation_rate, amount_precision)
self.difference_amount += new_amount - current_amount
def validate_data(self):
def _get_msg(row_num, msg):
@@ -875,7 +878,7 @@ class StockReconciliation(StockController):
"company": self.company,
"stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"),
"is_cancelled": 1 if self.docstatus == 2 else 0,
"valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")),
"valuation_rate": flt(row.valuation_rate),
}
)
@@ -1035,86 +1038,6 @@ class StockReconciliation(StockController):
else:
self._cancel()
def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False):
from erpnext.stock.stock_ledger import get_valuation_rate
for row in self.items:
if voucher_detail_no != row.name:
continue
if row.current_qty < 0:
return
val_rate = 0.0
current_qty = 0.0
if row.current_serial_and_batch_bundle:
current_qty = self.get_current_qty_for_serial_or_batch(row, sle_creation)
elif row.serial_no:
item_dict = get_stock_balance_for(
row.item_code,
row.warehouse,
self.posting_date,
self.posting_time,
row=row,
company=self.company,
)
current_qty = item_dict.get("qty")
row.current_serial_no = item_dict.get("serial_nos")
row.current_valuation_rate = item_dict.get("rate")
val_rate = item_dict.get("rate")
elif row.batch_no:
current_qty = get_batch_qty_for_stock_reco(
row.item_code,
row.warehouse,
row.batch_no,
self.posting_date,
self.posting_time,
self.name,
sle_creation,
)
precesion = row.precision("current_qty")
if flt(current_qty, precesion) != flt(row.current_qty, precesion):
if not row.serial_no:
val_rate = get_incoming_rate(
frappe._dict(
{
"item_code": row.item_code,
"warehouse": row.warehouse,
"qty": current_qty * -1,
"serial_and_batch_bundle": row.current_serial_and_batch_bundle,
"batch_no": row.batch_no,
"voucher_type": self.doctype,
"voucher_no": self.name,
"company": self.company,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
)
row.current_valuation_rate = val_rate
row.current_qty = current_qty
row.db_set(
{
"current_qty": row.current_qty,
"current_valuation_rate": row.current_valuation_rate,
"current_amount": flt(row.current_qty * row.current_valuation_rate),
}
)
if add_new_sle and not frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_detail_no": row.name, "actual_qty": ("<", 0), "is_cancelled": 0},
"name",
):
if not row.current_serial_and_batch_bundle:
self.set_current_serial_and_batch_bundle(voucher_detail_no, save=True)
row.reload()
self.add_missing_stock_ledger_entry(row, voucher_detail_no, sle_creation)
def add_missing_stock_ledger_entry(self, row, voucher_detail_no, sle_creation):
if row.current_qty == 0:
return

View File

@@ -1039,7 +1039,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sr.reload()
self.assertTrue(sr.items[0].serial_and_batch_bundle)
self.assertTrue(sr.items[0].current_serial_and_batch_bundle)
self.assertFalse(sr.items[0].current_serial_and_batch_bundle)
def test_not_reconcile_all_batch(self):
from erpnext.stock.doctype.batch.batch import get_batch_qty

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"beta": 1,
"creation": "2021-10-01 10:56:30.814787",
@@ -13,6 +14,7 @@
"end_time",
"limits_dont_apply_on",
"item_based_reposting",
"do_not_fetch_incoming_rate_from_serial_no",
"errors_notification_section",
"notify_reposting_error_to_role"
],
@@ -65,12 +67,19 @@
"fieldname": "errors_notification_section",
"fieldtype": "Section Break",
"label": "Errors Notification"
},
{
"default": "0",
"description": "For legacy serial nos, do not fetch incoming rate from serial no and calculate it based on the inward transaction",
"fieldname": "do_not_fetch_incoming_rate_from_serial_no",
"fieldtype": "Check",
"label": "Do not fetch incoming rate from Serial No"
}
],
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-08 11:27:46.659056",
"modified": "2026-05-15 12:59:34.392491",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reposting Settings",

View File

@@ -16,6 +16,7 @@ class StockRepostingSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
do_not_fetch_incoming_rate_from_serial_no: DF.Check
end_time: DF.Time | None
item_based_reposting: DF.Check
limit_reposting_timeslot: DF.Check

View File

@@ -294,9 +294,8 @@ def clean_all_descriptions():
@frappe.whitelist()
def get_enable_stock_uom_editing():
return frappe.get_cached_value(
return frappe.get_single_value(
"Stock Settings",
None,
["allow_to_edit_stock_uom_qty_for_sales", "allow_to_edit_stock_uom_qty_for_purchase"],
as_dict=1,
)

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,11 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
from erpnext.stock.report.stock_ageing.stock_ageing import (
FIFOSlots,
format_report_data,
get_average_age,
)
class TestStockAgeing(FrappeTestCase):
@@ -868,6 +872,560 @@ class TestStockAgeing(FrappeTestCase):
range_valuations = range_values[1::2]
self.assertEqual(range_valuations, [15, 7.5, 20, 5])
def test_batch_item_report_formatting_preserves_mixed_fifo_slots(self):
item_details = {
"Batch Mixed Item": {
"details": frappe._dict(
name="Batch Mixed Item",
item_name="Batch Mixed Item",
description="Batch Mixed Item",
item_group=None,
brand=None,
has_batch_no=True,
stock_uom="Nos",
),
"fifo_queue": [
["SA-BATCH-MIXED-SLOT", 1, 5.0, "2021-12-01", 50.0],
[3.0, "2021-12-02", 30.0],
],
"has_serial_no": False,
"total_qty": 8.0,
}
}
report_data = format_report_data(self.filters, item_details, self.filters["to_date"])
self.assertEqual(report_data[0][7:15], [8.0, 80.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
def test_average_age_accepts_batchwise_valuation_slots(self):
fifo_queue = [["SA-BATCH-SLOT", 1, 5.0, "2021-12-01", 50.0]]
self.assertEqual(get_average_age(fifo_queue, self.filters["to_date"]), 9.0)
def test_serial_transfer_replay_preserves_serial_slots(self):
fifo_slots = FIFOSlots(self.filters, [])
transfer_key = ("001", "Serial Item", "WH 1")
fifo_slots.transferred_item_details[transfer_key] = [[2, "2021-12-01", 20]]
row = frappe._dict(
name="Serial Item",
actual_qty=2,
stock_value_difference=20,
posting_date="2021-12-05",
has_serial_no=True,
)
fifo_queue = []
fifo_slots._compute_incoming_stock(row, fifo_queue, transfer_key, ["SN-A", "SN-B"], [])
self.assertEqual(fifo_queue, [["SN-A", "2021-12-01", 10.0], ["SN-B", "2021-12-01", 10.0]])
self.assertFalse(fifo_slots.transferred_item_details[transfer_key])
def test_batch_transfer_replay_removes_zeroed_negative_slot(self):
fifo_slots = FIFOSlots(self.filters, [])
fifo_queue = [["SA-ZERO-BATCH", 1, -4, "2021-12-01", -40]]
fifo_slots._add_transfer_slot_to_fifo_queue(fifo_queue, ["SA-ZERO-BATCH", 1, 4, "2021-12-02", 40])
self.assertEqual(fifo_queue, [])
def test_batchwise_valuation(self):
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batchwise Valuation",
{
"is_stock_item": 1,
"has_batch_no": 1,
"valuation_method": "FIFO",
},
).name
def make_batch(batch_id, use_batchwise_valuation):
if not frappe.db.exists("Batch", batch_id):
frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_id,
"item": item_code,
}
).insert(ignore_permissions=True)
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", use_batchwise_valuation)
batchwise_above_90 = "SA-BATCHWISE-ABOVE-90"
non_batchwise_above_90 = "SA-NON-BATCHWISE-ABOVE-90"
batchwise_61_90 = "SA-BATCHWISE-61-90"
non_batchwise_61_90 = "SA-NON-BATCHWISE-61-90"
batchwise_31_60 = "SA-BATCHWISE-31-60"
non_batchwise_31_60 = "SA-NON-BATCHWISE-31-60"
batchwise_0_30 = "SA-BATCHWISE-0-30"
non_batchwise_0_30 = "SA-NON-BATCHWISE-0-30"
for batch_id, use_batchwise_valuation in {
batchwise_above_90: 1,
non_batchwise_above_90: 0,
batchwise_61_90: 1,
non_batchwise_61_90: 0,
batchwise_31_60: 1,
non_batchwise_31_60: 0,
batchwise_0_30: 1,
non_batchwise_0_30: 0,
}.items():
make_batch(batch_id, use_batchwise_valuation)
qty_after_transaction = 0
def make_sle(posting_date, voucher_no, batch_no, actual_qty, stock_value_difference):
nonlocal qty_after_transaction
qty_after_transaction += actual_qty
return frappe._dict(
name=item_code,
actual_qty=actual_qty,
qty_after_transaction=qty_after_transaction,
stock_value_difference=stock_value_difference,
warehouse="WH 1",
posting_date=posting_date,
voucher_type="Stock Entry",
voucher_no=voucher_no,
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
valuation_rate=10,
)
sle = [
make_sle("2021-08-01", "001", batchwise_above_90, 50, 500),
make_sle("2021-08-10", "002", non_batchwise_above_90, 60, 600),
make_sle("2021-08-20", "003", batchwise_above_90, -10, -100),
make_sle("2021-09-01", "004", non_batchwise_above_90, -15, -150),
make_sle("2021-09-20", "005", batchwise_61_90, 40, 400),
make_sle("2021-09-25", "006", non_batchwise_61_90, 50, 500),
make_sle("2021-09-30", "007", batchwise_61_90, -5, -50),
make_sle("2021-10-05", "008", non_batchwise_above_90, -20, -200),
make_sle("2021-10-20", "009", batchwise_31_60, 30, 300),
make_sle("2021-10-25", "010", non_batchwise_31_60, 40, 400),
make_sle("2021-10-30", "011", batchwise_31_60, -8, -80),
make_sle("2021-11-05", "012", non_batchwise_above_90, -25, -250),
make_sle("2021-11-20", "013", batchwise_0_30, 20, 200),
make_sle("2021-11-25", "014", non_batchwise_0_30, 30, 300),
make_sle("2021-11-30", "015", batchwise_0_30, -6, -60),
make_sle("2021-12-01", "016", non_batchwise_61_90, -10, -100),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots[item_code]
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
self.assertEqual(item_result["total_qty"], 221.0)
self.assertEqual(
item_result["fifo_queue"],
[
[batchwise_above_90, 1, 40.0, "2021-08-01", 400.0],
[batchwise_61_90, 1, 35.0, "2021-09-20", 350.0],
[non_batchwise_61_90, 0, 40.0, "2021-09-25", 400.0],
[batchwise_31_60, 1, 22.0, "2021-10-20", 220.0],
[non_batchwise_31_60, 0, 40, "2021-10-25", 400],
[batchwise_0_30, 1, 14.0, "2021-11-20", 140.0],
[non_batchwise_0_30, 0, 30, "2021-11-25", 300],
],
)
report_data = format_report_data(self.filters, slots, self.filters["to_date"])
range_values = report_data[0][7:15]
self.assertEqual(range_values, [44.0, 440.0, 62.0, 620.0, 75.0, 750.0, 40.0, 400.0])
def test_batchwise_valuation_same_voucher_transfer(self):
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batchwise Transfer",
{
"is_stock_item": 1,
"has_batch_no": 1,
"valuation_method": "FIFO",
},
).name
def make_batch(batch_id):
if not frappe.db.exists("Batch", batch_id):
frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_id,
"item": item_code,
}
).insert(ignore_permissions=True)
frappe.db.set_value("Batch", batch_id, "use_batchwise_valuation", 1)
source_batch = "SA-BATCHWISE-TRANSFER-SOURCE"
target_batch = "SA-BATCHWISE-TRANSFER-TARGET"
make_batch(source_batch)
make_batch(target_batch)
sle = [
frappe._dict(
name=item_code,
actual_qty=20,
qty_after_transaction=20,
stock_value_difference=200,
warehouse="WH 1",
posting_date="2021-09-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=source_batch,
valuation_rate=10,
),
frappe._dict(
name=item_code,
actual_qty=-15,
qty_after_transaction=5,
stock_value_difference=-150,
warehouse="WH 1",
posting_date="2021-10-01",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=source_batch,
valuation_rate=10,
),
frappe._dict(
name=item_code,
actual_qty=10,
qty_after_transaction=15,
stock_value_difference=100,
warehouse="WH 1",
posting_date="2021-10-01",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=target_batch,
valuation_rate=10,
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots[item_code]
self.assertEqual(item_result["total_qty"], 15.0)
self.assertEqual(
item_result["fifo_queue"],
[
[source_batch, 1, 5.0, "2021-09-01", 50.0],
[target_batch, 1, 10.0, "2021-09-01", 100.0],
],
)
self.assertEqual(
fifo_slots.transferred_item_details[("002", item_code, "WH 1")],
[[5.0, "2021-09-01", 50.0]],
)
def test_batchwise_valuation_negative_stock_same_voucher(self):
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batchwise Negative Stock",
{
"is_stock_item": 1,
"has_batch_no": 1,
"valuation_method": "FIFO",
},
).name
batch_no = "SA-BATCHWISE-NEGATIVE-STOCK"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_no,
"item": item_code,
}
).insert(ignore_permissions=True)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
sle = [
frappe._dict(
name=item_code,
actual_qty=-10,
qty_after_transaction=-10,
stock_value_difference=-100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
valuation_rate=10,
)
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots[item_code]
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -10, "2021-12-01", -100]])
self.assertEqual(
fifo_slots.transferred_item_details[("001", item_code, "WH 1")], [[10, "2021-12-01", 100]]
)
sle.append(
frappe._dict(
name=item_code,
actual_qty=6,
qty_after_transaction=-4,
stock_value_difference=60,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
valuation_rate=10,
)
)
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots[item_code]
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-12-01", -40.0]])
self.assertEqual(
fifo_slots.transferred_item_details[("001", item_code, "WH 1")],
[[4.0, "2021-12-01", 40.0]],
)
def test_batchwise_valuation_neutralizes_non_head_negative_batch(self):
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batchwise Negative Non Head",
{
"is_stock_item": 1,
"has_batch_no": 1,
"valuation_method": "FIFO",
},
).name
buffer_batch = "SA-BATCHWISE-NEGATIVE-BUFFER"
negative_batch = "SA-BATCHWISE-NEGATIVE-NON-HEAD"
for batch_no in [buffer_batch, negative_batch]:
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_no,
"item": item_code,
}
).insert(ignore_permissions=True)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
sle = [
frappe._dict(
name=item_code,
actual_qty=5,
qty_after_transaction=5,
stock_value_difference=50,
warehouse="WH 1",
posting_date="2021-11-30",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=buffer_batch,
valuation_rate=10,
),
frappe._dict(
name=item_code,
actual_qty=-10,
qty_after_transaction=-5,
stock_value_difference=-100,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=negative_batch,
valuation_rate=10,
),
frappe._dict(
name=item_code,
actual_qty=6,
qty_after_transaction=1,
stock_value_difference=60,
warehouse="WH 1",
posting_date="2021-12-01",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=negative_batch,
valuation_rate=10,
),
]
fifo_slots = FIFOSlots(self.filters, sle)
slots = fifo_slots.generate()
item_result = slots[item_code]
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
self.assertEqual(
item_result["fifo_queue"],
[
[buffer_batch, 1, 5, "2021-11-30", 50],
[negative_batch, 1, -4.0, "2021-12-01", -40.0],
],
)
self.assertEqual(
fifo_slots.transferred_item_details[("002", item_code, "WH 1")],
[[4.0, "2021-12-01", 40.0]],
)
def test_batchwise_valuation_negative_stock_later_voucher(self):
from erpnext.stock.doctype.item.test_item import make_item
item_code = make_item(
"Test Stock Ageing Batchwise Negative Later Voucher",
{
"is_stock_item": 1,
"has_batch_no": 1,
"valuation_method": "FIFO",
},
).name
batch_no = "SA-BATCHWISE-NEGATIVE-LATER-VOUCHER"
if not frappe.db.exists("Batch", batch_no):
frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_no,
"item": item_code,
}
).insert(ignore_permissions=True)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
sle = [
frappe._dict(
name=item_code,
actual_qty=-10,
qty_after_transaction=-10,
stock_value_difference=-100,
warehouse="WH 1",
posting_date="2021-11-01",
voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
valuation_rate=10,
),
frappe._dict(
name=item_code,
actual_qty=6,
qty_after_transaction=-4,
stock_value_difference=60,
warehouse="WH 1",
posting_date="2021-11-10",
voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False,
has_batch_no=True,
serial_no=None,
batch_no=batch_no,
valuation_rate=10,
),
]
slots = FIFOSlots(self.filters, sle).generate()
item_result = slots[item_code]
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
self.assertEqual(item_result["total_qty"], -4.0)
self.assertEqual(item_result["fifo_queue"], [[batch_no, 1, -4.0, "2021-11-10", -40.0]])
def test_batchwise_valuation_stock_reconciliation_with_bundle(self):
from frappe.utils import add_days, getdate, nowdate
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
suffix = frappe.generate_hash(length=8).upper()
item_code = make_item(
f"Test Stock Ageing Batch Reco {suffix}",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": f"SA-RECO-{suffix}-.###",
"valuation_method": "FIFO",
},
).name
warehouse = "_Test Warehouse - _TC"
base_date = nowdate()
opening_reco = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=12,
rate=10,
posting_date=add_days(base_date, -2),
posting_time="10:00:00",
)
batch_no = get_batch_from_bundle(opening_reco.items[0].serial_and_batch_bundle)
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=5,
rate=10,
batch_no=batch_no,
posting_date=add_days(base_date, -1),
posting_time="10:00:00",
)
filters = frappe._dict(
company="_Test Company",
to_date=base_date,
ranges=["30", "60", "90"],
item_code=item_code,
)
slots = FIFOSlots(filters).generate()
item_result = slots[item_code]
self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
self.assertEqual(item_result["total_qty"], 5.0)
self.assertEqual(
item_result["fifo_queue"], [[batch_no.upper(), 1, 5.0, getdate(add_days(base_date, -2)), 50.0]]
)
def generate_item_and_item_wh_wise_slots(filters, sle):
"Return results with and without 'show_warehouse_wise_stock'"

View File

@@ -15,7 +15,11 @@ from frappe.utils.nestedset import get_descendants_of
import erpnext
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
from erpnext.stock.report.stock_ageing.stock_ageing import (
FIFOSlots,
get_average_age,
normalize_fifo_queue,
)
from erpnext.stock.utils import add_additional_uom_columns
@@ -123,6 +127,7 @@ class StockBalanceReport:
stock_ageing_data = {"average_age": 0, "earliest_age": 0, "latest_age": 0}
if opening_fifo_queue:
fifo_queue = sorted(filter(_func, opening_fifo_queue), key=_func)
fifo_queue = normalize_fifo_queue(fifo_queue)
if not fifo_queue:
continue

View File

@@ -10,7 +10,11 @@ from frappe import _
from frappe.query_builder.functions import Count
from frappe.utils import cint, flt, getdate
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
from erpnext.stock.report.stock_ageing.stock_ageing import (
FIFOSlots,
get_average_age,
normalize_fifo_queue,
)
from erpnext.stock.report.stock_analytics.stock_analytics import (
get_item_details,
get_items,
@@ -68,6 +72,7 @@ def execute(filters=None):
fifo_queue = item_ageing[item]["fifo_queue"]
average_age = 0.00
if fifo_queue:
fifo_queue = normalize_fifo_queue(fifo_queue)
average_age = get_average_age(fifo_queue, filters["to_date"])
row += [average_age]

View File

@@ -866,15 +866,6 @@ class update_entries_after:
if not self.args.get("sle_id"):
self.get_dynamic_incoming_outgoing_rate(sle)
if (
sle.voucher_type == "Stock Reconciliation"
and (sle.serial_and_batch_bundle)
and sle.voucher_detail_no
and not self.args.get("sle_id")
and sle.is_cancelled == 0
):
self.reset_actual_qty_for_stock_reco(sle)
if (
sle.voucher_type in ["Purchase Receipt", "Purchase Invoice"]
and sle.voucher_detail_no
@@ -1051,31 +1042,6 @@ class update_entries_after:
if not allow_zero_rate:
self.wh_data.valuation_rate = self.get_fallback_rate(sle)
def reset_actual_qty_for_stock_reco(self, sle):
doc = frappe.get_doc("Stock Reconciliation", sle.voucher_no)
doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0)
if sle.actual_qty < 0:
doc.reload()
sle.actual_qty = (
flt(frappe.db.get_value("Stock Reconciliation Item", sle.voucher_detail_no, "current_qty"))
* -1
)
if abs(sle.actual_qty) == 0.0:
sle.is_cancelled = 1
if sle.serial_and_batch_bundle:
for row in doc.items:
if row.name == sle.voucher_detail_no:
row.db_set("current_serial_and_batch_bundle", "")
sabb_doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle)
sabb_doc.voucher_detail_no = None
sabb_doc.voucher_no = None
sabb_doc.cancel()
def calculate_valuation_for_serial_batch_bundle(self, sle):
if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle):
return