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
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):
def setUp(self):
create_dimension()
def test_dimension_against_sales_invoice(self):
si = create_sales_invoice(do_not_save=1)
@@ -77,63 +74,3 @@ class TestAccountingDimension(ERPNextTestSuite):
si.save()
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
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.exceptions import InvalidAccountDimensionError, MandatoryAccountDimensionError
from erpnext.tests.utils import ERPNextTestSuite
@@ -16,7 +12,6 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestAccountingDimensionFilter(ERPNextTestSuite):
def setUp(self):
create_dimension()
create_accounting_dimension_filter()
self.invoice_list = []

View File

@@ -116,6 +116,7 @@ def get_default_company_bank_account(company, party_type, party):
@frappe.whitelist()
def get_bank_account_details(bank_account):
frappe.has_permission("Bank Account", doc=bank_account, ptype="read", throw=True)
return frappe.get_cached_value(
"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):
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,
)
row.db_set("merged", 1)
frappe.db.commit()
if not frappe.in_test:
frappe.db.commit()
successful_merges += 1
frappe.publish_realtime(
"ledger_merge_progress",
{"ledger_merge": ledger_merge.name, "current": successful_merges, "total": total},
)
except Exception:
frappe.db.rollback()
if not frappe.in_test:
frappe.db.rollback()
ledger_merge.log_error("Ledger merge failed")
finally:
if successful_merges == total:

View File

