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

This commit is contained in:
diptanilsaha
2026-03-30 23:31:28 +05:30
committed by GitHub
114 changed files with 2953 additions and 2193 deletions

View File

@@ -43,3 +43,6 @@ jobs:
- name: Run Semgrep rules - name: Run Semgrep rules
run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness
- name: Semgrep for Test Correctness
run: semgrep ci --include=**/test_*.py --config ./semgrep/test-correctness.yml

View File

@@ -10,9 +10,6 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestAccountingDimension(ERPNextTestSuite): class TestAccountingDimension(ERPNextTestSuite):
def setUp(self):
create_dimension()
def test_dimension_against_sales_invoice(self): def test_dimension_against_sales_invoice(self):
si = create_sales_invoice(do_not_save=1) si = create_sales_invoice(do_not_save=1)
@@ -77,63 +74,3 @@ class TestAccountingDimension(ERPNextTestSuite):
si.save() si.save()
self.assertRaises(frappe.ValidationError, si.submit) self.assertRaises(frappe.ValidationError, si.submit)
def create_dimension():
frappe.set_user("Administrator")
if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}):
dimension = frappe.get_doc(
{
"doctype": "Accounting Dimension",
"document_type": "Department",
}
)
dimension.append(
"dimension_defaults",
{
"company": "_Test Company",
"reference_document": "Department",
"default_dimension": "_Test Department - _TC",
},
)
dimension.insert()
dimension.save()
else:
dimension = frappe.get_doc("Accounting Dimension", "Department")
dimension.disabled = 0
dimension.save()
if not frappe.db.exists("Accounting Dimension", {"document_type": "Location"}):
dimension1 = frappe.get_doc(
{
"doctype": "Accounting Dimension",
"document_type": "Location",
}
)
dimension1.append(
"dimension_defaults",
{
"company": "_Test Company",
"reference_document": "Location",
"default_dimension": "Block 1",
},
)
dimension1.insert()
dimension1.save()
else:
dimension1 = frappe.get_doc("Accounting Dimension", "Location")
dimension1.disabled = 0
dimension1.save()
def disable_dimension():
dimension1 = frappe.get_doc("Accounting Dimension", "Department")
dimension1.disabled = 1
dimension1.save()
dimension2 = frappe.get_doc("Accounting Dimension", "Location")
dimension2.disabled = 1
dimension2.save()

View File

@@ -5,10 +5,6 @@ import unittest
import frappe import frappe
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError from erpnext.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
@@ -16,7 +12,6 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestAccountingDimensionFilter(ERPNextTestSuite): class TestAccountingDimensionFilter(ERPNextTestSuite):
def setUp(self): def setUp(self):
create_dimension()
create_accounting_dimension_filter() create_accounting_dimension_filter()
self.invoice_list = [] self.invoice_list = []

View File

@@ -116,6 +116,7 @@ def get_default_company_bank_account(company, party_type, party):
@frappe.whitelist() @frappe.whitelist()
def get_bank_account_details(bank_account): def get_bank_account_details(bank_account):
frappe.has_permission("Bank Account", doc=bank_account, ptype="read", throw=True)
return frappe.get_cached_value( return frappe.get_cached_value(
"Bank Account", bank_account, ["account", "bank", "bank_account_no"], as_dict=1 "Bank Account", bank_account, ["account", "bank", "bank_account_no"], as_dict=1
) )

View File

@@ -489,4 +489,5 @@ def rename_temporarily_named_docs(doctype):
for hook in frappe.get_hooks(hook_type): for hook in frappe.get_hooks(hook_type):
frappe.call(hook, newname=newname, oldname=oldname) frappe.call(hook, newname=newname, oldname=oldname)
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()

View File

@@ -71,14 +71,16 @@ def start_merge(docname):
ledger_merge.account, ledger_merge.account,
) )
row.db_set("merged", 1) row.db_set("merged", 1)
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
successful_merges += 1 successful_merges += 1
frappe.publish_realtime( frappe.publish_realtime(
"ledger_merge_progress", "ledger_merge_progress",
{"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total}, {"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total},
) )
except Exception: except Exception:
frappe.db.rollback() if not frappe.in_test:
frappe.db.rollback()
ledger_merge.log_error("Ledger merge failed") ledger_merge.log_error("Ledger merge failed")
finally: finally:
if successful_merges == total: if successful_merges == total:

View File

@@ -50,6 +50,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
refresh: function (frm) { refresh: function (frm) {
frm.disable_save(); frm.disable_save();
frm.trigger("create_missing_party");
!frm.doc.import_in_progress && frm.trigger("make_dashboard"); !frm.doc.import_in_progress && frm.trigger("make_dashboard");
frm.page.set_primary_action(__("Create Invoices"), () => { frm.page.set_primary_action(__("Create Invoices"), () => {
let btn_primary = frm.page.btn_primary.get(0); let btn_primary = frm.page.btn_primary.get(0);
@@ -123,7 +124,8 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
invoice_type: function (frm) { invoice_type: function (frm) {
$.each(frm.doc.invoices, (idx, row) => { $.each(frm.doc.invoices, (idx, row) => {
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier"; row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
row.party = ""; frappe.model.set_value(row.doctype, row.name, "party", "");
frappe.model.set_value(row.doctype, row.name, "party_name", "");
}); });
frm.refresh_fields(); frm.refresh_fields();
}, },
@@ -162,9 +164,35 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier"; row.party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
}); });
}, },
create_missing_party: function (frm) {
if (frm.doc.create_missing_party) {
frm.fields_dict["invoices"].grid.update_docfield_property("party", "reqd", 0);
frm.fields_dict["invoices"].grid.update_docfield_property("party_name", "read_only", 0);
} else {
frm.fields_dict["invoices"].grid.update_docfield_property("party", "reqd", 1);
frm.fields_dict["invoices"].grid.update_docfield_property("party_name", "read_only", 1);
}
frm.refresh_field("invoices");
},
}); });
frappe.ui.form.on("Opening Invoice Creation Tool Item", { frappe.ui.form.on("Opening Invoice Creation Tool Item", {
party: function (frm, cdt, cdn) {
let row = locals[cdt][cdn];
if (!row.party) {
frappe.model.set_value(cdt, cdn, "party_name", "");
return;
}
let party_type = frm.doc.invoice_type == "Sales" ? "Customer" : "Supplier";
let name_field = party_type === "Customer" ? "customer_name" : "supplier_name";
frappe.db.get_value(party_type, row.party, name_field, (r) => {
frappe.model.set_value(cdt, cdn, "party_name", r?.[name_field] || "");
});
},
invoices_add: (frm) => { invoices_add: (frm) => {
frm.trigger("update_invoice_table"); frm.trigger("update_invoice_table");
}, },

View File

@@ -8,9 +8,9 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"company", "company",
"create_missing_party",
"column_break_3", "column_break_3",
"invoice_type", "invoice_type",
"create_missing_party",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"dimension_col_break", "dimension_col_break",
@@ -29,7 +29,7 @@
}, },
{ {
"default": "0", "default": "0",
"description": "Create missing customer or supplier.", "description": "If party does not exist, create it using the Party Name field.",
"fieldname": "create_missing_party", "fieldname": "create_missing_party",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Create Missing Party" "label": "Create Missing Party"
@@ -65,10 +65,10 @@
"options": "Cost Center" "options": "Cost Center"
}, },
{ {
"fieldname": "project", "fieldname": "project",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Project", "label": "Project",
"options": "Project" "options": "Project"
}, },
{ {
"collapsible": 1, "collapsible": 1,
@@ -84,7 +84,7 @@
"hide_toolbar": 1, "hide_toolbar": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:06.564397", "modified": "2026-03-23 00:32:15.600086",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Opening Invoice Creation Tool", "name": "Opening Invoice Creation Tool",
@@ -101,6 +101,7 @@
} }
], ],
"quick_entry": 1, "quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -5,7 +5,7 @@
import frappe import frappe
from frappe import _, scrub from frappe import _, scrub
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import flt, nowdate from frappe.utils import escape_html, flt, nowdate
from frappe.utils.background_jobs import enqueue, is_job_enqueued from frappe.utils.background_jobs import enqueue, is_job_enqueued
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -32,6 +32,7 @@ class OpeningInvoiceCreationTool(Document):
create_missing_party: DF.Check create_missing_party: DF.Check
invoice_type: DF.Literal["Sales", "Purchase"] invoice_type: DF.Literal["Sales", "Purchase"]
invoices: DF.Table[OpeningInvoiceCreationToolItem] invoices: DF.Table[OpeningInvoiceCreationToolItem]
project: DF.Link | None
# end: auto-generated types # end: auto-generated types
def onload(self): def onload(self):
@@ -85,6 +86,11 @@ class OpeningInvoiceCreationTool(Document):
) )
prepare_invoice_summary(doctype, invoices) prepare_invoice_summary(doctype, invoices)
invoices_summary_companies = list(invoices_summary.keys())
for company in invoices_summary_companies:
invoices_summary[escape_html(company)] = invoices_summary.pop(company)
return invoices_summary, max_count return invoices_summary, max_count
def validate_company(self): def validate_company(self):
@@ -102,10 +108,20 @@ class OpeningInvoiceCreationTool(Document):
row.due_date = row.due_date or nowdate() row.due_date = row.due_date or nowdate()
def validate_mandatory_invoice_fields(self, row): def validate_mandatory_invoice_fields(self, row):
if not frappe.db.exists(row.party_type, row.party): if self.create_missing_party:
if self.create_missing_party: if not row.party and not row.party_name:
self.add_party(row.party_type, row.party) frappe.throw(_("Row #{}: Either Party ID or Party Name is required").format(row.idx))
else:
if not row.party and row.party_name:
row.party = self.add_party(row.party_type, row.party_name)
if row.party and not frappe.db.exists(row.party_type, row.party):
row.party = self.add_party(row.party_type, row.party)
else:
if not row.party:
frappe.throw(_("Row #{}: Party ID is required").format(row.idx))
if not frappe.db.exists(row.party_type, row.party):
frappe.throw( frappe.throw(
_("Row #{}: {} {} does not exist.").format( _("Row #{}: {} {} does not exist.").format(
row.idx, frappe.bold(row.party_type), frappe.bold(row.party) row.idx, frappe.bold(row.party_type), frappe.bold(row.party)
@@ -113,7 +129,7 @@ class OpeningInvoiceCreationTool(Document):
) )
mandatory_error_msg = _("Row #{0}: {1} is required to create the Opening {2} Invoices") mandatory_error_msg = _("Row #{0}: {1} is required to create the Opening {2} Invoices")
for d in ("Party", "Outstanding Amount", "Temporary Opening Account"): for d in ("Outstanding Amount", "Temporary Opening Account"):
if not row.get(scrub(d)): if not row.get(scrub(d)):
frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type)) frappe.throw(mandatory_error_msg.format(row.idx, d, self.invoice_type))
@@ -159,6 +175,7 @@ class OpeningInvoiceCreationTool(Document):
party_doc.flags.ignore_mandatory = True party_doc.flags.ignore_mandatory = True
party_doc.save(ignore_permissions=True) party_doc.save(ignore_permissions=True)
return party_doc.name
def get_invoice_dict(self, row=None): def get_invoice_dict(self, row=None):
def get_item_dict(): def get_item_dict():
@@ -262,7 +279,8 @@ def start_import(invoices):
doc.flags.ignore_mandatory = True doc.flags.ignore_mandatory = True
doc.insert(set_name=invoice_number) doc.insert(set_name=invoice_number)
doc.submit() doc.submit()
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
names.append(doc.name) names.append(doc.name)
except Exception: except Exception:
errors += 1 errors += 1

View File

@@ -1,5 +1,5 @@
{% $.each(data, (company, summary) => { %} {% $.each(data, (company, summary) => { %}
<h6 style="margin: 15px 0px -10px 0px;"><a class="company-link"> {{ company }}</a></h6> <div style="margin: 15px 0px -10px 0px;"> {{ company }}</div>
<table class="table table-bordered small"> <table class="table table-bordered small">
<thead> <thead>
@@ -23,7 +23,7 @@
<td class="text-right"> <td class="text-right">
{{ format_currency(summary[doctype].outstanding_amount, summary.currency, 2) }} {{ format_currency(summary[doctype].outstanding_amount, summary.currency, 2) }}
</td> </td>
</div> </tr>
{% endif %} {% endif %}
{% }); %} {% }); %}
</tbody> </tbody>

View File

@@ -3,10 +3,6 @@
import frappe import frappe
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
)
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account, get_temporary_opening_account,
) )
@@ -14,11 +10,6 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestOpeningInvoiceCreationTool(ERPNextTestSuite): class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
def setUp(self):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company()
create_dimension()
def make_invoices( def make_invoices(
self, self,
invoice_type="Sales", invoice_type="Sales",
@@ -183,19 +174,6 @@ def get_opening_invoice_creation_dict(**args):
return invoice_dict return invoice_dict
def make_company():
if frappe.db.exists("Company", "_Test Opening Invoice Company"):
return frappe.get_doc("Company", "_Test Opening Invoice Company")
company = frappe.new_doc("Company")
company.company_name = "_Test Opening Invoice Company"
company.abbr = "_TOIC"
company.default_currency = "INR"
company.country = "Pakistan"
company.insert()
return company
def make_customer(customer=None): def make_customer(customer=None):
customer_name = customer or "Opening Customer" customer_name = customer or "Opening Customer"
customer = frappe.get_doc( customer = frappe.get_doc(

View File

@@ -8,6 +8,7 @@
"invoice_number", "invoice_number",
"party_type", "party_type",
"party", "party",
"party_name",
"temporary_opening_account", "temporary_opening_account",
"column_break_3", "column_break_3",
"posting_date", "posting_date",
@@ -35,9 +36,9 @@
"fieldname": "party", "fieldname": "party",
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Party", "label": "Party ID",
"options": "party_type", "mandatory_depends_on": "eval: !parent.create_missing_party",
"reqd": 1 "options": "party_type"
}, },
{ {
"fieldname": "temporary_opening_account", "fieldname": "temporary_opening_account",
@@ -118,11 +119,17 @@
"fieldname": "supplier_invoice_date", "fieldname": "supplier_invoice_date",
"fieldtype": "Date", "fieldtype": "Date",
"label": "Supplier Invoice Date" "label": "Supplier Invoice Date"
},
{
"fieldname": "party_name",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Party Name"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-12-01 16:18:07.997594", "modified": "2026-03-20 02:11:42.023575",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Opening Invoice Creation Tool Item", "name": "Opening Invoice Creation Tool Item",

View File

@@ -22,7 +22,8 @@ class OpeningInvoiceCreationToolItem(Document):
parent: DF.Data parent: DF.Data
parentfield: DF.Data parentfield: DF.Data
parenttype: DF.Data parenttype: DF.Data
party: DF.DynamicLink party: DF.DynamicLink | None
party_name: DF.Data | None
party_type: DF.Link | None party_type: DF.Link | None
posting_date: DF.Date | None posting_date: DF.Date | None
qty: DF.Data | None qty: DF.Data | None

View File

@@ -2376,9 +2376,7 @@ def get_outstanding_reference_documents(args, validate=False):
vouchers=args.get("vouchers") or None, vouchers=args.get("vouchers") or None,
) )
outstanding_invoices = split_invoices_based_on_payment_terms( outstanding_invoices = split_refdocs_based_on_payment_terms(outstanding_invoices, args.get("company"))
outstanding_invoices, args.get("company")
)
for d in outstanding_invoices: for d in outstanding_invoices:
d["exchange_rate"] = 1 d["exchange_rate"] = 1
@@ -2416,6 +2414,8 @@ def get_outstanding_reference_documents(args, validate=False):
filters=args, filters=args,
) )
orders_to_be_billed = split_refdocs_based_on_payment_terms(orders_to_be_billed, args.get("company"))
data = negative_outstanding_invoices + outstanding_invoices + orders_to_be_billed data = negative_outstanding_invoices + outstanding_invoices + orders_to_be_billed
if not data: if not data:
@@ -2438,13 +2438,13 @@ def get_outstanding_reference_documents(args, validate=False):
return data return data
def split_invoices_based_on_payment_terms(outstanding_invoices, company) -> list: def split_refdocs_based_on_payment_terms(refdocs, company) -> list:
"""Split a list of invoices based on their payment terms.""" """Split a list of invoices based on their payment terms."""
exc_rates = get_currency_data(outstanding_invoices, company) exc_rates = get_currency_data(refdocs, company)
outstanding_invoices_after_split = [] outstanding_refdoc_after_split = []
for entry in outstanding_invoices: for entry in refdocs:
if entry.voucher_type in ["Sales Invoice", "Purchase Invoice"]: if entry.voucher_type in ["Sales Invoice", "Purchase Invoice", "Sales Order", "Purchase Order"]:
if payment_term_template := frappe.db.get_value( if payment_term_template := frappe.db.get_value(
entry.voucher_type, entry.voucher_no, "payment_terms_template" entry.voucher_type, entry.voucher_no, "payment_terms_template"
): ):
@@ -2459,25 +2459,25 @@ def split_invoices_based_on_payment_terms(outstanding_invoices, company) -> list
), ),
alert=True, alert=True,
) )
outstanding_invoices_after_split += split_rows outstanding_refdoc_after_split += split_rows
continue continue
# If not an invoice or no payment terms template, add as it is # If not an invoice or no payment terms template, add as it is
outstanding_invoices_after_split.append(entry) outstanding_refdoc_after_split.append(entry)
return outstanding_invoices_after_split return outstanding_refdoc_after_split
def get_currency_data(outstanding_invoices: list, company: str | None = None) -> dict: def get_currency_data(outstanding_refdocs: list, company: str | None = None) -> dict:
"""Get currency and conversion data for a list of invoices.""" """Get currency and conversion data for a list of invoices."""
exc_rates = frappe._dict() exc_rates = frappe._dict()
company_currency = frappe.db.get_value("Company", company, "default_currency") if company else None company_currency = frappe.db.get_value("Company", company, "default_currency") if company else None
for doctype in ["Sales Invoice", "Purchase Invoice"]: for doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order", "Purchase Order"]:
invoices = [x.voucher_no for x in outstanding_invoices if x.voucher_type == doctype] refdoc = [x.voucher_no for x in outstanding_refdocs if x.voucher_type == doctype]
for x in frappe.db.get_all( for x in frappe.db.get_all(
doctype, doctype,
filters={"name": ["in", invoices]}, filters={"name": ["in", refdoc]},
fields=["name", "currency", "conversion_rate", "party_account_currency"], fields=["name", "currency", "conversion_rate", "party_account_currency"],
): ):
exc_rates[x.name] = frappe._dict( exc_rates[x.name] = frappe._dict(

View File

@@ -2019,6 +2019,92 @@ class TestPaymentEntry(ERPNextTestSuite):
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name) self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, pe.doctype, pe.name)
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0]) self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, "Journal Entry", jv[0])
def test_outstanding_orders_split_by_payment_terms(self):
create_payment_terms_template()
so = make_sales_order(do_not_save=1, qty=1, rate=200)
so.payment_terms_template = "Test Receivable Template"
so.save().submit()
args = {
"posting_date": nowdate(),
"company": so.company,
"party_type": "Customer",
"payment_type": "Receive",
"party": so.customer,
"party_account": "Debtors - _TC",
"get_orders_to_be_billed": True,
}
references = get_outstanding_reference_documents(args)
self.assertEqual(len(references), 2)
self.assertEqual(references[0].voucher_no, so.name)
self.assertEqual(references[1].voucher_no, so.name)
self.assertEqual(references[0].payment_term, "Basic Amount Receivable")
self.assertEqual(references[1].payment_term, "Tax Receivable")
def test_outstanding_orders_no_split_when_allocate_disabled(self):
create_payment_terms_template()
template = frappe.get_doc("Payment Terms Template", "Test Receivable Template")
template.allocate_payment_based_on_payment_terms = 0
template.save()
so = make_sales_order(do_not_save=1, qty=1, rate=200)
so.payment_terms_template = "Test Receivable Template"
so.save().submit()
args = {
"posting_date": nowdate(),
"company": so.company,
"party_type": "Customer",
"payment_type": "Receive",
"party": so.customer,
"party_account": "Debtors - _TC",
"get_orders_to_be_billed": True,
}
references = get_outstanding_reference_documents(args)
self.assertEqual(len(references), 1)
self.assertIsNone(references[0].payment_term)
template.allocate_payment_based_on_payment_terms = 1
template.save()
def test_outstanding_multicurrency_sales_order_split(self):
create_payment_terms_template()
so = make_sales_order(
customer="_Test Customer USD",
currency="USD",
qty=1,
rate=100,
do_not_submit=True,
)
so.payment_terms_template = "Test Receivable Template"
so.conversion_rate = 50
so.save().submit()
args = {
"posting_date": nowdate(),
"company": so.company,
"party_type": "Customer",
"payment_type": "Receive",
"party": so.customer,
"party_account": "Debtors - _TC",
"get_orders_to_be_billed": True,
}
references = get_outstanding_reference_documents(args)
# Should split without throwing currency errors
self.assertEqual(len(references), 2)
for ref in references:
self.assertEqual(ref.voucher_no, so.name)
self.assertIsNotNone(ref.payment_term)
def create_payment_entry(**args): def create_payment_entry(**args):
payment_entry = frappe.new_doc("Payment Entry") payment_entry = frappe.new_doc("Payment Entry")

