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

This commit is contained in:
diptanilsaha
2026-04-14 23:57:09 +05:30
committed by GitHub
67 changed files with 1562 additions and 1051 deletions

View File

@@ -1541,31 +1541,31 @@ def get_against_jv(doctype, txt, searchfield, start, page_len, filters):
if not frappe.db.has_column("Journal Entry", searchfield):
return []
return frappe.db.sql(
f"""
SELECT jv.name, jv.posting_date, jv.user_remark
FROM `tabJournal Entry` jv, `tabJournal Entry Account` jv_detail
WHERE jv_detail.parent = jv.name
AND jv_detail.account = %(account)s
AND IFNULL(jv_detail.party, '') = %(party)s
AND (
jv_detail.reference_type IS NULL
OR jv_detail.reference_type = ''
)
AND jv.docstatus = 1
AND jv.`{searchfield}` LIKE %(txt)s
ORDER BY jv.name DESC
LIMIT %(limit)s offset %(offset)s
""",
dict(
account=filters.get("account"),
party=cstr(filters.get("party")),
txt=f"%{txt}%",
offset=start,
limit=page_len,
),
JournalEntry = frappe.qb.DocType("Journal Entry")
JournalEntryAccount = frappe.qb.DocType("Journal Entry Account")
query = (
frappe.qb.from_(JournalEntry)
.join(JournalEntryAccount)
.on(JournalEntryAccount.parent == JournalEntry.name)
.select(JournalEntry.name, JournalEntry.posting_date, JournalEntry.user_remark)
.where(JournalEntryAccount.account == filters.get("account"))
.where(JournalEntryAccount.reference_type.isnull() | (JournalEntryAccount.reference_type == ""))
.where(JournalEntry.docstatus == 1)
.where(JournalEntry[searchfield].like(f"%{txt}%"))
.orderby(JournalEntry.name, order=frappe.qb.desc)
.limit(page_len)
.offset(start)
)
party = filters.get("party")
if party:
query = query.where(JournalEntryAccount.party == party)
else:
query = query.where(JournalEntryAccount.party.isnull() | (JournalEntryAccount.party == ""))
return query.run()
@frappe.whitelist()
def get_outstanding(args):

View File

@@ -824,7 +824,7 @@ frappe.ui.form.on("Payment Entry", {
paid_amount: function (frm) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
if (frm.doc.paid_amount) {
if (!frm.doc.received_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) {
@@ -845,7 +845,7 @@ frappe.ui.form.on("Payment Entry", {
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
);
if (frm.doc.received_amount) {
if (!frm.doc.paid_amount) {
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount);
if (frm.doc.target_exchange_rate) {

View File

@@ -332,9 +332,6 @@ class PurchaseInvoice(BuyingController):
if self.bill_date:
self.remarks += " " + _("dated {0}").format(formatdate(self.bill_date))
else:
self.remarks = _("No Remarks")
def set_missing_values(self, for_validate=False):
if not self.credit_to:
self.credit_to = get_party_account("Supplier", self.supplier, self.company)

View File

@@ -740,7 +740,6 @@
"label": "Valuation Rate",
"no_copy": 1,
"options": "Company:company:default_currency",
"precision": "6",
"print_hide": 1,
"read_only": 1
},
@@ -1008,7 +1007,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-03-25 18:03:33.522195",
"modified": "2026-04-07 15:40:45.687554",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice Item",

View File

@@ -165,13 +165,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
);
}
}
// Show buttons only when pos view is active
if (cint(doc.docstatus == 0) && this.frm.page.current_view_name !== "pos" && !doc.is_return) {
this.frm.cscript.sales_order_btn();
this.frm.cscript.delivery_note_btn();
this.frm.cscript.quotation_btn();
}
this.toggle_get_items();
this.set_default_print_format();
if (doc.docstatus == 1 && !doc.inter_company_invoice_reference) {
@@ -260,6 +254,93 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
}
}
toggle_get_items() {
const buttons = ["Sales Order", "Quotation", "Timesheet", "Delivery Note"];
buttons.forEach((label) => {
this.frm.remove_custom_button(label, "Get Items From");
});
if (cint(this.frm.doc.docstatus) !== 0 || this.frm.page.current_view_name === "pos") {
return;
}
if (!this.frm.doc.is_return) {
this.frm.cscript.sales_order_btn();
this.frm.cscript.quotation_btn();
this.frm.cscript.timesheet_btn();
}
this.frm.cscript.delivery_note_btn();
}
timesheet_btn() {
var me = this;
me.frm.add_custom_button(
__("Timesheet"),
function () {
let d = new frappe.ui.Dialog({
title: __("Fetch Timesheet"),
fields: [
{
label: __("From"),
fieldname: "from_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Item Code"),
fieldname: "item_code",
fieldtype: "Link",
options: "Item",
get_query: () => {
return {
query: "erpnext.controllers.queries.item_query",
filters: {
is_sales_item: 1,
customer: me.frm.doc.customer,
has_variants: 0,
},
};
},
},
{
fieldtype: "Column Break",
fieldname: "col_break_1",
},
{
label: __("To"),
fieldname: "to_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Project"),
fieldname: "project",
fieldtype: "Link",
options: "Project",
default: me.frm.doc.project,
},
],
primary_action: function () {
const data = d.get_values();
me.frm.events.add_timesheet_data(me.frm, {
from_time: data.from_time,
to_time: data.to_time,
project: data.project,
item_code: data.item_code,
});
d.hide();
},
primary_action_label: __("Get Timesheets"),
});
d.show();
},
__("Get Items From")
);
}
sales_order_btn() {
var me = this;
@@ -331,6 +412,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
this.$delivery_note_btn = this.frm.add_custom_button(
__("Delivery Note"),
function () {
if (!me.frm.doc.customer) {
frappe.throw({
title: __("Mandatory"),
message: __("Please Select a Customer"),
});
}
erpnext.utils.map_current_doc({
method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
source_doctype: "Delivery Note",
@@ -343,7 +430,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
var filters = {
docstatus: 1,
company: me.frm.doc.company,
is_return: 0,
is_return: me.frm.doc.is_return,
};
if (me.frm.doc.customer) filters["customer"] = me.frm.doc.customer;
return {
@@ -610,6 +697,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
apply_tds(frm) {
this.frm.clear_table("tax_withholding_entries");
}
is_return() {
this.toggle_get_items();
}
};
// for backward compatibility: combine new and previous states
@@ -1061,71 +1152,6 @@ frappe.ui.form.on("Sales Invoice", {
},
refresh: function (frm) {
if (frm.doc.docstatus === 0 && !frm.doc.is_return) {
frm.add_custom_button(
__("Timesheet"),
function () {
let d = new frappe.ui.Dialog({
title: __("Fetch Timesheet"),
fields: [
{
label: __("From"),
fieldname: "from_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Item Code"),
fieldname: "item_code",
fieldtype: "Link",
options: "Item",
get_query: () => {
return {
query: "erpnext.controllers.queries.item_query",
filters: {
is_sales_item: 1,
customer: frm.doc.customer,
has_variants: 0,
},
};
},
},
{
fieldtype: "Column Break",
fieldname: "col_break_1",
},
{
label: __("To"),
fieldname: "to_time",
fieldtype: "Date",
reqd: 1,
},
{
label: __("Project"),
fieldname: "project",
fieldtype: "Link",
options: "Project",
default: frm.doc.project,
},
],
primary_action: function () {
const data = d.get_values();
frm.events.add_timesheet_data(frm, {
from_time: data.from_time,
to_time: data.to_time,
project: data.project,
item_code: data.item_code,
});
d.hide();
},
primary_action_label: __("Get Timesheets"),
});
d.show();
},
__("Get Items From")
);
}
if (frm.doc.is_debit_note) {
frm.set_df_property("return_against", "label", __("Adjustment Against"));
}

View File

@@ -1101,9 +1101,6 @@ class SalesInvoice(SellingController):
if self.po_date:
self.remarks += " " + _("dated {0}").format(formatdate(self.po_date))
else:
self.remarks = _("No Remarks")
def validate_auto_set_posting_time(self):
# Don't auto set the posting date and time if invoice is amended
if self.is_new() and self.amended_from:

View File

@@ -2887,7 +2887,7 @@ class TestSalesInvoice(ERPNextTestSuite):
si.submit()
# Check if adjustment entry is created
self.assertTrue(
self.assertFalse(
frappe.db.exists(
"GL Entry",
{

View File

@@ -120,12 +120,12 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
expected_data = [[100, 30, "No Remarks"], [100, 50, "No Remarks"], [100, 20, "No Remarks"]]
expected_data = [[100, 30], [100, 50], [100, 20]]
for i in range(3):
row = report[1][i - 1]
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced])
self.assertFalse(row.get("remarks"))
# check invoice grand total, invoiced, paid and outstanding column's value after payment
self.create_payment_entry(si.name)
report = execute(filters)
@@ -178,11 +178,11 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
expected_data = [[100, 30, "No Remarks"], [100, 50, "No Remarks"], [100, 20, "No Remarks"]]
expected_data = [[100, 30], [100, 50], [100, 20]]
for i in range(3):
row = report[1][i - 1]
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced])
# check invoice grand total, invoiced, paid and outstanding column's value after credit note
cr_note = self.create_credit_note(si.name, do_not_submit=True)
@@ -225,9 +225,10 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
row = report[1][0]
expected_data = [8000, 8000, "No Remarks"] # Data in company currency
expected_data = [8000, 8000] # Data in company currency
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced])
self.assertFalse(row.get("remarks"))
# CASE 2: Transaction currency and party account currency are the same
self.create_customer(
@@ -258,18 +259,20 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
row = report[1][0]
expected_data = [100, 100, "No Remarks"] # Data in Part Account Currency
expected_data = [100, 100] # Data in Part Account Currency
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced])
self.assertFalse(row.get("remarks"))
# View in Company currency
filters.pop("in_party_currency")
report = execute(filters)
row = report[1][0]
expected_data = [8000, 8000, "No Remarks"] # Data in Company Currency
expected_data = [8000, 8000] # Data in Company Currency
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced])
self.assertFalse(row.get("remarks"))
def test_accounts_receivable_with_partial_payment(self):
filters = {
@@ -285,11 +288,12 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
expected_data = [[200, 60, "No Remarks"], [200, 100, "No Remarks"], [200, 40, "No Remarks"]]
expected_data = [[200, 60], [200, 100], [200, 40]]
for i in range(3):
row = report[1][i - 1]
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced])
self.assertFalse(row.get("remarks"))
# check invoice grand total, invoiced, paid and outstanding column's value after payment
self.create_payment_entry(si.name)
@@ -348,11 +352,12 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters)
expected_data = [100, 100, "No Remarks"]
expected_data = [100, 100]
self.assertEqual(len(report[1]), 1)
row = report[1][0]
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced])
self.assertFalse(row.get("remarks"))
# check invoice grand total, invoiced, paid and outstanding column's value after payment
self.create_payment_entry(si.name)

