Asset lite updated changes from RC1

This commit is contained in:
Duradundi 2026-08-24 07:40:02 +00:00
parent eec1247c9a
commit eab723d294
35 changed files with 12717 additions and 5547 deletions

View File

@ -1,5 +1,51 @@
import frappe
from frappe import _
from datetime import datetime, timedelta
def _expand_list_filters(filters):
"""Expand UI filter keys (creation_from, etc.) into Frappe filter dict."""
if not filters:
return {}
expanded = dict(filters)
creation_from = expanded.pop('creation_from', None)
creation_to = expanded.pop('creation_to', None)
if creation_from and creation_to:
expanded['creation'] = [
'between',
[f"{str(creation_from)[:10]} 00:00:00", f"{str(creation_to)[:10]} 23:59:59"],
]
elif creation_from:
expanded['creation'] = ['>=', f"{str(creation_from)[:10]} 00:00:00"]
elif creation_to:
end = str(creation_to)[:10]
try:
d = datetime.strptime(end, '%Y-%m-%d') + timedelta(days=1)
end_exclusive = d.strftime('%Y-%m-%d')
except Exception:
end_exclusive = end
expanded['creation'] = ['<', f"{end_exclusive} 00:00:00"]
modified_from = expanded.pop('modified_from', None)
modified_to = expanded.pop('modified_to', None)
if modified_from and modified_to:
expanded['modified'] = [
'between',
[f"{str(modified_from)[:10]} 00:00:00", f"{str(modified_to)[:10]} 23:59:59"],
]
elif modified_from:
expanded['modified'] = ['>=', f"{str(modified_from)[:10]} 00:00:00"]
elif modified_to:
end = str(modified_to)[:10]
try:
d = datetime.strptime(end, '%Y-%m-%d') + timedelta(days=1)
end_exclusive = d.strftime('%Y-%m-%d')
except Exception:
end_exclusive = end
expanded['modified'] = ['<', f"{end_exclusive} 00:00:00"]
return expanded
@frappe.whitelist(allow_guest = True)
def get_assets(filters=None, fields=None, limit=20, offset=0, order_by=None, include_finance_books=True):
@ -31,7 +77,12 @@ def get_assets(filters=None, fields=None, limit=20, offset=0, order_by=None, inc
# Parse filters if provided
if filters and isinstance(filters, str):
filters = json.loads(filters)
filters = _expand_list_filters(filters or {})
from asset_lite.api.userperm_api import apply_permission_filters
filters = apply_permission_filters(filters, "Asset")
# Handle tree-based fields (Department is a nested set/tree structure)
if filters.get('department') and isinstance(filters['department'], str):
filters['department'] = ['descendants of (inclusive)', filters['department']]

View File

@ -29,6 +29,9 @@ def get_asset_maintenance_logs(filters=None, fields=None, limit=20, offset=0, or
# Parse filters if provided
if filters and isinstance(filters, str):
filters = json.loads(filters)
from asset_lite.api.dashboard_filters import expand_dashboard_filters
filters = expand_dashboard_filters(filters or {}, "Asset Maintenance Log")
# Parse fields if provided
if fields and isinstance(fields, str):

View File

@ -27,7 +27,12 @@ def get_user_details(user_id=None):
"last_login": user.last_login,
"enabled": user.enabled,
"creation": user.creation,
"modified": user.modified
"modified": user.modified,
"custom_site_name": user.get("custom_site_name") or "",
"custom_phcc_site_name": user.get("custom_phcc_site_name") or "",
"role_profile_name": user.get("role_profile_name") or "",
"custom_department": user.get("custom_department") or "",
"csrf_token": frappe.local.session.data.csrf_token or "",
}
frappe.response.message = response_data

View File

@ -15,31 +15,382 @@ def _err(msg, code=500):
@frappe.whitelist(allow_guest = True)
def get_number_cards():
def get_number_cards(company=None, site_name=None):
"""
Returns counts for Number Cards:
- total_assets
- work_orders_open
- work_orders_in_progress
- work_orders_completed
- work_orders_closed
Optional filters: company (Hospital), site_name (Mobile Team Site)
"""
try:
total_assets = frappe.db.count("Asset")
work_orders_open = frappe.db.count("Work Order", {"status": ["in", ["Not Started", "Open", "Pending"]]})
work_orders_in_progress = frappe.db.count("Work Order", {"status": ["in", ["In Process", "In Progress", "Started"]]})
work_orders_completed = frappe.db.count("Work Order", {"status": ["in", ["Completed", "Closed", "Finished"]]})
from asset_lite.api.dashboard_filters import expand_dashboard_filters
from asset_lite.api.userperm_api import get_permission_filters
asset_filters = expand_dashboard_filters({"company": company, "site_name": site_name}, "Asset")
wo_base = expand_dashboard_filters({"company": company, "site_name": site_name}, "Work_Order")
asset_perm = get_permission_filters("Asset").get("filters") or {}
wo_perm = get_permission_filters("Work_Order").get("filters") or {}
asset_filters = {**asset_perm, **(asset_filters or {})}
wo_base = {**wo_perm, **(wo_base or {})}
total_assets = frappe.db.count("Asset", asset_filters or {})
open_filters = {**wo_base, "repair_status": "Open"}
in_progress_filters = {**wo_base, "repair_status": "Work In Progress"}
completed_filters = {**wo_base, "repair_status": "Completed"}
closed_filters = {**wo_base, "repair_status": "Closed"}
work_orders_open = frappe.db.count("Work_Order", open_filters)
work_orders_in_progress = frappe.db.count("Work_Order", in_progress_filters)
work_orders_completed = frappe.db.count("Work_Order", completed_filters)
work_orders_closed = frappe.db.count("Work_Order", closed_filters)
_ok({
"total_assets": total_assets,
"work_orders_open": work_orders_open,
"work_orders_in_progress": work_orders_in_progress,
"work_orders_completed": work_orders_completed,
"work_orders_closed": work_orders_closed,
})
except Exception as e:
frappe.log_error(frappe.get_traceback(), "get_number_cards")
_err(str(e))
WO_STATUS_SORT_ORDER = [
"Open",
"Work In Progress",
"Pending Review",
"Completed",
"Closed",
"Rejected",
"Cancelled",
]
WO_STATUS_COLORS = {
"Open": "#F59E0B",
"Work In Progress": "#3B82F6",
"Pending Review": "#E11D48",
"Completed": "#10B981",
"Closed": "#A855F7",
"Rejected": "#EF4444",
"Cancelled": "#6B7280",
}
def _is_null_or_unknown_label(label):
key = (label or "").strip().lower()
return key in ("", "null", "unknown", "none", "undefined")
def _sort_status_labels(labels):
order = {name: idx for idx, name in enumerate(WO_STATUS_SORT_ORDER)}
def sort_key(label):
if label in order:
return (0, order[label])
return (1, label)
return sorted(labels, key=sort_key)
@frappe.whitelist(allow_guest=True)
def get_dashboard_work_order_metrics(
company=None,
site_name=None,
work_order_type=None,
from_date=None,
to_date=None,
):
"""
Aggregate Work Orders by repair_status for dashboard pie chart.
Optional filters: company, site_name, work_order_type, from_date, to_date (creation).
"""
try:
from asset_lite.api.dashboard_filters import expand_dashboard_filters
from asset_lite.api.userperm_api import get_permission_filters
wo_filters = expand_dashboard_filters(
{"company": company, "site_name": site_name}, "Work_Order"
)
wo_perm = get_permission_filters("Work_Order").get("filters") or {}
wo_filters = {**wo_perm, **(wo_filters or {})}
if work_order_type:
wo_filters["work_order_type"] = work_order_type
if from_date and to_date:
wo_filters["creation"] = ["between", [f"{from_date} 00:00:00", f"{to_date} 23:59:59"]]
elif from_date:
wo_filters["creation"] = [">=", f"{from_date} 00:00:00"]
elif to_date:
wo_filters["creation"] = ["<=", f"{to_date} 23:59:59"]
from asset_lite.api.work_order_api import get_work_order_role_query
role_filters, or_filters = get_work_order_role_query()
wo_filters.update(role_filters or {})
rows = frappe.get_all(
"Work_Order",
filters=wo_filters or None,
or_filters=or_filters,
fields=["repair_status", "work_order_type", "workflow_state"],
limit=0,
)
status_counts = {}
type_stats = {}
for row in rows:
status = (row.get("repair_status") or "Unknown").strip()
status_counts[status] = status_counts.get(status, 0) + 1
wo_type = (row.get("work_order_type") or "").strip() or "Unknown"
if _is_null_or_unknown_label(wo_type):
continue
bucket = type_stats.setdefault(
wo_type,
{
"type": wo_type,
"total": 0,
"open": 0,
"inProgress": 0,
"pendingReview": 0,
"completed": 0,
"closed": 0,
"rejected": 0,
"cancelled": 0,
"wfCompleted": 0,
"wfInProgress": 0,
"wfRejected": 0,
},
)
bucket["total"] += 1
if status == "Open":
bucket["open"] += 1
elif status == "Work In Progress":
bucket["inProgress"] += 1
elif status == "Pending Review":
bucket["pendingReview"] += 1
elif status == "Completed":
bucket["completed"] += 1
elif status == "Closed":
bucket["closed"] += 1
elif status == "Rejected":
bucket["rejected"] += 1
elif status == "Cancelled":
bucket["cancelled"] += 1
wf_state = (row.get("workflow_state") or "").strip()
if wf_state == "Completed":
bucket["wfCompleted"] += 1
elif wf_state in ("Work In Progress", "In Progress"):
bucket["wfInProgress"] += 1
elif wf_state == "Rejected":
bucket["wfRejected"] += 1
labels = _sort_status_labels(list(status_counts.keys()))
values = [status_counts[label] for label in labels]
colors = [WO_STATUS_COLORS.get(label, "#6366F1") for label in labels]
completed_raw = status_counts.get("Completed", 0)
closed_raw = status_counts.get("Closed", 0)
completed_combined = completed_raw + closed_raw
counts = {
"open": status_counts.get("Open", 0),
"inProgress": status_counts.get("Work In Progress", 0),
"completed": completed_raw,
"completedCombined": completed_combined,
"pendingReview": status_counts.get("Pending Review", 0),
"rejected": status_counts.get("Rejected", 0),
"closed": closed_raw,
"cancelled": status_counts.get("Cancelled", 0),
"total": len(rows),
}
type_rates = []
for wo_type, bucket in type_stats.items():
combined = bucket["completed"] + bucket["closed"]
bucket["completedCombined"] = combined
type_rates.append(bucket)
type_rates.sort(key=lambda item: item.get("total", 0), reverse=True)
type_status_matrix = {}
for row in rows:
status = (row.get("repair_status") or "Unknown").strip()
wo_type = (row.get("work_order_type") or "").strip() or "Unknown"
if _is_null_or_unknown_label(wo_type):
continue
bucket = type_status_matrix.setdefault(wo_type, {})
bucket[status] = bucket.get(status, 0) + 1
type_status_labels = sorted(
type_status_matrix.keys(),
key=lambda item: sum(type_status_matrix[item].values()),
reverse=True,
)
matrix_statuses = set()
for status_map in type_status_matrix.values():
matrix_statuses.update(status_map.keys())
type_status_columns = _sort_status_labels(list(matrix_statuses))
type_status_datasets = [
{
"name": status,
"values": [
type_status_matrix.get(wo_type, {}).get(status, 0)
for wo_type in type_status_labels
],
"color": WO_STATUS_COLORS.get(status, "#6366F1"),
}
for status in type_status_columns
]
_ok({
"counts": counts,
"completion_by_type": {
"labels": [item["type"] for item in type_rates],
"rates": type_rates,
},
"work_order_chart": {
"labels": labels,
"datasets": [{
"name": "Work Orders",
"values": values,
"colors": colors,
}],
"type": "Pie",
},
"type_status_chart": {
"labels": type_status_labels,
"datasets": type_status_datasets,
"type": "Bar",
},
})
except Exception as e:
frappe.log_error(frappe.get_traceback(), "get_dashboard_work_order_metrics")
_err(str(e))
ASSET_DEVICE_STATUS_COLORS = {
"up": "#10B981",
"down": "#EF4444",
"under maintenance": "#F59E0B",
"decommissioned": "#6B7280",
}
ASSET_STATUS_FALLBACK_COLORS = [
"#6366F1",
"#8B5CF6",
"#06B6D4",
"#EC4899",
"#F59E0B",
"#10B981",
]
def _asset_device_status_color(label, fallback_index=0):
key = (label or "").strip().lower()
if key in ASSET_DEVICE_STATUS_COLORS:
return ASSET_DEVICE_STATUS_COLORS[key]
if "maintenance" in key:
return ASSET_DEVICE_STATUS_COLORS["under maintenance"]
if "decommission" in key:
return ASSET_DEVICE_STATUS_COLORS["decommissioned"]
return ASSET_STATUS_FALLBACK_COLORS[fallback_index % len(ASSET_STATUS_FALLBACK_COLORS)]
@frappe.whitelist(allow_guest=True)
def get_asset_device_status_summary(
company=None,
site_name=None,
from_date=None,
to_date=None,
):
"""
Aggregate Assets by custom_device_status for dashboard pie chart.
Optional filters: company, site_name (custom_site on Asset), from_date, to_date (creation).
"""
try:
from asset_lite.api.dashboard_filters import expand_dashboard_filters
from asset_lite.api.userperm_api import get_permission_filters
asset_filters = expand_dashboard_filters(
{"company": company, "site_name": site_name}, "Asset"
)
asset_perm = get_permission_filters("Asset").get("filters") or {}
asset_filters = {**asset_perm, **(asset_filters or {})}
if frappe.db.has_column("Asset", "custom_delete_status"):
asset_filters["custom_delete_status"] = ["!=", "Deleted"]
if from_date and to_date:
asset_filters["creation"] = [
"between",
[f"{from_date} 00:00:00", f"{to_date} 23:59:59"],
]
elif from_date:
asset_filters["creation"] = [">=", f"{from_date} 00:00:00"]
elif to_date:
asset_filters["creation"] = ["<=", f"{to_date} 23:59:59"]
rows = frappe.get_all(
"Asset",
filters=asset_filters or None,
fields=["custom_device_status"],
limit=0,
)
status_counts = {}
for row in rows:
raw = (row.get("custom_device_status") or "").strip()
label = raw or "Unknown"
status_counts[label] = status_counts.get(label, 0) + 1
sorted_items = sorted(status_counts.items(), key=lambda item: item[1], reverse=True)
labels = [label for label, _count in sorted_items]
values = [count for _label, count in sorted_items]
colors = [
_asset_device_status_color(label, idx) for idx, label in enumerate(labels)
]
assets_down = 0
for label, count in status_counts.items():
if label.strip().lower() == "down":
assets_down += count
total_assets = len(rows)
_ok({
"total_assets": total_assets,
"assets_down": assets_down,
"chart": {
"labels": labels,
"datasets": [{
"name": "Assets",
"values": values,
"colors": colors,
}],
"type": "Pie",
"total": total_assets,
},
})
except Exception as e:
frappe.log_error(frappe.get_traceback(), "get_asset_device_status_summary")
_err(str(e))
@frappe.whitelist(allow_guest = True)
def list_dashboard_charts(search=None, public_only=True, limit=50):
"""

View File

@ -0,0 +1,37 @@
import frappe
def expand_dashboard_filters(filters=None, doctype=None):
"""Normalize dashboard hospital/site filters for list APIs."""
if not filters:
return {}
expanded = dict(filters)
company = expanded.pop("company", None) or expanded.pop("hospital", None)
site_name = expanded.pop("site_name", None)
if doctype == "Asset Maintenance Log":
if company:
expanded["custom_hospital_name"] = company
if site_name:
asset_names = frappe.get_all(
"Asset",
filters={"custom_site": site_name},
pluck="name",
)
expanded["asset_name"] = ["in", asset_names or ["__no_match__"]]
elif doctype == "Asset":
if company:
expanded["company"] = company
if site_name:
expanded["custom_site"] = site_name
elif doctype == "Work_Order":
if company:
expanded["company"] = company
if site_name:
expanded["site_name"] = site_name
elif doctype == "PM Schedule Generator":
if company:
expanded["hospital"] = company
return expanded

View File

@ -0,0 +1,277 @@
import frappe
from frappe import _
def _aml_location_conditions(filters, aml_alias="aml", asset_alias="a"):
conditions = ["1=1"]
params = {}
if filters.get("company"):
conditions.append(f"{aml_alias}.custom_hospital_name = %(company)s")
params["company"] = filters.get("company")
if filters.get("site_name"):
conditions.append(f"{asset_alias}.custom_site = %(site_name)s")
params["site_name"] = filters.get("site_name")
return " AND ".join(conditions), params
def asset_wise_count_execute(filters=None):
filters = filters or {}
where, params = _aml_location_conditions(filters)
query = f"""
SELECT
aml.item_name AS item_name,
aml.maintenance_status AS maintenance_status,
SUM(CASE WHEN aml.due_date = aml.completion_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_on_time,
SUM(CASE WHEN aml.completion_date < aml.due_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_within_time,
SUM(CASE WHEN aml.completion_date > aml.due_date THEN 1 ELSE 0 END) AS delay_in_completion,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date > CURRENT_DATE() THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date < CURRENT_DATE() THEN 1 ELSE 0 END) AS overdue,
SUM(CASE WHEN aml.maintenance_status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM `tabAsset Maintenance Log` aml
LEFT JOIN `tabAsset` a ON aml.asset_name = a.name
WHERE {where}
GROUP BY aml.item_name
"""
columns = [
{"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 200},
{"label": _("Maintenance Status"), "fieldname": "maintenance_status", "fieldtype": "Data", "width": 150},
{"label": _("Completed On Time"), "fieldname": "completed_on_time", "fieldtype": "Int", "width": 150},
{"label": _("Completed Within Time"), "fieldname": "completed_within_time", "fieldtype": "Int", "width": 170},
{"label": _("Delay In Completion"), "fieldname": "delay_in_completion", "fieldtype": "Int", "width": 150},
{"label": _("Pending"), "fieldname": "pending", "fieldtype": "Int", "width": 100},
{"label": _("Overdue"), "fieldname": "overdue", "fieldtype": "Int", "width": 100},
{"label": _("Cancelled"), "fieldname": "cancelled", "fieldtype": "Int", "width": 100},
]
data = frappe.db.sql(query, params, as_dict=True)
return columns, data
def assignees_status_count_execute(filters=None):
filters = filters or {}
where, params = _aml_location_conditions(filters)
query = f"""
SELECT
aml.item_name AS item_name,
aml.maintenance_status AS maintenance_status,
aml.assign_to_name AS assigned_to,
SUM(CASE WHEN aml.due_date = aml.completion_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_on_time,
SUM(CASE WHEN aml.completion_date < aml.due_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_within_time,
SUM(CASE WHEN aml.completion_date > aml.due_date THEN 1 ELSE 0 END) AS delay_in_completion,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date > CURRENT_DATE() THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date < CURRENT_DATE() THEN 1 ELSE 0 END) AS overdue,
SUM(CASE WHEN aml.maintenance_status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM `tabAsset Maintenance Log` aml
LEFT JOIN `tabAsset` a ON aml.asset_name = a.name
WHERE {where}
GROUP BY aml.assign_to_name
"""
columns = [
{"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 200},
{"label": _("Maintenance Status"), "fieldname": "maintenance_status", "fieldtype": "Data", "width": 150},
{"label": _("Assigned To"), "fieldname": "assigned_to", "fieldtype": "Data", "width": 150},
{"label": _("Completed On Time"), "fieldname": "completed_on_time", "fieldtype": "Int", "width": 150},
{"label": _("Completed Within Time"), "fieldname": "completed_within_time", "fieldtype": "Int", "width": 170},
{"label": _("Delay In Completion"), "fieldname": "delay_in_completion", "fieldtype": "Int", "width": 150},
{"label": _("Pending"), "fieldname": "pending", "fieldtype": "Int", "width": 100},
{"label": _("Overdue"), "fieldname": "overdue", "fieldtype": "Int", "width": 100},
{"label": _("Cancelled"), "fieldname": "cancelled", "fieldtype": "Int", "width": 100},
]
data = frappe.db.sql(query, params, as_dict=True)
return columns, data
def asset_maintenance_frequency_execute(filters=None):
filters = filters or {}
where, params = _aml_location_conditions(filters)
query = f"""
SELECT
aml.custom_asset_names AS item,
SUM(CASE WHEN aml.maintenance_status = 'Planned' THEN 1 ELSE 0 END) AS planned,
SUM(CASE WHEN aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN aml.maintenance_status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled,
SUM(CASE WHEN aml.maintenance_status = 'Overdue' THEN 1 ELSE 0 END) AS overdue,
COUNT(aml.custom_asset_names) AS total_count
FROM `tabAsset Maintenance Log` aml
LEFT JOIN `tabAsset` a ON aml.asset_name = a.name
WHERE {where}
GROUP BY aml.custom_asset_names
"""
columns = [
{"label": _("Item"), "fieldname": "item", "fieldtype": "Data", "width": 200},
{"label": _("Planned"), "fieldname": "planned", "fieldtype": "Int", "width": 100},
{"label": _("Completed"), "fieldname": "completed", "fieldtype": "Int", "width": 100},
{"label": _("Cancelled"), "fieldname": "cancelled", "fieldtype": "Int", "width": 100},
{"label": _("Overdue"), "fieldname": "overdue", "fieldtype": "Int", "width": 100},
{"label": _("Total Count"), "fieldname": "total_count", "fieldtype": "Int", "width": 100},
]
data = frappe.db.sql(query, params, as_dict=True)
return columns, data
def asset_up_and_down_execute(filters=None):
filters = filters or {}
conditions = ["1=1"]
params = {}
if filters.get("company"):
conditions.append("company = %(company)s")
params["company"] = filters.get("company")
if filters.get("site_name"):
conditions.append("custom_site = %(site_name)s")
params["site_name"] = filters.get("site_name")
if filters.get("department"):
conditions.append("department = %(department)s")
params["department"] = filters.get("department")
if filters.get("custom_class"):
conditions.append("custom_class = %(custom_class)s")
params["custom_class"] = filters.get("custom_class")
if filters.get("name"):
conditions.append("name = %(name)s")
params["name"] = filters.get("name")
where = " AND ".join(conditions)
result = frappe.db.sql(
f"""
SELECT
name AS name,
asset_name,
custom_device_status AS status,
department,
custom_class
FROM `tabAsset`
WHERE {where}
ORDER BY asset_name
""",
params,
as_dict=True,
)
status_count = {}
for row in result:
status = row.get("status")
if status:
normalized_status = status.strip().lower()
status_count[normalized_status] = status_count.get(normalized_status, 0) + 1
chart_labels = list(status_count.keys())
chart_values = list(status_count.values())
columns = [
{"fieldname": "name", "label": "Asset ID", "fieldtype": "Link", "options": "Asset", "width": 200},
{"fieldname": "asset_name", "label": "Asset Name", "fieldtype": "Data", "width": 200},
{"fieldname": "status", "label": "Status", "fieldtype": "Data", "width": 100},
{"fieldname": "department", "label": "Department", "fieldtype": "Link", "options": "Department", "width": 150},
{"fieldname": "custom_class", "label": "Class", "fieldtype": "Data", "width": 200},
]
status_colors = [
"#2ba63d" if status == "up" else "#FF0000" if status == "down" else "#0000FF"
for status in chart_labels
]
chart = {
"data": {
"labels": chart_labels,
"datasets": [{"name": "Number of Assets", "values": chart_values}],
},
"type": "pie",
"colors": status_colors,
}
return columns, result, None, chart
def work_order_status_execute(filters=None):
filters = filters or {}
conditions = ["repair_status IN ('Open', 'Work In Progress', 'Pending Review', 'Completed', 'Closed', 'Cancelled')"]
params = {}
filter_fields = ["work_order_type", "repair_status", "asset_type", "company", "site_name"]
for field in filter_fields:
if filters.get(field):
conditions.append(f"{field} = %({field})s")
params[field] = filters.get(field)
if filters.get("wo_name"):
conditions.append("name = %(wo_name)s")
params["wo_name"] = filters.get("wo_name")
where = " AND ".join(conditions)
query = f"""
SELECT
name AS wo_name,
asset_type,
work_order_type,
SUM(CASE WHEN repair_status = 'Open' THEN 1 ELSE 0 END) AS open_count,
SUM(CASE WHEN repair_status = 'Work In Progress' THEN 1 ELSE 0 END) AS work_in_progress_count,
SUM(CASE WHEN repair_status = 'Pending Review' THEN 1 ELSE 0 END) AS pending_review_count,
SUM(CASE WHEN repair_status = 'Completed' THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN repair_status = 'Closed' THEN 1 ELSE 0 END) AS closed_count
FROM `tabWork_Order`
WHERE {where}
GROUP BY work_order_type
"""
result = frappe.db.sql(query, params, as_dict=True)
columns = [
{"label": _("Work Order Type"), "fieldname": "work_order_type", "fieldtype": "Data", "width": 200},
{"label": _("Open"), "fieldname": "open_count", "fieldtype": "Int", "width": 200},
{"label": _("Work In Progress"), "fieldname": "work_in_progress_count", "fieldtype": "Int", "width": 200},
{"label": _("Pending Review"), "fieldname": "pending_review_count", "fieldtype": "Int", "width": 200},
{"label": _("Completed"), "fieldname": "completed_count", "fieldtype": "Int", "width": 200},
{"label": _("Closed"), "fieldname": "closed_count", "fieldtype": "Int", "width": 200},
]
report_summary = []
for status in ["Open", "Work In Progress", "Pending Review", "Completed", "Closed"]:
status_conditions = list(conditions)
status_conditions.append("repair_status = %(status)s")
status_params = {**params, "status": status}
count = frappe.db.sql(
f"SELECT COUNT(*) FROM `tabWork_Order` WHERE {' AND '.join(status_conditions)}",
status_params,
)[0][0]
report_summary.append({"value": count, "label": status})
total = frappe.db.sql(
f"SELECT COUNT(*) FROM `tabWork_Order` WHERE {where}",
params,
)[0][0]
report_summary.append({"value": total, "label": "Total Work Orders"})
chart = {
"data": {
"labels": [row["work_order_type"] for row in result],
"datasets": [
{"name": "Open", "values": [row["open_count"] for row in result]},
{"name": "Work In Progress", "values": [row["work_in_progress_count"] for row in result]},
{"name": "Pending Review", "values": [row["pending_review_count"] for row in result]},
{"name": "Completed", "values": [row["completed_count"] for row in result]},
{"name": "Closed", "values": [row["closed_count"] for row in result]},
],
},
"type": "bar",
"barOptions": {"stacked": 1, "spaceRatio": 0.6},
"colors": ["#CCCCB7", "#52B2BF", "#9EC1A4", "#058D7C", "#A3A5CF"],
}
return columns, result, None, chart, report_summary

View File

@ -119,6 +119,9 @@ def get_pm_schedules(filters=None, fields=None, limit=20, offset=0, order_by=Non
# Parse filters if provided
if filters and isinstance(filters, str):
filters = json.loads(filters)
from asset_lite.api.dashboard_filters import expand_dashboard_filters
filters = expand_dashboard_filters(filters or {}, "PM Schedule Generator")
# Parse fields if provided
if fields and isinstance(fields, str):

View File

@ -9,6 +9,8 @@ from frappe import _
DOCTYPE_PERMISSION_MAPPINGS = {
"Asset": {
"Company": "company",
"Hospital": "company",
"Mobile Team Site": "custom_site",
"Location": "location",
"Department": "department",
"Manufacturer": "custom_manufacturer",
@ -20,6 +22,8 @@ DOCTYPE_PERMISSION_MAPPINGS = {
},
"Work_Order": {
"Company": "company",
"Hospital": "company",
"Mobile Team Site": "site_name",
"Location": "location",
"Department": "department"
},
@ -29,8 +33,13 @@ DOCTYPE_PERMISSION_MAPPINGS = {
"Supplier": "supplier"
},
"Asset Maintenance Log": {
"Company": "company",
"Company": "custom_hospital_name",
"Hospital": "custom_hospital_name",
"Asset": "asset_name"
},
"Item": {
"Company": "custom_hospital_name",
"Hospital": "custom_hospital_name",
}
# Add more doctypes as needed - just add them here!
}
@ -49,6 +58,39 @@ def is_system_user(user):
return "System Manager" in roles
def merge_query_filters(user_filters=None, permission_filters=None):
"""Merge client filters with permission filters (permissions win on conflict)."""
merged = dict(user_filters or {})
for field, value in (permission_filters or {}).items():
if field not in merged or not merged[field]:
merged[field] = value
elif isinstance(value, list) and value and value[0] == "in":
permitted_values = value[1] or []
user_value = merged[field]
if isinstance(user_value, str):
if user_value not in permitted_values:
merged[field] = ["in", []]
elif isinstance(user_value, list) and user_value and user_value[0] == "in":
user_values = user_value[1] or []
intersection = [v for v in user_values if v in permitted_values]
merged[field] = ["in", intersection]
else:
merged[field] = value
return merged
def apply_permission_filters(user_filters, target_doctype, user=None):
"""Apply user permission filters to a query filter dict."""
perm_result = get_permission_filters(target_doctype, user)
if perm_result.get("is_admin"):
return user_filters or {}
return merge_query_filters(user_filters, perm_result.get("filters"))
# ============================================================================
# CORE API FUNCTIONS - These 4 functions handle everything
# ============================================================================

View File

@ -0,0 +1,142 @@
import frappe
from frappe import _
RATING_ORDER = [
"Excellent / ممتاز",
"Good / جيد",
"Average / متوسط",
"Poor / ضعيف",
]
REPORT_COLUMNS = [
{"label": _("Work Order"), "fieldname": "work_order", "fieldtype": "Link", "options": "Work_Order", "width": 150},
{"label": _("Requester Name"), "fieldname": "requester_name", "fieldtype": "Data", "width": 180},
{"label": _("Rating"), "fieldname": "rating", "fieldtype": "Data", "width": 160},
{"label": _("Comments"), "fieldname": "comments", "fieldtype": "Small Text", "width": 220},
{"label": _("Created On"), "fieldname": "creation", "fieldtype": "Datetime", "width": 150},
{"label": _("Last Modified"), "fieldname": "modified", "fieldtype": "Datetime", "width": 150},
]
def _parse_filters(filters=None):
if isinstance(filters, str):
filters = frappe.parse_json(filters or "{}")
return filters or {}
def _build_wo_feedback_conditions(filters):
conditions = ["IFNULL(wf.rating, '') != ''"]
params = {}
if filters.get("work_order"):
conditions.append("wf.work_order = %(work_order)s")
params["work_order"] = filters.get("work_order")
if filters.get("requester_name"):
conditions.append("wf.requester_name LIKE %(requester_name)s")
params["requester_name"] = f"%{filters.get('requester_name')}%"
if filters.get("rating"):
conditions.append("wf.rating = %(rating)s")
params["rating"] = filters.get("rating")
if filters.get("company"):
conditions.append("wo.company = %(company)s")
params["company"] = filters.get("company")
if filters.get("site_name"):
conditions.append("wo.site_name = %(site_name)s")
params["site_name"] = filters.get("site_name")
return conditions, params
def _rating_sort_key(rating):
try:
return RATING_ORDER.index(rating)
except ValueError:
return len(RATING_ORDER)
@frappe.whitelist(allow_guest=True)
def get_wo_feedback_for_work_order(work_order: str | None = None):
"""Guest-safe read for WO Feedback (autoname = work_order field value)."""
if not work_order or not str(work_order).strip():
frappe.throw(_("Work Order is required"))
work_order = str(work_order).strip()
feedback_name = work_order
if not frappe.db.exists("WO Feedback", work_order):
feedback_name = frappe.db.get_value("WO Feedback", {"work_order": work_order}, "name")
if not feedback_name:
return None
return frappe.db.get_value(
"WO Feedback",
feedback_name,
["name", "work_order", "requester_name", "rating", "comments"],
as_dict=True,
)
@frappe.whitelist()
def run_wo_feedback_report(filters=None):
"""Return WO feedback detail rows for dashboard popup (supports hospital/site filters)."""
filters = _parse_filters(filters)
conditions, params = _build_wo_feedback_conditions(filters)
where = " AND ".join(conditions)
rows = frappe.db.sql(
f"""
SELECT
wf.work_order,
wf.requester_name,
wf.rating,
wf.comments,
wf.creation,
wf.modified
FROM `tabWO Feedback` wf
INNER JOIN `tabWork_Order` wo ON wo.name = wf.work_order
WHERE {where}
ORDER BY wf.modified DESC
""",
params,
as_dict=True,
)
return {
"columns": REPORT_COLUMNS,
"result": rows,
}
@frappe.whitelist()
def get_wo_feedback_summary(filters=None):
"""Return chart-ready rating counts for dashboard card."""
filters = _parse_filters(filters)
conditions, params = _build_wo_feedback_conditions(filters)
where = " AND ".join(conditions)
rows = frappe.db.sql(
f"""
SELECT wf.rating, COUNT(*) AS count
FROM `tabWO Feedback` wf
INNER JOIN `tabWork_Order` wo ON wo.name = wf.work_order
WHERE {where}
GROUP BY wf.rating
""",
params,
as_dict=True,
)
count_by_rating = {row.rating: int(row.count or 0) for row in rows if row.rating}
labels = RATING_ORDER[:]
values = [count_by_rating.get(label, 0) for label in labels]
return {
"labels": labels,
"datasets": [{"name": "Responses", "values": values}],
"type": "Bar",
}

View File

@ -17,6 +17,8 @@ WORK_ORDER_FIELDS = [
'asset_type',
'manufacturer',
'serial_number',
'custom_local_id',
'custom_moh_id',
'custom_priority_',
'asset',
'custom_maintenance_manager',
@ -49,6 +51,7 @@ WORK_ORDER_FIELDS = [
'custom_diffrence',
'feedback_rating',
'first_responded_on',
'completion_date',
'assigned_manager',
'penalty',
'custom_assigned_supervisor',
@ -149,8 +152,44 @@ def get_child_table_data(parent_name, parentfield, child_doctype, fields=None):
return []
def get_work_order_role_query(user=None):
"""Match frontend mergeWorkOrderFilters / buildWorkOrderRoleFilters."""
user = user or frappe.session.user
if not user or user == "Guest":
return {}, None
role_profile = frappe.db.get_value("User", user, "role_profile_name")
if role_profile in ("End User", "PHCC End User"):
return {"owner": user}, None
if role_profile == "Technician":
return {}, [
["owner", "=", user],
["custom_assign_to_contractor", "=", user],
["assigned_technician", "=", user],
]
return {}, None
def _count_work_orders(filters=None, or_filters=None):
"""Count work orders, including optional OR filter groups."""
if or_filters:
return len(
frappe.get_all(
"Work_Order",
filters=filters or {},
or_filters=or_filters,
pluck="name",
limit_page_length=0,
)
)
return frappe.db.count("Work_Order", filters or {})
@frappe.whitelist(allow_guest=True)
def get_work_orders(filters=None, fields=None, limit=20, offset=0, order_by=None, include_child_tables=False):
def get_work_orders(filters=None, fields=None, limit=20, offset=0, order_by=None, include_child_tables=False, or_filters=None):
"""
Get list of work orders with filters and pagination
@ -177,6 +216,20 @@ def get_work_orders(filters=None, fields=None, limit=20, offset=0, order_by=None
# Parse filters if provided
if filters and isinstance(filters, str):
filters = json.loads(filters)
if or_filters and isinstance(or_filters, str):
or_filters = json.loads(or_filters)
from asset_lite.api.dashboard_filters import expand_dashboard_filters
if isinstance(filters, dict):
filters = expand_dashboard_filters(filters or {}, "Work_Order")
from asset_lite.api.asset_api import _expand_list_filters
filters = _expand_list_filters(filters or {})
elif filters is None:
filters = {}
from asset_lite.api.userperm_api import apply_permission_filters
filters = apply_permission_filters(filters, "Work_Order")
# Parse fields if provided
if fields and isinstance(fields, str):
@ -189,12 +242,13 @@ def get_work_orders(filters=None, fields=None, limit=20, offset=0, order_by=None
include_child_tables = include_child_tables.lower() in ('true', '1', 'yes')
# Get total count
total_count = frappe.db.count('Work_Order', filters=filters or {})
total_count = _count_work_orders(filters or {}, or_filters or None)
# Get work orders
work_orders = frappe.get_all(
'Work_Order',
filters=filters or {},
or_filters=or_filters or None,
fields=fields,
limit_page_length=int(limit),
limit_start=int(offset),

View File

@ -688,7 +688,7 @@
"reqd": 1
},
{
"depends_on": "eval:doc.asset_type == \"Non Biomedical\" || (doc.company && doc.asset_type == \"Biomedical\" && doc.company.startsWith(\"Mobile\"))",
"depends_on": "eval:doc.asset_type == \"Non Biomedical\" || (doc.company && doc.asset_type == \"Biomedical\" && (doc.company.startsWith(\"Mobile\") || doc.company.startsWith(\"Dental\")))",
"fetch_from": "asset.custom_site",
"fetch_if_empty": 1,
"fieldname": "site_name",

View File

@ -3,8 +3,36 @@
import frappe
from frappe.model.document import Document
from frappe.utils import get_datetime, now_datetime
class Work_Order(Document):
def validate(self):
self.apply_status_timestamps()
self.calculate_total_hours_spent()
def apply_status_timestamps(self):
"""Auto-set response/completion timestamps when repair status changes."""
if self.repair_status == "Work In Progress" and not self.first_responded_on:
self.first_responded_on = now_datetime()
if self.repair_status == "Completed" and not self.completion_date:
self.completion_date = now_datetime()
def calculate_total_hours_spent(self):
"""Total hours = completion_date - first_responded_on."""
if not self.first_responded_on or not self.completion_date:
return
start = get_datetime(self.first_responded_on)
end = get_datetime(self.completion_date)
if not start or not end or end < start:
self.total_hours_spent = 0
return
self.total_hours_spent = round((end - start).total_seconds() / 3600, 2)
@frappe.whitelist()
def check_site_version(self):
# Fetch the site version type from the site configuration

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -451,88 +451,6 @@
"x_field": null,
"y_axis": []
},
{
"aggregate_function_based_on": null,
"based_on": null,
"chart_name": "Technicians working Hours on Work orders",
"chart_type": "Report",
"color": null,
"custom_options": null,
"docstatus": 0,
"doctype": "Dashboard Chart",
"document_type": null,
"dynamic_filters_json": "{}",
"filters_json": "{}",
"from_date": null,
"group_by_based_on": null,
"group_by_type": "Count",
"heatmap_year": null,
"is_public": 1,
"is_standard": 0,
"last_synced_on": null,
"modified": "2025-02-18 13:40:45.427898",
"module": null,
"name": "Technicians working Hours on Work orders",
"number_of_groups": 0,
"parent_document_type": null,
"report_name": "Technicians working Hours",
"roles": [
{
"parent": "Technicians working Hours on Work orders",
"parentfield": "roles",
"parenttype": "Dashboard Chart",
"role": "Technician"
},
{
"parent": "Technicians working Hours on Work orders",
"parentfield": "roles",
"parenttype": "Dashboard Chart",
"role": "Maintenance Manager"
},
{
"parent": "Technicians working Hours on Work orders",
"parentfield": "roles",
"parenttype": "Dashboard Chart",
"role": "End user"
},
{
"parent": "Technicians working Hours on Work orders",
"parentfield": "roles",
"parenttype": "Dashboard Chart",
"role": "Finance Manager"
},
{
"parent": "Technicians working Hours on Work orders",
"parentfield": "roles",
"parenttype": "Dashboard Chart",
"role": "Finance User"
},
{
"parent": "Technicians working Hours on Work orders",
"parentfield": "roles",
"parenttype": "Dashboard Chart",
"role": "System Manager"
}
],
"source": null,
"time_interval": "Yearly",
"timeseries": 0,
"timespan": "Last Year",
"to_date": null,
"type": "Bar",
"use_report_chart": 0,
"value_based_on": null,
"x_field": "technician_name",
"y_axis": [
{
"color": "#85cc29",
"parent": "Technicians working Hours on Work orders",
"parentfield": "y_axis",
"parenttype": "Dashboard Chart",
"y_field": "total_hours"
}
]
},
{
"aggregate_function_based_on": null,
"based_on": "creation",

View File

@ -1262,7 +1262,7 @@
{
"attach_print": 0,
"channel": "Email",
"condition": "doc.workflow_state == \"Completed\"",
"condition": "doc.workflow_state == \"Completed\" and (doc.company ==\"Iman General Hospital\" or doc.company == \"Mobile Team - Iman\")",
"date_changed": null,
"days_in_advance": 0,
"docstatus": 0,
@ -1274,7 +1274,7 @@
"message": "Add your message here",
"message_type": "Markdown",
"method": null,
"modified": "2026-01-12 15:52:50.093133",
"modified": "2026-08-12 12:20:47.199206",
"module": "Asset Lite",
"name": "Work order Approved",
"print_format": null,
@ -1323,7 +1323,7 @@
{
"attach_print": 0,
"channel": "Email",
"condition": "doc.workflow_state == \"Pending Approval\"",
"condition": "doc.workflow_state == \"Pending Approval\" and (doc.company ==\"Iman General Hospital\" or doc.company == \"Mobile Team - Iman\")",
"date_changed": null,
"days_in_advance": 0,
"docstatus": 0,
@ -1335,7 +1335,7 @@
"message": "Add your message here",
"message_type": "Markdown",
"method": null,
"modified": "2026-01-12 15:51:25.758528",
"modified": "2026-08-12 12:22:47.792157",
"module": "Asset Lite",
"name": "Work order Pending Approval",
"print_format": null,
@ -1611,7 +1611,7 @@
"message": "Add your message here",
"message_type": "Markdown",
"method": null,
"modified": "2026-01-12 15:55:50.454012",
"modified": "2026-08-12 12:37:14.552101",
"module": "Asset Lite",
"name": "WO Close",
"print_format": null,
@ -1646,5 +1646,87 @@
"slack_webhook_url": null,
"subject": "Work order {{doc.name }} is Closed. ",
"value_changed": "workflow_state"
},
{
"attach_print": 0,
"channel": "Email",
"condition": "doc.workflow_state == \"Sent To Project Manager\"",
"date_changed": null,
"days_in_advance": 0,
"docstatus": 0,
"doctype": "Notification",
"document_type": "Work_Order",
"enabled": 1,
"event": "Value Change",
"is_standard": 0,
"message": "Add your message here",
"message_type": "Markdown",
"method": null,
"modified": "2026-08-13 17:00:32.314840",
"module": "Asset Lite",
"name": "WO Sent To Project Manager",
"print_format": null,
"property_value": null,
"recipients": [
{
"bcc": null,
"cc": null,
"condition": null,
"parent": "WO Sent To Project Manager",
"parentfield": "recipients",
"parenttype": "Notification",
"receiver_by_document_field": null,
"receiver_by_role": "Projects Manager"
}
],
"send_system_notification": 1,
"send_to_all_assignees": 0,
"sender": "Asset Notifications",
"sender_email": "prebiyap@gmail.com",
"set_property_after_alert": null,
"slack_webhook_url": null,
"subject": "Work order {{doc.name }} is Sent To Project Manager. Please Take the necessary action. ",
"value_changed": "workflow_state"
},
{
"attach_print": 0,
"channel": "Email",
"condition": "doc.workflow_state == \"Closed\"",
"date_changed": null,
"days_in_advance": 0,
"docstatus": 0,
"doctype": "Notification",
"document_type": "Work_Order",
"enabled": 1,
"event": "Value Change",
"is_standard": 0,
"message": "Add your message here",
"message_type": "Markdown",
"method": null,
"modified": "2026-08-18 15:42:44.801598",
"module": "Asset Lite",
"name": "Feedback Form Created",
"print_format": null,
"property_value": null,
"recipients": [
{
"bcc": null,
"cc": null,
"condition": null,
"parent": "Feedback Form Created",
"parentfield": "recipients",
"parenttype": "Notification",
"receiver_by_document_field": "owner",
"receiver_by_role": null
}
],
"send_system_notification": 1,
"send_to_all_assignees": 0,
"sender": "Asset Notifications",
"sender_email": "prebiyap@gmail.com",
"set_property_after_alert": null,
"slack_webhook_url": null,
"subject": "Work order {{doc.name }} is Closed, Please give the Feedback.",
"value_changed": "workflow_state"
}
]

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,20 @@
[
{
"default_value": null,
"doc_type": "Asset",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-05-22 14:09:07.502818",
"module": "Asset Lite",
"name": "Asset-main-image_field",
"property": "image_field",
"property_type": "Data",
"row_name": null,
"value": "custom_attach_image"
},
{
"default_value": null,
"doc_type": "Asset",
@ -1903,22 +1919,6 @@
"row_name": null,
"value": "1"
},
{
"default_value": null,
"doc_type": "Asset",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "item_code",
"is_system_generated": 0,
"modified": "2025-04-22 14:45:40.948870",
"module": "Asset Lite",
"name": "Asset-item_code-read_only",
"property": "read_only",
"property_type": "Check",
"row_name": null,
"value": "1"
},
{
"default_value": null,
"doc_type": "Asset",
@ -2063,22 +2063,6 @@
"row_name": null,
"value": "Hospital Name"
},
{
"default_value": null,
"doc_type": "Asset",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "item_code",
"is_system_generated": 0,
"modified": "2025-04-22 14:45:37.632724",
"module": "Asset Lite",
"name": "Asset-item_code-hidden",
"property": "hidden",
"property_type": "Check",
"row_name": null,
"value": "1"
},
{
"default_value": null,
"doc_type": "Asset",
@ -3727,22 +3711,6 @@
"row_name": null,
"value": "Light\nDark\nAutomatic\nModern_ui_theme"
},
{
"default_value": null,
"doc_type": "Asset",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocType",
"field_name": null,
"is_system_generated": 0,
"modified": "2025-05-22 14:09:07.502818",
"module": "Asset Lite",
"name": "Asset-main-image_field",
"property": "image_field",
"property_type": "Data",
"row_name": null,
"value": "custom_attach_image"
},
{
"default_value": null,
"doc_type": "Work_Order",
@ -4159,22 +4127,6 @@
"row_name": null,
"value": "0"
},
{
"default_value": null,
"doc_type": "Work_Order",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "company",
"is_system_generated": 0,
"modified": "2025-08-08 17:25:29.999119",
"module": "Asset Lite",
"name": "Work_Order-company-depends_on",
"property": "depends_on",
"property_type": "Data",
"row_name": null,
"value": "eval:doc.asset_type == \"Biomedical\""
},
{
"default_value": null,
"doc_type": "Report",
@ -4303,22 +4255,6 @@
"row_name": null,
"value": "PPM Assets"
},
{
"default_value": null,
"doc_type": "Work_Order",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "site_name",
"is_system_generated": 0,
"modified": "2025-08-08 17:33:53.907942",
"module": "Asset Lite",
"name": "Work_Order-site_name-depends_on",
"property": "depends_on",
"property_type": "Data",
"row_name": null,
"value": "eval:doc.asset_type == \"Non Biomedical\" || (doc.company && doc.asset_type == \"Biomedical\" && doc.company.startsWith(\"Mobile\"))"
},
{
"default_value": null,
"doc_type": "Work_Order",
@ -4590,5 +4526,37 @@
"property_type": "Check",
"row_name": null,
"value": "1"
},
{
"default_value": null,
"doc_type": "Work_Order",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "company",
"is_system_generated": 0,
"modified": "2026-08-24 12:23:00.164566",
"module": "Asset Lite",
"name": "Work_Order-company-depends_on",
"property": "depends_on",
"property_type": "Data",
"row_name": null,
"value": ""
},
{
"default_value": null,
"doc_type": "Work_Order",
"docstatus": 0,
"doctype": "Property Setter",
"doctype_or_field": "DocField",
"field_name": "site_name",
"is_system_generated": 0,
"modified": "2026-08-24 12:23:00.016499",
"module": "Asset Lite",
"name": "Work_Order-site_name-depends_on",
"property": "depends_on",
"property_type": "Data",
"row_name": null,
"value": ""
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -313,7 +313,7 @@
"doctype_event": "Before Insert",
"enable_rate_limit": 0,
"event_frequency": "All",
"modified": "2026-01-06 17:11:37.557942",
"modified": "2026-08-06 21:09:20.199705",
"module": "Asset Lite",
"name": "Create Item on Asset",
"rate_limit_count": 5,

File diff suppressed because it is too large Load Diff

View File

@ -27,6 +27,20 @@
"name": "Approve",
"workflow_action_name": "Approve"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
"modified": "2026-08-11 11:41:34.048015",
"name": "Work Complete",
"workflow_action_name": "Work Complete"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
"modified": "2026-08-13 13:28:13.970367",
"name": "Work Done",
"workflow_action_name": "Work Done"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
@ -41,13 +55,6 @@
"name": "Re-Open",
"workflow_action_name": "Re-Open"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
"modified": "2024-09-13 11:10:00.762984",
"name": "Send For Approval",
"workflow_action_name": "Send For Approval"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
@ -62,6 +69,20 @@
"name": "Reject",
"workflow_action_name": "Reject"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
"modified": "2025-08-11 12:02:06.266995",
"name": "Close",
"workflow_action_name": "Close"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
"modified": "2024-09-13 11:10:00.762984",
"name": "Send For Approval",
"workflow_action_name": "Send For Approval"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
@ -69,6 +90,13 @@
"name": "Cancel",
"workflow_action_name": "Cancel"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
"modified": "2026-08-11 11:47:24.201964",
"name": "Approve & Close",
"workflow_action_name": "Approve & Close"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
@ -76,13 +104,6 @@
"name": "Send For Repair",
"workflow_action_name": "Send For Repair"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",
"modified": "2025-08-11 12:02:06.266995",
"name": "Close",
"workflow_action_name": "Close"
},
{
"docstatus": 0,
"doctype": "Workflow Action Master",

View File

@ -35,15 +35,6 @@
"style": "",
"workflow_state_name": "Sent to Procurement User"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2025-06-17 15:42:44.566036",
"name": "Sent To Site Manager",
"style": "",
"workflow_state_name": "Sent To Site Manager"
},
{
"docstatus": 0,
"doctype": "Workflow State",
@ -53,24 +44,6 @@
"style": "",
"workflow_state_name": "Sent to General WOA"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2024-09-17 17:37:02.717022",
"name": "Cancelled",
"style": "",
"workflow_state_name": "Cancelled"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2025-05-22 18:15:15.104122",
"name": "Pending Purchase",
"style": "",
"workflow_state_name": "Pending Purchase"
},
{
"docstatus": 0,
"doctype": "Workflow State",
@ -80,15 +53,6 @@
"style": "Success",
"workflow_state_name": "Approved"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "remove",
"modified": "2024-09-10 18:16:06.113226",
"name": "Rejected",
"style": "Danger",
"workflow_state_name": "Rejected"
},
{
"docstatus": 0,
"doctype": "Workflow State",
@ -102,19 +66,64 @@
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2026-01-12 15:31:11.081747",
"name": "Sent to Team Leader",
"style": "",
"workflow_state_name": "Sent to Team Leader"
"modified": "2025-05-22 18:15:14.987359",
"name": "Sent To Maintenance manger",
"style": "Info",
"workflow_state_name": "Sent To Maintenance manger"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2025-08-11 12:01:35.474110",
"name": "Closed",
"modified": "2025-06-17 15:42:44.566036",
"name": "Sent To Site Manager",
"style": "",
"workflow_state_name": "Closed"
"workflow_state_name": "Sent To Site Manager"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2026-08-11 11:37:18.893891",
"name": "Sent To Project Manager",
"style": "",
"workflow_state_name": "Sent To Project Manager"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2024-09-17 17:37:02.717022",
"name": "Cancelled",
"style": "",
"workflow_state_name": "Cancelled"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "remove",
"modified": "2024-09-10 18:16:06.113226",
"name": "Rejected",
"style": "Danger",
"workflow_state_name": "Rejected"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2025-05-22 18:15:15.104122",
"name": "Pending Purchase",
"style": "",
"workflow_state_name": "Pending Purchase"
},
{
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2026-01-12 15:31:11.081747",
"name": "Sent to Team Leader",
"style": "",
"workflow_state_name": "Sent to Team Leader"
},
{
"docstatus": 0,
@ -129,10 +138,10 @@
"docstatus": 0,
"doctype": "Workflow State",
"icon": "",
"modified": "2025-05-22 18:15:14.987359",
"name": "Sent To Maintenance manger",
"style": "Info",
"workflow_state_name": "Sent To Maintenance manger"
"modified": "2025-08-11 12:01:35.474110",
"name": "Closed",
"style": "",
"workflow_state_name": "Closed"
},
{
"docstatus": 0,

View File

@ -142,9 +142,13 @@ override_doctype_class = {
# Hook on document methods and events
doc_events = {
"Asset":{
"before_save": "asset_lite.public.py.asset.generate_asset_qr"
}
"Asset": {
"before_save": "asset_lite.public.py.asset.generate_asset_qr",
},
"Work_Order": {
"after_insert": "asset_lite.public.py.work_order_qr.generate_work_order_feedback_qr",
"on_update": "asset_lite.notifications.work_order_notifications.notify_on_workflow_change",
},
}
# Scheduled Tasks
@ -261,12 +265,12 @@ fixtures = [
"module", "=", "Asset Lite"
]
]},
# {
# "doctype": "Role",
# "filters": [
# ["creation", ">", "2024-09-12"]
# ]
# },
{
"doctype": "Role",
"filters": [
["creation", ">", "2024-09-12"]
]
},
{"doctype": "Workspace", "filters": [
[
"module", "=", "Asset Lite"
@ -296,16 +300,17 @@ fixtures = [
]
},
{
"doctype": "Company",
"filters": [
["domain", "=", "Healthcare"]
]
},
{
"doctype": "User Permission",
"filters": [
["allow", "=", "Hospital"]
]
},
# Site-specific fixtures — skip on install to avoid company/account conflicts
# {
# "doctype": "Company",
# "filters": [
# ["domain", "=", "Healthcare"]
# ]
# },
# {
# "doctype": "User Permission",
# "filters": [
# ["allow", "=", "Hospital"]
# ]
# },
]

View File

@ -52,6 +52,7 @@ def get_active_map_data(hospital=None):
"wo_progress": count("Work_Order", {"company": name, "repair_status": "Work In Progress"}),
"wo_review": count("Work_Order", {"company": name, "repair_status": "Pending Review"}),
"wo_completed": count("Work_Order", {"company": name, "repair_status": "Completed"}),
"wo_closed": count("Work_Order", {"company": name, "repair_status": "Closed"}),
"planned_maintenance": count("Asset Maintenance Log", {

View File

View File

@ -0,0 +1,119 @@
import frappe
from frappe.utils import get_url
EXCLUDED_HOSPITAL = "Iman General Hospital"
NOTIFY_STATES = frozenset({
"Repair InProgress",
"Sent To Project Manager",
"Sent To Maintenance manger",
"Closed",
"Completed",
})
MAINTENANCE_MANAGER_PROFILE = "Maintenace Manager"
MOH_SUPERVISOR_PROFILE = "MOH Supervisor"
PROJECT_MANAGER_PROFILE = "Project Manager"
# workflow_state -> role profiles to notify (hospital matched via custom_site_name)
STATE_ROLE_PROFILES = {
"Sent To Maintenance manger": (MAINTENANCE_MANAGER_PROFILE,),
"Sent To Project Manager": (
MAINTENANCE_MANAGER_PROFILE,
MOH_SUPERVISOR_PROFILE,
PROJECT_MANAGER_PROFILE,
),
}
DEFAULT_ROLE_PROFILES = (MAINTENANCE_MANAGER_PROFILE, MOH_SUPERVISOR_PROFILE)
def notify_on_workflow_change(doc, method=None):
"""Send email + Notification Log only when workflow_state changes to a target state."""
previous_state = _get_previous_workflow_state(doc)
if previous_state == doc.workflow_state:
return
_send_workflow_notifications(doc, previous_state)
def _send_workflow_notifications(doc, previous_state=None):
if doc.workflow_state not in NOTIFY_STATES:
return
if not doc.company or doc.company == EXCLUDED_HOSPITAL:
return
recipients = _get_hospital_role_users(doc.company, doc.workflow_state)
if not recipients:
frappe.log_error(
f"No notification recipients for Work Order {doc.name} "
f"(company={doc.company}, workflow_state={doc.workflow_state})",
"Work Order Workflow Notification",
)
return
subject = f"Work Order {doc.name} - {doc.workflow_state}"
wo_link = _get_work_order_asm_app_url(doc.name)
message = _build_message(doc, previous_state, wo_link)
for user in recipients:
_create_notification_log(doc, user, subject, message)
if user.email:
frappe.sendmail(
recipients=[user.email],
subject=subject,
message=message,
reference_doctype="Work_Order",
reference_name=doc.name,
)
def _get_previous_workflow_state(doc):
previous = doc.get_doc_before_save()
if not previous:
return None
return previous.get("workflow_state")
def _get_work_order_asm_app_url(work_order_name):
base_url = get_url().rstrip("/")
app_base = (frappe.conf.get("asm_app_base_path") or "/asm_app").rstrip("/")
return f"{base_url}{app_base}/work-orders/{work_order_name}"
def _get_hospital_role_users(company, workflow_state=None):
role_profiles = STATE_ROLE_PROFILES.get(workflow_state, DEFAULT_ROLE_PROFILES)
return frappe.get_all(
"User",
filters={
"enabled": 1,
"custom_site_name": company,
"role_profile_name": ["in", list(role_profiles)],
},
fields=["name", "email", "full_name"],
)
def _build_message(doc, previous_state, wo_link):
previous_label = previous_state or "N/A"
return f"""
<p>Work Order <b>{doc.name}</b> workflow has been updated.</p>
<p><b>Previous state:</b> {previous_label}</p>
<p><b>Current state:</b> {doc.workflow_state}</p>
<p><b>Hospital:</b> {doc.company}</p>
<p><a href="{wo_link}">Open Work Order</a></p>
"""
def _create_notification_log(doc, user, subject, message):
frappe.get_doc({
"doctype": "Notification Log",
"type": "Alert",
"document_type": "Work_Order",
"document_name": doc.name,
"subject": subject,
"email_content": message,
"from_user": frappe.session.user,
"for_user": user.name,
}).insert(ignore_permissions=True)

View File

@ -3,4 +3,6 @@
# Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations
[post_model_sync]
# Patches added in this section will be executed after doctypes are migrated
# Patches added in this section will be executed after doctypes are migrated
asset_lite.patches.sync_dashboard_reports.execute
asset_lite.patches.sync_work_order_workflow.execute

View File

@ -0,0 +1,303 @@
"""Full inline Script Report bodies stored in Report.report_script (no imports, no .format())."""
ASSET_UP_AND_DOWN = r'''def execute(filters=None):
if filters is None:
filters = {}
result = []
status_count = {}
company = filters.get("company")
site_name = filters.get("site_name")
department = filters.get("department")
name = filters.get("name")
custom_class = filters.get("custom_class")
conditions = ["1=1"]
params = {}
if company:
conditions.append("company = %(company)s")
params["company"] = company
if site_name:
conditions.append("custom_site = %(site_name)s")
params["site_name"] = site_name
if department:
conditions.append("department = %(department)s")
params["department"] = department
if custom_class:
conditions.append("custom_class = %(custom_class)s")
params["custom_class"] = custom_class
if name:
conditions.append("name = %(name)s")
params["name"] = name
where = " AND ".join(conditions)
query = """
SELECT
name AS name,
asset_name,
custom_device_status AS status,
department,
custom_class
FROM
`tabAsset`
WHERE
""" + where + """
ORDER BY
asset_name
"""
result = frappe.db.sql(query, params, as_dict=True)
for row in result:
status = row.get('status')
if status:
normalized_status = status.strip().lower()
status_count[normalized_status] = status_count.get(normalized_status, 0) + 1
chart_labels = list(status_count.keys())
chart_values = list(status_count.values())
columns = [
{"fieldname": "name", "label": "Asset ID", "fieldtype": "Link", "options": "Asset", "width": 200},
{"fieldname": "asset_name", "label": "Asset Name", "fieldtype": "Data", "width": 200},
{"fieldname": "status", "label": "Status", "fieldtype": "Data", "width": 100},
{"fieldname": "department", "label": "Department", "fieldtype": "Link", "options": "Department", "width": 150},
{"fieldname": "custom_class", "label": "Class", "fieldtype": "Data", "width": 200},
]
status_colors = ["#2ba63d" if status == 'up' else "#FF0000" if status == 'down' else "#0000FF" for status in chart_labels]
chart = {
"data": {
"labels": chart_labels,
"datasets": [{"name": "Number of Assets", "values": chart_values}]
},
"type": "pie",
"colors": status_colors
}
return columns, result, None, chart
data = execute(filters)
'''
WORK_ORDER_STATUS = r'''def execute(filters=None):
if filters is None:
filters = {}
columns = [
{"label": _("Work Order Type"), "fieldname": "work_order_type", "fieldtype": "Data", "width": 200},
{"label": _("Open"), "fieldname": "open_count", "fieldtype": "Int", "width": 200},
{"label": _("Work In Progress"), "fieldname": "work_in_progress_count", "fieldtype": "Int", "width": 200},
{"label": _("Pending Review"), "fieldname": "pending_review_count", "fieldtype": "Int", "width": 200},
{"label": _("Completed"), "fieldname": "completed_count", "fieldtype": "Int", "width": 200},
{"label": _("Closed"), "fieldname": "closed_count", "fieldtype": "Int", "width": 200}
]
conditions = ["repair_status IN ('Open', 'Work In Progress', 'Pending Review', 'Completed', 'Closed', 'Cancelled')"]
params = {}
if filters.get('work_order_type'):
conditions.append("work_order_type = %(work_order_type)s")
params['work_order_type'] = filters.get('work_order_type')
if filters.get('repair_status'):
conditions.append("repair_status = %(repair_status)s")
params['repair_status'] = filters.get('repair_status')
if filters.get('asset_type'):
conditions.append("asset_type = %(asset_type)s")
params['asset_type'] = filters.get('asset_type')
if filters.get('company'):
conditions.append("company = %(company)s")
params['company'] = filters.get('company')
if filters.get('site_name'):
conditions.append("site_name = %(site_name)s")
params['site_name'] = filters.get('site_name')
if filters.get('wo_name'):
conditions.append("name = %(wo_name)s")
params['wo_name'] = filters.get('wo_name')
where = " AND ".join(conditions)
query = """
SELECT
name as wo_name,
asset_type,
work_order_type,
SUM(CASE WHEN repair_status = 'Open' THEN 1 ELSE 0 END) AS open_count,
SUM(CASE WHEN repair_status = 'Work In Progress' THEN 1 ELSE 0 END) AS work_in_progress_count,
SUM(CASE WHEN repair_status = 'Pending Review' THEN 1 ELSE 0 END) AS pending_review_count,
SUM(CASE WHEN repair_status = 'Completed' THEN 1 ELSE 0 END) AS completed_count,
SUM(CASE WHEN repair_status = 'Closed' THEN 1 ELSE 0 END) AS closed_count
FROM
`tabWork_Order`
WHERE
""" + where + """
GROUP BY
work_order_type
"""
result = frappe.db.sql(query, params, as_dict=1)
report_summary = []
for status in ['Open', 'Work In Progress', 'Pending Review', 'Completed', 'Closed']:
status_conditions = list(conditions)
status_conditions.append("repair_status = %(status)s")
status_params = dict(params)
status_params['status'] = status
status_where = " AND ".join(status_conditions)
count = frappe.db.sql(
"SELECT COUNT(*) FROM `tabWork_Order` WHERE " + status_where,
status_params
)[0][0]
report_summary.append({"value": count, "label": status})
total = frappe.db.sql(
"SELECT COUNT(*) FROM `tabWork_Order` WHERE " + where,
params
)[0][0]
report_summary.append({"value": total, "label": "Total Work Orders"})
chart = {
"data": {
"labels": [row['work_order_type'] for row in result],
"datasets": [
{"name": "Open", "values": [row['open_count'] for row in result]},
{"name": "Work In Progress", "values": [row['work_in_progress_count'] for row in result]},
{"name": "Pending Review", "values": [row['pending_review_count'] for row in result]},
{"name": "Completed", "values": [row['completed_count'] for row in result]},
{"name": "Closed", "values": [row['closed_count'] for row in result]},
]
},
"type": "bar",
"barOptions": {"stacked": 1, "spaceRatio": 0.6},
"colors": ["#CCCCB7", "#52B2BF", "#9EC1A4", "#058D7C", "#A3A5CF"],
}
return columns, result, None, chart, report_summary
data = execute(filters)
'''
ASSET_WISE_COUNT = r'''def execute(filters=None):
if filters is None:
filters = {}
conditions = ["1=1"]
params = {}
if filters.get("company"):
conditions.append("aml.custom_hospital_name = %(company)s")
params["company"] = filters.get("company")
if filters.get("site_name"):
conditions.append("a.custom_site = %(site_name)s")
params["site_name"] = filters.get("site_name")
where = " AND ".join(conditions)
query = """
SELECT
aml.item_name AS item_name,
aml.maintenance_status AS maintenance_status,
SUM(CASE WHEN aml.due_date = aml.completion_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_on_time,
SUM(CASE WHEN aml.completion_date < aml.due_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_within_time,
SUM(CASE WHEN aml.completion_date > aml.due_date THEN 1 ELSE 0 END) AS delay_in_completion,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date > CURRENT_DATE() THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date < CURRENT_DATE() THEN 1 ELSE 0 END) AS overdue,
SUM(CASE WHEN aml.maintenance_status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM `tabAsset Maintenance Log` aml
LEFT JOIN `tabAsset` a ON aml.asset_name = a.name
WHERE """ + where + """
GROUP BY aml.item_name
"""
data = frappe.db.sql(query, params, as_dict=True)
return data
data = execute(filters)
'''
ASSIGNEES_STATUS_COUNT = r'''def execute(filters=None):
if filters is None:
filters = {}
conditions = ["1=1"]
params = {}
if filters.get("company"):
conditions.append("aml.custom_hospital_name = %(company)s")
params["company"] = filters.get("company")
if filters.get("site_name"):
conditions.append("a.custom_site = %(site_name)s")
params["site_name"] = filters.get("site_name")
where = " AND ".join(conditions)
query = """
SELECT
aml.item_name AS item_name,
aml.maintenance_status AS maintenance_status,
aml.assign_to_name AS assigned_to,
SUM(CASE WHEN aml.due_date = aml.completion_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_on_time,
SUM(CASE WHEN aml.completion_date < aml.due_date AND aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed_within_time,
SUM(CASE WHEN aml.completion_date > aml.due_date THEN 1 ELSE 0 END) AS delay_in_completion,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date > CURRENT_DATE() THEN 1 ELSE 0 END) AS pending,
SUM(CASE WHEN aml.maintenance_status = 'Planned' AND aml.completion_date IS NULL AND aml.due_date < CURRENT_DATE() THEN 1 ELSE 0 END) AS overdue,
SUM(CASE WHEN aml.maintenance_status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled
FROM `tabAsset Maintenance Log` aml
LEFT JOIN `tabAsset` a ON aml.asset_name = a.name
WHERE """ + where + """
GROUP BY aml.assign_to_name
"""
data = frappe.db.sql(query, params, as_dict=True)
return data
data = execute(filters)
'''
ASSET_MAINTENANCE_FREQUENCY = r'''def execute(filters=None):
if filters is None:
filters = {}
conditions = ["1=1"]
params = {}
if filters.get("company"):
conditions.append("aml.custom_hospital_name = %(company)s")
params["company"] = filters.get("company")
if filters.get("site_name"):
conditions.append("a.custom_site = %(site_name)s")
params["site_name"] = filters.get("site_name")
where = " AND ".join(conditions)
query = """
SELECT
aml.custom_asset_names AS item,
SUM(CASE WHEN aml.maintenance_status = 'Planned' THEN 1 ELSE 0 END) AS planned,
SUM(CASE WHEN aml.maintenance_status = 'Completed' THEN 1 ELSE 0 END) AS completed,
SUM(CASE WHEN aml.maintenance_status = 'Cancelled' THEN 1 ELSE 0 END) AS cancelled,
SUM(CASE WHEN aml.maintenance_status = 'Overdue' THEN 1 ELSE 0 END) AS overdue,
COUNT(aml.custom_asset_names) AS total_count
FROM `tabAsset Maintenance Log` aml
LEFT JOIN `tabAsset` a ON aml.asset_name = a.name
WHERE """ + where + """
GROUP BY aml.custom_asset_names
"""
data = frappe.db.sql(query, params, as_dict=True)
return data
data = execute(filters)
'''
REPORT_SCRIPTS = {
"Asset Up and Down": ASSET_UP_AND_DOWN,
"Work Order Status": WORK_ORDER_STATUS,
"Asset wise Count": ASSET_WISE_COUNT,
"Asset Maintenance Assignees Status Count": ASSIGNEES_STATUS_COUNT,
"Asset Maintenance Frequency": ASSET_MAINTENANCE_FREQUENCY,
}

View File

@ -0,0 +1,173 @@
"""Sync dashboard report scripts and Hospital/Site filters into the live database."""
import frappe
from asset_lite.patches.dashboard_report_inline_scripts import REPORT_SCRIPTS
LOCATION_FILTERS = [
{
"fieldname": "company",
"label": "Hospital",
"fieldtype": "Link",
"options": "Company",
"mandatory": 0,
"wildcard_filter": 0,
},
{
"fieldname": "site_name",
"label": "Site Name",
"fieldtype": "Link",
"options": "Mobile Team Site",
"mandatory": 0,
"wildcard_filter": 0,
},
]
DASHBOARD_REPORTS = {
"Asset Up and Down": {
"javascript": (
'frappe.query_reports["Asset Up and Down"] = {\n'
" filters: [\n"
" {\n"
' fieldname: "company",\n'
' label: __("Hospital"),\n'
' fieldtype: "Link",\n'
' options: "Company"\n'
" },\n"
" {\n"
' fieldname: "site_name",\n'
' label: __("Site Name"),\n'
' fieldtype: "Link",\n'
' options: "Mobile Team Site"\n'
" },\n"
" {\n"
' fieldname: "department",\n'
' label: __("Department"),\n'
' fieldtype: "Link",\n'
' options: "Department"\n'
" },\n"
" {\n"
' fieldname: "custom_class",\n'
' label: __("Class"),\n'
' fieldtype: "Select",\n'
' options: "\\nClass A\\nClass B\\nClass C"\n'
" },\n"
" {\n"
' fieldname: "name",\n'
' label: __("Asset"),\n'
' fieldtype: "Link",\n'
' options: "Asset"\n'
" }\n"
" ]\n"
"};\n"
),
"extra_filters": [
{
"fieldname": "department",
"label": "Department",
"fieldtype": "Link",
"options": "Department",
"mandatory": 0,
"wildcard_filter": 0,
},
{
"fieldname": "custom_class",
"label": "Class",
"fieldtype": "Select",
"options": "\nClass A\nClass B\nClass C",
"mandatory": 0,
"wildcard_filter": 0,
},
{
"fieldname": "name",
"label": "Asset",
"fieldtype": "Link",
"options": "Asset",
"mandatory": 0,
"wildcard_filter": 0,
},
],
},
"Work Order Status": {
"extra_filters": [
{
"fieldname": "repair_status",
"label": "Status",
"fieldtype": "Select",
"options": "\nOpen\nWork In Progress\nPending Review\nCompleted\nClosed",
"mandatory": 0,
"wildcard_filter": 0,
},
{
"fieldname": "work_order_type",
"label": "Work Order Type",
"fieldtype": "Link",
"options": "Issue Type",
"mandatory": 0,
"wildcard_filter": 0,
},
{
"fieldname": "wo_name",
"label": "Work Order",
"fieldtype": "Link",
"options": "Work_Order",
"mandatory": 0,
"wildcard_filter": 0,
},
{
"fieldname": "asset_type",
"label": "Asset Type",
"fieldtype": "Link",
"options": "Asset Type",
"mandatory": 0,
"wildcard_filter": 0,
},
],
},
"Asset wise Count": {"extra_filters": []},
"Asset Maintenance Assignees Status Count": {"extra_filters": []},
"Asset Maintenance Frequency": {"extra_filters": []},
}
def _merge_filters(report_name, extra_filters):
merged = []
seen = set()
for row in LOCATION_FILTERS + extra_filters:
fieldname = row["fieldname"]
if fieldname in seen:
continue
seen.add(fieldname)
merged.append({**row, "parent": report_name, "parenttype": "Report", "parentfield": "filters"})
return merged
def sync_dashboard_reports():
updated = []
for report_name, config in DASHBOARD_REPORTS.items():
if not frappe.db.exists("Report", report_name):
continue
inline_script = REPORT_SCRIPTS.get(report_name)
if not inline_script:
continue
doc = frappe.get_doc("Report", report_name)
doc.report_script = inline_script.strip()
doc.report_type = "Script Report"
doc.query = None
if config.get("javascript") is not None:
doc.javascript = config.get("javascript")
doc.set("filters", _merge_filters(report_name, config.get("extra_filters", [])))
doc.flags.ignore_permissions = True
doc.save()
updated.append(report_name)
frappe.db.commit()
return updated
def execute():
return sync_dashboard_reports()

View File

@ -0,0 +1,90 @@
"""Sync Work Order workflow states/transitions from fixtures into the live database."""
import json
from pathlib import Path
import frappe
def _ensure_workflow_masters():
for action_name in ("Work Done", "Approve & Close"):
if not frappe.db.exists("Workflow Action Master", action_name):
frappe.get_doc(
{
"doctype": "Workflow Action Master",
"workflow_action_name": action_name,
}
).insert(ignore_permissions=True)
for state_name in ("Sent To Project Manager", "Sent To Maintenance manger"):
if not frappe.db.exists("Workflow State", state_name):
frappe.get_doc(
{
"doctype": "Workflow State",
"workflow_state_name": state_name,
"style": "Info",
}
).insert(ignore_permissions=True)
def _load_work_order_fixture():
fixture_path = Path(frappe.get_app_path("asset_lite")) / "fixtures" / "workflow.json"
workflows = json.loads(fixture_path.read_text())
for workflow in workflows:
if workflow.get("name") == "Work Order":
return workflow
raise frappe.ValidationError("Work Order workflow not found in fixtures/workflow.json")
def sync_work_order_workflow():
_ensure_workflow_masters()
workflow_data = _load_work_order_fixture()
if not frappe.db.exists("Workflow", "Work Order"):
return "Work Order workflow not found"
doc = frappe.get_doc("Workflow", "Work Order")
doc.is_active = workflow_data.get("is_active", 1)
doc.override_status = workflow_data.get("override_status", 0)
doc.send_email_alert = workflow_data.get("send_email_alert", 0)
doc.workflow_state_field = workflow_data.get("workflow_state_field", "workflow_state")
doc.states = []
for row in workflow_data.get("states", []):
doc.append(
"states",
{
"state": row.get("state"),
"doc_status": row.get("doc_status"),
"update_field": row.get("update_field"),
"update_value": row.get("update_value"),
"is_optional_state": row.get("is_optional_state", 0),
"avoid_status_override": row.get("avoid_status_override", 0),
"allow_edit": row.get("allow_edit"),
"message": row.get("message"),
"next_action_email_template": row.get("next_action_email_template"),
},
)
doc.transitions = []
for row in workflow_data.get("transitions", []):
doc.append(
"transitions",
{
"state": row.get("state"),
"action": row.get("action"),
"next_state": row.get("next_state"),
"allowed": row.get("allowed"),
"allow_self_approval": row.get("allow_self_approval", 1),
"condition": row.get("condition") or "",
},
)
doc.flags.ignore_permissions = True
doc.save()
frappe.db.commit()
return "Work Order workflow synced"
def execute():
return sync_work_order_workflow()

View File

@ -0,0 +1,64 @@
import base64
import io
import frappe
import pyqrcode
def generate_work_order_feedback_qr(doc, method=None):
"""Attach a small QR code for the public WO feedback page to custom_wo_qr."""
if not doc.name:
return
meta = frappe.get_meta("Work_Order")
if not meta.has_field("custom_wo_qr"):
return
if doc.get("custom_wo_qr"):
return
if frappe.db.exists(
"File",
{
"attached_to_doctype": "Work_Order",
"attached_to_name": doc.name,
"attached_to_field": "custom_wo_qr",
},
):
return
try:
site_url = frappe.utils.get_url().rstrip("/")
feedback_url = f"{site_url}/asm_app/wo-feedback?work_order={doc.name}"
qr_obj = pyqrcode.create(feedback_url, error="H")
buffer = io.BytesIO()
qr_obj.png(buffer, scale=6)
encoded_content = base64.b64encode(buffer.getvalue()).decode("utf-8")
file_doc = frappe.get_doc(
{
"doctype": "File",
"file_name": f"{doc.name}-wo-feedback-qr.png",
"attached_to_doctype": "Work_Order",
"attached_to_name": doc.name,
"attached_to_field": "custom_wo_qr",
"content": encoded_content,
"decode": True,
"is_private": 0,
}
)
file_doc.insert(ignore_permissions=True)
frappe.db.set_value(
"Work_Order",
doc.name,
"custom_wo_qr",
file_doc.file_url,
update_modified=False,
)
except Exception:
frappe.log_error(
f"Error generating WO feedback QR for {doc.name}",
"Work Order QR Generation Error",
)