View File

@@ -750,7 +750,8 @@ def make_payment_request(**args):
pr.submit() pr.submit()
if args.order_type == "Shopping Cart": if args.order_type == "Shopping Cart":
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
frappe.local.response["type"] = "redirect" frappe.local.response["type"] = "redirect"
frappe.local.response["location"] = pr.get_payment_url() frappe.local.response["location"] = pr.get_payment_url()

View File

@@ -4,10 +4,6 @@ import unittest
import frappe import frappe
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
)
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import ( from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
make_closing_entry_from_opening, make_closing_entry_from_opening,
) )
@@ -162,7 +158,6 @@ class TestPOSClosingEntry(ERPNextTestSuite):
test case to check whether we can create POS Closing Entry without mandatory accounting dimension test case to check whether we can create POS Closing Entry without mandatory accounting dimension
""" """
create_dimension()
location = frappe.get_doc("Accounting Dimension", "Location") location = frappe.get_doc("Accounting Dimension", "Location")
location.dimension_defaults[0].mandatory_for_bs = True location.dimension_defaults[0].mandatory_for_bs = True
location.save() location.save()
@@ -198,7 +193,6 @@ class TestPOSClosingEntry(ERPNextTestSuite):
) )
accounting_dimension_department.mandatory_for_bs = 0 accounting_dimension_department.mandatory_for_bs = 0
accounting_dimension_department.save() accounting_dimension_department.save()
disable_dimension()
def test_merging_into_sales_invoice_for_batched_item(self): def test_merging_into_sales_invoice_for_batched_item(self):
frappe.flags.print_message = False frappe.flags.print_message = False

View File

@@ -563,10 +563,10 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
new_from_date = add_months(new_to_date, -1 * doc.filter_duration) new_from_date = add_months(new_to_date, -1 * doc.filter_duration)
doc.add_comment("Comment", "Emails sent on: " + frappe.utils.format_datetime(frappe.utils.now())) doc.add_comment("Comment", "Emails sent on: " + frappe.utils.format_datetime(frappe.utils.now()))
if doc.report == "General Ledger": if doc.report == "General Ledger":
doc.db_set("to_date", new_to_date, commit=True) frappe.db.set_value(doc.doctype, doc.name, "to_date", new_to_date)
doc.db_set("from_date", new_from_date, commit=True) frappe.db.set_value(doc.doctype, doc.name, "from_date", new_from_date)
else: else:
doc.db_set("posting_date", new_to_date, commit=True) frappe.db.set_value(doc.doctype, doc.name, "posting_date", new_to_date)
return True return True
else: else:
return False return False

View File

@@ -8,7 +8,6 @@
"email_append_to": 1, "email_append_to": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"title",
"naming_series", "naming_series",
"supplier", "supplier",
"supplier_name", "supplier_name",
@@ -209,16 +208,6 @@
"connections_tab" "connections_tab"
], ],
"fields": [ "fields": [
{
"allow_on_submit": 1,
"default": "{supplier_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1
},
{ {
"fieldname": "naming_series", "fieldname": "naming_series",
"fieldtype": "Select", "fieldtype": "Select",
@@ -1693,7 +1682,7 @@
"idx": 204, "idx": 204,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-03-17 20:44:00.221219", "modified": "2026-03-25 11:45:38.696888",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice", "name": "Purchase Invoice",
@@ -1756,6 +1745,6 @@
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"timeline_field": "supplier", "timeline_field": "supplier",
"title_field": "title", "title_field": "supplier_name",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -203,7 +203,6 @@ class PurchaseInvoice(BuyingController):
taxes_and_charges_deducted: DF.Currency taxes_and_charges_deducted: DF.Currency
tc_name: DF.Link | None tc_name: DF.Link | None
terms: DF.TextEditor | None terms: DF.TextEditor | None
title: DF.Data | None
to_date: DF.Date | None to_date: DF.Date | None
total: DF.Currency total: DF.Currency
total_advance: DF.Currency total_advance: DF.Currency
@@ -617,12 +616,13 @@ class PurchaseInvoice(BuyingController):
frappe.db.set_value(self.doctype, self.name, "against_expense_account", self.against_expense_account) frappe.db.set_value(self.doctype, self.name, "against_expense_account", self.against_expense_account)
def po_required(self): def po_required(self):
if frappe.db.get_single_value("Buying Settings", "po_required") == "Yes": if (
if frappe.get_value( frappe.db.get_single_value("Buying Settings", "po_required") == "Yes"
and not self.is_internal_transfer()
and not frappe.get_value(
"Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_order" "Supplier", self.supplier, "allow_purchase_invoice_creation_without_purchase_order"
): )
return ):
for d in self.get("items"): for d in self.get("items"):
if not d.purchase_order: if not d.purchase_order:
msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code)) msg = _("Purchase Order Required for item {}").format(frappe.bold(d.item_code))

View File

@@ -2189,11 +2189,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
def test_offsetting_entries_for_accounting_dimensions(self): def test_offsetting_entries_for_accounting_dimensions(self):
from erpnext.accounts.doctype.account.test_account import create_account from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.accounts.report.trial_balance.test_trial_balance import (
clear_dimension_defaults,
create_accounting_dimension,
disable_dimension,
)
create_account( create_account(
account_name="Offsetting", account_name="Offsetting",
@@ -2201,7 +2196,16 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
parent_account="Temporary Accounts - _TC", parent_account="Temporary Accounts - _TC",
) )
create_accounting_dimension(company="_Test Company", offsetting_account="Offsetting - _TC") dim = frappe.get_doc("Accounting Dimension", "Branch")
dim.append(
"dimension_defaults",
{
"company": "_Test Company",
"reference_document": "Branch",
"offsetting_account": "Offsetting - _TC",
},
)
dim.save()
branch1 = frappe.new_doc("Branch") branch1 = frappe.new_doc("Branch")
branch1.branch = "Location 1" branch1.branch = "Location 1"
@@ -2238,8 +2242,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
voucher_type="Purchase Invoice", voucher_type="Purchase Invoice",
additional_columns=["branch"], additional_columns=["branch"],
) )
clear_dimension_defaults("Branch")
disable_dimension()
def test_repost_accounting_entries(self): def test_repost_accounting_entries(self):
# update repost settings # update repost settings

View File

@@ -2246,13 +2246,6 @@ class TestSalesInvoice(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True}) @ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
def test_rounding_adjustment_3(self): def test_rounding_adjustment_3(self):
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import create_dimension
# Dimension creates custom field, which does an implicit DB commit as it is a DDL command
# Ensure dimension don't have any mandatory fields
create_dimension()
# rollback from tearDown() happens till here
si = create_sales_invoice(do_not_save=True) si = create_sales_invoice(do_not_save=True)
si.items = [] si.items = []
for d in [(1122, 2), (1122.01, 1), (1122.01, 1)]: for d in [(1122, 2), (1122.01, 1), (1122.01, 1)]:

View File

@@ -772,7 +772,8 @@ def process_all(subscription: list, posting_date: DateTimeLikeObject | None = No
try: try:
subscription = frappe.get_doc("Subscription", subscription_name) subscription = frappe.get_doc("Subscription", subscription_name)
subscription.process(posting_date) subscription.process(posting_date)
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
except frappe.ValidationError: except frappe.ValidationError:
frappe.db.rollback() frappe.db.rollback()
subscription.log_error("Subscription failed") subscription.log_error("Subscription failed")

View File

@@ -18,7 +18,6 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
# create relevant supplier, etc # create relevant supplier, etc
create_records() create_records()
create_tax_withholding_category_records() create_tax_withholding_category_records()
make_pan_no_field()
def validate_tax_withholding_entries(self, doctype, docname, expected_entries): def validate_tax_withholding_entries(self, doctype, docname, expected_entries):
"""Validate tax withholding entries for a document""" """Validate tax withholding entries for a document"""
@@ -3998,18 +3997,3 @@ def create_lower_deduction_certificate(
"certificate_limit": limit, "certificate_limit": limit,
} }
).insert() ).insert()
def make_pan_no_field():
pan_field = {
"Supplier": [
{
"fieldname": "pan",
"label": "PAN",
"fieldtype": "Data",
"translatable": 0,
}
]
}
create_custom_fields(pan_field, update=1)

View File

@@ -48,6 +48,9 @@ class Deferred_Item:
Generate report data for output Generate report data for output
""" """
ret_data = frappe._dict({"name": self.item_name}) ret_data = frappe._dict({"name": self.item_name})
ret_data.service_start_date = self.service_start_date
ret_data.service_end_date = self.service_end_date
ret_data.amount = self.base_net_amount
for period in self.period_total: for period in self.period_total:
ret_data[period.key] = period.total ret_data[period.key] = period.total
ret_data.indent = 1 ret_data.indent = 1
@@ -205,6 +208,9 @@ class Deferred_Invoice:
for item in self.uniq_items: for item in self.uniq_items:
self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item])) self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item]))
# roll-up amount from all deferred items
self.amount_total = sum(item.base_net_amount for item in self.items)
def calculate_invoice_revenue_expense_for_period(self): def calculate_invoice_revenue_expense_for_period(self):
""" """
calculate deferred revenue/expense for all items in invoice calculate deferred revenue/expense for all items in invoice
@@ -232,7 +238,7 @@ class Deferred_Invoice:
generate report data for invoice, includes invoice total generate report data for invoice, includes invoice total
""" """
ret_data = [] ret_data = []
inv_total = frappe._dict({"name": self.name}) inv_total = frappe._dict({"name": self.name, "amount": self.amount_total})
for x in self.period_total: for x in self.period_total:
inv_total[x.key] = x.total inv_total[x.key] = x.total
inv_total.indent = 0 inv_total.indent = 0
@@ -386,6 +392,24 @@ class Deferred_Revenue_and_Expense_Report:
def get_columns(self): def get_columns(self):
columns = [] columns = []
columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1}) columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1})
columns.append(
{
"label": _("Service Start Date"),
"fieldname": "service_start_date",
"fieldtype": "Date",
"read_only": 1,
}
)
columns.append(
{
"label": _("Service End Date"),
"fieldname": "service_end_date",
"fieldtype": "Date",
"read_only": 1,
}
)
columns.append({"label": _("Amount"), "fieldname": "amount", "fieldtype": "Currency", "read_only": 1})
for period in self.period_list: for period in self.period_list:
columns.append( columns.append(
{ {
@@ -415,6 +439,8 @@ class Deferred_Revenue_and_Expense_Report:
elif self.filters.type == "Expense": elif self.filters.type == "Expense":
total_row = frappe._dict({"name": "Total Deferred Expense"}) total_row = frappe._dict({"name": "Total Deferred Expense"})
total_row["amount"] = sum(inv.amount_total for inv in self.deferred_invoices)
for idx, period in enumerate(self.period_list, 0): for idx, period in enumerate(self.period_list, 0):
total_row[period.key] = self.period_total[idx].total total_row[period.key] = self.period_total[idx].total
ret.append(total_row) ret.append(total_row)

View File

@@ -14,7 +14,6 @@ class TestTrialBalance(ERPNextTestSuite):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
self.company = create_company()
create_cost_center( create_cost_center(
cost_center_name="Test Cost Center", cost_center_name="Test Cost Center",
company="Trial Balance Company", company="Trial Balance Company",
@@ -26,7 +25,16 @@ class TestTrialBalance(ERPNextTestSuite):
parent_account="Temporary Accounts - TBC", parent_account="Temporary Accounts - TBC",
) )
self.fiscal_year = get_fiscal_year(today(), company="Trial Balance Company")[0] self.fiscal_year = get_fiscal_year(today(), company="Trial Balance Company")[0]
create_accounting_dimension() dim = frappe.get_doc("Accounting Dimension", "Branch")
dim.append(
"dimension_defaults",
{
"company": "Trial Balance Company",
"automatically_post_balancing_accounting_entry": 1,
"offsetting_account": "Offsetting - TBC",
},
)
dim.save()
def test_offsetting_entries_for_accounting_dimensions(self): def test_offsetting_entries_for_accounting_dimensions(self):
""" """
@@ -45,7 +53,7 @@ class TestTrialBalance(ERPNextTestSuite):
branch2.insert(ignore_if_duplicate=True) branch2.insert(ignore_if_duplicate=True)
si = create_sales_invoice( si = create_sales_invoice(
company=self.company, company="Trial Balance Company",
debit_to="Debtors - TBC", debit_to="Debtors - TBC",
cost_center="Test Cost Center - TBC", cost_center="Test Cost Center - TBC",
income_account="Sales - TBC", income_account="Sales - TBC",
@@ -57,60 +65,7 @@ class TestTrialBalance(ERPNextTestSuite):
si.submit() si.submit()
filters = frappe._dict( filters = frappe._dict(
{"company": self.company, "fiscal_year": self.fiscal_year, "branch": ["Location 1"]} {"company": "Trial Balance Company", "fiscal_year": self.fiscal_year, "branch": ["Location 1"]}
) )
total_row = execute(filters)[1][-1] total_row = execute(filters)[1][-1]
self.assertEqual(total_row["debit"], total_row["credit"]) self.assertEqual(total_row["debit"], total_row["credit"])
def create_company(**args):
args = frappe._dict(args)
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": args.company_name or "Trial Balance Company",
"country": args.country or "India",
"default_currency": args.currency or "INR",
"parent_company": args.get("parent_company"),
"is_group": args.get("is_group"),
}
)
company.insert(ignore_if_duplicate=True)
return company.name
def create_accounting_dimension(**args):
args = frappe._dict(args)
document_type = args.document_type or "Branch"
if frappe.db.exists("Accounting Dimension", document_type):
accounting_dimension = frappe.get_doc("Accounting Dimension", document_type)
accounting_dimension.disabled = 0
else:
accounting_dimension = frappe.new_doc("Accounting Dimension")
accounting_dimension.document_type = document_type
accounting_dimension.insert()
accounting_dimension.set("dimension_defaults", [])
accounting_dimension.append(
"dimension_defaults",
{
"company": args.company or "Trial Balance Company",
"automatically_post_balancing_accounting_entry": 1,
"offsetting_account": args.offsetting_account or "Offsetting - TBC",
},
)
accounting_dimension.save()
def disable_dimension(**args):
args = frappe._dict(args)
document_type = args.document_type or "Branch"
dimension = frappe.get_doc("Accounting Dimension", document_type)
dimension.disabled = 1
dimension.save()
def clear_dimension_defaults(dimension_name):
accounting_dimension = frappe.get_doc("Accounting Dimension", dimension_name)
accounting_dimension.dimension_defaults = []
accounting_dimension.save()

View File

@@ -61,7 +61,9 @@ def book_depreciation_entries(date):
accounting_dimensions, accounting_dimensions,
) )
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
except Exception as e: except Exception as e:
frappe.db.rollback() frappe.db.rollback()
failed_assets.append(asset_name) failed_assets.append(asset_name)
@@ -71,7 +73,8 @@ def book_depreciation_entries(date):
if failed_assets: if failed_assets:
set_depr_entry_posting_status_for_failed_assets(failed_assets) set_depr_entry_posting_status_for_failed_assets(failed_assets)
notify_depr_entry_posting_error(failed_assets, error_logs) notify_depr_entry_posting_error(failed_assets, error_logs)
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
def get_depreciable_assets_data(date): def get_depreciable_assets_data(date):

View File

@@ -380,6 +380,7 @@ class TestAssetCapitalization(ERPNextTestSuite):
"asset_type": "Composite Component", "asset_type": "Composite Component",
"purchase_date": pr.posting_date, "purchase_date": pr.posting_date,
"available_for_use_date": pr.posting_date, "available_for_use_date": pr.posting_date,
"location": "Test Location",
} }
) )
consumed_asset_doc.save() consumed_asset_doc.save()

View File

@@ -16,7 +16,6 @@ class TestAssetMovement(ERPNextTestSuite):
frappe.db.set_value( frappe.db.set_value(
"Company", "_Test Company", "capital_work_in_progress_account", "CWIP Account - _TC" "Company", "_Test Company", "capital_work_in_progress_account", "CWIP Account - _TC"
) )
make_location()
def test_movement(self): def test_movement(self):
pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location") pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=100000.0, location="Test Location")
@@ -40,10 +39,6 @@ class TestAssetMovement(ERPNextTestSuite):
if asset.docstatus == 0: if asset.docstatus == 0:
asset.submit() asset.submit()
# check asset movement is created
if not frappe.db.exists("Location", "Test Location 2"):
frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert()
create_asset_movement( create_asset_movement(
purpose="Transfer", purpose="Transfer",
company=asset.company, company=asset.company,
@@ -122,9 +117,6 @@ class TestAssetMovement(ERPNextTestSuite):
if asset.docstatus == 0: if asset.docstatus == 0:
asset.submit() asset.submit()
if not frappe.db.exists("Location", "Test Location 2"):
frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert()
movement = frappe.get_doc({"doctype": "Asset Movement", "reference_name": pr.name}) movement = frappe.get_doc({"doctype": "Asset Movement", "reference_name": pr.name})
self.assertRaises(frappe.ValidationError, movement.cancel) self.assertRaises(frappe.ValidationError, movement.cancel)
@@ -150,9 +142,6 @@ class TestAssetMovement(ERPNextTestSuite):
asset = create_asset(item_code="Macbook Pro", do_not_save=1) asset = create_asset(item_code="Macbook Pro", do_not_save=1)
asset.save().submit() asset.save().submit()
if not frappe.db.exists("Location", "Test Location 2"):
frappe.get_doc({"doctype": "Location", "location_name": "Test Location 2"}).insert()
asset_creation_date = frappe.db.get_value( asset_creation_date = frappe.db.get_value(
"Asset Movement", "Asset Movement",
[["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]], [["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]],
@@ -197,9 +186,3 @@ def create_asset_movement(**args):
movement.submit() movement.submit()
return movement return movement
def make_location():
for location in ["Pune", "Mumbai", "Nagpur"]:
if not frappe.db.exists("Location", location):
frappe.get_doc({"doctype": "Location", "location_name": location}).insert(ignore_permissions=True)

View File

@@ -9,7 +9,6 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"supplier_section", "supplier_section",
"title",
"naming_series", "naming_series",
"supplier", "supplier",
"supplier_name", "supplier_name",
@@ -172,17 +171,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"options": "fa fa-user" "options": "fa fa-user"
}, },
{
"allow_on_submit": 1,
"default": "{supplier_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
"label": "Title",
"no_copy": 1,
"print_hide": 1,
"reqd": 1
},
{ {
"fieldname": "naming_series", "fieldname": "naming_series",
"fieldtype": "Select", "fieldtype": "Select",
@@ -1328,7 +1316,7 @@
"idx": 105, "idx": 105,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-03-09 17:15:29.184682", "modified": "2026-03-25 11:46:18.748951",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Purchase Order", "name": "Purchase Order",

View File

@@ -159,7 +159,6 @@ class PurchaseOrder(BuyingController):
taxes_and_charges_deducted: DF.Currency taxes_and_charges_deducted: DF.Currency
tc_name: DF.Link | None tc_name: DF.Link | None
terms: DF.TextEditor | None terms: DF.TextEditor | None
title: DF.Data
to_date: DF.Date | None to_date: DF.Date | None
total: DF.Currency total: DF.Currency
total_net_weight: DF.Float total_net_weight: DF.Float
@@ -780,7 +779,8 @@ def make_purchase_invoice_from_portal(purchase_order_name):
if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"): if frappe.session.user not in frappe.get_all("Portal User", {"parent": doc.supplier}, pluck="user"):
frappe.throw(_("Not Permitted"), frappe.PermissionError) frappe.throw(_("Not Permitted"), frappe.PermissionError)
doc.save() doc.save()
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
frappe.response["type"] = "redirect" frappe.response["type"] = "redirect"
frappe.response.location = "/purchase-invoices/" + doc.name frappe.response.location = "/purchase-invoices/" + doc.name
@@ -802,18 +802,18 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
target.set_payment_schedule() target.set_payment_schedule()
target.credit_to = get_party_account("Supplier", source.supplier, source.company) target.credit_to = get_party_account("Supplier", source.supplier, source.company)
def get_billed_qty(po_item_name):
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.po_detail == po_item_name))
)
return query.run(pluck="qty")[0] or 0
def update_item(obj, target, source_parent): def update_item(obj, target, source_parent):
def get_billed_qty(po_item_name):
from frappe.query_builder.functions import Sum
table = frappe.qb.DocType("Purchase Invoice Item")
query = (
frappe.qb.from_(table)
.select(Sum(table.qty).as_("qty"))
.where((table.docstatus == 1) & (table.po_detail == po_item_name))
)
return query.run(pluck="qty")[0] or 0
billed_qty = flt(get_billed_qty(obj.name)) billed_qty = flt(get_billed_qty(obj.name))
target.qty = flt(obj.qty) - billed_qty target.qty = flt(obj.qty) - billed_qty
@@ -853,7 +853,11 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
"wip_composite_asset": "wip_composite_asset", "wip_composite_asset": "wip_composite_asset",
}, },
"postprocess": update_item, "postprocess": update_item,
"condition": lambda doc: (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)) "condition": lambda doc: (
doc.base_amount == 0
or abs(doc.billed_amt) < abs(doc.amount)
or doc.qty > flt(get_billed_qty(doc.name))
)
and select_item(doc), and select_item(doc),
}, },
"Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},