@@ -50,6 +50,7 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
refresh: function (frm) {
frm.disable_save();
frm.trigger("create_missing_party");
!frm.doc.import_in_progress && frm.trigger("make_dashboard");
frm.page.set_primary_action(__("Create Invoices"), () => {
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) {
$.each(frm.doc.invoices, (idx, row) => {
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();
},
@@ -162,9 +164,35 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
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", {
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) => {
frm.trigger("update_invoice_table");
},

View File

@@ -8,9 +8,9 @@
"engine": "InnoDB",
"field_order": [
"company",
"create_missing_party",
"column_break_3",
"invoice_type",
"create_missing_party",
"accounting_dimensions_section",
"cost_center",
"dimension_col_break",
@@ -29,7 +29,7 @@
},
{
"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",
"fieldtype": "Check",
"label": "Create Missing Party"
@@ -65,10 +65,10 @@
"options": "Cost Center"
},
{
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
"fieldname": "project",
"fieldtype": "Link",
"label": "Project",
"options": "Project"
},
{
"collapsible": 1,
@@ -84,7 +84,7 @@
"hide_toolbar": 1,
"issingle": 1,
"links": [],
"modified": "2024-03-27 13:10:06.564397",
"modified": "2026-03-23 00:32:15.600086",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Opening Invoice Creation Tool",
@@ -101,8 +101,9 @@
}
],
"quick_entry": 1,
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -5,7 +5,7 @@
import frappe
from frappe import _, scrub
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 erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
@@ -32,6 +32,7 @@ class OpeningInvoiceCreationTool(Document):
create_missing_party: DF.Check
invoice_type: DF.Literal["Sales", "Purchase"]
invoices: DF.Table[OpeningInvoiceCreationToolItem]
project: DF.Link | None
# end: auto-generated types
def onload(self):
@@ -85,6 +86,11 @@ class OpeningInvoiceCreationTool(Document):
)
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
def validate_company(self):
@@ -102,10 +108,20 @@ class OpeningInvoiceCreationTool(Document):
row.due_date = row.due_date or nowdate()
def validate_mandatory_invoice_fields(self, row):
if not frappe.db.exists(row.party_type, row.party):
if self.create_missing_party:
self.add_party(row.party_type, row.party)
else:
if self.create_missing_party:
if not row.party and not row.party_name:
frappe.throw(_("Row #{}: Either Party ID or Party Name is required").format(row.idx))
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(
_("Row #{}: {} {} does not exist.").format(
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")
for d in ("Party", "Outstanding Amount", "Temporary Opening Account"):
for d in ("Outstanding Amount", "Temporary Opening Account"):
if not row.get(scrub(d)):
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.save(ignore_permissions=True)
return party_doc.name
def get_invoice_dict(self, row=None):
def get_item_dict():
@@ -262,7 +279,8 @@ def start_import(invoices):
doc.flags.ignore_mandatory = True
doc.insert(set_name=invoice_number)
doc.submit()
frappe.db.commit()
if not frappe.in_test:
frappe.db.commit()
names.append(doc.name)
except Exception:
errors += 1

View File

@@ -1,5 +1,5 @@
{% $.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">
<thead>
@@ -23,7 +23,7 @@
<td class="text-right">
{{ format_currency(summary[doctype].outstanding_amount, summary.currency, 2) }}
</td>
</div>
</tr>
{% endif %}
{% }); %}
</tbody>

View File

@@ -3,10 +3,6 @@
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 (
get_temporary_opening_account,
)
@@ -14,11 +10,6 @@ from erpnext.tests.utils import ERPNextTestSuite
class TestOpeningInvoiceCreationTool(ERPNextTestSuite):
def setUp(self):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company()
create_dimension()
def make_invoices(
self,
invoice_type="Sales",
@@ -183,19 +174,6 @@ def get_opening_invoice_creation_dict(**args):
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):
customer_name = customer or "Opening Customer"
customer = frappe.get_doc(

View File

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

View File

@@ -22,7 +22,8 @@ class OpeningInvoiceCreationToolItem(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
party: DF.DynamicLink
party: DF.DynamicLink | None
party_name: DF.Data | None
party_type: DF.Link | None
posting_date: DF.Date | 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,
)
outstanding_invoices = split_invoices_based_on_payment_terms(
outstanding_invoices, args.get("company")
)
outstanding_invoices = split_refdocs_based_on_payment_terms(outstanding_invoices, args.get("company"))
for d in outstanding_invoices:
d["exchange_rate"] = 1
@@ -2416,6 +2414,8 @@ def get_outstanding_reference_documents(args, validate=False):
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
if not data:
@@ -2438,13 +2438,13 @@ def get_outstanding_reference_documents(args, validate=False):
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."""
exc_rates = get_currency_data(outstanding_invoices, company)
exc_rates = get_currency_data(refdocs, company)
outstanding_invoices_after_split = []
for entry in outstanding_invoices:
if entry.voucher_type in ["Sales Invoice", "Purchase Invoice"]:
outstanding_refdoc_after_split = []
for entry in refdocs:
if entry.voucher_type in ["Sales Invoice", "Purchase Invoice", "Sales Order", "Purchase Order"]:
if payment_term_template := frappe.db.get_value(
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,
)
outstanding_invoices_after_split += split_rows
outstanding_refdoc_after_split += split_rows
continue
# 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."""
exc_rates = frappe._dict()
company_currency = frappe.db.get_value("Company", company, "default_currency") if company else None
for doctype in ["Sales Invoice", "Purchase Invoice"]:
invoices = [x.voucher_no for x in outstanding_invoices if x.voucher_type == doctype]
for doctype in ["Sales Invoice", "Purchase Invoice", "Sales Order", "Purchase Order"]:
refdoc = [x.voucher_no for x in outstanding_refdocs if x.voucher_type == doctype]
for x in frappe.db.get_all(
doctype,
filters={"name": ["in", invoices]},
filters={"name": ["in", refdoc]},
fields=["name", "currency", "conversion_rate", "party_account_currency"],
):
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, "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):
payment_entry = frappe.new_doc("Payment Entry")

View File

@@ -750,7 +750,8 @@ def make_payment_request(**args):
pr.submit()
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["location"] = pr.get_payment_url()

View File

@@ -4,10 +4,6 @@ import unittest
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 (
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
"""
create_dimension()
location = frappe.get_doc("Accounting Dimension", "Location")
location.dimension_defaults[0].mandatory_for_bs = True
location.save()
@@ -198,7 +193,6 @@ class TestPOSClosingEntry(ERPNextTestSuite):
)
accounting_dimension_department.mandatory_for_bs = 0
accounting_dimension_department.save()
disable_dimension()
def test_merging_into_sales_invoice_for_batched_item(self):
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)
doc.add_comment("Comment", "Emails sent on: " + frappe.utils.format_datetime(frappe.utils.now()))
if doc.report == "General Ledger":
doc.db_set("to_date", new_to_date, commit=True)
doc.db_set("from_date", new_from_date, commit=True)
frappe.db.set_value(doc.doctype, doc.name, "to_date", new_to_date)
frappe.db.set_value(doc.doctype, doc.name, "from_date", new_from_date)
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
else:
return False

View File

@@ -8,7 +8,6 @@
"email_append_to": 1,
"engine": "InnoDB",
"field_order": [
"title",
"naming_series",
"supplier",
"supplier_name",
@@ -209,16 +208,6 @@
"connections_tab"
],
"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",
"fieldtype": "Select",
@@ -1693,7 +1682,7 @@
"idx": 204,
"is_submittable": 1,
"links": [],
"modified": "2026-03-17 20:44:00.221219",
"modified": "2026-03-25 11:45:38.696888",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Purchase Invoice",
@@ -1756,6 +1745,6 @@
"sort_order": "DESC",
"states": [],
"timeline_field": "supplier",
"title_field": "title",
"title_field": "supplier_name",
"track_changes": 1
}

View File

@@ -203,7 +203,6 @@ class PurchaseInvoice(BuyingController):
taxes_and_charges_deducted: DF.Currency
tc_name: DF.Link | None
terms: DF.TextEditor | None
title: DF.Data | None
to_date: DF.Date | None
total: 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)
def po_required(self):
if frappe.db.get_single_value("Buying Settings", "po_required") == "Yes":
if frappe.get_value(
if (
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"
):
return
)
):
for d in self.get("items"):
if not d.purchase_order:
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):
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(
account_name="Offsetting",
@@ -2201,7 +2196,16 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
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.branch = "Location 1"
@@ -2238,8 +2242,6 @@ class TestPurchaseInvoice(ERPNextTestSuite, StockTestMixin):
voucher_type="Purchase Invoice",
additional_columns=["branch"],
)
clear_dimension_defaults("Branch")
disable_dimension()
def test_repost_accounting_entries(self):
# update repost settings

View File

@@ -2246,13 +2246,6 @@ class TestSalesInvoice(ERPNextTestSuite):
@ERPNextTestSuite.change_settings("Selling Settings", {"allow_multiple_items": True})
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.items = []
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:
subscription = frappe.get_doc("Subscription", subscription_name)
subscription.process(posting_date)
frappe.db.commit()
if not frappe.in_test:
frappe.db.commit()
except frappe.ValidationError:
frappe.db.rollback()
subscription.log_error("Subscription failed")

View File

@@ -18,7 +18,6 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
# create relevant supplier, etc
create_records()
create_tax_withholding_category_records()
make_pan_no_field()
def validate_tax_withholding_entries(self, doctype, docname, expected_entries):
"""Validate tax withholding entries for a document"""
@@ -3998,18 +3997,3 @@ def create_lower_deduction_certificate(
"certificate_limit": limit,
}
).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
"""
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:
ret_data[period.key] = period.total
ret_data.indent = 1
@@ -205,6 +208,9 @@ class Deferred_Invoice:
for item in self.uniq_items:
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):
"""
calculate deferred revenue/expense for all items in invoice
@@ -232,7 +238,7 @@ class Deferred_Invoice:
generate report data for invoice, includes invoice total
"""
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:
inv_total[x.key] = x.total
inv_total.indent = 0
@@ -386,6 +392,24 @@ class Deferred_Revenue_and_Expense_Report:
def get_columns(self):
columns = []
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:
columns.append(
{
@@ -415,6 +439,8 @@ class Deferred_Revenue_and_Expense_Report:
elif self.filters.type == "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):
total_row[period.key] = self.period_total[idx].total
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.utils import get_fiscal_year
self.company = create_company()
create_cost_center(
cost_center_name="Test Cost Center",
company="Trial Balance Company",
@@ -26,7 +25,16 @@ class TestTrialBalance(ERPNextTestSuite):
parent_account="Temporary Accounts - TBC",
)
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):
"""
@@ -45,7 +53,7 @@ class TestTrialBalance(ERPNextTestSuite):
branch2.insert(ignore_if_duplicate=True)
si = create_sales_invoice(
company=self.company,
company="Trial Balance Company",
debit_to="Debtors - TBC",
cost_center="Test Cost Center - TBC",
income_account="Sales - TBC",
@@ -57,60 +65,7 @@ class TestTrialBalance(ERPNextTestSuite):
si.submit()
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]
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,
)
frappe.db.commit()
if not frappe.in_test:
frappe.db.commit()
except Exception as e:
frappe.db.rollback()
failed_assets.append(asset_name)
@@ -71,7 +73,8 @@ def book_depreciation_entries(date):
if failed_assets:
set_depr_entry_posting_status_for_failed_assets(failed_assets)
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):

View File

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

View File

@@ -16,7 +16,6 @@ class TestAssetMovement(ERPNextTestSuite):
frappe.db.set_value(
"Company", "_Test Company", "capital_work_in_progress_account", "CWIP Account - _TC"
)
make_location()
def test_movement(self):
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:
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(
purpose="Transfer",
company=asset.company,
@@ -122,9 +117,6 @@ class TestAssetMovement(ERPNextTestSuite):
if asset.docstatus == 0:
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})
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.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 Movement",
[["Asset Movement Item", "asset", "=", asset.name], ["docstatus", "=", 1]],
@@ -197,9 +186,3 @@ def create_asset_movement(**args):
movement.submit()
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",
"field_order": [
"supplier_section",
"title",
"naming_series",
"supplier",
"supplier_name",
@@ -172,17 +171,6 @@
"fieldtype": "Section Break",
"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",
"fieldtype": "Select",
@@ -1328,7 +1316,7 @@
"idx": 105,
"is_submittable": 1,
"links": [],
"modified": "2026-03-09 17:15:29.184682",
"modified": "2026-03-25 11:46:18.748951",
"modified_by": "Administrator",
"module": "Buying",
"name": "Purchase Order",

View File

@@ -159,7 +159,6 @@ class PurchaseOrder(BuyingController):
taxes_and_charges_deducted: DF.Currency
tc_name: DF.Link | None
terms: DF.TextEditor | None
title: DF.Data
to_date: DF.Date | None
total: DF.Currency
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"):
frappe.throw(_("Not Permitted"), frappe.PermissionError)
doc.save()
frappe.db.commit()
if not frappe.in_test:
frappe.db.commit()
frappe.response["type"] = "redirect"
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.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 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))
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",
},
"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),
},
"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(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():
from erpnext.controllers.tests.test_subcontracting_controller import (

View File

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

View File

@@ -9,8 +9,6 @@
"field_order": [
"naming_series",
"company",
"billing_address",
"billing_address_display",
"vendor",
"column_break1",
"transaction_date",
@@ -43,7 +41,13 @@
"select_print_heading",
"letter_head",
"more_info",
"opportunity"
"opportunity",
"address_and_contact_tab",
"billing_address",
"billing_address_display",
"column_break_czul",
"shipping_address",
"shipping_address_display"
],
"fields": [
{
@@ -346,6 +350,27 @@
"fieldtype": "Check",
"hidden": 1,
"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,
@@ -353,7 +378,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-03-09 17:15:29.774614",
"modified": "2026-03-19 15:27:56.730649",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation",

View File

@@ -56,6 +56,8 @@ class RequestforQuotation(BuyingController):
select_print_heading: DF.Link | None
send_attached_files: DF.Check
send_document_print: DF.Check
shipping_address: DF.Link | None
shipping_address_display: DF.TextEditor | None
status: DF.Literal["", "Draft", "Submitted", "Cancelled"]
subject: DF.Data
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")
if doctype in ["Purchase Order", "Purchase Invoice"]:
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:
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"),
"Delivery Note": ("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(

View File

@@ -503,11 +503,15 @@ class BuyingController(SubcontractingController):
if d.category not in ["Valuation", "Valuation and Total"]:
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":
total_valuation_amount += flt(d.base_tax_amount_after_discount_amount)
total_valuation_amount += amount
tax_accounts.append(d.account_head)
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
@@ -1094,7 +1098,8 @@ class BuyingController(SubcontractingController):
for dimension in accounting_dimensions[0]:
fieldname = dimension["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_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,
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)
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):
"""
Test workings of dimension filters
"""
self.setup_dimensions()
rate_in_account_currency = 1
# Invoices
@@ -1653,7 +1638,6 @@ class TestAccountsController(ERPNextTestSuite):
self.assertEqual(len(pr.payments), 1)
def test_91_cr_note_should_inherit_dimension(self):
self.setup_dimensions()
rate_in_account_currency = 1
# Invoice
@@ -1698,7 +1682,6 @@ class TestAccountsController(ERPNextTestSuite):
def test_92_dimension_inhertiance_exc_gain_loss(self):
# Sales Invoice in Foreign Currency
self.setup_dimensions()
rate_in_account_currency = 1
dpt = "Research & Development - _TC"
@@ -1734,7 +1717,6 @@ class TestAccountsController(ERPNextTestSuite):
)
def test_93_dimension_inheritance_on_advance(self):
self.setup_dimensions()
dpt = "Research & Development - _TC"
adv = self.create_payment_entry(amount=1, source_exc_rate=85)

View File

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

View File

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

View File

@@ -204,8 +204,22 @@ def send_mail(entry, email_campaign):
# called from hooks on doc_event Email Unsubscribe
def unsubscribe_recipient(unsubscribe, method):
if unsubscribe.reference_doctype == "Email Campaign":
frappe.db.set_value("Email Campaign", unsubscribe.reference_name, "status", "Unsubscribed")
if unsubscribe.reference_doctype != "Email Campaign":
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

View File

@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
import frappe
from frappe.model.document import Document
from frappe.utils import escape_html
if TYPE_CHECKING:
from lxml.etree import Element
@@ -63,14 +64,16 @@ class CodeList(Document):
def from_genericode(self, root: "Element"):
"""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.canonical_uri = root.find(".//CanonicalUri").text
# optionals
self.description = getattr(root.find(".//Identification/LongName"), "text", None)
self.publisher = getattr(root.find(".//Identification/Agency/ShortName"), "text", None)
self.description = escape_html(getattr(root.find(".//Identification/LongName"), "text", None))
self.publisher = escape_html(getattr(root.find(".//Identification/Agency/ShortName"), "text", None))
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.url = getattr(root.find(".//Identification/LocationUri"), "text", None)

View File

@@ -3,6 +3,7 @@ import json
import frappe
import requests
from frappe import _
from frappe.utils import escape_html
from lxml import etree
URL_PREFIXES = ("http://", "https://")
@@ -32,7 +33,12 @@ def import_genericode():
content = f.read()
# 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:
root = etree.fromstring(content, parser=parser)
except Exception as e:
@@ -104,7 +110,7 @@ def get_genericode_columns_and_examples(root):
# Get column names
for column in root.findall(".//Column"):
column_id = column.get("Id")
column_id = escape_html(column.get("Id"))
columns.append(column_id)
example_values[column_id] = []
filterable_columns[column_id] = set()
@@ -112,7 +118,7 @@ def get_genericode_columns_and_examples(root):
# Get all values and count unique occurrences
for row in root.findall(".//SimpleCodeList/Row"):
for value in row.findall("Value"):
column_id = value.get("ColumnRef")
column_id = escape_html(value.get("ColumnRef"))
if column_id not in columns:
# Handle undeclared column
columns.append(column_id)
@@ -123,7 +129,7 @@ def get_genericode_columns_and_examples(root):
if simple_value is None:
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
for row in root.findall(".//SimpleCodeList/Row")[:3]:
@@ -133,7 +139,7 @@ def get_genericode_columns_and_examples(root):
if simple_value is None:
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}

View File

@@ -62,7 +62,6 @@ welcome_email = "erpnext.setup.utils.welcome_email"
# setup wizard
setup_wizard_requires = "assets/erpnext/js/setup_wizard.js"
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"

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
)
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.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)
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 row.time_in_mins:
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)
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:
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, "source_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);
},
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) {
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) {
let completed_rows = time_logs.filter((d) => d.to_time);

View File

@@ -616,7 +616,12 @@ class ProductionPlan(Document):
None,
):
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(
_(
"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")
qty_precision = frappe.get_precision("Material Request Plan Item", "quantity")
for key, details in item_details.items():
details.qty = flt(details.qty, qty_precision)
so_item_details.setdefault(sales_order, frappe._dict())
if key in so_item_details.get(sales_order, {}):
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):
stock_entries = []
bom = frappe.get_doc(
"BOM", {"docstatus": 1, "with_operations": 1, "company": "_Test Company", "has_variants": 0}
item_code = make_item("_Test Item For Process Loss", {"is_stock_item": 1}).name
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(
item=bom.item,
qty=2,

View File

@@ -587,7 +587,7 @@ class WorkOrder(Document):
if self.docstatus == 0:
status = "Draft"
elif self.docstatus == 1:
if status != "Stopped":
if status not in ["Closed", "Stopped"]:
status = "Not Started"
if flt(self.material_transferred_for_manufacturing) > 0:
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]] = [
("BOM Explorer", {"bom": self.last_bom}),
("BOM Operations Time", {}),
("BOM Stock Calculated", {"bom": self.last_bom, "qty_to_make": 2}),
("BOM Stock Report", {"bom": self.last_bom, "qty_to_produce": 2}),
("BOM Stock Analysis", {"bom": self.last_bom, "_optional": ["warehouse"]}),
("Cost of Poor Quality Report", {"item": "_Test Item", "serial_no": "00"}),
("Downtime Analysis", {}),
(

View File

@@ -5,6 +5,7 @@ const doctype_list = [
"Purchase Order",
"Purchase Invoice",
"POS Invoice",
"Request for Quotation",
];
const allowed_print_formats = [
"Sales Order Standard",
@@ -19,6 +20,7 @@ const allowed_print_formats = [
"Purchase Invoice with Item Image",
"POS Invoice Standard",
"POS Invoice with Item Image",
"Request for Quotation with Item Image",
];
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):
quotation = make_quotation(
currency="QAR",
transaction_date="2026-06-04",
transaction_date="2026-01-01",
)
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)
expected_rate = flt(value) / 3.64

View File

@@ -6,7 +6,7 @@
"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",
"custom_blocks": [],
"docstatus": 0,
@@ -622,7 +622,7 @@
"type": "Link"
}
],
"modified": "2026-01-02 17:42:20.131214",
"modified": "2026-02-19 13:01:26.893303",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling",