View File

@@ -142,6 +142,7 @@ def prepare_data(accounts, filters, company_currency, dimension_list):
total = 0
row = {
"account": d.name,
"is_group": d.is_group,
"parent_account": d.parent_account,
"indent": d.indent,
"from_date": filters.from_date,

View File

@@ -578,7 +578,11 @@ class GrossProfitGenerator:
# get buying rate
if flt(row.qty):
row.buying_rate = flt(row.buying_amount / flt(row.qty), self.float_precision)
row.buying_rate = (
flt(row.buying_amount / flt(row.qty), self.float_precision)
if not row.delivered_by_supplier
else None
)
row.base_rate = flt(row.base_amount / flt(row.qty), self.float_precision)
else:
if self.is_not_invoice_row(row):
@@ -630,7 +634,8 @@ class GrossProfitGenerator:
returned_item_row.qty += row.qty
returned_item_row.base_amount += row.base_amount
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
if not row.delivered_by_supplier:
row.buying_amount = flt(flt(row.qty) * flt(row.buying_rate), self.currency_precision)
def get_average_rate_based_on_group_by(self):
for key in list(self.grouped):
@@ -799,6 +804,26 @@ class GrossProfitGenerator:
return self.calculate_buying_amount_from_sle(
row, my_sle, parenttype, parent, item_row, item_code
)
elif (
row.delivered_by_supplier
and row.so_detail
and (
po_details := frappe.get_all(
"Purchase Order Item",
filters={"sales_order_item": row.so_detail, "docstatus": 1},
pluck="name",
)
)
):
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.stock_qty * table.base_net_rate))
.where((table.po_detail.isin(po_details)) & (table.docstatus == 1))
)
return flt(query.run()[0][0])
elif row.sales_order and row.so_detail:
incoming_amount = self.get_buying_amount_from_so_dn(row.sales_order, row.so_detail, item_code)
if incoming_amount:
@@ -951,6 +976,7 @@ class GrossProfitGenerator:
SalesInvoice.is_return,
SalesInvoiceItem.cost_center,
SalesInvoiceItem.serial_and_batch_bundle,
SalesInvoiceItem.delivered_by_supplier,
)
if self.filters.group_by == "Sales Person":

View File

@@ -731,6 +731,31 @@ class TestGrossProfit(ERPNextTestSuite):
self.assertEqual(total[7], 1000.0)
self.assertEqual(total[8], 100.0)
def test_drop_ship(self):
from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice
from erpnext.selling.doctype.sales_order.sales_order import make_purchase_order, make_sales_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
item = make_item("_Test Drop Ship Item", properties={"is_stock_item": 1, "delivered_by_supplier": 1})
so = make_sales_order(item=item.name, qty=10, rate=100)
po = make_purchase_order(so.name, selected_items=[so.items[0]])[0]
po.items[0].rate = 80
po.supplier = "_Test Supplier"
po.submit()
make_purchase_invoice(po.name).submit()
si = make_sales_invoice(so.name).submit()
filters = frappe._dict(
company=si.company, from_date=si.posting_date, to_date=si.posting_date, group_by="Invoice"
)
_, data = execute(filters=filters)
self.assertEqual(data[1].buying_amount, 800)
self.assertIsNone(data[1].buying_rate)
self.assertEqual(data[1]["gross_profit_%"], 20)
def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}):

View File

@@ -1603,6 +1603,10 @@ def parse_naming_series_variable(doc, variable):
else:
data = {"YY": "%y", "YYYY": "%Y", "MM": "%m", "DD": "%d", "JJJ": "%j"}
if doc and doc.doctype in ["Batch", "Serial No"] and doc.reference_doctype and doc.reference_name:
doc = frappe.get_doc(doc.reference_doctype, doc.reference_name)
date = (
(
getdate(doc.get("posting_date") or doc.get("transaction_date") or doc.get("posting_datetime"))

View File

@@ -41,7 +41,7 @@ frappe.ui.form.on("Asset Movement", {
});
},
onload: (frm) => {
refresh: (frm) => {
frm.trigger("set_required_fields");
},

View File

@@ -285,7 +285,7 @@ class RequestforQuotation(BuyingController):
}
)
user.save(ignore_permissions=True)
update_password_link = user.reset_password()
update_password_link = user._reset_password()
return user, update_password_link

View File