View File

@@ -1386,6 +1386,34 @@ class TestPurchaseOrder(ERPNextTestSuite):
self.assertEqual(pi_2.status, "Paid") self.assertEqual(pi_2.status, "Paid")
self.assertEqual(po.status, "Completed") self.assertEqual(po.status, "Completed")
def test_purchase_order_over_billing_missing_item(self):
item1 = make_item(
"_Test Item for Overbilling",
).name
item2 = make_item(
"_Test Item for Overbilling 2",
).name
po = create_purchase_order(qty=10, rate=1000, item_code=item1, do_not_save=1)
po.append("items", {"item_code": item2, "qty": 5, "rate": 20, "warehouse": "_Test Warehouse - _TC"})
po.taxes = []
po.insert()
po.submit()
pi1 = make_pi_from_po(po.name)
pi1.items[0].qty = 8
pi1.items[0].rate = 1250
pi1.remove(pi1.items[1])
pi1.insert()
pi1.submit()
self.assertEqual(pi1.grand_total, 10000.0)
self.assertTrue(len(pi1.items) == 1)
pi2 = make_pi_from_po(po.name)
self.assertEqual(len(pi2.items), 2)
def create_po_for_sc_testing(): def create_po_for_sc_testing():
from erpnext.controllers.tests.test_subcontracting_controller import ( from erpnext.controllers.tests.test_subcontracting_controller import (

View File

@@ -100,6 +100,7 @@ frappe.ui.form.on("Request for Quotation", {
fieldname: "print_format", fieldname: "print_format",
options: "Print Format", options: "Print Format",
placeholder: "Standard", placeholder: "Standard",
default: frappe.get_meta("Request for Quotation").default_print_format || "",
get_query: () => { get_query: () => {
return { return {
filters: { filters: {

View File

@@ -9,8 +9,6 @@
"field_order": [ "field_order": [
"naming_series", "naming_series",
"company", "company",
"billing_address",
"billing_address_display",
"vendor", "vendor",
"column_break1", "column_break1",
"transaction_date", "transaction_date",
@@ -43,7 +41,13 @@
"select_print_heading", "select_print_heading",
"letter_head", "letter_head",
"more_info", "more_info",
"opportunity" "opportunity",
"address_and_contact_tab",
"billing_address",
"billing_address_display",
"column_break_czul",
"shipping_address",
"shipping_address_display"
], ],
"fields": [ "fields": [
{ {
@@ -346,6 +350,27 @@
"fieldtype": "Check", "fieldtype": "Check",
"hidden": 1, "hidden": 1,
"label": "Use HTML" "label": "Use HTML"
},
{
"fieldname": "address_and_contact_tab",
"fieldtype": "Tab Break",
"label": "Address & Contact"
},
{
"fieldname": "column_break_czul",
"fieldtype": "Column Break"
},
{
"fieldname": "shipping_address",
"fieldtype": "Link",
"label": "Company Shipping Address",
"options": "Address"
},
{
"fieldname": "shipping_address_display",
"fieldtype": "Text Editor",
"label": "Shipping Address Details",
"read_only": 1
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -353,7 +378,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-03-09 17:15:29.774614", "modified": "2026-03-19 15:27:56.730649",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Request for Quotation", "name": "Request for Quotation",

View File

@@ -56,6 +56,8 @@ class RequestforQuotation(BuyingController):
select_print_heading: DF.Link | None select_print_heading: DF.Link | None
send_attached_files: DF.Check send_attached_files: DF.Check
send_document_print: DF.Check send_document_print: DF.Check
shipping_address: DF.Link | None
shipping_address_display: DF.TextEditor | None
status: DF.Literal["", "Draft", "Submitted", "Cancelled"] status: DF.Literal["", "Draft", "Submitted", "Cancelled"]
subject: DF.Data subject: DF.Data
suppliers: DF.Table[RequestforQuotationSupplier] suppliers: DF.Table[RequestforQuotationSupplier]

File diff suppressed because one or more lines are too long

View File

@@ -4324,6 +4324,8 @@ def get_missing_company_details(doctype, docname):
company = frappe.db.get_value(doctype, docname, "company") company = frappe.db.get_value(doctype, docname, "company")
if doctype in ["Purchase Order", "Purchase Invoice"]: if doctype in ["Purchase Order", "Purchase Invoice"]:
company_address = frappe.db.get_value(doctype, docname, "billing_address") company_address = frappe.db.get_value(doctype, docname, "billing_address")
elif doctype in ["Request for Quotation"]:
company_address = frappe.db.get_value(doctype, docname, "shipping_address")
else: else:
company_address = frappe.db.get_value(doctype, docname, "company_address") company_address = frappe.db.get_value(doctype, docname, "company_address")
@@ -4423,6 +4425,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"),
"Request for Quotation": ("shipping_address", "shipping_address_display"),
} }
address_field, display_field = address_field_map.get( address_field, display_field = address_field_map.get(

View File

@@ -503,11 +503,15 @@ class BuyingController(SubcontractingController):
if d.category not in ["Valuation", "Valuation and Total"]: if d.category not in ["Valuation", "Valuation and Total"]:
continue continue
amount = flt(d.base_tax_amount_after_discount_amount) * (
-1 if d.get("add_deduct_tax") == "Deduct" else 1
)
if d.charge_type == "On Net Total": if d.charge_type == "On Net Total":
total_valuation_amount += flt(d.base_tax_amount_after_discount_amount) total_valuation_amount += amount
tax_accounts.append(d.account_head) tax_accounts.append(d.account_head)
else: else:
total_actual_tax_amount += flt(d.base_tax_amount_after_discount_amount) total_actual_tax_amount += amount
return tax_accounts, total_valuation_amount, total_actual_tax_amount return tax_accounts, total_valuation_amount, total_actual_tax_amount
@@ -1094,7 +1098,8 @@ class BuyingController(SubcontractingController):
for dimension in accounting_dimensions[0]: for dimension in accounting_dimensions[0]:
fieldname = dimension["fieldname"] fieldname = dimension["fieldname"]
default_dimension = accounting_dimensions[1].get(self.company, {}).get(fieldname) default_dimension = accounting_dimensions[1].get(self.company, {}).get(fieldname)
asset.update({fieldname: row.get(fieldname) or self.get(fieldname) or default_dimension}) if not asset.get(fieldname):
asset.update({fieldname: row.get(fieldname) or self.get(fieldname) or default_dimension})
asset.flags.ignore_validate = True asset.flags.ignore_validate = True
asset.flags.ignore_mandatory = True asset.flags.ignore_mandatory = True

View File

@@ -1002,3 +1002,26 @@ def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
limit_page_length=page_len, limit_page_length=page_len,
as_list=1, as_list=1,
) )
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_warehouse_address(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
table = frappe.qb.DocType(doctype)
child_table = frappe.qb.DocType("Dynamic Link")
query = (
frappe.qb.from_(table)
.inner_join(child_table)
.on((table.name == child_table.parent) & (child_table.parenttype == doctype))
.select(table.name)
.where(
(child_table.link_name == filters.get("warehouse"))
& (table.disabled == 0)
& (child_table.link_doctype == "Warehouse")
& (table.name.like(f"%{txt}%"))
)
.offset(start)
.limit(page_len)
)
return query.run(as_list=1)

View File

@@ -1567,25 +1567,10 @@ class TestAccountsController(ERPNextTestSuite):
frappe.db.set_value("Company", self.company, "cost_center", cc) frappe.db.set_value("Company", self.company, "cost_center", cc)
def setup_dimensions(self):
# create dimension
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
)
create_dimension()
# make it non-mandatory
loc = frappe.get_doc("Accounting Dimension", "Location")
for x in loc.dimension_defaults:
x.mandatory_for_bs = False
x.mandatory_for_pl = False
loc.save()
def test_90_dimensions_filter(self): def test_90_dimensions_filter(self):
""" """
Test workings of dimension filters Test workings of dimension filters
""" """
self.setup_dimensions()
rate_in_account_currency = 1 rate_in_account_currency = 1
# Invoices # Invoices
@@ -1653,7 +1638,6 @@ class TestAccountsController(ERPNextTestSuite):
self.assertEqual(len(pr.payments), 1) self.assertEqual(len(pr.payments), 1)
def test_91_cr_note_should_inherit_dimension(self): def test_91_cr_note_should_inherit_dimension(self):
self.setup_dimensions()
rate_in_account_currency = 1 rate_in_account_currency = 1
# Invoice # Invoice
@@ -1698,7 +1682,6 @@ class TestAccountsController(ERPNextTestSuite):
def test_92_dimension_inhertiance_exc_gain_loss(self): def test_92_dimension_inhertiance_exc_gain_loss(self):
# Sales Invoice in Foreign Currency # Sales Invoice in Foreign Currency
self.setup_dimensions()
rate_in_account_currency = 1 rate_in_account_currency = 1
dpt = "Research & Development - _TC" dpt = "Research & Development - _TC"
@@ -1734,7 +1717,6 @@ class TestAccountsController(ERPNextTestSuite):
) )
def test_93_dimension_inheritance_on_advance(self): def test_93_dimension_inheritance_on_advance(self):
self.setup_dimensions()
dpt = "Research & Development - _TC" dpt = "Research & Development - _TC"
adv = self.create_payment_entry(amount=1, source_exc_rate=85) adv = self.create_payment_entry(amount=1, source_exc_rate=85)

View File

@@ -120,7 +120,8 @@ class Appointment(Document):
self.auto_assign() self.auto_assign()
self.create_calendar_event() self.create_calendar_event()
self.save(ignore_permissions=True) self.save(ignore_permissions=True)
frappe.db.commit() if not frappe.in_test:
frappe.db.commit()
def create_lead_and_link(self): def create_lead_and_link(self):
# Return if already linked # Return if already linked

View File

@@ -56,7 +56,7 @@
} }
], ],
"links": [], "links": [],
"modified": "2024-03-27 13:06:46.495091", "modified": "2026-03-25 19:27:19.162421",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Contract Template", "name": "Contract Template",
@@ -75,42 +75,34 @@
"write": 1 "write": 1
}, },
{ {
"create": 1,
"delete": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "Sales Manager", "role": "Sales Manager",
"share": 1, "share": 1
"write": 1
}, },
{ {
"create": 1,
"delete": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "Purchase Manager", "role": "Purchase Manager",
"share": 1, "share": 1
"write": 1
}, },
{ {
"create": 1,
"delete": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "HR Manager", "role": "HR Manager",
"share": 1, "share": 1
"write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],

View File

@@ -204,8 +204,22 @@ def send_mail(entry, email_campaign):
# called from hooks on doc_event Email Unsubscribe # called from hooks on doc_event Email Unsubscribe
def unsubscribe_recipient(unsubscribe, method): def unsubscribe_recipient(unsubscribe, method):
if unsubscribe.reference_doctype == "Email Campaign": if unsubscribe.reference_doctype != "Email Campaign":
frappe.db.set_value("Email Campaign", unsubscribe.reference_name, "status", "Unsubscribed") return
email_campaign = frappe.get_doc("Email Campaign", unsubscribe.reference_name)
if email_campaign.email_campaign_for == "Email Group":
if unsubscribe.email:
frappe.db.set_value(
"Email Group Member",
{"email_group": email_campaign.recipient, "email": unsubscribe.email},
"unsubscribed",
1,
)
else:
# For Lead or Contact
frappe.db.set_value("Email Campaign", email_campaign.name, "status", "Unsubscribed")
# called through hooks to update email campaign status daily # called through hooks to update email campaign status daily

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import escape_html
if TYPE_CHECKING: if TYPE_CHECKING:
from lxml.etree import Element from lxml.etree import Element
@@ -63,14 +64,16 @@ class CodeList(Document):
def from_genericode(self, root: "Element"): def from_genericode(self, root: "Element"):
"""Extract Code List details from genericode XML""" """Extract Code List details from genericode XML"""
self.title = root.find(".//Identification/ShortName").text self.title = escape_html(root.find(".//Identification/ShortName").text)
self.version = root.find(".//Identification/Version").text self.version = root.find(".//Identification/Version").text
self.canonical_uri = root.find(".//CanonicalUri").text self.canonical_uri = root.find(".//CanonicalUri").text
# optionals # optionals
self.description = getattr(root.find(".//Identification/LongName"), "text", None) self.description = escape_html(getattr(root.find(".//Identification/LongName"), "text", None))
self.publisher = getattr(root.find(".//Identification/Agency/ShortName"), "text", None) self.publisher = escape_html(getattr(root.find(".//Identification/Agency/ShortName"), "text", None))
if not self.publisher: if not self.publisher:
self.publisher = getattr(root.find(".//Identification/Agency/LongName"), "text", None) self.publisher = escape_html(
getattr(root.find(".//Identification/Agency/LongName"), "text", None)
)
self.publisher_id = getattr(root.find(".//Identification/Agency/Identifier"), "text", None) self.publisher_id = getattr(root.find(".//Identification/Agency/Identifier"), "text", None)
self.url = getattr(root.find(".//Identification/LocationUri"), "text", None) self.url = getattr(root.find(".//Identification/LocationUri"), "text", None)

View File

@@ -3,6 +3,7 @@ import json
import frappe import frappe
import requests import requests
from frappe import _ from frappe import _
from frappe.utils import escape_html
from lxml import etree from lxml import etree
URL_PREFIXES = ("http://", "https://") URL_PREFIXES = ("http://", "https://")
@@ -32,7 +33,12 @@ def import_genericode():
content = f.read() content = f.read()
# Parse the xml content # Parse the xml content
parser = etree.XMLParser(remove_blank_text=True) parser = etree.XMLParser(
remove_blank_text=True,
resolve_entities=False,
load_dtd=False,
no_network=True,
)
try: try:
root = etree.fromstring(content, parser=parser) root = etree.fromstring(content, parser=parser)
except Exception as e: except Exception as e:
@@ -104,7 +110,7 @@ def get_genericode_columns_and_examples(root):
# Get column names # Get column names
for column in root.findall(".//Column"): for column in root.findall(".//Column"):
column_id = column.get("Id") column_id = escape_html(column.get("Id"))
columns.append(column_id) columns.append(column_id)
example_values[column_id] = [] example_values[column_id] = []
filterable_columns[column_id] = set() filterable_columns[column_id] = set()
@@ -112,7 +118,7 @@ def get_genericode_columns_and_examples(root):
# Get all values and count unique occurrences # Get all values and count unique occurrences
for row in root.findall(".//SimpleCodeList/Row"): for row in root.findall(".//SimpleCodeList/Row"):
for value in row.findall("Value"): for value in row.findall("Value"):
column_id = value.get("ColumnRef") column_id = escape_html(value.get("ColumnRef"))
if column_id not in columns: if column_id not in columns:
# Handle undeclared column # Handle undeclared column
columns.append(column_id) columns.append(column_id)
@@ -123,7 +129,7 @@ def get_genericode_columns_and_examples(root):
if simple_value is None: if simple_value is None:
continue continue
filterable_columns[column_id].add(simple_value.text) filterable_columns[column_id].add(escape_html(simple_value.text))
# Get example values (up to 3) and filter columns with cardinality <= 5 # Get example values (up to 3) and filter columns with cardinality <= 5
for row in root.findall(".//SimpleCodeList/Row")[:3]: for row in root.findall(".//SimpleCodeList/Row")[:3]:
@@ -133,7 +139,7 @@ def get_genericode_columns_and_examples(root):
if simple_value is None: if simple_value is None:
continue continue
example_values[column_id].append(simple_value.text) example_values[column_id].append(escape_html(simple_value.text))
filterable_columns = {k: list(v) for k, v in filterable_columns.items() if len(v) <= 5} filterable_columns = {k: list(v) for k, v in filterable_columns.items() if len(v) <= 5}

View File

@@ -62,7 +62,6 @@ welcome_email = "erpnext.setup.utils.welcome_email"
# setup wizard # setup wizard
setup_wizard_requires = "assets/erpnext/js/setup_wizard.js" setup_wizard_requires = "assets/erpnext/js/setup_wizard.js"
setup_wizard_stages = "erpnext.setup.setup_wizard.setup_wizard.get_setup_stages" setup_wizard_stages = "erpnext.setup.setup_wizard.setup_wizard.get_setup_stages"
setup_wizard_complete = "erpnext.setup.setup_wizard.setup_wizard.setup_demo"
after_install = "erpnext.setup.install.after_install" after_install = "erpnext.setup.install.after_install"

File diff suppressed because it is too large Load Diff

View File

@@ -944,12 +944,14 @@ class BOM(WebsiteGenerator):
hour_rate / flt(self.conversion_rate) if self.conversion_rate and hour_rate else hour_rate hour_rate / flt(self.conversion_rate) if self.conversion_rate and hour_rate else hour_rate
) )
if row.hour_rate and row.time_in_mins: if row.hour_rate:
row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate) row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate)
row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate) if row.time_in_mins:
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0) row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0) row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate)
row.cost_per_unit = row.operating_cost / (row.batch_size or 1.0)
row.base_cost_per_unit = row.base_operating_cost / (row.batch_size or 1.0)
if update_hour_rate: if update_hour_rate:
row.db_update() row.db_update()

View File

