diff --git a/erpnext/accounts/report/cheques_and_deposits_incorrectly_cleared/cheques_and_deposits_incorrectly_cleared.py b/erpnext/accounts/report/cheques_and_deposits_incorrectly_cleared/cheques_and_deposits_incorrectly_cleared.py index 891dc2c4bb1..85245d2f89a 100644 --- a/erpnext/accounts/report/cheques_and_deposits_incorrectly_cleared/cheques_and_deposits_incorrectly_cleared.py +++ b/erpnext/accounts/report/cheques_and_deposits_incorrectly_cleared/cheques_and_deposits_incorrectly_cleared.py @@ -3,7 +3,7 @@ import frappe from frappe import _, qb -from frappe.query_builder import CustomFunction +from frappe.query_builder import Case from frappe.query_builder.custom import ConstantColumn @@ -93,7 +93,6 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter .run(as_dict=1) ) - ifelse = CustomFunction("IF", ["condition", "then", "else"]) pe = qb.DocType("Payment Entry") doctype_name = ConstantColumn("Payment Entry") payments = ( @@ -101,7 +100,10 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter .select( doctype_name.as_("doctype"), pe.name, - ifelse(pe.paid_from.eq(filters.account), pe.paid_amount, pe.received_amount).as_("amount"), + Case() + .when(pe.paid_from.eq(filters.account), pe.paid_amount) + .else_(pe.received_amount) + .as_("amount"), pe.payment_type, pe.party_type, pe.posting_date, diff --git a/erpnext/accounts/report/cheques_and_deposits_incorrectly_cleared/test_cheques_and_deposits_incorrectly_cleared.py b/erpnext/accounts/report/cheques_and_deposits_incorrectly_cleared/test_cheques_and_deposits_incorrectly_cleared.py new file mode 100644 index 00000000000..9c18880dcd5 --- /dev/null +++ b/erpnext/accounts/report/cheques_and_deposits_incorrectly_cleared/test_cheques_and_deposits_incorrectly_cleared.py @@ -0,0 +1,24 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.utils import nowdate + +from erpnext.accounts.report.cheques_and_deposits_incorrectly_cleared.cheques_and_deposits_incorrectly_cleared import ( + execute, +) +from erpnext.tests.utils import ERPNextTestSuite + + +class TestChequesAndDepositsIncorrectlyCleared(ERPNextTestSuite): + def test_report_executes_with_case_amount(self): + # Exercises the Payment Entry branch whose amount column uses a db-aware CASE expression + # (previously a MySQL-only IF()). IF() does not compile on postgres, so running the report + # query guards the portability fix on both databases. + company = frappe.db.get_value("Company", {}, "name") + account = frappe.db.get_value( + "Account", {"account_type": "Bank", "company": company, "is_group": 0}, "name" + ) + columns, data = execute(frappe._dict({"account": account, "report_date": nowdate()})) + self.assertTrue(columns) + self.assertIsInstance(data, list) diff --git a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py index 6f90cb13398..59811f9ebd3 100644 --- a/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py +++ b/erpnext/accounts/report/inactive_sales_items/inactive_sales_items.py @@ -4,7 +4,7 @@ import frappe from frappe import _ -from frappe.query_builder import CustomFunction +from frappe.query_builder.functions import CurDate, DateDiff from frappe.utils import cint @@ -102,11 +102,11 @@ def get_sales_details(filters): child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item" child = frappe.qb.DocType(child_doctype) - date_diff = CustomFunction("DATEDIFF", ["d1", "d2"]) - current_date = CustomFunction("CURRENT_DATE", []) - date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date - days_since_last_order = date_diff(current_date(), date_col) + + # DateDiff is cross-database (DATEDIFF on MariaDB, date subtraction on postgres); CurDate() + # renders the bare CURRENT_DATE keyword. Yields the integer number of days. + days_since_last_order = DateDiff(CurDate(), date_col) sales_data = ( frappe.qb.from_(parent) diff --git a/erpnext/accounts/report/inactive_sales_items/test_inactive_sales_items.py b/erpnext/accounts/report/inactive_sales_items/test_inactive_sales_items.py new file mode 100644 index 00000000000..9c40c3ae7ce --- /dev/null +++ b/erpnext/accounts/report/inactive_sales_items/test_inactive_sales_items.py @@ -0,0 +1,32 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.utils import add_days, today + +from erpnext.accounts.report.inactive_sales_items.inactive_sales_items import execute +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.tests.utils import ERPNextTestSuite + + +class TestInactiveSalesItems(ERPNextTestSuite): + def test_days_since_last_order_is_computed(self): + # Exercises the date-arithmetic path (DATEDIFF/CURRENT_DATE on mariadb, date subtraction on + # postgres) which must produce the same integer day count on both databases. + item = make_item("_Test Inactive Sales Item").name + old_date = add_days(today(), -120) + so = make_sales_order(item=item, qty=3, rate=150, transaction_date=old_date) + so.items[0].delivery_date = add_days(old_date, 7) + so.save() + so.submit() + + columns, data = execute(frappe._dict({"based_on": "Sales Order", "days": 30})) + self.assertTrue(columns) + row = next((r for r in data if r.get("item") == item and r.get("days_since_last_order")), None) + self.assertIsNotNone(row, "Inactive item should appear in the report") + self.assertGreaterEqual(row["days_since_last_order"], 30) + + def test_report_runs_for_sales_invoice(self): + columns, _data = execute(frappe._dict({"based_on": "Sales Invoice", "days": 30})) + self.assertTrue(columns) diff --git a/erpnext/buying/doctype/supplier_scorecard_variable/supplier_scorecard_variable.py b/erpnext/buying/doctype/supplier_scorecard_variable/supplier_scorecard_variable.py index d7edd57b18a..99b780375fa 100644 --- a/erpnext/buying/doctype/supplier_scorecard_variable/supplier_scorecard_variable.py +++ b/erpnext/buying/doctype/supplier_scorecard_variable/supplier_scorecard_variable.py @@ -7,7 +7,7 @@ import sys import frappe from frappe import _ from frappe.model.document import Document -from frappe.query_builder.functions import Sum +from frappe.query_builder.functions import DateDiff, Sum from frappe.utils import getdate @@ -68,7 +68,7 @@ def get_item_workdays(scorecard): frappe.qb.from_(PO_Item) .join(PO) .on(PO_Item.parent == PO.name) - .select(Sum(frappe.qb.fn.DATEDIFF(scorecard.end_date, PO_Item.schedule_date) * (PO_Item.qty))) + .select(Sum(DateDiff(scorecard.end_date, PO_Item.schedule_date) * (PO_Item.qty))) .where(PO.supplier == scorecard.supplier) .where(PO_Item.received_qty < PO_Item.qty) .where(PO_Item.schedule_date[scorecard.start_date : scorecard.end_date]) # Équivalent du BETWEEN @@ -153,7 +153,7 @@ def get_total_days_late(scorecard): .on(PR_Item.purchase_order_item == PO_Item.name) .join(PO) .on(PO_Item.parent == PO.name) - .select(Sum(frappe.qb.fn.DATEDIFF(PR.posting_date, PO_Item.schedule_date) * PR_Item.qty)) + .select(Sum(DateDiff(PR.posting_date, PO_Item.schedule_date) * PR_Item.qty)) .where(PO.supplier == scorecard.supplier) .where(PO_Item.schedule_date[scorecard.start_date : scorecard.end_date]) .where(PO_Item.schedule_date < PR.posting_date) @@ -170,10 +170,7 @@ def get_total_days_late(scorecard): .join(PO) .on(PO_Item.parent == PO.name) .select( - Sum( - frappe.qb.fn.DATEDIFF(scorecard.end_date, PO_Item.schedule_date) - * (PO_Item.qty - PO_Item.received_qty) - ) + Sum(DateDiff(scorecard.end_date, PO_Item.schedule_date) * (PO_Item.qty - PO_Item.received_qty)) ) .where(PO.supplier == scorecard.supplier) .where(PO_Item.received_qty < PO_Item.qty) @@ -530,7 +527,7 @@ def get_rfq_response_days(scorecard): .on(sq_item.request_for_quotation_item == rfq_item.name) .join(sq) .on(sq_item.parent == sq.name) - .select(frappe.qb.fn.Sum(frappe.qb.fn.Datediff(sq.transaction_date, rfq.transaction_date))) + .select(frappe.qb.fn.Sum(DateDiff(sq.transaction_date, rfq.transaction_date))) .where(rfq_sup.supplier == scorecard.supplier) .where(sq.supplier == scorecard.supplier) .where(rfq.transaction_date[scorecard.start_date : scorecard.end_date]) diff --git a/erpnext/crm/report/lost_opportunity/lost_opportunity.py b/erpnext/crm/report/lost_opportunity/lost_opportunity.py index cfbee3901e2..03dc634589a 100644 --- a/erpnext/crm/report/lost_opportunity/lost_opportunity.py +++ b/erpnext/crm/report/lost_opportunity/lost_opportunity.py @@ -5,8 +5,7 @@ import frappe from frappe import _ from frappe.query_builder import DocType -from frappe.query_builder.custom import GROUP_CONCAT -from frappe.query_builder.functions import Date +from frappe.query_builder.functions import Date, GroupConcat Opportunity = DocType("Opportunity") OpportunityLostReasonDetail = DocType("Opportunity Lost Reason Detail") @@ -72,6 +71,9 @@ def get_columns(): def get_data(filters): + # db-aware GROUP_CONCAT (MariaDB) / STRING_AGG (postgres) with a ", " separator + lost_reasons = GroupConcat(OpportunityLostReasonDetail.lost_reason, ", ", alias="lost_reason") + query = ( frappe.qb.from_(Opportunity) .left_join(OpportunityLostReasonDetail) @@ -85,7 +87,7 @@ def get_data(filters): Opportunity.party_name, Opportunity.customer_name, Opportunity.opportunity_type, - GROUP_CONCAT(OpportunityLostReasonDetail.lost_reason, alias="lost_reason").separator(", "), + lost_reasons, Opportunity.sales_stage, Opportunity.territory, ) diff --git a/erpnext/crm/report/lost_opportunity/test_lost_opportunity.py b/erpnext/crm/report/lost_opportunity/test_lost_opportunity.py new file mode 100644 index 00000000000..3183f45cf87 --- /dev/null +++ b/erpnext/crm/report/lost_opportunity/test_lost_opportunity.py @@ -0,0 +1,22 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe.utils import add_days, today + +from erpnext.crm.report.lost_opportunity.lost_opportunity import execute +from erpnext.tests.utils import ERPNextTestSuite + + +class TestLostOpportunity(ERPNextTestSuite): + def test_report_aggregates_lost_reasons(self): + # Exercises the db-aware GROUP_CONCAT (MariaDB) / STRING_AGG (postgres) aggregation of the + # child "Opportunity Lost Reason Detail" rows. The MySQL-only GROUP_CONCAT term would fail to + # compile on postgres, so simply running the report query guards the portability fix on both + # databases. + company = frappe.db.get_value("Company", {}, "name") + columns, data = execute( + frappe._dict({"company": company, "from_date": add_days(today(), -365), "to_date": today()}) + ) + self.assertTrue(columns) + self.assertIsInstance(data, list) diff --git a/erpnext/manufacturing/doctype/work_order/services/operations.py b/erpnext/manufacturing/doctype/work_order/services/operations.py index e3249c31204..26bd7ee73e5 100644 --- a/erpnext/manufacturing/doctype/work_order/services/operations.py +++ b/erpnext/manufacturing/doctype/work_order/services/operations.py @@ -11,6 +11,7 @@ are called from other modules. import frappe from dateutil.relativedelta import relativedelta from frappe import _ +from frappe.query_builder.functions import CombineDatetime from frappe.utils import ( cint, date_diff, @@ -268,13 +269,17 @@ class OperationsService: self.doc.actual_end_date = max(end_dates) def _set_dates_from_stock_entries(self): - data = frappe.get_all( - "Stock Entry", - fields=[{"TIMESTAMP": ["posting_date", "posting_time"], "as": "posting_datetime"}], - filters={ - "work_order": self.doc.name, - "purpose": ("in", ["Material Transfer for Manufacture", "Manufacture"]), - }, + # {"TIMESTAMP": [...]} renders MySQL's TIMESTAMP(date, time), invalid on postgres; use the + # portable CombineDatetime via query builder instead. + se = frappe.qb.DocType("Stock Entry") + data = ( + frappe.qb.from_(se) + .select(CombineDatetime(se.posting_date, se.posting_time).as_("posting_datetime")) + .where( + (se.work_order == self.doc.name) + & (se.purpose.isin(["Material Transfer for Manufacture", "Manufacture"])) + ) + .run(as_dict=True) ) if not data: return