mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-17 10:36:31 +00:00
refactor(manufacturing, projects): make raw SQL portable to PostgreSQL (parity rollout 2/9) (#56008)
refactor(manufacturing, projects): make raw SQL portable to PostgreSQL Convert the MariaDB-only raw `frappe.db.sql` in the Manufacturing and Projects modules to the cross-database query builder / ORM, and fix the non-portable constructs that remain. Every change is a no-op on MariaDB (identical rendered SQL / identical results) and only brings PostgreSQL — standards-strict where MySQL is lax — in line. Areas: BOM (cost/where-used/explosion), Work Order (operations, required items, mapper, stock report), Workstation, Production Plan sub-assembly/explosion queries, BOM Stock Analysis / Process Loss / Work Order Stock reports; Projects (project, task, timesheet, activity cost, project update), Daily Timesheet Summary and Project-wise Stock Tracking reports. Part of the staged MariaDB<->PostgreSQL parity rollout (module 2 of 9). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import frappe
|
|||||||
from frappe import _, bold
|
from frappe import _, bold
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.query_builder import Field
|
from frappe.query_builder import Field
|
||||||
from frappe.query_builder.functions import Count, IfNull, Sum
|
from frappe.query_builder.functions import Count, IfNull, Max, Min, Sum
|
||||||
from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json
|
from frappe.utils import cint, cstr, flt, get_link_to_form, parse_json
|
||||||
from frappe.website.website_generator import WebsiteGenerator
|
from frappe.website.website_generator import WebsiteGenerator
|
||||||
|
|
||||||
@@ -1194,7 +1194,9 @@ def _query_bom_items(bom, company, opts):
|
|||||||
t = _get_bom_item_tables(opts)
|
t = _get_bom_item_tables(opts)
|
||||||
query = _build_base_bom_items_query(bom, company, opts.qty, t)
|
query = _build_base_bom_items_query(bom, company, opts.qty, t)
|
||||||
query, group_by = _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods)
|
query, group_by = _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods)
|
||||||
return query.groupby(*group_by).orderby(Field("idx")).run(as_dict=True)
|
# qualify + aggregate idx: bare "idx" is ambiguous across the joined tables and isn't grouped
|
||||||
|
# (idx is unique per BOM item, so Min() preserves the original ordering) — needed for postgres
|
||||||
|
return query.groupby(*group_by).orderby(Min(t.bom_item.idx)).run(as_dict=True)
|
||||||
|
|
||||||
|
|
||||||
def _get_bom_item_tables(opts):
|
def _get_bom_item_tables(opts):
|
||||||
@@ -1228,17 +1230,20 @@ def _build_base_bom_items_query(bom, company, qty, t):
|
|||||||
.on((t.item_default.parent == t.item_doc.name) & (t.item_default.company == company))
|
.on((t.item_default.parent == t.item_doc.name) & (t.item_default.company == company))
|
||||||
.select(
|
.select(
|
||||||
t.bom_item.item_code,
|
t.bom_item.item_code,
|
||||||
t.bom_item.idx,
|
# every non-grouped column here is functionally dependent on the grouped item_code
|
||||||
t.item_doc.item_name,
|
# (item attributes / the single BOM's project / per-item Item Default), so Max()/Min()
|
||||||
|
# returns the value MySQL picked arbitrarily while making the GROUP BY valid on postgres.
|
||||||
|
Min(t.bom_item.idx).as_("idx"),
|
||||||
|
Max(t.item_doc.item_name).as_("item_name"),
|
||||||
(Sum(t.qty_field_col / IfNull(t.bom_doc.quantity, 1)) * qty).as_("qty"),
|
(Sum(t.qty_field_col / IfNull(t.bom_doc.quantity, 1)) * qty).as_("qty"),
|
||||||
t.item_doc.image,
|
Max(t.item_doc.image).as_("image"),
|
||||||
t.bom_doc.project,
|
Max(t.bom_doc.project).as_("project"),
|
||||||
t.item_doc.stock_uom,
|
Max(t.item_doc.stock_uom).as_("stock_uom"),
|
||||||
t.item_doc.item_group,
|
Max(t.item_doc.item_group).as_("item_group"),
|
||||||
t.item_doc.allow_alternative_item,
|
Max(t.item_doc.allow_alternative_item).as_("allow_alternative_item"),
|
||||||
t.item_default.default_warehouse,
|
Max(t.item_default.default_warehouse).as_("default_warehouse"),
|
||||||
t.item_default.expense_account.as_("expense_account"),
|
Max(t.item_default.expense_account).as_("expense_account"),
|
||||||
t.item_default.buying_cost_center.as_("cost_center"),
|
Max(t.item_default.buying_cost_center).as_("cost_center"),
|
||||||
)
|
)
|
||||||
.where((t.bom_item.docstatus < 2) & (t.bom_doc.name == bom))
|
.where((t.bom_item.docstatus < 2) & (t.bom_doc.name == bom))
|
||||||
)
|
)
|
||||||
@@ -1247,9 +1252,11 @@ def _build_base_bom_items_query(bom, company, qty, t):
|
|||||||
def _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods):
|
def _add_bom_item_columns(query, t, bom, opts, track_semi_finished_goods):
|
||||||
is_stock_item = cint(not opts.include_non_stock_items)
|
is_stock_item = cint(not opts.include_non_stock_items)
|
||||||
stock_item_condition = t.item_doc.is_stock_item.isin([1, is_stock_item])
|
stock_item_condition = t.item_doc.is_stock_item.isin([1, is_stock_item])
|
||||||
amount_col = (Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * t.bom_item.rate * opts.qty).as_(
|
# rate is constant per grouped item -> Max() keeps it out of the Sum (preserving the original
|
||||||
"amount"
|
# Sum(...) * rate * qty arithmetic) while making the expression postgres-valid under GROUP BY.
|
||||||
)
|
amount_col = (
|
||||||
|
Sum(t.bom_item.stock_qty / IfNull(t.bom_doc.quantity, 1)) * Max(t.bom_item.rate) * opts.qty
|
||||||
|
).as_("amount")
|
||||||
|
|
||||||
if cint(opts.fetch_exploded):
|
if cint(opts.fetch_exploded):
|
||||||
return _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition)
|
return _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition)
|
||||||
@@ -1267,13 +1274,16 @@ def _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition):
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# non-grouped columns are constant per grouped item_code -> Max() preserves the value while
|
||||||
|
# keeping the GROUP BY postgres-valid; the correlated idx subquery references only item_code
|
||||||
|
# (a grouped column) so it stays valid and still overrides the explosion idx for display.
|
||||||
query = query.select(
|
query = query.select(
|
||||||
t.bom_item.source_warehouse,
|
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
|
||||||
t.bom_item.operation,
|
Max(t.bom_item.operation).as_("operation"),
|
||||||
t.bom_item.include_item_in_manufacturing,
|
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
|
||||||
t.bom_item.description,
|
Max(t.bom_item.description).as_("description"),
|
||||||
t.bom_item.rate,
|
Max(t.bom_item.rate).as_("rate"),
|
||||||
t.bom_item.sourced_by_supplier,
|
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
|
||||||
amount_col,
|
amount_col,
|
||||||
idx_subquery.as_("idx"),
|
idx_subquery.as_("idx"),
|
||||||
).where(stock_item_condition)
|
).where(stock_item_condition)
|
||||||
@@ -1282,33 +1292,37 @@ def _add_exploded_item_columns(query, t, bom, amount_col, stock_item_condition):
|
|||||||
|
|
||||||
|
|
||||||
def _add_secondary_item_columns(query, t, stock_item_condition):
|
def _add_secondary_item_columns(query, t, stock_item_condition):
|
||||||
|
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid on
|
||||||
|
# postgres while returning the same value MySQL picked arbitrarily.
|
||||||
query = query.select(
|
query = query.select(
|
||||||
t.item_doc.description,
|
Max(t.item_doc.description).as_("description"),
|
||||||
t.bom_item.cost_allocation_per,
|
Max(t.bom_item.cost_allocation_per).as_("cost_allocation_per"),
|
||||||
t.bom_item.process_loss_per,
|
Max(t.bom_item.process_loss_per).as_("process_loss_per"),
|
||||||
t.bom_item.secondary_item_type,
|
Max(t.bom_item.secondary_item_type).as_("secondary_item_type"),
|
||||||
t.bom_item.name,
|
Max(t.bom_item.name).as_("name"),
|
||||||
t.bom_item.is_legacy,
|
Max(t.bom_item.is_legacy).as_("is_legacy"),
|
||||||
).where(stock_item_condition)
|
).where(stock_item_condition)
|
||||||
|
|
||||||
return query, [t.bom_item.item_code]
|
return query, [t.bom_item.item_code]
|
||||||
|
|
||||||
|
|
||||||
def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods):
|
def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods):
|
||||||
|
# non-grouped columns are constant per grouped item_code (+operation/operation_row_id) -> Max()
|
||||||
|
# keeps the GROUP BY valid on postgres while returning the value MySQL picked arbitrarily.
|
||||||
query = query.select(
|
query = query.select(
|
||||||
t.bom_item.rate,
|
Max(t.bom_item.rate).as_("rate"),
|
||||||
t.bom_item.uom,
|
Max(t.bom_item.uom).as_("uom"),
|
||||||
t.bom_item.conversion_factor,
|
Max(t.bom_item.conversion_factor).as_("conversion_factor"),
|
||||||
t.bom_item.source_warehouse,
|
Max(t.bom_item.source_warehouse).as_("source_warehouse"),
|
||||||
t.bom_item.operation,
|
Max(t.bom_item.operation).as_("operation"),
|
||||||
t.bom_item.include_item_in_manufacturing,
|
Max(t.bom_item.include_item_in_manufacturing).as_("include_item_in_manufacturing"),
|
||||||
t.bom_item.sourced_by_supplier,
|
Max(t.bom_item.sourced_by_supplier).as_("sourced_by_supplier"),
|
||||||
amount_col,
|
amount_col,
|
||||||
t.bom_item.description,
|
Max(t.bom_item.description).as_("description"),
|
||||||
t.bom_item.base_rate.as_("rate"),
|
Max(t.bom_item.base_rate).as_("rate"),
|
||||||
t.bom_item.operation_row_id,
|
Max(t.bom_item.operation_row_id).as_("operation_row_id"),
|
||||||
t.bom_item.is_phantom_item,
|
Max(t.bom_item.is_phantom_item).as_("is_phantom_item"),
|
||||||
t.bom_item.bom_no,
|
Max(t.bom_item.bom_no).as_("bom_no"),
|
||||||
).where(stock_item_condition | (t.bom_item.is_phantom_item == 1))
|
).where(stock_item_condition | (t.bom_item.is_phantom_item == 1))
|
||||||
|
|
||||||
if track_semi_finished_goods:
|
if track_semi_finished_goods:
|
||||||
@@ -1386,16 +1400,19 @@ def validate_bom_no(item, bom_no):
|
|||||||
|
|
||||||
|
|
||||||
def _bom_contains_item(bom, item):
|
def _bom_contains_item(bom, item):
|
||||||
item = item.lower()
|
# Lower-case only for the case-insensitive item_code comparisons; keep the original `item`
|
||||||
|
# for the Item lookup, whose name is case-sensitive on postgres (lower-casing it would miss
|
||||||
|
# the row and wrongly reject a variant's template BOM).
|
||||||
|
item_code = item.lower()
|
||||||
for d in bom.items:
|
for d in bom.items:
|
||||||
if d.item_code.lower() == item:
|
if d.item_code.lower() == item_code:
|
||||||
return True
|
return True
|
||||||
for d in bom.secondary_items:
|
for d in bom.secondary_items:
|
||||||
if d.item_code.lower() == item:
|
if d.item_code.lower() == item_code:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return (
|
return (
|
||||||
bom.item.lower() == item
|
bom.item.lower() == item_code
|
||||||
or bom.item.lower() == cstr(frappe.db.get_value("Item", item, "variant_of")).lower()
|
or bom.item.lower() == cstr(frappe.db.get_value("Item", item, "variant_of")).lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -97,10 +97,10 @@ class TestBOM(ERPNextTestSuite):
|
|||||||
update_cost_in_all_boms_in_test()
|
update_cost_in_all_boms_in_test()
|
||||||
|
|
||||||
# check if new valuation rate updated in all BOMs
|
# check if new valuation rate updated in all BOMs
|
||||||
for d in frappe.db.sql(
|
for d in frappe.get_all(
|
||||||
"""select base_rate from `tabBOM Item`
|
"BOM Item",
|
||||||
where item_code='_Test Item 2' and docstatus=1 and parenttype='BOM'""",
|
filters={"item_code": "_Test Item 2", "docstatus": 1, "parenttype": "BOM"},
|
||||||
as_dict=1,
|
fields=["base_rate"],
|
||||||
):
|
):
|
||||||
self.assertEqual(d.base_rate, rm_base_rate + 10)
|
self.assertEqual(d.base_rate, rm_base_rate + 10)
|
||||||
|
|
||||||
@@ -881,12 +881,10 @@ def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=Non
|
|||||||
warehouse_list = [warehouse_list]
|
warehouse_list = [warehouse_list]
|
||||||
|
|
||||||
if not warehouse_list:
|
if not warehouse_list:
|
||||||
warehouse_list = frappe.db.sql_list(
|
warehouse_list = frappe.get_all(
|
||||||
"""
|
"Bin",
|
||||||
select warehouse from `tabBin`
|
filters={"item_code": item_code, "actual_qty": [">", 0]},
|
||||||
where item_code=%s and actual_qty > 0
|
pluck="warehouse",
|
||||||
""",
|
|
||||||
item_code,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not warehouse_list:
|
if not warehouse_list:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"""BOM explosion helpers for Production Plan material planning."""
|
"""BOM explosion helpers for Production Plan material planning."""
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.query_builder.functions import IfNull, Sum
|
from frappe.query_builder.functions import IfNull, Max, Min, Sum
|
||||||
|
|
||||||
from erpnext.manufacturing.doctype.production_plan.services.planning_queries import get_uom_conversion_factor
|
from erpnext.manufacturing.doctype.production_plan.services.planning_queries import get_uom_conversion_factor
|
||||||
|
|
||||||
@@ -38,22 +38,25 @@ def _exploded_items_query(company, bom_no, include_non_stock_items, planned_qty)
|
|||||||
|
|
||||||
|
|
||||||
def _exploded_item_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
def _exploded_item_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
||||||
|
# only item_code/stock_uom are grouped; the rest are functionally dependent on the grouped item
|
||||||
|
# or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY valid on postgres with the same
|
||||||
|
# value MySQL picked.
|
||||||
return [
|
return [
|
||||||
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
|
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
|
||||||
item.item_name,
|
Max(item.item_name).as_("item_name"),
|
||||||
item.name.as_("item_code"),
|
Max(item.name).as_("item_code"),
|
||||||
bei.description,
|
Max(bei.description).as_("description"),
|
||||||
bei.stock_uom,
|
bei.stock_uom,
|
||||||
item.min_order_qty,
|
Max(item.min_order_qty).as_("min_order_qty"),
|
||||||
bei.source_warehouse,
|
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||||
item.default_material_request_type,
|
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||||
item.min_order_qty,
|
Max(item.min_order_qty).as_("min_order_qty"),
|
||||||
item_default.default_warehouse,
|
Max(item_default.default_warehouse).as_("default_warehouse"),
|
||||||
item.purchase_uom,
|
Max(item.purchase_uom).as_("purchase_uom"),
|
||||||
item_uom.conversion_factor,
|
Max(item_uom.conversion_factor).as_("conversion_factor"),
|
||||||
item.safety_stock,
|
Max(item.safety_stock).as_("safety_stock"),
|
||||||
bom.item.as_("main_bom_item"),
|
Max(bom.item).as_("main_bom_item"),
|
||||||
bom.name.as_("main_bom"),
|
Max(bom.name).as_("main_bom"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -106,30 +109,34 @@ def _subitems_query(company, bom_no, include_non_stock_items, parent_qty, planne
|
|||||||
.select(*_subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty))
|
.select(*_subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty))
|
||||||
.where(_subitem_filter(bom_item, bom, item, bom_no, include_non_stock_items))
|
.where(_subitem_filter(bom_item, bom, item, bom_no, include_non_stock_items))
|
||||||
.groupby(bom_item.item_code)
|
.groupby(bom_item.item_code)
|
||||||
.orderby(bom_item.idx)
|
# idx is not grouped; Min() preserves the original ordering and is valid on postgres
|
||||||
|
.orderby(Min(bom_item.idx))
|
||||||
).run(as_dict=True)
|
).run(as_dict=True)
|
||||||
|
|
||||||
|
|
||||||
def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty):
|
def _subitem_columns(bom_item, bom, item, item_default, item_uom, parent_qty, planned_qty):
|
||||||
qty = IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_("qty")
|
qty = IfNull(parent_qty * Sum(bom_item.stock_qty / IfNull(bom.quantity, 1)) * planned_qty, 0).as_("qty")
|
||||||
|
# only item_code is grouped; the rest are functionally dependent on the grouped item (item
|
||||||
|
# attributes) or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY valid on postgres
|
||||||
|
# while returning the same value MySQL picked.
|
||||||
return [
|
return [
|
||||||
bom_item.item_code,
|
bom_item.item_code,
|
||||||
item.default_material_request_type,
|
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||||
item.item_name,
|
Max(item.item_name).as_("item_name"),
|
||||||
qty,
|
qty,
|
||||||
item.is_sub_contracted_item.as_("is_sub_contracted"),
|
Max(item.is_sub_contracted_item).as_("is_sub_contracted"),
|
||||||
bom_item.source_warehouse,
|
Max(bom_item.source_warehouse).as_("source_warehouse"),
|
||||||
item.default_bom.as_("default_bom"),
|
Max(item.default_bom).as_("default_bom"),
|
||||||
bom_item.description.as_("description"),
|
Max(bom_item.description).as_("description"),
|
||||||
bom_item.stock_uom.as_("stock_uom"),
|
Max(bom_item.stock_uom).as_("stock_uom"),
|
||||||
item.min_order_qty.as_("min_order_qty"),
|
Max(item.min_order_qty).as_("min_order_qty"),
|
||||||
item.safety_stock.as_("safety_stock"),
|
Max(item.safety_stock).as_("safety_stock"),
|
||||||
item_default.default_warehouse,
|
Max(item_default.default_warehouse).as_("default_warehouse"),
|
||||||
item.purchase_uom,
|
Max(item.purchase_uom).as_("purchase_uom"),
|
||||||
item_uom.conversion_factor,
|
Max(item_uom.conversion_factor).as_("conversion_factor"),
|
||||||
bom.item.as_("main_bom_item"),
|
Max(bom.item).as_("main_bom_item"),
|
||||||
bom.name.as_("main_bom"),
|
Max(bom.name).as_("main_bom"),
|
||||||
bom_item.is_phantom_item,
|
Max(bom_item.is_phantom_item).as_("is_phantom_item"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"""Sub-assembly resolution helpers for Production Plan."""
|
"""Sub-assembly resolution helpers for Production Plan."""
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.query_builder.functions import IfNull, Sum
|
from frappe.query_builder.functions import IfNull, Max, Sum
|
||||||
from frappe.utils import flt
|
from frappe.utils import flt
|
||||||
|
|
||||||
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
|
from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_children
|
||||||
@@ -184,24 +184,27 @@ def _sub_assembly_rm_query(company, bom_no, include_non_stock_items, planned_qty
|
|||||||
|
|
||||||
|
|
||||||
def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
def _sub_assembly_rm_columns(bei, bom, item, item_default, item_uom, planned_qty):
|
||||||
|
# only item_code/stock_uom are grouped; every other column is functionally dependent on the
|
||||||
|
# grouped item (item attributes) or arbitrary per BOM Item on MySQL -> Max() keeps the GROUP BY
|
||||||
|
# valid on postgres while returning the same value MySQL picked.
|
||||||
return [
|
return [
|
||||||
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
|
(IfNull(Sum(bei.stock_qty / IfNull(bom.quantity, 1)), 0) * planned_qty).as_("qty"),
|
||||||
item.item_name,
|
Max(item.item_name).as_("item_name"),
|
||||||
item.name.as_("item_code"),
|
Max(item.name).as_("item_code"),
|
||||||
bei.description,
|
Max(bei.description).as_("description"),
|
||||||
bei.stock_uom,
|
bei.stock_uom,
|
||||||
bei.is_phantom_item,
|
Max(bei.is_phantom_item).as_("is_phantom_item"),
|
||||||
bei.bom_no,
|
Max(bei.bom_no).as_("bom_no"),
|
||||||
item.min_order_qty,
|
Max(item.min_order_qty).as_("min_order_qty"),
|
||||||
bei.source_warehouse,
|
Max(bei.source_warehouse).as_("source_warehouse"),
|
||||||
item.default_material_request_type,
|
Max(item.default_material_request_type).as_("default_material_request_type"),
|
||||||
item.min_order_qty,
|
Max(item.min_order_qty).as_("min_order_qty"),
|
||||||
item_default.default_warehouse,
|
Max(item_default.default_warehouse).as_("default_warehouse"),
|
||||||
item.purchase_uom,
|
Max(item.purchase_uom).as_("purchase_uom"),
|
||||||
item_uom.conversion_factor,
|
Max(item_uom.conversion_factor).as_("conversion_factor"),
|
||||||
item.safety_stock,
|
Max(item.safety_stock).as_("safety_stock"),
|
||||||
bom.item.as_("main_bom_item"),
|
Max(bom.item).as_("main_bom_item"),
|
||||||
bom.name.as_("main_bom"),
|
Max(bom.name).as_("main_bom"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,12 @@ def _item_master_details(item):
|
|||||||
|
|
||||||
|
|
||||||
def _item_is_alive(item_table):
|
def _item_is_alive(item_table):
|
||||||
return (
|
# "not set" end_of_life is NULL on postgres (the MariaDB zero-date '0000-00-00' is an invalid
|
||||||
item_table.end_of_life.isnull()
|
# date constant there), so only add the zero-date term on MariaDB.
|
||||||
| (item_table.end_of_life == "0000-00-00")
|
is_alive = item_table.end_of_life.isnull() | (item_table.end_of_life > nowdate())
|
||||||
| (item_table.end_of_life > nowdate())
|
if frappe.db.db_type != "postgres":
|
||||||
)
|
is_alive |= item_table.end_of_life == "0000-00-00"
|
||||||
|
return is_alive
|
||||||
|
|
||||||
|
|
||||||
def _default_bom_for_item(item, project):
|
def _default_bom_for_item(item, project):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ are called from other modules.
|
|||||||
import frappe
|
import frappe
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
from frappe.query_builder.functions import CombineDatetime
|
||||||
from frappe.utils import (
|
from frappe.utils import (
|
||||||
cint,
|
cint,
|
||||||
date_diff,
|
date_diff,
|
||||||
@@ -268,13 +269,17 @@ class OperationsService:
|
|||||||
self.doc.actual_end_date = max(end_dates)
|
self.doc.actual_end_date = max(end_dates)
|
||||||
|
|
||||||
def _set_dates_from_stock_entries(self):
|
def _set_dates_from_stock_entries(self):
|
||||||
data = frappe.get_all(
|
# {"TIMESTAMP": [...]} renders MySQL's TIMESTAMP(date, time), invalid on postgres; use the
|
||||||
"Stock Entry",
|
# portable CombineDatetime via query builder instead.
|
||||||
fields=[{"TIMESTAMP": ["posting_date", "posting_time"], "as": "posting_datetime"}],
|
se = frappe.qb.DocType("Stock Entry")
|
||||||
filters={
|
data = (
|
||||||
"work_order": self.doc.name,
|
frappe.qb.from_(se)
|
||||||
"purpose": ("in", ["Material Transfer for Manufacture", "Manufacture"]),
|
.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:
|
if not data:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -158,7 +158,13 @@ class RequiredItemsService:
|
|||||||
frappe.qb.from_(ste)
|
frappe.qb.from_(ste)
|
||||||
.inner_join(ste_child)
|
.inner_join(ste_child)
|
||||||
.on(ste_child.parent == ste.name)
|
.on(ste_child.parent == ste.name)
|
||||||
.select(ste_child.item_code, ste_child.original_item, fn.Sum(ste_child.transfer_qty).as_("qty"))
|
# original_item is arbitrary per grouped item_code on MySQL -> Max() keeps the GROUP BY valid
|
||||||
|
# on postgres while returning the same value (it is only used as a dict key fallback below)
|
||||||
|
.select(
|
||||||
|
ste_child.item_code,
|
||||||
|
fn.Max(ste_child.original_item).as_("original_item"),
|
||||||
|
fn.Sum(ste_child.transfer_qty).as_("qty"),
|
||||||
|
)
|
||||||
.where(self._material_transfer_filter(ste, is_return))
|
.where(self._material_transfer_filter(ste, is_return))
|
||||||
.groupby(ste_child.item_code)
|
.groupby(ste_child.item_code)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5136,11 +5136,10 @@ def update_job_card(job_card, jc_qty=None, days=None):
|
|||||||
|
|
||||||
def get_secondary_item_details(bom_no):
|
def get_secondary_item_details(bom_no):
|
||||||
secondary_items = {}
|
secondary_items = {}
|
||||||
for item in frappe.db.sql(
|
for item in frappe.get_all(
|
||||||
"""select item_code, stock_qty from `tabBOM Secondary Item`
|
"BOM Secondary Item",
|
||||||
where parent = %s""",
|
filters={"parent": bom_no},
|
||||||
bom_no,
|
fields=["item_code", "stock_qty"],
|
||||||
as_dict=1,
|
|
||||||
):
|
):
|
||||||
secondary_items[item.item_code] = item.stock_qty
|
secondary_items[item.item_code] = item.stock_qty
|
||||||
|
|
||||||
|
|||||||
@@ -843,15 +843,16 @@ class WorkOrder(Document):
|
|||||||
frappe.throw(_("Stopped Work Order cannot be cancelled, Unstop it first to cancel"))
|
frappe.throw(_("Stopped Work Order cannot be cancelled, Unstop it first to cancel"))
|
||||||
|
|
||||||
# Check whether any stock entry exists against this Work Order
|
# Check whether any stock entry exists against this Work Order
|
||||||
stock_entry = frappe.db.sql(
|
stock_entry = frappe.get_all(
|
||||||
"""select name from `tabStock Entry`
|
"Stock Entry",
|
||||||
where work_order = %s and docstatus = 1""",
|
filters={"work_order": self.name, "docstatus": 1},
|
||||||
self.name,
|
pluck="name",
|
||||||
|
limit=1,
|
||||||
)
|
)
|
||||||
if stock_entry:
|
if stock_entry:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Cannot cancel because submitted Stock Entry {0} exists").format(
|
_("Cannot cancel because submitted Stock Entry {0} exists").format(
|
||||||
frappe.utils.get_link_to_form("Stock Entry", stock_entry[0][0])
|
frappe.utils.get_link_to_form("Stock Entry", stock_entry[0])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -942,14 +943,20 @@ class WorkOrder(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def make_bom(self):
|
def make_bom(self):
|
||||||
data = frappe.db.sql(
|
sed = frappe.qb.DocType("Stock Entry Detail")
|
||||||
""" select sed.item_code, sed.qty, sed.s_warehouse
|
se = frappe.qb.DocType("Stock Entry")
|
||||||
from `tabStock Entry Detail` sed, `tabStock Entry` se
|
data = (
|
||||||
where se.name = sed.parent and se.purpose = 'Manufacture'
|
frappe.qb.from_(sed)
|
||||||
and (sed.t_warehouse is null or sed.t_warehouse = '') and se.docstatus = 1
|
.inner_join(se)
|
||||||
and se.work_order = %s""",
|
.on(se.name == sed.parent)
|
||||||
(self.name),
|
.select(sed.item_code, sed.qty, sed.s_warehouse)
|
||||||
as_dict=1,
|
.where(
|
||||||
|
(se.purpose == "Manufacture")
|
||||||
|
& (sed.t_warehouse.isnull() | (sed.t_warehouse == ""))
|
||||||
|
& (se.docstatus == 1)
|
||||||
|
& (se.work_order == self.name)
|
||||||
|
)
|
||||||
|
.run(as_dict=1)
|
||||||
)
|
)
|
||||||
|
|
||||||
bom = frappe.new_doc("BOM")
|
bom = frappe.new_doc("BOM")
|
||||||
|
|||||||
@@ -169,15 +169,20 @@ class Workstation(Document):
|
|||||||
def validate_overlap_for_operation_timings(self):
|
def validate_overlap_for_operation_timings(self):
|
||||||
"""Check if there is no overlap in setting Workstation Operating Hours"""
|
"""Check if there is no overlap in setting Workstation Operating Hours"""
|
||||||
for d in self.get("working_hours"):
|
for d in self.get("working_hours"):
|
||||||
existing = frappe.db.sql_list(
|
wh = frappe.qb.DocType("Workstation Working Hour")
|
||||||
"""select idx from `tabWorkstation Working Hour`
|
existing = (
|
||||||
where parent = %s and name != %s
|
frappe.qb.from_(wh)
|
||||||
and (
|
.select(wh.idx)
|
||||||
(start_time between %s and %s) or
|
.where(
|
||||||
(end_time between %s and %s) or
|
(wh.parent == self.name)
|
||||||
(%s between start_time and end_time))
|
& (wh.name != d.name)
|
||||||
""",
|
& (
|
||||||
(self.name, d.name, d.start_time, d.end_time, d.start_time, d.end_time, d.start_time),
|
wh.start_time.between(d.start_time, d.end_time)
|
||||||
|
| wh.end_time.between(d.start_time, d.end_time)
|
||||||
|
| ((wh.start_time <= d.start_time) & (wh.end_time >= d.start_time))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run(pluck=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
if existing:
|
if existing:
|
||||||
@@ -187,17 +192,20 @@ class Workstation(Document):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def update_bom_operation(self):
|
def update_bom_operation(self):
|
||||||
bom_list = frappe.db.sql(
|
bom_list = frappe.get_all(
|
||||||
"""select DISTINCT parent from `tabBOM Operation`
|
"BOM Operation",
|
||||||
where workstation = %s and parenttype = 'routing' """,
|
filters={"workstation": self.name, "parenttype": "routing"},
|
||||||
self.name,
|
pluck="parent",
|
||||||
|
distinct=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
for bom_no in bom_list:
|
if bom_list:
|
||||||
frappe.db.sql(
|
bom_op = frappe.qb.DocType("BOM Operation")
|
||||||
"""update `tabBOM Operation` set hour_rate = %s
|
(
|
||||||
where parent = %s and workstation = %s""",
|
frappe.qb.update(bom_op)
|
||||||
(self.hour_rate, bom_no[0], self.name),
|
.set(bom_op.hour_rate, self.hour_rate)
|
||||||
|
.where(bom_op.parent.isin(bom_list) & (bom_op.workstation == self.name))
|
||||||
|
.run()
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=False):
|
def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=False):
|
||||||
@@ -451,12 +459,15 @@ def check_workstation_for_holiday(workstation, from_datetime, to_datetime):
|
|||||||
holiday_list = frappe.db.get_value("Workstation", workstation, "holiday_list")
|
holiday_list = frappe.db.get_value("Workstation", workstation, "holiday_list")
|
||||||
if holiday_list and from_datetime and to_datetime:
|
if holiday_list and from_datetime and to_datetime:
|
||||||
applicable_holidays = []
|
applicable_holidays = []
|
||||||
for d in frappe.db.sql(
|
for holiday_date in frappe.get_all(
|
||||||
"""select holiday_date from `tabHoliday` where parent = %s
|
"Holiday",
|
||||||
and holiday_date between %s and %s """,
|
filters={
|
||||||
(holiday_list, getdate(from_datetime), getdate(to_datetime)),
|
"parent": holiday_list,
|
||||||
|
"holiday_date": ["between", [getdate(from_datetime), getdate(to_datetime)]],
|
||||||
|
},
|
||||||
|
pluck="holiday_date",
|
||||||
):
|
):
|
||||||
applicable_holidays.append(formatdate(d[0]))
|
applicable_holidays.append(formatdate(holiday_date))
|
||||||
|
|
||||||
if applicable_holidays:
|
if applicable_holidays:
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.query_builder.functions import Floor, IfNull, Sum
|
from frappe.query_builder.functions import Floor, IfNull, Max, Min, Sum
|
||||||
from frappe.utils import flt
|
from frappe.utils import flt
|
||||||
from frappe.utils.data import comma_and
|
from frappe.utils.data import comma_and
|
||||||
from pypika.terms import ExistsCriterion
|
from pypika.terms import ExistsCriterion
|
||||||
@@ -202,14 +202,15 @@ def get_bom_data(filters):
|
|||||||
.on(bom_item.item_code == bin.item_code)
|
.on(bom_item.item_code == bin.item_code)
|
||||||
.select(
|
.select(
|
||||||
bom_item.item_code,
|
bom_item.item_code,
|
||||||
bom_item.description,
|
# non-grouped columns are constant per grouped item_code -> Max() keeps the GROUP BY valid
|
||||||
bom_item.parent.as_("from_bom_no"),
|
Max(bom_item.description).as_("description"),
|
||||||
|
Max(bom_item.parent).as_("from_bom_no"),
|
||||||
Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"),
|
Sum(bom_item.qty_consumed_per_unit).as_("qty_per_unit"),
|
||||||
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
|
IfNull(Sum(bin.actual_qty), 0).as_("actual_qty"),
|
||||||
)
|
)
|
||||||
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
|
.where((bom_item.parent == filters.get("bom")) & (bom_item.parenttype == "BOM"))
|
||||||
.groupby(bom_item.item_code)
|
.groupby(bom_item.item_code)
|
||||||
.orderby(bom_item.idx)
|
.orderby(Min(bom_item.idx))
|
||||||
)
|
)
|
||||||
|
|
||||||
if filters.get("warehouse"):
|
if filters.get("warehouse"):
|
||||||
@@ -233,7 +234,9 @@ def get_bom_data(filters):
|
|||||||
query = query.where(bin.warehouse == filters.get("warehouse"))
|
query = query.where(bin.warehouse == filters.get("warehouse"))
|
||||||
|
|
||||||
if bom_item_table == "BOM Item":
|
if bom_item_table == "BOM Item":
|
||||||
query = query.select(bom_item.bom_no, bom_item.is_phantom_item)
|
query = query.select(
|
||||||
|
Max(bom_item.bom_no).as_("bom_no"), Max(bom_item.is_phantom_item).as_("is_phantom_item")
|
||||||
|
)
|
||||||
|
|
||||||
data = query.run(as_dict=True)
|
data = query.run(as_dict=True)
|
||||||
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
|
return explode_phantom_boms(data, filters) if bom_item_table == "BOM Item" else data
|
||||||
@@ -312,15 +315,17 @@ def get_producible_fg_items(filters):
|
|||||||
.on(BOM_ITEM.item_code == bin_subquery.item_code)
|
.on(BOM_ITEM.item_code == bin_subquery.item_code)
|
||||||
.select(
|
.select(
|
||||||
BOM_ITEM.item_code,
|
BOM_ITEM.item_code,
|
||||||
BOM_ITEM.description,
|
# Sum() below makes this an aggregate query; the other columns are constant per grouped
|
||||||
BOM_ITEM.parent.as_("from_bom_no"),
|
# item_code -> Max() keeps them valid on postgres with the same value MySQL picked.
|
||||||
(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
|
Max(BOM_ITEM.description).as_("description"),
|
||||||
IfNull(bin_subquery.actual_qty, 0).as_("available_qty"),
|
Max(BOM_ITEM.parent).as_("from_bom_no"),
|
||||||
Floor(bin_subquery.actual_qty / ((Sum(BOM_ITEM.stock_qty)) / BOM.quantity)),
|
Max(BOM_ITEM.stock_qty / BOM.quantity).as_("qty_per_unit"),
|
||||||
|
Max(IfNull(bin_subquery.actual_qty, 0)).as_("available_qty"),
|
||||||
|
Floor(Max(bin_subquery.actual_qty) / ((Sum(BOM_ITEM.stock_qty)) / Max(BOM.quantity))),
|
||||||
)
|
)
|
||||||
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
|
.where((BOM_ITEM.parent == filters.get("bom")) & (BOM_ITEM.parenttype == "BOM"))
|
||||||
.groupby(BOM_ITEM.item_code)
|
.groupby(BOM_ITEM.item_code)
|
||||||
.orderby(BOM_ITEM.idx)
|
.orderby(Min(BOM_ITEM.idx))
|
||||||
)
|
)
|
||||||
|
|
||||||
return query.run(as_list=True)
|
return query.run(as_list=True)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.query_builder.functions import Sum
|
from frappe.query_builder.functions import Max, Sum
|
||||||
|
|
||||||
Filters = frappe._dict
|
Filters = frappe._dict
|
||||||
Row = frappe._dict
|
Row = frappe._dict
|
||||||
@@ -29,12 +29,14 @@ def get_data(filters: Filters) -> Data:
|
|||||||
.inner_join(se)
|
.inner_join(se)
|
||||||
.on(wo.name == se.work_order)
|
.on(wo.name == se.work_order)
|
||||||
.select(
|
.select(
|
||||||
wo.name,
|
# grouped by se.work_order (== wo.name); the work-order columns are constant per group ->
|
||||||
wo.status,
|
# Max() keeps the GROUP BY valid on postgres with the same value.
|
||||||
wo.production_item,
|
Max(wo.name).as_("name"),
|
||||||
wo.produced_qty,
|
Max(wo.status).as_("status"),
|
||||||
wo.process_loss_qty,
|
Max(wo.production_item).as_("production_item"),
|
||||||
wo.qty.as_("qty_to_manufacture"),
|
Max(wo.produced_qty).as_("produced_qty"),
|
||||||
|
Max(wo.process_loss_qty).as_("process_loss_qty"),
|
||||||
|
Max(wo.qty).as_("qty_to_manufacture"),
|
||||||
Sum(se.total_incoming_value).as_("total_fg_value"),
|
Sum(se.total_incoming_value).as_("total_fg_value"),
|
||||||
Sum(se.total_outgoing_value).as_("total_rm_value"),
|
Sum(se.total_outgoing_value).as_("total_rm_value"),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,7 +47,10 @@ def get_item_list(wo_list, filters):
|
|||||||
& (bom_item.item_code == wo_item_details.item_code)
|
& (bom_item.item_code == wo_item_details.item_code)
|
||||||
& (bom.name == wo_details.bom_no)
|
& (bom.name == wo_details.bom_no)
|
||||||
)
|
)
|
||||||
.groupby(bom_item.item_code)
|
# build_qty multiplies columns from bin/bom/bom_item that aren't functionally
|
||||||
|
# dependent on the grouped item_code, so postgres requires them in the GROUP BY.
|
||||||
|
# The WHERE pins bom, item and warehouse to single rows, so this stays one row.
|
||||||
|
.groupby(bom_item.item_code, bom.quantity, bom_item.stock_qty, bin.actual_qty)
|
||||||
).run(as_dict=1)
|
).run(as_dict=1)
|
||||||
|
|
||||||
stock_qty = 0
|
stock_qty = 0
|
||||||
|
|||||||
@@ -43,9 +43,13 @@ class ActivityCost(Document):
|
|||||||
|
|
||||||
def check_unique(self):
|
def check_unique(self):
|
||||||
if self.employee:
|
if self.employee:
|
||||||
if frappe.db.sql(
|
if frappe.db.exists(
|
||||||
"""select name from `tabActivity Cost` where employee_name= %s and activity_type= %s and name != %s""",
|
"Activity Cost",
|
||||||
(self.employee_name, self.activity_type, self.name),
|
{
|
||||||
|
"employee_name": self.employee_name,
|
||||||
|
"activity_type": self.activity_type,
|
||||||
|
"name": ["!=", self.name],
|
||||||
|
},
|
||||||
):
|
):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Activity Cost exists for Employee {0} against Activity Type - {1}").format(
|
_("Activity Cost exists for Employee {0} against Activity Type - {1}").format(
|
||||||
@@ -54,9 +58,13 @@ class ActivityCost(Document):
|
|||||||
DuplicationError,
|
DuplicationError,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
if frappe.db.sql(
|
if frappe.db.exists(
|
||||||
"""select name from `tabActivity Cost` where ifnull(employee, '')='' and activity_type= %s and name != %s""",
|
"Activity Cost",
|
||||||
(self.activity_type, self.name),
|
{
|
||||||
|
"employee": ["is", "not set"],
|
||||||
|
"activity_type": self.activity_type,
|
||||||
|
"name": ["!=", self.name],
|
||||||
|
},
|
||||||
):
|
):
|
||||||
frappe.throw(
|
frappe.throw(
|
||||||
_("Default Activity Cost exists for Activity Type - {0}").format(self.activity_type),
|
_("Default Activity Cost exists for Activity Type - {0}").format(self.activity_type),
|
||||||
|
|||||||
@@ -4,15 +4,14 @@
|
|||||||
import frappe
|
import frappe
|
||||||
from email_reply_parser import EmailReplyParser
|
from email_reply_parser import EmailReplyParser
|
||||||
from frappe import _, qb
|
from frappe import _, qb
|
||||||
from frappe.desk.reportview import get_match_cond
|
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
from frappe.query_builder import Interval
|
from frappe.query_builder import Case, Interval
|
||||||
from frappe.query_builder.functions import Count, CurDate, Date, Sum, UnixTimestamp
|
from frappe.query_builder.functions import Count, CurDate, Date, Locate, Sum, UnixTimestamp
|
||||||
from frappe.utils import add_days, flt, get_datetime, get_link_to_form, get_time, nowtime, today
|
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 frappe.utils.user import is_website_user
|
||||||
|
from pypika import Order
|
||||||
|
|
||||||
from erpnext import get_default_company
|
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.controllers.website_list_for_contact import get_customers_suppliers
|
||||||
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
|
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
|
||||||
|
|
||||||
@@ -74,16 +73,15 @@ class Project(Document):
|
|||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
def onload(self):
|
def onload(self):
|
||||||
|
timesheet_detail = frappe.qb.DocType("Timesheet Detail")
|
||||||
self.set_onload(
|
self.set_onload(
|
||||||
"activity_summary",
|
"activity_summary",
|
||||||
frappe.db.sql(
|
frappe.qb.from_(timesheet_detail)
|
||||||
"""select activity_type,
|
.select(timesheet_detail.activity_type, Sum(timesheet_detail.hours).as_("total_hours"))
|
||||||
sum(hours) as total_hours
|
.where((timesheet_detail.project == self.name) & (timesheet_detail.docstatus < 2))
|
||||||
from `tabTimesheet Detail` where project=%s and docstatus < 2 group by activity_type
|
.groupby(timesheet_detail.activity_type)
|
||||||
order by total_hours desc""",
|
.orderby("total_hours", order=frappe.qb.desc)
|
||||||
self.name,
|
.run(as_dict=True),
|
||||||
as_dict=True,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def before_print(self, settings=None):
|
def before_print(self, settings=None):
|
||||||
@@ -102,7 +100,7 @@ class Project(Document):
|
|||||||
"""
|
"""
|
||||||
Copy tasks from template
|
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
|
# has a template, and no loaded tasks, so lets create
|
||||||
if not self.expected_start_date:
|
if not self.expected_start_date:
|
||||||
# project starts today
|
# project starts today
|
||||||
@@ -267,32 +265,25 @@ class Project(Document):
|
|||||||
if (self.percent_complete_method == "Task Completion" and total > 0) or (
|
if (self.percent_complete_method == "Task Completion" and total > 0) or (
|
||||||
not self.percent_complete_method and total > 0
|
not self.percent_complete_method and total > 0
|
||||||
):
|
):
|
||||||
completed = frappe.db.sql(
|
completed = frappe.db.count(
|
||||||
"""select count(name) from tabTask where
|
"Task", {"project": self.name, "status": ["in", ["Cancelled", "Completed"]]}
|
||||||
project=%s and status in ('Cancelled', 'Completed')""",
|
)
|
||||||
self.name,
|
|
||||||
)[0][0]
|
|
||||||
self.percent_complete = flt(flt(completed) / total * 100, 2)
|
self.percent_complete = flt(flt(completed) / total * 100, 2)
|
||||||
|
|
||||||
if self.percent_complete_method == "Task Progress" and total > 0:
|
if self.percent_complete_method == "Task Progress" and total > 0:
|
||||||
progress = frappe.db.sql(
|
task = frappe.qb.DocType("Task")
|
||||||
"""select sum(progress) from tabTask where
|
progress = (
|
||||||
project=%s""",
|
frappe.qb.from_(task).select(Sum(task.progress)).where(task.project == self.name).run()
|
||||||
self.name,
|
|
||||||
)[0][0]
|
)[0][0]
|
||||||
self.percent_complete = flt(flt(progress) / total, 2)
|
self.percent_complete = flt(flt(progress) / total, 2)
|
||||||
|
|
||||||
if self.percent_complete_method == "Task Weight" and total > 0:
|
if self.percent_complete_method == "Task Weight" and total > 0:
|
||||||
weight_sum = frappe.db.sql(
|
task = frappe.qb.DocType("Task")
|
||||||
"""select sum(task_weight) from tabTask where
|
weight_sum = (
|
||||||
project=%s""",
|
frappe.qb.from_(task).select(Sum(task.task_weight)).where(task.project == self.name).run()
|
||||||
self.name,
|
|
||||||
)[0][0]
|
)[0][0]
|
||||||
weighted_progress = frappe.db.sql(
|
weighted_progress = frappe.get_all(
|
||||||
"""select progress, task_weight from tabTask where
|
"Task", filters={"project": self.name}, fields=["progress", "task_weight"]
|
||||||
project=%s""",
|
|
||||||
self.name,
|
|
||||||
as_dict=1,
|
|
||||||
)
|
)
|
||||||
pct_complete = 0
|
pct_complete = 0
|
||||||
for row in weighted_progress:
|
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
|
self.total_purchase_cost = total_purchase_cost and total_purchase_cost[0][0] or 0
|
||||||
|
|
||||||
def update_sales_amount(self):
|
def update_sales_amount(self):
|
||||||
total_sales_amount = frappe.db.sql(
|
so = frappe.qb.DocType("Sales Order")
|
||||||
"""select sum(base_net_total)
|
total_sales_amount = (
|
||||||
from `tabSales Order` where project = %s and docstatus=1""",
|
frappe.qb.from_(so)
|
||||||
self.name,
|
.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
|
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()
|
self.total_billed_amount = self.get_billed_amount_from_parent() + self.get_billed_amount_from_child()
|
||||||
|
|
||||||
def get_billed_amount_from_parent(self):
|
def get_billed_amount_from_parent(self):
|
||||||
total_billed_amount = frappe.db.sql(
|
si = frappe.qb.DocType("Sales Invoice")
|
||||||
"""select sum(base_net_amount)
|
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||||
from `tabSales Invoice` si join `tabSales Invoice Item` si_item on si_item.parent = si.name
|
total_billed_amount = (
|
||||||
where si_item.project is null
|
frappe.qb.from_(si)
|
||||||
and si.project is not null
|
.join(si_item)
|
||||||
and si.project = %s
|
.on(si_item.parent == si.name)
|
||||||
and si.docstatus = 1""",
|
.select(Sum(si_item.base_net_amount))
|
||||||
self.name,
|
.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
|
return total_billed_amount and total_billed_amount[0][0] or 0
|
||||||
|
|
||||||
def get_billed_amount_from_child(self):
|
def get_billed_amount_from_child(self):
|
||||||
total_billed_amount = frappe.db.sql(
|
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||||
"""select sum(base_net_amount)
|
total_billed_amount = (
|
||||||
from `tabSales Invoice Item`
|
frappe.qb.from_(si_item)
|
||||||
where project = %s
|
.select(Sum(si_item.base_net_amount))
|
||||||
and docstatus = 1""",
|
.where((si_item.project == self.name) & (si_item.docstatus == 1))
|
||||||
self.name,
|
.run()
|
||||||
)
|
)
|
||||||
|
|
||||||
return total_billed_amount and total_billed_amount[0][0] or 0
|
return total_billed_amount and total_billed_amount[0][0] or 0
|
||||||
@@ -499,28 +498,35 @@ def get_list_context(context=None):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def get_users_for_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
def get_users_for_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||||
conditions = []
|
User = frappe.qb.DocType("User")
|
||||||
return frappe.db.sql(
|
search_str = f"%{txt}%"
|
||||||
"""select name, concat_ws(' ', first_name, middle_name, last_name)
|
txt_no_percent = txt.replace("%", "")
|
||||||
from `tabUser`
|
|
||||||
where enabled=1
|
query = frappe.qb.get_query(
|
||||||
and name not in ("Guest", "Administrator")
|
"User",
|
||||||
and ({key} like %(txt)s
|
fields=["name", "full_name"],
|
||||||
or full_name like %(txt)s)
|
filters=filters,
|
||||||
{fcond} {mcond}
|
ignore_permissions=False,
|
||||||
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),
|
return (
|
||||||
idx desc,
|
query.where(User.enabled == 1)
|
||||||
name, full_name
|
.where(User.name.notin(["Guest", "Administrator"]))
|
||||||
limit %(page_len)s offset %(start)s""".format(
|
.where(User[searchfield].like(search_str) | User.full_name.like(search_str))
|
||||||
**{
|
.orderby(
|
||||||
"key": searchfield,
|
Case().when(Locate(txt_no_percent, User.name) > 0, Locate(txt_no_percent, User.name)).else_(99999)
|
||||||
"fcond": get_filters_cond(doctype, filters, conditions),
|
)
|
||||||
"mcond": get_match_cond(doctype),
|
.orderby(
|
||||||
}
|
Case()
|
||||||
),
|
.when(Locate(txt_no_percent, User.full_name) > 0, Locate(txt_no_percent, User.full_name))
|
||||||
{"txt": "%%%s%%" % txt, "_txt": txt.replace("%", ""), "start": start, "page_len": page_len},
|
.else_(99999)
|
||||||
|
)
|
||||||
|
.orderby(User.idx, order=Order.desc)
|
||||||
|
.orderby(User.name)
|
||||||
|
.orderby(User.full_name)
|
||||||
|
.limit(page_len)
|
||||||
|
.offset(start)
|
||||||
|
.run()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -580,11 +586,7 @@ def weekly_reminder():
|
|||||||
|
|
||||||
|
|
||||||
def allow_to_make_project_update(project, time, frequency):
|
def allow_to_make_project_update(project, time, frequency):
|
||||||
data = frappe.db.sql(
|
data = frappe.get_all("Project Update", filters={"project": project, "date": today()}, pluck="name")
|
||||||
""" SELECT name from `tabProject Update`
|
|
||||||
WHERE project = %s and date = %s """,
|
|
||||||
(project, today()),
|
|
||||||
)
|
|
||||||
|
|
||||||
# len(data) > 1 condition is checked for twicely frequency
|
# len(data) > 1 condition is checked for twicely frequency
|
||||||
if data and (frequency in ["Daily", "Weekly"] or len(data) > 1):
|
if data and (frequency in ["Daily", "Weekly"] or len(data) > 1):
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class TestProject(ERPNextTestSuite):
|
|||||||
|
|
||||||
def test_project_with_template_having_no_parent_and_depend_tasks(self):
|
def test_project_with_template_having_no_parent_and_depend_tasks(self):
|
||||||
project_name = "Test Project with Template - No Parent and Dependend Tasks"
|
project_name = "Test Project with Template - No Parent and Dependend Tasks"
|
||||||
frappe.db.sql(""" delete from tabTask where project = %s """, project_name)
|
frappe.db.delete("Task", {"project": project_name})
|
||||||
frappe.delete_doc("Project", project_name)
|
frappe.delete_doc("Project", project_name)
|
||||||
|
|
||||||
task1 = task_exists("Test Template Task with No Parent and Dependency")
|
task1 = task_exists("Test Template Task with No Parent and Dependency")
|
||||||
@@ -67,7 +67,7 @@ class TestProject(ERPNextTestSuite):
|
|||||||
if frappe.db.get_value("Project", {"project_name": project_name}, "name"):
|
if frappe.db.get_value("Project", {"project_name": project_name}, "name"):
|
||||||
project_name = frappe.db.get_value("Project", {"project_name": project_name}, "name")
|
project_name = frappe.db.get_value("Project", {"project_name": project_name}, "name")
|
||||||
|
|
||||||
frappe.db.sql(""" delete from tabTask where project = %s """, project_name)
|
frappe.db.delete("Task", {"project": project_name})
|
||||||
frappe.delete_doc("Project", project_name)
|
frappe.delete_doc("Project", project_name)
|
||||||
|
|
||||||
task1 = task_exists("Test Template Task Parent")
|
task1 = task_exists("Test Template Task Parent")
|
||||||
@@ -122,7 +122,7 @@ class TestProject(ERPNextTestSuite):
|
|||||||
|
|
||||||
def test_project_template_having_dependent_tasks(self):
|
def test_project_template_having_dependent_tasks(self):
|
||||||
project_name = "Test Project with Template - Dependent Tasks"
|
project_name = "Test Project with Template - Dependent Tasks"
|
||||||
frappe.db.sql(""" delete from tabTask where project = %s """, project_name)
|
frappe.db.delete("Task", {"project": project_name})
|
||||||
frappe.delete_doc("Project", project_name)
|
frappe.delete_doc("Project", project_name)
|
||||||
|
|
||||||
task1 = task_exists("Test Template Task for Dependency")
|
task1 = task_exists("Test Template Task for Dependency")
|
||||||
@@ -218,7 +218,7 @@ class TestProject(ERPNextTestSuite):
|
|||||||
|
|
||||||
def test_project_having_no_tasks_complete(self):
|
def test_project_having_no_tasks_complete(self):
|
||||||
project_name = "Test Project - No Tasks Completion"
|
project_name = "Test Project - No Tasks Completion"
|
||||||
frappe.db.sql(""" delete from tabTask where project = %s """, project_name)
|
frappe.db.delete("Task", {"project": project_name})
|
||||||
frappe.delete_doc("Project", project_name)
|
frappe.delete_doc("Project", project_name)
|
||||||
|
|
||||||
project = frappe.get_doc(
|
project = frappe.get_doc(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils import add_days, today
|
||||||
|
|
||||||
|
|
||||||
class ProjectUpdate(Document):
|
class ProjectUpdate(Document):
|
||||||
@@ -31,30 +32,34 @@ class ProjectUpdate(Document):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def daily_reminder():
|
def daily_reminder():
|
||||||
project = frappe.db.sql(
|
projects = frappe.get_all(
|
||||||
"""SELECT `tabProject`.project_name,`tabProject`.frequency,`tabProject`.expected_start_date,`tabProject`.expected_end_date,`tabProject`.percent_complete FROM `tabProject`;"""
|
"Project",
|
||||||
|
fields=[
|
||||||
|
"project_name",
|
||||||
|
"frequency",
|
||||||
|
"expected_start_date",
|
||||||
|
"expected_end_date",
|
||||||
|
"percent_complete",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
for projects in project:
|
for project in projects:
|
||||||
project_name = projects[0]
|
project_name = project.project_name
|
||||||
frequency = projects[1]
|
frequency = project.frequency
|
||||||
date_start = projects[2]
|
date_start = project.expected_start_date
|
||||||
date_end = projects[3]
|
date_end = project.expected_end_date
|
||||||
progress = projects[4]
|
progress = project.percent_complete
|
||||||
draft = frappe.db.sql(
|
number_of_drafts = frappe.db.count("Project Update", {"project": project_name, "docstatus": 0})
|
||||||
"""SELECT count(docstatus) from `tabProject Update` WHERE `tabProject Update`.project = %s AND `tabProject Update`.docstatus = 0;""",
|
update = frappe.get_all(
|
||||||
project_name,
|
"Project Update",
|
||||||
)
|
filters={"project": project_name, "date": add_days(today(), -1)},
|
||||||
for drafts in draft:
|
fields=["name", "date", "time", "progress", "progress_details"],
|
||||||
number_of_drafts = drafts[0]
|
as_list=True,
|
||||||
update = frappe.db.sql(
|
|
||||||
"""SELECT name,date,time,progress,progress_details FROM `tabProject Update` WHERE `tabProject Update`.project = %s AND date = DATE_ADD(CURRENT_DATE, INTERVAL -1 DAY);""",
|
|
||||||
project_name,
|
|
||||||
)
|
)
|
||||||
email_sending(project_name, frequency, date_start, date_end, progress, number_of_drafts, update)
|
email_sending(project_name, frequency, date_start, date_end, progress, number_of_drafts, update)
|
||||||
|
|
||||||
|
|
||||||
def email_sending(project_name, frequency, date_start, date_end, progress, number_of_drafts, update):
|
def email_sending(project_name, frequency, date_start, date_end, progress, number_of_drafts, update):
|
||||||
holiday = frappe.db.sql("""SELECT holiday_date FROM `tabHoliday` where holiday_date = CURRENT_DATE;""")
|
holiday_today = frappe.db.exists("Holiday", {"holiday_date": today()})
|
||||||
msg = (
|
msg = (
|
||||||
"<p>Project Name: "
|
"<p>Project Name: "
|
||||||
+ project_name
|
+ project_name
|
||||||
@@ -98,9 +103,9 @@ def email_sending(project_name, frequency, date_start, date_end, progress, numbe
|
|||||||
)
|
)
|
||||||
|
|
||||||
msg += "</table>"
|
msg += "</table>"
|
||||||
if len(holiday) == 0:
|
if not holiday_today:
|
||||||
email = frappe.db.sql("""SELECT user from `tabProject User` WHERE parent = %s;""", project_name)
|
recipients = frappe.get_all("Project User", filters={"parent": project_name}, pluck="user")
|
||||||
for emails in email:
|
for user in recipients:
|
||||||
frappe.sendmail(recipients=emails, subject=frappe._(project_name + " " + "Summary"), message=msg)
|
frappe.sendmail(recipients=[user], subject=frappe._(project_name + " " + "Summary"), message=msg)
|
||||||
else:
|
else:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -76,10 +76,9 @@ class Task(NestedSet):
|
|||||||
nsm_parent_field = "parent_task"
|
nsm_parent_field = "parent_task"
|
||||||
|
|
||||||
def get_customer_details(self):
|
def get_customer_details(self):
|
||||||
cust = frappe.db.sql("select customer_name from `tabCustomer` where name=%s", self.customer)
|
customer_name = frappe.db.get_value("Customer", self.customer, "customer_name")
|
||||||
if cust:
|
if customer_name:
|
||||||
ret = {"customer_name": cust and cust[0][0] or ""}
|
return {"customer_name": customer_name or ""}
|
||||||
return ret
|
|
||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_dates()
|
self.validate_dates()
|
||||||
@@ -252,9 +251,11 @@ class Task(NestedSet):
|
|||||||
for d in check_list:
|
for d in check_list:
|
||||||
task_list, count = [self.name], 0
|
task_list, count = [self.name], 0
|
||||||
while len(task_list) > count:
|
while len(task_list) > count:
|
||||||
tasks = frappe.db.sql(
|
tasks = frappe.get_all(
|
||||||
" select {} from `tabTask Depends On` where {} = {} ".format(d[0], d[1], "%s"),
|
"Task Depends On",
|
||||||
cstr(task_list[count]),
|
filters={d[1]: cstr(task_list[count])},
|
||||||
|
fields=[d[0]],
|
||||||
|
as_list=True,
|
||||||
)
|
)
|
||||||
count = count + 1
|
count = count + 1
|
||||||
for b in tasks:
|
for b in tasks:
|
||||||
@@ -268,30 +269,34 @@ class Task(NestedSet):
|
|||||||
|
|
||||||
def reschedule_dependent_tasks(self):
|
def reschedule_dependent_tasks(self):
|
||||||
end_date = self.exp_end_date or self.act_end_date
|
end_date = self.exp_end_date or self.act_end_date
|
||||||
if end_date:
|
if not end_date:
|
||||||
for task_name in frappe.db.sql(
|
return
|
||||||
"""
|
|
||||||
select name from `tabTask` as parent
|
dependent_parents = frappe.get_all(
|
||||||
where parent.project = %(project)s
|
"Task Depends On",
|
||||||
and parent.name in (
|
filters={"task": self.name, "project": self.project},
|
||||||
select parent from `tabTask Depends On` as child
|
pluck="parent",
|
||||||
where child.task = %(task)s and child.project = %(project)s)
|
)
|
||||||
""",
|
if not dependent_parents:
|
||||||
{"project": self.project, "task": self.name},
|
return
|
||||||
as_dict=1,
|
|
||||||
|
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)
|
task_duration = date_diff(task.exp_end_date, task.exp_start_date)
|
||||||
if (
|
task.exp_start_date = add_days(end_date, 1)
|
||||||
task.exp_start_date
|
task.exp_end_date = add_days(task.exp_start_date, task_duration)
|
||||||
and task.exp_end_date
|
task.flags.ignore_recursion_check = True
|
||||||
and task.exp_start_date < end_date
|
task.save()
|
||||||
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()
|
|
||||||
|
|
||||||
def has_webform_permission(self):
|
def has_webform_permission(self):
|
||||||
project_user = frappe.db.get_value(
|
project_user = frappe.db.get_value(
|
||||||
@@ -337,27 +342,23 @@ def check_if_child_exists(name: str):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
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 = frappe.get_meta(doctype).get_search_fields()
|
||||||
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)
|
|
||||||
|
|
||||||
return frappe.db.sql(
|
Project = frappe.qb.DocType("Project")
|
||||||
f""" select name {search_columns} from `tabProject`
|
search_str = f"%{txt}%"
|
||||||
where %(key)s like %(txt)s
|
search_fields = list(dict.fromkeys([searchfield, *searchfields]))
|
||||||
%(mcond)s
|
search_conditions = [Project[field].like(search_str) for field in search_fields]
|
||||||
{search_cond}
|
|
||||||
order by name
|
query = frappe.qb.get_query("Project", fields=["name", *searchfields], ignore_permissions=False)
|
||||||
limit %(page_len)s offset %(start)s""",
|
|
||||||
{
|
return (
|
||||||
"key": searchfield,
|
query.where(Criterion.any(search_conditions))
|
||||||
"txt": "%" + txt + "%",
|
.orderby(Project.name)
|
||||||
"mcond": get_match_cond(doctype),
|
.limit(page_len)
|
||||||
"start": start,
|
.offset(start)
|
||||||
"page_len": page_len,
|
.run()
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import json
|
|||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.model.document import Document
|
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 import flt, get_datetime, getdate
|
||||||
from frappe.utils.deprecations import deprecated
|
from frappe.utils.deprecations import deprecated
|
||||||
|
|
||||||
from erpnext.controllers.queries import get_match_cond
|
|
||||||
from erpnext.setup.utils import get_exchange_rate
|
from erpnext.setup.utils import get_exchange_rate
|
||||||
|
|
||||||
|
|
||||||
@@ -308,41 +308,41 @@ def get_projectwise_timesheet_data(
|
|||||||
from_time: str | None = None,
|
from_time: str | None = None,
|
||||||
to_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:
|
if project:
|
||||||
condition += "AND tsd.project = %(project)s "
|
query = query.where(tsd.project == project)
|
||||||
if parent:
|
if parent:
|
||||||
condition += "AND tsd.parent = %(parent)s "
|
query = query.where(tsd.parent == parent)
|
||||||
if from_time and to_time:
|
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"""
|
return query.orderby(tsd.from_time).run(as_dict=1)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@@ -372,25 +372,28 @@ def get_timesheet(doctype: str, txt: str, searchfield: str, start: int, page_len
|
|||||||
if not filters:
|
if not filters:
|
||||||
filters = {}
|
filters = {}
|
||||||
|
|
||||||
condition = ""
|
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||||
if filters.get("project"):
|
ts = frappe.qb.DocType("Timesheet")
|
||||||
condition = "and tsd.project = %(project)s"
|
|
||||||
|
|
||||||
return frappe.db.sql(
|
query = (
|
||||||
f"""select distinct tsd.parent from `tabTimesheet Detail` tsd,
|
frappe.qb.from_(tsd)
|
||||||
`tabTimesheet` ts where
|
.inner_join(ts)
|
||||||
ts.status in ('Submitted', 'Payslip') and tsd.parent = ts.name and
|
.on(tsd.parent == ts.name)
|
||||||
tsd.docstatus = 1 and ts.total_billable_amount > 0
|
.select(tsd.parent)
|
||||||
and tsd.parent LIKE %(txt)s {condition}
|
.distinct()
|
||||||
order by tsd.parent limit %(page_len)s offset %(start)s""",
|
.where(
|
||||||
{
|
ts.status.isin(["Submitted", "Payslip"])
|
||||||
"txt": "%" + txt + "%",
|
& (tsd.docstatus == 1)
|
||||||
"start": start,
|
& (ts.total_billable_amount > 0)
|
||||||
"page_len": page_len,
|
& tsd.parent.like(f"%{txt}%")
|
||||||
"project": filters.get("project"),
|
)
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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()
|
@frappe.whitelist()
|
||||||
def get_timesheet_data(name: str, project: str):
|
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 end: End date-time.
|
||||||
:param filters: Filters (JSON).
|
:param filters: Filters (JSON).
|
||||||
"""
|
"""
|
||||||
filters = json.loads(filters) if filters else {}
|
|
||||||
from frappe.desk.calendar import get_event_conditions
|
from frappe.desk.calendar import get_event_conditions
|
||||||
|
|
||||||
conditions = get_event_conditions("Timesheet", filters)
|
filters = json.loads(filters) if filters else {}
|
||||||
|
|
||||||
return frappe.db.sql(
|
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||||
"""select `tabTimesheet Detail`.name as name,
|
ts = frappe.qb.DocType("Timesheet")
|
||||||
`tabTimesheet Detail`.docstatus as status, `tabTimesheet Detail`.parent as parent,
|
|
||||||
from_time as start_date, hours, activity_type,
|
query = (
|
||||||
`tabTimesheet Detail`.project, to_time as end_date,
|
frappe.qb.from_(tsd)
|
||||||
CONCAT(`tabTimesheet Detail`.parent, ' (', ROUND(hours,2),' hrs)') as title
|
.inner_join(ts)
|
||||||
from `tabTimesheet Detail`, `tabTimesheet`
|
.on(tsd.parent == ts.name)
|
||||||
where `tabTimesheet Detail`.parent = `tabTimesheet`.name
|
.select(
|
||||||
and `tabTimesheet`.docstatus < 2
|
tsd.name.as_("name"),
|
||||||
and (from_time <= %(end)s and to_time >= %(start)s) {conditions} {match_cond}
|
tsd.docstatus.as_("status"),
|
||||||
""".format(conditions=conditions, match_cond=get_match_cond("Timesheet")),
|
tsd.parent.as_("parent"),
|
||||||
{"start": start, "end": end},
|
tsd.from_time.as_("start_date"),
|
||||||
as_dict=True,
|
tsd.hours,
|
||||||
update={"allDay": 0},
|
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("Timesheet", filters, as_qb=True):
|
||||||
|
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"):
|
def get_timesheets_list(doctype, txt, filters, limit_start, limit_page_length=20, order_by="creation"):
|
||||||
user = frappe.session.user
|
user = frappe.session.user
|
||||||
|
|||||||
@@ -4,19 +4,17 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
from frappe.desk.reportview import build_match_conditions
|
from frappe.desk.reportview import get_match_conditions_qb
|
||||||
|
from frappe.utils import add_days, getdate
|
||||||
|
|
||||||
|
from erpnext.stock.utils import get_combine_datetime
|
||||||
|
|
||||||
|
|
||||||
def execute(filters=None):
|
def execute(filters=None):
|
||||||
if not filters:
|
filters = filters or {}
|
||||||
filters = {}
|
|
||||||
elif filters.get("from_date") or filters.get("to_date"):
|
|
||||||
filters["from_time"] = "00:00:00"
|
|
||||||
filters["to_time"] = "24:00:00"
|
|
||||||
|
|
||||||
columns = get_column()
|
columns = get_column()
|
||||||
conditions = get_conditions(filters)
|
data = get_data(filters)
|
||||||
data = get_data(conditions, filters)
|
|
||||||
|
|
||||||
return columns, data
|
return columns, data
|
||||||
|
|
||||||
@@ -36,30 +34,40 @@ def get_column():
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_data(conditions, filters):
|
def get_data(filters):
|
||||||
time_sheet = frappe.db.sql(
|
ts = frappe.qb.DocType("Timesheet")
|
||||||
""" select `tabTimesheet`.name, `tabTimesheet`.employee, `tabTimesheet`.employee_name,
|
tsd = frappe.qb.DocType("Timesheet Detail")
|
||||||
`tabTimesheet Detail`.from_time, `tabTimesheet Detail`.to_time, `tabTimesheet Detail`.hours,
|
|
||||||
`tabTimesheet Detail`.activity_type, `tabTimesheet Detail`.task, `tabTimesheet Detail`.project,
|
query = (
|
||||||
`tabTimesheet`.status from `tabTimesheet Detail`, `tabTimesheet` where
|
frappe.qb.from_(tsd)
|
||||||
`tabTimesheet Detail`.parent = `tabTimesheet`.name and %s order by `tabTimesheet`.name"""
|
.inner_join(ts)
|
||||||
% (conditions),
|
.on(tsd.parent == ts.name)
|
||||||
filters,
|
.select(
|
||||||
as_list=1,
|
ts.name,
|
||||||
|
ts.employee,
|
||||||
|
ts.employee_name,
|
||||||
|
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"):
|
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"):
|
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")
|
# apply Timesheet user-permission match conditions (query-builder form of build_match_conditions)
|
||||||
if match_conditions:
|
for condition in get_match_conditions_qb("Timesheet"):
|
||||||
conditions += " and (%s)" % match_conditions
|
query = query.where(condition)
|
||||||
|
|
||||||
return conditions
|
return query.orderby(ts.name).run(as_list=True)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import _
|
from frappe import _
|
||||||
|
from frappe.query_builder.functions import Sum
|
||||||
|
|
||||||
|
|
||||||
def execute(filters=None):
|
def execute(filters=None):
|
||||||
@@ -50,19 +51,28 @@ def get_columns():
|
|||||||
|
|
||||||
|
|
||||||
def get_project_details():
|
def get_project_details():
|
||||||
return frappe.db.sql(
|
return frappe.get_all(
|
||||||
""" select name, project_name, status, company, customer, estimated_costing,
|
"Project",
|
||||||
expected_start_date, expected_end_date from tabProject where docstatus < 2""",
|
filters={"docstatus": ["<", 2]},
|
||||||
as_dict=1,
|
fields=[
|
||||||
|
"name",
|
||||||
|
"project_name",
|
||||||
|
"status",
|
||||||
|
"company",
|
||||||
|
"customer",
|
||||||
|
"estimated_costing",
|
||||||
|
"expected_start_date",
|
||||||
|
"expected_end_date",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_purchased_items_cost():
|
def get_purchased_items_cost():
|
||||||
pr_items = frappe.db.sql(
|
pr_items = frappe.get_all(
|
||||||
"""select project, sum(base_net_amount) as amount
|
"Purchase Receipt Item",
|
||||||
from `tabPurchase Receipt Item` where ifnull(project, '') != ''
|
filters={"project": ["is", "set"], "docstatus": 1},
|
||||||
and docstatus = 1 group by project""",
|
fields=["project", {"SUM": "base_net_amount", "as": "amount"}],
|
||||||
as_dict=1,
|
group_by="project",
|
||||||
)
|
)
|
||||||
|
|
||||||
pr_item_map = {}
|
pr_item_map = {}
|
||||||
@@ -73,12 +83,20 @@ def get_purchased_items_cost():
|
|||||||
|
|
||||||
|
|
||||||
def get_issued_items_cost():
|
def get_issued_items_cost():
|
||||||
se_items = frappe.db.sql(
|
se = frappe.qb.DocType("Stock Entry")
|
||||||
"""select se.project, sum(se_item.amount) as amount
|
se_item = frappe.qb.DocType("Stock Entry Detail")
|
||||||
from `tabStock Entry` se, `tabStock Entry Detail` se_item
|
se_items = (
|
||||||
where se.name = se_item.parent and se.docstatus = 1 and ifnull(se_item.t_warehouse, '') = ''
|
frappe.qb.from_(se)
|
||||||
and se.project != '' group by se.project""",
|
.inner_join(se_item)
|
||||||
as_dict=1,
|
.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 = {}
|
se_item_map = {}
|
||||||
@@ -89,21 +107,28 @@ def get_issued_items_cost():
|
|||||||
|
|
||||||
|
|
||||||
def get_delivered_items_cost():
|
def get_delivered_items_cost():
|
||||||
dn_items = frappe.db.sql(
|
dn = frappe.qb.DocType("Delivery Note")
|
||||||
"""select dn.project, sum(dn_item.base_net_amount) as amount
|
dn_item = frappe.qb.DocType("Delivery Note Item")
|
||||||
from `tabDelivery Note` dn, `tabDelivery Note Item` dn_item
|
dn_items = (
|
||||||
where dn.name = dn_item.parent and dn.docstatus = 1 and ifnull(dn.project, '') != ''
|
frappe.qb.from_(dn)
|
||||||
group by dn.project""",
|
.inner_join(dn_item)
|
||||||
as_dict=1,
|
.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(
|
si = frappe.qb.DocType("Sales Invoice")
|
||||||
"""select si.project, sum(si_item.base_net_amount) as amount
|
si_item = frappe.qb.DocType("Sales Invoice Item")
|
||||||
from `tabSales Invoice` si, `tabSales Invoice Item` si_item
|
si_items = (
|
||||||
where si.name = si_item.parent and si.docstatus = 1 and si.update_stock = 1
|
frappe.qb.from_(si)
|
||||||
and si.is_pos = 1 and ifnull(si.project, '') != ''
|
.inner_join(si_item)
|
||||||
group by si.project""",
|
.on(si.name == si_item.parent)
|
||||||
as_dict=1,
|
.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 = {}
|
dn_item_map = {}
|
||||||
|
|||||||
@@ -5,28 +5,25 @@
|
|||||||
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
from frappe.query_builder import Case
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def query_task(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
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
|
Task = frappe.qb.DocType("Task")
|
||||||
order_by_string = "%s%%" % txt
|
query = frappe.qb.get_query("Task", fields=["name", "subject"], ignore_permissions=False)
|
||||||
match_conditions = build_match_conditions("Task")
|
|
||||||
match_conditions = (f"and ({match_conditions})") if match_conditions else ""
|
|
||||||
|
|
||||||
return frappe.db.sql(
|
return (
|
||||||
"""select name, subject from `tabTask`
|
query.where(Task[searchfield].like(search_str) | Task.subject.like(search_str))
|
||||||
where (`{}` like {} or `subject` like {}) {}
|
.orderby(Case().when(Task.subject.like(prefix_str), 0).else_(1))
|
||||||
order by
|
.orderby(Case().when(Task[searchfield].like(prefix_str), 0).else_(1))
|
||||||
case when `subject` like {} then 0 else 1 end,
|
.orderby(Task[searchfield])
|
||||||
case when `{}` like {} then 0 else 1 end,
|
.orderby(Task.subject)
|
||||||
`{}`,
|
.limit(page_len)
|
||||||
subject
|
.offset(start)
|
||||||
limit {} offset {}""".format(
|
.run()
|
||||||
searchfield, "%s", "%s", match_conditions, "%s", searchfield, "%s", searchfield, "%s", "%s"
|
|
||||||
),
|
|
||||||
(search_string, search_string, order_by_string, order_by_string, page_len, start),
|
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user