@@ -40,6 +40,14 @@ frappe.ui.form.on("Job Card", {
}; };
}); });
frm.set_query("work_order", function () {
return {
filters: {
status: ["not in", ["Cancelled", "Closed", "Stopped"]],
},
};
});
frm.events.set_company_filters(frm, "target_warehouse"); frm.events.set_company_filters(frm, "target_warehouse");
frm.events.set_company_filters(frm, "source_warehouse"); frm.events.set_company_filters(frm, "source_warehouse");
frm.events.set_company_filters(frm, "wip_warehouse"); frm.events.set_company_filters(frm, "wip_warehouse");
@@ -780,26 +788,12 @@ frappe.ui.form.on("Job Card Time Log", {
frm.events.set_total_completed_qty(frm); frm.events.set_total_completed_qty(frm);
}, },
time_in_mins(frm, cdt, cdn) {
let d = locals[cdt][cdn];
if (d.time_in_mins) {
d.to_time = add_mins_to_time(d.from_time, d.time_in_mins);
frappe.model.set_value(cdt, cdn, "to_time", d.to_time);
}
},
}); });
function get_seconds_diff(d1, d2) { function get_seconds_diff(d1, d2) {
return moment(d1).diff(d2, "seconds"); return moment(d1).diff(d2, "seconds");
} }
function add_mins_to_time(datetime, mins) {
let new_date = moment(datetime).add(mins, "minutes");
return new_date.format("YYYY-MM-DD HH:mm:ss");
}
function get_last_completed_row(time_logs) { function get_last_completed_row(time_logs) {
let completed_rows = time_logs.filter((d) => d.to_time); let completed_rows = time_logs.filter((d) => d.to_time);

View File

@@ -616,7 +616,12 @@ class ProductionPlan(Document):
None, None,
): ):
item.db_set("sub_assembly_item_reference", reference) item.db_set("sub_assembly_item_reference", reference)
elif self.reserve_stock and item.main_item_code and item.from_bom: elif (
self.reserve_stock
and item.main_item_code
and item.from_bom
and item.main_item_code != frappe.get_cached_value("BOM", item.from_bom, "item")
):
frappe.throw( frappe.throw(
_( _(
"Sub assembly item references are missing. Please fetch the sub assemblies and raw materials again." "Sub assembly item references are missing. Please fetch the sub assemblies and raw materials again."
@@ -1778,8 +1783,10 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
) )
sales_order = data.get("sales_order") sales_order = data.get("sales_order")
qty_precision = frappe.get_precision("Material Request Plan Item", "quantity")
for key, details in item_details.items(): for key, details in item_details.items():
details.qty = flt(details.qty, qty_precision)
so_item_details.setdefault(sales_order, frappe._dict()) so_item_details.setdefault(sales_order, frappe._dict())
if key in so_item_details.get(sales_order, {}): if key in so_item_details.get(sales_order, {}):
so_item_details[sales_order][key]["qty"] = so_item_details[sales_order][key].get( so_item_details[sales_order][key]["qty"] = so_item_details[sales_order][key].get(

View File

@@ -508,10 +508,28 @@ class TestWorkOrder(ERPNextTestSuite):
def test_work_order_material_transferred_qty_with_process_loss(self): def test_work_order_material_transferred_qty_with_process_loss(self):
stock_entries = [] stock_entries = []
bom = frappe.get_doc( item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name
"BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company", "has_variants": 0} rm_item_code = make_item("Test Item For Process Loss RM", {"is_stock_item": 1}).name
bom = make_bom(
item=item_code,
raw_materials=[rm_item_code],
with_operations=1,
do_not_save=True,
) )
operation = {
"operation": "_Test Operation 1",
"workstation": "_Test Workstation 1",
"description": "Test Data",
"operating_cost": 100,
"time_in_mins": 40,
}
bom.append("operations", operation)
bom.save()
bom.submit()
work_order = make_wo_order_test_record( work_order = make_wo_order_test_record(
item=bom.item, item=bom.item,
qty=2, qty=2,

View File

@@ -587,7 +587,7 @@ class WorkOrder(Document):
if self.docstatus == 0: if self.docstatus == 0:
status = "Draft" status = "Draft"
elif self.docstatus == 1: elif self.docstatus == 1:
if status != "Stopped": if status not in ["Closed", "Stopped"]:
status = "Not Started" status = "Not Started"
if flt(self.material_transferred_for_manufacturing) > 0: if flt(self.material_transferred_for_manufacturing) > 0:
status = "In Process" status = "In Process"

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.query_reports["BOM Stock Analysis"] = {
filters: [
{
fieldname: "bom",
label: __("BOM"),
fieldtype: "Link",
options: "BOM",
reqd: 1,
},
{
fieldname: "warehouse",
label: __("Warehouse"),
fieldtype: "Link",
options: "Warehouse",
},
{
fieldname: "qty_to_make",
label: __("FG Items to Make"),
fieldtype: "Float",
},
{
fieldname: "show_exploded_view",
label: __("Show availability of exploded items"),
fieldtype: "Check",
default: false,
},
],
formatter(value, row, column, data, default_formatter) {
if (data && data.bold && column.fieldname === "item") {
return value ? `<b>${value}</b>` : "";
}
value = default_formatter(value, row, column, data);
if (column.fieldname === "difference_qty" && value !== "" && value !== undefined) {
const numeric = parseFloat(value.replace(/,/g, "")) || 0;
if (numeric < 0) {
value = `<span style="color: red">${value}</span>`;
} else if (numeric > 0) {
value = `<span style="color: green">${value}</span>`;
}
}
if (data && data.bold) {
if (column.fieldname === "description") {
const qty_to_make = Number(frappe.query_report.get_filter_value("qty_to_make")) || 0;
const producible = Number(String(data.description ?? "").replace(/,/g, "")) || 0;
const colour = qty_to_make && producible < qty_to_make ? "red" : "green";
return `<b style="color: ${colour}">${value}</b>`;
}
return `<b>${value}</b>`;
}
return value;
},
};

View File

@@ -0,0 +1,31 @@
{
"add_total_row": 0,
"add_translate_data": 0,
"columns": [],
"creation": "2026-03-23 15:42:06.064606",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"filters": [],
"idx": 0,
"is_standard": "Yes",
"letter_head": null,
"modified": "2026-03-23 15:48:56.933892",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Stock Analysis",
"owner": "Administrator",
"prepared_report": 0,
"ref_doctype": "BOM",
"report_name": "BOM Stock Analysis",
"report_type": "Script Report",
"roles": [
{
"role": "Manufacturing Manager"
},
{
"role": "Manufacturing User"
}
],
"timeout": 0
}

View File

@@ -0,0 +1,326 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.query_builder.functions import Floor, IfNull, Sum
from frappe.utils import flt, fmt_money
from frappe.utils.data import comma_and
from pypika.terms import ExistsCriterion
def execute(filters=None):
filters = filters or {}
if filters.get("qty_to_make"):
columns = get_columns_with_qty_to_make()
data = get_data_with_qty_to_make(filters)
else:
columns = get_columns_without_qty_to_make()
data = get_data_without_qty_to_make(filters)
return columns, data
def fmt_qty(value):
"""Format a float quantity for display as a string, so blank rows stay blank."""
return frappe.utils.fmt_money(value, precision=2, currency=None)
def fmt_rate(value):
"""Format a currency rate for display as a string."""
currency = frappe.defaults.get_global_default("currency")
return frappe.utils.fmt_money(value, precision=2, currency=currency)
def get_data_with_qty_to_make(filters):
bom_data = get_bom_data(filters)
manufacture_details = get_manufacturer_records()
purchase_rates = batch_fetch_purchase_rates(bom_data)
qty_to_make = flt(filters.get("qty_to_make"))
data = []
for row in bom_data:
qty_per_unit = flt(row.qty_per_unit) if row.qty_per_unit > 0 else 0
required_qty = qty_to_make * qty_per_unit
difference_qty = flt(row.actual_qty) - required_qty
rate = purchase_rates.get(row.item_code, 0)
data.append(
{
"item": row.item_code,
"description": row.description,
"from_bom_no": row.from_bom_no,
"manufacturer": comma_and(
manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False
),
"manufacturer_part_number": comma_and(
manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False
),
"qty_per_unit": fmt_qty(qty_per_unit),
"available_qty": fmt_qty(row.actual_qty),
"required_qty": fmt_qty(required_qty),
"difference_qty": fmt_qty(difference_qty),
"last_purchase_rate": fmt_rate(rate),
"_available_qty": flt(row.actual_qty),
"_qty_per_unit": qty_per_unit,
}
)
min_producible = (
min(int(r["_available_qty"] // r["_qty_per_unit"]) for r in data if r["_qty_per_unit"]) if data else 0
)
for row in data:
row.pop("_available_qty", None)
row.pop("_qty_per_unit", None)
# blank spacer row
data.append({})
data.append(
{
"item": _("Maximum Producible Items"),
"description": min_producible,
"from_bom_no": "",
"manufacturer": "",
"manufacturer_part_number": "",
"qty_per_unit": "",
"available_qty": "",
"required_qty": "",
"difference_qty": "",
"last_purchase_rate": "",
"bold": 1,
}
)
return data
def get_columns_with_qty_to_make():
return [
{"fieldname": "item", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 180},
{"fieldname": "description", "label": _("Description"), "fieldtype": "Data", "width": 160},
{
"fieldname": "from_bom_no",
"label": _("From BOM No"),
"fieldtype": "Link",
"options": "BOM",
"width": 150,
},
{"fieldname": "manufacturer", "label": _("Manufacturer"), "fieldtype": "Data", "width": 130},
{
"fieldname": "manufacturer_part_number",
"label": _("Manufacturer Part Number"),
"fieldtype": "Data",
"width": 170,
},
{"fieldname": "qty_per_unit", "label": _("Qty Per Unit"), "fieldtype": "Data", "width": 110},
{"fieldname": "available_qty", "label": _("Available Qty"), "fieldtype": "Data", "width": 120},
{"fieldname": "required_qty", "label": _("Required Qty"), "fieldtype": "Data", "width": 120},
{"fieldname": "difference_qty", "label": _("Difference Qty"), "fieldtype": "Data", "width": 130},
{
"fieldname": "last_purchase_rate",
"label": _("Last Purchase Rate"),
"fieldtype": "Data",
"width": 160,
},
]
def get_data_without_qty_to_make(filters):
raw_rows = get_producible_fg_items(filters)
data = []
for row in raw_rows:
data.append(
{
"item": row[0],
"description": row[1],
"from_bom_no": row[2],
"qty_per_unit": fmt_qty(row[3]),
"available_qty": fmt_qty(row[4]),
}
)
min_producible = min((row[5] or 0) for row in raw_rows) if raw_rows else 0
# blank spacer row
data.append({})
data.append(
{
"item": _("Maximum Producible Items"),
"description": min_producible,
"from_bom_no": "",
"qty_per_unit": "",
"available_qty": "",
"bold": 1,
}
)
return data
def get_columns_without_qty_to_make():
return [
{"fieldname": "item", "label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 180},
{"fieldname": "description", "label": _("Description"), "fieldtype": "Data", "width": 200},
{
"fieldname": "from_bom_no",
"label": _("From BOM No"),
"fieldtype": "Link",
"options": "BOM",
"width": 160,
},
{"fieldname": "qty_per_unit", "label": _("Qty Per Unit"), "fieldtype": "Data", "width": 120},
{"fieldname": "available_qty", "label": _("Available Qty"), "fieldtype": "Data", "width": 120},
]
def batch_fetch_purchase_rates(bom_data):
if not bom_data:
return {}
item_codes = [row.item_code for row in bom_data]
return {
r.name: r.last_purchase_rate
for r in frappe.get_all(
"Item",
filters={"name": ["in", item_codes]},
fields=["name", "last_purchase_rate"],
)
}
def get_bom_data(filters):
bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
bom_item = frappe.qb.DocType(bom_item_table)
bin = frappe.qb.DocType("Bin")
query = (
frappe.qb.from_(bom_item)
.left_join(bin)
.on(bom_item.item_code == bin.item_code)
.select(
bom_item.item_code,
bom_item.description,
bom_item.parent.as_("from_bom_no"),
Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"),
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
)
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
.groupby(bom_item.item_code)
.orderby(bom_item.idx)
)
if filters.get("warehouse"):
warehouse_details = frappe.db.get_value(
"Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1
)
if warehouse_details:
wh = frappe.qb.DocType("Warehouse")
query = query.where(
ExistsCriterion(
frappe.qb.from_(wh)
.select(wh.name)
.where(
(wh.lft >= warehouse_details.lft)
& (wh.rgt <= warehouse_details.rgt)
& (bin.warehouse == wh.name)
)
)
)
else:
query = query.where(bin.warehouse == filters.get("warehouse"))
if bom_item_table == "BOM Item":
query = query.select(bom_item.bom_no, bom_item.is_phantom_item)
data = query.run(as_dict=True)
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
def explode_phantom_boms(data, filters):
original_bom = filters.get("bom")
replacements = []
for idx, item in enumerate(data):
if not item.is_phantom_item:
continue
filters["bom"] = item.bom_no
children = get_bom_data(filters)
filters["bom"] = original_bom
for child in children:
child.qty_per_unit = (child.qty_per_unit or 0) * (item.qty_per_unit or 0)
replacements.append((idx, children))
for idx, children in reversed(replacements):
data.pop(idx)
data[idx:idx] = children
return data
def get_manufacturer_records():
details = frappe.get_all(
"Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"]
)
manufacture_details = frappe._dict()
for detail in details:
dic = manufacture_details.setdefault(detail.get("item_code"), {})
dic.setdefault("manufacturer", []).append(detail.get("manufacturer"))
dic.setdefault("manufacturer_part", []).append(detail.get("manufacturer_part_no"))
return manufacture_details
def get_producible_fg_items(filters):
BOM_ITEM = frappe.qb.DocType("BOM Item")
BOM = frappe.qb.DocType("BOM")
BIN = frappe.qb.DocType("Bin")
WH = frappe.qb.DocType("Warehouse")
warehouse = filters.get("warehouse")
if not warehouse:
frappe.throw(_("Warehouse is required to get producible FG Items"))
warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1)
if warehouse_details:
bin_subquery = (
frappe.qb.from_(BIN)
.join(WH)
.on(BIN.warehouse == WH.name)
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
.where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt))
.groupby(BIN.item_code)
)
else:
bin_subquery = (
frappe.qb.from_(BIN)
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
.where(BIN.warehouse == warehouse)
.groupby(BIN.item_code)
)
query = (
frappe.qb.from_(BOM_ITEM)
.join(BOM)
.on(BOM_ITEM.parent == BOM.name)
.left_join(bin_subquery)
.on(BOM_ITEM.item_code == bin_subquery.item_code)
.select(
BOM_ITEM.item_code,
BOM_ITEM.description,
BOM_ITEM.parent.as_("from_bom_no"),
(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
IfNull(bin_subquery.actual_qty, 0).as_("available_qty"),
Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty)) / BOM.quantity)),
)
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
.groupby(BOM_ITEM.item_code)
.orderby(BOM_ITEM.idx)
)
return query.run(as_list=True)

View File

@@ -0,0 +1,171 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import fmt_money
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.report.bom_stock_analysis.bom_stock_analysis import (
execute as bom_stock_analysis_report,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
def fmt_qty(value):
return fmt_money(value, precision=2, currency=None)
def fmt_rate(value):
currency = frappe.defaults.get_global_default("currency")
return fmt_money(value, precision=2, currency=currency)
class TestBOMStockAnalysis(ERPNextTestSuite):
def setUp(self):
self.fg_item, self.rm_items = create_items()
self.boms = create_boms(self.fg_item, self.rm_items)
def test_bom_stock_analysis(self):
qty_to_make = 10
# Case 1: When Item(s) Qty and Stock Qty are equal.
raw_data = bom_stock_analysis_report(
filters={
"qty_to_make": qty_to_make,
"bom": self.boms[0].name,
}
)[1]
data, footer = split_data_and_footer(raw_data)
expected_data, expected_min = get_expected_data(self.boms[0], qty_to_make)
self.assertSetEqual(
set(tuple(sorted(r.items())) for r in data),
set(tuple(sorted(r.items())) for r in expected_data),
)
self.assertEqual(footer.get("description"), expected_min)
# Case 2: When Item(s) Qty and Stock Qty are different and BOM Qty is 1.
raw_data = bom_stock_analysis_report(
filters={
"qty_to_make": qty_to_make,
"bom": self.boms[1].name,
}
)[1]
data, footer = split_data_and_footer(raw_data)
expected_data, expected_min = get_expected_data(self.boms[1], qty_to_make)
self.assertSetEqual(
set(tuple(sorted(r.items())) for r in data),
set(tuple(sorted(r.items())) for r in expected_data),
)
self.assertEqual(footer.get("description"), expected_min)
# Case 3: When Item(s) Qty and Stock Qty are different and BOM Qty is greater than 1.
raw_data = bom_stock_analysis_report(
filters={
"qty_to_make": qty_to_make,
"bom": self.boms[2].name,
}
)[1]
data, footer = split_data_and_footer(raw_data)
expected_data, expected_min = get_expected_data(self.boms[2], qty_to_make)
self.assertSetEqual(
set(tuple(sorted(r.items())) for r in data),
set(tuple(sorted(r.items())) for r in expected_data),
)
self.assertEqual(footer.get("description"), expected_min)
def split_data_and_footer(raw_data):
"""Separate component rows from the footer row. Skips blank spacer rows."""
data = [row for row in raw_data if row and not row.get("bold")]
footer = next((row for row in raw_data if row and row.get("bold")), {})
return data, footer
def create_items():
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item1 = make_item(
properties={
"is_stock_item": 1,
"standard_rate": 100,
"opening_stock": 100,
"last_purchase_rate": 100,
"item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}],
}
).name
rm_item2 = make_item(
properties={
"is_stock_item": 1,
"standard_rate": 200,
"opening_stock": 200,
"last_purchase_rate": 200,
"item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}],
}
).name
return fg_item, [rm_item1, rm_item2]
def create_boms(fg_item, rm_items):
def update_bom_items(bom, uom, conversion_factor):
for item in bom.items:
item.uom = uom
item.conversion_factor = conversion_factor
return bom
bom1 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10)
bom2 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10, do_not_submit=True)
bom2 = update_bom_items(bom2, "Box", 10)
bom2.save()
bom2.submit()
bom3 = make_bom(item=fg_item, quantity=2, raw_materials=rm_items, rm_qty=10, do_not_submit=True)
bom3 = update_bom_items(bom3, "Box", 10)
bom3.save()
bom3.submit()
return [bom1, bom2, bom3]
def get_expected_data(bom, qty_to_make):
"""
Returns (component_rows, min_producible).
Component rows are dicts matching what the report produces.
min_producible is the expected footer value.
"""
expected_data = []
producible_per_item = []
for idx, bom_item in enumerate(bom.items):
qty_per_unit = float(bom_item.stock_qty / bom.quantity)
available_qty = float(100 * (idx + 1))
required_qty = float(qty_to_make * qty_per_unit)
difference_qty = available_qty - required_qty
last_purchase_rate = float(100 * (idx + 1))
expected_data.append(
{
"item": bom_item.item_code,
"description": bom_item.item_code, # description falls back to item_code in test items
"from_bom_no": bom.name,
"manufacturer": "",
"manufacturer_part_number": "",
"qty_per_unit": fmt_qty(qty_per_unit),
"available_qty": fmt_qty(available_qty),
"required_qty": fmt_qty(required_qty),
"difference_qty": fmt_qty(difference_qty),
"last_purchase_rate": fmt_rate(last_purchase_rate),
}
)
producible_per_item.append(int(available_qty // qty_per_unit) if qty_per_unit else 0)
min_producible = min(producible_per_item) if producible_per_item else 0
return expected_data, min_producible

View File

@@ -1,33 +0,0 @@
// Copyright (c) 2016, Epoch Consulting and contributors
// For license information, please see license.txt
frappe.query_reports["BOM Stock Calculated"] = {
filters: [
{
fieldname: "bom",
label: __("BOM"),
fieldtype: "Link",
options: "BOM",
reqd: 1,
},
{
fieldname: "warehouse",
label: __("Warehouse"),
fieldtype: "Link",
options: "Warehouse",
},
{
fieldname: "qty_to_make",
label: __("Quantity to Make"),
fieldtype: "Float",
default: "1.0",
reqd: 1,
},
{
fieldname: "show_exploded_view",
label: __("Show exploded view"),
fieldtype: "Check",
default: false,
},
],
};

View File

@@ -1,26 +0,0 @@
{
"add_total_row": 0,
"creation": "2018-05-17 12:40:31.355049",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"letter_head": "",
"modified": "2018-06-18 13:33:18.103220",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Stock Calculated",
"owner": "Administrator",
"ref_doctype": "BOM",
"report_name": "BOM Stock Calculated",
"report_type": "Script Report",
"roles": [
{
"role": "Manufacturing Manager"
},
{
"role": "Manufacturing User"
}
]
}

View File

@@ -1,199 +0,0 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.query_builder.functions import IfNull, Sum
from frappe.utils.data import comma_and
from pypika.terms import ExistsCriterion
def execute(filters=None):
columns = get_columns()
data = []
bom_data = get_bom_data(filters)
qty_to_make = filters.get("qty_to_make")
manufacture_details = get_manufacturer_records()
for row in bom_data:
required_qty = qty_to_make * row.qty_per_unit
last_purchase_rate = frappe.db.get_value("Item", row.item_code, "last_purchase_rate")
data.append(get_report_data(last_purchase_rate, required_qty, row, manufacture_details))
return columns, data
def get_report_data(last_purchase_rate, required_qty, row, manufacture_details):
qty_per_unit = row.qty_per_unit if row.qty_per_unit > 0 else 0
difference_qty = row.actual_qty - required_qty
return [
row.item_code,
row.description,
row.from_bom_no,
comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer", []), add_quotes=False),
comma_and(manufacture_details.get(row.item_code, {}).get("manufacturer_part", []), add_quotes=False),
qty_per_unit,
row.actual_qty,
required_qty,
difference_qty,
last_purchase_rate,
]
def get_columns():
return [
{
"fieldname": "item",
"label": _("Item"),
"fieldtype": "Link",
"options": "Item",
"width": 120,
},
{
"fieldname": "description",
"label": _("Description"),
"fieldtype": "Data",
"width": 150,
},
{
"fieldname": "from_bom_no",
"label": _("From BOM No"),
"fieldtype": "Link",
"options": "BOM",
"width": 150,
},
{
"fieldname": "manufacturer",
"label": _("Manufacturer"),
"fieldtype": "Data",
"width": 120,
},
{
"fieldname": "manufacturer_part_number",
"label": _("Manufacturer Part Number"),
"fieldtype": "Data",
"width": 150,
},
{
"fieldname": "qty_per_unit",
"label": _("Qty Per Unit"),
"fieldtype": "Float",
"width": 110,
},
{
"fieldname": "available_qty",
"label": _("Available Qty"),
"fieldtype": "Float",
"width": 120,
},
{
"fieldname": "required_qty",
"label": _("Required Qty"),
"fieldtype": "Float",
"width": 120,
},
{
"fieldname": "difference_qty",
"label": _("Difference Qty"),
"fieldtype": "Float",
"width": 130,
},
{
"fieldname": "last_purchase_rate",
"label": _("Last Purchase Rate"),
"fieldtype": "Float",
"width": 160,
},
]
def get_bom_data(filters):
bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
bom_item = frappe.qb.DocType(bom_item_table)
bin = frappe.qb.DocType("Bin")
query = (
frappe.qb.from_(bom_item)
.left_join(bin)
.on(bom_item.item_code == bin.item_code)
.select(
bom_item.item_code,
bom_item.description,
bom_item.parent.as_("from_bom_no"),
bom_item.qty_consumed_per_unit.as_("qty_per_unit"),
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
)
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
.groupby(bom_item.item_code)
.orderby(bom_item.idx)
)
if filters.get("warehouse"):
warehouse_details = frappe.db.get_value(
"Warehouse", filters.get("warehouse"), ["lft", "rgt"], as_dict=1
)
if warehouse_details:
wh = frappe.qb.DocType("Warehouse")
query = query.where(
ExistsCriterion(
frappe.qb.from_(wh)
.select(wh.name)
.where(
(wh.lft >= warehouse_details.lft)
& (wh.rgt <= warehouse_details.rgt)
& (bin.warehouse == wh.name)
)
)
)
else:
query = query.where(bin.warehouse == filters.get("warehouse"))
if bom_item_table == "BOM Item":
query = query.select(bom_item.bom_no, bom_item.is_phantom_item)
data = query.run(as_dict=True)
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
def explode_phantom_boms(data, filters):
original_bom = filters.get("bom")
replacements = []
for idx, item in enumerate(data):
if not item.is_phantom_item:
continue
filters["bom"] = item.bom_no
children = get_bom_data(filters)
filters["bom"] = original_bom
for child in children:
child.qty_per_unit = (child.qty_per_unit or 0) * (item.qty_per_unit or 0)
replacements.append((idx, children))
for idx, children in reversed(replacements):
data.pop(idx)
data[idx:idx] = children
filters["bom"] = original_bom
return data
def get_manufacturer_records():
details = frappe.get_all(
"Item Manufacturer", fields=["manufacturer", "manufacturer_part_no", "item_code"]
)
manufacture_details = frappe._dict()
for detail in details:
dic = manufacture_details.setdefault(detail.get("item_code"), {})
dic.setdefault("manufacturer", []).append(detail.get("manufacturer"))
dic.setdefault("manufacturer_part", []).append(detail.get("manufacturer_part_no"))
return manufacture_details

View File

@@ -1,119 +0,0 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.report.bom_stock_calculated.bom_stock_calculated import (
execute as bom_stock_calculated_report,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
class TestBOMStockCalculated(ERPNextTestSuite):
def setUp(self):
self.fg_item, self.rm_items = create_items()
self.boms = create_boms(self.fg_item, self.rm_items)
def test_bom_stock_calculated(self):
qty_to_make = 10
# Case 1: When Item(s) Qty and Stock Qty are equal.
data = bom_stock_calculated_report(
filters={
"qty_to_make": qty_to_make,
"bom": self.boms[0].name,
}
)[1]
expected_data = get_expected_data(self.boms[0], qty_to_make)
self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
# Case 2: When Item(s) Qty and Stock Qty are different and BOM Qty is 1.
data = bom_stock_calculated_report(
filters={
"qty_to_make": qty_to_make,
"bom": self.boms[1].name,
}
)[1]
expected_data = get_expected_data(self.boms[1], qty_to_make)
self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
# Case 3: When Item(s) Qty and Stock Qty are different and BOM Qty is greater than 1.
data = bom_stock_calculated_report(
filters={
"qty_to_make": qty_to_make,
"bom": self.boms[2].name,
}
)[1]
expected_data = get_expected_data(self.boms[2], qty_to_make)
self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
def create_items():
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item1 = make_item(
properties={
"is_stock_item": 1,
"standard_rate": 100,
"opening_stock": 100,
"last_purchase_rate": 100,
"item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}],
}
).name
rm_item2 = make_item(
properties={
"is_stock_item": 1,
"standard_rate": 200,
"opening_stock": 200,
"last_purchase_rate": 200,
"item_defaults": [{"company": "_Test Company", "default_warehouse": "Stores - _TC"}],
}
).name
return fg_item, [rm_item1, rm_item2]
def create_boms(fg_item, rm_items):
def update_bom_items(bom, uom, conversion_factor):
for item in bom.items:
item.uom = uom
item.conversion_factor = conversion_factor
return bom
bom1 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10)
bom2 = make_bom(item=fg_item, quantity=1, raw_materials=rm_items, rm_qty=10, do_not_submit=True)
bom2 = update_bom_items(bom2, "Box", 10)
bom2.save()
bom2.submit()
bom3 = make_bom(item=fg_item, quantity=2, raw_materials=rm_items, rm_qty=10, do_not_submit=True)
bom3 = update_bom_items(bom3, "Box", 10)
bom3.save()
bom3.submit()
return [bom1, bom2, bom3]
def get_expected_data(bom, qty_to_make):
expected_data = []
for idx in range(len(bom.items)):
expected_data.append(
[
bom.items[idx].item_code,
bom.items[idx].item_code,
bom.name,
"",
"",
float(bom.items[idx].stock_qty / bom.quantity),
float(100 * (idx + 1)),
float(qty_to_make * (bom.items[idx].stock_qty / bom.quantity)),
float((100 * (idx + 1)) - (qty_to_make * (bom.items[idx].stock_qty / bom.quantity))),
float(100 * (idx + 1)),
]
)
return expected_data

View File

@@ -1,27 +0,0 @@
<h1 class="text-left"><b>{%= __("BOM Stock Report") %}</b></h1>
<h5 class="text-left">{%= filters.bom %}</h5>
<h5 class="text-left">{%= filters.warehouse %}</h5>
<hr>
<table class="table table-bordered">
<thead>
<tr>
<th style="width: 15%">{%= __("Item") %}</th>
<th style="width: 35%">{%= __("Description") %}</th>
<th style="width: 14%">{%= __("Required Qty") %}</th>
<th style="width: 13%">{%= __("In Stock Qty") %}</th>
<th style="width: 23%">{%= __("Enough Parts to Build") %}</th>
</tr>
</thead>
<tbody>
{% for(var i=0, l=data.length; i<l; i++) { %}
<tr>
<td>{%= data[i][ __("Item")] %}</td>
<td>{%= data[i][ __("Description")] %} </td>
<td align="right">{%= data[i][ __("Required Qty")] %} </td>
<td align="right">{%= data[i][ __("In Stock Qty")] %} </td>
<td align="right">{%= data[i][ __("Enough Parts to Build")] %} </td>
</tr>
{% } %}
</tbody>
</table>

View File

@@ -1,41 +0,0 @@
frappe.query_reports["BOM Stock Report"] = {
filters: [
{
fieldname: "bom",
label: __("BOM"),
fieldtype: "Link",
options: "BOM",
reqd: 1,
},
{
fieldname: "warehouse",
label: __("Warehouse"),
fieldtype: "Link",
options: "Warehouse",
reqd: 1,
},
{
fieldname: "show_exploded_view",
label: __("Show exploded view"),
fieldtype: "Check",
},
{
fieldname: "qty_to_produce",
label: __("Quantity to Produce"),
fieldtype: "Int",
default: "1",
},
],
formatter: function (value, row, column, data, default_formatter) {
value = default_formatter(value, row, column, data);
if (column.id == "item") {
if (data["in_stock_qty"] >= data["required_qty"]) {
value = `<a style='color:green' href="/app/item/${data["item"]}" data-doctype="Item">${data["item"]}</a>`;
} else {
value = `<a style='color:red' href="/app/item/${data["item"]}" data-doctype="Item">${data["item"]}</a>`;
}
}
return value;
},
};

View File

@@ -1,28 +0,0 @@
{
"add_total_row": 0,
"apply_user_permissions": 1,
"creation": "2017-01-10 14:00:50.387244",
"disabled": 0,
"docstatus": 0,
"doctype": "Report",
"idx": 0,
"is_standard": "Yes",
"letter_head": "",
"modified": "2017-06-23 04:46:43.209008",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Stock Report",
"owner": "Administrator",
"query": "SELECT \n\tbom_item.item_code as \"Item:Link/Item:200\",\n\tbom_item.description as \"Description:Data:300\",\n\tbom_item.qty as \"Required Qty:Float:100\",\n\tledger.actual_qty as \"In Stock Qty:Float:100\",\n\tFLOOR(ledger.actual_qty /bom_item.qty) as \"Enough Parts to Build:Int:100\"\nFROM\n\t`tabBOM Item` AS bom_item \n\tLEFT JOIN `tabBin` AS ledger\t\n\t\tON bom_item.item_code = ledger.item_code \n\t\tAND ledger.warehouse = %(warehouse)s\nWHERE\n\tbom_item.parent=%(bom)s\n\nGROUP BY bom_item.item_code",
"ref_doctype": "BOM",
"report_name": "BOM Stock Report",
"report_type": "Script Report",
"roles": [
{
"role": "Manufacturing Manager"
},
{
"role": "Manufacturing User"
}
]
}

View File

@@ -1,106 +0,0 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.query_builder.functions import Floor, Sum
from frappe.utils import cint
def execute(filters=None):
if not filters:
filters = {}
columns = get_columns()
data = get_bom_stock(filters)
return columns, data
def get_columns():
return [
_("Item") + ":Link/Item:150",
_("Item Name") + "::240",
_("Description") + "::300",
_("From BOM No") + "::200",
_("BOM Qty") + ":Float:160",
_("BOM UOM") + "::160",
_("Required Qty") + ":Float:120",
_("In Stock Qty") + ":Float:120",
_("Enough Parts to Build") + ":Float:200",
]
def get_bom_stock(filters):
qty_to_produce = filters.get("qty_to_produce")
if cint(qty_to_produce) <= 0:
frappe.throw(_("Quantity to Produce should be greater than zero."))
bom_item_table = "BOM Explosion Item" if filters.get("show_exploded_view") else "BOM Item"
warehouse = filters.get("warehouse")
warehouse_details = frappe.db.get_value("Warehouse", warehouse, ["lft", "rgt"], as_dict=1)
BOM = frappe.qb.DocType("BOM")
BOM_ITEM = frappe.qb.DocType(bom_item_table)
BIN = frappe.qb.DocType("Bin")
WH = frappe.qb.DocType("Warehouse")
if warehouse_details:
bin_subquery = (
frappe.qb.from_(BIN)
.join(WH)
.on(BIN.warehouse == WH.name)
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
.where((WH.lft >= warehouse_details.lft) & (WH.rgt <= warehouse_details.rgt))
.groupby(BIN.item_code)
)
else:
bin_subquery = (
frappe.qb.from_(BIN)
.select(BIN.item_code, Sum(BIN.actual_qty).as_("actual_qty"))
.where(BIN.warehouse == warehouse)
.groupby(BIN.item_code)
)
QUERY = (
frappe.qb.from_(BOM)
.join(BOM_ITEM)
.on(BOM.name == BOM_ITEM.parent)
.left_join(bin_subquery)
.on(BOM_ITEM.item_code == bin_subquery.item_code)
.select(
BOM_ITEM.item_code,
BOM_ITEM.item_name,
BOM_ITEM.description,
BOM.name,
Sum(BOM_ITEM.stock_qty),
BOM_ITEM.stock_uom,
(Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity,
bin_subquery.actual_qty,
Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty) * qty_to_produce) / BOM.quantity)),
)
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
.groupby(BOM_ITEM.item_code)
.orderby(BOM_ITEM.idx)
)
if bom_item_table == "BOM Item":
QUERY = QUERY.select(BOM_ITEM.bom_no, BOM_ITEM.is_phantom_item)
data = QUERY.run(as_list=True)
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
def explode_phantom_boms(data, filters):
expanded = []
for row in data:
if row[-1]: # last element is `is_phantom_item`
phantom_filters = filters.copy()
phantom_filters["qty_to_produce"] = row[-5]
phantom_filters["bom"] = row[-2]
expanded.extend(get_bom_stock(phantom_filters))
else:
expanded.append(row)
return expanded

