refactor(postgres): port Projects module queries to the query builder

Convert raw `frappe.db.sql` across the Projects module to `frappe.qb` / the ORM
so the same code runs on MariaDB and Postgres. Behaviour is preserved on
MariaDB; the conversions also make these paths valid under Postgres' stricter
SQL (GROUP BY, case-sensitivity, empty-string handling).

Conversions of note (behaviour kept identical to the MariaDB original):
- project.get_users_for_project: search selects the stored full_name instead of
  concat_ws(first, middle, last) (concat_ws diverges on Postgres, where empty
  Data fields are NULL) and wraps Locate in LOWER() to keep MariaDB's
  case-insensitive result ordering.
- project costing: percent-complete and sales/billed-amount aggregates rebuilt
  as Sum() query-builder selects.
- task.reschedule_dependent_tasks: the correlated subquery is split into a
  `Task Depends On` parent-pluck + a Task lookup (same rows, no nested SQL).
- timesheet.get_events: user-permission match conditions move to the query-builder
  form via get_event_conditions_qb; calendar columns rebuilt with Concat/Round.
- report/project_wise_stock_tracking & report/daily_timesheet_summary: GROUP BY
  cost aggregates and the timesheet date window (timestamp(to_date,'24:00:00') ->
  end-of-day via get_combine_datetime) rebuilt to satisfy Postgres.
- search helpers (query_task, get_project, get_timesheet) use frappe.qb.get_query
  with ignore_permissions=False in place of build_match_conditions/get_match_cond.

Tests (run on both MariaDB and Postgres, --lightmode):
- Existing project/task/timesheet/activity_cost suites kept green (27 tests).
- New project_wise_stock_tracking test drives all three cost aggregates with
  positive data (purchased / issued / delivered GROUP BY) plus get_project_details.
- New daily_timesheet_summary test covers the date-window join.

Not included: project_update.py is deferred. Its daily_reminder()/email_sending()
select `progress`/`progress_details`, columns that do not exist on the Project
Update doctype, so the function errors when invoked regardless of backend - a
pre-existing bug that needs an email-rework, not just a query port.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mihir Kandoi
2026-06-19 15:22:20 +05:30
parent 02f7cba20a
commit ea665d1a9b
9 changed files with 434 additions and 270 deletions

View File

