mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-23 08:38:30 +00:00
Merge pull request #54283 from frappe/version-16-hotfix
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2887,7 +2887,7 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
si.submit()
|
||||
|
||||
# Check if adjustment entry is created
|
||||
self.assertTrue(
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"GL Entry",
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}):
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -41,7 +41,7 @@ frappe.ui.form.on("Asset Movement", {
|
||||
});
|
||||
},
|
||||
|
||||
onload: (frm) => {
|
||||
refresh: (frm) => {
|
||||
frm.trigger("set_required_fields");
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
_(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
89
erpnext/patches/v16_0/depends_on_inv_dimensions.py
Normal file
89
erpnext/patches/v16_0/depends_on_inv_dimensions.py
Normal 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,
|
||||
)
|
||||
@@ -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");
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user