@@ -4357,6 +4357,15 @@ def get_missing_company_details(doctype, docname):
address_display_list = get_address_display_list("Company", company)
address_line = address_display_list[0].get("address_line1") if address_display_list else ""
needs_new_company_address = not address_line
if needs_new_company_address and not frappe.has_permission("Address", "create", throw=False):
frappe.msgprint(
_(
"Company Address is missing. You don't have permission to create an Address. Please contact your System Manager."
)
)
return
required_fields.append(company_address)
required_fields.append(address_line)
@@ -4379,6 +4388,18 @@ def get_missing_company_details(doctype, docname):
def update_company_master_and_address(current_doctype, name, company, details):
from frappe.utils import validate_email_address
if not frappe.has_permission(current_doctype, "write", doc=name, throw=False):
frappe.throw(
_("You don't have permission to update this document. Please contact your System Manager."),
title=_("Insufficient Permissions"),
)
if not frappe.has_permission("Company", "write", doc=company, throw=False):
frappe.throw(
_("You don't have permission to update Company details. Please contact your System Manager."),
title=_("Insufficient Permissions"),
)
if isinstance(details, str):
details = frappe.parse_json(details)
@@ -4393,6 +4414,13 @@ def update_company_master_and_address(current_doctype, name, company, details):
company_address = details.get("company_address")
if details.get("address_line1"):
if not frappe.has_permission("Address", "create", throw=False):
frappe.throw(
_(
"You don't have permission to create a Company Address. Please contact your System Manager."
),
title=_("Insufficient Permissions"),
)
address_doc = frappe.get_doc(
{
"doctype": "Address",
@@ -4425,6 +4453,7 @@ def update_doc_company_address(current_doctype, docname, company_address, detail
"Sales Invoice": ("company_address", "company_address_display"),
"Delivery Note": ("company_address", "company_address_display"),
"POS Invoice": ("company_address", "company_address_display"),
"Quotation": ("company_address", "company_address_display"),
"Request for Quotation": ("shipping_address", "shipping_address_display"),
}

View File

@@ -364,38 +364,43 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, filters, as_dict):
doctype = "Delivery Note"
def get_delivery_notes_to_be_billed(
doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict, as_dict: bool = False
):
DeliveryNote = frappe.qb.DocType("Delivery Note")
fields = get_fields(doctype, ["name", "customer", "posting_date"])
return frappe.db.sql(
"""
select {fields}
from `tabDelivery Note`
where `tabDelivery Note`.`{key}` like {txt} and
`tabDelivery Note`.docstatus = 1
and status not in ('Stopped', 'Closed') {fcond}
and (
(`tabDelivery Note`.is_return = 0 and `tabDelivery Note`.per_billed < 100)
or (`tabDelivery Note`.grand_total = 0 and `tabDelivery Note`.per_billed < 100)
or (
`tabDelivery Note`.is_return = 1
and return_against in (select name from `tabDelivery Note` where per_billed < 100)
original_dn = (
frappe.qb.from_(DeliveryNote)
.select(DeliveryNote.name)
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
)
query = (
frappe.qb.from_(DeliveryNote)
.select(*[DeliveryNote[f] for f in fields])
.where(
(DeliveryNote.docstatus == 1)
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
& (DeliveryNote[searchfield].like(f"%{txt}%"))
& (
((DeliveryNote.is_return == 0) & (DeliveryNote.per_billed < 100))
| ((DeliveryNote.grand_total == 0) & (DeliveryNote.per_billed < 100))
| (
(DeliveryNote.is_return == 1)
& (DeliveryNote.per_billed < 100)
& (DeliveryNote.return_against.isin(original_dn))
)
)
{mcond} order by `tabDelivery Note`.`{key}` asc limit {page_len} offset {start}
""".format(
fields=", ".join([f"`tabDelivery Note`.{f}" for f in fields]),
key=searchfield,
fcond=get_filters_cond(doctype, filters, []),
mcond=get_match_cond(doctype),
start=start,
page_len=page_len,
txt="%(txt)s",
),
{"txt": ("%%%s%%" % txt)},
as_dict=as_dict,
)
)
if filters and isinstance(filters, dict):
for key, value in filters.items():
query = query.where(DeliveryNote[key] == value)
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
return query.run(as_dict=as_dict)
@frappe.whitelist()

View File

