mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-10 02:15:07 +00:00
Merge pull request #53915 from frappe/version-16-hotfix
This commit is contained in:
3
.github/workflows/linters.yml
vendored
3
.github/workflows/linters.yml
vendored
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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", {}),
|
||||
(
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 & 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 & 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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user