Merge pull request #55307 from frappe/version-16-hotfix

chore: release v16
This commit is contained in:
Diptanil Saha
2026-05-27 05:33:15 +05:30
committed by GitHub
73 changed files with 3632 additions and 1862 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

@@ -807,11 +807,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");
@@ -828,15 +831,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

@@ -1238,9 +1238,9 @@ class PaymentEntry(AccountsController):
else:
remarks = [
_("Amount {0} {1} {2} {3}").format(
_(self.paid_to_account_currency)
_(self.paid_from_account_currency)
if self.payment_type == "Receive"
else _(self.paid_from_account_currency),
else _(self.paid_to_account_currency),
self.paid_amount if self.payment_type == "Receive" else self.received_amount,
_("received from") if self.payment_type == "Receive" else _("paid to"),
self.party,
@@ -1256,7 +1256,7 @@ class PaymentEntry(AccountsController):
for d in self.get("references"):
if d.allocated_amount:
remarks.append(
_("Amount {0} {1} against {2} {3}").format(
_("Amount {0} {1} adjusted against {2} {3}").format(
_(self.party_account_currency),
d.allocated_amount,
d.reference_doctype,
@@ -1267,7 +1267,7 @@ class PaymentEntry(AccountsController):
for d in self.get("deductions"):
if d.amount:
remarks.append(
_("Amount {0} {1} deducted against {2}").format(
_("Amount {0} {1} as adjustment to {2}").format(
_(self.company_currency), d.amount, d.account
)
)

View File

@@ -3,7 +3,8 @@
import frappe
from frappe import qb
from frappe.utils import nowdate
from frappe.query_builder.functions import Count, Sum
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
@@ -90,6 +91,7 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
posting_date = nowdate()
sinv = create_sales_invoice(
posting_date=posting_date,
qty=qty,
rate=rate,
company=self.company,
@@ -531,3 +533,82 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
# with references removed, deletion should be possible
so.delete()
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
@ERPNextTestSuite.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

@@ -8,6 +8,7 @@ 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
from erpnext.tests.utils import ERPNextTestSuite
@@ -334,6 +335,48 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
return pcv
@ERPNextTestSuite.change_settings(
"Accounts Settings",
{"enable_immutable_ledger": 1},
)
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
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

@@ -13,52 +13,69 @@
"column_break_9",
"warehouse",
"company_address",
"section_break_15",
"applicable_for_users",
"accounting_tab",
"section_break_11",
"payments",
"set_grand_total_to_default_mop",
"price_list_and_currency_section",
"currency",
"column_break_bptt",
"selling_price_list",
"write_off_section",
"write_off_account",
"column_break_ukpz",
"write_off_cost_center",
"column_break_pkca",
"write_off_limit",
"income_and_expense_account",
"income_account",
"column_break_byzk",
"expense_account",
"taxes_section",
"taxes_and_charges",
"column_break_cjpp",
"tax_category",
"section_break_19",
"account_for_change_amount",
"disable_rounded_total",
"column_break_23",
"apply_discount_on",
"allow_partial_payment",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"pos_configurations_tab",
"section_break_14",
"hide_images",
"hide_unavailable_items",
"auto_add_item_to_cart",
"validate_stock_on_save",
"print_receipt_on_order_complete",
"action_on_new_invoice",
"validate_stock_on_save",
"column_break_16",
"update_stock",
"ignore_pricing_rule",
"print_receipt_on_order_complete",
"pos_item_selector_section",
"hide_images",
"column_break_rpny",
"hide_unavailable_items",
"column_break_stcl",
"auto_add_item_to_cart",
"pos_item_details_section",
"allow_rate_change",
"column_break_hwfg",
"allow_discount_change",
"set_grand_total_to_default_mop",
"allow_partial_payment",
"section_break_15",
"applicable_for_users",
"section_break_23",
"item_groups",
"column_break_25",
"customer_groups",
"more_info_tab",
"section_break_16",
"print_format",
"letter_head",
"column_break0",
"tc_name",
"select_print_heading",
"section_break_19",
"selling_price_list",
"currency",
"write_off_account",
"write_off_cost_center",
"write_off_limit",
"account_for_change_amount",
"disable_rounded_total",
"column_break_23",
"income_account",
"expense_account",
"taxes_and_charges",
"tax_category",
"apply_discount_on",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
"project",
"utm_analytics_section",
"utm_source",
"column_break_tvls",
@@ -133,8 +150,7 @@
},
{
"fieldname": "section_break_14",
"fieldtype": "Section Break",
"label": "Configuration"
"fieldtype": "Section Break"
},
{
"description": "Only show Items from these Item Groups",
@@ -155,6 +171,7 @@
"options": "POS Customer Group"
},
{
"collapsible": 1,
"fieldname": "section_break_16",
"fieldtype": "Section Break",
"label": "Print Settings"
@@ -194,7 +211,7 @@
{
"fieldname": "section_break_19",
"fieldtype": "Section Break",
"label": "Accounting"
"label": "Miscellaneous"
},
{
"fieldname": "selling_price_list",
@@ -430,6 +447,7 @@
},
{
"default": "0",
"description": "Applicable on POS Invoice",
"fieldname": "allow_partial_payment",
"fieldtype": "Check",
"label": "Allow Partial Payment"
@@ -447,6 +465,83 @@
"fieldname": "utm_analytics_section",
"fieldtype": "Section Break",
"label": "Campaign"
},
{
"fieldname": "accounting_tab",
"fieldtype": "Tab Break",
"label": "Accounting"
},
{
"fieldname": "more_info_tab",
"fieldtype": "Tab Break",
"label": "More Info"
},
{
"fieldname": "pos_configurations_tab",
"fieldtype": "Tab Break",
"label": "POS Configurations"
},
{
"fieldname": "price_list_and_currency_section",
"fieldtype": "Section Break",
"label": "Price List & Currency"
},
{
"fieldname": "column_break_bptt",
"fieldtype": "Column Break"
},
{
"fieldname": "write_off_section",
"fieldtype": "Section Break",
"label": "Write Off"
},
{
"fieldname": "column_break_ukpz",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_pkca",
"fieldtype": "Column Break"
},
{
"fieldname": "income_and_expense_account",
"fieldtype": "Section Break",
"label": "Income and Expense"
},
{
"fieldname": "column_break_byzk",
"fieldtype": "Column Break"
},
{
"fieldname": "taxes_section",
"fieldtype": "Section Break",
"label": "Taxes"
},
{
"fieldname": "column_break_cjpp",
"fieldtype": "Column Break"
},
{
"fieldname": "pos_item_selector_section",
"fieldtype": "Section Break",
"label": "POS Item Selector"
},
{
"fieldname": "column_break_rpny",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_stcl",
"fieldtype": "Column Break"
},
{
"fieldname": "pos_item_details_section",
"fieldtype": "Section Break",
"label": "POS Item Details"
},
{
"fieldname": "column_break_hwfg",
"fieldtype": "Column Break"
}
],
"grid_page_length": 50,
@@ -475,7 +570,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2026-02-10 14:24:48.597412",
"modified": "2026-05-26 12:07:48.597412",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",

View File

@@ -137,9 +137,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")
@@ -173,6 +174,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):
@@ -288,7 +292,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

@@ -1921,7 +1921,7 @@
{
"default": "0",
"depends_on": "eval: !doc.is_return",
"description": "Issue a debit note with 0 qty against an existing Sales Invoice",
"description": "Issue a debit note against an existing Sales Invoice to adjust the rate. The quantity will be retained from the original invoice.",
"fieldname": "is_debit_note",
"fieldtype": "Check",
"label": "Is Rate Adjustment Entry (Debit Note)"
@@ -2367,7 +2367,7 @@
"link_fieldname": "consolidated_invoice"
}
],
"modified": "2026-05-01 02:37:29.742764",
"modified": "2026-05-21 17:31:11.190958",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice",

View File

@@ -968,9 +968,6 @@ class SalesInvoice(SellingController):
if selling_price_list:
self.set("selling_price_list", selling_price_list)
if not for_validate:
self.update_stock = cint(pos.get("update_stock"))
# set pos values in items
for item in self.get("items"):
if item.get("item_code"):
@@ -981,6 +978,10 @@ class SalesInvoice(SellingController):
if (not for_validate) or (for_validate and not item.get(fname)):
item.set(fname, val)
if not for_validate:
dn_flag = any(d.get("dn_detail") for d in self.get("items"))
self.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
# fetch terms
if self.tc_name and not self.terms:
self.terms = frappe.db.get_value("Terms and Conditions", self.tc_name, "terms")

View File

@@ -14,6 +14,7 @@ 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):
@@ -176,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
@@ -236,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

@@ -62,6 +62,117 @@ class TestTaxRule(ERPNextTestSuite):
"_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

@@ -431,6 +431,8 @@ def make_entry(args, adv_adj, update_outstanding, from_repost=False):
gle.flags.adv_adj = adv_adj
gle.flags.update_outstanding = update_outstanding or "Yes"
gle.flags.notify_update = False
if gle.is_cancelled or is_immutable_ledger_enabled():
gle.flags.ignore_links = True
gle.submit()
if (
@@ -717,7 +719,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

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

@@ -40,7 +40,7 @@ import erpnext
from erpnext.accounts.doctype.account.account import get_account_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
from erpnext.stock import get_warehouse_account_map
from erpnext.stock.utils import get_stock_value_on
from erpnext.stock.utils import get_combine_datetime, get_stock_value_on
if TYPE_CHECKING:
from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import RepostItemValuation
@@ -1752,31 +1752,31 @@ def sort_stock_vouchers_by_posting_date(
def get_future_stock_vouchers(posting_date, posting_time, for_warehouses=None, for_items=None, company=None):
values = []
condition = ""
posting_datetime = get_combine_datetime(posting_date, posting_time)
SLE = DocType("Stock Ledger Entry")
query = (
frappe.qb.from_(SLE)
.select(SLE.voucher_type, SLE.voucher_no)
.distinct()
.where(SLE.posting_datetime >= posting_datetime)
.where(SLE.is_cancelled == 0)
.orderby(SLE.posting_datetime)
.orderby(SLE.creation)
.for_update()
)
if for_items:
condition += " and item_code in ({})".format(", ".join(["%s"] * len(for_items)))
values += for_items
query = query.where(SLE.item_code.isin(for_items))
if for_warehouses:
condition += " and warehouse in ({})".format(", ".join(["%s"] * len(for_warehouses)))
values += for_warehouses
query = query.where(SLE.warehouse.isin(for_warehouses))
if company:
condition += " and company = %s"
values.append(company)
query = query.where(SLE.company == company)
future_stock_vouchers = frappe.db.sql(
f"""select distinct sle.voucher_type, sle.voucher_no
from `tabStock Ledger Entry` sle
where
timestamp(sle.posting_date, sle.posting_time) >= timestamp(%s, %s)
and is_cancelled = 0
{condition}
order by timestamp(sle.posting_date, sle.posting_time) asc, creation asc for update""",
tuple([posting_date, posting_time, *values]),
as_dict=True,
)
future_stock_vouchers = query.run(as_dict=True)
return [(d.voucher_type, d.voucher_no) for d in future_stock_vouchers]
@@ -2130,8 +2130,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()
@@ -2220,6 +2221,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)
@@ -2236,9 +2238,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

@@ -551,7 +551,9 @@ frappe.ui.form.on("Asset", {
asset_type: function (frm) {
if (frm.doc.docstatus == 0) {
if (frm.doc.asset_type == "Composite Asset") {
frm.set_value("net_purchase_amount", 0);
if (!frm.doc.net_purchase_amount) {
frm.set_value("net_purchase_amount", 0);
}
} else {
frm.set_df_property("net_purchase_amount", "read_only", 0);
}

View File

@@ -72,6 +72,7 @@ from erpnext.stock.get_item_details import (
ItemDetailsCtx,
_get_item_tax_template,
_get_item_tax_template_from_item_group,
get_bin_details,
get_conversion_factor,
get_item_details,
get_item_tax_map,
@@ -3737,6 +3738,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
child_item.warehouse = get_item_warehouse_(p_doc, item, 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 ItemDetailsCtx, _get_item_tax_template
from erpnext.stock.utils import get_combine_datetime
# searches for active employees
@@ -210,16 +211,28 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
if filters and isinstance(filters, dict):
if filters.get("customer") or filters.get("supplier"):
party_type = "Customer" if filters.get("customer") else "Supplier"
party = filters.get("customer") or filters.get("supplier")
group = "Customer Group" if filters.get("customer") else "Supplier Group"
item_rules_list = frappe.get_all(
"Party Specific Item",
filters={
"party": ["!=", party],
"party_type": "Customer" if filters.get("customer") else "Supplier",
"party_type": party_type,
},
fields=["restrict_based_on", "based_on_value"],
)
party_group_rules_list = frappe.get_all(
"Party Specific Item",
filters={"party_type": group},
fields=["party as party_group", "restrict_based_on", "based_on_value"],
)
current_party_group = frappe.get_value(party_type, party, frappe.scrub(group))
for rule in party_group_rules_list:
if current_party_group != rule.party_group:
item_rules_list.append(rule)
filters_dict = {}
for rule in item_rules_list:
if rule["restrict_based_on"] == "Item":
@@ -484,6 +497,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()))
@@ -537,6 +557,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

@@ -579,6 +579,7 @@ class SellingController(StockController):
or (
get_valuation_method(d.item_code, self.company) == "Moving Average"
and self.get("is_return")
and not is_standalone
)
):
d.incoming_rate = get_incoming_rate(

View File

@@ -281,10 +281,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 (

View File

@@ -651,20 +651,25 @@ class SubcontractingInwardController:
).update_manufacturing_qty_fields()
elif self.purpose in ["Subcontracting Delivery", "Subcontracting Return"]:
fieldname = "delivered_qty" if self.purpose == "Subcontracting Delivery" else "returned_qty"
qty_map = defaultdict(lambda: defaultdict(float))
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item"
)
frappe.db.set_value(
doctype,
item.scio_detail,
fieldname,
frappe.get_value(doctype, item.scio_detail, fieldname)
+ (item.transfer_qty if self._action == "submit" else -item.transfer_qty),
qty_map[doctype][item.scio_detail] += (
item.transfer_qty if self._action == "submit" else -item.transfer_qty
)
for doctype, item_qty_map in qty_map.items():
table = frappe.qb.DocType(doctype)
field = table[fieldname]
doc_updates = {
scio_detail: {fieldname: field + qty} for scio_detail, qty in item_qty_map.items()
}
frappe.db.bulk_update(doctype, doc_updates, chunk_size=len(doc_updates))
def update_inward_order_received_items(self):
if self.subcontracting_inward_order:
match self.purpose:
@@ -679,14 +684,18 @@ class SubcontractingInwardController:
else -item.transfer_qty
for item in self.items
}
case_expr = Case()
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
for scio_rm_name, qty in scio_rm_names.items():
case_expr = case_expr.when(table.name == scio_rm_name, table.returned_qty + qty)
frappe.qb.update(table).set(table.returned_qty, case_expr).where(
(table.name.isin(list(scio_rm_names.keys()))) & (table.docstatus == 1)
).run()
doc_updates = {
scio_rm_name: {"returned_qty": table.returned_qty + qty}
for scio_rm_name, qty in scio_rm_names.items()
}
if doc_updates:
frappe.db.bulk_update(
"Subcontracting Inward Order Received Item",
doc_updates,
chunk_size=len(doc_updates),
update_modified=False,
)
def update_inward_order_received_items_for_raw_materials_receipt(self):
data = frappe._dict()
@@ -737,9 +746,7 @@ class SubcontractingInwardController:
fields=["rate", "name", "required_qty", "received_qty"],
)
deleted_docs = []
table = frappe.qb.DocType("Subcontracting Inward Order Received Item")
case_expr_qty, case_expr_rate = Case(), Case()
doc_updates = {}
for d in result:
current_qty = flt(data[d.name].transfer_qty) * (1 if self._action == "submit" else -1)
current_rate = flt(data[d.name].rate)
@@ -754,16 +761,17 @@ class SubcontractingInwardController:
)
if not d.required_qty and not d.received_qty:
deleted_docs.append(d.name)
frappe.delete_doc("Subcontracting Inward Order Received Item", d.name)
else:
case_expr_qty = case_expr_qty.when(table.name == d.name, d.received_qty)
case_expr_rate = case_expr_rate.when(table.name == d.name, d.rate)
doc_updates[d.name] = {"received_qty": d.received_qty, "rate": d.rate}
if final_list := list(set(data.keys()) - set(deleted_docs)):
frappe.qb.update(table).set(table.received_qty, case_expr_qty).set(
table.rate, case_expr_rate
).where((table.name.isin(final_list)) & (table.docstatus == 1)).run()
if doc_updates:
frappe.db.bulk_update(
"Subcontracting Inward Order Received Item",
doc_updates,
chunk_size=len(doc_updates),
update_modified=False,
)
def update_inward_order_received_items_for_manufacture(self):
customer_warehouse = frappe.get_cached_value(
@@ -815,8 +823,8 @@ class SubcontractingInwardController:
)
if data := data.run(as_dict=True):
deleted_docs, used_item_wh = [], []
case_expr = Case()
used_item_wh = []
doc_updates = {}
for d in data:
if not d.warehouse:
d.warehouse = next(
@@ -828,15 +836,17 @@ class SubcontractingInwardController:
qty = d.consumed_qty + item_code_wh[(d.rm_item_code, d.warehouse)]
if qty or d.is_customer_provided_item or not d.is_additional_item:
case_expr = case_expr.when((table.name == d.name), qty)
doc_updates[d.name] = {"consumed_qty": qty}
else:
deleted_docs.append(d.name)
frappe.delete_doc("Subcontracting Inward Order Received Item", d.name)
if final_list := list(set([d.name for d in data]) - set(deleted_docs)):
frappe.qb.update(table).set(table.consumed_qty, case_expr).where(
(table.name.isin(final_list)) & (table.docstatus == 1)
).run()
if doc_updates:
frappe.db.bulk_update(
"Subcontracting Inward Order Received Item",
doc_updates,
chunk_size=len(doc_updates),
update_modified=False,
)
main_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code
for extra_item in [
@@ -908,27 +918,25 @@ class SubcontractingInwardController:
for d in result
}
)
deleted_docs = []
case_expr = Case()
table = frappe.qb.DocType("Subcontracting Inward Order Secondary Item")
doc_updates = {}
for key, value in secondary_items_dict.items():
if (
self._action == "cancel"
and value.produced_qty - abs(secondary_items.get(key)) == 0
):
deleted_docs.append(value.name)
frappe.delete_doc("Subcontracting Inward Order Secondary Item", value.name)
else:
case_expr = case_expr.when(
table.name == value.name, value.produced_qty + secondary_items.get(key)
)
doc_updates[value.name] = {
"produced_qty": value.produced_qty + secondary_items.get(key)
}
if final_list := list(
set([v.name for v in secondary_items_dict.values()]) - set(deleted_docs)
):
frappe.qb.update(table).set(table.produced_qty, case_expr).where(
(table.name.isin(final_list)) & (table.docstatus == 1)
).run()
if doc_updates:
frappe.db.bulk_update(
"Subcontracting Inward Order Secondary Item",
doc_updates,
chunk_size=len(doc_updates),
update_modified=False,
)
fg_item_code = next(fg for fg in self.items if fg.is_finished_item).item_code
for secondary_item in [

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,
@@ -18,6 +22,19 @@ class TestItemVariant(ERPNextTestSuite):
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
}

File diff suppressed because it is too large Load Diff

View File

@@ -1638,12 +1638,12 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=
)
def add_operating_cost_component_wise(
stock_entry, work_order=None, consumed_operating_cost=None, op_expense_account=None, job_card=None
):
def add_operating_cost_component_wise(stock_entry, work_order=None, op_expense_account=None, job_card=None):
if not work_order:
return False
from erpnext.stock.doctype.stock_entry.stock_entry import get_consumed_operating_cost
cost_added = False
for row in work_order.operations:
if job_card and job_card.operation_id != row.name:
@@ -1661,18 +1661,32 @@ def add_operating_cost_component_wise(
},
)
consumed_operating_cost = (
get_consumed_operating_cost(work_order.name, stock_entry.bom_no, row.name) or []
)
for wc in workstation_cost:
expense_account = (
get_component_account(wc.operating_component, stock_entry.company) or op_expense_account
)
consumed_op_cost = next(
(
cost
for cost in consumed_operating_cost
if cost.get("operating_component") == wc.operating_component
),
{},
)
actual_cp_operating_cost = flt(
flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0) - consumed_operating_cost,
flt(wc.operating_cost) * flt(flt(row.actual_operation_time) / 60.0)
- flt(consumed_op_cost.get("consumed_cost")),
row.precision("actual_operating_cost"),
)
per_unit_cost = flt(actual_cp_operating_cost) / flt(row.completed_qty - work_order.produced_qty)
remaining_qty = row.completed_qty - consumed_op_cost.get("consumed_qty", 0)
per_unit_cost = actual_cp_operating_cost / (remaining_qty or 1)
operating_cost = per_unit_cost * stock_entry.fg_completed_qty
if per_unit_cost:
if actual_cp_operating_cost:
stock_entry.append(
"additional_costs",
{
@@ -1680,8 +1694,14 @@ def add_operating_cost_component_wise(
"description": _("{0} Operating Cost for operation {1}").format(
wc.operating_component, row.operation
),
"amount": per_unit_cost * flt(stock_entry.fg_completed_qty),
"amount": flt(
min(operating_cost, actual_cp_operating_cost),
frappe.get_precision("Landed Cost Taxes and Charges", "amount"),
),
"has_operating_cost": 1,
"operation_id": row.name,
"operating_component": wc.operating_component,
"qty": min(remaining_qty, stock_entry.fg_completed_qty),
},
)
@@ -1699,17 +1719,15 @@ def get_component_account(parent, company):
def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_card=None):
from erpnext.stock.doctype.stock_entry.stock_entry import (
get_consumed_operating_cost,
get_operating_cost_per_unit,
get_remaining_operating_cost,
)
operating_cost_per_unit = get_operating_cost_per_unit(work_order, stock_entry.bom_no)
remaining_operating_cost = get_remaining_operating_cost(work_order, stock_entry.bom_no)
if operating_cost_per_unit:
if remaining_operating_cost:
cost_added = add_operating_cost_component_wise(
stock_entry,
work_order,
get_consumed_operating_cost(work_order.name, stock_entry.bom_no),
expense_account,
job_card=job_card,
)
@@ -1720,7 +1738,10 @@ def add_operations_cost(stock_entry, work_order=None, expense_account=None, job_
{
"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(
remaining_operating_cost * stock_entry.fg_completed_qty,
frappe.get_precision("Landed Cost Taxes and Charges", "amount"),
),
"has_operating_cost": 1,
},
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,40 @@
{
"actions": [],
"allow_bulk_edit": 1,
"autoname": "naming_series:",
"creation": "2026-03-31 21:06:16.282931",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"section_break_smqo",
"job_card_dashboard",
"section_break_fsba",
"work_order",
"column_break_uqjq",
"production_item",
"column_break_qrpg",
"for_quantity",
"column_break_yecz",
"bom_no",
"section_break_oisd",
"company",
"naming_series",
"work_order",
"employee",
"column_break_4",
"posting_date",
"project",
"bom_no",
"is_subcontracted",
"semi_finished_good__finished_good_section",
"finished_good",
"production_item",
"semi_fg_bom",
"total_completed_qty",
"column_break_mcnb",
"for_quantity",
"transferred_qty",
"manufactured_qty",
"semi_fg_bom",
"section_break_folk",
"pending_qty",
"column_break_cyjw",
"process_loss_qty",
"total_completed_qty",
"section_break_wpjf",
"transferred_qty",
"column_break_lgte",
"manufactured_qty",
"production_section",
"operation",
"source_warehouse",
@@ -35,6 +45,7 @@
"workstation_type",
"workstation",
"target_warehouse",
"employee",
"section_break_8",
"items",
"quality_inspection_section",
@@ -71,8 +82,10 @@
"item_name",
"requested_qty",
"is_paused",
"is_subcontracted",
"track_semi_finished_goods",
"column_break_20",
"project",
"remarks",
"section_break_dfoc",
"status",
@@ -155,6 +168,7 @@
"fieldname": "wip_warehouse",
"fieldtype": "Link",
"label": "WIP Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"mandatory_depends_on": "eval:!doc.finished_good || doc.skip_material_transfer === 0 || (doc.skip_material_transfer && doc.backflush_from_wip_warehouse)",
"options": "Warehouse"
},
@@ -506,6 +520,7 @@
"fieldname": "target_warehouse",
"fieldtype": "Link",
"label": "Target Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"mandatory_depends_on": "eval:doc.track_semi_finished_goods",
"options": "Warehouse"
},
@@ -518,6 +533,7 @@
"fieldname": "source_warehouse",
"fieldtype": "Link",
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse"
},
{
@@ -622,12 +638,64 @@
"fieldname": "secondary_items_section",
"fieldtype": "Tab Break",
"label": "Secondary Items"
},
{
"fieldname": "section_break_folk",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "column_break_cyjw",
"fieldtype": "Column Break"
},
{
"allow_on_submit": 1,
"fieldname": "pending_qty",
"fieldtype": "Float",
"label": "Pending Qty"
},
{
"fieldname": "section_break_wpjf",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_lgte",
"fieldtype": "Column Break"
},
{
"fieldname": "job_card_dashboard",
"fieldtype": "HTML"
},
{
"fieldname": "section_break_oisd",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_uqjq",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_qrpg",
"fieldtype": "Column Break"
},
{
"fieldname": "column_break_yecz",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_smqo",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"fieldname": "section_break_fsba",
"fieldtype": "Section Break"
}
],
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-03-31 21:06:48.987740",
"modified": "2026-05-21 18:37:05.688342",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -103,6 +103,7 @@ class JobCard(Document):
operation_id: DF.Data | None
operation_row_id: DF.Int
operation_row_number: DF.Literal[None]
pending_qty: DF.Float
posting_date: DF.Date | None
process_loss_qty: DF.Float
production_item: DF.Link | None
@@ -881,7 +882,9 @@ class JobCard(Document):
precision = self.precision("total_completed_qty")
total_completed_qty = flt(
flt(self.total_completed_qty, precision) + flt(self.process_loss_qty, precision)
flt(self.total_completed_qty, precision)
+ flt(self.process_loss_qty, precision)
+ flt(self.pending_qty, precision)
)
if self.for_quantity and flt(total_completed_qty, precision) != flt(self.for_quantity, precision):
@@ -928,8 +931,10 @@ class JobCard(Document):
self.process_loss_qty = 0.0
if self.total_completed_qty and self.for_quantity > self.total_completed_qty:
self.process_loss_qty = flt(self.for_quantity, precision) - flt(
self.total_completed_qty, precision
self.process_loss_qty = (
flt(self.for_quantity, precision)
- flt(self.total_completed_qty, precision)
- flt(self.pending_qty, precision)
)
def update_work_order(self):
@@ -943,13 +948,14 @@ class JobCard(Document):
):
return
for_quantity, time_in_mins, process_loss_qty = 0, 0, 0
for_quantity, time_in_mins, process_loss_qty, pending_qty = 0, 0, 0, 0
data = self.get_current_operation_data()
if data and len(data) > 0:
for_quantity = flt(data[0].completed_qty)
time_in_mins = flt(data[0].time_in_mins)
process_loss_qty = flt(data[0].process_loss_qty)
pending_qty = flt(data[0].pending_qty)
wo = frappe.get_doc("Work Order", self.work_order)
@@ -957,8 +963,8 @@ class JobCard(Document):
self.update_corrective_in_work_order(wo)
elif self.operation_id:
self.validate_produced_quantity(for_quantity, process_loss_qty, wo)
self.update_work_order_data(for_quantity, process_loss_qty, time_in_mins, wo)
self.validate_produced_quantity(for_quantity, process_loss_qty, pending_qty, wo)
self.update_work_order_data(for_quantity, process_loss_qty, pending_qty, time_in_mins, wo)
def update_semi_finished_good_details(self):
if self.operation_id:
@@ -987,11 +993,11 @@ class JobCard(Document):
wo.flags.ignore_validate_update_after_submit = True
wo.save()
def validate_produced_quantity(self, for_quantity, process_loss_qty, wo):
def validate_produced_quantity(self, for_quantity, process_loss_qty, pending_qty, wo):
if self.docstatus < 2:
return
if wo.produced_qty > for_quantity + process_loss_qty:
if wo.produced_qty > for_quantity + process_loss_qty + pending_qty:
first_part_msg = _(
"The {0} {1} is used to calculate the valuation cost for the finished good {2}."
).format(frappe.bold(_("Job Card")), frappe.bold(self.name), frappe.bold(self.production_item))
@@ -1004,7 +1010,7 @@ class JobCard(Document):
_("{0} {1}").format(first_part_msg, second_part_msg), JobCardCancelError, title=_("Error")
)
def update_work_order_data(self, for_quantity, process_loss_qty, time_in_mins, wo):
def update_work_order_data(self, for_quantity, process_loss_qty, pending_qty, time_in_mins, wo):
workstation_hour_rate = frappe.get_value("Workstation", self.workstation, "hour_rate")
jc = frappe.qb.DocType("Job Card")
jctl = frappe.qb.DocType("Job Card Time Log")
@@ -1026,6 +1032,7 @@ class JobCard(Document):
if data.get("name") == self.operation_id:
data.completed_qty = for_quantity
data.process_loss_qty = process_loss_qty
data.pending_qty = pending_qty
data.actual_operation_time = time_in_mins
data.actual_start_time = time_data[0].start_time if time_data else None
data.actual_end_time = time_data[0].end_time if time_data else None
@@ -1051,6 +1058,7 @@ class JobCard(Document):
{"SUM": "total_time_in_mins", "as": "time_in_mins"},
{"SUM": "total_completed_qty", "as": "completed_qty"},
{"SUM": "process_loss_qty", "as": "process_loss_qty"},
{"SUM": "pending_qty", "as": "pending_qty"},
],
filters={
"docstatus": 1,
@@ -1445,10 +1453,19 @@ class JobCard(Document):
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
if kwargs.end_time:
if kwargs.for_quantity:
self.for_quantity = kwargs.for_quantity
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) < 0:
frappe.throw(_("Pending quantity cannot be negative."))
if flt(kwargs.process_loss_qty) and flt(kwargs.process_loss_qty) < 0:
frappe.throw(_("Process loss quantity cannot be negative."))
if flt(kwargs.pending_qty) and flt(kwargs.pending_qty) > self.for_quantity:
frappe.throw(_("Pending quantity cannot be greater than the for quantity."))
self.pending_qty = flt(kwargs.pending_qty)
self.process_loss_qty = flt(kwargs.process_loss_qty)
if kwargs.end_time:
self.add_time_logs(
to_time=kwargs.end_time,
completed_qty=kwargs.qty,

View File

@@ -720,6 +720,7 @@ class TestJobCard(ERPNextTestSuite):
)
jc.time_logs[0].completed_qty = 8
jc.pending_qty = 0.0
jc.save()
jc.submit()
@@ -1080,6 +1081,243 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(s.items[3].item_code, "_Test Item")
self.assertEqual(s.items[3].transfer_qty, 2)
@ERPNextTestSuite.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
workstation = make_workstation(
workstation_name="Test Workstation for Overproduction", hour_rate_rent=10, hour_rate_labour=10
)
operations = [
{"operation": "Test Operation 1", "workstation": workstation.name, "time_in_mins": 30},
{"operation": "Test Operation 2", "workstation": workstation.name, "time_in_mins": 30},
]
warehouse = create_warehouse("Test Warehouse for Overproduction")
setup_operations(operations)
fg = make_item("Test FG for Overproduction", {"stock_uom": "Nos", "is_stock_item": 1})
rm = make_item("Test RM for Overproduction", {"stock_uom": "Nos", "is_stock_item": 1})
routing_doc = create_routing(routing_name="Testing Route", 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 = add_to_date(now(), days=1)
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 = add_to_date(now(), days=2)
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()
self.assertEqual(s.additional_costs[0].amount, 240)
self.assertEqual(s.additional_costs[1].amount, 240)
self.assertEqual(s.additional_costs[2].amount, 480)
self.assertEqual(s.additional_costs[3].amount, 480)
make_job_card(
wo_doc.name,
[
{
"name": wo_doc.operations[0].name,
"operation": "Test Operation 1",
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = add_to_date(now(), days=4)
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": "Test Operation 2",
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = add_to_date(now(), days=5)
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()
self.assertEqual(s2.additional_costs[0].amount, 120)
self.assertEqual(s2.additional_costs[1].amount, 120)
self.assertEqual(s2.additional_costs[2].amount, 240)
self.assertEqual(s2.additional_costs[3].amount, 240)
make_job_card(
wo_doc.name,
[
{
"name": wo_doc.operations[0].name,
"operation": "Test Operation 1",
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = add_to_date(now(), days=7)
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": "Test Operation 2",
"qty": 2,
"pending_qty": 2,
}
],
)
job_card = frappe.get_last_doc("Job Card", {"work_order": wo_doc.name})
from_time = add_to_date(now(), days=8)
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()
s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 2))
s.submit()
self.assertEqual(s.additional_costs[0].amount, 240)
self.assertEqual(s.additional_costs[1].amount, 240)
self.assertEqual(s.additional_costs[2].amount, 480)
self.assertEqual(s.additional_costs[3].amount, 480)
s2.cancel()
s = frappe.get_doc(make_stock_entry_for_wo(wo_doc.name, "Manufacture", 3))
s.submit()
self.assertEqual(s.additional_costs[0].amount, 240)
self.assertEqual(s.additional_costs[1].amount, 240)
self.assertEqual(s.additional_costs[2].amount, 480)
self.assertEqual(s.additional_costs[3].amount, 480)
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,
@@ -34,6 +35,7 @@
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse"
},
{
@@ -113,7 +115,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-12-04 14:30:19.472294",
"modified": "2026-05-12 12:22:18.506904",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card Item",

View File

@@ -1315,6 +1315,7 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p
item_uom.conversion_factor,
item.safety_stock,
bom.item.as_("main_bom_item"),
bom.name.as_("main_bom"),
)
.where(
(bei.docstatus < 2)
@@ -1384,6 +1385,7 @@ def get_subitems(
item.purchase_uom,
item_uom.conversion_factor,
bom.item.as_("main_bom_item"),
bom.name.as_("main_bom"),
bom_item.is_phantom_item,
)
.where(

View File

@@ -47,11 +47,3 @@ frappe.ui.form.on("Sales Forecast", {
}
},
});
frappe.ui.form.on("Sales Forecast Item", {
adjust_qty(frm, cdt, cdn) {
let row = locals[cdt][cdn];
row.demand_qty = row.forecast_qty + row.adjust_qty;
frappe.model.set_value(cdt, cdn, "demand_qty", row.demand_qty);
},
});

View File

@@ -10,8 +10,6 @@
"item_name",
"uom",
"delivery_date",
"forecast_qty",
"adjust_qty",
"demand_qty",
"warehouse"
],
@@ -55,22 +53,6 @@
"label": "Delivery Date",
"read_only": 1
},
{
"columns": 2,
"fieldname": "forecast_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Forecast Qty",
"non_negative": 1,
"read_only": 1
},
{
"columns": 2,
"fieldname": "adjust_qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Adjust Qty"
},
{
"columns": 3,
"fieldname": "demand_qty",
@@ -94,7 +76,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-08-18 21:59:38.859082",
"modified": "2026-05-21 12:38:47.636301",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Sales Forecast Item",

View File

@@ -14,10 +14,8 @@ class SalesForecastItem(Document):
if TYPE_CHECKING:
from frappe.types import DF
adjust_qty: DF.Float
delivery_date: DF.Date | None
demand_qty: DF.Float
forecast_qty: DF.Float
item_code: DF.Link
item_name: DF.Data | None
parent: DF.Data

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";
@@ -348,7 +329,7 @@ frappe.ui.form.on("Work Order", {
{
fieldtype: "Data",
fieldname: "name",
label: __("Operation Id"),
label: __("Operation ID"),
},
{
fieldtype: "Float",
@@ -425,6 +406,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",
@@ -266,6 +267,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) && !doc.track_semi_finished_goods",
"options": "Warehouse"
},
@@ -274,6 +276,7 @@
"fieldname": "fg_warehouse",
"fieldtype": "Link",
"label": "Target Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse",
"read_only_depends_on": "subcontracting_inward_order"
},
@@ -286,6 +289,7 @@
"fieldname": "scrap_warehouse",
"fieldtype": "Link",
"label": "Scrap Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse"
},
{
@@ -513,6 +517,7 @@
"fieldname": "source_warehouse",
"fieldtype": "Link",
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse",
"read_only_depends_on": "eval:doc.subcontracting_inward_order"
},
@@ -706,7 +711,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-04-17 13:42:12.374055",
"modified": "2026-05-19 12:20:38.102403",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -167,18 +167,19 @@ class WorkOrder(Document):
self.set_onload("backflush_raw_materials_based_on", based_on)
def show_create_job_card_button(self):
operation_details = frappe._dict(
frappe.get_all(
"Job Card",
fields=["operation", {"SUM": "for_quantity"}],
filters={"docstatus": ("<", 2), "work_order": self.name},
as_list=1,
group_by="operation_id",
)
jc_doctype = frappe.qb.DocType("Job Card")
query = (
frappe.qb.from_(jc_doctype)
.select(jc_doctype.operation_id, Sum(jc_doctype.for_quantity - IfNull(jc_doctype.pending_qty, 0)))
.where((jc_doctype.docstatus < 2) & (jc_doctype.work_order == self.name))
.groupby(jc_doctype.operation_id)
)
operation_details = query.run(as_list=1)
operation_details = frappe._dict(operation_details)
for d in self.operations:
job_card_qty = self.qty - flt(operation_details.get(d.operation))
job_card_qty = self.qty - flt(operation_details.get(d.name))
if job_card_qty > 0:
return True

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2016-04-18 07:38:26.314642",
"doctype": "DocType",
"editable_grid": 1,
@@ -53,6 +54,7 @@
"ignore_user_permissions": 1,
"in_list_view": 1,
"label": "Source Warehouse",
"link_filters": "[[\"Warehouse\",\"disabled\",\"=\",0],[\"Warehouse\",\"is_group\",\"=\",0]]",
"options": "Warehouse",
"read_only_depends_on": "eval:parent.subcontracting_inward_order && doc.is_customer_provided_item"
},
@@ -207,7 +209,7 @@
"grid_page_length": 50,
"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

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2025-04-09 12:12:19.824560",
"doctype": "DocType",
"editable_grid": 1,
@@ -10,6 +11,7 @@
"status",
"completed_qty",
"process_loss_qty",
"pending_qty",
"column_break_4",
"bom",
"workstation_type",
@@ -301,13 +303,20 @@
"fieldname": "quality_inspection_required",
"fieldtype": "Check",
"label": "Quality Inspection Required"
},
{
"fieldname": "pending_qty",
"fieldtype": "Float",
"label": "Pending Qty",
"no_copy": 1,
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-30 17:20:08.874381",
"modified": "2026-05-20 13:01:21.827200",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",

View File

@@ -32,6 +32,7 @@ class WorkOrderOperation(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
pending_qty: DF.Float
planned_end_time: DF.Datetime | None
planned_operating_cost: DF.Currency
planned_start_time: DF.Datetime | None

View File

@@ -482,3 +482,4 @@ erpnext.patches.v16_0.packed_item_inv_dimen
erpnext.patches.v16_0.fix_titles
erpnext.patches.v16_0.set_not_applicable_on_german_item_tax_templates
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

@@ -181,6 +181,7 @@
"fieldtype": "Link",
"in_global_search": 1,
"label": "Customer",
"no_copy": 1,
"oldfieldname": "customer",
"oldfieldtype": "Link",
"options": "Customer",
@@ -195,6 +196,7 @@
"fieldname": "sales_order",
"fieldtype": "Link",
"label": "Sales Order",
"no_copy": 1,
"options": "Sales Order"
},
{
@@ -480,7 +482,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
"modified": "2026-04-14 18:17:40.676750",
"modified": "2026-05-22 16:45:50.762759",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",

View File

@@ -2266,6 +2266,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
for (const [key, value] of Object.entries(child)) {
if (!["doctype", "name"].includes(key)) {
if (key === "price_list_rate") {
const doc = frappe.get_doc(child.doctype, child.name);
if (doc) doc.price_list_rate = value; // silent update so rate trigger uses correct value
frappe.model.set_value(child.doctype, child.name, "rate", value);
}

View File

@@ -483,6 +483,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

@@ -2,6 +2,21 @@
// For license information, please see license.txt
frappe.ui.form.on("Party Specific Item", {
// refresh: function(frm) {
// }
setup: function (frm) {
frm.trigger("party_type");
},
party_type: function (frm) {
if (["Customer Group", "Supplier Group"].includes(frm.doc.party_type)) {
frm.set_query("party", function () {
return {
filters: {
is_group: 0,
},
};
});
} else {
frm.set_query("party", null);
}
},
});

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_import": 1,
"creation": "2021-08-27 19:28:07.559978",
"doctype": "DocType",
@@ -18,7 +19,7 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Party Type",
"options": "Customer\nSupplier",
"options": "Customer\nCustomer Group\nSupplier\nSupplier Group",
"reqd": 1
},
{
@@ -52,7 +53,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2024-03-27 13:10:08.752476",
"modified": "2026-05-17 11:46:17.634855",
"modified_by": "Administrator",
"module": "Selling",
"name": "Party Specific Item",
@@ -71,9 +72,10 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "party",
"track_changes": 1
}
}

View File

@@ -17,7 +17,7 @@ class PartySpecificItem(Document):
based_on_value: DF.DynamicLink
party: DF.DynamicLink
party_type: DF.Literal["Customer", "Supplier"]
party_type: DF.Literal["Customer", "Customer Group", "Supplier", "Supplier Group"]
restrict_based_on: DF.Literal["Item", "Item Group", "Brand"]
# end: auto-generated types

View File

@@ -49,6 +49,23 @@ class TestPartySpecificItem(ERPNextTestSuite):
)
self.assertTrue(item in flatten(items))
def test_party_group(self):
customer = "_Test Customer With Template"
item = "_Test Item"
frappe.set_value("Customer", customer, "customer_group", "Government")
create_party_specific_item(
party_type="Customer Group",
party="Government",
restrict_based_on="Item",
based_on_value=item,
)
filters = {"is_sales_item": 1, "customer": customer}
items = item_query(
doctype="Item", txt="", searchfield="name", start=0, page_len=20, filters=filters, as_dict=False
)
self.assertTrue(item in flatten(items))
def flatten(lst):
result = []

View File

@@ -452,7 +452,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):
@@ -1798,6 +1798,7 @@ def make_work_orders(items, sales_order, company, project=None):
return [p.name for p in out]
@frappe.whitelist()
def make_production_plan(source_name, target_doc=None):
sales_order = frappe.get_doc("Sales Order", source_name)