View File

@@ -7,7 +7,7 @@ from random import randint
import frappe
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.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
def setup_demo_data():
def setup_demo_data(company_name):
from frappe.utils.telemetry import capture
capture("demo_data_creation_started", "erpnext")
try:
company = create_demo_company()
frappe.db.savepoint("demo_data")
company = create_demo_company(company_name)
process_masters()
make_transactions(company)
frappe.cache.delete_keys("bootinfo")
frappe.publish_realtime("demo_data_complete")
capture("demo_data_creation_completed", "erpnext")
frappe.clear_messages()
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()})
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()
@@ -56,21 +79,8 @@ def clear_demo_data():
)
def create_demo_company():
if frappe.flags.in_test:
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()
def create_demo_company(company):
company_doc = frappe.get_doc("Company", company).as_dict()
# Make a dummy 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)
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()
@@ -936,7 +936,9 @@ def cache_companies_monthly_sales_history():
for company in companies:
update_company_monthly_sales(company)
update_transactions_annual_history(company)
frappe.db.commit()
if not frappe.in_test:
frappe.db.commit()
@frappe.whitelist()

View File

@@ -199,7 +199,9 @@ class TestCompany(ERPNextTestSuite):
def test_demo_data(self):
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)")})
self.assertTrue(company_name)

