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

chore: release v16
This commit is contained in:
Diptanil Saha
2026-08-12 05:08:00 +05:30
committed by GitHub
164 changed files with 47256 additions and 36850 deletions

View File

@@ -71,4 +71,6 @@ def get_shipping_address(company, address=None):
if address:
address_as_dict = address[0]
name, address_template = get_address_templates(address_as_dict)
return address_as_dict.get("name"), frappe.render_template(address_template, address_as_dict)
return address_as_dict.get("name"), frappe.render_template(
address_template, address_as_dict, restrict_globals=True
)

View File

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

View File

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

View File

@@ -162,7 +162,7 @@ class JournalEntry(AccountsController):
JournalTaxWithholding(self).on_validate()
if self.is_new() or not self.title:
if not self.title or (self.is_new() and self.amended_from):
self.title = self.get_title()
def validate_advance_accounts(self):
@@ -906,6 +906,16 @@ class JournalEntry(AccountsController):
)
)
if reference_type == "Purchase Invoice" and invoice.invoice_is_blocked():
msg = (
_("{0} {1} is blocked and on hold until {2}.").format(
invoice.doctype, invoice.name, invoice.release_date
)
if invoice.release_date
else _("{0} {1} is blocked.").format(invoice.doctype, invoice.name)
)
frappe.throw(msg)
def set_against_account(self):
accounts_debited, accounts_credited = [], []
if self.voucher_type in ("Deferred Revenue", "Deferred Expense"):

View File

@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt, nowdate
from frappe.utils import add_days, flt, nowdate
from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.accounts.doctype.journal_entry.journal_entry import StockAccountInvalidTransaction
@@ -609,6 +609,69 @@ class TestJournalEntry(ERPNextTestSuite):
jv.save()
self.assertRaises(frappe.ValidationError, jv.submit)
def make_jv_against_purchase_invoice(self, invoice, amount=100):
jv = make_journal_entry("Creditors - _TC", "_Test Cash - _TC", amount, save=False)
jv.accounts[0].party_type = "Supplier"
jv.accounts[0].party = invoice.supplier
jv.accounts[0].reference_type = "Purchase Invoice"
jv.accounts[0].reference_name = invoice.name
return jv
def test_jv_against_purchase_invoice_respects_hold_state(self):
"""Payment can be booked against a Purchase Invoice only while it is not on hold."""
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
release_date = add_days(nowdate(), 10)
def never_held():
return make_purchase_invoice()
def held_until_a_future_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
return invoice
def held_without_a_release_date():
invoice = make_purchase_invoice()
invoice.block_invoice(hold_comment="Under dispute")
return invoice
def held_until_a_date_that_has_passed():
invoice = held_until_a_future_date()
frappe.db.set_value("Purchase Invoice", invoice.name, "release_date", add_days(nowdate(), -1))
return invoice
def unblocked_again():
invoice = held_until_a_future_date()
invoice.unblock_invoice()
return invoice
for build_invoice in (held_until_a_future_date, held_without_a_release_date):
with self.subTest(build_invoice.__name__):
jv = self.make_jv_against_purchase_invoice(build_invoice())
self.assertRaisesRegex(frappe.ValidationError, "is blocked", jv.insert)
for build_invoice in (never_held, held_until_a_date_that_has_passed, unblocked_again):
with self.subTest(build_invoice.__name__):
invoice = build_invoice()
jv = self.make_jv_against_purchase_invoice(invoice)
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Purchase Invoice")
def test_jv_against_blocked_sales_invoice_reference_is_not_checked(self):
"""A Sales Invoice has no hold state, so the check must skip it rather than fail."""
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
invoice = create_sales_invoice(rate=500)
jv = make_journal_entry("_Test Cash - _TC", "Debtors - _TC", 100, save=False)
jv.accounts[1].party_type = "Customer"
jv.accounts[1].party = "_Test Customer"
jv.accounts[1].reference_type = "Sales Invoice"
jv.accounts[1].reference_name = invoice.name
jv.insert()
self.assertEqual(jv.reference_types[invoice.name], "Sales Invoice")
def make_journal_entry(
account1,

View File

@@ -474,7 +474,7 @@ class PaymentRequest(Document):
}
if self.message:
return frappe.render_template(self.message, context)
return frappe.render_template(self.message, context, restrict_globals=True)
def set_failed(self):
pass

View File

@@ -6,9 +6,10 @@ import copy
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import add_days, flt, formatdate, getdate
from frappe.query_builder.functions import Max, Sum
from frappe.utils import add_days, flt, fmt_money, formatdate, get_link_to_form, getdate
from erpnext import is_perpetual_inventory_enabled
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
make_closing_entries,
)
@@ -18,6 +19,8 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
from erpnext.accounts.general_ledger import check_freezing_date, is_immutable_ledger_enabled
from erpnext.accounts.utils import get_account_currency, get_fiscal_year
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.stock.doctype.stock_closing_entry.stock_closing_entry import apply_unscoped_filters
from erpnext.stock.utils import get_stock_value_on
class PeriodClosingVoucher(AccountsController):
@@ -139,6 +142,121 @@ class PeriodClosingVoucher(AccountsController):
if account_currency != company_currency:
frappe.throw(_("Currency of the Closing Account must be {0}").format(company_currency))
def before_submit(self):
if not self.has_stock_transactions():
return
self.validate_stock_accounts_balance()
self.validate_stock_closing_entry()
def has_stock_transactions(self):
if not is_perpetual_inventory_enabled(self.company):
return False
return bool(
frappe.db.exists(
"Stock Ledger Entry",
{
"company": self.company,
"is_cancelled": 0,
"posting_date": ("<=", self.period_end_date),
},
)
)
def validate_stock_accounts_balance(self):
precision = frappe.get_precision("GL Entry", "debit")
account_balance = flt(self.get_stock_accounts_balance(), precision)
stock_value = flt(
get_stock_value_on(posting_date=self.period_end_date, company=self.company), precision
)
if account_balance == stock_value:
return
currency = frappe.get_cached_value("Company", self.company, "default_currency")
frappe.throw(
_(
"The closing balance {0} of the Stock Asset accounts does not match the closing value {1} of the Stock Balance report as on {2}. Resolve the difference using the Stock Ledger Variance report before closing the period."
).format(
frappe.bold(fmt_money(account_balance, currency=currency)),
frappe.bold(fmt_money(stock_value, currency=currency)),
frappe.bold(formatdate(self.period_end_date)),
),
title=_("Stock Value Mismatch"),
)
def get_stock_accounts_balance(self):
gle = frappe.qb.DocType("GL Entry")
account = frappe.qb.DocType("Account")
stock_accounts = (
frappe.qb.from_(account)
.select(account.name)
.where(
(account.account_type == "Stock")
& (account.company == self.company)
& (account.is_group == 0)
)
)
balance = (
frappe.qb.from_(gle)
.select(Sum(gle.debit - gle.credit))
.where(
(gle.company == self.company)
& (gle.is_cancelled == 0)
& (gle.posting_date <= self.period_end_date)
& gle.account.isin(stock_accounts)
)
).run()
return flt(balance[0][0]) if balance else 0.0
def validate_stock_closing_entry(self):
closing_entry = frappe.db.get_value(
"Stock Closing Entry",
apply_unscoped_filters(
{"company": self.company, "to_date": self.period_end_date, "docstatus": 1}
),
["name", "status", "modified"],
as_dict=True,
)
if not closing_entry:
frappe.throw(
_(
"Create a Stock Closing Entry for the entire company with To Date as {0} before submitting the Period Closing Voucher."
).format(frappe.bold(formatdate(self.period_end_date))),
title=_("Stock Closing Entry Required"),
)
if closing_entry.status != "Completed":
frappe.throw(
_(
"The Stock Closing Entry for {0} is not completed yet. Wait for it to complete before submitting the Period Closing Voucher."
).format(frappe.bold(formatdate(self.period_end_date))),
title=_("Stock Closing Entry In Progress"),
)
self.validate_stock_closing_entry_is_fresh(closing_entry)
def validate_stock_closing_entry_is_fresh(self, closing_entry):
sle = frappe.qb.DocType("Stock Ledger Entry")
last_change = (
frappe.qb.from_(sle)
.select(Max(sle.modified))
.where((sle.company == self.company) & (sle.posting_date <= self.period_end_date))
).run()
if last_change and last_change[0][0] and last_change[0][0] > closing_entry.modified:
frappe.throw(
_(
"Stock transactions were created or modified after the Stock Closing Entry {0} was generated. Regenerate it before submitting the Period Closing Voucher."
).format(get_link_to_form("Stock Closing Entry", closing_entry.name)),
title=_("Stock Closing Entry Outdated"),
)
def on_submit(self):
self.db_set("gle_processing_status", "In Progress")
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):

View File