@@ -634,11 +634,11 @@ class SellingController(StockController):
if allow_at_arms_length_price:
continue
rate = flt(
flt(d.incoming_rate, d.precision("incoming_rate")) * d.conversion_factor,
d.precision("rate"),
)
if d.rate != rate:
rate = flt(flt(d.incoming_rate) * flt(d.conversion_factor or 1.0))
if flt(d.rate, d.precision("incoming_rate")) != flt(
rate, d.precision("incoming_rate")
):
d.rate = rate
frappe.msgprint(
_(

View File

@@ -341,7 +341,7 @@ class StatusUpdater(Document):
item_details.extend(self.fetch_items_with_pending_qty(args, "item_code", regular_items))
# Query production plan items with production_item field
if pp_items:
if pp_items and args.get("target_dt") in ["Production Plan Sub Assembly Item"]:
item_details.extend(self.fetch_items_with_pending_qty(args, "production_item", pp_items))
item_lookup = {item.name: item for item in item_details}

View File

@@ -167,8 +167,11 @@ class calculate_taxes_and_totals:
bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
)
do_not_round_fields = ["valuation_rate", "incoming_rate"]
for item in self.doc.items:
self.doc.round_floats_in(item)
self.doc.round_floats_in(item, do_not_round_fields=do_not_round_fields)
if item.discount_percentage == 100:
item.rate = 0.0

View File

@@ -4,6 +4,12 @@
import frappe
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.custom import GROUP_CONCAT
from frappe.query_builder.functions import Date
Opportunity = DocType("Opportunity")
OpportunityLostReasonDetail = DocType("Opportunity Lost Reason Detail")
def execute(filters=None):
@@ -66,58 +72,48 @@ def get_columns():
def get_data(filters):
return frappe.db.sql(
f"""
SELECT
`tabOpportunity`.name,
`tabOpportunity`.opportunity_from,
`tabOpportunity`.party_name,
`tabOpportunity`.customer_name,
`tabOpportunity`.opportunity_type,
GROUP_CONCAT(`tabOpportunity Lost Reason Detail`.lost_reason separator ', ') lost_reason,
`tabOpportunity`.sales_stage,
`tabOpportunity`.territory
FROM
`tabOpportunity`
{get_join(filters)}
WHERE
`tabOpportunity`.status = 'Lost' and `tabOpportunity`.company = %(company)s
AND DATE(`tabOpportunity`.modified) BETWEEN %(from_date)s AND %(to_date)s
{get_conditions(filters)}
GROUP BY
`tabOpportunity`.name
ORDER BY
`tabOpportunity`.creation asc """,
filters,
as_dict=1,
query = (
frappe.qb.from_(Opportunity)
.left_join(OpportunityLostReasonDetail)
.on(
(OpportunityLostReasonDetail.parenttype == "Opportunity")
& (OpportunityLostReasonDetail.parent == Opportunity.name)
)
.select(
Opportunity.name,
Opportunity.opportunity_from,
Opportunity.party_name,
Opportunity.customer_name,
Opportunity.opportunity_type,
GROUP_CONCAT(OpportunityLostReasonDetail.lost_reason, alias="lost_reason").separator(", "),
Opportunity.sales_stage,
Opportunity.territory,
)
.where(
(Opportunity.status == "Lost")
& (Opportunity.company == filters.get("company"))
& (Date(Opportunity.modified).between(filters.get("from_date"), filters.get("to_date")))
)
.groupby(Opportunity.name)
.orderby(Opportunity.creation)
)
query = get_conditions(filters, query)
def get_conditions(filters):
conditions = []
return query.run(as_dict=1)
def get_conditions(filters, query):
if filters.get("territory"):
conditions.append(" and `tabOpportunity`.territory=%(territory)s")
query = query.where(Opportunity.territory == filters.get("territory"))
if filters.get("opportunity_from"):
conditions.append(" and `tabOpportunity`.opportunity_from=%(opportunity_from)s")
query = query.where(Opportunity.opportunity_from == filters.get("opportunity_from"))
if filters.get("party_name"):
conditions.append(" and `tabOpportunity`.party_name=%(party_name)s")
return " ".join(conditions) if conditions else ""
def get_join(filters):
join = """LEFT JOIN `tabOpportunity Lost Reason Detail`
ON `tabOpportunity Lost Reason Detail`.parenttype = 'Opportunity' and
`tabOpportunity Lost Reason Detail`.parent = `tabOpportunity`.name"""
query = query.where(Opportunity.party_name == filters.get("party_name"))
if filters.get("lost_reason"):
join = """JOIN `tabOpportunity Lost Reason Detail`
ON `tabOpportunity Lost Reason Detail`.parenttype = 'Opportunity' and
`tabOpportunity Lost Reason Detail`.parent = `tabOpportunity`.name and
`tabOpportunity Lost Reason Detail`.lost_reason = '{}'
""".format(filters.get("lost_reason"))
query = query.where(OpportunityLostReasonDetail.lost_reason == filters.get("lost_reason"))
return join
return query

File diff suppressed because it is too large Load Diff

View File

@@ -955,6 +955,8 @@ frappe.ui.form.on("BOM Item", "sourced_by_supplier", function (frm, cdt, cdn) {
if (d.sourced_by_supplier) {
d.rate = 0;
refresh_field("rate", d.name, d.parentfield);
} else {
get_bom_material_detail(frm.doc, cdt, cdn, false);
}
});

View File

@@ -1585,15 +1585,10 @@ def get_children(parent=None, is_root=False, **filters):
def add_additional_cost(stock_entry, work_order, job_card=None):
# Add non stock items cost in the additional cost
stock_entry.additional_costs = []
company_account = frappe.db.get_value(
expense_account = frappe.get_value(
"Company",
work_order.company,
["default_expense_account", "default_operating_cost_account"],
as_dict=1,
)
expense_account = (
company_account.default_operating_cost_account or company_account.default_expense_account
"default_operating_cost_account",
)
add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=job_card)
add_operations_cost(stock_entry, work_order, expense_account, job_card=job_card)
@@ -1676,7 +1671,7 @@ def add_operating_cost_component_wise(
per_unit_cost = flt(actual_cp_operating_cost) / flt(row.completed_qty - work_order.produced_qty)
if per_unit_cost and expense_account:
if per_unit_cost:
stock_entry.append(
"additional_costs",
{

View File

@@ -20,6 +20,7 @@
"is_subcontracted",
"is_final_finished_good",
"set_cost_based_on_bom_qty",
"quality_inspection_required",
"warehouse_section",
"skip_material_transfer",
"backflush_from_wip_warehouse",
@@ -290,13 +291,20 @@
"fieldname": "backflush_from_wip_warehouse",
"fieldtype": "Check",
"label": "Backflush Materials From WIP Warehouse"
},
{
"default": "0",
"depends_on": "eval:parent.inspection_required",
"fieldname": "quality_inspection_required",
"fieldtype": "Check",
"label": "Quality Inspection Required"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2026-03-31 17:09:48.771834",
"modified": "2026-04-01 17:09:48.771834",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Operation",

View File

@@ -35,6 +35,7 @@ class BOMOperation(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
quality_inspection_required: DF.Check
sequence_id: DF.Int
set_cost_based_on_bom_qty: DF.Check
skip_material_transfer: DF.Check

View File

@@ -1,7 +1,7 @@
{
"actions": [],
"autoname": "naming_series:",
"creation": "2018-07-09 17:23:29.518745",
"creation": "2026-03-31 21:06:16.282931",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -452,7 +452,6 @@
"show_dashboard": 1
},
{
"depends_on": "expected_start_date",
"fieldname": "scheduled_time_section",
"fieldtype": "Section Break",
"label": "Scheduled Time"
@@ -628,7 +627,7 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-02-26 15:13:56.767070",
"modified": "2026-03-31 21:06:48.987740",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -788,27 +788,27 @@ class JobCard(Document):
["action_if_quality_inspection_is_not_submitted", "action_if_quality_inspection_is_rejected"],
)
item = self.finished_good or self.production_item
bom_inspection_required = frappe.db.get_value(
"BOM", self.semi_fg_bom or self.bom_no, "inspection_required"
bom_inspection_required = frappe.get_value("BOM", self.bom_no, "inspection_required")
operation_inspection_required = frappe.get_value(
"Work Order Operation", self.operation_id, "quality_inspection_required"
)
if bom_inspection_required:
if bom_inspection_required and operation_inspection_required:
if not self.quality_inspection:
frappe.throw(
_(
"Quality Inspection is required for the item {0} before completing the job card {1}"
).format(get_link_to_form("Item", item), bold(self.name))
).format(get_link_to_form("Item", self.finished_good), bold(self.name))
)
qa_status, docstatus = frappe.db.get_value(
qa_status, docstatus = frappe.get_value(
"Quality Inspection", self.quality_inspection, ["status", "docstatus"]
)
if docstatus != 1:
if action_submit == "Stop":
frappe.throw(
_("Quality Inspection {0} is not submitted for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
get_link_to_form("Item", self.finished_good),
),
title=_("Inspection Submission"),
exc=QualityInspectionNotSubmittedError,
@@ -817,7 +817,7 @@ class JobCard(Document):
frappe.msgprint(
_("Quality Inspection {0} is not submitted for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
get_link_to_form("Item", self.finished_good),
),
alert=True,
indicator="orange",
@@ -827,7 +827,7 @@ class JobCard(Document):
frappe.throw(
_("Quality Inspection {0} is rejected for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
get_link_to_form("Item", self.finished_good),
),
title=_("Inspection Rejected"),
exc=QualityInspectionRejectedError,
@@ -836,7 +836,7 @@ class JobCard(Document):
frappe.msgprint(
_("Quality Inspection {0} is rejected for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection),
get_link_to_form("Item", item),
get_link_to_form("Item", self.finished_good),
),
alert=True,
indicator="orange",

View File

@@ -87,6 +87,7 @@ class TestJobCard(ERPNextTestSuite):
with_operations=1,
track_semi_finished_goods=1,
company="_Test Company",
inspection_required=1,
)
final_bom.append("items", {"item_code": raw.name, "qty": 1})
final_bom.append(
@@ -97,6 +98,7 @@ class TestJobCard(ERPNextTestSuite):
"bom_no": cut_bom,
"skip_material_transfer": 1,
"time_in_mins": 60,
"quality_inspection_required": 1,
},
)
final_bom.append(
@@ -133,6 +135,15 @@ class TestJobCard(ERPNextTestSuite):
work_order.submit()
job_card = frappe.get_all("Job Card", filters={"work_order": work_order.name, "operation": "Cutting"})
job_card_doc = frappe.get_doc("Job Card", job_card[0].name)
job_card_doc.append(
"time_logs",
{
"from_time": "2024-01-01 08:00:00",
"to_time": "2024-01-01 09:00:00",
"time_in_mins": 60,
"completed_qty": 1,
},
)
self.assertRaises(frappe.ValidationError, job_card_doc.submit)
def test_job_card_operations(self):

View File

@@ -16,7 +16,8 @@
"fieldtype": "Link",
"in_list_view": 1,
"label": "Operation",
"options": "Operation"
"options": "Operation",
"reqd": 1
},
{
"default": "0",
@@ -40,7 +41,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-08-04 16:15:11.425349",
"modified": "2026-04-13 12:17:33.776504",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Sub Operation",

View File

@@ -16,7 +16,7 @@ class SubOperation(Document):
from frappe.types import DF
description: DF.SmallText | None
operation: DF.Link | None
operation: DF.Link
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data

View File

@@ -698,10 +698,14 @@ class TestWorkOrder(ERPNextTestSuite):
if not bom_name:
bom = make_bom(item=fg_item, rate=1000, raw_materials=[rm1], do_not_save=True)
bom.with_operations = 1
operation = make_operation(operation="Batch Size Operation")
operation.create_job_card_based_on_batch_size = 1
operation.save()
bom.append(
"operations",
{
"operation": "_Test Operation 1",
"operation": "Batch Size Operation",
"workstation": "_Test Workstation 1",
"description": "Test Data",
"operating_cost": 100,
@@ -4178,6 +4182,64 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0)
def test_operating_time(self):
workstation = make_workstation(workstation="Test Workstation for Operating Time")
raw_material = make_item(item_code="Raw Material 1", properties={"is_stock_item": 1})
subassembly_item = make_item(item_code="Subassembly Item", properties={"is_stock_item": 1})
subassembly_bom = make_bom(
item=subassembly_item.name,
quantity=5,
raw_materials=[raw_material.name],
rm_qty=25,
with_operations=1,
do_not_submit=True,
)
subassembly_operation = make_operation(operation="Subassembly Operation")
subassembly_bom.append(
"operations",
{
"operation": subassembly_operation.name,
"time_in_mins": 60,
"workstation": workstation.name,
},
)
subassembly_bom.save()
subassembly_bom.submit()
fg_item = make_item(item_code="FG Item", properties={"is_stock_item": 1})
fg_bom = make_bom(
item=fg_item.name,
quantity=50,
raw_materials=[subassembly_item.name],
rm_qty=3,
with_operations=1,
do_not_submit=True,
)
fg_operation = make_operation(operation="FG Operation")
fg_operation.create_job_card_based_on_batch_size = 1
fg_operation.batch_size = 25
fg_operation.save()
fg_bom.append(
"operations",
{
"operation": fg_operation.name,
"time_in_mins": 60,
"workstation": workstation.name,
},
)
fg_bom.items[0].do_not_explode = 0
fg_bom.items[0].bom_no = subassembly_bom.name
fg_bom.save()
fg_bom.submit()
wo_order = make_wo_order_test_record(
item=fg_item.name,
qty=100,
use_multi_level_bom=1,
)
self.assertEqual(wo_order.operations[0].time_in_mins, 72)
self.assertEqual(wo_order.operations[1].time_in_mins, 240)
def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

@@ -419,6 +419,7 @@ frappe.ui.form.on("Work Order", {
sequence_id: data.sequence_id,
skip_material_transfer: data.skip_material_transfer,
backflush_from_wip_warehouse: data.backflush_from_wip_warehouse,
time_in_mins: data.time_in_mins,
});
}
}

View File

@@ -1252,7 +1252,7 @@ class WorkOrder(Document):
def set_work_order_operations(self):
"""Fetch operations from BOM and set in 'Work Order'"""
def _get_operations(bom_no, qty=1):
def _get_operations(bom_no, qty=1, exploded=False):
data = frappe.get_all(
"BOM Operation",
filters={"parent": bom_no},
@@ -1277,16 +1277,20 @@ class WorkOrder(Document):
"skip_material_transfer",
"backflush_from_wip_warehouse",
"set_cost_based_on_bom_qty",
"quality_inspection_required",
],
order_by="idx",
)
for d in data:
if not d.fixed_time:
if d.set_cost_based_on_bom_qty:
d.time_in_mins = flt(d.time_in_mins) * flt(flt(qty) / flt(d.batch_size or 1))
if frappe.get_value("Operation", d.operation, "create_job_card_based_on_batch_size"):
qty = d.batch_size
if exploded:
d.time_in_mins *= flt(qty)
else:
d.time_in_mins = flt(d.time_in_mins) * flt(qty)
d.time_in_mins /= flt(qty)
d.status = "Pending"
@@ -1307,7 +1311,9 @@ class WorkOrder(Document):
for node in bom_traversal:
if node.is_bom:
operations.extend(_get_operations(node.name, qty=node.exploded_qty / node.bom_qty))
operations.extend(
_get_operations(node.name, qty=node.exploded_qty / node.bom_qty, exploded=True)
)
bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
operations.extend(_get_operations(self.bom_no, qty=bom_qty))
@@ -1321,7 +1327,7 @@ class WorkOrder(Document):
def calculate_time(self):
for d in self.get("operations"):
if not d.fixed_time:
d.time_in_mins = flt(d.time_in_mins) * (flt(self.qty) / flt(d.batch_size))
d.time_in_mins = flt(d.time_in_mins) * flt(self.qty)
self.calculate_operating_cost()
@@ -2630,6 +2636,7 @@ def validate_operation_data(row):
def create_job_card(work_order, row, enable_capacity_planning=False, auto_create=False):
doc = frappe.new_doc("Job Card")
qty = row.job_card_qty or work_order.get("qty", 0)
doc.update(
{
"work_order": work_order.name,
@@ -2638,7 +2645,7 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create
"workstation": row.get("workstation"),
"operation_row_id": cint(row.idx),
"posting_date": nowdate(),
"for_quantity": row.job_card_qty or work_order.get("qty", 0),
"for_quantity": qty,
"operation_id": row.get("name"),
"bom_no": work_order.bom_no,
"project": work_order.project,
@@ -2646,7 +2653,7 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create
"sequence_id": row.get("sequence_id"),
"hour_rate": row.get("hour_rate"),
"serial_no": row.get("serial_no"),
"time_required": row.get("time_in_mins"),
"time_required": (row.get("time_in_mins", 0) / work_order.qty) * qty,
"source_warehouse": row.get("source_warehouse") or work_order.get("source_warehouse"),
"target_warehouse": row.get("fg_warehouse") or work_order.get("fg_warehouse"),
"wip_warehouse": work_order.wip_warehouse or row.get("wip_warehouse")

View File

@@ -15,6 +15,7 @@
"workstation_type",
"workstation",
"sequence_id",
"quality_inspection_required",
"section_break_insy",
"bom_no",
"finished_good",
@@ -294,13 +295,19 @@
"fieldtype": "Check",
"label": "Backflush Materials From WIP Warehouse",
"read_only": 1
},
{
"default": "0",
"fieldname": "quality_inspection_required",
"fieldtype": "Check",
"label": "Quality Inspection Required"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-05-15 15:10:06.885440",
"modified": "2026-03-30 17:20:08.874381",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Work Order Operation",

View File

@@ -36,6 +36,7 @@ class WorkOrderOperation(Document):
planned_operating_cost: DF.Currency
planned_start_time: DF.Datetime | None
process_loss_qty: DF.Float
quality_inspection_required: DF.Check
sequence_id: DF.Int
skip_material_transfer: DF.Check
source_warehouse: DF.Link | None

View File

@@ -10,6 +10,6 @@ frappe.listview_settings["Workstation"] = {
Setup: "blue",
};
return [__(doc.status), color_map[doc.status], true];
return [__(doc.status), color_map[doc.status], "status,=," + doc.status];
},
};

View File

@@ -473,3 +473,4 @@ erpnext.patches.v16_0.enable_serial_batch_setting
erpnext.patches.v16_0.update_requested_qty_packed_item
erpnext.patches.v16_0.remove_payables_receivables_workspace
erpnext.patches.v16_0.co_by_product_patch
erpnext.patches.v16_0.depends_on_inv_dimensions

View File

@@ -0,0 +1,89 @@
import frappe
def get_inventory_dimensions():
return frappe.get_all(
"Inventory Dimension",
fields=[
"target_fieldname as fieldname",
"source_fieldname",
"reference_document as doctype",
"reqd",
"mandatory_depends_on",
],
order_by="creation",
distinct=True,
)
def get_display_depends_on(doctype, fieldname):
if doctype not in [
"Stock Entry Detail",
"Sales Invoice Item",
"Delivery Note Item",
"Purchase Invoice Item",
"Purchase Receipt Item",
]:
return None, None
fieldname_start_with = "to"
display_depends_on = ""
if doctype in ["Purchase Invoice Item", "Purchase Receipt Item"]:
display_depends_on = "eval:parent.is_internal_supplier == 1"
fieldname_start_with = "from"
elif doctype != "Stock Entry Detail":
display_depends_on = "eval:parent.is_internal_customer == 1"
elif doctype == "Stock Entry Detail":
display_depends_on = "eval:doc.t_warehouse"
return f"{fieldname_start_with}_{fieldname}", display_depends_on
def execute():
for dimension in get_inventory_dimensions():
if frappe.db.exists(
"Custom Field", {"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail"}
):
frappe.set_value(
"Custom Field",
{"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail"},
"depends_on",
"eval:doc.s_warehouse",
)
if frappe.db.exists(
"Custom Field", {"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail", "reqd": 1}
):
frappe.set_value(
"Custom Field",
{"fieldname": dimension.source_fieldname, "dt": "Stock Entry Detail", "reqd": 1},
{"mandatory_depends_on": "eval:doc.s_warehouse", "reqd": 0},
)
if frappe.db.exists(
"Custom Field",
{
"fieldname": f"to_{dimension.fieldname}",
"dt": "Stock Entry Detail",
"depends_on": "eval:parent.purpose != 'Material Issue'",
},
):
frappe.set_value(
"Custom Field",
{
"fieldname": f"to_{dimension.fieldname}",
"dt": "Stock Entry Detail",
"depends_on": "eval:parent.purpose != 'Material Issue'",
},
"depends_on",
"eval:doc.t_warehouse",
)
fieldname, display_depends_on = get_display_depends_on(dimension.doctype, dimension.fieldname)
if display_depends_on and frappe.db.exists(
"Custom Field", {"fieldname": fieldname, "dt": dimension.doctype}
):
frappe.set_value(
"Custom Field",
{"fieldname": fieldname, "dt": dimension.doctype},
"mandatory_depends_on",
display_depends_on if dimension.reqd else dimension.mandatory_depends_on,
)

View File

@@ -230,7 +230,10 @@ erpnext.financial_statements = {
value = default_formatter(value, row, column, data);
if (data && !data.parent_account && !data.parent_section) {
if (
data &&
((!data.parent_account && !data.parent_section) || data.is_group_account || data.is_group)
) {
value = $(`<span>${value}</span>`);
var $value = $(value).css("font-weight", "bold");

View File

@@ -5,6 +5,7 @@ const doctype_list = [
"Purchase Order",
"Purchase Invoice",
"POS Invoice",
"Quotation",
"Request for Quotation",
];
const allowed_print_formats = [
@@ -20,6 +21,8 @@ const allowed_print_formats = [
"Purchase Invoice with Item Image",
"POS Invoice Standard",
"POS Invoice with Item Image",
"Quotation Standard",
"Quotation with Item Image",
"Request for Quotation with Item Image",
];
const allowed_letterheads = ["Company Letterhead", "Company Letterhead - Grey"];

View File

@@ -93,10 +93,10 @@ class SellingSettings(Document):
self.validate_fallback_to_default_price_list()
if old_doc.enable_tracking_sales_commissions != self.enable_tracking_sales_commissions:
if old_doc and old_doc.enable_tracking_sales_commissions != self.enable_tracking_sales_commissions:
toggle_tracking_sales_commissions_section(not self.enable_tracking_sales_commissions)
if old_doc.enable_utm != self.enable_utm:
if old_doc and old_doc.enable_utm != self.enable_utm:
toggle_utm_analytics_section(not self.enable_utm)
def validate_fallback_to_default_price_list(self):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -310,6 +310,7 @@ def set_default_print_formats():
"Purchase Order": "Purchase Order with Item Image",
"Purchase Invoice": "Purchase Invoice with Item Image",
"POS Invoice": "POS Invoice with Item Image",
"Quotation": "Quotation with Item Image",
"Request for Quotation": "Request for Quotation with Item Image",
}

View File

@@ -759,7 +759,6 @@
"label": "Incoming Rate",
"no_copy": 1,
"options": "Company:company:default_currency",
"precision": "6",
"print_hide": 1,
"read_only": 1
},
@@ -953,7 +952,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2025-05-31 19:51:32.651562",
"modified": "2026-04-07 15:43:20.892151",
"modified_by": "Administrator",
"module": "Stock",
"name": "Delivery Note Item",

View File

@@ -41,6 +41,8 @@ frappe.ui.form.on("Delivery Trip", {
},
refresh: function (frm) {
frm.ignore_doctypes_on_cancel_all = ["Delivery Note"];
if (frm.doc.docstatus == 1 && frm.doc.delivery_stops.length > 0) {
frm.add_custom_button(__("Notify Customers via Email"), function () {
frm.trigger("notify_customers");

View File

@@ -8,9 +8,8 @@
"field_order": [
"dimension_details_tab",
"dimension_name",
"reference_document",
"column_break_4",
"disabled",
"reference_document",
"field_mapping_section",
"source_fieldname",
"column_break_9",
@@ -93,12 +92,6 @@
"fieldtype": "Check",
"label": "Apply to All Inventory Documents"
},
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{
"fieldname": "target_fieldname",
"fieldtype": "Data",
@@ -159,6 +152,7 @@
"label": "Conditional Rule Examples"
},
{
"depends_on": "eval:!doc.apply_to_all_doctypes",
"description": "To apply condition on parent field use parent.field_name and to apply condition on child table use doc.field_name. Here field_name could be based on the actual column name of the respective field.",
"fieldname": "mandatory_depends_on",
"fieldtype": "Small Text",
@@ -188,7 +182,7 @@
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2025-07-07 15:51:29.329064",
"modified": "2026-04-08 10:10:16.884388",
"modified_by": "Administrator",
"module": "Stock",
"name": "Inventory Dimension",

View File

@@ -32,7 +32,6 @@ class InventoryDimension(Document):
apply_to_all_doctypes: DF.Check
condition: DF.Code | None
dimension_name: DF.Data
disabled: DF.Check
document_type: DF.Link | None
fetch_from_parent: DF.Literal[None]
istable: DF.Check
@@ -76,7 +75,6 @@ class InventoryDimension(Document):
old_doc = self._doc_before_save
allow_to_edit_fields = [
"disabled",
"fetch_from_parent",
"type_of_transaction",
"condition",
@@ -120,6 +118,7 @@ class InventoryDimension(Document):
def reset_value(self):
if self.apply_to_all_doctypes:
self.type_of_transaction = ""
self.mandatory_depends_on = ""
self.istable = 0
for field in ["document_type", "condition"]:
@@ -184,8 +183,12 @@ class InventoryDimension(Document):
label=_(label),
depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "",
search_index=1,
reqd=self.reqd,
mandatory_depends_on=self.mandatory_depends_on,
reqd=1
if self.reqd and not self.mandatory_depends_on and doctype != "Stock Entry Detail"
else 0,
mandatory_depends_on="eval:doc.s_warehouse"
if self.reqd and doctype == "Stock Entry Detail"
else self.mandatory_depends_on,
),
]
@@ -296,12 +299,13 @@ class InventoryDimension(Document):
options=self.reference_document,
label=label,
depends_on=display_depends_on,
mandatory_depends_on=display_depends_on if self.reqd else self.mandatory_depends_on,
),
]
)
def field_exists(doctype, fieldname) -> str or None:
def field_exists(doctype, fieldname) -> str | None:
return frappe.db.get_value("DocField", {"parent": doctype, "fieldname": fieldname}, "name")
@@ -372,7 +376,6 @@ def get_document_wise_inventory_dimensions(doctype) -> dict:
"type_of_transaction",
"fetch_from_parent",
],
filters={"disabled": 0},
or_filters={"document_type": doctype, "apply_to_all_doctypes": 1},
)
@@ -389,7 +392,6 @@ def get_inventory_dimensions():
"validate_negative_stock",
"name as dimension_name",
],
filters={"disabled": 0},
order_by="creation",
distinct=True,
)

View File

@@ -211,9 +211,9 @@ class TestInventoryDimension(ERPNextTestSuite):
doc = create_inventory_dimension(
reference_document="Pallet",
type_of_transaction="Outward",
dimension_name="Pallet",
dimension_name="Pallet 75",
apply_to_all_doctypes=0,
document_type="Stock Entry Detail",
document_type="Delivery Note Item",
)
doc.reqd = 1
@@ -221,7 +221,7 @@ class TestInventoryDimension(ERPNextTestSuite):
self.assertTrue(
frappe.db.get_value(
"Custom Field", {"fieldname": "pallet", "dt": "Stock Entry Detail", "reqd": 1}, "name"
"Custom Field", {"fieldname": "pallet_75", "dt": "Delivery Note Item", "reqd": 1}, "name"
)
)

View File

@@ -87,16 +87,40 @@ frappe.ui.form.on("Item", {
toggle_has_serial_batch_fields(frm) {
let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0;
frm.toggle_display(["serial_no_series", "batch_number_series", "create_new_batch"], !hide_fields);
frm.toggle_display(
[
"serial_no_series",
"batch_number_series",
"create_new_batch",
"has_expiry_date",
"retain_sample",
],
!hide_fields
);
frm.toggle_enable(["has_serial_no", "has_batch_no"], !hide_fields);
if (hide_fields) {
let description = __(
"To enable the Serial No and Batch No feature, please check the 'Enable Serial / Batch No for Item' checkbox in Stock Settings."
);
let header = frm.fields_dict["serial_nos_and_batches"].wrapper;
let wrapper = header.find(".section-head.collapsible");
frm.set_df_property("has_serial_no", "description", description);
frm.set_df_property("has_batch_no", "description", description);
render_serial_batch_banner(wrapper);
if (!wrapper.data("banner-handler-added")) {
wrapper.data("banner-handler-added", true);
wrapper.on("click", function () {
setTimeout(() => {
let isCollapsed = $(this).hasClass("collapsed");
wrapper.find(".custom-serial-batch-banner").toggleClass("hidden", isCollapsed);
}, 10);
});
}
// Button action
wrapper.find(".go-to-settings").on("click", function () {
frappe.set_route("Form", "Stock Settings");
});
}
},
@@ -372,6 +396,63 @@ var set_customer_group = function (frm, cdt, cdn) {
return true;
};
function render_serial_batch_banner(wrapper) {
let hiddenClass = "";
if (wrapper.hasClass("collapsed")) {
hiddenClass = "hidden";
}
wrapper.find(".custom-serial-batch-banner").remove();
let banner_html = `
<div class="custom-serial-batch-banner ${hiddenClass}">
<div class="banner-content">
<span class="banner-icon">${frappe.utils.icon("solid-warning", "lg", "", "padding-bottom:2px")}</span>
<span class="banner-text">
${__("To use Serial / Batch feature, enable {0} in {1}.", [
`<b>${__("Activate Serial / Batch No for Item")}</b>`,
`<a class="go-to-settings" style="text-decoration: underline;">${__(
"Stock Settings"
)}</a>`,
])}
</span>
</div>
</div>
<style>
.custom-serial-batch-banner {
background-color: var(--amber-50);
border: 1px solid var(--amber-50);
border-radius: 8px;
padding: 12px 16px;
margin-top: 16px;
}
.custom-serial-batch-banner .banner-content {
display: flex;
align-items: center;
gap: 12px;
}
.custom-serial-batch-banner .banner-icon {
font-size: 18px;
}
.custom-serial-batch-banner .banner-text {
flex: 1;
font-size: 14px;
color: var(--gray-800);
}
.custom-serial-batch-banner .btn {
white-space: nowrap;
}
</style>
`;
// Insert banner at top of section
wrapper.append(banner_html);
}
$.extend(erpnext.item, {
setup_queries: function (frm) {
frm.fields_dict["item_defaults"].grid.get_field("expense_account").get_query = function (

View File

@@ -433,7 +433,7 @@
"depends_on": "eval:doc.is_stock_item",
"fieldname": "serial_nos_and_batches",
"fieldtype": "Section Break",
"label": "Serial Nos and Batches"
"label": "Serial Nos / Batches"
},
{
"default": "0",
@@ -995,7 +995,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-03-24 15:45:40.207531",
"modified": "2026-04-14 13:37:00.183583",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -735,7 +735,6 @@
"oldfieldname": "valuation_rate",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
"precision": "6",
"print_hide": 1,
"print_width": "80px",
"read_only": 1,
@@ -1150,7 +1149,7 @@
"idx": 1,
"istable": 1,
"links": [],
"modified": "2026-02-07 14:42:11.646809",
"modified": "2026-04-07 15:40:47.032889",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",

View File

@@ -58,6 +58,7 @@ frappe.ui.form.on("Quality Inspection", {
if (doc.reference_type && doc.reference_name) {
let filters = {
from: doctype,
parent_doctype: doc.reference_type,
inspection_type: doc.inspection_type,
};

View File

@@ -368,10 +368,11 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
from frappe.desk.reportview import get_match_cond
from_doctype = cstr(filters.get("from"))
parent_doctype = cstr(filters.get("parent_doctype"))
if not from_doctype or not frappe.db.exists("DocType", from_doctype):
return []
mcond = get_match_cond(from_doctype)
mcond = get_match_cond(parent_doctype or from_doctype)
cond, qi_condition = "", "and (quality_inspection is null or quality_inspection = '')"
if filters.get("parent"):

View File

@@ -148,7 +148,7 @@ class SerialandBatchBundle(Document):
if not frappe.db.get_single_value("Stock Settings", "enable_serial_and_batch_no_for_item"):
frappe.throw(
_(
"Please check the 'Enable Serial and Batch No for Item' checkbox in the {0} to make Serial and Batch Bundle for the item."
"Please check the 'Activate Serial and Batch No for Item' checkbox in the {0} to make Serial and Batch Bundle for the item."
).format(get_link_to_form("Stock Settings", "Stock Settings")),
title=_("Serial and Batch No for Item Disabled"),
)

View File

@@ -725,14 +725,6 @@ frappe.ui.form.on("Stock Entry", {
}
},
set_rate_and_fg_qty: function (frm, cdt, cdn) {
frm.events.set_basic_rate(frm, cdt, cdn);
let item = frappe.get_doc(cdt, cdn);
if (item.is_finished_item) {
frm.events.set_fg_completed_qty(frm);
}
},
get_warehouse_details: function (frm, cdt, cdn) {
var child = locals[cdt][cdn];
if (!child.bom_no) {
@@ -1021,7 +1013,7 @@ frappe.ui.form.on("Stock Entry Detail", {
},
conversion_factor(frm, cdt, cdn) {
frm.events.set_rate_and_fg_qty(frm, cdt, cdn);
frm.events.set_basic_rate(frm, cdt, cdn);
},
s_warehouse(frm, cdt, cdn) {

View File

@@ -3928,9 +3928,12 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None):
for d in work_order.get("operations"):
if flt(d.completed_qty):
operating_cost_per_unit += flt(
d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no)
) / flt(d.completed_qty - work_order.produced_qty)
if not (remaining_qty := flt(d.completed_qty - work_order.produced_qty)):
continue
operating_cost_per_unit += (
flt(d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no))
/ remaining_qty
)
elif work_order.qty:
operating_cost_per_unit += flt(d.planned_operating_cost) / flt(work_order.qty)

View File

@@ -1801,6 +1801,47 @@ class TestStockReconciliation(ERPNextTestSuite, StockTestMixin):
elif s.id_plant == plant_b.name:
self.assertEqual(s.actual_qty, 3)
def test_serial_no_status_with_backdated_stock_reco(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
item_code = self.make_item(
"Test Item",
{
"is_stock_item": 1,
"has_serial_no": 1,
"serial_no_series": "SERIAL.###",
},
).name
warehouse = "_Test Warehouse - _TC"
reco = create_stock_reconciliation(
item_code=item_code,
posting_date=add_days(nowdate(), -2),
warehouse=warehouse,
qty=1,
rate=80,
purpose="Opening Stock",
)
serial_no = get_serial_nos_from_bundle(reco.items[0].serial_and_batch_bundle)[0]
create_delivery_note(
item_code=item_code, warehouse=warehouse, qty=1, rate=100, posting_date=nowdate()
)
self.assertEqual(frappe.get_value("Serial No", serial_no, "status"), "Delivered")
reco = create_stock_reconciliation(
item_code=item_code,
posting_date=add_days(nowdate(), -1),
warehouse=warehouse,
qty=1,
rate=90,
)
self.assertEqual(frappe.get_value("Serial No", serial_no, "status"), "Delivered")
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -15,6 +15,12 @@ frappe.ui.form.on("Stock Settings", {
frm.set_query("sample_retention_warehouse", filters);
},
enable_serial_and_batch_no_for_item(frm) {
if (frm.doc.enable_serial_and_batch_no_for_item) {
frappe.msgprint(__("After save, please refresh the page to apply the changes."));
}
},
use_serial_batch_fields(frm) {
if (frm.doc.use_serial_batch_fields && !frm.doc.disable_serial_no_and_batch_selector) {
frm.set_value("disable_serial_no_and_batch_selector", 1);

View File

@@ -528,18 +528,18 @@
"label": "Allow Negative Stock for Batch"
},
{
"default": "0",
"fieldname": "enable_serial_and_batch_no_for_item",
"fieldtype": "Check",
"label": "Enable Serial / Batch No for Item"
"label": "Activate Serial / Batch No for Item"
}
],
"hide_toolbar": 0,
"icon": "icon-cog",
"idx": 1,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2026-03-27 22:39:16.812184",
"modified": "2026-04-14 13:51:49.545114",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -109,49 +109,23 @@ class Warehouse(NestedSet):
def warn_about_multiple_warehouse_account(self):
"If Warehouse value is split across multiple accounts, warn."
def get_accounts_where_value_is_booked(name):
sle = frappe.qb.DocType("Stock Ledger Entry")
gle = frappe.qb.DocType("GL Entry")
ac = frappe.qb.DocType("Account")
return (
frappe.qb.from_(sle)
.join(gle)
.on(sle.voucher_no == gle.voucher_no)
.join(ac)
.on(ac.name == gle.account)
.select(gle.account)
.distinct()
.where((sle.warehouse == name) & (ac.account_type == "Stock"))
.orderby(sle.creation)
.run(as_dict=True)
)
if self.is_new():
if not frappe.db.count("Stock Ledger Entry", {"warehouse": self.name}):
return
old_wh_account = frappe.db.get_value("Warehouse", self.name, "account")
doc_before_save = self.get_doc_before_save()
old_wh_account = doc_before_save.account if doc_before_save else None
# WH account is being changed or set get all accounts against which wh value is booked
if self.account != old_wh_account:
accounts = get_accounts_where_value_is_booked(self.name)
accounts = [d.account for d in accounts]
if self.is_new() or (self.account and old_wh_account == self.account):
return
if not accounts or (len(accounts) == 1 and self.account in accounts):
# if same singular account has stock value booked ignore
return
warning = _("Warehouse's Stock Value has already been booked in the following accounts:")
account_str = "<br>" + ", ".join(frappe.bold(ac) for ac in accounts)
reason = "<br><br>" + _(
"Booking stock value across multiple accounts will make it harder to track stock and account value."
)
frappe.msgprint(
warning + account_str + reason,
title=_("Multiple Warehouse Accounts"),
indicator="orange",
)
frappe.msgprint(
title=_("Warning: Account changed for warehouse"),
indicator="orange",
msg=_(
"Stock entries exist with the old account. Changing the account may lead to a mismatch between the warehouse closing balance and the account closing balance. The overall closing balance will still match, but not for the specific account."
),
alert=True,
)
def check_if_sle_exists(self, non_cancelled_only=False):
filters = {"warehouse": self.name}

View File

@@ -250,7 +250,7 @@ def get_item_warehouse_batch_map(filters, float_precision):
)
qty_dict.bal_qty = flt(qty_dict.bal_qty, float_precision) + flt(d.actual_qty, float_precision)
qty_dict.bal_value += flt(d.stock_value_difference, float_precision)
qty_dict.bal_value += flt(d.stock_value_difference)
return iwb_map

View File

@@ -119,7 +119,7 @@ frappe.query_reports["Stock Ledger"] = {
},
{
fieldname: "segregate_serial_batch_bundle",
label: __("Segregate Serial / Batch Bundle"),
label: __("Enable Serial / Batch Bundle"),
fieldtype: "Check",
default: 0,
},

View File

@@ -390,12 +390,21 @@ def get_columns(filters):
"options": "voucher_type",
"width": 100,
},
{
"label": _("Serial and Batch Bundle"),
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"options": "Serial and Batch Bundle",
"width": 150,
"hidden": not filters.get("segregate_serial_batch_bundle"),
},
{
"label": _("Batch"),
"fieldname": "batch_no",
"fieldtype": "Link",
"options": "Batch",
"width": 100,
"hidden": not filters.get("segregate_serial_batch_bundle"),
},
{
"label": _("Serial No"),
@@ -403,13 +412,7 @@ def get_columns(filters):
"fieldtype": "Link",
"options": "Serial No",
"width": 100,
},
{
"label": _("Serial and Batch Bundle"),
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"options": "Serial and Batch Bundle",
"width": 100,
"hidden": not filters.get("segregate_serial_batch_bundle"),
},
{
"label": _("Project"),

View File

@@ -556,6 +556,16 @@ class update_entries_after:
previous_sle = get_previous_sle_of_current_voucher(args)
if previous_sle:
self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = previous_sle
else:
self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = frappe._dict(
{
"qty_after_transaction": 0.0,
"valuation_rate": 0.0,
"stock_value": 0.0,
"prev_stock_value": 0.0,
"stock_queue": [],
}
)
warehouse_dict.previous_sle = previous_sle
@@ -1071,34 +1081,6 @@ class update_entries_after:
sabb_doc.voucher_no = None
sabb_doc.cancel()
if sle.serial_and_batch_bundle and frappe.get_cached_value("Item", sle.item_code, "has_serial_no"):
self.update_serial_no_status(sle)
def update_serial_no_status(self, sle):
from erpnext.stock.serial_batch_bundle import get_serial_nos
serial_nos = get_serial_nos(sle.serial_and_batch_bundle)
if not serial_nos:
return
warehouse = None
status = "Inactive"
if sle.actual_qty > 0:
warehouse = sle.warehouse
status = "Active"
sn_table = frappe.qb.DocType("Serial No")
query = (
frappe.qb.update(sn_table)
.set(sn_table.warehouse, warehouse)
.set(sn_table.status, status)
.where(sn_table.name.isin(serial_nos))
)
query.run()
def calculate_valuation_for_serial_batch_bundle(self, sle):
if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle):
return