@@ -43,9 +43,13 @@ class ActivityCost(Document):
def check_unique(self):
if self.employee:
if frappe.db.sql(
"""select name from `tabActivity Cost` where employee_name= %s and activity_type= %s and name != %s""",
(self.employee_name, self.activity_type, self.name),
if frappe.db.exists(
"Activity Cost",
{
"employee_name": self.employee_name,
"activity_type": self.activity_type,
"name": ["!=", self.name],
},
):
frappe.throw(
_("Activity Cost exists for Employee {0} against Activity Type - {1}").format(
@@ -54,9 +58,13 @@ class ActivityCost(Document):
DuplicationError,
)
else:
if frappe.db.sql(
"""select name from `tabActivity Cost` where ifnull(employee, '')='' and activity_type= %s and name != %s""",
(self.activity_type, self.name),
if frappe.db.exists(
"Activity Cost",
{
"employee": ["is", "not set"],
"activity_type": self.activity_type,
"name": ["!=", self.name],
},
):
frappe.throw(
_("Default Activity Cost exists for Activity Type - {0}").format(self.activity_type),

View File

@@ -4,15 +4,14 @@
import frappe
from email_reply_parser import EmailReplyParser
from frappe import _, qb
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
from frappe.query_builder import Interval
from frappe.query_builder.functions import Count, CurDate, Date, Sum, UnixTimestamp
from frappe.query_builder import Case, Interval
from frappe.query_builder.functions import Count, CurDate, Date, Locate, Lower, Sum, UnixTimestamp
from frappe.utils import add_days, flt, get_datetime, get_link_to_form, get_time, nowtime, today
from frappe.utils.user import is_website_user
from pypika import Order
from erpnext import get_default_company
from erpnext.controllers.queries import get_filters_cond
from erpnext.controllers.website_list_for_contact import get_customers_suppliers
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
@@ -74,16 +73,15 @@ class Project(Document):
# end: auto-generated types
def onload(self):
timesheet_detail = frappe.qb.DocType("Timesheet Detail")
self.set_onload(
"activity_summary",
frappe.db.sql(
"""select activity_type,
sum(hours) as total_hours
from `tabTimesheet Detail` where project=%s and docstatus < 2 group by activity_type
order by total_hours desc""",
self.name,
as_dict=True,
),
frappe.qb.from_(timesheet_detail)
.select(timesheet_detail.activity_type, Sum(timesheet_detail.hours).as_("total_hours"))
.where((timesheet_detail.project == self.name) & (timesheet_detail.docstatus < 2))
.groupby(timesheet_detail.activity_type)
.orderby("total_hours", order=frappe.qb.desc)
.run(as_dict=True),
)
def before_print(self, settings=None):
@@ -102,7 +100,7 @@ class Project(Document):
"""
Copy tasks from template
"""
if self.project_template and not frappe.db.get_all("Task", dict(project=self.name), limit=1):
if self.project_template and not frappe.db.exists("Task", {"project": self.name}):
# has a template, and no loaded tasks, so lets create
if not self.expected_start_date:
# project starts today
@@ -267,32 +265,25 @@ class Project(Document):
if (self.percent_complete_method == "Task Completion" and total > 0) or (
not self.percent_complete_method and total > 0
):
completed = frappe.db.sql(
"""select count(name) from tabTask where
project=%s and status in ('Cancelled', 'Completed')""",
self.name,
)[0][0]
completed = frappe.db.count(
"Task", {"project": self.name, "status": ["in", ["Cancelled", "Completed"]]}
)
self.percent_complete = flt(flt(completed) / total * 100, 2)
if self.percent_complete_method == "Task Progress" and total > 0:
progress = frappe.db.sql(
"""select sum(progress) from tabTask where
project=%s""",
self.name,
task = frappe.qb.DocType("Task")
progress = (
frappe.qb.from_(task).select(Sum(task.progress)).where(task.project == self.name).run()
)[0][0]
self.percent_complete = flt(flt(progress) / total, 2)
if self.percent_complete_method == "Task Weight" and total > 0:
weight_sum = frappe.db.sql(
"""select sum(task_weight) from tabTask where
project=%s""",
self.name,
task = frappe.qb.DocType("Task")
weight_sum = (
frappe.qb.from_(task).select(Sum(task.task_weight)).where(task.project == self.name).run()
)[0][0]
weighted_progress = frappe.db.sql(
"""select progress, task_weight from tabTask where
project=%s""",
self.name,
as_dict=1,
weighted_progress = frappe.get_all(
"Task", filters={"project": self.name}, fields=["progress", "task_weight"]
)
pct_complete = 0
for row in weighted_progress:
@@ -353,10 +344,12 @@ class Project(Document):
self.total_purchase_cost = total_purchase_cost and total_purchase_cost[0][0] or 0
def update_sales_amount(self):
total_sales_amount = frappe.db.sql(
"""select sum(base_net_total)
from `tabSales Order` where project = %s and docstatus=1""",
self.name,
so = frappe.qb.DocType("Sales Order")
total_sales_amount = (
frappe.qb.from_(so)
.select(Sum(so.base_net_total))
.where((so.project == self.name) & (so.docstatus == 1))
.run()
)
self.total_sales_amount = total_sales_amount and total_sales_amount[0][0] or 0
@@ -365,25 +358,31 @@ class Project(Document):
self.total_billed_amount = self.get_billed_amount_from_parent() + self.get_billed_amount_from_child()
def get_billed_amount_from_parent(self):
total_billed_amount = frappe.db.sql(
"""select sum(base_net_amount)
from `tabSales Invoice` si join `tabSales Invoice Item` si_item on si_item.parent = si.name
where si_item.project is null
and si.project is not null
and si.project = %s
and si.docstatus = 1""",
self.name,
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
total_billed_amount = (
frappe.qb.from_(si)
.join(si_item)
.on(si_item.parent == si.name)
.select(Sum(si_item.base_net_amount))
.where(
si_item.project.isnull()
& si.project.isnotnull()
& (si.project == self.name)
& (si.docstatus == 1)
)
.run()
)
return total_billed_amount and total_billed_amount[0][0] or 0
def get_billed_amount_from_child(self):
total_billed_amount = frappe.db.sql(
"""select sum(base_net_amount)
from `tabSales Invoice Item`
where project = %s
and docstatus = 1""",
self.name,
si_item = frappe.qb.DocType("Sales Invoice Item")
total_billed_amount = (
frappe.qb.from_(si_item)
.select(Sum(si_item.base_net_amount))
.where((si_item.project == self.name) & (si_item.docstatus == 1))
.run()
)
return total_billed_amount and total_billed_amount[0][0] or 0
@@ -499,28 +498,43 @@ def get_list_context(context=None):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_users_for_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
conditions = []
return frappe.db.sql(
"""select name, concat_ws(' ', first_name, middle_name, last_name)
from `tabUser`
where enabled=1
and name not in ("Guest", "Administrator")
and ({key} like %(txt)s
or full_name like %(txt)s)
{fcond} {mcond}
order by
(case when locate(%(_txt)s, name) > 0 then locate(%(_txt)s, name) else 99999 end),
(case when locate(%(_txt)s, full_name) > 0 then locate(%(_txt)s, full_name) else 99999 end),
idx desc,
name, full_name
limit %(page_len)s offset %(start)s""".format(
**{
"key": searchfield,
"fcond": get_filters_cond(doctype, filters, conditions),
"mcond": get_match_cond(doctype),
}
),
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
User = frappe.qb.DocType("User")
search_str = f"%{txt}%"
txt_no_percent = txt.replace("%", "")
query = frappe.qb.get_query(
"User",
fields=["name", "full_name"],
filters=filters,
ignore_permissions=False,
)
return (
query.where(User.enabled == 1)
.where(User.name.notin(["Guest", "Administrator"]))
.where(User[searchfield].like(search_str) | User.full_name.like(search_str))
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(User.name)) > 0,
Locate(Lower(txt_no_percent), Lower(User.name)),
)
.else_(99999)
)
.orderby(
Case()
.when(
Locate(Lower(txt_no_percent), Lower(User.full_name)) > 0,
Locate(Lower(txt_no_percent), Lower(User.full_name)),
)
.else_(99999)
)
.orderby(User.idx, order=Order.desc)
.orderby(User.name)
.orderby(User.full_name)
.limit(page_len)
.offset(start)
.run()
)
@@ -580,11 +594,7 @@ def weekly_reminder():
def allow_to_make_project_update(project, time, frequency):
data = frappe.db.sql(
""" SELECT name from `tabProject Update`
WHERE project = %s and date = %s """,
(project, today()),
)
data = frappe.get_all("Project Update", filters={"project": project, "date": today()}, pluck="name")
# len(data) > 1 condition is checked for twicely frequency
if data and (frequency in ["Daily", "Weekly"] or len(data) > 1):

View File

@@ -76,10 +76,9 @@ class Task(NestedSet):
nsm_parent_field = "parent_task"
def get_customer_details(self):
cust = frappe.db.sql("select customer_name from `tabCustomer` where name=%s", self.customer)
if cust:
ret = {"customer_name": cust and cust[0][0] or ""}
return ret
customer_name = frappe.db.get_value("Customer", self.customer, "customer_name")
if customer_name:
return {"customer_name": customer_name or ""}
def validate(self):
self.validate_dates()
@@ -252,9 +251,11 @@ class Task(NestedSet):
for d in check_list:
task_list, count = [self.name], 0
while len(task_list) > count:
tasks = frappe.db.sql(
" select {} from `tabTask Depends On` where {} = {} ".format(d[0], d[1], "%s"),
cstr(task_list[count]),
tasks = frappe.get_all(
"Task Depends On",
filters={d[1]: cstr(task_list[count])},
fields=[d[0]],
as_list=True,
)
count = count + 1
for b in tasks:
@@ -268,30 +269,34 @@ class Task(NestedSet):
def reschedule_dependent_tasks(self):
end_date = self.exp_end_date or self.act_end_date
if end_date:
for task_name in frappe.db.sql(
"""
select name from `tabTask` as parent
where parent.project = %(project)s
and parent.name in (
select parent from `tabTask Depends On` as child
where child.task = %(task)s and child.project = %(project)s)
""",
{"project": self.project, "task": self.name},
as_dict=1,
if not end_date:
return
dependent_parents = frappe.get_all(
"Task Depends On",
filters={"task": self.name, "project": self.project},
pluck="parent",
)
if not dependent_parents:
return
for task_name in frappe.get_all(
"Task",
filters={"project": self.project, "name": ["in", dependent_parents]},
pluck="name",
):
task = frappe.get_doc("Task", task_name)
if (
task.exp_start_date
and task.exp_end_date
and task.exp_start_date < end_date
and task.status == "Open"
):
task = frappe.get_doc("Task", task_name.name)
if (
task.exp_start_date
and task.exp_end_date
and task.exp_start_date < end_date
and task.status == "Open"
):
task_duration = date_diff(task.exp_end_date, task.exp_start_date)
task.exp_start_date = add_days(end_date, 1)
task.exp_end_date = add_days(task.exp_start_date, task_duration)
task.flags.ignore_recursion_check = True
task.save()
task_duration = date_diff(task.exp_end_date, task.exp_start_date)
task.exp_start_date = add_days(end_date, 1)
task.exp_end_date = add_days(task.exp_start_date, task_duration)
task.flags.ignore_recursion_check = True
task.save()
def has_webform_permission(self):
project_user = frappe.db.get_value(
@@ -337,27 +342,23 @@ def check_if_child_exists(name: str):
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
from erpnext.controllers.queries import get_match_cond
from frappe.query_builder import Criterion
meta = frappe.get_meta(doctype)
searchfields = meta.get_search_fields()
search_columns = ", " + ", ".join(searchfields) if searchfields else ""
search_cond = " or " + " or ".join(field + " like %(txt)s" for field in searchfields)
searchfields = frappe.get_meta(doctype).get_search_fields()
return frappe.db.sql(
f""" select name {search_columns} from `tabProject`
where %(key)s like %(txt)s
%(mcond)s
{search_cond}
order by name
limit %(page_len)s offset %(start)s""",
{
"key": searchfield,
"txt": "%" + txt + "%",
"mcond": get_match_cond(doctype),
"start": start,
"page_len": page_len,
},
Project = frappe.qb.DocType("Project")
search_str = f"%{txt}%"
search_fields = list(dict.fromkeys([searchfield, *searchfields]))
search_conditions = [Project[field].like(search_str) for field in search_fields]
query = frappe.qb.get_query("Project", fields=["name", *searchfields], ignore_permissions=False)
return (
query.where(Criterion.any(search_conditions))
.orderby(Project.name)
.limit(page_len)
.offset(start)
.run()
)

View File

@@ -7,10 +7,10 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Concat, Date, Round
from frappe.utils import flt, get_datetime, getdate
from frappe.utils.deprecations import deprecated
from erpnext.controllers.queries import get_match_cond
from erpnext.setup.utils import get_exchange_rate
@@ -308,41 +308,41 @@ def get_projectwise_timesheet_data(
from_time: str | None = None,
to_time: str | None = None,
):
condition = ""
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(ts.name == tsd.parent)
.select(
tsd.name.as_("name"),
tsd.parent.as_("time_sheet"),
tsd.from_time.as_("from_time"),
tsd.to_time.as_("to_time"),
tsd.billing_hours.as_("billing_hours"),
tsd.billing_amount.as_("billing_amount"),
tsd.activity_type.as_("activity_type"),
tsd.description.as_("description"),
ts.currency.as_("currency"),
tsd.project_name.as_("project_name"),
)
.where(
(tsd.parenttype == "Timesheet")
& (tsd.docstatus == 1)
& (tsd.is_billable == 1)
& tsd.sales_invoice.isnull()
)
)
if project:
condition += "AND tsd.project = %(project)s "
query = query.where(tsd.project == project)
if parent:
condition += "AND tsd.parent = %(parent)s "
query = query.where(tsd.parent == parent)
if from_time and to_time:
condition += "AND CAST(tsd.from_time as DATE) BETWEEN %(from_time)s AND %(to_time)s"
query = query.where(Date(tsd.from_time).between(from_time, to_time))
query = f"""
SELECT
tsd.name as name,
tsd.parent as time_sheet,
tsd.from_time as from_time,
tsd.to_time as to_time,
tsd.billing_hours as billing_hours,
tsd.billing_amount as billing_amount,
tsd.activity_type as activity_type,
tsd.description as description,
ts.currency as currency,
tsd.project_name as project_name
FROM `tabTimesheet Detail` tsd
INNER JOIN `tabTimesheet` ts
ON ts.name = tsd.parent
WHERE
tsd.parenttype = 'Timesheet'
AND tsd.docstatus = 1
AND tsd.is_billable = 1
AND tsd.sales_invoice is NULL
{condition}
ORDER BY tsd.from_time ASC
"""
filters = {"project": project, "parent": parent, "from_time": from_time, "to_time": to_time}
return frappe.db.sql(query, filters, as_dict=1)
return query.orderby(tsd.from_time).run(as_dict=1)
@frappe.whitelist()
@@ -372,25 +372,28 @@ def get_timesheet(doctype: str, txt: str, searchfield: str, start: int, page_len
if not filters:
filters = {}
condition = ""
if filters.get("project"):
condition = "and tsd.project = %(project)s"
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
return frappe.db.sql(
f"""select distinct tsd.parent from `tabTimesheet Detail` tsd,
`tabTimesheet` ts where
ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and
tsd.docstatus = 1 and ts.total_billable_amount > 0
and tsd.parent LIKE %(txt)s {condition}
order by tsd.parent limit %(page_len)s offset %(start)s""",
{
"txt": "%" + txt + "%",
"start": start,
"page_len": page_len,
"project": filters.get("project"),
},
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(tsd.parent == ts.name)
.select(tsd.parent)
.distinct()
.where(
ts.status.isin(["Submitted", "Payslip"])
& (tsd.docstatus == 1)
& (ts.total_billable_amount > 0)
& tsd.parent.like(f"%{txt}%")
)
)
if filters.get("project"):
query = query.where(tsd.project == filters.get("project"))
return query.orderby(tsd.parent).limit(page_len).offset(start).run()
@frappe.whitelist()
def get_timesheet_data(name: str, project: str):
@@ -500,27 +503,37 @@ def get_events(start: str, end: str, filters: str | None = None):
:param end: End date-time.
:param filters: Filters (JSON).
"""
from erpnext.utilities.query import get_event_conditions_qb
filters = json.loads(filters) if filters else {}
from frappe.desk.calendar import get_event_conditions
conditions = get_event_conditions("Timesheet", filters)
tsd = frappe.qb.DocType("Timesheet Detail")
ts = frappe.qb.DocType("Timesheet")
return frappe.db.sql(
"""select `tabTimesheet Detail`.name as name,
`tabTimesheet Detail`.docstatus as status, `tabTimesheet Detail`.parent as parent,
from_time as start_date, hours, activity_type,
`tabTimesheet Detail`.project, to_time as end_date,
CONCAT(`tabTimesheet Detail`.parent, ' (', ROUND(hours,2),' hrs)') as title
from `tabTimesheet Detail`, `tabTimesheet`
where `tabTimesheet Detail`.parent = `tabTimesheet`.name
and `tabTimesheet`.docstatus < 2
and (from_time <= %(end)s and to_time >= %(start)s) {conditions} {match_cond}
""".format(conditions=conditions, match_cond=get_match_cond("Timesheet")),
{"start": start, "end": end},
as_dict=True,
update={"allDay": 0},
query = (
frappe.qb.from_(tsd)
.inner_join(ts)
.on(tsd.parent == ts.name)
.select(
tsd.name.as_("name"),
tsd.docstatus.as_("status"),
tsd.parent.as_("parent"),
tsd.from_time.as_("start_date"),
tsd.hours,
tsd.activity_type,
tsd.project,
tsd.to_time.as_("end_date"),
Concat(tsd.parent, " (", Round(tsd.hours, 2), " hrs)").as_("title"),
)
.where((ts.docstatus < 2) & (tsd.from_time <= end) & (tsd.to_time >= start))
)
# user-permission match conditions + calendar filters on Timesheet (query-builder form)
for condition in get_event_conditions_qb("Timesheet", filters):
query = query.where(condition)
return query.run(as_dict=True, update={"allDay": 0})
def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="creation"):
user = frappe.session.user

View File

@@ -4,19 +4,16 @@
import frappe
from frappe import _
from frappe.desk.reportview import build_match_conditions
from frappe.utils import add_days, getdate
from erpnext.stock.utils import get_combine_datetime
def execute(filters=None):
if not filters:
filters = {}
elif filters.get("from_date") or filters.get("to_date"):
filters["from_time"] = "00:00:00"
filters["to_time"] = "24:00:00"
filters = filters or {}
columns = get_column()
conditions = get_conditions(filters)
data = get_data(conditions, filters)
data = get_data(filters)
return columns, data
@@ -36,30 +33,39 @@ def get_column():
]
def get_data(conditions, filters):
time_sheet = frappe.db.sql(
""" select `tabTimesheet`.name, `tabTimesheet`.employee, `tabTimesheet`.employee_name,
`tabTimesheet Detail`.from_time, `tabTimesheet Detail`.to_time, `tabTimesheet Detail`.hours,
`tabTimesheet Detail`.activity_type, `tabTimesheet Detail`.task, `tabTimesheet Detail`.project,
`tabTimesheet`.status from `tabTimesheet Detail`, `tabTimesheet` where
`tabTimesheet Detail`.parent = `tabTimesheet`.name and %s order by `tabTimesheet`.name"""
% (conditions),
filters,
as_list=1,
def get_data(filters):
ts = frappe.qb.DocType("Timesheet")
tsd = frappe.qb.DocType("Timesheet Detail")
# Base the query on Timesheet so get_query applies its user-permission match conditions
# (the qb form of build_match_conditions); Timesheet Detail rows are inner-joined on.
query = (
frappe.qb.get_query(
"Timesheet",
fields=["name", "employee", "employee_name"],
ignore_permissions=False,
)
.inner_join(tsd)
.on(tsd.parent == ts.name)
.select(
tsd.from_time,
tsd.to_time,
tsd.hours,
tsd.activity_type,
tsd.task,
tsd.project,
ts.status,
)
.where(ts.docstatus == 1)
)
return time_sheet
def get_conditions(filters):
conditions = "`tabTimesheet`.docstatus = 1"
if filters.get("from_date"):
conditions += " and `tabTimesheet Detail`.from_time >= timestamp(%(from_date)s, %(from_time)s)"
query = query.where(tsd.from_time >= get_combine_datetime(filters.get("from_date"), "00:00:00"))
if filters.get("to_date"):
conditions += " and `tabTimesheet Detail`.to_time <= timestamp(%(to_date)s, %(to_time)s)"
# upper bound is the end of to_date, i.e. midnight of the next day
# (matches the original `timestamp(to_date, '24:00:00')`)
end_of_to_date = get_combine_datetime(add_days(getdate(filters.get("to_date")), 1), "00:00:00")
query = query.where(tsd.to_time <= end_of_to_date)
match_conditions = build_match_conditions("Timesheet")
if match_conditions:
conditions += " and (%s)" % match_conditions
return conditions
return query.orderby(ts.name).run(as_list=True)

View File

@@ -0,0 +1,26 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import today
from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet
from erpnext.projects.report.daily_timesheet_summary.daily_timesheet_summary import execute
from erpnext.setup.doctype.employee.test_employee import make_employee
from erpnext.tests.utils import ERPNextTestSuite
class TestDailyTimesheetSummary(ERPNextTestSuite):
def test_submitted_timesheet_in_summary(self):
frappe.set_user("Administrator")
employee = make_employee("test_employee_6@salary.com", company="_Test Company")
timesheet = make_timesheet(employee, simulate=True)
_columns, data = execute({"from_date": today(), "to_date": today()})
# Row column order: [Timesheet.name, employee, employee_name, from_time, to_time,
# hours, activity_type, task, project, status]. The converted join must surface the
# submitted timesheet for today; row[0] holds the Timesheet name.
names = [row[0] for row in data]
self.assertIn(timesheet.name, names)

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
def execute(filters=None):
@@ -50,19 +51,28 @@ def get_columns():
def get_project_details():
return frappe.db.sql(
""" select name, project_name, status, company, customer, estimated_costing,
expected_start_date, expected_end_date from tabProject where docstatus < 2""",
as_dict=1,
return frappe.get_all(
"Project",
filters={"docstatus": ["<", 2]},
fields=[
"name",
"project_name",
"status",
"company",
"customer",
"estimated_costing",
"expected_start_date",
"expected_end_date",
],
)
def get_purchased_items_cost():
pr_items = frappe.db.sql(
"""select project, sum(base_net_amount) as amount
from `tabPurchase Receipt Item` where ifnull(project, '') != ''
and docstatus = 1 group by project""",
as_dict=1,
pr_items = frappe.get_all(
"Purchase Receipt Item",
filters={"project": ["is", "set"], "docstatus": 1},
fields=["project", {"SUM": "base_net_amount", "as": "amount"}],
group_by="project",
)
pr_item_map = {}
@@ -73,12 +83,20 @@ def get_purchased_items_cost():
def get_issued_items_cost():
se_items = frappe.db.sql(
"""select se.project, sum(se_item.amount) as amount
from `tabStock Entry` se, `tabStock Entry Detail` se_item
where se.name = se_item.parent and se.docstatus = 1 and ifnull(se_item.t_warehouse, '') = ''
and se.project != '' group by se.project""",
as_dict=1,
se = frappe.qb.DocType("Stock Entry")
se_item = frappe.qb.DocType("Stock Entry Detail")
se_items = (
frappe.qb.from_(se)
.inner_join(se_item)
.on(se.name == se_item.parent)
.select(se.project, Sum(se_item.amount).as_("amount"))
.where(
(se.docstatus == 1)
& (se_item.t_warehouse.isnull() | (se_item.t_warehouse == ""))
& (se.project != "")
)
.groupby(se.project)
.run(as_dict=1)
)
se_item_map = {}
@@ -89,21 +107,28 @@ def get_issued_items_cost():
def get_delivered_items_cost():
dn_items = frappe.db.sql(
"""select dn.project, sum(dn_item.base_net_amount) as amount
from `tabDelivery Note` dn, `tabDelivery Note Item` dn_item
where dn.name = dn_item.parent and dn.docstatus = 1 and ifnull(dn.project, '') != ''
group by dn.project""",
as_dict=1,
dn = frappe.qb.DocType("Delivery Note")
dn_item = frappe.qb.DocType("Delivery Note Item")
dn_items = (
frappe.qb.from_(dn)
.inner_join(dn_item)
.on(dn.name == dn_item.parent)
.select(dn.project, Sum(dn_item.base_net_amount).as_("amount"))
.where((dn.docstatus == 1) & (dn.project != ""))
.groupby(dn.project)
.run(as_dict=1)
)
si_items = frappe.db.sql(
"""select si.project, sum(si_item.base_net_amount) as amount
from `tabSales Invoice` si, `tabSales Invoice Item` si_item
where si.name = si_item.parent and si.docstatus = 1 and si.update_stock = 1
and si.is_pos = 1 and ifnull(si.project, '') != ''
group by si.project""",
as_dict=1,
si = frappe.qb.DocType("Sales Invoice")
si_item = frappe.qb.DocType("Sales Invoice Item")
si_items = (
frappe.qb.from_(si)
.inner_join(si_item)
.on(si.name == si_item.parent)
.select(si.project, Sum(si_item.base_net_amount).as_("amount"))
.where((si.docstatus == 1) & (si.update_stock == 1) & (si.is_pos == 1) & (si.project != ""))
.groupby(si.project)
.run(as_dict=1)
)
dn_item_map = {}

View File

@@ -0,0 +1,78 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.utils import flt, random_string, today
from erpnext.projects.report.project_wise_stock_tracking.project_wise_stock_tracking import execute
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestSuite
class TestProjectWiseStockTracking(ERPNextTestSuite):
def test_project_wise_stock_tracking(self):
project = frappe.get_doc(
{
"doctype": "Project",
"project_name": "_Test PWST " + random_string(10),
"status": "Open",
"company": "_Test Company",
}
).insert()
# Issued cost: a project-tagged Material Issue (t_warehouse empty) -> get_issued_items_cost.
make_stock_entry(item_code="_Test Item", qty=10, to_warehouse="_Test Warehouse - _TC", rate=100)
issue = make_stock_entry(
item_code="_Test Item", qty=4, from_warehouse="_Test Warehouse - _TC", do_not_save=True
)
issue.project = project.name
issue.save()
issue.submit()
expected_issued_cost = issue.items[0].amount
# Purchased cost: a submitted Purchase Receipt Item tagged to the project. Inserted directly
# (no parent receipt) so get_purchased_items_cost has data without running Purchase Receipt
# validation (which would also pull in the landed-cost-voucher path).
self.make_child_row("Purchase Receipt Item", "Purchase Receipt", 300, project=project.name)
# Delivered cost: a submitted Delivery Note + line; the report joins on the parent's project.
dn = self.make_parent_row("Delivery Note", company="_Test Company", customer="_Test Customer")
frappe.db.set_value("Delivery Note", dn, "project", project.name)
self.make_child_row("Delivery Note Item", "Delivery Note", 200, parent=dn)
_columns, data = execute(filters=None)
row = next((r for r in data if r[0] == project.name), None)
# get_project_details must surface the freshly created project.
self.assertIsNotNone(row, "Project row missing from report output")
self.assertEqual(flt(row[1]), 300) # get_purchased_items_cost (GROUP BY project)
self.assertEqual(flt(row[2]), flt(expected_issued_cost)) # get_issued_items_cost
self.assertEqual(flt(row[3]), 200) # get_delivered_items_cost
def make_parent_row(self, doctype, **fields):
doc = frappe.new_doc(doctype)
for key, value in fields.items():
doc.set(key, value)
doc.posting_date = today()
doc.docstatus = 1
doc.flags.name_set = True
doc.name = frappe.generate_hash("pwst", 12)
doc.db_insert()
return doc.name
def make_child_row(self, doctype, parenttype, base_net_amount, project=None, parent=None):
row = frappe.new_doc(doctype)
row.parenttype = parenttype
row.parentfield = "items"
row.parent = parent or frappe.generate_hash("pwst", 12)
row.idx = 1
row.item_code = "_Test Item"
row.item_name = "_Test Item"
row.base_net_amount = base_net_amount
if project:
row.project = project
row.docstatus = 1
row.flags.name_set = True
row.name = frappe.generate_hash("pwst", 12)
row.db_insert()
return row.name

View File

@@ -5,28 +5,25 @@
import frappe
from frappe.query_builder import Case
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def query_task(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
from frappe.desk.reportview import build_match_conditions
search_str = f"%{txt}%"
prefix_str = f"{txt}%"
search_string = "%%%s%%" % txt
order_by_string = "%s%%" % txt
match_conditions = build_match_conditions("Task")
match_conditions = (f"and ({match_conditions})") if match_conditions else ""
Task = frappe.qb.DocType("Task")
query = frappe.qb.get_query("Task", fields=["name", "subject"], ignore_permissions=False)
return frappe.db.sql(
"""select name, subject from `tabTask`
where (`{}` like {} or `subject` like {}) {}
order by
case when `subject` like {} then 0 else 1 end,
case when `{}` like {} then 0 else 1 end,
`{}`,
subject
limit {} offset {}""".format(
searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"
),
(search_string, search_string, order_by_string, order_by_string, page_len, start),
return (
query.where(Task[searchfield].like(search_str) | Task.subject.like(search_str))
.orderby(Case().when(Task.subject.like(prefix_str), 0).else_(1))
.orderby(Case().when(Task[searchfield].like(prefix_str), 0).else_(1))
.orderby(Task[searchfield])
.orderby(Task.subject)
.limit(page_len)
.offset(start)
.run()
)