@@ -3,7 +3,7 @@
import unittest
import frappe
from frappe.utils import today
from frappe.utils import flt, 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
@@ -307,6 +307,218 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
repost_doc.posting_date = today()
repost_doc.save()
def test_stock_validations_before_period_closing(self):
from unittest.mock import patch
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
create_custom_fields(
{
"Stock Closing Entry": [
{
"fieldname": "warehouse",
"label": "Warehouse",
"fieldtype": "Link",
"options": "Warehouse",
}
]
}
)
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
se = make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
sce = frappe.get_doc(
{
"doctype": "Stock Closing Entry",
"company": "Test PCV Company",
"from_date": pcv.period_start_date,
"to_date": pcv.period_end_date,
"warehouse": "Stores - TPC",
}
).insert()
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
sce.submit()
sce.db_set("status", "Completed")
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Create a Stock Closing Entry", pcv.submit)
frappe.db.set_value("Stock Closing Entry", sce.name, {"warehouse": None, "status": "In Progress"})
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "is not completed yet", pcv.submit)
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
sle = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": se.name},
["name", "stock_value_difference"],
as_dict=1,
)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference + 100
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "does not match", pcv.submit)
frappe.db.set_value(
"Stock Ledger Entry", sle.name, "stock_value_difference", sle.stock_value_difference
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
self.rebuild_stock_closing_balance(sce)
pcv.reload()
pcv.submit()
self.assertEqual(pcv.docstatus, 1)
def test_batch_valuation_seeded_from_stock_closing_after_period_closing(self):
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_entry.stock_entry_utils import make_stock_entry
item = make_item(
"Test PCV Batch Item",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TPCVB.####",
},
)
se1 = make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
make_stock_entry(
item_code=item.name,
qty=10,
rate=200,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-06-15",
batch_no=batch_no,
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
pcv.reload()
pcv.submit()
outward = make_stock_entry(
item_code=item.name,
qty=5,
from_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2022-04-01",
batch_no=batch_no,
)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": outward.name, "is_cancelled": 0},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -750.0)
self.assertRaisesRegex(
frappe.ValidationError,
"frozen",
make_stock_entry,
item_code=item.name,
qty=1,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-05-01",
)
self.assertRaisesRegex(frappe.ValidationError, "frozen", se1.cancel)
self.assertRaisesRegex(frappe.ValidationError, "closed accounting period", sce.cancel)
def test_period_closing_blocks_stale_stock_closing_entry(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item("Test PCV Stock Item", {"is_stock_item": 1})
make_stock_entry(
item_code=item.name,
qty=10,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-03-15",
)
pcv = self.make_period_closing_voucher(posting_date="2021-03-31", submit=False)
sce = self.make_completed_stock_closing_entry(pcv.period_start_date, pcv.period_end_date)
make_stock_entry(
item_code=item.name,
qty=5,
rate=100,
to_warehouse="Stores - TPC",
company="Test PCV Company",
posting_date="2021-05-01",
)
pcv.reload()
self.assertRaisesRegex(frappe.ValidationError, "Regenerate", pcv.submit)
self.rebuild_stock_closing_balance(sce)
pcv.reload()
pcv.submit()
self.assertEqual(pcv.docstatus, 1)
def make_completed_stock_closing_entry(self, from_date, to_date):
from unittest.mock import patch
sce = frappe.get_doc(
{
"doctype": "Stock Closing Entry",
"company": "Test PCV Company",
"from_date": from_date,
"to_date": to_date,
}
).insert()
with patch("erpnext.stock.doctype.stock_closing_entry.stock_closing_entry.enqueue"):
sce.submit()
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
return sce
def rebuild_stock_closing_balance(self, sce):
sce.remove_stock_closing()
sce.create_stock_closing_balance_entries()
sce.db_set("status", "Completed")
def make_period_closing_voucher(self, posting_date, submit=True):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")

View File

@@ -238,6 +238,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -858,7 +859,7 @@
],
"istable": 1,
"links": [],
"modified": "2026-04-20 16:16:12.322024",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice Item",

View File

@@ -240,10 +240,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
unblock_invoice() {
const me = this;
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.unblock_invoice",
args: { name: me.frm.doc.name },
callback: (r) => me.frm.reload_doc(),
me.frm.call("unblock_invoice", null, () => {
me.frm.reload_doc();
});
}
@@ -294,15 +292,16 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
this.dialog.set_primary_action(__("Save"), function () {
const dialog_data = me.dialog.get_values();
frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.block_invoice",
args: {
name: me.frm.doc.name,
me.frm.call(
"block_invoice",
{
hold_comment: dialog_data.hold_comment,
release_date: dialog_data.release_date,
},
callback: (r) => me.frm.reload_doc(),
});
() => {
me.frm.reload_doc();
}
);
me.dialog.hide();
});
@@ -341,10 +340,9 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
}
set_release_date(data) {
return frappe.call({
method: "erpnext.accounts.doctype.purchase_invoice.purchase_invoice.change_release_date",
args: data,
callback: (r) => this.frm.reload_doc(),
const me = this;
return me.frm.call("change_release_date", { release_date: data.release_date }, () => {
me.frm.reload_doc();
});
}

View File

@@ -352,6 +352,7 @@
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.on_hold",
"depends_on": "eval:doc.on_hold",
"fieldname": "sb_14",
"fieldtype": "Section Break",
"label": "Hold Invoice"
@@ -1702,7 +1703,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-07-12 23:54:21.263951",
"modified": "2026-08-05 15:40:16.519774",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",

View File

@@ -8,7 +8,7 @@ import frappe
from frappe import _, qb, throw
from frappe.model.mapper import get_mapped_doc
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
from frappe.utils import DateTimeLikeObject, cint, cstr, flt, formatdate, get_link_to_form, getdate, nowdate
import erpnext
from erpnext.accounts.deferred_revenue import validate_service_stop_date
@@ -309,6 +309,9 @@ class PurchaseInvoice(BuyingController):
PurchaseTaxWithholding(self).on_validate()
self.set_percentage_received()
if self.on_hold:
self.validate_invoice_hold()
def set_percentage_received(self):
total_billed_qty = 0.0
total_received_qty = 0.0
@@ -320,6 +323,13 @@ class PurchaseInvoice(BuyingController):
if total_billed_qty and total_received_qty:
self.per_received = total_received_qty / total_billed_qty * 100
def validate_invoice_hold(self):
if self.is_return:
frappe.throw(_("Return Purchase Invoice cannot be held."))
if self.docstatus < 1:
frappe.throw(_("Purchase Invoice can be held after submitting."))
def validate_release_date(self):
if self.release_date and getdate(nowdate()) >= getdate(self.release_date):
frappe.throw(_("Release date must be in the future"))
@@ -1901,14 +1911,38 @@ class PurchaseInvoice(BuyingController):
def on_recurring(self, reference_doc, auto_repeat_doc):
self.due_date = None
def block_invoice(self, hold_comment=None, release_date=None):
self.db_set("on_hold", 1)
self.db_set("hold_comment", cstr(hold_comment))
@frappe.whitelist(methods=["POST"])
def block_invoice(self, hold_comment: str | None = None, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
self.on_hold = 1
self.release_date = release_date
self.validate_block_invoice()
self.db_set({"on_hold": 1, "hold_comment": cstr(hold_comment), "release_date": release_date})
@frappe.whitelist(methods=["POST"])
def unblock_invoice(self):
self.check_permission("write")
self.db_set({"on_hold": 0, "release_date": None})
@frappe.whitelist(methods=["POST"])
def change_release_date(self, release_date: DateTimeLikeObject | None = None):
self.check_permission("write")
if not self.on_hold:
frappe.throw(_("Invoice is not blocked. Block the invoice to change the release date."))
self.release_date = release_date
self.validate_block_invoice()
self.db_set("release_date", release_date)
def unblock_invoice(self):
self.db_set("on_hold", 0)
self.db_set("release_date", None)
def validate_block_invoice(self):
self.validate_invoice_hold()
if self.outstanding_amount <= 0:
frappe.throw(_("Purchase Invoice without any outstanding amount cannot be held."))
self.validate_release_date()
def set_status(self, update=False, status=None, update_modified=True):
if self.is_new():
@@ -2033,28 +2067,6 @@ def make_stock_entry(source_name, target_doc=None):
return doc
@frappe.whitelist()
def change_release_date(name, release_date=None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.check_permission()
pi.db_set("release_date", release_date)
@frappe.whitelist()
def unblock_invoice(name):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.unblock_invoice()
@frappe.whitelist()
def block_invoice(name, release_date, hold_comment=None):
if frappe.db.exists("Purchase Invoice", name):
pi = frappe.get_lazy_doc("Purchase Invoice", name)
pi.block_invoice(hold_comment, release_date)
@frappe.whitelist()
def make_inter_company_sales_invoice(source_name, target_doc=None):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_inter_company_transaction

View File

@@ -278,14 +278,166 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
def test_purchase_invoice_explicit_block(self):
pi = make_purchase_invoice()
pi.block_invoice()
release_date = add_days(nowdate(), 10)
pi.block_invoice(hold_comment="Waiting for the goods", release_date=release_date)
self.assertEqual(pi.on_hold, 1)
on_hold, hold_comment, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "hold_comment", "release_date"]
)
self.assertEqual(on_hold, 1)
self.assertEqual(hold_comment, "Waiting for the goods")
self.assertEqual(getdate(saved_release_date), getdate(release_date))
pi.unblock_invoice()
self.assertEqual(pi.on_hold, 0)
on_hold, saved_release_date = frappe.db.get_value(
"Purchase Invoice", pi.name, ["on_hold", "release_date"]
)
self.assertEqual(on_hold, 0)
self.assertIsNone(saved_release_date)
def test_purchase_invoice_cannot_be_held_before_submission(self):
pi = make_purchase_invoice(do_not_save=True)
pi.on_hold = 1
self.assertRaises(frappe.ValidationError, pi.save)
pi.on_hold = 0
pi.save()
pi.submit()
pi.block_invoice()
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 1)
def test_return_purchase_invoice_cannot_be_held(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.on_hold = 1
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.save)
return_pi.on_hold = 0
return_pi.save()
return_pi.submit()
self.assertRaisesRegex(frappe.ValidationError, "cannot be held", return_pi.block_invoice)
def test_return_purchase_invoice_is_not_affected_by_hold_validations(self):
from erpnext.controllers.sales_and_purchase_return import make_return_doc
pi = make_purchase_invoice()
# a return has a negative outstanding amount, which must not be mistaken
# for an invalid hold on a document that was never held
return_pi = make_return_doc(pi.doctype, pi.name)
return_pi.save()
return_pi.submit()
self.assertEqual(return_pi.docstatus, 1)
self.assertEqual(return_pi.on_hold, 0)
self.assertLess(return_pi.outstanding_amount, 0)
def test_settled_purchase_invoice_cannot_be_held(self):
pi = make_purchase_invoice()
pe = get_payment_entry("Purchase Invoice", dn=pi.name, bank_account="_Test Bank - _TC")
pe.reference_no = "1"
pe.reference_date = nowdate()
pe.save()
pe.submit()
pi.reload()
self.assertEqual(pi.outstanding_amount, 0)
self.assertRaises(frappe.ValidationError, pi.block_invoice)
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_release_date_of_held_invoice_must_be_in_future(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", nowdate())
def test_rejected_hold_does_not_partially_update_invoice(self):
pi = make_purchase_invoice()
self.assertRaises(frappe.ValidationError, pi.block_invoice, "Hold", add_days(nowdate(), -1))
pi.reload()
self.assertEqual(pi.on_hold, 0)
self.assertIsNone(pi.release_date)
def test_change_release_date_of_held_invoice(self):
pi = make_purchase_invoice()
pi.block_invoice(hold_comment="Hold", release_date=add_days(nowdate(), 10))
new_release_date = add_days(nowdate(), 20)
pi.change_release_date(new_release_date)
self.assertEqual(
getdate(frappe.db.get_value("Purchase Invoice", pi.name, "release_date")),
getdate(new_release_date),
)
self.assertRaises(frappe.ValidationError, pi.change_release_date, add_days(nowdate(), -1))
def test_release_date_cannot_be_changed_on_an_invoice_that_is_not_held(self):
pi = make_purchase_invoice()
self.assertRaisesRegex(
frappe.ValidationError,
"Invoice is not blocked",
pi.change_release_date,
add_days(nowdate(), 10),
)
self.assertIsNone(frappe.db.get_value("Purchase Invoice", pi.name, "release_date"))
def test_hold_methods_are_whitelisted_document_methods(self):
import erpnext.accounts.doctype.purchase_invoice.purchase_invoice as purchase_invoice_module
pi = frappe.new_doc("Purchase Invoice")
for method in ("block_invoice", "unblock_invoice", "change_release_date"):
# raises if the method is not whitelisted for client side calls
pi.is_whitelisted(method)
self.assertFalse(
hasattr(purchase_invoice_module, method),
f"{method} should only be exposed as a document method",
)
def test_hold_methods_require_write_permission(self):
pi = make_purchase_invoice()
user = "test_pi_hold_permission@example.com"
if not frappe.db.exists("User", user):
frappe.get_doc(
{
"doctype": "User",
"email": user,
"first_name": "Test PI Hold",
"roles": [{"role": "Employee"}],
}
).insert(ignore_permissions=True)
frappe.set_user(user)
try:
self.assertRaises(frappe.PermissionError, pi.block_invoice)
self.assertRaises(frappe.PermissionError, pi.unblock_invoice)
self.assertRaises(frappe.PermissionError, pi.change_release_date, add_days(nowdate(), 10))
finally:
frappe.set_user("Administrator")
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi.name, "on_hold"), 0)
def test_gl_entries_with_perpetual_inventory_against_pr(self):
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",

View File

@@ -237,6 +237,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -1017,7 +1018,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-05-06 08:08:40.782395",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -1180,7 +1180,16 @@ frappe.ui.form.on("Sales Invoice", {
}
frm.set_df_property("update_stock", "read_only", frm.doc.has_subcontracted);
frm.toggle_display("update_stock", !frm.doc.has_subcontracted);
// frm.set_df_property mutates a per-document copy, not the doctype's shared field
// metadata, so this always reflects the original (Customize Form) hidden value.
const hidden_by_customization = cint(
frappe.meta.get_docfield("Sales Invoice", "update_stock")?.hidden
);
frm.set_df_property(
"update_stock",
"hidden",
cint(frm.doc.has_subcontracted) || hidden_by_customization
);
},
});

View File

@@ -108,6 +108,14 @@ class TestSalesInvoice(ERPNextTestSuite):
si.save()
self.assertEqual(si.items[0].qty, 1)
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_negative_rates_for_items": 1})
def test_sales_invoice_negative_grand_total_still_blocked_with_setting(self):
"""allow_negative_rates_for_items must not bypass the >=0 guard for a non-return
invoice, since invoices post to the GL (unlike Sales Order)."""
si = create_sales_invoice(qty=1, rate=100, do_not_save=True)
si.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150})
self.assertRaises(frappe.ValidationError, si.save)
def test_timestamp_change(self):
w = frappe.copy_doc(self.globalTestRecords["Sales Invoice"][0])
w.docstatus = 0

View File

@@ -228,6 +228,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"reqd": 1
},
@@ -1036,7 +1037,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-06-03 13:17:36.145788",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Sales Invoice Item",

View File