View File

@@ -75,13 +75,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

@@ -44,7 +44,7 @@ frappe.ui.form.on("Employee", {
},
refresh: function (frm) {
frm.fields_dict.date_of_birth.datepicker.update({ maxDate: new Date() });
frm.fields_dict.date_of_birth.datepicker?.update({ maxDate: new Date() });
if (!frm.is_new() && !frm.doc.user_id) {
frm.add_custom_button(__("Create User"), () => {

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

@@ -25,6 +25,7 @@ def after_install():
setup_repost_defaults()
create_print_setting_custom_fields()
create_marketing_campaign_custom_fields()
create_address_and_contact_custom_fields()
create_custom_company_links()
add_all_roles_to("Administrator")
create_default_success_action()
@@ -145,6 +146,37 @@ def create_marketing_campaign_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_default_success_action():
for success_action in get_default_success_action():
if not frappe.db.exists("Success Action", success_action.get("ref_doctype")):

View File

@@ -543,6 +543,7 @@ class TestBatch(ERPNextTestSuite):
"plc_conversion_rate": 1,
"customer": "_Test Customer",
"name": None,
"qty": 1,
}
)

View File

@@ -286,14 +286,6 @@ frappe.ui.form.on("Item", {
frm.set_df_property(fieldname, "read_only", stock_exists);
});
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) {
@@ -304,10 +296,6 @@ frappe.ui.form.on("Item", {
refresh_field("image_view");
},
is_customer_provided_item: function (frm) {
frm.toggle_reqd("customer", frm.doc.is_customer_provided_item ? 1 : 0);
},
is_fixed_asset: function (frm) {
// set serial no to false & toggles its visibility
frm.set_value("has_serial_no", 0);
@@ -548,12 +536,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,
@@ -790,11 +772,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 {
@@ -831,7 +812,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>`,
},
]
@@ -893,6 +874,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

@@ -31,7 +31,6 @@
"column_break_kpmi",
"is_purchase_item",
"is_customer_provided_item",
"customer",
"section_break_gjns",
"standard_rate",
"column_break_ixrh",
@@ -632,13 +631,6 @@
"read_only_depends_on": "eval: doc.is_purchase_item",
"show_description_on_click": 1
},
{
"depends_on": "eval:doc.is_customer_provided_item",
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"options": "Customer"
},
{
"depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "supplier_details",
@@ -1077,7 +1069,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-04-28 17:31:47.613279",
"modified": "2026-05-26 10:18:46.862670",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -82,7 +82,6 @@ class Item(Document):
brand: DF.Link | None
country_of_origin: DF.Link | None
create_new_batch: DF.Check
customer: DF.Link | None
customer_code: DF.SmallText | None
customer_items: DF.Table[ItemCustomerDetail]
customs_tariff_number: DF.Link | None
@@ -872,8 +871,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

@@ -161,6 +161,7 @@ class TestItem(ERPNextTestSuite):
"conversion_factor": 1,
"price_list_uom_dependant": 1,
"ignore_pricing_rule": 1,
"qty": 1,
}
)
)

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2014-07-11 11:51:00.453717",
"doctype": "DocType",
"editable_grid": 1,
@@ -13,7 +14,10 @@
"amount",
"base_amount",
"has_corrective_cost",
"has_operating_cost"
"has_operating_cost",
"operation_id",
"qty",
"operating_component"
],
"fields": [
{
@@ -78,13 +82,38 @@
"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
},
{
"fieldname": "operating_component",
"fieldtype": "Data",
"hidden": 1,
"label": "Operating Component",
"no_copy": 1,
"read_only": 1
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-07-16 15:27:59.175530",
"modified": "2026-05-19 12:21:07.953801",
"modified_by": "Administrator",
"module": "Stock",
"name": "Landed Cost Taxes and Charges",

View File

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

View File

@@ -1374,7 +1374,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))
@@ -1420,7 +1420,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

@@ -5606,6 +5606,157 @@ class TestPurchaseReceipt(ERPNextTestSuite):
self.assertIn(company.default_inventory_account, gl_accounts)
pr.cancel()
@ERPNextTestSuite.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)
@ERPNextTestSuite.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 create_asset_category_for_pr_test():
category_name = "Test Asset Category for PR"

View File

@@ -9,7 +9,7 @@ import frappe
from frappe import _, bold
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder import DocType
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import Max, Sum
from frappe.utils import (
cint,
comma_or,
@@ -1288,13 +1288,21 @@ class StockEntry(StockController, SubcontractingInwardController):
)
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:
@@ -3895,28 +3903,33 @@ def get_work_order_details(work_order, company):
}
def get_consumed_operating_cost(wo_name, bom_no):
def get_consumed_operating_cost(wo_name, bom_no, operation_id):
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"))
.select(
Sum(child_table.amount).as_("consumed_cost"),
Sum(child_table.qty).as_("consumed_qty"),
child_table.operating_component,
)
.where(
(table.docstatus == 1)
& (table.work_order == wo_name)
& (table.purpose == "Manufacture")
& (table.bom_no == bom_no)
& (child_table.has_operating_cost == 1)
& (child_table.operation_id == operation_id)
)
.groupby(child_table.operation_id, child_table.operating_component)
)
cost = query.run(pluck="consumed_cost")
return cost[0] if cost and cost[0] else 0
return query.run(as_dict=True)
def get_operating_cost_per_unit(work_order=None, bom_no=None):
operating_cost_per_unit = 0
def get_remaining_operating_cost(work_order=None, bom_no=None):
remaining_operating_cost = 0
if work_order:
if (
bom_no
@@ -3931,23 +3944,23 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None):
bom_no = work_order.bom_no
for d in work_order.get("operations"):
consumed_op_cost = get_consumed_operating_cost(work_order.name, bom_no, d.name) or []
cost = 0
for row in consumed_op_cost:
cost += flt(row.consumed_cost)
if flt(d.completed_qty):
if not (remaining_qty := flt(d.completed_qty - work_order.produced_qty)):
continue
operating_cost_per_unit += (
flt(d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no))
/ remaining_qty
)
remaining_operating_cost += flt(d.actual_operating_cost - cost)
elif work_order.qty:
operating_cost_per_unit += flt(d.planned_operating_cost) / flt(work_order.qty)
remaining_operating_cost += flt(d.planned_operating_cost) / flt(work_order.qty)
# Get operating cost from BOM if not found in work_order.
if not operating_cost_per_unit and bom_no:
if not remaining_operating_cost and bom_no:
bom = frappe.db.get_value("BOM", bom_no, ["operating_cost", "quantity"], as_dict=1)
if bom.quantity:
operating_cost_per_unit = flt(bom.operating_cost) / flt(bom.quantity)
remaining_operating_cost = flt(bom.operating_cost) / flt(bom.quantity)
return operating_cost_per_unit
return remaining_operating_cost
def get_used_alternative_items(

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):
@@ -885,7 +888,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),
}
)
@@ -1045,84 +1048,6 @@ class StockReconciliation(StockController):
else:
self._cancel()
def recalculate_current_qty(self, voucher_detail_no, sle_creation, add_new_sle=False):
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

@@ -1044,7 +1044,7 @@ class TestStockReconciliation(ERPNextTestSuite, 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

@@ -320,9 +320,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,
)

View File

@@ -1198,9 +1198,15 @@ def get_item_price(
if not ignore_party:
if pctx.customer:
query = query.where(ip.customer == pctx.customer)
query = query.where(
(ip.customer == pctx.customer)
| ((IfNull(ip.customer, "") == "") & (IfNull(ip.supplier, "") == ""))
).orderby(IfNull(ip.customer, ""), order=frappe.qb.desc)
elif pctx.supplier:
query = query.where(ip.supplier == pctx.supplier)
query = query.where(
(ip.supplier == pctx.supplier)
| ((IfNull(ip.customer, "") == "") & (IfNull(ip.supplier, "") == ""))
).orderby(IfNull(ip.supplier, ""), order=frappe.qb.desc)
else:
query = query.where((IfNull(ip.customer, "") == "") & (IfNull(ip.supplier, "") == ""))
@@ -1258,9 +1264,6 @@ def get_price_list_rate_for(ctx: ItemDetailsCtx, item_code):
if desired_qty and check_packing_list(price_list_rate[0].name, desired_qty, item_code):
item_price_data = price_list_rate
else:
for field in ["customer", "supplier"]:
del pctx[field]
general_price_list_rate = get_item_price(pctx, item_code, ignore_party=ctx.get("ignore_party"))
if not general_price_list_rate and ctx.get("uom") != ctx.get("stock_uom"):

File diff suppressed because it is too large Load Diff

View File

@@ -868,6 +868,555 @@ class TestStockAgeing(ERPNextTestSuite):
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_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

@@ -874,15 +874,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
@@ -1059,31 +1050,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

View File

@@ -3003,6 +3003,9 @@ class ERPNextTestSuite(unittest.TestCase):
def tearDown(self):
frappe.db.rollback()
frappe.local.request_cache.clear()
if hasattr(frappe.local, "future_sle"):
frappe.local.future_sle.clear()
def load_test_records(self, doctype):
if doctype not in self.globalTestRecords:

1
frappe-semgrep-rules Submodule

Submodule frappe-semgrep-rules added at a05bce32ad