diff --git a/hrms/api/roster.py b/hrms/api/roster.py index bc448e0d17..b470c9a185 100644 --- a/hrms/api/roster.py +++ b/hrms/api/roster.py @@ -7,6 +7,7 @@ from hrms.hr.doctype.shift_assignment.shift_assignment import ShiftAssignment from hrms.hr.doctype.shift_assignment_tool.shift_assignment_tool import create_shift_assignment from hrms.hr.doctype.shift_schedule.shift_schedule import get_or_insert_shift_schedule +from hrms.telemetry import capture ALLOWED_EMPLOYEE_FILTERS = { "status", @@ -105,6 +106,16 @@ def create_shift_schedule_assignment( } ).insert() + capture( + "shift_schedule_assignment_created", + { + "frequency": frequency, + "status": status, + "has_end_date": bool(end_date), + "repeat_on_days": len(repeat_on_days or []), + }, + ) + if not end_date or date_diff(end_date, start_date) <= 90: return shift_schedule_assignment.create_shifts(start_date, end_date) @@ -153,6 +164,9 @@ def swap_shift( else: tgt_company = frappe.db.get_value("Employee", tgt_employee, "company") + # All guards passed and the swap is proceeding — capture only successful attempts. + capture("shift_swapped", {"mutual_swap": bool(tgt_shift)}) + break_shift(src_shift_doc, src_date) insert_shift( tgt_employee, diff --git a/hrms/hooks.py b/hrms/hooks.py index 50892083ca..428a582b40 100644 --- a/hrms/hooks.py +++ b/hrms/hooks.py @@ -210,12 +210,30 @@ "hrms.overrides.employee_master.update_approver_role", "hrms.overrides.employee_master.publish_update", ], - "after_insert": "hrms.overrides.employee_master.update_job_applicant_and_offer", + "after_insert": [ + "hrms.overrides.employee_master.update_job_applicant_and_offer", + "hrms.telemetry.on_milestone_insert", + ], "on_trash": "hrms.overrides.employee_master.update_employee_transfer", "after_delete": "hrms.overrides.employee_master.publish_update", }, "Project": {"validate": "hrms.controllers.employee_boarding_controller.update_employee_boarding_status"}, "Task": {"on_update": "hrms.controllers.employee_boarding_controller.update_task"}, + # ---- Usage telemetry: recurring feature usage (see hrms/telemetry.py) ---- + "Leave Application": {"on_submit": "hrms.telemetry.on_leave_application_submit"}, + "Expense Claim": {"on_submit": "hrms.telemetry.on_expense_claim_submit"}, + "Attendance Request": {"on_submit": "hrms.telemetry.on_attendance_request_submit"}, + "Shift Request": {"on_submit": "hrms.telemetry.on_shift_request_submit"}, + "Employee Checkin": {"after_insert": "hrms.telemetry.on_employee_checkin"}, + # ---- Activation telemetry: post-install setup funnel (first-time milestones) ---- + "Shift Type": {"after_insert": "hrms.telemetry.on_milestone_insert"}, + "Leave Type": {"after_insert": "hrms.telemetry.on_milestone_insert"}, + "Salary Structure": {"after_insert": "hrms.telemetry.on_milestone_insert"}, + "Job Opening": {"after_insert": "hrms.telemetry.on_milestone_insert"}, + "Appraisal Cycle": {"after_insert": "hrms.telemetry.on_milestone_insert"}, + "Employee Onboarding": {"after_insert": "hrms.telemetry.on_milestone_insert"}, + "Salary Slip": {"on_submit": "hrms.telemetry.on_milestone_submit"}, + "Payroll Entry": {"on_submit": "hrms.telemetry.on_milestone_submit"}, } # Scheduled Tasks @@ -240,6 +258,7 @@ "hrms.hr.doctype.interview.interview.send_daily_feedback_reminder", "hrms.hr.doctype.shift_assignment.shift_assignment.mark_expired_shift_assignments_as_inactive", "hrms.hr.doctype.job_opening.job_opening.close_expired_job_openings", + "hrms.telemetry.capture_daily_attendance_pulse", ], "daily_long": [ "hrms.hr.doctype.leave_ledger_entry.leave_ledger_entry.process_expired_allocation", diff --git a/hrms/hr/doctype/expense_claim/expense_claim.json b/hrms/hr/doctype/expense_claim/expense_claim.json index fd04ec9c34..792e5e42a0 100644 --- a/hrms/hr/doctype/expense_claim/expense_claim.json +++ b/hrms/hr/doctype/expense_claim/expense_claim.json @@ -260,6 +260,7 @@ "fieldname": "payable_account", "fieldtype": "Link", "label": "Payable Account", + "mandatory_depends_on": "eval:!doc.is_paid", "options": "Account" }, { @@ -507,7 +508,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2025-11-24 11:45:16.354788", + "modified": "2026-07-15 16:43:28.006860", "modified_by": "Administrator", "module": "HR", "name": "Expense Claim", diff --git a/hrms/hr/doctype/expense_claim/expense_claim.py b/hrms/hr/doctype/expense_claim/expense_claim.py index 2d35debcc0..926888de43 100644 --- a/hrms/hr/doctype/expense_claim/expense_claim.py +++ b/hrms/hr/doctype/expense_claim/expense_claim.py @@ -186,9 +186,6 @@ def on_discard(self): self.db_set("approval_status", "Cancelled") def before_submit(self): - if not self.payable_account and not self.is_paid: - frappe.throw(_("Payable Account is mandatory to submit an Expense Claim")) - self.validate_for_self_approval() def publish_update(self): diff --git a/hrms/public/js/hrms.bundle.js b/hrms/public/js/hrms.bundle.js index 8793d78423..f8e0c1a009 100644 --- a/hrms/public/js/hrms.bundle.js +++ b/hrms/public/js/hrms.bundle.js @@ -5,4 +5,5 @@ import "./templates/rating.html"; import "./utils"; import "./utils/payroll_utils"; import "./utils/leave_utils"; +import "./utils/telemetry.js"; import "./salary_slip_deductions_report_filters.js"; diff --git a/hrms/public/js/utils/telemetry.js b/hrms/public/js/utils/telemetry.js new file mode 100644 index 0000000000..1d0241936c --- /dev/null +++ b/hrms/public/js/utils/telemetry.js @@ -0,0 +1,157 @@ +frappe.provide("hrms.telemetry"); + +// HR workspaces shown on the desk sidebar (see hrms/hr/workspace + payroll/workspace). +const HR_WORKSPACES = new Set([ + "HR", + "HR Setup", + "Leaves", + "Shift & Attendance", + "Expenses", + "Performance", + "Recruitment", + "Tenure", + "Payroll", +]); + +// Key HR doctypes worth tracking navigation into. Kept explicit so we only ever +// emit HR-relevant events and never generic desk traffic. +const HR_DOCTYPES = new Set([ + "Employee", + "Employee Checkin", + "Attendance", + "Attendance Request", + "Leave Application", + "Leave Allocation", + "Leave Type", + "Leave Policy", + "Shift Type", + "Shift Assignment", + "Shift Request", + "Expense Claim", + "Salary Structure", + "Salary Structure Assignment", + "Salary Slip", + "Payroll Entry", + "Payroll Period", + "Job Opening", + "Job Applicant", + "Job Offer", + "Interview", + "Appraisal", + "Appraisal Cycle", + "Appraisal Template", + "Employee Onboarding", + "Employee Separation", +]); + +// HR + Payroll reports (query/script reports). Scoped explicitly so we learn which +// HR reports get used frequently, without capturing unrelated desk report traffic. +const HR_REPORTS = new Set([ + "Monthly Attendance Sheet", + "Shift Attendance", + "Employees working on a holiday", + "Employee Leave Balance", + "Employee Leave Balance Summary", + "Leave Ledger", + "Employee Analytics", + "Employee Information", + "Employee Birthday", + "Employee Exits", + "Employee Advance Summary", + "Employee Hours Utilization Based On Timesheet", + "Recruitment Analytics", + "Appraisal Overview", + "Unpaid Expense Claim", + "Vehicle Expenses", + "Project Profitability", + "Salary Register", + "Salary Payments Based On Payment Mode", + "Salary Payments via ECS", + "Bank Remittance", + "Employee CTC Break-up", + "Accrued Earnings Report", + "Income Tax Computation", + "Income Tax Deductions", + "Professional Tax Deductions", + "Provident Fund Deductions", + "Daily Work Summary Replies", +]); + +function hr_capture(event, props) { + if (!frappe.telemetry?.enabled) return; + try { + frappe.telemetry.capture(event, "hrms", props || {}); + } catch (e) { + // telemetry must never break navigation + } +} + +// Turn the current route into a semantic HR event, or null if it's not HR. +function classify(route) { + if (!route || !route.length) return null; + const head = route[0]; + + if (head === "Workspaces") { + // ["Workspaces", Name] or ["Workspaces", "private", Name] + const name = route[route.length - 1]; + if (HR_WORKSPACES.has(name)) { + return { event: "viewed_workspace", props: { workspace: name } }; + } + return null; + } + + if (head === "List" && HR_DOCTYPES.has(route[1])) { + return { + event: "viewed_list", + props: { doctype: route[1], view: route[2] || "List" }, + }; + } + + if (head === "Form" && HR_DOCTYPES.has(route[1])) { + const name = route[2]; + const is_new = typeof name === "string" && name.startsWith("new-"); + return { + event: is_new ? "started_creating" : "viewed_form", + props: { doctype: route[1] }, + }; + } + + if ((head === "query-report" || head === "report") && HR_REPORTS.has(route[1])) { + return { event: "viewed_report", props: { report: route[1] } }; + } + + return null; +} + +function track_route() { + const hit = classify(frappe.get_route()); + if (hit) hr_capture(hit.event, hit.props); +} + +function track_landing() { + try { + if (sessionStorage.getItem("hrms_landing_tracked")) return; + sessionStorage.setItem("hrms_landing_tracked", "1"); + } catch (e) { + // private mode / storage disabled — fall through and still capture once + } + + const route = frappe.get_route() || []; + const hit = classify(route); + hr_capture("landed_on_desk", { + route_type: route[0] || "", + landed_in_hr: Boolean(hit), + ...(hit ? hit.props : {}), + }); +} + +$(document).on("app_ready", function () { + if (!frappe.telemetry?.enabled) return; + + // Defer to the next tick so the first route is fully resolved. + frappe.after_ajax(() => { + track_landing(); + track_route(); + frappe.router.on("change", track_route); + }); +}); diff --git a/hrms/telemetry.py b/hrms/telemetry.py new file mode 100644 index 0000000000..a01412db53 --- /dev/null +++ b/hrms/telemetry.py @@ -0,0 +1,191 @@ +import frappe +from frappe.query_builder.functions import Count +from frappe.utils import date_diff, getdate, today +from frappe.utils.telemetry import capture as _capture +from frappe.utils.telemetry import site_age + +APP = "hrms" +ACTIVATION_WINDOW_DAYS = 30 + + +def _skip_context() -> bool: + """Don't record telemetry from automated / non-interactive runs.""" + return bool( + frappe.flags.in_install + or frappe.flags.in_migrate + or frappe.flags.in_patch + or frappe.flags.in_test + or frappe.flags.in_import + ) + + +def capture(event: str, properties: dict | None = None) -> None: + """Record an HR usage event (fires on every occurrence).""" + if _skip_context(): + return + + _capture(event, APP, properties=properties or {}) + + +def capture_first( + event: str, + doctype: str, + properties: dict | None = None, + filters: dict | None = None, +) -> None: + if _skip_context(): + return + + age = site_age() + if not age or age > ACTIVATION_WINDOW_DAYS: + return + + # after_insert/on_submit run once the row exists, so the first ever action => count 1 + if frappe.db.count(doctype, filters or {}) > 1: + return + + capture(event, {"day_since_install": age, **(properties or {})}) + + +def _duration_days(from_date, to_date) -> int | None: + if not (from_date and to_date): + return None + return date_diff(to_date, from_date) + 1 + + +def on_leave_application_submit(doc, method=None): + capture( + "leave_application_submitted", + { + "leave_type": doc.leave_type, + "total_leave_days": doc.total_leave_days, + "half_day": bool(doc.half_day), + "self_approved": doc.leave_approver == frappe.session.user, + }, + ) + capture_first("first_leave_applied", "Leave Application", filters={"docstatus": 1}) + + +def on_expense_claim_submit(doc, method=None): + capture( + "expense_claim_submitted", + { + "total_claimed_amount": doc.total_claimed_amount, + "expense_count": len(doc.expenses or []), + "is_paid": bool(doc.is_paid), + "has_advances": bool(doc.get("advances")), + "has_taxes": bool(doc.get("taxes")), + }, + ) + capture_first("first_expense_claimed", "Expense Claim", filters={"docstatus": 1}) + + +# Standard `Attendance Request.reason` Select options. Anything outside this set +# (e.g. a site that customised the field into free-text) is coarsened to "other" +# so raw user-entered notes never leave the site. +ATTENDANCE_REQUEST_REASONS = {"Work From Home", "On Duty"} + + +def on_attendance_request_submit(doc, method=None): + capture( + "attendance_request_submitted", + { + "reason": doc.reason if doc.reason in ATTENDANCE_REQUEST_REASONS else "other", + "half_day": bool(doc.half_day), + "include_holidays": bool(doc.include_holidays), + "days": _duration_days(doc.from_date, doc.to_date), + }, + ) + + +def on_shift_request_submit(doc, method=None): + capture( + "shift_request_submitted", + { + "shift_type": doc.shift_type, + "days": _duration_days(doc.from_date, doc.to_date), + }, + ) + + +def on_employee_checkin(doc, method=None): + capture( + "employee_checkin", + { + "log_type": doc.log_type, + "has_shift": bool(doc.shift), + "has_geolocation": bool(doc.latitude and doc.longitude), + "via_device": bool(doc.device_id), + }, + ) + capture_first("first_attendance_marked", "Employee Checkin") + + +CREATION_MILESTONES = { + "Employee": "first_employee_created", + "Shift Type": "shift_type_configured", + "Leave Type": "leave_type_configured", + "Salary Structure": "salary_structure_created", + "Job Opening": "recruitment_started", + "Appraisal Cycle": "performance_cycle_started", + "Employee Onboarding": "employee_onboarding_started", +} + +# doctype -> milestone event, fired the first time such a doc is *submitted* +SUBMISSION_MILESTONES = { + "Salary Slip": "first_salary_slip_created", + "Payroll Entry": "first_payroll_run", +} + + +def on_milestone_insert(doc, method=None): + event = CREATION_MILESTONES.get(doc.doctype) + if event: + capture_first(event, doc.doctype) + + +def on_milestone_submit(doc, method=None): + event = SUBMISSION_MILESTONES.get(doc.doctype) + if event: + capture_first(event, doc.doctype, filters={"docstatus": 1}) + + +def capture_daily_attendance_pulse(): + if _skip_context() or not frappe.get_system_settings("enable_telemetry"): + return + + active_employees = frappe.db.count("Employee", {"status": "Active"}) + if not active_employees: + # Nothing set up yet — a zero-employee site would just add noise. + return + + day = today() + day_start, day_end = f"{day} 00:00:00", f"{day} 23:59:59" + + checkins = frappe.db.count("Employee Checkin", {"time": ["between", [day_start, day_end]]}) + + Checkin = frappe.qb.DocType("Employee Checkin") + employees_checked_in = ( + frappe.qb.from_(Checkin) + .select(Count(Checkin.employee).distinct()) + .where((Checkin.time >= day_start) & (Checkin.time <= day_end)) + ).run()[0][0] or 0 + + attendance_marked = frappe.db.count("Attendance", {"attendance_date": day, "docstatus": 1}) + + def rate(n): + return round(n / active_employees, 3) + + capture( + "attendance_daily_summary", + { + "active_employees": active_employees, + "checkins": checkins, + "employees_checked_in": employees_checked_in, + "attendance_marked": attendance_marked, + "checkin_participation_rate": rate(employees_checked_in), + "attendance_participation_rate": rate(attendance_marked), + # 0 = Monday .. 6 = Sunday, so weekends can be excluded when judging regularity + "weekday": getdate(day).weekday(), + }, + )