@@ -254,6 +254,9 @@ class Subscription(Document):
"""
Sets the status of the `Subscription`
"""
if self.status == "Cancelled":
return
if self.is_trialling():
self.status = "Trialing"
elif (
@@ -605,6 +608,11 @@ class Subscription(Document):
1. `process_for_active`
2. `process_for_past_due`
"""
# Snapshot before update_subscription_period() below can roll this forward,
# so the cancel_at_period_end check further down still targets the period
# that just ended, not the next one.
current_period_end = self.current_invoice_end
if not self.is_current_invoice_generated(
self.current_invoice_start, self.current_invoice_end
) and self.can_generate_new_invoice(posting_date):
@@ -625,8 +633,8 @@ class Subscription(Document):
self.update_subscription_period()
if self.cancel_at_period_end and (
getdate(posting_date) >= getdate(self.current_invoice_end)
or getdate(posting_date) >= getdate(self.end_date)
getdate(posting_date) >= getdate(current_period_end)
or (self.end_date and getdate(posting_date) >= getdate(self.end_date))
):
self.cancel_subscription()

View File

@@ -614,6 +614,32 @@ class TestSubscription(ERPNextTestSuite):
self.assertRaises(frappe.ValidationError, subscription.process, posting_date=add_days(start_date, 7))
def test_subscription_cancels_at_period_end_without_end_date(self):
# https://github.com/frappe/erpnext/issues/57761 -- generate_invoice() rolls
# current_invoice_end forward to the next period before this check runs, so
# with no end_date to fall back on, cancel_at_period_end must compare
# against the period that just ended, not the (already advanced) next one.
create_plan(
plan_name="_Test plan name 11",
cost=80,
currency="INR",
billing_interval="Day",
billing_interval_count=3,
)
subscription = create_subscription(
start_date=nowdate(),
cancel_at_period_end=1,
generate_invoice_at="End of the current subscription period",
plans=[{"plan": "_Test plan name 11", "qty": 1}],
)
self.assertEqual(len(subscription.invoices), 0)
period_end = subscription.current_invoice_end
subscription.process(posting_date=period_end)
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), 1)
def test_invoice_generated_when_scheduler_runs_one_day_late(self):
# The trigger date (period end) is long past, yet catch-up still bills the period
# on creation (Bug 1: the check is `>= trigger`, not `== trigger`).
@@ -774,6 +800,38 @@ class TestSubscription(ERPNextTestSuite):
subscription.reload()
self.assertEqual(subscription.status, "Active")
def test_cancelled_subscription_stays_cancelled_after_payment_and_reprocess(self):
# https://github.com/frappe/erpnext/issues/57761
subscription = create_subscription(
start_date=nowdate(),
generate_invoice_at="Beginning of the current subscription period",
submit_invoice=1,
cancel_at_period_end=1,
)
subscription.process(posting_date=nowdate())
invoice = subscription.get_current_invoice()
self.assertGreater(invoice.outstanding_amount, 0)
subscription.cancel_subscription()
self.assertEqual(subscription.status, "Cancelled")
cancelation_date = getdate(subscription.cancelation_date)
self.assertIsNotNone(cancelation_date)
payment_entry = get_payment_entry(invoice.doctype, invoice.name, bank_account="_Test Bank - _TC")
payment_entry.reference_no = "12345"
payment_entry.reference_date = nowdate()
payment_entry.submit()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(getdate(subscription.cancelation_date), cancelation_date)
invoice_count = len(subscription.invoices)
subscription.process()
subscription.reload()
self.assertEqual(subscription.status, "Cancelled")
self.assertEqual(len(subscription.invoices), invoice_count)
def test_first_invoice_generated_on_create_for_prepaid(self):
subscription = create_subscription(
start_date=nowdate(),

View File

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

View File

@@ -160,7 +160,8 @@ def _execute(filters, additional_table_columns=None):
row.update(
{
"debit": inv.base_grand_total,
"credit": 0.0,
# credits the invoice itself posts to the receivable (mirrors its GL)
"credit": get_in_invoice_receivable_credit(inv),
"outstanding_amount": flt(
(inv.outstanding_amount * (inv.conversion_rate or 1)), outstanding_precision
),
@@ -181,6 +182,14 @@ def _execute(filters, additional_table_columns=None):
return columns, res, None, None, None, include_payments
def get_in_invoice_receivable_credit(inv):
# amount the invoice settles against its own receivable, matching the invoice's GL entries
credit = flt(inv.loyalty_amount) # loyalty redemption, POS or not
if inv.is_pos: # POS payments and write-off credit the receivable only on POS invoices
credit += flt(inv.base_paid_amount) - flt(inv.base_change_amount) + flt(inv.base_write_off_amount)
return credit
def get_columns(invoice_list, additional_table_columns, include_payments=False):
"""return columns based on filters"""
columns = [
@@ -447,6 +456,11 @@ def get_invoices(filters, additional_query_columns):
si.base_net_total,
si.base_grand_total,
si.base_rounded_total,
si.is_pos,
si.base_paid_amount,
si.base_change_amount,
si.base_write_off_amount,
si.loyalty_amount,
si.outstanding_amount,
si.is_internal_customer,
si.represents_company,

View File

@@ -1,6 +1,7 @@
import frappe
from frappe.utils import add_days, flt, getdate, today
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.report.sales_register.sales_register import execute
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
@@ -218,6 +219,46 @@ class TestItemWiseSalesRegister(ERPNextTestSuite, AccountsTestMixin):
result_output = {k: v for k, v in filtered_output[0].items() if k in expected_result}
self.assertDictEqual(result_output, expected_result)
def test_ledger_view_nets_pos_paid_invoice(self):
# A POS payment settles the receivable inside the invoice, so the ledger view must credit it
# and net to zero instead of showing a phantom outstanding.
make_pos_profile()
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
debit_to=self.debit_to,
posting_date=today(),
parent_cost_center=self.cost_center,
cost_center=self.cost_center,
rate=100,
price_list_rate=100,
do_not_save=1,
)
si.is_pos = 1
si.append("payments", {"mode_of_payment": "Cash", "amount": 100})
si = si.save().submit()
self.assertEqual(flt(si.outstanding_amount), 0.0)
filters = frappe._dict(
{
"from_date": today(),
"to_date": today(),
"company": self.company,
"include_payments": True,
"customer": self.customer,
}
)
rows = execute(filters)[1]
inv_row = next(x for x in rows if x.get("voucher_no") == si.name)
self.assertEqual(flt(inv_row.get("debit")), 100.0)
self.assertEqual(flt(inv_row.get("credit")), 100.0)
# running balance is unchanged by a fully-paid POS invoice
idx = rows.index(inv_row)
self.assertEqual(flt(inv_row.get("balance")), flt(rows[idx - 1].get("balance")))
def test_outstanding_currency_conversion(self):
foreign_invoice = create_sales_invoice(
customer="_Test Customer",

View File

@@ -116,24 +116,39 @@ frappe.ui.form.on("Asset Repair", {
},
repair_status: (frm) => {
if (frm.doc.completion_date && frm.doc.repair_status == "Completed") {
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
}
if (frm.doc.repair_status == "Completed" && !frm.doc.completion_date) {
frm.set_value("completion_date", frappe.datetime.now_datetime());
}
frm.events.set_downtime(frm);
},
failure_date: (frm) => {
frm.events.set_downtime(frm);
},
completion_date: (frm) => {
frm.events.set_downtime(frm);
},
set_downtime: (frm) => {
if (frm.doc.repair_status != "Completed" || !frm.doc.failure_date || !frm.doc.completion_date) {
frm.set_value("downtime", null);
return;
}
frappe.call({
method: "erpnext.assets.doctype.asset_repair.asset_repair.get_downtime",
args: {
failure_date: frm.doc.failure_date,
completion_date: frm.doc.completion_date,
},
callback: function (r) {
if (r.message) {
frm.set_value("downtime", r.message + " Hrs");
}
},
});
},
stock_items_on_form_rendered() {

View File

@@ -68,6 +68,7 @@ class AssetRepair(AccountsController):
self.calculate_repair_cost()
self.calculate_total_repair_cost()
self.check_repair_status()
self.set_downtime()
def validate_asset(self):
if self.asset_doc.status in ("Sold", "Scrapped"):
@@ -239,6 +240,13 @@ class AssetRepair(AccountsController):
if self.repair_status == "Pending" and self.docstatus == 1:
frappe.throw(_("Please update Repair Status."))
def set_downtime(self):
# keep downtime in sync with the entered dates, regardless of edit order
if self.repair_status == "Completed" and self.failure_date and self.completion_date:
self.downtime = f"{get_downtime(self.failure_date, self.completion_date)} Hrs"
else:
self.downtime = None
def update_asset_value(self):
total_repair_cost = self.total_repair_cost if self.docstatus == 1 else -1 * self.total_repair_cost

View File

@@ -97,6 +97,21 @@ class TestAssetRepair(ERPNextTestSuite):
asset_repair = create_asset_repair(submit=1)
self.assertNotEqual(asset_repair.repair_status, "Pending")
def test_downtime_stays_in_sync_with_dates(self):
asset = create_asset(submit=1)
asset_repair = create_asset_repair(asset=asset)
asset_repair.failure_date = "2026-07-31 09:00:00"
asset_repair.completion_date = "2026-07-31 11:00:00"
asset_repair.repair_status = "Completed"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "2.0 Hrs")
# editing a date must refresh downtime, not leave a stale value
asset_repair.completion_date = "2026-07-31 14:30:00"
asset_repair.save()
self.assertEqual(asset_repair.downtime, "5.5 Hrs")
def test_stock_items(self):
asset_repair = create_asset_repair(stock_consumption=1)
self.assertTrue(asset_repair.stock_consumption)

View File

@@ -54,6 +54,28 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.save()
self.assertEqual(po.items[1].qty, 1)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 0})
def test_purchase_order_negative_grand_total_blocked_without_setting(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
self.assertRaises(frappe.ValidationError, po.save)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_grand_total_allowed_with_setting(self):
"""Use a negative rate to represent a credit while order quantities remain positive."""
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": 1, "rate": -150, "schedule_date": nowdate()})
po.save()
po.submit()
self.assertEqual(po.docstatus, 1)
self.assertTrue(po.base_grand_total < 0)
@ERPNextTestSuite.change_settings("Buying Settings", {"allow_negative_rates_for_items": 1})
def test_purchase_order_negative_rate_setting_does_not_allow_negative_quantity(self):
po = create_purchase_order(qty=1, rate=100, do_not_save=True)
po.append("items", {"item_code": "_Test Item 2", "qty": -1, "rate": 100})
self.assertRaises(frappe.ValidationError, po.save)
def test_purchase_order_zero_qty(self):
po = create_purchase_order(qty=0, do_not_save=True)
@@ -266,6 +288,7 @@ class TestPurchaseOrder(ERPNextTestSuite):
po.load_from_db()
existing_ordered_qty = get_ordered_qty()
existing_ordered_qty_in_new_warehouse = get_ordered_qty(warehouse="_Test Warehouse 2 - _TC")
first_item_of_po = po.get("items")[0]
trans_item = json.dumps(
@@ -276,16 +299,64 @@ class TestPurchaseOrder(ERPNextTestSuite):
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": "_Test Item", "rate": 200, "qty": 7},
{"item_code": "_Test Item", "rate": 200, "qty": 7, "warehouse": "_Test Warehouse 2 - _TC"},
]
)
update_child_qty_rate("Purchase Order", trans_item, po.name)
po.reload()
self.assertEqual(len(po.get("items")), 2)
self.assertEqual(po.get("items")[-1].warehouse, "_Test Warehouse 2 - _TC")
self.assertEqual(po.status, "To Receive and Bill")
# ordered qty should increase on row addition
self.assertEqual(get_ordered_qty(), existing_ordered_qty + 7)
# ordered qty should increase on row addition, in the warehouse passed for the new row
self.assertEqual(get_ordered_qty(), existing_ordered_qty)
self.assertEqual(
get_ordered_qty(warehouse="_Test Warehouse 2 - _TC"),
existing_ordered_qty_in_new_warehouse + 7,
)
def test_update_child_adding_new_item_without_any_default_warehouse(self):
stock_item = make_item("_Test PO Item Without Default Warehouse", {"is_stock_item": 1}).name
service_item = make_item("_Test PO Item Non Stock", {"is_stock_item": 0}).name
po = create_purchase_order(do_not_save=1)
po.save()
po.submit()
first_item_of_po = po.get("items")[0]
stock_settings_default = frappe.db.get_single_value("Stock Settings", "default_warehouse")
frappe.db.set_single_value("Stock Settings", "default_warehouse", None)
self.addCleanup(
frappe.db.set_single_value, "Stock Settings", "default_warehouse", stock_settings_default
)
def get_trans_items(item_code):
return json.dumps(
[
{
"item_code": first_item_of_po.item_code,
"rate": first_item_of_po.rate,
"qty": first_item_of_po.qty,
"docname": first_item_of_po.name,
},
{"item_code": item_code, "rate": 200, "qty": 7},
]
)
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot find a default warehouse",
update_child_qty_rate,
"Purchase Order",
get_trans_items(stock_item),
po.name,
)
update_child_qty_rate("Purchase Order", get_trans_items(service_item), po.name)
po.reload()
self.assertEqual(po.get("items")[-1].item_code, service_item)
self.assertFalse(po.get("items")[-1].warehouse)
def test_update_child_removing_item(self):
po = create_purchase_order(do_not_save=1)
@@ -470,11 +541,13 @@ class TestPurchaseOrder(ERPNextTestSuite):
"item_code": item,
"rate": 100,
"qty": 1,
"warehouse": po.items[0].warehouse,
}, # added item whose tax account head already exists in PO
{
"item_code": new_item_with_tax.name,
"rate": 100,
"qty": 1,
"warehouse": po.items[0].warehouse,
}, # added item whose tax account head is missing in PO
]
)

View File

@@ -260,6 +260,7 @@
"label": "UOM Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"print_hide": 1,
"print_width": "100px",
"reqd": 1,
@@ -953,7 +954,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-15 10:30:04.600510",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order Item",

View File

@@ -132,6 +132,7 @@
"label": "Conversion Factor",
"oldfieldname": "conversion_factor",
"oldfieldtype": "Currency",
"precision": "9",
"read_only": 1
},
{
@@ -207,7 +208,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:26.235916",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Receipt Item Supplied",

View File

@@ -328,14 +328,14 @@ class RequestforQuotation(BuyingController):
message_template = self.mfs_html if self.use_html else self.message_for_supplier
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-ssti
rendered_message = frappe.render_template(message_template, doc_args)
rendered_message = frappe.render_template(message_template, doc_args, restrict_globals=True)
subject_source = (
self.subject
or frappe.get_value("Email Template", self.email_template, "subject")
or _("Request for Quotation")
)
rendered_subject = frappe.render_template(subject_source, doc_args)
rendered_subject = frappe.render_template(subject_source, doc_args, restrict_globals=True)
if preview:
return {
"message": rendered_message,

View File

@@ -239,6 +239,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -261,7 +262,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-01-31 19:46:27.884592",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Item",

View File

@@ -217,6 +217,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "UOM Conversion Factor",
"precision": "9",
"print_hide": 1,
"read_only": 1,
"reqd": 1
@@ -614,7 +615,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-07-15 10:33:24.855979",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation Item",

View File

@@ -77,6 +77,11 @@ from erpnext.stock.get_item_details import (
get_item_tax_map,
get_item_warehouse_,
)
from erpnext.stock.utils import (
is_group_warehouse,
validate_disabled_warehouse,
validate_warehouse_company,
)
from erpnext.utilities.regional import temporary_flag
from erpnext.utilities.transaction_base import TransactionBase
@@ -236,6 +241,23 @@ class AccountsController(TransactionBase):
)
frappe.msgprint(msg)
def is_negative_grand_total_allowed(self) -> bool:
"""Return True if this document may save with a negative grand total.
Sales Order and Purchase Order never post to the GL, so a negative
total is safe there whenever the user has explicitly opted into
negative rates via Selling/Buying Settings. Every other
AccountsController doctype (invoices, delivery notes, receipts,
quotations, ...) keeps relying on the `is_return` escape hatch only.
"""
if self.doctype == "Sales Order":
return bool(frappe.get_single_value("Selling Settings", "allow_negative_rates_for_items"))
if self.doctype == "Purchase Order":
return bool(frappe.get_single_value("Buying Settings", "allow_negative_rates_for_items"))
return False
def validate(self):
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()
@@ -261,6 +283,7 @@ class AccountsController(TransactionBase):
if self.is_return:
self.validate_qty()
else:
self.clear_stale_deferred_fields()
self.validate_deferred_start_and_end_date()
self.validate_inter_company_reference()
@@ -285,7 +308,8 @@ class AccountsController(TransactionBase):
self.calculate_taxes_and_totals()
if not self.meta.get_field("is_return") or not self.is_return:
self.validate_value("base_grand_total", ">=", 0)
if not self.is_negative_grand_total_allowed():
self.validate_value("base_grand_total", ">=", 0)
validate_return(self)
@@ -644,6 +668,23 @@ class AccountsController(TransactionBase):
if self.get("from_date") and self.get("to_date") and getdate(self.from_date) > getdate(self.to_date):
frappe.throw(_("To Date cannot be before From Date"), title=_("Invalid Auto Repeat Date"))
def clear_stale_deferred_fields(self):
field_map = {
"Sales Invoice": "deferred_revenue_account",
"Purchase Invoice": "deferred_expense_account",
}
account_field = field_map.get(self.doctype)
for item in self.get("items"):
if item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"):
continue
item.service_start_date = None
item.service_end_date = None
item.service_stop_date = None
if account_field:
item.set(account_field, None)
def validate_deferred_start_and_end_date(self):
for d in self.items:
if d.get("enable_deferred_revenue") or d.get("enable_deferred_expense"):
@@ -3777,7 +3818,7 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
child_item.update({date_fieldname: trans_item.get(date_fieldname) or p_doc.get(date_fieldname)})
child_item.stock_uom = item.stock_uom
child_item.uom = trans_item.get("uom") or item.stock_uom
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
child_item.warehouse = get_new_child_item_warehouse(p_doc, item, trans_item, child_doctype)
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")))
@@ -3786,20 +3827,45 @@ def set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child
# Initialized value will update in parent validation
child_item.base_rate = 1
child_item.base_amount = 1
if child_doctype == "Sales Order Item":
child_item.warehouse = get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
if not child_item.warehouse:
frappe.throw(
_(
"Cannot find a default warehouse for item {0}. Please set one in the Item Master or in Stock Settings."
).format(frappe.bold(item.item_code))
)
set_child_tax_template_and_map(item, child_item, p_doc)
add_taxes_from_tax_template(child_item, p_doc)
return child_item
def get_new_child_item_warehouse(p_doc, item, trans_item: dict, child_doctype: str) -> str | None:
"""Return the warehouse picked in the Update Items dialog, else the configured default.
Validates whichever warehouse was resolved, since a submitted parent skips validate().
"""
warehouse = trans_item.get("warehouse") or get_item_warehouse_(p_doc, item, overwrite_warehouse=True)
if not warehouse:
if is_warehouse_required_for_new_child_item(child_doctype, item, trans_item):
frappe.throw(
_(
"Cannot find a default warehouse for item {0}. Please select one in the Update Items dialog, or set a default in the Item Master or in Stock Settings."
).format(frappe.bold(item.item_code))
)
return None
validate_warehouse_company(warehouse, p_doc.company)
validate_disabled_warehouse(warehouse)
is_group_warehouse(warehouse)
return warehouse
def is_warehouse_required_for_new_child_item(child_doctype: str, item, trans_item: dict) -> bool:
"""Sales Order always needs one; buying documents only for stock rows, as in validate_stock_item_warehouse."""
if child_doctype == "Sales Order Item":
return True
if child_doctype in ("Purchase Order Item", "Supplier Quotation Item"):
return bool(item.is_stock_item and flt(trans_item.get("qty")) and not item.delivered_by_supplier)
return False
def validate_child_on_delete(row, parent, ordered_item=None):
"""Check if partially transacted item (row) is being deleted."""
if parent.doctype == "Sales Order":

View File

@@ -996,9 +996,8 @@ def get_payment_terms_for_references(doctype, txt, searchfield, start, page_len,
def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters) -> list:
table = frappe.qb.DocType(doctype)
query = (
frappe.qb.from_(table)
frappe.get_query(table, filters=filters)
.select(
table.name,
Concat("#", table.idx, ", ", table.item_code),
)
.orderby(table.idx)
@@ -1006,10 +1005,6 @@ def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters)
.limit(page_len)
)
if filters:
for field, value in filters.items():
query = query.where(table[field] == value)
if txt:
txt += "%"
query = query.where(

View File

@@ -253,7 +253,7 @@ class SellingController(StockController):
total += sales_person.allocated_percentage
if sales_team and total != 100.0:
if sales_team and flt(total, self.precision("allocated_percentage", "sales_team")) != 100.0:
throw(_("Total allocated percentage for sales team should be 100"))
def validate_sales_team(self, sales_team):

View File

@@ -264,6 +264,9 @@ class StatusUpdater(Document):
def validate_qty(self):
"""Validates qty at row level"""
selling_doctypes = ("Sales Order", "Sales Invoice", "Delivery Note")
buying_doctypes = ("Purchase Order", "Purchase Invoice", "Purchase Receipt")
for args in self.status_updater:
if "target_ref_field" not in args or args.get("validate_qty") is False:
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
@@ -291,11 +294,8 @@ class StatusUpdater(Document):
if hasattr(d, "qty") and flt(d.qty) > 0 and self.get("is_return"):
frappe.throw(_("For an item {0}, quantity must be negative number").format(d.item_code))
if (
not selling_negative_rate_allowed and self.doctype in ["Sales Invoice", "Delivery Note"]
) or (
not buying_negative_rate_allowed
and self.doctype in ["Purchase Invoice", "Purchase Receipt"]
if (not selling_negative_rate_allowed and self.doctype in selling_doctypes) or (
not buying_negative_rate_allowed and self.doctype in buying_doctypes
):
if hasattr(d, "item_code") and hasattr(d, "rate") and flt(d.rate) < 0:
frappe.throw(
@@ -306,7 +306,7 @@ class StatusUpdater(Document):
frappe.bold(_("`Allow Negative rates for Items`")),
get_link_to_form(
"Selling Settings"
if self.doctype in ["Sales Invoice", "Delivery Note"]
if self.doctype in selling_doctypes
else "Buying Settings"
),
),

View File

@@ -32,7 +32,7 @@ from erpnext.exceptions import (
)
from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock import get_warehouse_account_map
from erpnext.stock import get_warehouse_account, get_warehouse_account_map
from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import (
get_evaluated_inventory_dimension,
@@ -268,7 +268,9 @@ class StockController(AccountsController):
def use_item_inventory_account(self):
return frappe.get_cached_value("Company", self.company, "enable_item_wise_inventory_account")
def get_inventory_account_dict(self, row, inventory_account_map, warehouse_field=None):
def get_inventory_account_dict(
self, row, inventory_account_map, warehouse_field=None, *, raise_error=True
):
account_dict = frappe._dict()
if isinstance(row, dict):
@@ -297,8 +299,15 @@ class StockController(AccountsController):
if not warehouse:
warehouse = self.get(warehouse_field)
if warehouse and warehouse in inventory_account_map:
account_dict = inventory_account_map[warehouse]
if warehouse:
account_dict = inventory_account_map.get(warehouse)
if not account_dict and raise_error:
account = get_warehouse_account(frappe.get_cached_doc("Warehouse", warehouse))
account_dict = frappe._dict(
account=account,
account_currency=frappe.get_cached_value("Account", account, "account_currency"),
)
inventory_account_map[warehouse] = account_dict
return account_dict
@@ -2417,6 +2426,11 @@ def is_reposting_pending():
)
def invalidate_future_sle_cache(voucher_type, voucher_no):
if hasattr(frappe.local, "future_sle"):
frappe.local.future_sle.pop((voucher_type, voucher_no), None)
def future_sle_exists(args, sl_entries=None):
from erpnext.stock.utils import get_combine_datetime

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
# For license information, please see license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.controllers.stock_controller import (
@@ -75,3 +76,174 @@ class TestLedgerPreviewPermission(ERPNextTestSuite):
stock_ledger_result = show_stock_ledger_preview(company, "Purchase Receipt", pr.name)
self.assertTrue(stock_ledger_result.get("sl_data"))
class TestStockControllerConversions(ERPNextTestSuite):
@staticmethod
def _cancel_and_delete(doctype, name):
if not frappe.db.exists(doctype, name):
return
doc = frappe.get_doc(doctype, name)
if doc.docstatus == 1:
doc.cancel()
frappe.delete_doc(doctype, name, force=1)
def test_future_sle_exists_detects_later_entries(self):
# A later SLE for the same item+warehouse must be reported as a future entry, which
# exercises the GROUP BY query in future_sle_exists on both engines.
from erpnext.controllers.stock_controller import future_sle_exists
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
item = make_item("_Test Future SLE Item", {"is_stock_item": 1}).name
se = make_stock_entry(item_code=item, target="_Test Warehouse - _TC", qty=10, basic_rate=100)
self.addCleanup(self._cancel_and_delete, "Stock Entry", se.name)
# Pretend a different voucher posts a day earlier for the same item/warehouse: the existing
# (later) SLE must be reported as a future entry.
args = frappe._dict(
voucher_type="Stock Entry",
voucher_no="_TEST-NONEXISTENT-SE",
posting_date=add_days(today(), -1),
posting_time="00:00:00",
)
sl_entries = [frappe._dict(item_code=item, warehouse="_Test Warehouse - _TC")]
self.assertTrue(future_sle_exists(args, sl_entries))
def _make_opening_entry(self, item, warehouse):
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
opening = make_stock_entry(
item_code=item,
target=warehouse,
qty=100,
basic_rate=100,
posting_date=add_days(today(), -5),
posting_time="01:00:00",
)
self.addCleanup(self._cancel_and_delete, "Stock Entry", opening.name)
return opening
def _later_sle(self, item, warehouse, opening):
sle = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"item_code": item,
"warehouse": warehouse,
"posting_date": today(),
"posting_time": "12:00:00",
"voucher_type": "Stock Entry",
"voucher_no": opening.name,
"actual_qty": 7,
"incoming_rate": 100,
"qty_after_transaction": 107,
"valuation_rate": 100,
"stock_value": 10700,
"company": opening.company,
"stock_uom": "Nos",
}
)
sle.flags.ignore_permissions = True
sle.flags.ignore_links = True
return sle
def _submit_entry(self, item, warehouse, inject=None):
from erpnext.stock import stock_ledger
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
original_make_entry = stock_ledger.make_entry
injected = []
def make_entry_with_injection(*args, **kwargs):
if inject is not None and not injected:
injected.append(True)
inject.submit()
return original_make_entry(*args, **kwargs)
stock_ledger.make_entry = make_entry_with_injection
try:
entry = make_stock_entry(
item_code=item,
target=warehouse,
qty=5,
basic_rate=500,
posting_date=today(),
posting_time="06:00:00",
)
finally:
stock_ledger.make_entry = original_make_entry
self.addCleanup(self._cancel_and_delete, "Stock Entry", entry.name)
if inject is not None:
self.assertTrue(injected, "the later SL Entry was not written during the submit")
return entry
def _reposts_queued_for(self, item, warehouse, voucher_no):
names = set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "item_code": item, "warehouse": warehouse},
pluck="name",
)
) | set(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": voucher_no},
pluck="name",
)
)
for name in names:
self.addCleanup(frappe.delete_doc, "Repost Item Valuation", name, force=1)
return names
def test_repost_queued_for_entry_backdated_while_its_sl_entries_were_written(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Concurrent Backdated Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
self._reposts_queued_for(item, warehouse, backdated.name),
"No Repost Item Valuation was queued for an entry that a later SL Entry made backdated",
)
def test_repost_queued_against_voucher_when_item_based_reposting_is_off(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Voucher Based Repost Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
with self.change_settings("Stock Reposting Settings", item_based_reposting=0):
opening = self._make_opening_entry(item, warehouse)
backdated = self._submit_entry(item, warehouse, inject=self._later_sle(item, warehouse, opening))
self.assertTrue(
frappe.get_all(
"Repost Item Valuation",
filters={"docstatus": 1, "voucher_no": backdated.name},
pluck="name",
),
"No voucher based Repost Item Valuation was queued",
)
def test_no_repost_queued_when_nothing_was_written_after_the_entry(self):
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Unconcurrent Item", {"is_stock_item": 1}).name
warehouse = "_Test Warehouse - _TC"
self._make_opening_entry(item, warehouse)
entry = self._submit_entry(item, warehouse)
self.assertFalse(
self._reposts_queued_for(item, warehouse, entry.name),
"A Repost Item Valuation was queued for an entry with nothing posted after it",
)

View File

@@ -13,6 +13,7 @@ from frappe.model.document import Document
from frappe.share import add_docshare
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
from frappe.utils.data import sha256_hash
from frappe.utils.html_utils import escape_html
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
@@ -269,7 +270,11 @@ class Appointment(Document):
if self.customer_details:
lead.append(
"notes",
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
{
"note": escape_html(self.customer_details),
"added_by": frappe.session.user,
"added_on": now(),
},
)
self.party = lead.insert(ignore_permissions=True).name

View File

@@ -30,7 +30,7 @@ class ContractTemplate(Document):
def validate(self):
if self.contract_terms:
validate_template(self.contract_terms)
validate_template(self.contract_terms, restrict_globals=True)
@frappe.whitelist()
@@ -42,6 +42,6 @@ def get_contract_template(template_name, doc):
contract_terms = None
if contract_template.contract_terms:
contract_terms = frappe.render_template(contract_template.contract_terms, doc)
contract_terms = frappe.render_template(contract_template.contract_terms, doc, restrict_globals=True)
return {"contract_template": contract_template, "contract_terms": contract_terms}

View File

@@ -171,8 +171,8 @@ def send_mail(entry, email_campaign):
context = {"doc": frappe.get_doc("Email Group", recipient)}
# Render template
subject = frappe.render_template(email_template.get("subject"), context)
content = frappe.render_template(email_template.response_, context)
subject = frappe.render_template(email_template.get("subject"), context, restrict_globals=True)
content = frappe.render_template(email_template.response_, context, restrict_globals=True)
try:
comm = make(

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -282,6 +282,7 @@ class BOM(WebsiteGenerator):
self.clear_inspection()
self.validate_main_item()
self.validate_currency()
self.set_operation_finished_goods()
self.set_materials_based_on_operation_bom()
self.set_conversion_rate()
self.set_plc_conversion_rate()
@@ -307,15 +308,42 @@ class BOM(WebsiteGenerator):
if self.docstatus == 1:
self.validate_raw_materials_of_operation()
def set_operation_finished_goods(self):
"""Fill each operation's FG item where it is unambiguous: the final operation produces
this BOM's item, an operation with a BOM produces that BOM's item. Runs before
set_materials_based_on_operation_bom so derived rows get their materials expanded."""
if not self.track_semi_finished_goods:
return
for row in self.operations:
if row.is_final_finished_good and not row.finished_good:
row.finished_good = self.item
elif row.bom_no and not row.finished_good:
row.finished_good = frappe.get_cached_value("BOM", row.bom_no, "item")
def validate_semi_finished_goods(self):
if not self.track_semi_finished_goods or not self.operations:
return
fg_items = []
for row in self.operations:
if not row.finished_good:
frappe.throw(
_(
"Row #{0}: FG / Semi FG Item is required for the operation {1} as 'Track Semi Finished Goods' is enabled."
).format(row.idx, bold(row.operation)),
)
if not row.is_final_finished_good:
continue
if row.finished_good != self.item:
frappe.throw(
_(
"Row #{0}: The operation {1} has 'Is Final Finished Good' checked, so its FG / Semi FG Item must be {2}."
).format(row.idx, bold(row.operation), bold(self.item)),
)
fg_items.append(row.finished_good)
if not fg_items:
@@ -826,7 +854,7 @@ class BOM(WebsiteGenerator):
self.add_materials_from_bom(row.finished_good, row.bom_no, row.idx, qty=row.finished_good_qty)
@frappe.whitelist()
def add_raw_materials(self, operation_row_id, items):
def add_raw_materials(self, operation_row_id: str | int, items: str | list[dict]) -> None:
if isinstance(items, str):
items = parse_json(items)
@@ -836,17 +864,10 @@ class BOM(WebsiteGenerator):
row.update(get_item_details(row.get("item_code")))
row.operation_row_id = operation_row_id
item_row = None
if row.name:
item_row = self.get_item_data(row.name)
item_row = self.get_item_data(row.item_code, operation_row_id)
if item_row:
item_row.update(
{
"item_code": row.get("item_code"),
"qty": row.get("qty"),
}
)
item_row.qty = row.get("qty")
else:
row.idx = None
row.name = None
@@ -867,9 +888,9 @@ class BOM(WebsiteGenerator):
return False
def get_item_data(self, name):
def get_item_data(self, item_code, operation_row_id):
for row in self.items:
if row.item_code == name:
if row.item_code == item_code and cint(row.operation_row_id) == cint(operation_row_id):
return row
@frappe.whitelist()
@@ -1888,10 +1909,10 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
[IfNull(Field("end_of_life"), "3099-12-31"), ">", today()],
]
or_cond_filters = {}
or_cond_filters = []
if txt:
for s_field in searchfields:
or_cond_filters[s_field] = ("like", f"%{txt}%")
or_cond_filters.append([s_field, "like", f"%{txt}%"])
barcodes = frappe.get_all(
"Item Barcode",
@@ -1902,7 +1923,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
barcodes = [d.item_code for d in barcodes]
if barcodes:
or_cond_filters["name"] = ("in", barcodes)
or_cond_filters.append(["name", "in", barcodes])
if filters and filters.get("item_code"):
has_variants = frappe.get_cached_value("Item", filters.get("item_code"), "has_variants")

View File

@@ -7,7 +7,7 @@ from functools import partial
import frappe
from frappe.tests import timeout
from frappe.utils import cstr, flt
from frappe.utils import cint, cstr, flt
from erpnext.controllers.tests.test_subcontracting_controller import (
set_backflush_based_on,
@@ -486,6 +486,29 @@ class TestBOM(ERPNextTestSuite):
self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results")
self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results")
@timeout
def test_bom_item_query_matches_item_code_colliding_with_another_barcode(self):
item = make_item(
"_Test BOM Query 2.5MM",
{"is_stock_item": 1, "item_name": "_Test BOM Query Sheet", "description": "sheet"},
)
make_item(
"_Test BOM Query Barcode Holder",
{"is_stock_item": 1},
barcode=f"90{item.name}90",
)
results = item_query(
doctype="Item",
txt=item.name,
searchfield="name",
start=0,
page_len=20,
filters={"is_stock_item": 1},
)
self.assertIn(item.name, [d[0] for d in results])
@timeout
def test_exclude_exploded_items_from_bom(self):
bom_no = get_default_bom()
@@ -811,6 +834,207 @@ class TestBOM(ERPNextTestSuite):
for row in bom.items:
self.assertEqual(row.stock_uom, "Kg")
@timeout
def test_track_semi_finished_goods_requires_finished_good_on_operations(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
# the first operation produces nothing derivable: no FG item, no BOM to take it from
self.assertRaises(frappe.ValidationError, bom.insert)
bom.operations[0].finished_good = sfg_item
bom.insert()
# the final operation's FG item is derived from the BOM's own item
self.assertEqual(bom.operations[1].finished_good, fg_item)
@timeout
def test_add_raw_materials_when_item_is_used_by_another_operation(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"finished_good": sfg_item,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
bom.insert()
def rows_for(item_code, operation_row_id):
return [
row
for row in bom.items
if row.item_code == item_code and cint(row.operation_row_id) == operation_row_id
]
# the item already used by operation 1 gets its own new row under operation 2
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 3}])
self.assertEqual(len(rows_for(rm_item, 2)), 1)
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 3.0)
self.assertEqual(flt(rows_for(rm_item, 1)[0].qty), 1.0)
# adding it again for the same operation updates the row instead of stacking another
bom.add_raw_materials(2, [{"item_code": rm_item, "qty": 5}])
self.assertEqual(len(rows_for(rm_item, 2)), 1)
self.assertEqual(flt(rows_for(rm_item, 2)[0].qty), 5.0)
@timeout
def test_operation_bom_materials_expand_on_single_pass_submit(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg_item, quantity=1)
sfg_bom.append("items", {"item_code": rm_item, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"bom_no": sfg_bom.name,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
},
)
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
bom.submit()
self.assertEqual(bom.docstatus, 1)
self.assertEqual(bom.operations[0].finished_good, sfg_item)
self.assertTrue(
any(row.item_code == rm_item and cint(row.operation_row_id) == 1 for row in bom.items)
)
@timeout
def test_final_operation_must_produce_the_bom_item(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
fg_item = make_item(properties={"is_stock_item": 1}).name
sfg_item = make_item(properties={"is_stock_item": 1}).name
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100.0}).name
make_workstation({"workstation": "_Test SFG Workstation"})
for operation in ("_Test SFG Operation", "_Test SFG Final Operation"):
make_operation({"operation": operation, "workstation": "_Test SFG Workstation"})
bom = frappe.new_doc("BOM")
bom.company = "_Test Company"
bom.item = fg_item
bom.quantity = 1
bom.with_operations = 1
bom.track_semi_finished_goods = 1
bom.append(
"operations",
{
"operation": "_Test SFG Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"finished_good": sfg_item,
},
)
bom.append(
"operations",
{
"operation": "_Test SFG Final Operation",
"workstation": "_Test SFG Workstation",
"time_in_mins": 30,
"is_final_finished_good": 1,
"finished_good": sfg_item,
},
)
bom.append("items", {"item_code": rm_item, "qty": 1, "operation_row_id": 1})
bom.append("items", {"item_code": sfg_item, "qty": 1, "operation_row_id": 2})
# the final operation claims to produce the semi FG, not this BOM's item
self.assertRaises(frappe.ValidationError, bom.insert)
bom.operations[1].finished_good = fg_item
bom.insert()
def get_default_bom(item_code="_Test FG Item 2"):
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
@@ -881,10 +1105,15 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
warehouse_list = [warehouse_list]
if not warehouse_list:
# Reconcile every warehouse the item has a non-zero balance in -- including
# negative balances left by other tests. get_valuation_rate averages
# Sum(stock_value)/Sum(actual_qty) across all bins, so a leftover negative
# balance in one warehouse can cancel the reset qty elsewhere and make the
# average collapse to 0, which is a source of flaky BOM-cost failures.
warehouse_list = frappe.db.sql_list(
"""
select warehouse from `tabBin`
where item_code=%s and actual_qty > 0
where item_code=%s and actual_qty != 0
""",
item_code,
)

View File

@@ -140,7 +140,8 @@
{
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor"
"label": "Conversion Factor",
"precision": "9"
},
{
"fetch_from": "item_code.stock_uom",
@@ -264,7 +265,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-05 21:15:55.187671",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Creator Item",

View File

@@ -177,7 +177,8 @@
{
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor"
"label": "Conversion Factor",
"precision": "9"
},
{
"fieldname": "rate_amount_section",
@@ -327,7 +328,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-11-05 19:00:38.646539",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Item",

View File

@@ -213,6 +213,7 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "FG / Semi FG Item",
"mandatory_depends_on": "eval:parent.track_semi_finished_goods === 1",
"options": "Item"
},
{
@@ -307,7 +308,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-05-25 17:15:42.044630",
"modified": "2026-08-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@@ -99,6 +99,7 @@
"fieldtype": "Float",
"label": "Conversion Factor",
"non_negative": 1,
"precision": "9",
"reqd": 1
},
{
@@ -217,7 +218,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-06-16 16:49:19.000000",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Secondary Item",

View File

@@ -67,7 +67,11 @@ frappe.ui.form.on("Job Card", {
if (remaining_qty < frm.doc.pending_qty) {
frm.doc.pending_qty = 0.0;
refresh_field("pending_qty");
frappe.throw(__("Pending Quantity cannot be greater than {0}", [remaining_qty]));
frappe.throw(
__("Pending Quantity cannot be greater than {0}", [
get_qty_with_uom(remaining_qty, frm.doc.stock_uom),
])
);
}
const process_loss_qty = flt(remaining_qty) - flt(frm.doc.pending_qty);
@@ -99,7 +103,8 @@ frappe.ui.form.on("Job Card", {
doc.docstatus === 1 &&
!doc.is_subcontracted &&
(doc.skip_material_transfer || doc.transferred_qty > 0) &&
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) < flt(doc.for_quantity);
flt(doc.manufactured_qty) + flt(doc.process_loss_qty) <
flt(doc.for_quantity) - flt(doc.pending_qty);
if (!can_make_stock_entry) return;
@@ -243,13 +248,15 @@ frappe.ui.form.on("Job Card", {
const fields = [
{
fieldtype: "Float",
label: __("Qty to Manufacture"),
label: __("Qty to Manufacture in this Cycle"),
fieldname: "for_quantity",
reqd: 1,
default: pending_qty,
description: __("Completed, Pending and Process Loss quantities must add up to this."),
change() {
const dialog = frm.job_completion_dialog;
dialog.set_value("completed_qty", dialog.get_value("for_quantity"));
dialog.set_value("pending_qty", 0);
dialog.set_value("process_loss_qty", 0);
},
},
@@ -261,8 +268,23 @@ frappe.ui.form.on("Job Card", {
default: pending_qty,
change() {
const dialog = frm.job_completion_dialog;
const remaining = dialog.get_value("for_quantity") - dialog.get_value("completed_qty");
if (remaining > 0 && remaining != dialog.get_value("pending_qty")) {
const remaining =
dialog.get_value("for_quantity") -
dialog.get_value("completed_qty") -
dialog.get_value("process_loss_qty");
if (remaining < 0) {
const max_completed_qty =
flt(dialog.get_value("for_quantity")) - flt(dialog.get_value("process_loss_qty"));
dialog.set_value("completed_qty", max_completed_qty);
frappe.throw(
__("Completed Quantity cannot be greater than {0}", [
get_qty_with_uom(max_completed_qty, frm.doc.stock_uom),
])
);
}
if (remaining != dialog.get_value("pending_qty")) {
dialog.set_value("pending_qty", remaining);
}
},
@@ -272,13 +294,28 @@ frappe.ui.form.on("Job Card", {
label: __("Pending Quantity"),
fieldname: "pending_qty",
default: 0.0,
description: __("Qty left for a later cycle or for another job card."),
change() {
const dialog = frm.job_completion_dialog;
const process_loss_qty =
dialog.get_value("for_quantity") -
dialog.get_value("completed_qty") -
dialog.get_value("pending_qty");
if (process_loss_qty >= 0 && process_loss_qty != dialog.get_value("process_loss_qty")) {
if (process_loss_qty < 0) {
dialog.set_value("pending_qty", 0);
frappe.throw(
__("Pending Quantity cannot be greater than {0}", [
get_qty_with_uom(
flt(dialog.get_value("for_quantity")) -
flt(dialog.get_value("completed_qty")),
frm.doc.stock_uom
),
])
);
}
if (process_loss_qty != dialog.get_value("process_loss_qty")) {
dialog.set_value("process_loss_qty", process_loss_qty);
}
},
@@ -287,13 +324,28 @@ frappe.ui.form.on("Job Card", {
fieldtype: "Float",
label: __("Process Loss Quantity"),
fieldname: "process_loss_qty",
description: __("Qty scrapped in this cycle, nobody will produce it."),
onchange() {
const dialog = frm.job_completion_dialog;
const remaining =
dialog.get_value("for_quantity") -
dialog.get_value("completed_qty") -
dialog.get_value("process_loss_qty");
if (remaining >= 0 && remaining != dialog.get_value("pending_qty")) {
if (remaining < 0) {
dialog.set_value("process_loss_qty", 0);
frappe.throw(
__("Process Loss Quantity cannot be greater than {0}", [
get_qty_with_uom(
flt(dialog.get_value("for_quantity")) -
flt(dialog.get_value("completed_qty")),
frm.doc.stock_uom
),
])
);
}
if (remaining != dialog.get_value("pending_qty")) {
dialog.set_value("pending_qty", remaining);
}
},
@@ -357,9 +409,8 @@ frappe.ui.form.on("Job Card", {
},
});
},
__("Enter Value"),
__("Update"),
__("Set Finished Good Quantity")
__("Complete Job"),
__("Update")
);
},
@@ -385,46 +436,6 @@ frappe.ui.form.on("Job Card", {
});
},
make_finished_good(frm) {
const fields = [
{
fieldtype: "Float",
label: __("Completed Quantity"),
fieldname: "qty",
reqd: 1,
default: frm.doc.for_quantity - frm.doc.manufactured_qty,
},
{
fieldtype: "Datetime",
label: __("End Time"),
fieldname: "end_time",
default: frappe.datetime.now_datetime(),
},
];
frappe.prompt(
fields,
(data) => {
if (data.qty <= 0) {
frappe.throw(__("Quantity should be greater than 0"));
}
frm.call({
method: "make_finished_good",
doc: frm.doc,
args: { qty: data.qty, end_time: data.end_time },
callback(r) {
const doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
},
__("Enter Value"),
__("Update"),
__("Set Finished Good Quantity")
);
},
setup_quality_inspection(frm) {
const quality_inspection_field = frm.get_docfield("quality_inspection");
quality_inspection_field.get_route_options_for_new_doc = function (frm) {
@@ -587,8 +598,7 @@ frappe.ui.form.on("Job Card", {
const has_remaining_qty = doc.for_quantity + doc.process_loss_qty > doc.total_completed_qty;
const pending_transfer =
has_items && doc.items.some((row) => flt(row.transferred_qty) < flt(row.required_qty));
const materials_ready =
doc.skip_material_transfer || !pending_transfer || !doc.finished_good || !has_items;
const materials_ready = doc.skip_material_transfer || doc.is_corrective_job_card || !pending_transfer;
let last_row = {};
const has_sub_ops_or_pending_qty = doc.sub_operations?.length || doc.pending_qty > 0;
@@ -886,3 +896,7 @@ function get_last_completed_row(time_logs) {
function get_last_row(time_logs) {
return time_logs[time_logs.length - 1] || {};
}
function get_qty_with_uom(qty, stock_uom) {
return stock_uom ? `${flt(qty)} ${stock_uom}` : flt(qty);
}

View File

@@ -13,10 +13,11 @@
"work_order",
"column_break_uqjq",
"production_item",
"bom_no",
"column_break_qrpg",
"for_quantity",
"column_break_yecz",
"bom_no",
"stock_uom",
"section_break_oisd",
"company",
"naming_series",
@@ -164,6 +165,13 @@
"in_preview": 1,
"label": "Qty To Manufacture"
},
{
"fieldname": "stock_uom",
"fieldtype": "Link",
"label": "Stock UOM",
"options": "UOM",
"read_only": 1
},
{
"fieldname": "wip_warehouse",
"fieldtype": "Link",
@@ -695,7 +703,7 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-06-19 17:39:42.293242",
"modified": "2026-08-01 14:22:19.926911",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -130,6 +130,7 @@ class JobCard(Document):
"Cancelled",
"Completed",
]
stock_uom: DF.Link | None
sub_operations: DF.Table[JobCardOperation]
target_warehouse: DF.Link | None
time_logs: DF.Table[JobCardTimeLog]
@@ -158,6 +159,7 @@ class JobCard(Document):
def before_validate(self):
self.set_wip_warehouse()
self.set_stock_uom()
def validate(self):
self.validate_time_logs()
@@ -845,6 +847,9 @@ class JobCard(Document):
)
def validate_transfer_qty(self):
if self.track_semi_finished_goods and self.skip_material_transfer:
return
if (
not self.finished_good
and not self.is_corrective_job_card
@@ -895,22 +900,21 @@ class JobCard(Document):
)
precision = self.precision("total_completed_qty")
total_completed_qty = flt(
accounted_qty = flt(
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):
total_completed_qty_label = bold(_("Total Completed Qty"))
qty_to_manufacture = bold(_("Qty to Manufacture"))
if self.for_quantity and flt(accounted_qty, precision) != flt(self.for_quantity, precision):
frappe.throw(
_("The {0} ({1}) must be equal to {2} ({3})").format(
total_completed_qty_label,
bold(flt(total_completed_qty, precision)),
qty_to_manufacture,
bold(self.for_quantity),
_(
"Total Completed Qty ({0}), Process Loss Qty ({1}) and Pending Qty ({2}) must add up to the Qty to Manufacture ({3})."
).format(
bold(self.get_qty_with_uom(self.total_completed_qty)),
bold(self.get_qty_with_uom(self.process_loss_qty)),
bold(self.get_qty_with_uom(self.pending_qty)),
bold(self.get_qty_with_uom(self.for_quantity)),
)
)
@@ -1073,6 +1077,9 @@ class JobCard(Document):
wo.calculate_operating_cost()
wo.set_actual_dates()
if wo.track_semi_finished_goods:
wo.set_process_loss_qty()
if time_data:
wo.status = "In Process"
@@ -1162,7 +1169,10 @@ class JobCard(Document):
_(
"Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
).format(
row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card
row.idx,
frappe.bold(self.get_qty_with_uom(required_qty, row.item_code)),
frappe.bold(row.item_code),
ste_doc.job_card,
),
title=_("Excess Transfer"),
exc=JobCardOverTransferError,
@@ -1236,7 +1246,7 @@ class JobCard(Document):
def set_status(self, update_status=False):
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
if self.finished_good and self.docstatus == 1:
if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
if (self.manufactured_qty + self.process_loss_qty) >= self.get_qty_to_produce():
self.status = "Completed"
elif self.transferred_qty > 0 or self.skip_material_transfer:
self.status = "Work In Progress"
@@ -1267,7 +1277,8 @@ class JobCard(Document):
self.status = "Work In Progress"
if self.docstatus == 1 and (
self.for_quantity <= (self.total_completed_qty + self.process_loss_qty) or not self.items
self.get_qty_to_produce() <= (self.total_completed_qty + self.process_loss_qty)
or not self.items
):
self.status = "Completed"
@@ -1280,10 +1291,27 @@ class JobCard(Document):
if self.workstation:
self.update_workstation_status()
def get_qty_to_produce(self):
"""Qty this job card is expected to produce, the pending qty is left to another job card."""
return flt(self.for_quantity) - flt(self.pending_qty)
def get_qty_with_uom(self, qty, item_code=None):
"""A quantity in a message reads as a count of nothing without the unit it is measured in."""
uom = self.stock_uom
if item_code:
uom = frappe.get_cached_value("Item", item_code, "stock_uom")
return f"{flt(qty, self.precision('total_completed_qty'))} {uom or ''}".strip()
def set_wip_warehouse(self):
if not self.wip_warehouse:
self.wip_warehouse = frappe.get_cached_value("Company", self.company, "default_wip_warehouse")
def set_stock_uom(self):
item_code = self.finished_good or self.production_item
if item_code:
self.stock_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
def validate_operation_id(self):
if (
self.get("operation_id")
@@ -1346,9 +1374,9 @@ class JobCard(Document):
current_operation_qty += flt(self.total_completed_qty)
data = frappe.get_all(
previous_operations = frappe.get_all(
"Work Order Operation",
fields=["operation", "status", "completed_qty", "sequence_id"],
fields=["name", "operation", "status", "completed_qty", "sequence_id", "finished_good"],
filters={"docstatus": 1, "parent": self.work_order, "sequence_id": ("<", self.sequence_id)},
order_by="sequence_id, idx",
)
@@ -1357,7 +1385,19 @@ class JobCard(Document):
bold(self.name), bold(get_link_to_form("Work Order", self.work_order))
)
for row in data:
if self.track_semi_finished_goods and previous_operations:
manufactured_qty = self.get_manufactured_qty_per_operation(
[row.name for row in previous_operations]
)
for row in previous_operations:
row.manufactured_qty = flt(manufactured_qty.get(row.name))
for row in previous_operations:
if self.track_semi_finished_goods:
self.validate_previous_operation_manufactured_qty(row, current_operation_qty)
continue
if not row.completed_qty:
frappe.throw(
_("{0}, complete the operation {1} before the operation {2}.").format(
@@ -1379,13 +1419,59 @@ class JobCard(Document):
_(
"The completed quantity {0} of an operation {1} cannot be greater than the completed quantity {2} of a previous operation {3}."
).format(
bold(current_operation_qty),
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(row.completed_qty),
bold(self.get_qty_with_uom(row.completed_qty, row.finished_good)),
bold(row.operation),
)
)
def get_manufactured_qty_per_operation(self, operation_ids):
job_card = frappe.qb.DocType("Job Card")
data = (
frappe.qb.from_(job_card)
.select(job_card.operation_id, Sum(job_card.manufactured_qty))
.where(
(job_card.work_order == self.work_order)
& (job_card.docstatus == 1)
& (IfNull(job_card.is_corrective_job_card, 0) == 0)
& (job_card.operation_id.isin(operation_ids))
)
.groupby(job_card.operation_id)
).run()
return dict(data)
def validate_previous_operation_manufactured_qty(self, row, current_operation_qty):
manufactured_qty = flt(row.manufactured_qty)
if not manufactured_qty:
frappe.throw(
_(
"Job Card {0}: As per the sequence of the operations in the work order {1}, submit the manufacturing entry for the operation {2} before the operation {3}."
).format(
bold(self.name),
bold(get_link_to_form("Work Order", self.work_order)),
bold(row.operation),
bold(self.operation),
),
OperationSequenceError,
)
if manufactured_qty < current_operation_qty:
frappe.throw(
_(
"The completed quantity {0} of an operation {1} cannot be greater than the manufactured quantity {2} of a previous operation {3}. Submit the manufacturing entry for the operation {3} first."
).format(
bold(self.get_qty_with_uom(current_operation_qty)),
bold(self.operation),
bold(self.get_qty_with_uom(manufactured_qty, row.finished_good)),
bold(row.operation),
),
OperationSequenceError,
)
def validate_work_order(self):
if self.is_work_order_closed():
frappe.throw(_("You can't make any changes to Job Card since Work Order is closed."))
@@ -1493,6 +1579,7 @@ class JobCard(Document):
def start_timer(self, **kwargs):
frappe.has_permission("Job Card", "write", doc=self, throw=True)
self.validate_docstatus()
self.validate_transfer_qty()
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
@@ -1510,12 +1597,22 @@ class JobCard(Document):
frappe.has_permission("Job Card", "write", doc=self, throw=True)
self.validate_docstatus()
self.validate_transfer_qty()
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
self.set_for_quantity(kwargs)
self.validate_complete_job_card_qty(kwargs)
def set_for_quantity(self, kwargs):
"""Qty to Manufacture of the completion dialog covers the current cycle only,
so the qty completed by the earlier cycles of this job card is kept."""
if not flt(kwargs.for_quantity):
return
self.for_quantity = flt(self.total_completed_qty) + flt(kwargs.for_quantity)
def validate_docstatus(self):
if self.docstatus == 2:
frappe.throw(_("Cancelled Job Card cannot be processed."))
@@ -1533,6 +1630,8 @@ class JobCard(Document):
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.validate_completion_qty_split(kwargs)
self.pending_qty = flt(kwargs.pending_qty)
self.process_loss_qty = flt(kwargs.process_loss_qty)
@@ -1561,25 +1660,49 @@ class JobCard(Document):
_("Job Card {0} has been completed").format(get_link_to_form("Job Card", self.name))
)
def validate_completion_qty_split(self, kwargs):
if not flt(kwargs.for_quantity):
return
precision = self.precision("total_completed_qty")
accounted_qty = flt(
flt(kwargs.qty, precision)
+ flt(kwargs.pending_qty, precision)
+ flt(kwargs.process_loss_qty, precision)
)
if flt(accounted_qty, precision) == flt(kwargs.for_quantity, precision):
return
frappe.throw(
_(
"Completed Quantity ({0}), Pending Quantity ({1}) and Process Loss Quantity ({2}) must add up to the Qty to Manufacture ({3})."
).format(
bold(self.get_qty_with_uom(kwargs.qty)),
bold(self.get_qty_with_uom(kwargs.pending_qty)),
bold(self.get_qty_with_uom(kwargs.process_loss_qty)),
bold(self.get_qty_with_uom(kwargs.for_quantity)),
)
)
def get_consumed_process_loss(self):
table = frappe.qb.DocType("Stock Entry")
query = (
frappe.qb.from_(table)
.select(Sum(table.process_loss_qty))
.where((table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1))
)
return query.run()[0][0] or 0
@frappe.whitelist()
def make_stock_entry_for_semi_fg_item(self, auto_submit: bool = False):
def get_consumed_process_loss():
table = frappe.qb.DocType("Stock Entry")
query = (
frappe.qb.from_(table)
.select(Sum(table.process_loss_qty))
.where(
(table.purpose == "Manufacture") & (table.job_card == self.name) & (table.docstatus == 1)
)
)
return query.run()[0][0] or 0
from erpnext.stock.doctype.stock_entry_type.stock_entry_type import ManufactureEntry
consumed_process_loss = self.get_consumed_process_loss()
ste = ManufactureEntry(
{
"for_quantity": self.for_quantity - self.manufactured_qty,
"process_loss_qty": max(self.process_loss_qty - get_consumed_process_loss(), 0),
"for_quantity": self.get_qty_to_produce() - self.manufactured_qty - consumed_process_loss,
"process_loss_qty": max(self.process_loss_qty - consumed_process_loss, 0),
"job_card": self.name,
"skip_material_transfer": self.skip_material_transfer,
"backflush_from_wip_warehouse": self.backflush_from_wip_warehouse,

View File

@@ -11,6 +11,7 @@ from frappe.utils.data import add_to_date, now, today
from erpnext.manufacturing.doctype.job_card.job_card import (
JobCardOverTransferError,
OperationMismatchError,
OperationSequenceError,
OverlapError,
make_corrective_job_card,
make_material_request,
@@ -21,6 +22,7 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder, make_work_order
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.patches.v16_0.set_stock_uom_in_job_card import execute as set_stock_uom_in_job_card
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
@@ -359,6 +361,31 @@ class TestJobCard(ERPNextTestSuite):
# JC is Completed with excess transfer
self.assertEqual(job_card.status, "Completed")
def test_job_card_actions_blocked_until_material_transfer(self):
"Start and Complete must wait for the transfer when RMs move against Job Card."
self.transfer_material_against = "Job Card"
self.source_warehouse = "Stores - _TC"
self.generate_required_stock(self.work_order)
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
self.assertRaises(frappe.ValidationError, job_card.start_timer, start_time=now())
self.assertRaises(frappe.ValidationError, job_card.complete_job_card, qty=2, for_quantity=2)
transfer_entry = make_stock_entry_from_jc(job_card.name)
transfer_entry.insert()
transfer_entry.submit()
job_card.reload()
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=2, for_quantity=2, pending_qty=0, process_loss_qty=0, end_time="2024-03-01 09:00:00"
)
job_card.reload()
self.assertEqual(flt(job_card.total_completed_qty), 2)
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
def test_job_card_excess_material_transfer_block(self):
self.transfer_material_against = "Job Card"
@@ -888,6 +915,123 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(wo_doc.process_loss_qty, 2)
self.assertEqual(wo_doc.status, "Completed")
def get_first_job_card(self, work_order):
return frappe.get_doc(
"Job Card",
frappe.get_all(
"Job Card",
filters={"work_order": work_order},
order_by="sequence_id, creation",
limit=1,
pluck="name",
)[0],
)
def test_stock_uom_is_set_from_the_produced_item(self):
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
job_card = self.get_first_job_card(work_order.name)
item_code = job_card.finished_good or job_card.production_item
self.assertEqual(job_card.stock_uom, frappe.db.get_value("Item", item_code, "stock_uom"))
def test_stock_uom_patch_backfills_legacy_job_cards(self):
suffix = random_string(8)
finished_good = create_item(f"Stock UOM Patch FG {suffix}", stock_uom="Kg")
production_item = create_item(f"Stock UOM Patch Product {suffix}", stock_uom="Nos")
finished_good_job_card = self.get_first_job_card(
make_wo_order_test_record(item="_Test FG Item 2", qty=5).name
)
production_item_job_card = self.get_first_job_card(
make_wo_order_test_record(item="_Test FG Item 2", qty=6).name
)
frappe.db.set_value(
"Job Card",
finished_good_job_card.name,
{
"finished_good": finished_good.name,
"production_item": production_item.name,
"stock_uom": None,
},
update_modified=False,
)
frappe.db.set_value(
"Job Card",
production_item_job_card.name,
{"finished_good": None, "production_item": production_item.name, "stock_uom": None},
update_modified=False,
)
set_stock_uom_in_job_card()
self.assertEqual(frappe.db.get_value("Job Card", finished_good_job_card.name, "stock_uom"), "Kg")
self.assertEqual(frappe.db.get_value("Job Card", production_item_job_card.name, "stock_uom"), "Nos")
frappe.db.set_value(
"Job Card", finished_good_job_card.name, "stock_uom", "Nos", update_modified=False
)
set_stock_uom_in_job_card()
self.assertEqual(frappe.db.get_value("Job Card", finished_good_job_card.name, "stock_uom"), "Nos")
def test_completion_qty_reduces_for_quantity_without_process_loss(self):
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
job_card = self.get_first_job_card(work_order.name)
job_card.append("time_logs", {"from_time": "2024-03-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=3,
for_quantity=3,
pending_qty=0,
process_loss_qty=0,
end_time="2024-03-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.for_quantity), 3)
self.assertEqual(flt(job_card.total_completed_qty), 3)
self.assertEqual(flt(job_card.process_loss_qty), 0)
def test_completion_qty_keeps_for_quantity_across_cycles(self):
work_order = make_wo_order_test_record(item="_Test FG Item 2", qty=5)
job_card = self.get_first_job_card(work_order.name)
job_card.append("time_logs", {"from_time": "2024-03-02 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=3,
for_quantity=5,
pending_qty=2,
process_loss_qty=0,
end_time="2024-03-02 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.for_quantity), 5)
self.assertEqual(flt(job_card.pending_qty), 2)
self.assertEqual(flt(job_card.process_loss_qty), 0)
job_card.append("time_logs", {"from_time": "2024-03-02 10:00:00"})
job_card.save()
job_card.complete_job_card(
qty=2,
for_quantity=2,
pending_qty=0,
process_loss_qty=0,
end_time="2024-03-02 11:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.for_quantity), 5)
self.assertEqual(flt(job_card.total_completed_qty), 5)
self.assertEqual(flt(job_card.process_loss_qty), 0)
def test_op_cost_calculation(self):
from erpnext.manufacturing.doctype.routing.test_routing import (
create_routing,
@@ -1265,6 +1409,440 @@ class TestJobCard(ERPNextTestSuite):
8,
)
def test_semi_fg_process_loss_rolls_up_to_work_order(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Process Loss Rollup RM 1", {"is_stock_item": 1}).name
fg = make_item("Process Loss Rollup FG 1", {"is_stock_item": 1}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Process Loss Rollup Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = frappe.get_doc(
"Job Card",
frappe.get_all(
"Job Card",
filters={"work_order": work_order.name},
order_by="sequence_id, creation",
limit=1,
pluck="name",
)[0],
)
job_card.append("time_logs", {"from_time": "2024-05-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=8,
for_quantity=10,
pending_qty=0,
process_loss_qty=2,
end_time="2024-05-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.process_loss_qty), 2)
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
self.assertEqual(
flt(
frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "process_loss_qty")
),
2,
)
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(flt(work_order.process_loss_qty), 2)
self.assertEqual(work_order.status, "Completed")
def test_semi_fg_process_loss_of_an_intermediate_operation_rolls_up_to_work_order(self):
"""Loss booked by an earlier operation shrinks what the final operation can produce,
so it has to show up on the work order even though the final operation loses nothing."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Intermediate Loss RM 1", {"is_stock_item": 1}).name
sfg = make_item("Intermediate Loss SFG 1", {"is_stock_item": 1}).name
fg = make_item("Intermediate Loss FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operations = [
{
"operation": "Intermediate Loss Op A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"sequence_id": 1,
},
{
"operation": "Intermediate Loss Op B",
"finished_good": fg,
"is_final_finished_good": 1,
"sequence_id": 2,
},
]
for row in operations:
row.update(
{
"workstation": "_Test Workstation A",
"finished_good_qty": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
)
make_workstation(row)
make_operation(row)
fg_bom.append("operations", row)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=10,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
for row in work_order.operations:
row.time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
def get_job_card(operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card",
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
"name",
),
)
jc_a = get_job_card("Intermediate Loss Op A")
jc_a.append("time_logs", {"from_time": "2024-06-01 08:00:00"})
jc_a.save()
jc_a.complete_job_card(
qty=8, for_quantity=10, pending_qty=0, process_loss_qty=2, end_time="2024-06-01 09:00:00"
)
jc_a.reload()
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.process_loss_qty), 2)
jc_b = get_job_card("Intermediate Loss Op B")
jc_b.for_quantity = 8
for row in jc_b.items:
row.required_qty = 8
jc_b.append(
"time_logs",
{"from_time": "2024-06-02 08:00:00", "to_time": "2024-06-02 09:00:00", "completed_qty": 8},
)
jc_b.save()
jc_b.submit()
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(flt(work_order.process_loss_qty), 2)
self.assertEqual(work_order.status, "Completed")
def test_semi_fg_pending_qty_is_left_to_another_job_card(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Pending Qty RM 1", {"is_stock_item": 1}).name
fg = make_item("Pending Qty FG 1", {"is_stock_item": 1}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Pending Qty Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = frappe.get_doc(
"Job Card",
frappe.get_all(
"Job Card",
filters={"work_order": work_order.name},
order_by="sequence_id, creation",
limit=1,
pluck="name",
)[0],
)
job_card.append("time_logs", {"from_time": "2024-04-01 08:00:00"})
job_card.save()
job_card.complete_job_card(
qty=3,
for_quantity=5,
pending_qty=2,
process_loss_qty=0,
end_time="2024-04-01 09:00:00",
)
job_card.reload()
self.assertEqual(flt(job_card.for_quantity), 5)
self.assertEqual(flt(job_card.pending_qty), 2)
self.assertEqual(flt(job_card.process_loss_qty), 0)
job_card.submit()
self.assertEqual(job_card.status, "Work In Progress")
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
finished_item = next(row for row in manufacturing_entry.items if row.is_finished_item)
self.assertEqual(flt(finished_item.qty), 3)
manufacturing_entry.submit()
job_card.reload()
self.assertEqual(flt(job_card.manufactured_qty), 3)
self.assertEqual(job_card.status, "Completed")
def test_semi_fg_sequence_needs_previous_operations_manufactured(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item("Sequence Check RM 1", {"is_stock_item": 1}).name
rm2 = make_item("Sequence Check RM 2", {"is_stock_item": 1}).name
sfg1 = make_item("Sequence Check SFG 1", {"is_stock_item": 1}).name
sfg2 = make_item("Sequence Check SFG 2", {"is_stock_item": 1}).name
fg = make_item("Sequence Check FG 1", {"is_stock_item": 1}).name
semi_fg_boms = {}
for semi_fg_item, raw_material in ((sfg1, rm1), (sfg2, rm2)):
bom = frappe.new_doc("BOM", company="_Test Company", item=semi_fg_item, quantity=1)
bom.append("items", {"item_code": raw_material, "qty": 1})
bom.insert()
bom.submit()
semi_fg_boms[semi_fg_item] = bom.name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operations = [
{
"operation": "Sequence Check Op A",
"finished_good": sfg1,
"bom_no": semi_fg_boms[sfg1],
"sequence_id": 1,
},
{
"operation": "Sequence Check Op B",
"finished_good": sfg2,
"bom_no": semi_fg_boms[sfg2],
"sequence_id": 1,
},
{
"operation": "Sequence Check Op C",
"finished_good": fg,
"is_final_finished_good": 1,
"sequence_id": 2,
},
]
for row in operations:
row.update(
{
"workstation": "_Test Workstation A",
"finished_good_qty": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
)
make_workstation(row)
make_operation(row)
fg_bom.append("operations", row)
fg_bom.append("items", {"item_code": sfg1, "qty": 1, "operation_row_id": 3})
fg_bom.append("items", {"item_code": sfg2, "qty": 1, "operation_row_id": 3})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
for row in work_order.operations:
row.time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
def get_job_card(operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card",
{"work_order": work_order.name, "operation": operation, "docstatus": 0},
"name",
),
)
def add_time_log(job_card, day, qty):
job_card.append(
"time_logs",
{
"from_time": f"2024-01-{day} 08:00:00",
"to_time": f"2024-01-{day} 09:00:00",
"completed_qty": qty,
},
)
jc_a = get_job_card("Sequence Check Op A")
jc_a.for_quantity = 3
add_time_log(jc_a, "01", 3)
jc_a.submit()
jc_b = get_job_card("Sequence Check Op B")
add_time_log(jc_b, "02", jc_b.for_quantity)
jc_b.submit()
frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item()).submit()
jc_c = get_job_card("Sequence Check Op C")
jc_c.for_quantity = 3
add_time_log(jc_c, "03", 3)
self.assertRaises(OperationSequenceError, jc_c.save)
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
jc_c.reload()
jc_c.for_quantity = 4
add_time_log(jc_c, "03", 4)
self.assertRaises(OperationSequenceError, jc_c.save)
jc_c.reload()
jc_c.for_quantity = 3
add_time_log(jc_c, "03", 3)
jc_c.submit()
self.assertEqual(jc_c.docstatus, 1)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
@@ -1402,6 +1980,299 @@ class TestJobCard(ERPNextTestSuite):
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
def test_manufacture_entry_process_loss_not_taken_from_previous_operation(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item("PL Scope RM 1", {"is_stock_item": 1}).name
rm2 = make_item("PL Scope RM 2", {"is_stock_item": 1}).name
sfg = make_item("PL Scope SFG 1", {"is_stock_item": 1}).name
fg1 = make_item("PL Scope FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operation1 = {
"operation": "PL Scope Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "PL Scope Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
)
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=sfg, target=warehouse, qty=10, basic_rate=100)
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
)
jc_a.pending_qty = 0
jc_a.process_loss_qty = 2
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
self.assertEqual(flt(me_a.process_loss_qty), 2.0)
jc_b = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "PL Scope Op B"}, "name"
),
)
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 2
jc_b.submit()
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
# operation A's loss must not leak into operation B's entry
self.assertEqual(flt(me_b.process_loss_qty), 0.0)
fg_row = next(row for row in me_b.items if row.is_finished_item)
self.assertEqual(flt(fg_row.qty), 3.0)
me_b.submit()
def make_semi_fg_work_order(self, prefix, qty=5):
"""Two-operation semi FG work order: Op A makes the SFG from RM 1, final Op B
consumes it. Both operations skip material transfer; stock is pre-seeded."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm1 = make_item(f"{prefix} RM 1", {"is_stock_item": 1}).name
rm2 = make_item(f"{prefix} RM 2", {"is_stock_item": 1}).name
sfg = make_item(f"{prefix} SFG 1", {"is_stock_item": 1}).name
fg1 = make_item(f"{prefix} FG 1", {"is_stock_item": 1}).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
operation1 = {
"operation": f"{prefix} Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": f"{prefix} Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
fg_bom.append("items", {"item_code": sfg, "qty": 1, "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=qty,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
)
for item_code in (rm1, rm2, sfg):
make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=100)
return work_order
def get_semi_fg_job_card(self, work_order, operation):
return frappe.get_doc(
"Job Card",
frappe.db.get_value("Job Card", {"work_order": work_order.name, "operation": operation}, "name"),
)
def test_partial_manufacture_entry_then_finish(self):
work_order = self.make_semi_fg_work_order("PL Partial")
jc_a = self.get_semi_fg_job_card(work_order, "PL Partial Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
)
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
jc_b = self.get_semi_fg_job_card(work_order, "PL Partial Op B")
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 0
jc_b.process_loss_qty = 2
jc_b.submit()
# book 1 of the 3 finished units now; the full process loss goes with this first entry,
# so it accounts for 3 of 5 and its materials are trimmed to the same share
first = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
fg_row = next(row for row in first.items if row.is_finished_item)
fg_row.qty = 1
for row in first.items:
if row.s_warehouse and not row.is_finished_item:
row.qty = flt(row.qty) * 3 / 5
first.save()
first.submit()
# the follow-up entry must be generated net of the already-booked loss and still submit
jc_b.reload()
second = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
fg_row = next(row for row in second.items if row.is_finished_item)
self.assertEqual(flt(fg_row.qty), 2.0)
self.assertEqual(flt(second.process_loss_qty), 0.0)
second.submit()
jc_b.reload()
self.assertEqual(flt(jc_b.manufactured_qty), 3.0)
# across both entries, consumption adds up to the job card's requirement of 5, no more
consumed = frappe.get_all(
"Stock Entry Detail",
filters={"parent": ["in", [first.name, second.name]], "s_warehouse": ["is", "set"]},
fields=["item_code", {"SUM": "qty", "as": "qty"}],
group_by="item_code",
)
self.assertTrue(consumed)
for row in consumed:
self.assertEqual(flt(row.qty), 5.0, f"{row.item_code} mis-consumed across partial entries")
def test_update_after_submit_keeps_manufacture_entry_intact(self):
work_order = self.make_semi_fg_work_order("PL Update")
jc_a = self.get_semi_fg_job_card(work_order, "PL Update Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 3},
)
jc_a.pending_qty = 0
jc_a.process_loss_qty = 2
jc_a.submit()
entry = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
entry.submit()
if not frappe.db.exists("Print Heading", "_Test SFG Heading"):
frappe.get_doc({"doctype": "Print Heading", "print_heading": "_Test SFG Heading"}).insert()
entry.reload()
entry.select_print_heading = "_Test SFG Heading"
entry.save()
entry.reload()
self.assertEqual(flt(entry.process_loss_qty), 2.0)
def test_stale_manufacture_draft_cannot_over_produce_without_operation_bom(self):
work_order = self.make_semi_fg_work_order("PL NoBom")
jc_a = self.get_semi_fg_job_card(work_order, "PL NoBom Op A")
jc_a.append(
"time_logs",
{"from_time": "2024-01-01 08:00:00", "to_time": "2024-01-01 09:00:00", "completed_qty": 5},
)
jc_a.submit()
frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item()).submit()
# Op B has no operation BOM, so its entries carry no For Quantity to validate against
jc_b = self.get_semi_fg_job_card(work_order, "PL NoBom Op B")
jc_b.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 3},
)
jc_b.pending_qty = 0
jc_b.process_loss_qty = 2
jc_b.submit()
draft_one = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
draft_two = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
draft_one.submit()
stale = frappe.get_doc("Stock Entry", draft_two.name)
self.assertRaises(frappe.ValidationError, stale.submit)
def test_semi_fg_auto_pull_with_uom_conversion(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
@@ -1817,6 +2688,54 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(s.additional_costs[2].amount, 480)
self.assertEqual(s.additional_costs[3].amount, 480)
def test_semi_fg_job_card_is_exempt_from_transfer_qty_check(self):
jc = frappe.new_doc("Job Card")
jc.track_semi_finished_goods = 1
jc.skip_material_transfer = 1
jc.for_quantity = 10
jc.transferred_qty = 0
jc.append("items", {"item_code": "_Test Item"})
jc.validate_transfer_qty()
# with transfer enabled, a legacy card without an FG item keeps the strict check
jc.skip_material_transfer = 0
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
jc.finished_good = "_Test Item"
jc.validate_transfer_qty()
jc.finished_good = None
jc.track_semi_finished_goods = 0
self.assertRaises(frappe.ValidationError, jc.validate_transfer_qty)
def test_qty_in_messages_carries_the_uom(self):
jc = frappe.new_doc("Job Card")
jc.stock_uom = "Nos"
self.assertEqual(jc.get_qty_with_uom(5), "5.0 Nos")
self.assertEqual(jc.get_qty_with_uom(0), "0.0 Nos")
def test_completion_qty_split_must_add_up(self):
jc = frappe.new_doc("Job Card")
jc.for_quantity = 5
jc.validate_completion_qty_split(
frappe._dict(for_quantity=5, qty=3, pending_qty=2, process_loss_qty=0)
)
self.assertRaises(
frappe.ValidationError,
jc.validate_completion_qty_split,
frappe._dict(for_quantity=3, qty=3, pending_qty=2, process_loss_qty=0),
)
self.assertRaises(
frappe.ValidationError,
jc.validate_completion_qty_split,
frappe._dict(for_quantity=1, qty=0.3334, pending_qty=0.3334, process_loss_qty=0.3334),
)
def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -193,6 +193,7 @@
"fieldname": "conversion_factor",
"fieldtype": "Float",
"label": "Conversion Factor",
"precision": "9",
"read_only": 1
},
{
@@ -266,7 +267,7 @@
"grid_page_length": 50,
"istable": 1,
"links": [],
"modified": "2025-10-30 17:01:25.996352",
"modified": "2026-08-07 17:31:31.732720",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Material Request Plan Item",

View File

@@ -1478,8 +1478,6 @@ def get_material_request_items(
)
)
required_qty = required_qty / row["conversion_factor"]
if frappe.db.get_value("UOM", row["purchase_uom"], "must_be_whole_number"):
required_qty = ceil(required_qty)
@@ -1498,10 +1496,11 @@ def get_material_request_items(
get_conversion_factor(row.item_code, item_details.purchase_uom).get("conversion_factor") or 1.0
)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
return {
"item_code": row.item_code,
"item_name": row.item_name,
"quantity": required_qty / conversion_factor,
"quantity": flt(required_qty / conversion_factor, precision),
"conversion_factor": conversion_factor,
"required_bom_qty": row.get("qty"),
"stock_uom": row.get("stock_uom"),
@@ -1910,7 +1909,7 @@ def get_materials_from_other_locations(
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
required_qty = ceil(required_qty)
item["quantity"] = required_qty / item.get("conversion_factor")
item["quantity"] = flt(required_qty / item.get("conversion_factor"), precision)
new_mr_items.append(item)

View File

@@ -1366,6 +1366,29 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(row.uom, "Nos")
self.assertEqual(row.qty, 1)
def test_material_request_item_quantity_rounded_to_precision(self):
from erpnext.stock.doctype.item.test_item import make_item
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
bom_item = make_item(
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
).name
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
doc = frappe.get_doc("Item", bom_item)
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
doc.save()
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1, stock_uom="_Test UOM 1"
)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
self.assertEqual(len(pln.mr_items), 1)
self.assertEqual(pln.mr_items[0].quantity, flt(10 / 3, precision))
def test_material_request_for_sub_assembly_items(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
@@ -2079,6 +2102,40 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(row.get("uom"), "Nos")
self.assertEqual(row.get("conversion_factor"), 10.0)
def test_remaining_purchase_qty_rounded_to_precision(self):
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
fg_item = make_item(properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1"}).name
bom_item = make_item(
properties={"is_stock_item": 1, "stock_uom": "_Test UOM 1", "purchase_uom": "Nos"}
).name
store_warehouse = create_warehouse("Store Warehouse", company="_Test Company")
rm_warehouse = create_warehouse("RM Warehouse", company="_Test Company")
make_stock_entry(item_code=bom_item, qty=4, target=store_warehouse, rate=100)
if not frappe.db.exists("UOM Conversion Detail", {"parent": bom_item, "uom": "Nos"}):
doc = frappe.get_doc("Item", bom_item)
doc.append("uoms", {"uom": "Nos", "conversion_factor": 3})
doc.save()
make_bom(item=fg_item, raw_materials=[bom_item], source_warehouse="_Test Warehouse - _TC")
pln = create_production_plan(
item_code=fg_item, planned_qty=30, stock_uom="_Test UOM 1", do_not_submit=1
)
pln.for_warehouse = rm_warehouse
pln.ignore_existing_ordered_qty = 1
items = get_items_for_material_requests(pln.as_dict(), warehouses=[{"warehouse": store_warehouse}])
rows_by_type = {row.get("material_request_type"): row for row in items}
self.assertEqual(rows_by_type["Material Transfer"].get("quantity"), 4)
precision = frappe.get_precision("Material Request Plan Item", "quantity")
self.assertEqual(rows_by_type["Purchase"].get("quantity"), flt(26 / 3, precision))
def test_unreserve_qty_on_closing_of_pp(self):
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_or_make_bin

View File

@@ -4839,6 +4839,24 @@ class TestWorkOrder(ERPNextTestSuite):
# generated qty (3.0 for 8 units) differs from the BOM-scaled qty (7.5 for 20 units)
self.assertEqual(flt(row.qty, 6), 3.0)
def test_wip_warehouse_required_when_tracking_semi_finished_goods(self):
wo = frappe.new_doc("Work Order")
wo.track_semi_finished_goods = 1
wo.skip_transfer = 0
wo.fg_warehouse = "_Test Warehouse 1 - _TC"
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
wo.wip_warehouse = "_Test Warehouse - _TC"
wo.validate_warehouse()
# the top-level target warehouse stays optional; operations may carry their own
wo.fg_warehouse = None
wo.validate_warehouse()
wo.track_semi_finished_goods = 0
self.assertRaises(frappe.ValidationError, wo.validate_warehouse)
def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -272,7 +272,7 @@
"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",
"mandatory_depends_on": "eval:!doc.skip_transfer || doc.from_wip_warehouse",
"options": "Warehouse"
},
{
@@ -739,7 +739,7 @@
"image_field": "image",
"is_submittable": 1,
"links": [],
"modified": "2026-06-03 21:35:34.175667",
"modified": "2026-08-08 12:00:00.000000",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order",

View File

@@ -883,6 +883,12 @@ class WorkOrder(Document):
return flt(query.run()[0][0])
def set_process_loss_qty(self):
self.db_set("process_loss_qty", self._process_loss_qty())
def _process_loss_qty(self):
if self.track_semi_finished_goods:
return flt(sum(flt(row.process_loss_qty) for row in self.operations))
table = frappe.qb.DocType("Stock Entry")
process_loss_qty = (
frappe.qb.from_(table)
@@ -892,7 +898,7 @@ class WorkOrder(Document):
)
).run()[0][0]
self.db_set("process_loss_qty", flt(process_loss_qty))
return flt(process_loss_qty)
def update_production_plan_status(self):
production_plan = frappe.get_doc("Production Plan", self.production_plan)
@@ -915,12 +921,9 @@ class WorkOrder(Document):
production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
def validate_warehouse(self):
if self.track_semi_finished_goods:
return
if not self.wip_warehouse and not self.skip_transfer:
frappe.throw(_("Work-in-Progress Warehouse is required before Submit"))
if not self.fg_warehouse:
if not self.fg_warehouse and not self.track_semi_finished_goods:
frappe.throw(_("Target Warehouse is required before Submit"))
def before_submit(self):
@@ -2822,6 +2825,7 @@ def get_operation_details(name, work_order, parent_bom):
for row in work_order.operations:
if row.name == name:
return {
"idx": row.idx,
"workstation": row.workstation,
"workstation_type": row.workstation_type,
"source_warehouse": row.source_warehouse,

View File

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

View File

@@ -24,7 +24,8 @@ def get_exploded_items(bom, data, indent=0, qty=1):
fields=[
"qty",
"bom_no",
"qty",
"bom_no.quantity as child_bom_qty",
"stock_qty",
"item_code",
"item_name",
"description",
@@ -51,7 +52,12 @@ def get_exploded_items(bom, data, indent=0, qty=1):
}
)
if item.bom_no:
get_exploded_items(item.bom_no, data, indent=indent + 1, qty=item.qty)
get_exploded_items(
item.bom_no,
data,
indent=indent + 1,
qty=qty * item.stock_qty / item.child_bom_qty,
)
def get_columns():

View File

@@ -0,0 +1,42 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
from unittest.mock import patch
import frappe
from erpnext.manufacturing.report.bom_explorer.bom_explorer import get_exploded_items
class TestBOMExplorer(unittest.TestCase):
def test_nested_bom_normalizes_and_accumulates_qty(self):
def item(item_code, qty, stock_qty, bom_no="", uom="Nos", child_bom_qty=None):
return frappe._dict(
item_code=item_code,
item_name=item_code,
description="",
qty=qty,
stock_qty=stock_qty,
bom_no=bom_no,
child_bom_qty=child_bom_qty,
uom=uom,
idx=1,
is_phantom_item=0,
)
children = {
"root": [item("parent", 2, 20, "parent-bom", "Box", 5)],
"parent-bom": [item("child", 3, 12, "child-bom", "Pack", 4)],
"child-bom": [item("raw-material", 2, 2, uom="Kg")],
}
def get_items(_doctype, filters, **kwargs):
self.assertIn("bom_no.quantity as child_bom_qty", kwargs["fields"])
return children[filters["parent"]]
data = []
with patch.object(frappe, "get_all", side_effect=get_items):
get_exploded_items("root", data)
self.assertEqual([row["qty"] for row in data], [2, 12, 24])

View File

@@ -496,3 +496,6 @@ erpnext.patches.v16_0.rename_ar_ap_ageing_filter
erpnext.patches.v16_0.fix_subcontracting_titles
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
erpnext.patches.v16_0.merge_seeded_item_group_root
erpnext.patches.v16_0.rename_italy_customer_name_fields
erpnext.patches.v16_0.set_stock_uom_in_job_card
erpnext.patches.v16_0.recalculate_purchase_receipt_billing_status

View File

@@ -1,3 +1,4 @@
import frappe
from frappe import qb
@@ -13,5 +14,8 @@ def execute():
"Payment Reconciliation Allocation",
]
for x in doctypes:
# child tables may not exist yet on sites where this pre-model-sync patch runs first
if not frappe.db.table_exists(x):
continue
dt = qb.DocType(x)
qb.from_(dt).delete().run()

View File

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

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