View File

@@ -180,5 +180,39 @@
"default_currency": "ZAR",
"doctype": "Company",
"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) {
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) {
@@ -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({

View File

@@ -28,8 +28,9 @@
"status",
"erpnext_user",
"user_id",
"create_user",
"create_user_permission",
"column_break_xwnm",
"create_user_automatically",
"company_details_section",
"company",
"department",
@@ -39,19 +40,11 @@
"reports_to",
"column_break_18",
"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",
"cell_number",
"column_break_40",
"personal_email",
"company_email",
"personal_email",
"column_break4",
"prefered_contact_email",
"prefered_email",
@@ -101,6 +94,14 @@
"external_work_history",
"history_in_company",
"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",
"resignation_letter_date",
"relieving_date",
@@ -273,6 +274,7 @@
},
{
"collapsible": 1,
"collapsible_depends_on": "eval:doc.__islocal",
"fieldname": "erpnext_user",
"fieldtype": "Section Break",
"label": "User Details"
@@ -285,20 +287,23 @@
"label": "User ID",
"options": "User"
},
{
"depends_on": "eval:(!doc.user_id)",
"fieldname": "create_user",
"fieldtype": "Button",
"label": "Create User"
},
{
"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",
"fieldname": "create_user_permission",
"fieldtype": "Check",
"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,
"collapsible": 1,
@@ -348,6 +353,7 @@
{
"fieldname": "department",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Department",
"oldfieldname": "department",
@@ -377,6 +383,7 @@
{
"fieldname": "branch",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Branch",
"oldfieldname": "branch",
"oldfieldtype": "Link",
@@ -600,7 +607,7 @@
"collapsible": 1,
"fieldname": "exit",
"fieldtype": "Tab Break",
"label": "Employee Exit",
"label": "Exit",
"oldfieldtype": "Section Break"
},
{
@@ -816,6 +823,10 @@
"fieldtype": "Data",
"label": "IBAN",
"options": "IBAN"
},
{
"fieldname": "column_break_xwnm",
"fieldtype": "Column Break"
}
],
"icon": "fa fa-user",
@@ -823,7 +834,7 @@
"image_field": "image",
"is_tree": 1,
"links": [],
"modified": "2025-08-29 11:52:12.819878",
"modified": "2026-03-23 15:26:05.149280",
"modified_by": "Administrator",
"module": "Setup",
"name": "Employee",

View File

@@ -8,7 +8,7 @@ from frappe.permissions import (
get_doc_permissions,
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 erpnext.utilities.transaction_base import delete_events
@@ -23,6 +23,94 @@ class InactiveEmployeeStatusError(frappe.ValidationError):
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"
def autoname(self):
@@ -72,6 +160,16 @@ class Employee(NestedSet):
self.validate_for_enabled_user_id(data.get("enabled", 0))
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):
frappe.utils.nestedset.update_nsm(self)
@@ -83,6 +181,22 @@ class Employee(NestedSet):
self.update_user_permissions()
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):
if not self.has_value_changed("user_id") and not self.has_value_changed("create_user_permission"):
return
@@ -310,10 +424,17 @@ def deactivate_sales_person(status=None, employee=None):
@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)
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(" ")
first_name = employee_name[0]
middle_name = last_name = ""
if len(employee_name) >= 3:
@@ -322,16 +443,10 @@ def create_user(employee, user=None, email=None):
elif len(employee_name) == 2:
last_name = employee_name[1]
first_name = employee_name[0]
if email:
emp.prefered_email = email
user = frappe.new_doc("User")
user.update(
{
"name": emp.employee_name,
"email": emp.prefered_email,
"email": email,
"enabled": 1,
"first_name": first_name,
"middle_name": middle_name,
@@ -342,9 +457,18 @@ def create_user(employee, user=None, email=None):
"bio": emp.bio,
}
)
emp.db_set("user_id", email)
user.append_roles("Employee")
user.insert()
emp.user_id = user.name
emp.create_user_permission = cint(create_user_permission)
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

View File

@@ -1,11 +1,25 @@
frappe.listview_settings["Employee"] = {
add_fields: ["status", "branch", "department", "designation", "image"],
filters: [["status", "=", "Active"]],
get_indicator: function (doc) {
get_indicator(doc) {
return [
__(doc.status, null, "Employee"),
{ Active: "green", Inactive: "red", Left: "gray", Suspended: "orange" }[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)
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):
if not frappe.db.get_value("User", user):

View File

@@ -4,6 +4,7 @@
import frappe
from frappe.model.document import Document
from frappe.query_builder import DocType
class PartyType(Document):
@@ -24,29 +25,36 @@ class PartyType(Document):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_party_type(doctype, txt, searchfield, start, page_len, filters):
cond = ""
account_type = None
def get_party_type(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
PartyType = DocType("Party Type")
get_party_type_query = frappe.qb.from_(PartyType).select(PartyType.name).orderby(PartyType.name)
condition_list = []
if filters and filters.get("account"):
account_type = frappe.db.get_value("Account", filters.get("account"), "account_type")
if account_type:
if account_type in ["Receivable", "Payable"]:
# 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:
cond = "and account_type = %(account_type)s"
condition_list.append(PartyType.account_type == account_type)
# Build parameters dictionary
params = {"txt": "%" + txt + "%", "start": start, "page_len": page_len}
if account_type:
params["account_type"] = account_type
for condition in condition_list:
get_party_type_query = get_party_type_query.where(condition)
result = frappe.db.sql(
f"""select name from `tabParty Type`
where `{searchfield}` LIKE %(txt)s {cond}
order by name limit %(page_len)s offset %(start)s""",
params,
)
if frappe.local.lang == "en":
get_party_type_query = get_party_type_query.where(getattr(PartyType, searchfield).like(f"%{txt}%"))
get_party_type_query = get_party_type_query.limit(page_len)
get_party_type_query = get_party_type_query.offset(start)
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 []

View File

@@ -310,6 +310,7 @@ def set_default_print_formats():
"Purchase Order": "Purchase Order with Item Image",
"Purchase Invoice": "Purchase Invoice with Item Image",
"POS Invoice": "POS Invoice with Item Image",
"Request for Quotation": "Request for Quotation with Item Image",
}
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):
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"),
"fail_msg": _("Failed to login"),
"tasks": [{"fn": fin, "args": args, "fail_msg": _("Failed to login")}],
"status": _("Creating demo data"),
"fail_msg": _("Failed to create demo data"),
"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
@@ -59,19 +54,8 @@ def setup_defaults(args):
fixtures.install_defaults(frappe._dict(args))
def fin(args):
frappe.local.message_log = []
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"))
def setup_demo(args): # nosemgrep
setup_demo_data(args.get("company_name"))
# Only for programmatical use
@@ -79,4 +63,3 @@ def setup_complete(args=None):
stage_fixtures(args)
setup_company(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 () {
if (source && (dialog.get_value("qty") == 0 || dialog.get_value("qty") > actual_qty)) {
frappe.msgprint(__("Quantity must be greater than zero, and less or equal to {0}", [actual_qty]));
if (flt(dialog.get_value("qty")) <= 0) {
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;
}

View File

@@ -1,6 +1,6 @@
import frappe
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 (
get_sre_reserved_qty_for_items_and_warehouses as get_reserved_stock_details,
@@ -70,8 +70,10 @@ def get_data(
for item in items:
item.update(
{
"item_name": frappe.get_cached_value("Item", item.item_code, "item_name"),
"stock_uom": frappe.get_cached_value("Item", item.item_code, "stock_uom"),
"item_code": escape_html(item.item_code),
"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")
or frappe.get_cached_value("Item", item.item_code, "has_serial_no"),
"projected_qty": flt(item.projected_qty, precision),

View File

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

View File

@@ -1,6 +1,6 @@
import frappe
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
@@ -75,6 +75,9 @@ def get_warehouse_capacity_data(filters, start):
balance_qty = get_stock_balance(entry.item_code, entry.warehouse, nowdate()) or 0
entry.update(
{
"warehouse": escape_html(entry.warehouse),
"item_code": escape_html(entry.item_code),
"company": escape_html(entry.company),
"actual_qty": balance_qty,
"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):
def setUp(self):
prepare_test_data()
def test_validate_inventory_dimension(self):
# Can not be child doc
inv_dim1 = create_inventory_dimension(
@@ -77,6 +74,7 @@ class TestInventoryDimension(ERPNextTestSuite):
self.assertFalse(custom_field)
def test_inventory_dimension(self):
create_warehouse("Shelf Warehouse")
warehouse = "Shelf Warehouse - _TC"
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):
args = frappe._dict(args)

View File

@@ -47,7 +47,6 @@
"column_break_cqdk",
"valuation_rate",
"inventory_settings_section",
"shelf_life_in_days",
"end_of_life",
"default_material_request_type",
"column_break1",
@@ -64,6 +63,7 @@
"create_new_batch",
"batch_number_series",
"has_expiry_date",
"shelf_life_in_days",
"retain_sample",
"sample_quantity",
"column_break_37",
@@ -334,6 +334,7 @@
"options": "fa fa-truck"
},
{
"depends_on": "has_expiry_date",
"fieldname": "shelf_life_in_days",
"fieldtype": "Int",
"label": "Shelf Life In Days",
@@ -343,11 +344,13 @@
{
"default": "2099-12-31",
"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",
"fieldtype": "Date",
"label": "End of Life",
"oldfieldname": "end_of_life",
"oldfieldtype": "Date"
"oldfieldtype": "Date",
"show_description_on_click": 1
},
{
"default": "Purchase",
@@ -467,9 +470,12 @@
{
"default": "0",
"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",
"fieldtype": "Check",
"label": "Retain Sample"
"label": "Retain Sample",
"show_description_on_click": 1
},
{
"depends_on": "eval: (doc.retain_sample && doc.has_batch_no)",
@@ -989,7 +995,7 @@
"image_field": "image",
"links": [],
"make_attachments_public": 1,
"modified": "2026-03-17 20:39:05.218344",
"modified": "2026-03-24 15:45:40.207531",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",

View File

@@ -10,6 +10,7 @@ from frappe.query_builder.custom import ConstantColumn
from frappe.utils import cint, flt
import erpnext
from erpnext import is_perpetual_inventory_enabled
from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@@ -175,6 +176,9 @@ class LandedCostVoucher(Document):
)
def validate_expense_accounts(self):
if not is_perpetual_inventory_enabled(self.company):
return
for t in self.taxes:
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)
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 (
IncorrectCompanyValidationError,
)
@@ -187,6 +189,20 @@ class TestLandedCostVoucher(ERPNextTestSuite):
company_a = "_Test Company"
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(
company=company_a,
warehouse="Stores - _TC",
@@ -212,6 +228,9 @@ class TestLandedCostVoucher(ERPNextTestSuite):
distribute_landed_cost_on_items(lcv)
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):
"Test impact of LCV on future stock balances."
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].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):
material_request = make_material_request(

View File

@@ -86,6 +86,7 @@ class PickList(TransactionBase):
"join_field": "material_request_item",
"target_ref_field": "stock_qty",
"source_field": "stock_qty",
"validate_qty": False,
}
]
@@ -522,8 +523,26 @@ class PickList(TransactionBase):
self.item_location_map = frappe._dict()
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.
locations_replica = self.get("locations")
@@ -541,6 +560,13 @@ class PickList(TransactionBase):
len_idx = len(self.get("locations")) or 0
for item_doc in items:
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(
item_code,
@@ -551,6 +577,7 @@ class PickList(TransactionBase):
self.company,
picked_item_details=picked_items_details.get(item_code),
consider_rejected_warehouses=self.consider_rejected_warehouses,
priority_warehouses=priority_warehouses,
),
)
@@ -968,6 +995,7 @@ def get_available_item_locations(
ignore_validation=False,
picked_item_details=None,
consider_rejected_warehouses=False,
priority_warehouses=None,
):
locations = []
@@ -1008,7 +1036,7 @@ def get_available_item_locations(
locations = filter_locations_by_picked_materials(locations, picked_item_details)
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:
validate_picked_materials(item_code, required_qty, locations, picked_item_details)
@@ -1016,9 +1044,14 @@ def get_available_item_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 = []
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:
if location.qty >= required_qty:
location.qty = required_qty

View File

@@ -1050,6 +1050,53 @@ class TestPickList(ERPNextTestSuite):
pl = create_pick_list(so.name)
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):
warehouse = "_Test Warehouse - _TC"
item = make_item(

View File

@@ -1240,6 +1240,65 @@ class TestPurchaseReceipt(ERPNextTestSuite):
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):
"""Test following behaviour:
- Create PO

View File

@@ -69,9 +69,15 @@ frappe.ui.form.on("Repost Item Valuation", {
}
if (frm.doc.status == "In Progress") {
frm.doc.current_index = data.current_index;
frm.doc.items_to_be_repost = data.items_to_be_repost;
frm.doc.total_reposting_count = data.total_reposting_count;
if (data.current_index) {
frm.doc.current_index = data.current_index;
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.trigger("show_reposting_progress");
@@ -108,15 +114,31 @@ frappe.ui.form.on("Repost Item Valuation", {
show_reposting_progress: function (frm) {
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;
if (frm.doc?.total_reposting_count) {
total_count = frm.doc.total_reposting_count;
if (total_count > 1) {
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;
var title = __("Reposting Completed {0}%", [progress]);
if (!frm.doc.vouchers_posted) {
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({
title: title,
@@ -124,7 +146,7 @@ frappe.ui.form.on("Repost Item Valuation", {
progress_class: "progress-bar-success",
});
frm.dashboard.add_progress(__("Reposting Progress"), bars);
frm.dashboard.add_progress(__("Reposting Vouchers Progress"), bars);
},
restart_reposting: function (frm) {

View File

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

View File

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

View File

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

View File

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

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