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

View File

@@ -824,7 +824,7 @@ frappe.ui.form.on("Payment Entry", {
paid_amount: function (frm) { paid_amount: function (frm) {
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); 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; 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) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("received_amount", frm.doc.paid_amount); frm.set_value("received_amount", frm.doc.paid_amount);
} else if (company_currency == frm.doc.paid_to_account_currency) { } 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) 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) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
frm.set_value("paid_amount", frm.doc.received_amount); frm.set_value("paid_amount", frm.doc.received_amount);
if (frm.doc.target_exchange_rate) { if (frm.doc.target_exchange_rate) {

View File

@@ -332,9 +332,6 @@ class PurchaseInvoice(BuyingController):
if self.bill_date: if self.bill_date:
self.remarks += " " + _("dated {0}").format(formatdate(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): def set_missing_values(self, for_validate=False):
if not self.credit_to: if not self.credit_to:
self.credit_to = get_party_account("Supplier", self.supplier, self.company) self.credit_to = get_party_account("Supplier", self.supplier, self.company)

View File

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

View File

@@ -165,13 +165,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
); );
} }
} }
this.toggle_get_items();
// 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.set_default_print_format(); this.set_default_print_format();
if (doc.docstatus == 1 && !doc.inter_company_invoice_reference) { 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() { sales_order_btn() {
var me = this; var me = this;
@@ -331,6 +412,12 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
this.$delivery_note_btn = this.frm.add_custom_button( this.$delivery_note_btn = this.frm.add_custom_button(
__("Delivery Note"), __("Delivery Note"),
function () { function () {
if (!me.frm.doc.customer) {
frappe.throw({
title: __("Mandatory"),
message: __("Please Select a Customer"),
});
}
erpnext.utils.map_current_doc({ erpnext.utils.map_current_doc({
method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice", method: "erpnext.stock.doctype.delivery_note.delivery_note.make_sales_invoice",
source_doctype: "Delivery Note", source_doctype: "Delivery Note",
@@ -343,7 +430,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
var filters = { var filters = {
docstatus: 1, docstatus: 1,
company: me.frm.doc.company, 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; if (me.frm.doc.customer) filters["customer"] = me.frm.doc.customer;
return { return {
@@ -610,6 +697,10 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends (
apply_tds(frm) { apply_tds(frm) {
this.frm.clear_table("tax_withholding_entries"); this.frm.clear_table("tax_withholding_entries");
} }
is_return() {
this.toggle_get_items();
}
}; };
// for backward compatibility: combine new and previous states // for backward compatibility: combine new and previous states
@@ -1061,71 +1152,6 @@ frappe.ui.form.on("Sales Invoice", {
}, },
refresh: function (frm) { 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) { if (frm.doc.is_debit_note) {
frm.set_df_property("return_against", "label", __("Adjustment Against")); frm.set_df_property("return_against", "label", __("Adjustment Against"));
} }

View File

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

View File

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

View File

@@ -120,12 +120,12 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters) 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): for i in range(3):
row = report[1][i - 1] 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 # check invoice grand total, invoiced, paid and outstanding column's value after payment
self.create_payment_entry(si.name) self.create_payment_entry(si.name)
report = execute(filters) report = execute(filters)
@@ -178,11 +178,11 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters) 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): for i in range(3):
row = report[1][i - 1] 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 # 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) cr_note = self.create_credit_note(si.name, do_not_submit=True)
@@ -225,9 +225,10 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters) report = execute(filters)
row = report[1][0] 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 # CASE 2: Transaction currency and party account currency are the same
self.create_customer( self.create_customer(
@@ -258,18 +259,20 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters) report = execute(filters)
row = report[1][0] 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 # View in Company currency
filters.pop("in_party_currency") filters.pop("in_party_currency")
report = execute(filters) report = execute(filters)
row = report[1][0] 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): def test_accounts_receivable_with_partial_payment(self):
filters = { filters = {
@@ -285,11 +288,12 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters) 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): for i in range(3):
row = report[1][i - 1] 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 # check invoice grand total, invoiced, paid and outstanding column's value after payment
self.create_payment_entry(si.name) self.create_payment_entry(si.name)
@@ -348,11 +352,12 @@ class TestAccountsReceivable(ERPNextTestSuite, AccountsTestMixin):
report = execute(filters) report = execute(filters)
expected_data = [100, 100, "No Remarks"] expected_data = [100, 100]
self.assertEqual(len(report[1]), 1) self.assertEqual(len(report[1]), 1)
row = report[1][0] 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 # check invoice grand total, invoiced, paid and outstanding column's value after payment
self.create_payment_entry(si.name) self.create_payment_entry(si.name)

View File

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

View File

@@ -578,7 +578,11 @@ class GrossProfitGenerator:
# get buying rate # get buying rate
if flt(row.qty): 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) row.base_rate = flt(row.base_amount / flt(row.qty), self.float_precision)
else: else:
if self.is_not_invoice_row(row): if self.is_not_invoice_row(row):
@@ -630,7 +634,8 @@ class GrossProfitGenerator:
returned_item_row.qty += row.qty returned_item_row.qty += row.qty
returned_item_row.base_amount += row.base_amount 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): def get_average_rate_based_on_group_by(self):
for key in list(self.grouped): for key in list(self.grouped):
@@ -799,6 +804,26 @@ class GrossProfitGenerator:
return self.calculate_buying_amount_from_sle( return self.calculate_buying_amount_from_sle(
row, my_sle, parenttype, parent, item_row, item_code 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: 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) incoming_amount = self.get_buying_amount_from_so_dn(row.sales_order, row.so_detail, item_code)
if incoming_amount: if incoming_amount:
@@ -951,6 +976,7 @@ class GrossProfitGenerator:
SalesInvoice.is_return, SalesInvoice.is_return,
SalesInvoiceItem.cost_center, SalesInvoiceItem.cost_center,
SalesInvoiceItem.serial_and_batch_bundle, SalesInvoiceItem.serial_and_batch_bundle,
SalesInvoiceItem.delivered_by_supplier,
) )
if self.filters.group_by == "Sales Person": 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[7], 1000.0)
self.assertEqual(total[8], 100.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"): def make_sales_person(sales_person_name="_Test Sales Person"):
if not frappe.db.exists("Sales Person", {"sales_person_name": sales_person_name}): 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: else:
data = {"YY": "%y", "YYYY": "%Y", "MM": "%m", "DD": "%d", "JJJ": "%j"} 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 = ( date = (
( (
getdate(doc.get("posting_date") or doc.get("transaction_date") or doc.get("posting_datetime")) 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"); frm.trigger("set_required_fields");
}, },

View File

@@ -285,7 +285,7 @@ class RequestforQuotation(BuyingController):
} }
) )
user.save(ignore_permissions=True) user.save(ignore_permissions=True)
update_password_link = user.reset_password() update_password_link = user._reset_password()
return user, update_password_link 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_display_list = get_address_display_list("Company", company)
address_line = address_display_list[0].get("address_line1") if address_display_list else "" 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(company_address)
required_fields.append(address_line) 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): def update_company_master_and_address(current_doctype, name, company, details):
from frappe.utils import validate_email_address 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): if isinstance(details, str):
details = frappe.parse_json(details) 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") company_address = details.get("company_address")
if details.get("address_line1"): 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( address_doc = frappe.get_doc(
{ {
"doctype": "Address", "doctype": "Address",
@@ -4425,6 +4453,7 @@ def update_doc_company_address(current_doctype, docname, company_address, detail
"Sales Invoice": ("company_address", "company_address_display"), "Sales Invoice": ("company_address", "company_address_display"),
"Delivery Note": ("company_address", "company_address_display"), "Delivery Note": ("company_address", "company_address_display"),
"POS Invoice": ("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"), "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.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_delivery_notes_to_be_billed(doctype, txt, searchfield, start, page_len, filters, as_dict): def get_delivery_notes_to_be_billed(
doctype = "Delivery Note" 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"]) fields = get_fields(doctype, ["name", "customer", "posting_date"])
return frappe.db.sql( original_dn = (
""" frappe.qb.from_(DeliveryNote)
select {fields} .select(DeliveryNote.name)
from `tabDelivery Note` .where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
where `tabDelivery Note`.`{key}` like {txt} and )
`tabDelivery Note`.docstatus = 1
and status not in ('Stopped', 'Closed') {fcond} query = (
and ( frappe.qb.from_(DeliveryNote)
(`tabDelivery Note`.is_return = 0 and `tabDelivery Note`.per_billed < 100) .select(*[DeliveryNote[f] for f in fields])
or (`tabDelivery Note`.grand_total = 0 and `tabDelivery Note`.per_billed < 100) .where(
or ( (DeliveryNote.docstatus == 1)
`tabDelivery Note`.is_return = 1 & (DeliveryNote.status.notin(["Stopped", "Closed"]))
and return_against in (select name from `tabDelivery Note` where per_billed < 100) & (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() @frappe.whitelist()

View File

@@ -634,11 +634,11 @@ class SellingController(StockController):
if allow_at_arms_length_price: if allow_at_arms_length_price:
continue continue
rate = flt( rate = flt(flt(d.incoming_rate) * flt(d.conversion_factor or 1.0))
flt(d.incoming_rate, d.precision("incoming_rate")) * d.conversion_factor,
d.precision("rate"), if flt(d.rate, d.precision("incoming_rate")) != flt(
) rate, d.precision("incoming_rate")
if d.rate != rate: ):
d.rate = rate d.rate = rate
frappe.msgprint( 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)) item_details.extend(self.fetch_items_with_pending_qty(args, "item_code", regular_items))
# Query production plan items with production_item field # 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_details.extend(self.fetch_items_with_pending_qty(args, "production_item", pp_items))
item_lookup = {item.name: item for item in item_details} 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( bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value(
"Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"
) )
do_not_round_fields = ["valuation_rate", "incoming_rate"]
for item in self.doc.items: 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: if item.discount_percentage == 100:
item.rate = 0.0 item.rate = 0.0

View File

@@ -4,6 +4,12 @@
import frappe import frappe
from frappe import _ 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): def execute(filters=None):
@@ -66,58 +72,48 @@ def get_columns():
def get_data(filters): def get_data(filters):
return frappe.db.sql( query = (
f""" frappe.qb.from_(Opportunity)
SELECT .left_join(OpportunityLostReasonDetail)
`tabOpportunity`.name, .on(
`tabOpportunity`.opportunity_from, (OpportunityLostReasonDetail.parenttype == "Opportunity")
`tabOpportunity`.party_name, & (OpportunityLostReasonDetail.parent == Opportunity.name)
`tabOpportunity`.customer_name, )
`tabOpportunity`.opportunity_type, .select(
GROUP_CONCAT(`tabOpportunity Lost Reason Detail`.lost_reason separator ', ') lost_reason, Opportunity.name,
`tabOpportunity`.sales_stage, Opportunity.opportunity_from,
`tabOpportunity`.territory Opportunity.party_name,
FROM Opportunity.customer_name,
`tabOpportunity` Opportunity.opportunity_type,
{get_join(filters)} GROUP_CONCAT(OpportunityLostReasonDetail.lost_reason, alias="lost_reason").separator(", "),
WHERE Opportunity.sales_stage,
`tabOpportunity`.status = 'Lost' and `tabOpportunity`.company = %(company)s Opportunity.territory,
AND DATE(`tabOpportunity`.modified) BETWEEN %(from_date)s AND %(to_date)s )
{get_conditions(filters)} .where(
GROUP BY (Opportunity.status == "Lost")
`tabOpportunity`.name & (Opportunity.company == filters.get("company"))
ORDER BY & (Date(Opportunity.modified).between(filters.get("from_date"), filters.get("to_date")))
`tabOpportunity`.creation asc """, )
filters, .groupby(Opportunity.name)
as_dict=1, .orderby(Opportunity.creation)
) )
query = get_conditions(filters, query)
def get_conditions(filters): return query.run(as_dict=1)
conditions = []
def get_conditions(filters, query):
if filters.get("territory"): if filters.get("territory"):
conditions.append(" and `tabOpportunity`.territory=%(territory)s") query = query.where(Opportunity.territory == filters.get("territory"))
if filters.get("opportunity_from"): 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"): if filters.get("party_name"):
conditions.append(" and `tabOpportunity`.party_name=%(party_name)s") query = query.where(Opportunity.party_name == filters.get("party_name"))
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"""
if filters.get("lost_reason"): if filters.get("lost_reason"):
join = """JOIN `tabOpportunity Lost Reason Detail` query = query.where(OpportunityLostReasonDetail.lost_reason == filters.get("lost_reason"))
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"))
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) { if (d.sourced_by_supplier) {
d.rate = 0; d.rate = 0;
refresh_field("rate", d.name, d.parentfield); 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): def add_additional_cost(stock_entry, work_order, job_card=None):
# Add non stock items cost in the additional cost # Add non stock items cost in the additional cost
stock_entry.additional_costs = [] stock_entry.additional_costs = []
company_account = frappe.db.get_value( expense_account = frappe.get_value(
"Company", "Company",
work_order.company, work_order.company,
["default_expense_account", "default_operating_cost_account"], "default_operating_cost_account",
as_dict=1,
)
expense_account = (
company_account.default_operating_cost_account or company_account.default_expense_account
) )
add_non_stock_items_cost(stock_entry, work_order, expense_account, job_card=job_card) 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) 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) 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( stock_entry.append(
"additional_costs", "additional_costs",
{ {

View File

@@ -20,6 +20,7 @@
"is_subcontracted", "is_subcontracted",
"is_final_finished_good", "is_final_finished_good",
"set_cost_based_on_bom_qty", "set_cost_based_on_bom_qty",
"quality_inspection_required",
"warehouse_section", "warehouse_section",
"skip_material_transfer", "skip_material_transfer",
"backflush_from_wip_warehouse", "backflush_from_wip_warehouse",
@@ -290,13 +291,20 @@
"fieldname": "backflush_from_wip_warehouse", "fieldname": "backflush_from_wip_warehouse",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Backflush Materials From WIP Warehouse" "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, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2026-03-31 17:09:48.771834", "modified": "2026-04-01 17:09:48.771834",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "BOM Operation", "name": "BOM Operation",

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"actions": [], "actions": [],
"autoname": "naming_series:", "autoname": "naming_series:",
"creation": "2018-07-09 17:23:29.518745", "creation": "2026-03-31 21:06:16.282931",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
@@ -452,7 +452,6 @@
"show_dashboard": 1 "show_dashboard": 1
}, },
{ {
"depends_on": "expected_start_date",
"fieldname": "scheduled_time_section", "fieldname": "scheduled_time_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Scheduled Time" "label": "Scheduled Time"
@@ -628,7 +627,7 @@
"grid_page_length": 50, "grid_page_length": 50,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-26 15:13:56.767070", "modified": "2026-03-31 21:06:48.987740",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Manufacturing", "module": "Manufacturing",
"name": "Job Card", "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"], ["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.get_value("BOM", self.bom_no, "inspection_required")
bom_inspection_required = frappe.db.get_value( operation_inspection_required = frappe.get_value(
"BOM", self.semi_fg_bom or self.bom_no, "inspection_required" "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: if not self.quality_inspection:
frappe.throw( frappe.throw(
_( _(
"Quality Inspection is required for the item {0} before completing the job card {1}" "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"] "Quality Inspection", self.quality_inspection, ["status", "docstatus"]
) )
if docstatus != 1: if docstatus != 1:
if action_submit == "Stop": if action_submit == "Stop":
frappe.throw( frappe.throw(
_("Quality Inspection {0} is not submitted for the item: {1}").format( _("Quality Inspection {0} is not submitted for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection), 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"), title=_("Inspection Submission"),
exc=QualityInspectionNotSubmittedError, exc=QualityInspectionNotSubmittedError,
@@ -817,7 +817,7 @@ class JobCard(Document):
frappe.msgprint( frappe.msgprint(
_("Quality Inspection {0} is not submitted for the item: {1}").format( _("Quality Inspection {0} is not submitted for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection), 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, alert=True,
indicator="orange", indicator="orange",
@@ -827,7 +827,7 @@ class JobCard(Document):
frappe.throw( frappe.throw(
_("Quality Inspection {0} is rejected for the item: {1}").format( _("Quality Inspection {0} is rejected for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection), 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"), title=_("Inspection Rejected"),
exc=QualityInspectionRejectedError, exc=QualityInspectionRejectedError,
@@ -836,7 +836,7 @@ class JobCard(Document):
frappe.msgprint( frappe.msgprint(
_("Quality Inspection {0} is rejected for the item: {1}").format( _("Quality Inspection {0} is rejected for the item: {1}").format(
get_link_to_form("Quality Inspection", self.quality_inspection), 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, alert=True,
indicator="orange", indicator="orange",

View File

@@ -87,6 +87,7 @@ class TestJobCard(ERPNextTestSuite):
with_operations=1, with_operations=1,
track_semi_finished_goods=1, track_semi_finished_goods=1,
company="_Test Company", company="_Test Company",
inspection_required=1,
) )
final_bom.append("items", {"item_code": raw.name, "qty": 1}) final_bom.append("items", {"item_code": raw.name, "qty": 1})
final_bom.append( final_bom.append(
@@ -97,6 +98,7 @@ class TestJobCard(ERPNextTestSuite):
"bom_no": cut_bom, "bom_no": cut_bom,
"skip_material_transfer": 1, "skip_material_transfer": 1,
"time_in_mins": 60, "time_in_mins": 60,
"quality_inspection_required": 1,
}, },
) )
final_bom.append( final_bom.append(
@@ -133,6 +135,15 @@ class TestJobCard(ERPNextTestSuite):
work_order.submit() work_order.submit()
job_card = frappe.get_all("Job Card", filters={"work_order": work_order.name, "operation": "Cutting"}) 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 = 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) self.assertRaises(frappe.ValidationError, job_card_doc.submit)
def test_job_card_operations(self): def test_job_card_operations(self):

View File

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

View File

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

View File

@@ -698,10 +698,14 @@ class TestWorkOrder(ERPNextTestSuite):
if not bom_name: if not bom_name:
bom = make_bom(item=fg_item, rate=1000, raw_materials=[rm1], do_not_save=True) bom = make_bom(item=fg_item, rate=1000, raw_materials=[rm1], do_not_save=True)
bom.with_operations = 1 bom.with_operations = 1
operation = make_operation(operation="Batch Size Operation")
operation.create_job_card_based_on_batch_size = 1
operation.save()
bom.append( bom.append(
"operations", "operations",
{ {
"operation": "_Test Operation 1", "operation": "Batch Size Operation",
"workstation": "_Test Workstation 1", "workstation": "_Test Workstation 1",
"description": "Test Data", "description": "Test Data",
"operating_cost": 100, "operating_cost": 100,
@@ -4178,6 +4182,64 @@ class TestWorkOrder(ERPNextTestSuite):
self.assertEqual(bin1_at_completion.reserved_qty_for_production, 0) 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): def get_reserved_entries(voucher_no, warehouse=None):
doctype = frappe.qb.DocType("Stock Reservation Entry") doctype = frappe.qb.DocType("Stock Reservation Entry")

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,6 @@ frappe.listview_settings["Workstation"] = {
Setup: "blue", 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.update_requested_qty_packed_item
erpnext.patches.v16_0.remove_payables_receivables_workspace erpnext.patches.v16_0.remove_payables_receivables_workspace
erpnext.patches.v16_0.co_by_product_patch 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); 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>`); value = $(`<span>${value}</span>`);
var $value = $(value).css("font-weight", "bold"); var $value = $(value).css("font-weight", "bold");

View File

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

View File

@@ -93,10 +93,10 @@ class SellingSettings(Document):
self.validate_fallback_to_default_price_list() 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) 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) toggle_utm_analytics_section(not self.enable_utm)
def validate_fallback_to_default_price_list(self): 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 Order": "Purchase Order with Item Image",
"Purchase Invoice": "Purchase Invoice with Item Image", "Purchase Invoice": "Purchase Invoice with Item Image",
"POS Invoice": "POS 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", "Request for Quotation": "Request for Quotation with Item Image",
} }

View File

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

View File

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

View File

@@ -8,9 +8,8 @@
"field_order": [ "field_order": [
"dimension_details_tab", "dimension_details_tab",
"dimension_name", "dimension_name",
"reference_document",
"column_break_4", "column_break_4",
"disabled", "reference_document",
"field_mapping_section", "field_mapping_section",
"source_fieldname", "source_fieldname",
"column_break_9", "column_break_9",
@@ -93,12 +92,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Apply to All Inventory Documents" "label": "Apply to All Inventory Documents"
}, },
{
"default": "0",
"fieldname": "disabled",
"fieldtype": "Check",
"label": "Disabled"
},
{ {
"fieldname": "target_fieldname", "fieldname": "target_fieldname",
"fieldtype": "Data", "fieldtype": "Data",
@@ -159,6 +152,7 @@
"label": "Conditional Rule Examples" "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.", "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", "fieldname": "mandatory_depends_on",
"fieldtype": "Small Text", "fieldtype": "Small Text",
@@ -188,7 +182,7 @@
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-07-07 15:51:29.329064", "modified": "2026-04-08 10:10:16.884388",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Inventory Dimension", "name": "Inventory Dimension",

View File

@@ -32,7 +32,6 @@ class InventoryDimension(Document):
apply_to_all_doctypes: DF.Check apply_to_all_doctypes: DF.Check
condition: DF.Code | None condition: DF.Code | None
dimension_name: DF.Data dimension_name: DF.Data
disabled: DF.Check
document_type: DF.Link | None document_type: DF.Link | None
fetch_from_parent: DF.Literal[None] fetch_from_parent: DF.Literal[None]
istable: DF.Check istable: DF.Check
@@ -76,7 +75,6 @@ class InventoryDimension(Document):
old_doc = self._doc_before_save old_doc = self._doc_before_save
allow_to_edit_fields = [ allow_to_edit_fields = [
"disabled",
"fetch_from_parent", "fetch_from_parent",
"type_of_transaction", "type_of_transaction",
"condition", "condition",
@@ -120,6 +118,7 @@ class InventoryDimension(Document):
def reset_value(self): def reset_value(self):
if self.apply_to_all_doctypes: if self.apply_to_all_doctypes:
self.type_of_transaction = "" self.type_of_transaction = ""
self.mandatory_depends_on = ""
self.istable = 0 self.istable = 0
for field in ["document_type", "condition"]: for field in ["document_type", "condition"]:
@@ -184,8 +183,12 @@ class InventoryDimension(Document):
label=_(label), label=_(label),
depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "", depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "",
search_index=1, search_index=1,
reqd=self.reqd, reqd=1
mandatory_depends_on=self.mandatory_depends_on, 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, options=self.reference_document,
label=label, label=label,
depends_on=display_depends_on, 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") 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", "type_of_transaction",
"fetch_from_parent", "fetch_from_parent",
], ],
filters={"disabled": 0},
or_filters={"document_type": doctype, "apply_to_all_doctypes": 1}, or_filters={"document_type": doctype, "apply_to_all_doctypes": 1},
) )
@@ -389,7 +392,6 @@ def get_inventory_dimensions():
"validate_negative_stock", "validate_negative_stock",
"name as dimension_name", "name as dimension_name",
], ],
filters={"disabled": 0},
order_by="creation", order_by="creation",
distinct=True, distinct=True,
) )

View File

@@ -211,9 +211,9 @@ class TestInventoryDimension(ERPNextTestSuite):
doc = create_inventory_dimension( doc = create_inventory_dimension(
reference_document="Pallet", reference_document="Pallet",
type_of_transaction="Outward", type_of_transaction="Outward",
dimension_name="Pallet", dimension_name="Pallet 75",
apply_to_all_doctypes=0, apply_to_all_doctypes=0,
document_type="Stock Entry Detail", document_type="Delivery Note Item",
) )
doc.reqd = 1 doc.reqd = 1
@@ -221,7 +221,7 @@ class TestInventoryDimension(ERPNextTestSuite):
self.assertTrue( self.assertTrue(
frappe.db.get_value( 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) { toggle_has_serial_batch_fields(frm) {
let hide_fields = cint(frappe.user_defaults?.enable_serial_and_batch_no_for_item) === 0 ? 1 : 0; 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); frm.toggle_enable(["has_serial_no", "has_batch_no"], !hide_fields);
if (hide_fields) { if (hide_fields) {
let description = __( let header = frm.fields_dict["serial_nos_and_batches"].wrapper;
"To enable the Serial No and Batch No feature, please check the 'Enable Serial / Batch No for Item' checkbox in Stock Settings." let wrapper = header.find(".section-head.collapsible");
);
frm.set_df_property("has_serial_no", "description", description); render_serial_batch_banner(wrapper);
frm.set_df_property("has_batch_no", "description", description);
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; 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, { $.extend(erpnext.item, {
setup_queries: function (frm) { setup_queries: function (frm) {
frm.fields_dict["item_defaults"].grid.get_field("expense_account").get_query = function ( 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", "depends_on": "eval:doc.is_stock_item",
"fieldname": "serial_nos_and_batches", "fieldname": "serial_nos_and_batches",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Serial Nos and Batches" "label": "Serial Nos / Batches"
}, },
{ {
"default": "0", "default": "0",
@@ -995,7 +995,7 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2026-03-24 15:45:40.207531", "modified": "2026-04-14 13:37:00.183583",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item", "name": "Item",

View File

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

View File

@@ -58,6 +58,7 @@ frappe.ui.form.on("Quality Inspection", {
if (doc.reference_type && doc.reference_name) { if (doc.reference_type && doc.reference_name) {
let filters = { let filters = {
from: doctype, from: doctype,
parent_doctype: doc.reference_type,
inspection_type: doc.inspection_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 frappe.desk.reportview import get_match_cond
from_doctype = cstr(filters.get("from")) 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): if not from_doctype or not frappe.db.exists("DocType", from_doctype):
return [] 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 = '')" cond, qi_condition = "", "and (quality_inspection is null or quality_inspection = '')"
if filters.get("parent"): 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"): if not frappe.db.get_single_value("Stock Settings", "enable_serial_and_batch_no_for_item"):
frappe.throw( 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")), ).format(get_link_to_form("Stock Settings", "Stock Settings")),
title=_("Serial and Batch No for Item Disabled"), 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) { get_warehouse_details: function (frm, cdt, cdn) {
var child = locals[cdt][cdn]; var child = locals[cdt][cdn];
if (!child.bom_no) { if (!child.bom_no) {
@@ -1021,7 +1013,7 @@ frappe.ui.form.on("Stock Entry Detail", {
}, },
conversion_factor(frm, cdt, cdn) { 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) { 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"): for d in work_order.get("operations"):
if flt(d.completed_qty): if flt(d.completed_qty):
operating_cost_per_unit += flt( if not (remaining_qty := flt(d.completed_qty - work_order.produced_qty)):
d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no) continue
) / flt(d.completed_qty - work_order.produced_qty) operating_cost_per_unit += (
flt(d.actual_operating_cost - get_consumed_operating_cost(work_order.name, bom_no))
/ remaining_qty
)
elif work_order.qty: elif work_order.qty:
operating_cost_per_unit += flt(d.planned_operating_cost) / flt(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: elif s.id_plant == plant_b.name:
self.assertEqual(s.actual_qty, 3) 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): def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1) 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); 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) { use_serial_batch_fields(frm) {
if (frm.doc.use_serial_batch_fields && !frm.doc.disable_serial_no_and_batch_selector) { 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); frm.set_value("disable_serial_no_and_batch_selector", 1);

View File

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

View File

@@ -109,49 +109,23 @@ class Warehouse(NestedSet):
def warn_about_multiple_warehouse_account(self): def warn_about_multiple_warehouse_account(self):
"If Warehouse value is split across multiple accounts, warn." "If Warehouse value is split across multiple accounts, warn."
def get_accounts_where_value_is_booked(name): if not frappe.db.count("Stock Ledger Entry", {"warehouse": self.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():
return 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.is_new() or (self.account and old_wh_account == self.account):
if self.account != old_wh_account: return
accounts = get_accounts_where_value_is_booked(self.name)
accounts = [d.account for d in accounts]
if not accounts or (len(accounts) == 1 and self.account in accounts): frappe.msgprint(
# if same singular account has stock value booked ignore title=_("Warning: Account changed for warehouse"),
return indicator="orange",
msg=_(
warning = _("Warehouse's Stock Value has already been booked in the following accounts:") "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."
account_str = "<br>" + ", ".join(frappe.bold(ac) for ac in accounts) ),
reason = "<br><br>" + _( alert=True,
"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",
)
def check_if_sle_exists(self, non_cancelled_only=False): def check_if_sle_exists(self, non_cancelled_only=False):
filters = {"warehouse": self.name} 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_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 return iwb_map

View File

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

View File

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

View File

@@ -556,6 +556,16 @@ class update_entries_after:
previous_sle = get_previous_sle_of_current_voucher(args) previous_sle = get_previous_sle_of_current_voucher(args)
if previous_sle: if previous_sle:
self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = 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 warehouse_dict.previous_sle = previous_sle
@@ -1071,34 +1081,6 @@ class update_entries_after:
sabb_doc.voucher_no = None sabb_doc.voucher_no = None
sabb_doc.cancel() 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): def calculate_valuation_for_serial_batch_bundle(self, sle):
if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle): if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle):
return return