View File

@@ -1,112 +0,0 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.exceptions import ValidationError
from frappe.utils import floor
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.report.bom_stock_report.bom_stock_report import (
get_bom_stock as bom_stock_report,
)
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestBomStockReport(ERPNextTestSuite):
def setUp(self):
self.warehouse = "_Test Warehouse - _TC"
self.fg_item, self.rm_items = create_items()
make_stock_entry(target=self.warehouse, item_code=self.rm_items[0], qty=20, basic_rate=100)
make_stock_entry(target=self.warehouse, item_code=self.rm_items[1], qty=40, basic_rate=200)
self.bom = make_bom(item=self.fg_item, quantity=1, raw_materials=self.rm_items, rm_qty=10)
def test_bom_stock_report(self):
# Test 1: When `qty_to_produce` is 0.
filters = frappe._dict(
{
"bom": self.bom.name,
"warehouse": "Stores - _TC",
"qty_to_produce": 0,
}
)
self.assertRaises(ValidationError, bom_stock_report, filters)
# Test 2: When stock is not available.
data = bom_stock_report(
frappe._dict(
{
"bom": self.bom.name,
"warehouse": "Stores - _TC",
"qty_to_produce": 1,
}
)
)
expected_data = get_expected_data(self.bom, "Stores - _TC", 1)
self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
# Test 3: When stock is available.
data = bom_stock_report(
frappe._dict(
{
"bom": self.bom.name,
"warehouse": self.warehouse,
"qty_to_produce": 1,
}
)
)
expected_data = get_expected_data(self.bom, self.warehouse, 1)
self.assertSetEqual(set(tuple(x) for x in data), set(tuple(x) for x in expected_data))
def create_items():
fg_item = make_item(properties={"is_stock_item": 1}).name
rm_item1 = make_item(
properties={
"is_stock_item": 1,
"standard_rate": 100,
"opening_stock": 100,
"last_purchase_rate": 100,
}
).name
rm_item2 = make_item(
properties={
"is_stock_item": 1,
"standard_rate": 200,
"opening_stock": 200,
"last_purchase_rate": 200,
}
).name
return fg_item, [rm_item1, rm_item2]
def get_expected_data(bom, warehouse, qty_to_produce, show_exploded_view=False):
expected_data = []
for item in bom.get("exploded_items") if show_exploded_view else bom.get("items"):
in_stock_qty = frappe.get_cached_value(
"Bin", {"item_code": item.item_code, "warehouse": warehouse}, "actual_qty"
)
expected_data.append(
[
item.item_code,
item.item_name,
item.description,
bom.name,
item.stock_qty,
item.stock_uom,
item.stock_qty * qty_to_produce / bom.quantity,
in_stock_qty,
floor(in_stock_qty / (item.stock_qty * qty_to_produce / bom.quantity))
if in_stock_qty
else None,
item.bom_no,
item.is_phantom_item,
]
)
return expected_data

View File

