mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-29 22:58:25 +00:00
Merge branch 'version-15-hotfix' into mergify/bp/version-15-hotfix/pr-58351
This commit is contained in:
@@ -10,6 +10,7 @@ from frappe.contacts.address_and_contact import (
|
||||
load_address_and_contact,
|
||||
)
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.utils import get_link_to_form
|
||||
|
||||
from erpnext.accounts.party import (
|
||||
get_dashboard_info,
|
||||
@@ -177,10 +178,15 @@ class Supplier(TransactionBase):
|
||||
)
|
||||
|
||||
if internal_supplier:
|
||||
internal_supplier_link = get_link_to_form("Supplier", internal_supplier)
|
||||
frappe.throw(
|
||||
_("Internal Supplier for company {0} already exists").format(
|
||||
frappe.bold(self.represents_company)
|
||||
)
|
||||
_(
|
||||
"Internal Supplier {0} already exists for {1}. Disable it to make this Supplier internal."
|
||||
).format(
|
||||
internal_supplier_link,
|
||||
frappe.bold(self.represents_company),
|
||||
),
|
||||
title=_("Internal Supplier Already Exists"),
|
||||
)
|
||||
|
||||
def create_primary_contact(self):
|
||||
|
||||
@@ -10,6 +10,26 @@ frappe.query_reports["Purchase Analytics"] = {
|
||||
options: ["Supplier Group", "Supplier", "Item Group", "Item"],
|
||||
default: "Supplier",
|
||||
reqd: 1,
|
||||
on_change: function () {
|
||||
const entity_filter = frappe.query_report.get_filter("entity");
|
||||
if (entity_filter) {
|
||||
entity_filter.df.label = __(frappe.query_report.get_filter_value("tree_type"));
|
||||
entity_filter.set_value([]);
|
||||
entity_filter.refresh();
|
||||
}
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "entity",
|
||||
label: __("Entity"),
|
||||
fieldtype: "MultiSelectList",
|
||||
get_data: function (txt) {
|
||||
const tree_type = frappe.query_report.get_filter_value("tree_type");
|
||||
if (!tree_type || tree_type === "Order Type") return [];
|
||||
return frappe.db.get_link_options(tree_type, txt);
|
||||
},
|
||||
depends_on: "eval:doc.tree_type != 'Order Type'",
|
||||
},
|
||||
{
|
||||
fieldname: "doc_type",
|
||||
@@ -65,6 +85,19 @@ frappe.query_reports["Purchase Analytics"] = {
|
||||
default: "Monthly",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "curves",
|
||||
label: __("Curves"),
|
||||
fieldtype: "Select",
|
||||
options: [
|
||||
{ value: "select", label: __("Select") },
|
||||
{ value: "all", label: __("All") },
|
||||
{ value: "non-zeros", label: __("Non-Zeros") },
|
||||
{ value: "total", label: __("Total Only") },
|
||||
],
|
||||
default: "select",
|
||||
reqd: 1,
|
||||
},
|
||||
],
|
||||
get_datatable_options(options) {
|
||||
return Object.assign(options, {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.buying.report.purchase_analytics.purchase_analytics import execute
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_GROUP = "_Test Supplier Group"
|
||||
# A historical window that ordinary test fixtures don't post into.
|
||||
FROM_DATE = "2019-04-01"
|
||||
TO_DATE = "2019-06-30"
|
||||
|
||||
|
||||
class TestPurchaseAnalytics(FrappeTestCase):
|
||||
"""purchase_analytics reuses the shared Analytics engine; these tests lock its
|
||||
wiring (doc_type=Purchase Order) across the Supplier Group / Item Group trees."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def _filters(self, **overrides):
|
||||
filters = {
|
||||
"doc_type": "Purchase Order",
|
||||
"value_quantity": "Value",
|
||||
"range": "Monthly",
|
||||
"company": COMPANY,
|
||||
"from_date": FROM_DATE,
|
||||
"to_date": TO_DATE,
|
||||
}
|
||||
filters.update(overrides)
|
||||
return frappe._dict(filters)
|
||||
|
||||
def _rows(self, filters):
|
||||
return {row["entity"]: row for row in execute(filters)[1]}
|
||||
|
||||
def make_po(self, qty=4, rate=250):
|
||||
return create_purchase_order(
|
||||
company=COMPANY, supplier=SUPPLIER, qty=qty, rate=rate, transaction_date="2019-04-10"
|
||||
)
|
||||
|
||||
def test_supplier_entity_filter(self):
|
||||
filters = self._filters(tree_type="Supplier", entity=[SUPPLIER], curves="all")
|
||||
base_total = flt(self._rows(filters).get(SUPPLIER, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po()
|
||||
columns, data, _message, chart, *_rest = execute(filters)
|
||||
|
||||
self.assertTrue(columns)
|
||||
self.assertEqual({row["entity"] for row in data}, {SUPPLIER})
|
||||
self.assertAlmostEqual(data[0]["total"] - base_total, flt(po.base_net_total), places=2)
|
||||
|
||||
supplier_name = frappe.db.get_value("Supplier", SUPPLIER, "supplier_name")
|
||||
self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {supplier_name})
|
||||
|
||||
def test_parent_supplier_group_filter_preserves_rollup(self):
|
||||
self.make_po()
|
||||
filters = self._filters(tree_type="Supplier Group")
|
||||
unfiltered = self._rows(filters)
|
||||
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=["All Supplier Groups"]))
|
||||
|
||||
self.assertEqual(set(filtered), {"All Supplier Groups"})
|
||||
self.assertAlmostEqual(
|
||||
filtered["All Supplier Groups"]["total"],
|
||||
unfiltered["All Supplier Groups"]["total"],
|
||||
places=2,
|
||||
)
|
||||
|
||||
def test_supplier_group_entity_filter(self):
|
||||
self.make_po()
|
||||
unfiltered = self._rows(self._filters(tree_type="Supplier Group"))
|
||||
filtered = self._rows(self._filters(tree_type="Supplier Group", entity=[SUPPLIER_GROUP]))
|
||||
|
||||
self.assertEqual(set(filtered), {SUPPLIER_GROUP})
|
||||
self.assertEqual(filtered[SUPPLIER_GROUP]["indent"], 0)
|
||||
self.assertAlmostEqual(
|
||||
filtered[SUPPLIER_GROUP]["total"], unfiltered[SUPPLIER_GROUP]["total"], places=2
|
||||
)
|
||||
|
||||
def test_supplier_group_tree_rolls_up_to_root(self):
|
||||
filters = self._filters(tree_type="Supplier Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
# supplier is remapped to its group; the root sits at indent 0
|
||||
self.assertIn(SUPPLIER_GROUP, rows)
|
||||
self.assertIn("All Supplier Groups", rows)
|
||||
self.assertNotIn(SUPPLIER, rows)
|
||||
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_item_group_tree_rolls_up_to_root(self):
|
||||
item_group = frappe.db.get_value("Item", "_Test Item", "item_group")
|
||||
filters = self._filters(tree_type="Item Group")
|
||||
base = self._rows(filters)
|
||||
base_group = flt(base.get(item_group, {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=4, rate=250)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertIn(item_group, rows)
|
||||
self.assertIn("All Item Groups", rows)
|
||||
# the raw item code must not leak as its own entity; the root sits at indent 0
|
||||
self.assertNotIn("_Test Item", rows)
|
||||
self.assertEqual(rows["All Item Groups"]["indent"], 0)
|
||||
self.assertAlmostEqual(rows[item_group]["total"] - base_group, flt(po.base_net_total), places=2)
|
||||
self.assertGreaterEqual(flt(rows["All Item Groups"]["total"]), flt(po.base_net_total))
|
||||
|
||||
def test_supplier_group_by_quantity(self):
|
||||
filters = self._filters(tree_type="Supplier Group", value_quantity="Quantity")
|
||||
base = self._rows(filters)
|
||||
base_qty = flt(base.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
base_root_qty = flt(base.get("All Supplier Groups", {}).get("total", 0.0))
|
||||
|
||||
po = self.make_po(qty=7, rate=100)
|
||||
rows = self._rows(filters)
|
||||
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_qty, flt(po.total_qty), places=2)
|
||||
# the quantity must roll up to the root too, not just the leaf group
|
||||
self.assertAlmostEqual(
|
||||
rows["All Supplier Groups"]["total"] - base_root_qty, flt(po.total_qty), places=2
|
||||
)
|
||||
@@ -206,6 +206,43 @@ class TestJobCard(FrappeTestCase):
|
||||
# transfer was made for 2 fg qty in first transfer Stock Entry
|
||||
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
|
||||
|
||||
def test_material_request_stock_entry_uses_job_card_coverage(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import make_stock_entry
|
||||
|
||||
self.transfer_material_against = "Job Card"
|
||||
self.source_warehouse = "Stores - _TC"
|
||||
job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
|
||||
mr = make_material_request(job_card.name)
|
||||
mr.schedule_date = today()
|
||||
for row in mr.items:
|
||||
row.qty = flt(row.qty) / 2
|
||||
row.stock_qty = flt(row.stock_qty) / 2
|
||||
mr.submit()
|
||||
|
||||
stock_entry = make_stock_entry(mr.name)
|
||||
self.assertEqual(stock_entry.fg_completed_qty, job_card.for_quantity / 2)
|
||||
|
||||
selected_row = mr.items[0]
|
||||
try:
|
||||
frappe.flags.selected_children = {"items": [selected_row.name]}
|
||||
selected_stock_entry = make_stock_entry(mr.name)
|
||||
finally:
|
||||
frappe.flags.selected_children = None
|
||||
|
||||
self.assertEqual(
|
||||
[row.job_card_item for row in selected_stock_entry.items], [selected_row.job_card_item]
|
||||
)
|
||||
self.assertEqual(selected_stock_entry.fg_completed_qty, 0)
|
||||
|
||||
for row in mr.items:
|
||||
transferred_qty = flt(row.stock_qty) / 2
|
||||
frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", transferred_qty)
|
||||
frappe.db.set_value(row.doctype, row.name, "ordered_qty", transferred_qty)
|
||||
mr.reload()
|
||||
|
||||
repeated_stock_entry = make_stock_entry(mr.name)
|
||||
self.assertEqual(repeated_stock_entry.fg_completed_qty, job_card.for_quantity / 4)
|
||||
|
||||
@change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
|
||||
def test_job_card_excess_material_transfer(self):
|
||||
"Test transferring more than required RM against Job Card."
|
||||
@@ -616,6 +653,7 @@ class TestJobCard(FrappeTestCase):
|
||||
self.assertEqual(ste.job_card, job_card_name)
|
||||
self.assertEqual(ste.from_bom, 1.0)
|
||||
self.assertEqual(ste.bom_no, work_order.bom_no)
|
||||
self.assertEqual(ste.fg_completed_qty, frappe.get_value("Job Card", job_card_name, "for_quantity"))
|
||||
|
||||
def test_job_card_proccess_qty_and_completed_qty(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
|
||||
@@ -546,8 +546,14 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20
|
||||
customer = contact.get_link_for("Customer")
|
||||
|
||||
if customer:
|
||||
sales_invoices = frappe.get_all("Sales Invoice", filters={"customer": customer}, pluck="name")
|
||||
sales_invoices = frappe.get_all(
|
||||
"Sales Invoice",
|
||||
filters={"customer": customer, "docstatus": ["!=", 2]},
|
||||
pluck="name",
|
||||
)
|
||||
projects = frappe.get_all("Project", filters={"customer": customer}, pluck="name")
|
||||
if not (sales_invoices or projects):
|
||||
return []
|
||||
|
||||
# Return timesheet related data to web portal.
|
||||
table = frappe.qb.DocType("Timesheet")
|
||||
@@ -577,10 +583,7 @@ def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20
|
||||
if projects:
|
||||
conditions.append(child_table.project.isin(projects))
|
||||
|
||||
if conditions:
|
||||
query = query.where(frappe.qb.terms.Criterion.any(conditions))
|
||||
|
||||
return query.run(as_dict=True)
|
||||
return query.where(frappe.qb.terms.Criterion.any(conditions)).run(as_dict=True)
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
@@ -982,7 +982,7 @@ erpnext.utils.map_current_doc = function (opts) {
|
||||
|
||||
if (already_set) {
|
||||
frappe.msgprint(
|
||||
__("You have already selected items from {0} {1}", [opts.source_doctype, src])
|
||||
__("You have already selected items from {0} {1}", [__(opts.source_doctype), src])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ from frappe.contacts.address_and_contact import (
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.model.utils.rename_doc import update_linked_doctypes
|
||||
from frappe.utils import cint, cstr, flt, get_formatted_email, today
|
||||
from frappe.utils import cint, cstr, flt, get_formatted_email, get_link_to_form, today
|
||||
from frappe.utils.deprecations import deprecated
|
||||
from frappe.utils.user import get_users_with_role
|
||||
|
||||
@@ -227,10 +227,15 @@ class Customer(TransactionBase):
|
||||
)
|
||||
|
||||
if internal_customer:
|
||||
internal_customer_link = get_link_to_form("Customer", internal_customer)
|
||||
frappe.throw(
|
||||
_("Internal Customer for company {0} already exists").format(
|
||||
frappe.bold(self.represents_company)
|
||||
)
|
||||
_(
|
||||
"Internal Customer {0} already exists for {1}. Disable it to make this Customer internal."
|
||||
).format(
|
||||
internal_customer_link,
|
||||
frappe.bold(self.represents_company),
|
||||
),
|
||||
title=_("Internal Customer Already Exists"),
|
||||
)
|
||||
|
||||
def on_update(self):
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.query_reports["Sales Analytics"] = {
|
||||
// "All" reports on every doctype at once and forces the tree to Customer
|
||||
entity_tree_type() {
|
||||
const doc_type = frappe.query_report.get_filter_value("doc_type");
|
||||
return doc_type === "All" ? "Customer" : frappe.query_report.get_filter_value("tree_type");
|
||||
},
|
||||
reset_entity_filter() {
|
||||
const entity_filter = frappe.query_report.get_filter("entity");
|
||||
if (!entity_filter) return;
|
||||
entity_filter.df.label = __(this.entity_tree_type());
|
||||
entity_filter.set_value([]);
|
||||
entity_filter.refresh();
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
fieldname: "tree_type",
|
||||
@@ -18,6 +30,21 @@ frappe.query_reports["Sales Analytics"] = {
|
||||
],
|
||||
default: "Customer",
|
||||
reqd: 1,
|
||||
on_change: function () {
|
||||
frappe.query_reports["Sales Analytics"].reset_entity_filter();
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "entity",
|
||||
label: __("Entity"),
|
||||
fieldtype: "MultiSelectList",
|
||||
get_data: function (txt) {
|
||||
const tree_type = frappe.query_reports["Sales Analytics"].entity_tree_type();
|
||||
if (!tree_type || tree_type === "Order Type") return [];
|
||||
return frappe.db.get_link_options(tree_type, txt);
|
||||
},
|
||||
depends_on: "eval:doc.tree_type != 'Order Type'",
|
||||
},
|
||||
{
|
||||
fieldname: "doc_type",
|
||||
@@ -26,6 +53,10 @@ frappe.query_reports["Sales Analytics"] = {
|
||||
options: ["Sales Order", "Delivery Note", "Sales Invoice"],
|
||||
default: "Sales Invoice",
|
||||
reqd: 1,
|
||||
on_change: function () {
|
||||
frappe.query_reports["Sales Analytics"].reset_entity_filter();
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "value_quantity",
|
||||
|
||||
@@ -18,6 +18,7 @@ def execute(filters=None):
|
||||
class Analytics:
|
||||
def __init__(self, filters=None):
|
||||
self.filters = frappe._dict(filters or {})
|
||||
self.entities = self.filters.get("entity") or []
|
||||
self.date_field = (
|
||||
"transaction_date"
|
||||
if self.filters.doc_type in ["Sales Order", "Purchase Order"]
|
||||
@@ -61,6 +62,7 @@ class Analytics:
|
||||
self.update_company_list_for_parent_company()
|
||||
self.get_columns()
|
||||
self.get_data()
|
||||
self.filter_data_by_entities()
|
||||
self.get_chart_data()
|
||||
|
||||
# Skipping total row for tree-view reports
|
||||
@@ -325,6 +327,23 @@ class Analytics:
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
def filter_data_by_entities(self):
|
||||
if not self.entities:
|
||||
return
|
||||
|
||||
entities = set(self.entities)
|
||||
selected_data = []
|
||||
for row in self.data:
|
||||
if row["entity"] not in entities:
|
||||
continue
|
||||
|
||||
row = row.copy()
|
||||
if "indent" in row:
|
||||
row["indent"] = 0
|
||||
selected_data.append(row)
|
||||
|
||||
self.data = selected_data
|
||||
|
||||
def get_rows(self):
|
||||
self.data = []
|
||||
self.get_periodic_data()
|
||||
|
||||
253
erpnext/selling/report/sales_analytics/test_sales_analytics.py
Normal file
253
erpnext/selling/report/sales_analytics/test_sales_analytics.py
Normal file
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_analytics.sales_analytics import execute
|
||||
|
||||
# Bootstrap masters reused as-is (see erpnext/tests/utils.py):
|
||||
# "_Test Customer" -> customer_group "_Test Customer Group", territory "_Test Territory"
|
||||
# "_Test Supplier" -> supplier_group "_Test Supplier Group" (child of "All Supplier Groups")
|
||||
# Sales Order.order_type defaults to "Sales" (reqd Select field)
|
||||
COMPANY = "_Test Company"
|
||||
CUSTOMER = "_Test Customer"
|
||||
CUSTOMER_GROUP = "_Test Customer Group"
|
||||
TERRITORY = "_Test Territory"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_GROUP = "_Test Supplier Group"
|
||||
FROM_DATE = "2019-04-01"
|
||||
TO_DATE = "2019-06-30"
|
||||
|
||||
|
||||
class TestSalesAnalytics(FrappeTestCase):
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.created_docs = []
|
||||
# Two submitted Sales Orders for the bootstrap customer inside the report window.
|
||||
# These roll up into the tree roots the converted tree/order-type queries build.
|
||||
self.orders = [
|
||||
self.make_so(qty=5, rate=100, transaction_date="2019-04-10"),
|
||||
self.make_so(qty=3, rate=100, transaction_date="2019-05-15"),
|
||||
]
|
||||
|
||||
def tearDown(self):
|
||||
for doctype, name in reversed(self.created_docs):
|
||||
if not frappe.db.exists(doctype, name):
|
||||
continue
|
||||
|
||||
doc = frappe.get_doc(doctype, name)
|
||||
if doc.docstatus == 1:
|
||||
doc.cancel()
|
||||
frappe.delete_doc(doctype, name, force=True)
|
||||
|
||||
super().tearDown()
|
||||
|
||||
def make_so(self, qty, rate, transaction_date, order_type=None):
|
||||
so = make_sales_order(
|
||||
company=COMPANY,
|
||||
customer=CUSTOMER,
|
||||
qty=qty,
|
||||
rate=rate,
|
||||
transaction_date=transaction_date,
|
||||
do_not_save=True,
|
||||
)
|
||||
# v15's test helper does not populate these hidden analytics dimensions.
|
||||
so.customer_group = CUSTOMER_GROUP
|
||||
so.territory = TERRITORY
|
||||
if order_type:
|
||||
so.order_type = order_type
|
||||
so.insert()
|
||||
so.submit()
|
||||
self.created_docs.append((so.doctype, so.name))
|
||||
return so
|
||||
|
||||
def _base_filters(self, **overrides):
|
||||
filters = {
|
||||
"doc_type": "Sales Order",
|
||||
"value_quantity": "Value",
|
||||
"range": "Monthly",
|
||||
"company": COMPANY,
|
||||
"from_date": FROM_DATE,
|
||||
"to_date": TO_DATE,
|
||||
}
|
||||
filters.update(overrides)
|
||||
return filters
|
||||
|
||||
def _expected_value_total(self):
|
||||
return sum(flt(so.base_net_total) for so in self.orders)
|
||||
|
||||
def _expected_qty_total(self):
|
||||
return sum(flt(so.total_qty) for so in self.orders)
|
||||
|
||||
def _row_by_entity(self, data):
|
||||
return {row["entity"]: row for row in data}
|
||||
|
||||
def test_customer_entity_filter(self):
|
||||
_columns, data, _message, chart, *_rest = execute(
|
||||
self._base_filters(tree_type="Customer", entity=[CUSTOMER], curves="all")
|
||||
)
|
||||
|
||||
self.assertEqual({row["entity"] for row in data}, {CUSTOMER})
|
||||
self.assertAlmostEqual(data[0]["total"], self._expected_value_total(), places=2)
|
||||
self.assertEqual({dataset["name"] for dataset in chart["data"]["datasets"]}, {CUSTOMER})
|
||||
|
||||
def test_parent_customer_group_filter_preserves_rollup(self):
|
||||
_columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group"))
|
||||
_columns, filtered_data, *_rest = execute(
|
||||
self._base_filters(tree_type="Customer Group", entity=["All Customer Groups"])
|
||||
)
|
||||
|
||||
unfiltered = self._row_by_entity(unfiltered_data)
|
||||
filtered = self._row_by_entity(filtered_data)
|
||||
self.assertEqual(set(filtered), {"All Customer Groups"})
|
||||
self.assertAlmostEqual(
|
||||
filtered["All Customer Groups"]["total"],
|
||||
unfiltered["All Customer Groups"]["total"],
|
||||
places=2,
|
||||
)
|
||||
|
||||
def test_customer_group_entity_filter(self):
|
||||
_columns, unfiltered_data, *_rest = execute(self._base_filters(tree_type="Customer Group"))
|
||||
_columns, filtered_data, *_rest = execute(
|
||||
self._base_filters(tree_type="Customer Group", entity=[CUSTOMER_GROUP])
|
||||
)
|
||||
|
||||
unfiltered = self._row_by_entity(unfiltered_data)
|
||||
filtered = self._row_by_entity(filtered_data)
|
||||
self.assertEqual(set(filtered), {CUSTOMER_GROUP})
|
||||
self.assertEqual(filtered[CUSTOMER_GROUP]["indent"], 0)
|
||||
self.assertAlmostEqual(
|
||||
filtered[CUSTOMER_GROUP]["total"], unfiltered[CUSTOMER_GROUP]["total"], places=2
|
||||
)
|
||||
|
||||
def test_customer_group_tree_rolls_up_to_root(self):
|
||||
"""tree_type='Customer Group' drives get_groups (tree get_all ordered by lft)
|
||||
and get_rows_by_group, rolling child values up to the 'All Customer Groups' root."""
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Customer Group"))
|
||||
|
||||
self.assertTrue(columns)
|
||||
self.assertTrue(data)
|
||||
|
||||
rows = self._row_by_entity(data)
|
||||
# The whole tree is returned, so both the root and the customer's own group appear.
|
||||
self.assertIn("All Customer Groups", rows)
|
||||
self.assertIn(CUSTOMER_GROUP, rows)
|
||||
|
||||
expected = self._expected_value_total()
|
||||
self.assertGreater(expected, 0)
|
||||
# Leaf group holds the orders; root receives the same total via roll-up.
|
||||
self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected, places=2)
|
||||
self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected, places=2)
|
||||
# Roots of a tree report sit at indent 0.
|
||||
self.assertEqual(rows["All Customer Groups"]["indent"], 0)
|
||||
|
||||
def test_territory_tree_rolls_up_to_root(self):
|
||||
"""tree_type='Territory' exercises the same tree path against the Territory tree."""
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Territory"))
|
||||
|
||||
self.assertTrue(columns)
|
||||
rows = self._row_by_entity(data)
|
||||
self.assertIn("All Territories", rows)
|
||||
self.assertIn(TERRITORY, rows)
|
||||
|
||||
expected = self._expected_value_total()
|
||||
self.assertAlmostEqual(rows[TERRITORY]["total"], expected, places=2)
|
||||
self.assertAlmostEqual(rows["All Territories"]["total"], expected, places=2)
|
||||
|
||||
def test_order_type_synthetic_tree(self):
|
||||
"""tree_type='Order Type' drives get_teams: distinct order_type rebuilt in Python
|
||||
under a synthetic 'Order Types' root, then rolled up via get_rows_by_group."""
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Order Type"))
|
||||
|
||||
self.assertTrue(columns)
|
||||
rows = self._row_by_entity(data)
|
||||
# Synthetic root plus the default order_type the bootstrap Sales Orders carry.
|
||||
self.assertIn("Order Types", rows)
|
||||
self.assertIn("Sales", rows)
|
||||
self.assertEqual(rows["Order Types"]["indent"], 0)
|
||||
|
||||
expected = self._expected_value_total()
|
||||
self.assertAlmostEqual(rows["Sales"]["total"], expected, places=2)
|
||||
self.assertAlmostEqual(rows["Order Types"]["total"], expected, places=2)
|
||||
|
||||
def test_order_type_leaf_rows_in_sorted_order(self):
|
||||
"""get_teams fetches distinct order_types; frappe drops the SQL ORDER BY for distinct queries on
|
||||
postgres, so the report sorts the order-type rows in python (key=str.casefold) to keep them in a
|
||||
deterministic, case-insensitive order identical on both engines."""
|
||||
for order_type in ("Shopping Cart", "Maintenance", "Sales"): # created out of sorted order
|
||||
self.make_so(
|
||||
qty=1,
|
||||
rate=100,
|
||||
transaction_date="2019-04-12",
|
||||
order_type=order_type,
|
||||
)
|
||||
|
||||
columns, data, *_ = execute(self._base_filters(tree_type="Order Type"))
|
||||
|
||||
mine = {"Sales", "Maintenance", "Shopping Cart"}
|
||||
leaves = [row["entity"] for row in data if row.get("entity") in mine]
|
||||
# the order-type rows must appear in casefold-sorted order on both engines
|
||||
self.assertEqual(leaves, sorted(leaves, key=str.casefold))
|
||||
self.assertEqual(set(leaves), mine)
|
||||
|
||||
def test_customer_group_by_quantity(self):
|
||||
"""value_quantity='Quantity' switches the selected value column (total_qty)."""
|
||||
_columns, data, *_ = execute(
|
||||
self._base_filters(tree_type="Customer Group", value_quantity="Quantity")
|
||||
)
|
||||
|
||||
rows = self._row_by_entity(data)
|
||||
self.assertIn(CUSTOMER_GROUP, rows)
|
||||
|
||||
expected_qty = self._expected_qty_total()
|
||||
self.assertGreater(expected_qty, 0)
|
||||
self.assertAlmostEqual(rows[CUSTOMER_GROUP]["total"], expected_qty, places=2)
|
||||
self.assertAlmostEqual(rows["All Customer Groups"]["total"], expected_qty, places=2)
|
||||
|
||||
def test_supplier_group_tree_maps_supplier_to_group(self):
|
||||
"""tree_type='Supplier Group' (doc_type='Purchase Order') exercises
|
||||
get_supplier_parent_child_map: the query selects 'supplier' as entity, then
|
||||
get_periodic_data remaps each supplier to its group via the parent->child map
|
||||
built by frappe.get_all('Supplier', ['name', 'supplier_group'], as_list=True).
|
||||
The group total then rolls up into the 'All Supplier Groups' root."""
|
||||
# Baseline the report before adding our Purchase Order so the assertion is
|
||||
# robust to any pre-existing rows in the historical window.
|
||||
base_filters = self._base_filters(tree_type="Supplier Group", doc_type="Purchase Order")
|
||||
_columns, base_data, *_ = execute(base_filters)
|
||||
base_rows = self._row_by_entity(base_data)
|
||||
base_group_total = flt(base_rows.get(SUPPLIER_GROUP, {}).get("total", 0.0))
|
||||
|
||||
po = create_purchase_order(
|
||||
company=COMPANY,
|
||||
supplier=SUPPLIER,
|
||||
qty=4,
|
||||
rate=250,
|
||||
transaction_date="2019-04-10",
|
||||
)
|
||||
self.created_docs.append((po.doctype, po.name))
|
||||
po_value = flt(po.base_net_total)
|
||||
self.assertGreater(po_value, 0)
|
||||
|
||||
columns, data, *_ = execute(base_filters)
|
||||
|
||||
self.assertTrue(columns)
|
||||
self.assertTrue(data)
|
||||
|
||||
rows = self._row_by_entity(data)
|
||||
# The supplier was remapped to its group; both the leaf group and the tree
|
||||
# root appear as entities (no raw supplier name leaks into the output).
|
||||
self.assertIn(SUPPLIER_GROUP, rows)
|
||||
self.assertIn("All Supplier Groups", rows)
|
||||
self.assertNotIn(SUPPLIER, rows)
|
||||
# Roots of a tree report sit at indent 0.
|
||||
self.assertEqual(rows["All Supplier Groups"]["indent"], 0)
|
||||
|
||||
# The new PO lands in the supplier's group via the parent->child map.
|
||||
self.assertAlmostEqual(rows[SUPPLIER_GROUP]["total"] - base_group_total, po_value, places=2)
|
||||
# Roll-up: the root aggregates every group, so it covers at least this PO.
|
||||
self.assertGreaterEqual(flt(rows["All Supplier Groups"]["total"]), po_value)
|
||||
@@ -28,6 +28,68 @@ class TestBin(FrappeTestCase):
|
||||
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_repost_resets_bin_without_sle(self):
|
||||
"""A repost must zero the bin when the ledger is empty, e.g. after entries were deleted."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.stock_ledger import update_entries_after
|
||||
|
||||
item_code = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
|
||||
|
||||
# deleting a transaction with `delete_linked_ledger_entries` on drops its entries outright
|
||||
frappe.db.delete("Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse})
|
||||
|
||||
update_entries_after(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": "1900-01-01",
|
||||
"posting_time": "00:01",
|
||||
}
|
||||
)
|
||||
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 0)
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_cancelling_last_entry_resets_bin(self):
|
||||
"""Cancelling the only voucher must clear stock value, not just quantity."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item_code = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
|
||||
|
||||
se.cancel()
|
||||
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 0)
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_deleting_last_voucher_resets_bin(self):
|
||||
"""Deleting the only voucher wipes its ledger entries outright, the bin must still be cleared."""
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
item_code = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
delete_entries = frappe.get_single_value("Accounts Settings", "delete_linked_ledger_entries")
|
||||
frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 1)
|
||||
|
||||
try:
|
||||
se = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=100)
|
||||
se.cancel()
|
||||
frappe.delete_doc("Stock Entry", se.name, force=1)
|
||||
finally:
|
||||
frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", delete_entries)
|
||||
|
||||
bin = frappe.get_doc("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
self.assertEqual(bin.actual_qty, 0)
|
||||
self.assertEqual(bin.valuation_rate, 0)
|
||||
self.assertEqual(bin.stock_value, 0)
|
||||
|
||||
def test_index_exists(self):
|
||||
indexes = frappe.db.sql("show index from tabBin where Non_unique = 0", as_dict=1)
|
||||
if not any(index.get("Key_name") == "unique_item_warehouse" for index in indexes):
|
||||
|
||||
@@ -791,6 +791,8 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
target.fg_completed_qty = job_card_details[0].for_quantity
|
||||
target.from_bom = 1
|
||||
|
||||
target.cap_completed_qty_to_material_coverage()
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Material Request",
|
||||
source_name,
|
||||
|
||||
@@ -1205,17 +1205,26 @@ class StockEntry(StockController):
|
||||
if transfer_limit_qty < to_transfer_qty:
|
||||
return
|
||||
|
||||
required_qty, transferred_qty = self._get_work_order_material_qty()
|
||||
self.cap_completed_qty_to_material_coverage()
|
||||
|
||||
def cap_completed_qty_to_material_coverage(self):
|
||||
required_qty, transferred_qty, target_qty, precision = self._get_material_coverage_data()
|
||||
if not required_qty:
|
||||
return
|
||||
|
||||
covered_before = self._get_covered_work_order_qty(required_qty, transferred_qty)
|
||||
covered_before = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
|
||||
for row in self.items:
|
||||
item_code = row.original_item or row.item_code
|
||||
if row.s_warehouse and item_code in required_qty:
|
||||
transferred_qty[item_code] += flt(row.qty) * flt(row.conversion_factor or 1)
|
||||
if self.job_card:
|
||||
material_reference = row.job_card_item
|
||||
transferred = flt(row.qty)
|
||||
else:
|
||||
material_reference = row.original_item or row.item_code
|
||||
transferred = flt(row.qty) * flt(row.conversion_factor or 1)
|
||||
|
||||
covered_after = self._get_covered_work_order_qty(required_qty, transferred_qty)
|
||||
if material_reference in required_qty and (self.job_card or row.s_warehouse):
|
||||
transferred_qty[material_reference] += transferred
|
||||
|
||||
covered_after = self._get_covered_qty(required_qty, transferred_qty, target_qty, precision)
|
||||
covered_by_entry = flt(max(covered_after - covered_before, 0), self.precision("fg_completed_qty"))
|
||||
self.fg_completed_qty = min(flt(self.fg_completed_qty), covered_by_entry)
|
||||
|
||||
@@ -1230,6 +1239,49 @@ class StockEntry(StockController):
|
||||
return False
|
||||
return not (self.pro_doc.operations and self.pro_doc.transfer_material_against == "Job Card")
|
||||
|
||||
def _get_material_coverage_data(self):
|
||||
if self.job_card:
|
||||
return self._get_job_card_material_qty()
|
||||
return self._get_work_order_material_qty()
|
||||
|
||||
def _get_job_card_material_qty(self):
|
||||
job_card = frappe.get_doc("Job Card", self.job_card)
|
||||
required_qty = {}
|
||||
transferred_qty = {}
|
||||
for row in job_card.items:
|
||||
if flt(row.required_qty) <= 0:
|
||||
continue
|
||||
required_qty[row.name] = flt(row.required_qty)
|
||||
transferred_qty[row.name] = flt(row.transferred_qty)
|
||||
|
||||
return (
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self._get_job_card_target_qty(job_card),
|
||||
job_card.precision("required_qty", "items"),
|
||||
)
|
||||
|
||||
def _get_job_card_target_qty(self, job_card):
|
||||
required_by_item = {}
|
||||
for row in job_card.items:
|
||||
required_by_item[row.item_code] = required_by_item.get(row.item_code, 0.0) + flt(row.required_qty)
|
||||
|
||||
work_order_required_by_item = {}
|
||||
work_order = frappe.get_doc("Work Order", job_card.work_order)
|
||||
for row in work_order.required_items:
|
||||
if job_card.operation != row.operation:
|
||||
continue
|
||||
work_order_required_by_item[row.item_code] = work_order_required_by_item.get(
|
||||
row.item_code, 0.0
|
||||
) + flt(row.required_qty)
|
||||
|
||||
target_qty = [
|
||||
item_required * flt(work_order.qty) / work_order_required_by_item[item_code]
|
||||
for item_code, item_required in required_by_item.items()
|
||||
if work_order_required_by_item.get(item_code)
|
||||
]
|
||||
return min(target_qty) if target_qty else job_card.for_quantity
|
||||
|
||||
def _get_work_order_material_qty(self):
|
||||
required_qty = {}
|
||||
transferred_qty = {}
|
||||
@@ -1241,15 +1293,20 @@ class StockEntry(StockController):
|
||||
transferred_qty[row.item_code] = max(
|
||||
transferred_qty.get(row.item_code, 0.0), flt(row.transferred_qty)
|
||||
)
|
||||
return required_qty, transferred_qty
|
||||
return (
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self.pro_doc.qty,
|
||||
self.pro_doc.precision("required_qty", "required_items"),
|
||||
)
|
||||
|
||||
def _get_covered_work_order_qty(self, required_qty, transferred_qty):
|
||||
def _get_covered_qty(self, required_qty, transferred_qty, target_qty, precision):
|
||||
min_fraction = get_minimum_material_coverage_fraction(
|
||||
required_qty,
|
||||
transferred_qty,
|
||||
self.pro_doc.precision("required_qty", "required_items"),
|
||||
precision,
|
||||
)
|
||||
return min_fraction * flt(self.pro_doc.qty)
|
||||
return min_fraction * flt(target_qty)
|
||||
|
||||
def _validate_no_excess_transfer(self):
|
||||
if self.is_return:
|
||||
|
||||
@@ -1750,6 +1750,30 @@ class update_entries_after:
|
||||
|
||||
frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True)
|
||||
|
||||
self.reset_bin_without_stock_ledger_entries()
|
||||
|
||||
def reset_bin_without_stock_ledger_entries(self):
|
||||
"""Reset the bin when its ledger has no entries left, a repost never covers that case."""
|
||||
item_code, warehouse = self.args.get("item_code"), self.args.get("warehouse")
|
||||
if not item_code or not warehouse or (item_code, warehouse) in self.prev_sle_dict:
|
||||
return
|
||||
|
||||
if frappe.db.exists(
|
||||
"Stock Ledger Entry", {"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}
|
||||
):
|
||||
return
|
||||
|
||||
bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
|
||||
if not bin_name:
|
||||
return
|
||||
|
||||
frappe.db.set_value(
|
||||
"Bin",
|
||||
bin_name,
|
||||
{"actual_qty": 0.0, "stock_value": 0.0, "valuation_rate": 0.0},
|
||||
update_modified=True,
|
||||
)
|
||||
|
||||
|
||||
def get_sle_against_current_voucher(kwargs):
|
||||
kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time)
|
||||
|
||||
Reference in New Issue
Block a user