@@ -21,8 +21,7 @@ class TestManufacturingReports(ERPNextTestSuite):
self.REPORT_FILTER_TEST_CASES: list[tuple[ReportName, ReportFilters]] = [ self.REPORT_FILTER_TEST_CASES: list[tuple[ReportName, ReportFilters]] = [
("BOM Explorer", {"bom": self.last_bom}), ("BOM Explorer", {"bom": self.last_bom}),
("BOM Operations Time", {}), ("BOM Operations Time", {}),
("BOM Stock Calculated", {"bom": self.last_bom, "qty_to_make": 2}), ("BOM Stock Analysis", {"bom": self.last_bom, "_optional": ["warehouse"]}),
("BOM Stock Report", {"bom": self.last_bom, "qty_to_produce": 2}),
("Cost of Poor Quality Report", {"item": "_Test Item", "serial_no": "00"}), ("Cost of Poor Quality Report", {"item": "_Test Item", "serial_no": "00"}),
("Downtime Analysis", {}), ("Downtime Analysis", {}),
( (

View File

@@ -5,6 +5,7 @@ const doctype_list = [
"Purchase Order", "Purchase Order",
"Purchase Invoice", "Purchase Invoice",
"POS Invoice", "POS Invoice",
"Request for Quotation",
]; ];
const allowed_print_formats = [ const allowed_print_formats = [
"Sales Order Standard", "Sales Order Standard",
@@ -19,6 +20,7 @@ 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",
"Request for Quotation with Item Image",
]; ];
const allowed_letterheads = ["Company Letterhead", "Company Letterhead - Grey"]; const allowed_letterheads = ["Company Letterhead", "Company Letterhead - Grey"];

View File

@@ -1029,11 +1029,11 @@ class TestQuotation(ERPNextTestSuite):
def test_make_quotation_qar_to_inr(self): def test_make_quotation_qar_to_inr(self):
quotation = make_quotation( quotation = make_quotation(
currency="QAR", currency="QAR",
transaction_date="2026-06-04", transaction_date="2026-01-01",
) )
cache = frappe.cache() cache = frappe.cache()
key = "currency_exchange_rate_{}:{}:{}".format("2026-06-04", "QAR", "INR") key = "currency_exchange_rate_{}:{}:{}".format("2026-01-01", "QAR", "INR")
value = cache.get(key) value = cache.get(key)
expected_rate = flt(value) / 3.64 expected_rate = flt(value) / 3.64

View File

@@ -6,7 +6,7 @@
"label": "Sales Order Trends" "label": "Sales Order Trends"
} }
], ],
"content": "[{\"id\":\"vBSf8Vi9U8\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Sales Order Trends\",\"col\":12}},{\"id\":\"aW2i5R5GRP\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"43fzlS1qZg\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Sales Orders\",\"col\":4}},{\"id\":\"jhtxl-XOGi\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Total Sales Amount\",\"col\":4}},{\"id\":\"0Ioq-P11FP\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Average Order Value\",\"col\":4}},{\"id\":\"1it3dCOnm6\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Quick Access</b></span>\",\"col\":12}},{\"id\":\"0BcePLg0g1\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports &amp; Masters</b></span>\",\"col\":12}},{\"id\":\"uze5dJ1ipL\",\"type\":\"card\",\"data\":{\"card_name\":\"Selling\",\"col\":4}},{\"id\":\"3j2fYwMAkq\",\"type\":\"card\",\"data\":{\"card_name\":\"Point of Sale\",\"col\":4}},{\"id\":\"xImm8NepFt\",\"type\":\"card\",\"data\":{\"card_name\":\"Items and Pricing\",\"col\":4}},{\"id\":\"6MjIe7KCQo\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"lBu2EKgmJF\",\"type\":\"card\",\"data\":{\"card_name\":\"Key Reports\",\"col\":4}},{\"id\":\"1ARHrjg4kI\",\"type\":\"card\",\"data\":{\"card_name\":\"Other Reports\",\"col\":4}}]", "content": "[{\"id\":\"vBSf8Vi9U8\",\"type\":\"chart\",\"data\":{\"chart_name\":\"Sales Order Trends\",\"col\":12}},{\"id\":\"aW2i5R5GRP\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"43fzlS1qZg\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Sales Orders\",\"col\":4}},{\"id\":\"jhtxl-XOGi\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Total Sales Amount\",\"col\":4}},{\"id\":\"0Ioq-P11FP\",\"type\":\"number_card\",\"data\":{\"number_card_name\":\"Average Order Value\",\"col\":4}},{\"id\":\"0BcePLg0g1\",\"type\":\"header\",\"data\":{\"text\":\"<span class=\\\"h4\\\"><b>Reports &amp; Masters</b></span>\",\"col\":12}},{\"id\":\"uze5dJ1ipL\",\"type\":\"card\",\"data\":{\"card_name\":\"Selling\",\"col\":4}},{\"id\":\"3j2fYwMAkq\",\"type\":\"card\",\"data\":{\"card_name\":\"Point of Sale\",\"col\":4}},{\"id\":\"xImm8NepFt\",\"type\":\"card\",\"data\":{\"card_name\":\"Items and Pricing\",\"col\":4}},{\"id\":\"6MjIe7KCQo\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"id\":\"lBu2EKgmJF\",\"type\":\"card\",\"data\":{\"card_name\":\"Key Reports\",\"col\":4}},{\"id\":\"1ARHrjg4kI\",\"type\":\"card\",\"data\":{\"card_name\":\"Other Reports\",\"col\":4}}]",
"creation": "2020-01-28 11:49:12.092882", "creation": "2020-01-28 11:49:12.092882",
"custom_blocks": [], "custom_blocks": [],
"docstatus": 0, "docstatus": 0,
@@ -622,7 +622,7 @@
"type": "Link" "type": "Link"
} }
], ],
"modified": "2026-01-02 17:42:20.131214", "modified": "2026-02-19 13:01:26.893303",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Selling", "name": "Selling",

View File

@@ -7,7 +7,7 @@ from random import randint
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import add_days, getdate from frappe.utils import add_days, get_url_to_form, getdate
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
@@ -16,21 +16,44 @@ from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account
def setup_demo_data(): def setup_demo_data(company_name):
from frappe.utils.telemetry import capture from frappe.utils.telemetry import capture
capture("demo_data_creation_started", "erpnext") capture("demo_data_creation_started", "erpnext")
try: try:
company = create_demo_company() frappe.db.savepoint("demo_data")
company = create_demo_company(company_name)
process_masters() process_masters()
make_transactions(company) make_transactions(company)
frappe.cache.delete_keys("bootinfo") capture("demo_data_creation_completed", "erpnext")
frappe.publish_realtime("demo_data_complete") frappe.clear_messages()
except Exception: except Exception:
frappe.log_error("Failed to create demo data") frappe.db.rollback(save_point="demo_data")
error_log = frappe.log_error("Failed to create demo data")
log_demo_data_failed_notification(error_log)
capture("demo_data_creation_failed", "erpnext", properties={"exception": frappe.get_traceback()}) capture("demo_data_creation_failed", "erpnext", properties={"exception": frappe.get_traceback()})
raise
capture("demo_data_creation_completed", "erpnext")
def log_demo_data_failed_notification(error_log):
from frappe.core.doctype.role.role import get_users
from frappe.desk.doctype.notification_log.notification_log import make_notification_logs
frappe.msgprint(
_("Demo data creation failed. Check notifications for more info."),
alert=True,
indicator="red",
realtime=True,
)
users = get_users("System Manager")
notif_log_doc = {
"subject": _("Demo Data creation failed."),
"type": "Alert",
"link": get_url_to_form("Error Log", error_log.name),
}
make_notification_logs(notif_log_doc, users)
@frappe.whitelist() @frappe.whitelist()
@@ -56,21 +79,8 @@ def clear_demo_data():
) )
def create_demo_company(): def create_demo_company(company):
if frappe.flags.in_test: company_doc = frappe.get_doc("Company", company).as_dict()
hash = frappe.generate_hash(length=3)
company_doc = frappe._dict(
{
"company_name": "Test Company" + " " + hash,
"abbr": "TC" + hash,
"default_currency": "INR",
"country": "India",
"chart_of_accounts": "Standard",
}
)
else:
company = frappe.db.get_all("Company")[0].name
company_doc = frappe.get_doc("Company", company).as_dict()
# Make a dummy company # Make a dummy company
new_company = frappe.new_doc("Company") new_company = frappe.new_doc("Company")

View File

@@ -927,7 +927,7 @@ def update_transactions_annual_history(company, commit=False):
transactions_history = get_all_transactions_annual_history(company) transactions_history = get_all_transactions_annual_history(company)
frappe.db.set_value("Company", company, "transactions_annual_history", json.dumps(transactions_history)) frappe.db.set_value("Company", company, "transactions_annual_history", json.dumps(transactions_history))
if commit: if commit and not frappe.in_test:
frappe.db.commit() frappe.db.commit()
@@ -936,7 +936,9 @@ def cache_companies_monthly_sales_history():
for company in companies: for company in companies:
update_company_monthly_sales(company) update_company_monthly_sales(company)
update_transactions_annual_history(company) update_transactions_annual_history(company)
frappe.db.commit()
if not frappe.in_test:
frappe.db.commit()
@frappe.whitelist() @frappe.whitelist()

View File

@@ -199,7 +199,9 @@ class TestCompany(ERPNextTestSuite):
def test_demo_data(self): def test_demo_data(self):
from erpnext.setup.demo import clear_demo_data, setup_demo_data from erpnext.setup.demo import clear_demo_data, setup_demo_data
setup_demo_data() self.load_test_records("Company")
setup_demo_data(self.globalTestRecords["Company"][0]["company_name"])
company_name = frappe.db.get_value("Company", {"name": ("like", "%(Demo)")}) company_name = frappe.db.get_value("Company", {"name": ("like", "%(Demo)")})
self.assertTrue(company_name) self.assertTrue(company_name)

View File

@@ -180,5 +180,39 @@
"default_currency": "ZAR", "default_currency": "ZAR",
"doctype": "Company", "doctype": "Company",
"create_chart_of_accounts_based_on": "Standard Template" "create_chart_of_accounts_based_on": "Standard Template"
},
{
"abbr": "_TOIC",
"company_name": "_Test Opening Invoice Company",
"country": "Pakistan",
"default_currency": "INR",
"doctype": "Company",
"create_chart_of_accounts_based_on": "Standard Template"
},
{
"abbr": "TBC",
"company_name": "Trial Balance Company",
"country": "India",
"default_currency": "INR",
"doctype": "Company",
"create_chart_of_accounts_based_on": "Standard Template"
},
{
"abbr": "_TSS",
"company_name": "_Test Support SLA",
"country": "India",
"default_currency": "INR",
"doctype": "Company",
"chart_of_accounts": "Standard",
"create_chart_of_accounts_based_on": "Standard Template"
},
{
"abbr": "TQC",
"company_name": "Test Quality Company",
"country": "India",
"default_currency": "INR",
"doctype": "Company",
"chart_of_accounts": "Standard",
"create_chart_of_accounts_based_on": "Standard Template"
} }
] ]

View File

@@ -45,6 +45,64 @@ frappe.ui.form.on("Employee", {
refresh: function (frm) { refresh: function (frm) {
frm.fields_dict.date_of_birth.datepicker.update({ maxDate: new Date() }); frm.fields_dict.date_of_birth.datepicker.update({ maxDate: new Date() });
if (!frm.is_new() && !frm.doc.user_id) {
frm.add_custom_button(__("Create User"), () => {
const dialog = new frappe.ui.Dialog({
title: __("Create User"),
fields: [
{
fieldtype: "Data",
fieldname: "email",
label: __("Email"),
reqd: 1,
default:
frm.doc.prefered_email || frm.doc.company_email || frm.doc.personal_email,
},
{
fieldtype: "Check",
fieldname: "create_user_permission",
label: __("Create User Permission"),
default: 1,
},
],
primary_action_label: __("Create"),
primary_action: (values) => {
if (!values.email) {
frappe.msgprint(__("Email is required to create a user."));
return;
}
frappe
.call({
method: "erpnext.setup.doctype.employee.employee.create_user",
args: {
employee: frm.doc.name,
email: values.email,
create_user_permission: values.create_user_permission ? 1 : 0,
},
freeze: true,
freeze_message: __("Creating User..."),
})
.then(() => {
dialog.hide();
frm.reload_doc();
});
},
});
dialog.show();
});
}
},
create_user_automatically: function (frm) {
if (frm.doc.create_user_automatically) {
frm.set_value("user_id", "");
frm.set_df_property("user_id", "read_only", 1);
} else {
frm.set_df_property("user_id", "read_only", 0);
}
}, },
prefered_contact_email: function (frm) { prefered_contact_email: function (frm) {
@@ -77,24 +135,6 @@ frappe.ui.form.on("Employee", {
}, },
}); });
}, },
create_user: function (frm) {
if (!frm.doc.prefered_email) {
frappe.throw(__("Please enter Preferred Contact Email"));
}
frappe.call({
method: "erpnext.setup.doctype.employee.employee.create_user",
args: {
employee: frm.doc.name,
email: frm.doc.prefered_email,
},
freeze: true,
freeze_message: __("Creating User..."),
callback: function (r) {
frm.reload_doc();
},
});
},
}); });
cur_frm.cscript = new erpnext.setup.EmployeeController({ cur_frm.cscript = new erpnext.setup.EmployeeController({

View File

@@ -28,8 +28,9 @@
"status", "status",
"erpnext_user", "erpnext_user",
"user_id", "user_id",
"create_user",
"create_user_permission", "create_user_permission",
"column_break_xwnm",
"create_user_automatically",
"company_details_section", "company_details_section",
"company", "company",
"department", "department",
@@ -39,19 +40,11 @@
"reports_to", "reports_to",
"column_break_18", "column_break_18",
"branch", "branch",
"employment_details",
"scheduled_confirmation_date",
"column_break_32",
"final_confirmation_date",
"contract_end_date",
"col_break_22",
"notice_number_of_days",
"date_of_retirement",
"contact_details", "contact_details",
"cell_number", "cell_number",
"column_break_40", "column_break_40",
"personal_email",
"company_email", "company_email",
"personal_email",
"column_break4", "column_break4",
"prefered_contact_email", "prefered_contact_email",
"prefered_email", "prefered_email",
@@ -101,6 +94,14 @@
"external_work_history", "external_work_history",
"history_in_company", "history_in_company",
"internal_work_history", "internal_work_history",
"employment_details",
"scheduled_confirmation_date",
"column_break_32",
"final_confirmation_date",
"contract_end_date",
"col_break_22",
"notice_number_of_days",
"date_of_retirement",
"exit", "exit",
"resignation_letter_date", "resignation_letter_date",
"relieving_date", "relieving_date",
@@ -273,6 +274,7 @@
}, },
{ {
"collapsible": 1, "collapsible": 1,
"collapsible_depends_on": "eval:doc.__islocal",
"fieldname": "erpnext_user", "fieldname": "erpnext_user",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "User Details" "label": "User Details"
@@ -285,20 +287,23 @@
"label": "User ID", "label": "User ID",
"options": "User" "options": "User"
}, },
{
"depends_on": "eval:(!doc.user_id)",
"fieldname": "create_user",
"fieldtype": "Button",
"label": "Create User"
},
{ {
"default": "1", "default": "1",
"depends_on": "user_id", "depends_on": "eval:doc.user_id || doc.create_user_automatically",
"description": "This will restrict user access to other employee records", "description": "This will restrict user access to other employee records",
"fieldname": "create_user_permission", "fieldname": "create_user_permission",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Create User Permission" "label": "Create User Permission"
}, },
{
"default": "0",
"depends_on": "eval:doc.__islocal && !doc.user_id",
"description": "Creates a User account for this employee using the Preferred, Company, or Personal email.",
"fieldname": "create_user_automatically",
"fieldtype": "Check",
"label": "Create User Automatically",
"set_only_once": 1
},
{ {
"allow_in_quick_entry": 1, "allow_in_quick_entry": 1,
"collapsible": 1, "collapsible": 1,
@@ -348,6 +353,7 @@
{ {
"fieldname": "department", "fieldname": "department",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1, "in_standard_filter": 1,
"label": "Department", "label": "Department",
"oldfieldname": "department", "oldfieldname": "department",
@@ -377,6 +383,7 @@
{ {
"fieldname": "branch", "fieldname": "branch",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"label": "Branch", "label": "Branch",
"oldfieldname": "branch", "oldfieldname": "branch",
"oldfieldtype": "Link", "oldfieldtype": "Link",
@@ -600,7 +607,7 @@
"collapsible": 1, "collapsible": 1,
"fieldname": "exit", "fieldname": "exit",
"fieldtype": "Tab Break", "fieldtype": "Tab Break",
"label": "Employee Exit", "label": "Exit",
"oldfieldtype": "Section Break" "oldfieldtype": "Section Break"
}, },
{ {
@@ -816,6 +823,10 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "IBAN", "label": "IBAN",
"options": "IBAN" "options": "IBAN"
},
{
"fieldname": "column_break_xwnm",
"fieldtype": "Column Break"
} }
], ],
"icon": "fa fa-user", "icon": "fa fa-user",
@@ -823,7 +834,7 @@
"image_field": "image", "image_field": "image",
"is_tree": 1, "is_tree": 1,
"links": [], "links": [],
"modified": "2025-08-29 11:52:12.819878", "modified": "2026-03-23 15:26:05.149280",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Setup", "module": "Setup",
"name": "Employee", "name": "Employee",

View File

@@ -8,7 +8,7 @@ from frappe.permissions import (
get_doc_permissions, get_doc_permissions,
remove_user_permission, remove_user_permission,
) )
from frappe.utils import cstr, getdate, today, validate_email_address from frappe.utils import cint, cstr, getdate, today, validate_email_address
from frappe.utils.nestedset import NestedSet from frappe.utils.nestedset import NestedSet
from erpnext.utilities.transaction_base import delete_events from erpnext.utilities.transaction_base import delete_events
@@ -23,6 +23,94 @@ class InactiveEmployeeStatusError(frappe.ValidationError):
class Employee(NestedSet): class Employee(NestedSet):
# begin: auto-generated types
# This code is auto-generated. Do not modify anything in this block.
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from frappe.types import DF
from erpnext.setup.doctype.employee_education.employee_education import EmployeeEducation
from erpnext.setup.doctype.employee_external_work_history.employee_external_work_history import (
EmployeeExternalWorkHistory,
)
from erpnext.setup.doctype.employee_internal_work_history.employee_internal_work_history import (
EmployeeInternalWorkHistory,
)
attendance_device_id: DF.Data | None
bank_ac_no: DF.Data | None
bank_name: DF.Data | None
bio: DF.TextEditor | None
blood_group: DF.Literal["", "A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"]
branch: DF.Link | None
cell_number: DF.Data | None
company: DF.Link
company_email: DF.Data | None
contract_end_date: DF.Date | None
create_user_automatically: DF.Check
create_user_permission: DF.Check
ctc: DF.Currency
current_accommodation_type: DF.Literal["", "Rented", "Owned"]
current_address: DF.SmallText | None
date_of_birth: DF.Date
date_of_issue: DF.Date | None
date_of_joining: DF.Date
date_of_retirement: DF.Date | None
department: DF.Link | None
designation: DF.Link | None
education: DF.Table[EmployeeEducation]
emergency_phone_number: DF.Data | None
employee: DF.Data | None
employee_name: DF.Data | None
employee_number: DF.Data | None
encashment_date: DF.Date | None
external_work_history: DF.Table[EmployeeExternalWorkHistory]
family_background: DF.SmallText | None
feedback: DF.SmallText | None
final_confirmation_date: DF.Date | None
first_name: DF.Data
gender: DF.Link
health_details: DF.SmallText | None
held_on: DF.Date | None
holiday_list: DF.Link | None
iban: DF.Data | None
image: DF.AttachImage | None
internal_work_history: DF.Table[EmployeeInternalWorkHistory]
last_name: DF.Data | None
leave_encashed: DF.Literal["", "Yes", "No"]
lft: DF.Int
marital_status: DF.Literal["", "Single", "Married", "Divorced", "Widowed"]
middle_name: DF.Data | None
naming_series: DF.Literal["HR-EMP-"]
new_workplace: DF.Data | None
notice_number_of_days: DF.Int
old_parent: DF.Data | None
passport_number: DF.Data | None
permanent_accommodation_type: DF.Literal["", "Rented", "Owned"]
permanent_address: DF.SmallText | None
person_to_be_contacted: DF.Data | None
personal_email: DF.Data | None
place_of_issue: DF.Data | None
prefered_contact_email: DF.Literal["", "Company Email", "Personal Email", "User ID"]
prefered_email: DF.Data | None
reason_for_leaving: DF.SmallText | None
relation: DF.Data | None
relieving_date: DF.Date | None
reports_to: DF.Link | None
resignation_letter_date: DF.Date | None
rgt: DF.Int
salary_currency: DF.Link | None
salary_mode: DF.Literal["", "Bank", "Cash", "Cheque"]
salutation: DF.Link | None
scheduled_confirmation_date: DF.Date | None
status: DF.Literal["Active", "Inactive", "Suspended", "Left"]
unsubscribed: DF.Check
user_id: DF.Link | None
valid_upto: DF.Date | None
# end: auto-generated types
nsm_parent_field = "reports_to" nsm_parent_field = "reports_to"
def autoname(self): def autoname(self):
@@ -72,6 +160,16 @@ class Employee(NestedSet):
self.validate_for_enabled_user_id(data.get("enabled", 0)) self.validate_for_enabled_user_id(data.get("enabled", 0))
self.validate_duplicate_user_id() self.validate_duplicate_user_id()
def validate_auto_user_creation(self):
if self.create_user_automatically and not (
self.prefered_email or self.company_email or self.personal_email
):
frappe.throw(
_("Company or Personal Email is mandatory when 'Create User Automatically' is enabled"),
frappe.MandatoryError,
title=_("Auto User Creation Error"),
)
def update_nsm_model(self): def update_nsm_model(self):
frappe.utils.nestedset.update_nsm(self) frappe.utils.nestedset.update_nsm(self)
@@ -83,6 +181,22 @@ class Employee(NestedSet):
self.update_user_permissions() self.update_user_permissions()
self.reset_employee_emails_cache() self.reset_employee_emails_cache()
def before_insert(self):
self.validate_auto_user_creation()
def after_insert(self):
if not self.create_user_automatically:
return
if self.user_id:
return
create_user(
employee=self.name,
email=self.prefered_email or self.company_email or self.personal_email,
create_user_permission=self.create_user_permission,
)
def update_user_permissions(self): def update_user_permissions(self):
if not self.has_value_changed("user_id") and not self.has_value_changed("create_user_permission"): if not self.has_value_changed("user_id") and not self.has_value_changed("create_user_permission"):
return return
@@ -310,10 +424,17 @@ def deactivate_sales_person(status=None, employee=None):
@frappe.whitelist() @frappe.whitelist()
def create_user(employee, user=None, email=None): def create_user(employee: str, email: str | None = None, create_user_permission: int = 0) -> str:
emp = frappe.get_doc("Employee", employee) emp = frappe.get_doc("Employee", employee)
if emp.user_id:
frappe.throw(_("Employee {0} already has a linked user").format(emp.name))
if not email:
frappe.throw(_("Email is required to create a user"))
email = validate_email_address(email, True)
employee_name = emp.employee_name.split(" ") employee_name = emp.employee_name.split(" ")
first_name = employee_name[0]
middle_name = last_name = "" middle_name = last_name = ""
if len(employee_name) >= 3: if len(employee_name) >= 3:
@@ -322,16 +443,10 @@ def create_user(employee, user=None, email=None):
elif len(employee_name) == 2: elif len(employee_name) == 2:
last_name = employee_name[1] last_name = employee_name[1]
first_name = employee_name[0]
if email:
emp.prefered_email = email
user = frappe.new_doc("User") user = frappe.new_doc("User")
user.update( user.update(
{ {
"name": emp.employee_name, "email": email,
"email": emp.prefered_email,
"enabled": 1, "enabled": 1,
"first_name": first_name, "first_name": first_name,
"middle_name": middle_name, "middle_name": middle_name,
@@ -342,9 +457,18 @@ def create_user(employee, user=None, email=None):
"bio": emp.bio, "bio": emp.bio,
} }
) )
emp.db_set("user_id", email)
user.append_roles("Employee")
user.insert() user.insert()
emp.user_id = user.name emp.user_id = user.name
emp.create_user_permission = cint(create_user_permission)
emp.save() emp.save()
if cint(create_user_permission):
add_user_permission("Employee", emp.name, user.name)
add_user_permission("Company", emp.company, user.name)
return user.name return user.name

View File

@@ -1,11 +1,25 @@
frappe.listview_settings["Employee"] = { frappe.listview_settings["Employee"] = {
add_fields: ["status", "branch", "department", "designation", "image"], add_fields: ["status", "branch", "department", "designation", "image"],
filters: [["status", "=", "Active"]], filters: [["status", "=", "Active"]],
get_indicator: function (doc) { get_indicator(doc) {
return [ return [
__(doc.status, null, "Employee"), __(doc.status, null, "Employee"),
{ Active: "green", Inactive: "red", Left: "gray", Suspended: "orange" }[doc.status], { Active: "green", Inactive: "red", Left: "gray", Suspended: "orange" }[doc.status],
"status,=," + doc.status, "status,=," + doc.status,
]; ];
}, },
onload(listview) {
if (frappe.perm.has_perm("Employee", 0, "create")) {
frappe.db.count("Employee").then((count) => {
if (count === 0) {
listview.page.add_inner_button(__("Import Employees"), () => {
frappe.new_doc("Data Import", {
reference_doctype: "Employee",
});
});
}
});
}
},
}; };

View File

@@ -64,6 +64,58 @@ class TestEmployee(ERPNextTestSuite):
self.assertEqual(qb_employee_list, employee_list) self.assertEqual(qb_employee_list, employee_list)
frappe.set_user("Administrator") frappe.set_user("Administrator")
def test_create_user_automatically(self):
def get_new_employee(email: str, create_user_permission: int):
return frappe.get_doc(
{
"doctype": "Employee",
"first_name": "Test Auto User 1",
"company": "_Test Company",
"date_of_birth": "2000-05-08",
"date_of_joining": "2013-01-01",
"gender": "Female",
"personal_email": email,
"status": "Active",
"create_user_automatically": 1,
"create_user_permission": create_user_permission,
}
).insert()
employee1 = get_new_employee("test_auto_user1@example.com", True)
user = frappe.db.get_value("User", "test_auto_user1@example.com")
self.assertTrue(user)
self.assertEqual(employee1.user_id, user)
# Verify user permissions are created
self.assertTrue(
frappe.db.exists(
"User Permission", {"allow": "Employee", "for_value": employee1.name, "user": user}
)
)
self.assertTrue(
frappe.db.exists(
"User Permission", {"allow": "Company", "for_value": employee1.company, "user": user}
)
)
# Test disabled create_user_permission
employee2 = get_new_employee("test_auto_user2@example.com", False)
user2 = frappe.db.get_value("User", "test_auto_user2@example.com")
self.assertTrue(user2)
self.assertEqual(employee2.user_id, user2)
# Verify user permissions are not created
self.assertFalse(
frappe.db.exists(
"User Permission", {"allow": "Employee", "for_value": employee2.name, "user": user2}
)
)
self.assertFalse(
frappe.db.exists(
"User Permission", {"allow": "Company", "for_value": employee2.company, "user": user2}
)
)
def make_employee(user, company=None, **kwargs): def make_employee(user, company=None, **kwargs):
if not frappe.db.get_value("User", user): if not frappe.db.get_value("User", user):

View File

@@ -4,6 +4,7 @@
import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder import DocType
class PartyType(Document): class PartyType(Document):
@@ -24,29 +25,36 @@ class PartyType(Document):
@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_party_type(doctype, txt, searchfield, start, page_len, filters): def get_party_type(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
cond = "" PartyType = DocType("Party Type")
account_type = None get_party_type_query = frappe.qb.from_(PartyType).select(PartyType.name).orderby(PartyType.name)
condition_list = []
if filters and filters.get("account"): if filters and filters.get("account"):
account_type = frappe.db.get_value("Account", filters.get("account"), "account_type") account_type = frappe.db.get_value("Account", filters.get("account"), "account_type")
if account_type: if account_type:
if account_type in ["Receivable", "Payable"]: if account_type in ["Receivable", "Payable"]:
# Include Employee regardless of its configured account_type, but still respect the text filter # Include Employee regardless of its configured account_type, but still respect the text filter
cond = "and (account_type = %(account_type)s or name = 'Employee')" condition_list.append(
(PartyType.account_type == account_type) | (PartyType.name == "Employee")
)
else: else:
cond = "and account_type = %(account_type)s" condition_list.append(PartyType.account_type == account_type)
# Build parameters dictionary for condition in condition_list:
params = {"txt": "%" + txt + "%", "start": start, "page_len": page_len} get_party_type_query = get_party_type_query.where(condition)
if account_type:
params["account_type"] = account_type
result = frappe.db.sql( if frappe.local.lang == "en":
f"""select name from `tabParty Type` get_party_type_query = get_party_type_query.where(getattr(PartyType, searchfield).like(f"%{txt}%"))
where `{searchfield}` LIKE %(txt)s {cond} get_party_type_query = get_party_type_query.limit(page_len)
order by name limit %(page_len)s offset %(start)s""", get_party_type_query = get_party_type_query.offset(start)
params,
) result = get_party_type_query.run()
else:
result = get_party_type_query.run()
test_str = txt.lower()
result = [row for row in result if test_str in frappe._(row[0]).lower()]
result = result[start : start + page_len]
return result or [] return result or []

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",
"Request for Quotation": "Request for Quotation with Item Image",
} }
for doctype, print_format in default_map.items(): for doctype, print_format in default_map.items():

View File

@@ -10,39 +10,34 @@ from erpnext.setup.setup_wizard.operations import install_fixtures as fixtures
def get_setup_stages(args=None): def get_setup_stages(args=None):
if frappe.db.sql("select name from tabCompany"): stages = [
stages = [ {
"status": _("Installing presets"),
"fail_msg": _("Failed to install presets"),
"tasks": [{"fn": stage_fixtures, "args": args, "fail_msg": _("Failed to install presets")}],
},
{
"status": _("Setting up company"),
"fail_msg": _("Failed to setup company"),
"tasks": [{"fn": setup_company, "args": args, "fail_msg": _("Failed to setup company")}],
},
{
"status": _("Setting defaults"),
"fail_msg": _("Failed to set defaults"),
"tasks": [
{"fn": setup_defaults, "args": args, "fail_msg": _("Failed to setup defaults")},
],
},
]
if args.get("setup_demo"):
stages.append(
{ {
"status": _("Wrapping up"), "status": _("Creating demo data"),
"fail_msg": _("Failed to login"), "fail_msg": _("Failed to create demo data"),
"tasks": [{"fn": fin, "args": args, "fail_msg": _("Failed to login")}], "tasks": [{"fn": setup_demo, "args": args, "fail_msg": _("Failed to create demo data")}],
} }
] )
else:
stages = [
{
"status": _("Installing presets"),
"fail_msg": _("Failed to install presets"),
"tasks": [{"fn": stage_fixtures, "args": args, "fail_msg": _("Failed to install presets")}],
},
{
"status": _("Setting up company"),
"fail_msg": _("Failed to setup company"),
"tasks": [{"fn": setup_company, "args": args, "fail_msg": _("Failed to setup company")}],
},
{
"status": _("Setting defaults"),
"fail_msg": "Failed to set defaults",
"tasks": [
{"fn": setup_defaults, "args": args, "fail_msg": _("Failed to setup defaults")},
],
},
{
"status": _("Wrapping up"),
"fail_msg": _("Failed to login"),
"tasks": [{"fn": fin, "args": args, "fail_msg": _("Failed to login")}],
},
]
return stages return stages
@@ -59,19 +54,8 @@ def setup_defaults(args):
fixtures.install_defaults(frappe._dict(args)) fixtures.install_defaults(frappe._dict(args))
def fin(args): def setup_demo(args): # nosemgrep
frappe.local.message_log = [] setup_demo_data(args.get("company_name"))
login_as_first_user(args)
def setup_demo(args):
if args.get("setup_demo"):
frappe.enqueue(setup_demo_data, enqueue_after_commit=True, at_front=True)
def login_as_first_user(args):
if args.get("email") and hasattr(frappe.local, "login_manager"):
frappe.local.login_manager.login_as(args.get("email"))
# Only for programmatical use # Only for programmatical use
@@ -79,4 +63,3 @@ def setup_complete(args=None):
stage_fixtures(args) stage_fixtures(args)
setup_company(args) setup_company(args)
setup_defaults(args) setup_defaults(args)
fin(args)

View File

@@ -281,8 +281,13 @@ erpnext.stock.move_item = function (item, source, target, actual_qty, rate, stoc
} }
dialog.set_primary_action(__("Create Stock Entry"), function () { dialog.set_primary_action(__("Create Stock Entry"), function () {
if (source && (dialog.get_value("qty") == 0 || dialog.get_value("qty") > actual_qty)) { if (flt(dialog.get_value("qty")) <= 0) {
frappe.msgprint(__("Quantity must be greater than zero, and less or equal to {0}", [actual_qty])); frappe.msgprint(__("Quantity must be greater than zero"));
return;
}
if (source && dialog.get_value("qty") > actual_qty) {
frappe.msgprint(__("Quantity must be less than or equal to {0}", [actual_qty]));
return; return;
} }

View File

@@ -1,6 +1,6 @@
import frappe import frappe
from frappe.desk.reportview import build_match_conditions from frappe.desk.reportview import build_match_conditions
from frappe.utils import cint, flt from frappe.utils import cint, escape_html, flt
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
get_sre_reserved_qty_for_items_and_warehouses as get_reserved_stock_details, get_sre_reserved_qty_for_items_and_warehouses as get_reserved_stock_details,
@@ -70,8 +70,10 @@ def get_data(
for item in items: for item in items:
item.update( item.update(
{ {
"item_name": frappe.get_cached_value("Item", item.item_code, "item_name"), "item_code": escape_html(item.item_code),
"stock_uom": frappe.get_cached_value("Item", item.item_code, "stock_uom"), "item_name": escape_html(frappe.get_cached_value("Item", item.item_code, "item_name")),
"stock_uom": escape_html(frappe.get_cached_value("Item", item.item_code, "stock_uom")),
"warehouse": escape_html(item.warehouse),
"disable_quick_entry": frappe.get_cached_value("Item", item.item_code, "has_batch_no") "disable_quick_entry": frappe.get_cached_value("Item", item.item_code, "has_batch_no")
or frappe.get_cached_value("Item", item.item_code, "has_serial_no"), or frappe.get_cached_value("Item", item.item_code, "has_serial_no"),
"projected_qty": flt(item.projected_qty, precision), "projected_qty": flt(item.projected_qty, precision),

View File

@@ -50,15 +50,15 @@
data-warehouse="{{ d.warehouse }}" data-warehouse="{{ d.warehouse }}"
data-actual_qty="{{ d.actual_qty }}" data-actual_qty="{{ d.actual_qty }}"
data-stock-uom="{{ d.stock_uom }}" data-stock-uom="{{ d.stock_uom }}"
data-item="{{ escape(d.item_code) }}">{{ __("Move") }}</a> data-item="{{ d.item_code }}">{{ __("Move") }}</button>
{% endif %} {% endif %}
<button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add" <button style="margin-left: 7px;" class="btn btn-default btn-xs btn-add"
data-disable_quick_entry="{{ d.disable_quick_entry }}" data-disable_quick_entry="{{ d.disable_quick_entry }}"
data-warehouse="{{ d.warehouse }}" data-warehouse="{{ d.warehouse }}"
data-actual_qty="{{ d.actual_qty }}" data-actual_qty="{{ d.actual_qty }}"
data-stock-uom="{{ d.stock_uom }}" data-stock-uom="{{ d.stock_uom }}"
data-item="{{ escape(d.item_code) }}" data-item="{{ d.item_code }}"
data-rate="{{ d.valuation_rate }}">{{ __("Add") }}</a> data-rate="{{ d.valuation_rate }}">{{ __("Add") }}</button>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,6 +1,6 @@
import frappe import frappe
from frappe.desk.reportview import build_match_conditions from frappe.desk.reportview import build_match_conditions
from frappe.utils import flt, nowdate from frappe.utils import escape_html, flt, nowdate
from erpnext.stock.utils import get_stock_balance from erpnext.stock.utils import get_stock_balance
@@ -75,6 +75,9 @@ def get_warehouse_capacity_data(filters, start):
balance_qty = get_stock_balance(entry.item_code, entry.warehouse, nowdate()) or 0 balance_qty = get_stock_balance(entry.item_code, entry.warehouse, nowdate()) or 0
entry.update( entry.update(
{ {
"warehouse": escape_html(entry.warehouse),
"item_code": escape_html(entry.item_code),
"company": escape_html(entry.company),
"actual_qty": balance_qty, "actual_qty": balance_qty,
"percent_occupied": flt((flt(balance_qty) / flt(entry.stock_capacity)) * 100, 0), "percent_occupied": flt((flt(balance_qty) / flt(entry.stock_capacity)) * 100, 0),
} }

View File

@@ -22,9 +22,6 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestInventoryDimension(ERPNextTestSuite): class TestInventoryDimension(ERPNextTestSuite):
def setUp(self):
prepare_test_data()
def test_validate_inventory_dimension(self): def test_validate_inventory_dimension(self):
# Can not be child doc # Can not be child doc
inv_dim1 = create_inventory_dimension( inv_dim1 = create_inventory_dimension(
@@ -77,6 +74,7 @@ class TestInventoryDimension(ERPNextTestSuite):
self.assertFalse(custom_field) self.assertFalse(custom_field)
def test_inventory_dimension(self): def test_inventory_dimension(self):
create_warehouse("Shelf Warehouse")
warehouse = "Shelf Warehouse - _TC" warehouse = "Shelf Warehouse - _TC"
item_code = "_Test Item" item_code = "_Test Item"
@@ -556,28 +554,6 @@ def get_voucher_sl_entries(voucher_no, fields):
) )
def prepare_test_data():
for shelf in ["Shelf 1", "Shelf 2"]:
if not frappe.db.exists("Shelf", shelf):
frappe.get_doc({"doctype": "Shelf", "shelf_name": shelf}).insert(ignore_permissions=True)
create_warehouse("Shelf Warehouse")
for rack in ["Rack 1", "Rack 2"]:
if not frappe.db.exists("Rack", rack):
frappe.get_doc({"doctype": "Rack", "rack_name": rack}).insert(ignore_permissions=True)
create_warehouse("Rack Warehouse")
for site in ["Site 1", "Site 2"]:
if not frappe.db.exists("Inv Site", site):
frappe.get_doc({"doctype": "Inv Site", "site_name": site}).insert(ignore_permissions=True)
for store in ["Store 1", "Store 2"]:
if not frappe.db.exists("Store", store):
frappe.get_doc({"doctype": "Store", "store_name": store}).insert(ignore_permissions=True)
def create_inventory_dimension(**args): def create_inventory_dimension(**args):
args = frappe._dict(args) args = frappe._dict(args)

View File

@@ -47,7 +47,6 @@
"column_break_cqdk", "column_break_cqdk",
"valuation_rate", "valuation_rate",
"inventory_settings_section", "inventory_settings_section",
"shelf_life_in_days",
"end_of_life", "end_of_life",
"default_material_request_type", "default_material_request_type",
"column_break1", "column_break1",
@@ -64,6 +63,7 @@
"create_new_batch", "create_new_batch",
"batch_number_series", "batch_number_series",
"has_expiry_date", "has_expiry_date",
"shelf_life_in_days",
"retain_sample", "retain_sample",
"sample_quantity", "sample_quantity",
"column_break_37", "column_break_37",
@@ -334,6 +334,7 @@
"options": "fa fa-truck" "options": "fa fa-truck"
}, },
{ {
"depends_on": "has_expiry_date",
"fieldname": "shelf_life_in_days", "fieldname": "shelf_life_in_days",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Shelf Life In Days", "label": "Shelf Life In Days",
@@ -343,11 +344,13 @@
{ {
"default": "2099-12-31", "default": "2099-12-31",
"depends_on": "is_stock_item", "depends_on": "is_stock_item",
"description": "Defines the date after which the item can no longer be used in transactions or manufacturing",
"fieldname": "end_of_life", "fieldname": "end_of_life",
"fieldtype": "Date", "fieldtype": "Date",
"label": "End of Life", "label": "End of Life",
"oldfieldname": "end_of_life", "oldfieldname": "end_of_life",
"oldfieldtype": "Date" "oldfieldtype": "Date",
"show_description_on_click": 1
}, },
{ {
"default": "Purchase", "default": "Purchase",
@@ -467,9 +470,12 @@
{ {
"default": "0", "default": "0",
"depends_on": "has_batch_no", "depends_on": "has_batch_no",
"description": "Enable to reserve a small sample from each batch for any analysis arising ahead",
"documentation_url": "https://docs.frappe.io/erpnext/retain-sample-stock",
"fieldname": "retain_sample", "fieldname": "retain_sample",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Retain Sample" "label": "Retain Sample",
"show_description_on_click": 1
}, },
{ {
"depends_on": "eval: (doc.retain_sample && doc.has_batch_no)", "depends_on": "eval: (doc.retain_sample && doc.has_batch_no)",
@@ -989,7 +995,7 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"make_attachments_public": 1, "make_attachments_public": 1,
"modified": "2026-03-17 20:39:05.218344", "modified": "2026-03-24 15:45:40.207531",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item", "name": "Item",

View File

@@ -10,6 +10,7 @@ from frappe.query_builder.custom import ConstantColumn
from frappe.utils import cint, flt from frappe.utils import cint, flt
import erpnext import erpnext
from erpnext import is_perpetual_inventory_enabled
from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -175,6 +176,9 @@ class LandedCostVoucher(Document):
) )
def validate_expense_accounts(self): def validate_expense_accounts(self):
if not is_perpetual_inventory_enabled(self.company):
return
for t in self.taxes: for t in self.taxes:
company = frappe.get_cached_value("Account", t.expense_account, "company") company = frappe.get_cached_value("Account", t.expense_account, "company")

View File

@@ -180,6 +180,8 @@ class TestLandedCostVoucher(ERPNextTestSuite):
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0) self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 50.0)
def test_lcv_validates_company(self): def test_lcv_validates_company(self):
from erpnext import is_perpetual_inventory_enabled
from erpnext.accounts.doctype.account.test_account import create_account
from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import ( from erpnext.stock.doctype.landed_cost_voucher.landed_cost_voucher import (
IncorrectCompanyValidationError, IncorrectCompanyValidationError,
) )
@@ -187,6 +189,20 @@ class TestLandedCostVoucher(ERPNextTestSuite):
company_a = "_Test Company" company_a = "_Test Company"
company_b = "_Test Company with perpetual inventory" company_b = "_Test Company with perpetual inventory"
srbnb = create_account(
account_name="Stock Received But Not Billed",
account_type="Stock Received But Not Billed",
parent_account="Stock Liabilities - _TC",
company=company_a,
account_currency="INR",
)
epi = is_perpetual_inventory_enabled(company_a)
company_doc = frappe.get_doc("Company", company_a)
company_doc.enable_perpetual_inventory = 1
company_doc.stock_received_but_not_billed = srbnb
company_doc.save()
pr = make_purchase_receipt( pr = make_purchase_receipt(
company=company_a, company=company_a,
warehouse="Stores - _TC", warehouse="Stores - _TC",
@@ -212,6 +228,9 @@ class TestLandedCostVoucher(ERPNextTestSuite):
distribute_landed_cost_on_items(lcv) distribute_landed_cost_on_items(lcv)
lcv.submit() lcv.submit()
frappe.db.set_value("Company", company_a, "enable_perpetual_inventory", epi)
frappe.local.enable_perpetual_inventory = {}
def test_landed_cost_voucher_for_zero_purchase_rate(self): def test_landed_cost_voucher_for_zero_purchase_rate(self):
"Test impact of LCV on future stock balances." "Test impact of LCV on future stock balances."
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item

View File

@@ -1083,7 +1083,9 @@ class TestMaterialRequest(ERPNextTestSuite):
pl.locations[0].qty = 2 pl.locations[0].qty = 2
pl.locations[0].stock_qty = 2 pl.locations[0].stock_qty = 2
self.assertRaises(frappe.ValidationError, pl.submit)
# System should allow picking qty for excess transfer
pl.submit()
def test_mr_status_with_partial_and_excess_end_transit(self): def test_mr_status_with_partial_and_excess_end_transit(self):
material_request = make_material_request( material_request = make_material_request(

View File

@@ -86,6 +86,7 @@ class PickList(TransactionBase):
"join_field": "material_request_item", "join_field": "material_request_item",
"target_ref_field": "stock_qty", "target_ref_field": "stock_qty",
"source_field": "stock_qty", "source_field": "stock_qty",
"validate_qty": False,
} }
] ]
@@ -522,8 +523,26 @@ class PickList(TransactionBase):
self.item_location_map = frappe._dict() self.item_location_map = frappe._dict()
from_warehouses = [self.parent_warehouse] if self.parent_warehouse else [] from_warehouses = [self.parent_warehouse] if self.parent_warehouse else []
if self.parent_warehouse:
from_warehouses.extend(get_descendants_of("Warehouse", self.parent_warehouse)) if self.work_order:
root_warehouse = frappe.db.get_value(
"Warehouse", {"company": self.company, "parent_warehouse": ["IS", "NOT SET"], "is_group": 1}
)
from_warehouses = [root_warehouse]
if from_warehouses:
from_warehouses.extend(get_descendants_of("Warehouse", from_warehouses[0]))
item_warehouse_dict = frappe._dict()
if self.work_order:
item_warehouse_list = frappe.get_all(
"Work Order Item",
filters={"parent": self.work_order},
fields=["item_code", "source_warehouse"],
)
if item_warehouse_list:
item_warehouse_dict = {item.item_code: item.source_warehouse for item in item_warehouse_list}
# Create replica before resetting, to handle empty table on update after submit. # Create replica before resetting, to handle empty table on update after submit.
locations_replica = self.get("locations") locations_replica = self.get("locations")
@@ -541,6 +560,13 @@ class PickList(TransactionBase):
len_idx = len(self.get("locations")) or 0 len_idx = len(self.get("locations")) or 0
for item_doc in items: for item_doc in items:
item_code = item_doc.item_code item_code = item_doc.item_code
priority_warehouses = []
if self.work_order and item_warehouse_dict.get(item_code):
source_warehouse = item_warehouse_dict.get(item_code)
priority_warehouses = [source_warehouse]
priority_warehouses.extend(get_descendants_of("Warehouse", source_warehouse))
from_warehouses = list(dict.fromkeys(priority_warehouses + from_warehouses))
self.item_location_map.setdefault( self.item_location_map.setdefault(
item_code, item_code,
@@ -551,6 +577,7 @@ class PickList(TransactionBase):
self.company, self.company,
picked_item_details=picked_items_details.get(item_code), picked_item_details=picked_items_details.get(item_code),
consider_rejected_warehouses=self.consider_rejected_warehouses, consider_rejected_warehouses=self.consider_rejected_warehouses,
priority_warehouses=priority_warehouses,
), ),
) )
@@ -968,6 +995,7 @@ def get_available_item_locations(
ignore_validation=False, ignore_validation=False,
picked_item_details=None, picked_item_details=None,
consider_rejected_warehouses=False, consider_rejected_warehouses=False,
priority_warehouses=None,
): ):
locations = [] locations = []
@@ -1008,7 +1036,7 @@ def get_available_item_locations(
locations = filter_locations_by_picked_materials(locations, picked_item_details) locations = filter_locations_by_picked_materials(locations, picked_item_details)
if locations: if locations:
locations = get_locations_based_on_required_qty(locations, required_qty) locations = get_locations_based_on_required_qty(locations, required_qty, priority_warehouses)
if not ignore_validation: if not ignore_validation:
validate_picked_materials(item_code, required_qty, locations, picked_item_details) validate_picked_materials(item_code, required_qty, locations, picked_item_details)
@@ -1016,9 +1044,14 @@ def get_available_item_locations(
return locations return locations
def get_locations_based_on_required_qty(locations, required_qty): def get_locations_based_on_required_qty(locations, required_qty, priority_warehouses):
filtered_locations = [] filtered_locations = []
if priority_warehouses:
priority_locations = [loc for loc in locations if loc.warehouse in priority_warehouses]
fallback_locations = [loc for loc in locations if loc.warehouse not in priority_warehouses]
locations = priority_locations + fallback_locations
for location in locations: for location in locations:
if location.qty >= required_qty: if location.qty >= required_qty:
location.qty = required_qty location.qty = required_qty

View File

@@ -1050,6 +1050,53 @@ class TestPickList(ERPNextTestSuite):
pl = create_pick_list(so.name) pl = create_pick_list(so.name)
self.assertFalse(pl.locations) self.assertFalse(pl.locations)
def test_pick_list_warehouse_for_work_order(self):
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list, make_work_order
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
# Create Warehouses for Work Order
source_warehouse = create_warehouse("_Test WO Warehouse")
wip_warehouse = create_warehouse("_Test WIP Warehouse", company="_Test Company")
fg_warehouse = create_warehouse("_Test Finished Goods Warehouse", company="_Test Company")
# Create Finished Good Item
fg_item = make_item("Test Work Order Finished Good Item", properties={"is_stock_item": 1}).name
# Create Raw Material Item
rm_item = make_item("Test Work Order Raw Material Item", properties={"is_stock_item": 1}).name
# Create BOM
bom = make_bom(item=fg_item, rate=100, raw_materials=[rm_item])
# Create Inward entry for Raw Material
make_stock_entry(item=rm_item, to_warehouse=wip_warehouse, qty=10)
make_stock_entry(item=rm_item, to_warehouse=source_warehouse, qty=10)
# Create Work Order
wo = make_work_order(item=fg_item, qty=5, bom_no=bom.name, company="_Test Company")
wo.required_items[0].source_warehouse = source_warehouse
wo.fg_warehouse = fg_warehouse
wo.skip_transfer = True
wo.submit()
# Create Pick List
pl = create_pick_list(wo.name, for_qty=wo.qty)
# System prioritises the Source Warehouse
self.assertEqual(pl.locations[0].warehouse, source_warehouse)
self.assertEqual(pl.locations[0].item_code, rm_item)
self.assertEqual(pl.locations[0].qty, 5)
# Create Outward Entry from Source Warehouse
make_stock_entry(item=rm_item, from_warehouse=source_warehouse, qty=10)
pl.set_item_locations()
# System should pick other available warehouses
self.assertEqual(pl.locations[0].warehouse, wip_warehouse)
self.assertEqual(pl.locations[0].item_code, rm_item)
self.assertEqual(pl.locations[0].qty, 5)
def test_pick_list_validation_for_serial_no(self): def test_pick_list_validation_for_serial_no(self):
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
item = make_item( item = make_item(

View File

@@ -1240,6 +1240,65 @@ class TestPurchaseReceipt(ERPNextTestSuite):
pr.cancel() pr.cancel()
def test_item_valuation_with_deduct_valuation_and_total_tax(self):
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
supplier_warehouse="Work In Progress - TCP1",
qty=5,
rate=100,
do_not_save=1,
)
pr.append(
"taxes",
{
"charge_type": "Actual",
"add_deduct_tax": "Deduct",
"account_head": "_Test Account Shipping Charges - TCP1",
"category": "Valuation and Total",
"cost_center": "Main - TCP1",
"description": "Valuation Discount",
"tax_amount": 20,
},
)
pr.insert()
self.assertAlmostEqual(pr.items[0].item_tax_amount, -20.0, places=2)
self.assertAlmostEqual(pr.items[0].valuation_rate, 96.0, places=2)
pr.delete()
pr = make_purchase_receipt(
company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1",
supplier_warehouse="Work In Progress - TCP1",
qty=5,
rate=100,
do_not_save=1,
)
pr.append(
"taxes",
{
"charge_type": "On Net Total",
"add_deduct_tax": "Deduct",
"account_head": "_Test Account Shipping Charges - TCP1",
"category": "Valuation and Total",
"cost_center": "Main - TCP1",
"description": "Valuation Discount",
"rate": 10,
},
)
pr.insert()
self.assertAlmostEqual(pr.items[0].item_tax_amount, -50.0, places=2)
self.assertAlmostEqual(pr.items[0].valuation_rate, 90.0, places=2)
pr.delete()
def test_po_to_pi_and_po_to_pr_worflow_full(self): def test_po_to_pi_and_po_to_pr_worflow_full(self):
"""Test following behaviour: """Test following behaviour:
- Create PO - Create PO

View File

@@ -69,9 +69,15 @@ frappe.ui.form.on("Repost Item Valuation", {
} }
if (frm.doc.status == "In Progress") { if (frm.doc.status == "In Progress") {
frm.doc.current_index = data.current_index; if (data.current_index) {
frm.doc.items_to_be_repost = data.items_to_be_repost; frm.doc.current_index = data.current_index;
frm.doc.total_reposting_count = data.total_reposting_count; frm.doc.items_to_be_repost = data.items_to_be_repost;
}
if (data.vouchers_posted) {
frm.doc.total_vouchers = data.total_vouchers;
frm.doc.vouchers_posted = data.vouchers_posted;
}
frm.dashboard.reset(); frm.dashboard.reset();
frm.trigger("show_reposting_progress"); frm.trigger("show_reposting_progress");
@@ -108,15 +114,31 @@ frappe.ui.form.on("Repost Item Valuation", {
show_reposting_progress: function (frm) { show_reposting_progress: function (frm) {
var bars = []; var bars = [];
let title = "";
let progress = 0.0;
let total_count = frm.doc.items_to_be_repost ? JSON.parse(frm.doc.items_to_be_repost).length : 0; let total_count = frm.doc.items_to_be_repost ? JSON.parse(frm.doc.items_to_be_repost).length : 0;
if (frm.doc?.total_reposting_count) { if (total_count > 1) {
total_count = frm.doc.total_reposting_count; progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5;
title = __("Reposting for Item-Wh Completed {0}%", [progress]);
bars.push({
title: title,
width: progress + "%",
progress_class: "progress-bar-success",
});
frm.dashboard.add_progress(__("Reposting Progress"), bars);
} }
let progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5; if (!frm.doc.vouchers_posted) {
var title = __("Reposting Completed {0}%", [progress]); return;
}
// Show voucher posting progress if vouchers are being reposted
bars = [];
progress = flt((cint(frm.doc.vouchers_posted) / cint(frm.doc.total_vouchers)) * 100, 2) || 0.5;
title = __("Reposting for Vouchers Completed {0}%", [progress]);
bars.push({ bars.push({
title: title, title: title,
@@ -124,7 +146,7 @@ frappe.ui.form.on("Repost Item Valuation", {
progress_class: "progress-bar-success", progress_class: "progress-bar-success",
}); });
frm.dashboard.add_progress(__("Reposting Progress"), bars); frm.dashboard.add_progress(__("Reposting Vouchers Progress"), bars);
}, },
restart_reposting: function (frm) { restart_reposting: function (frm) {

View File

@@ -27,14 +27,16 @@
"error_section", "error_section",
"error_log", "error_log",
"reposting_info_section", "reposting_info_section",
"reposting_data_file",
"items_to_be_repost", "items_to_be_repost",
"distinct_item_and_warehouse",
"column_break_o1sj", "column_break_o1sj",
"total_reposting_count", "total_reposting_count",
"current_index", "current_index",
"gl_reposting_index", "gl_reposting_index",
"affected_transactions" "reposting_data_file",
"vouchers_based_on_item_and_warehouse_section",
"total_vouchers",
"column_break_yqwo",
"vouchers_posted"
], ],
"fields": [ "fields": [
{ {
@@ -167,15 +169,6 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "distinct_item_and_warehouse",
"fieldtype": "Code",
"hidden": 1,
"label": "Distinct Item and Warehouse",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{ {
"fieldname": "current_index", "fieldname": "current_index",
"fieldtype": "Int", "fieldtype": "Int",
@@ -185,14 +178,6 @@
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "affected_transactions",
"fieldtype": "Code",
"hidden": 1,
"label": "Affected Transactions",
"no_copy": 1,
"read_only": 1
},
{ {
"default": "0", "default": "0",
"fieldname": "gl_reposting_index", "fieldname": "gl_reposting_index",
@@ -205,7 +190,7 @@
{ {
"fieldname": "reposting_info_section", "fieldname": "reposting_info_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Reposting Info" "label": "Reposting Item and Warehouse"
}, },
{ {
"fieldname": "column_break_o1sj", "fieldname": "column_break_o1sj",
@@ -214,14 +199,7 @@
{ {
"fieldname": "total_reposting_count", "fieldname": "total_reposting_count",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Total Reposting Count", "label": "No of Items to Repost",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "reposting_data_file",
"fieldtype": "Attach",
"label": "Reposting Data File",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
}, },
@@ -247,13 +225,44 @@
"fieldname": "repost_only_accounting_ledgers", "fieldname": "repost_only_accounting_ledgers",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Repost Only Accounting Ledgers" "label": "Repost Only Accounting Ledgers"
},
{
"fieldname": "vouchers_based_on_item_and_warehouse_section",
"fieldtype": "Section Break",
"hidden": 1,
"label": "Reposting Vouchers"
},
{
"fieldname": "total_vouchers",
"fieldtype": "Int",
"label": "Total Ledgers",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break_yqwo",
"fieldtype": "Column Break"
},
{
"fieldname": "vouchers_posted",
"fieldtype": "Int",
"label": "Ledgers Posted",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "reposting_data_file",
"fieldtype": "Attach",
"label": "Reposting Data File",
"no_copy": 1,
"read_only": 1
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2026-02-25 14:22:21.681549", "modified": "2026-03-27 18:59:58.637964",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Repost Item Valuation", "name": "Repost Item Valuation",

View File

@@ -35,14 +35,12 @@ class RepostItemValuation(Document):
if TYPE_CHECKING: if TYPE_CHECKING:
from frappe.types import DF from frappe.types import DF
affected_transactions: DF.Code | None
allow_negative_stock: DF.Check allow_negative_stock: DF.Check
allow_zero_rate: DF.Check allow_zero_rate: DF.Check
amended_from: DF.Link | None amended_from: DF.Link | None
based_on: DF.Literal["Transaction", "Item and Warehouse"] based_on: DF.Literal["Transaction", "Item and Warehouse"]
company: DF.Link | None company: DF.Link | None
current_index: DF.Int current_index: DF.Int
distinct_item_and_warehouse: DF.Code | None
error_log: DF.LongText | None error_log: DF.LongText | None
gl_reposting_index: DF.Int gl_reposting_index: DF.Int
item_code: DF.Link | None item_code: DF.Link | None
@@ -55,9 +53,11 @@ class RepostItemValuation(Document):
reposting_reference: DF.Data | None reposting_reference: DF.Data | None
status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed", "Cancelled"] status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed", "Cancelled"]
total_reposting_count: DF.Int total_reposting_count: DF.Int
total_vouchers: DF.Int
via_landed_cost_voucher: DF.Check via_landed_cost_voucher: DF.Check
voucher_no: DF.DynamicLink | None voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None voucher_type: DF.Link | None
vouchers_posted: DF.Int
warehouse: DF.Link | None warehouse: DF.Link | None
# end: auto-generated types # end: auto-generated types
@@ -261,6 +261,8 @@ class RepostItemValuation(Document):
self.items_to_be_repost = None self.items_to_be_repost = None
self.gl_reposting_index = 0 self.gl_reposting_index = 0
self.total_reposting_count = 0 self.total_reposting_count = 0
self.total_vouchers = 0
self.vouchers_posted = 0
self.clear_attachment() self.clear_attachment()
self.db_update() self.db_update()
@@ -435,7 +437,7 @@ def repost_sl_entries(doc):
) )
else: else:
repost_future_sle( repost_future_sle(
args=[ items_to_be_repost=[
frappe._dict( frappe._dict(
{ {
"item_code": doc.item_code, "item_code": doc.item_code,

View File

@@ -14,19 +14,19 @@
"fields": [ "fields": [
{ {
"fieldname": "length", "fieldname": "length",
"fieldtype": "Int", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Length (cm)" "label": "Length (cm)"
}, },
{ {
"fieldname": "width", "fieldname": "width",
"fieldtype": "Int", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Width (cm)" "label": "Width (cm)"
}, },
{ {
"fieldname": "height", "fieldname": "height",
"fieldtype": "Int", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Height (cm)" "label": "Height (cm)"
}, },
@@ -49,7 +49,7 @@
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:41.396354", "modified": "2026-03-29 00:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Shipment Parcel", "name": "Shipment Parcel",

View File

@@ -15,21 +15,21 @@
"fields": [ "fields": [
{ {
"fieldname": "length", "fieldname": "length",
"fieldtype": "Int", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Length (cm)", "label": "Length (cm)",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "width", "fieldname": "width",
"fieldtype": "Int", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Width (cm)", "label": "Width (cm)",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "height", "fieldname": "height",
"fieldtype": "Int", "fieldtype": "Float",
"in_list_view": 1, "in_list_view": 1,
"label": "Height (cm)", "label": "Height (cm)",
"reqd": 1 "reqd": 1
@@ -52,7 +52,7 @@
} }
], ],
"links": [], "links": [],
"modified": "2024-03-27 13:10:41.521126", "modified": "2026-03-29 00:00:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Shipment Parcel Template", "name": "Shipment Parcel Template",

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