CAFM Implementation & Handover
Computer-Aided Facility Management for Jood FM (Ministry of National Guard Health Affairs), built on Zoho Creator (Deluge). One app, 6 hospital sites, hospital-side supervisors on portal access. This document is the developer handover: every module below lists its real fields, functions, workflows, the Deluge for each form event, data-flow scenarios, and a flowchart.
Architecture
Location tree: SITE → Building → Floor → Zone → Room. Transactional forms (Work Orders, PPM, Stock Movements, Assets) carry a SITE lookup and are scoped by role.
Roles
Supervisor = portal user locked to one hospital (SITE auto-filled & hidden). Admin = app user or portal admin, picks any site. Resolved via getSiteForUser(zoho.loginuserid).
Environments
Development → Stage → Production (link name cafm). Schema publishes across; records do not — data is loaded per environment.
How to read this
Pick a module on the left. Each opens with inner tabs: Overview, Data Model, Deluge Scripts, Functions, Workflows, Scenarios, Flowchart. Code blocks are copy-paste ready.
Modules
- Work Orders (Ticketing)
- Service Requests (Staff / Resident Intake)
- SLA Management
- PPM Schedules & Auto-Generation
- Assets, O&M Docs, Contracts & Audit
- Spare Parts & Inventory
- Checklist Engine
- Sites & Facility Hierarchy
- Access, Roles & Portal
- Master Data (Vendors, Technicians, Categories, Tools)
- Notifications & Email
- Reports & Compliance
- Dashboards & Reporting
- Automations & Scheduled Jobs
- Data-Model Additions (build these)
Requirements coverage
| Client requirement | Built in |
|---|---|
| Assets & Facility Register | |
| Centralized asset repository, unique IDs | Assets, O&M Docs, Contracts & Audit · Data-Model Additions (uniqueness) |
| Facility structure (buildings, floors, zones, rooms) | Sites & Facility Hierarchy |
| Asset details (type, model, serial, install, warranty, ownership) | Assets, O&M Docs, Contracts & Audit · Data-Model Additions (ownership) |
| Store & link O&M manuals + contracts per asset | Assets, O&M Docs, Contracts & Audit |
| Revision control & audit trail of O&M / contracts | Data-Model Additions (revision control) · Assets (audit) |
| Digital access of O&M | Assets, O&M Docs, Contracts & Audit |
| Preventive & Predictive Maintenance | |
| Master PPM plan (daily/weekly/monthly/yearly) | PPM Schedules & Auto-Generation |
| Work order generation | PPM Schedules & Auto-Generation · Automations & Scheduled Jobs |
| Maintenance checklists & compliance reports | Checklist Engine · Reports & Compliance |
| Predictive maintenance scheduling (usage hours) | PPM Schedules & Auto-Generation · Automations & Scheduled Jobs |
| Task Management (Ticketing) | |
| Service Request Ticket (staff or residents) | Service Requests (Staff / Resident Intake) |
| Ticket Confirmation | Service Requests · Data-Model Additions (acknowledge step) |
| Ticket Assigning / Closing / Status update | Work Orders (Ticketing) |
| Ticket Email | Notifications & Email · Service Requests |
| Dashboards | Dashboards & Reporting |
| SLA Monitoring & Performance | |
| SLA definitions per service type / asset | SLA Management |
| Response & resolution time tracking | SLA Management |
| Performance dashboards (SLA %, avg downtime, technician KPIs) | Reports & Compliance · Dashboards & Reporting |
| Spare Parts & Inventory | |
| Product Master | Spare Parts & Inventory · Master Data |
| Opening Stock | Spare Parts & Inventory |
| Stock Receipt, Issues, Transfer | Spare Parts & Inventory · Data-Model Additions (inter-site transfer) |
Work Orders (Ticketing)
The Work Orders module is the flagship ticketing engine of the Jood FM CAFM app, running across six hospital sites. It captures every maintenance job — whether raised manually by a site Supervisor, opened by an Administrator for any hospital, auto-generated from a PPM schedule, triggered predictively from asset running hours, or pushed in from an external help desk — and drives it through a full lifecycle: Open, Assigned, In progress, On hold, Completed, Verified, Cancelled. Role is detected on load via getSiteForUser(zoho.loginuserid): a Supervisor is locked to their own SITE (auto-filled and hidden), while Admins see the site picker. Each ticket gets a per-center, per-month sequential number WO-{Center}{yyMM}-{4-digit serial} and an SLA target_completion derived from its priority (Critical 4h, High 8h, Medium 24h, Low 72h). Every create, status change and delete is written to audit_log, and notifications fire to the technician on assignment and to the raiser on completion. This document is a junior-developer handover: all field link names below are the real ones on the live forms — never invent new ones.
Developer notes & pending
- Center code source: the SITE form has BOTH Site_Code and Center. This doc uses SITE.Center per the WO-{Center} spec. Confirm with real data which field holds the short alpha code (e.g. a 3-letter hospital code) before go-live; buildWorkOrderNumber uppercases and strips non-alphanumerics, so keep Center values short.
- Portal email: always read the logged-in email as zoho.loginuserid on this app (NOT zoho.loginuser) - portal logins expose the email there. getSiteForUser(email) already exists in owner context and returns the Supervisor SITE record id, or 0 if the user is not a site supervisor.
- Create both computeTargetCompletion and buildWorkOrderNumber as standalone owner-context Deluge functions so the form events AND the PPM/Predictive/Desk generator functions can all call them.
- On Validate binding: bind the WO-number line to the Created event. If your single validate also fires on Edit, guard the number line so an edit never re-stamps it (e.g. only call buildWorkOrderNumber when the record is new / work_order_number is blank). The target_completion line is safe to run on both Create and Edit.
- SLA hours math: adding a decimal to a datetime adds that many days in Deluge, so slaHours/24.0 adds the hours. If a Deluge build rejects fractional addition, fall back to addDay for whole days plus explicit residual-hour handling. Rounding is to the nearest second - acceptable for SLA windows.
- No WHILE loops: both the serial max-scan and the 4-digit pad use bounded logic (for-each over a filtered query, and subString-based padding) per the app's Deluge rules.
- Programmatic inserts (scenarios 3, 4, 5) do NOT trigger On Load or On user input form events. The generating function must set every field explicitly - SITE, work_order_number (call buildWorkOrderNumber), status, raised_on, target_completion (call computeTargetCompletion) - and write its own audit_log Created row.
- audit_log field names verified live: the action field link name is action_1 (not 'action'); other real fields are SITE, reference_type, reference_id, field_changed, old_value, new_value, changed_by, changed_on. reference_type value for this module is 'Work order'.
- No raiser field exists on work_orders. The raiser email is recovered from the audit_log Created row (changed_by = zoho.loginuserid captured on add). The completion email workflow depends on that Created row existing.
- No SLA-breach flag field exists today. The Flag SLA breach workflow logs to audit_log only. If a visible flag / report filter is needed, add a checkbox field (suggested link name sla_breached) and set it in that workflow - do not reuse on_time, which is about completion timeliness.
- sendmail 'from' must be a verified sender; zoho.adminuserid (the app owner email) is the safe default. technicians.email is the verified notification target and assigned_technician / verified_by store a single technician record ID.
- Delete: Zoho Creator has no native form-level pre-delete veto. Remove the Delete permission from the report and expose a custom record button running the Delete Deluge above, so Completed/Verified tickets are protected and every deletion is audited.
- Schedule limits: prefer a record-level time-based workflow anchored to target_completion for SLA breach detection rather than a polling scheduled function - the app has tight per-user schedule-run limits.
- Image field routes to the client's S3 rather than Creator storage: 10 photos per ticket across 6 sites will exhaust the 1 GB/user Creator quota quickly.
- Predictive running-hours source: the meter/running-hours data lives on the assets side, which was not fetched for this module. The Predictive generator (scenario 4) assumes an asset running-hours reading exists there - confirm the exact asset field before building that generator.
| Field | Type | Notes |
|---|---|---|
SITE | Lookup -> SITE | Admin-only site picker. Auto-filled and hidden for Supervisors via getSiteForUser; visible for Admins. Set by record ID. |
work_order_number | Single line | System-generated WO-{Center}{yyMM}-{4-digit serial}. Disabled on both Create and Edit. Preview on load, final stamp On Validate. |
job_type | Dropdown | Preventive | Predictive | Corrective. Defaults to Corrective on manual create. |
Section1 | Dropdown | Trade / discipline: HVAC | CIVIL | Plumber | Electrical. |
asset | Lookup -> assets | Registered asset the job is against. |
General_Asset | Single line | Free-text asset when the item is not in the asset register. |
location_path | Single line | Building / area path. Hidden on Create, shown on Edit. |
floors | Lookup -> floors | Floor reference for the work location. |
source_schedule | Lookup -> ppm_schedules | Populated only for PPM auto-generated WOs. Hidden on Create. |
desk_ticket_id | Single line | External help-desk ticket reference. Populated for desk-raised WOs. Hidden on Create. |
checklist_template | Lookup -> checklist_templates | Attached for preventive / checklist jobs. Hidden on Create. |
Description | Multi-line | What needs doing. |
priority | Dropdown | High | Low | Medium | Critical. Drives SLA target_completion. Defaults Medium. |
status | Dropdown | Open | Assigned | In progress | On hold | Completed | Verified | Cancelled | Draft. Forced Open and hidden on Create. |
assigned_technician | Lookup -> technicians | Owner of the work. Notification target on assignment. |
raised_on | Date-time | Stamped at creation from zoho.currenttime. Disabled and immutable. |
scheduled_date | Date | Planned date. Defaults today on manual create. |
target_completion | Date-time | SLA due. Computed by computeTargetCompletion from priority + raised_on. Hidden on Create. |
started_on | Date-time | When work began. Hidden on Create. |
completed_on | Date-time | When work finished. Drives the on_time evaluation. Hidden on Create. |
on_time | Checkbox | Auto-set: true when completed_on <= target_completion, else false. Hidden on Create. |
completion_percent | Decimal | Progress 0-100. Defaults 0. Hidden on Create. |
downtime_hours | Decimal | Asset downtime recorded by the technician. Hidden on Create. |
failure_cause | Dropdown | Root-cause classification. Hidden on Create. |
work_performed | Multi-line | Technician's completion notes. Used in the raiser completion email. Hidden on Create. |
Image | Image (up to 10) | Site photos. Route to the client S3, not Creator file storage (1 GB/user fills fast) - see devNotes. |
technician_sign_off | Checkbox | Technician confirms work done. Hidden on Create. |
verified_by | Lookup -> technicians | Supervisor / verifier who signs off. Hidden on Create. |
verified_on | Date-time | Verification timestamp. Hidden on Create. |
On Load - Create
Runs when a blank Work Order form opens. Detects role, sets defaults, builds the WO-number preview once a SITE is known, locks system fields, and hides every field that only matters later in the lifecycle.
// ===== work_orders : On Load (Create / Add) =====
// --- 1. Defaults for a brand-new ticket ---
input.raised_on = zoho.currenttime; // moment the form opened
input.status = "Open"; // every new ticket starts Open
input.priority = "Medium"; // safe default SLA band
input.completion_percent = 0; // nothing done yet
input.scheduled_date = zoho.currentdate; // planned for today unless changed
input.job_type = "Corrective"; // manual tickets are Corrective
// --- 2. Role detection ---
// Portal logins expose the email in zoho.loginuserid on THIS app (not zoho.loginuser).
loginEmail = zoho.loginuserid;
supSiteId = getSiteForUser(loginEmail); // owner-context fn: Supervisor SITE id, else 0
if(supSiteId != 0)
{
// ROLE = SUPERVISOR -> locked to their own hospital
input.SITE = supSiteId; // lookup set by record ID
hide SITE; // must not pick another site
}
else
{
// ROLE = ADMIN (portal user OR app user) -> may raise for any hospital
show SITE; // leave the site picker visible
}
// --- 3. WO number preview (only once a SITE is known) ---
if(input.SITE != null)
{
input.work_order_number = buildWorkOrderNumber(input.SITE); // provisional
}
// --- 4. Lock system-owned fields ---
disable work_order_number; // generated, never typed
disable raised_on; // system timestamp
// --- 5. Hide everything that belongs to later stages ---
hide checklist_template;
hide status;
hide completion_percent;
hide desk_ticket_id;
hide source_schedule;
hide location_path;
hide target_completion;
hide started_on;
hide completed_on;
hide on_time;
hide downtime_hours;
hide failure_cause;
hide work_performed;
hide technician_sign_off;
hide verified_by;
hide verified_on;SITE - On user input
Fires when an Admin picks or clears the hospital site. Keeps the WO-number preview in sync with the chosen center; blanks it when the site is cleared.
// ===== work_orders : SITE field -> On user input =====
if(input.SITE != null)
{
// A site is selected -> rebuild the preview for that center
input.work_order_number = buildWorkOrderNumber(input.SITE);
}
else
{
// Site cleared -> wipe the stale preview
input.work_order_number = "";
}On Load - Edit
Runs when an existing Work Order is opened. The whole lifecycle is now relevant, so every field hidden at create time is revealed. work_order_number and raised_on stay disabled - both are immutable once set.
// ===== work_orders : On Load (Edit) =====
// --- Reveal the execution / verification fields ---
show status;
show scheduled_date;
show target_completion;
show assigned_technician;
show started_on;
show completed_on;
show completion_percent;
show downtime_hours;
show failure_cause;
show work_performed;
show on_time;
show technician_sign_off;
show verified_by;
show verified_on;
show checklist_template;
show source_schedule;
show desk_ticket_id;
show location_path;
// --- Keep system-owned fields locked ---
disable work_order_number; // frozen once stamped
disable raised_on; // original creation timestamp is immutableOn Validate
Last step before save. Stamps the FINAL, collision-free WO number (buildWorkOrderNumber re-scans the max serial, so it stays unique even if another ticket was created while this form was open) and sets target_completion from priority. Bind this to the Created event; on Edit, guard the number line so it is not re-stamped (see devNotes).
// ===== work_orders : On Validate =====
// --- 1. Final, unique WO number ---
// buildWorkOrderNumber() reads the current max serial for this center+month,
// so calling it here yields a fresh number with no clash.
if(input.SITE != null)
{
input.work_order_number = buildWorkOrderNumber(input.SITE);
}
// --- 2. SLA target from priority + raised time ---
if(input.raised_on != null)
{
input.target_completion = computeTargetCompletion(input.priority, input.raised_on);
}On Success - on add
Runs AFTER the new record is saved. Writes a Created row to audit_log and, if a technician was chosen at creation, emails them. Uses the real audit_log field link names, including action_1 (not 'action').
// ===== work_orders : On Success (Add only) =====
// --- 1. Audit trail: one Created row ---
auditRec = insert into audit_log
[
SITE : input.SITE
reference_type : "Work order"
reference_id : input.work_order_number
action_1 : "Created"
changed_by : zoho.loginuserid // stores the raiser email
changed_on : zoho.currenttime
new_value : "Status " + input.status + " - Priority " + input.priority
];
// --- 2. Assignment email (only if a technician was set on the new ticket) ---
if(input.assigned_technician != null)
{
techRec = technicians[ID == input.assigned_technician];
techEmail = ifnull(techRec.email,"");
if(techEmail != "")
{
sendmail
[
from : zoho.adminuserid
to : techEmail
subject : "New Work Order assigned - " + input.work_order_number
message : "You have been assigned Work Order " + input.work_order_number + ". Priority " + input.priority + ". Please review it in the CAFM app."
]
}
}Delete
Zoho Creator has no native pre-delete veto in a form event, so remove the Delete permission from the report and drive deletion through a custom record button running this Deluge. It refuses to delete Completed/Verified tickets (the compliance record) and always writes a Deleted row to audit_log first.
// ===== work_orders : custom "Delete Work Order" button (record context) =====
woStatus = input.status;
if(woStatus == "Completed" || woStatus == "Verified")
{
// Closed tickets are the compliance record - never delete them
alert "This Work Order is " + woStatus + " and cannot be deleted. Cancel it instead.";
}
else
{
// Log the deletion BEFORE removing the row
insert into audit_log
[
SITE : input.SITE
reference_type : "Work order"
reference_id : input.work_order_number
action_1 : "Deleted"
changed_by : zoho.loginuserid
changed_on : zoho.currenttime
old_value : "Status " + input.status + " - Priority " + input.priority
];
// Remove just this record
delete from work_orders[ID == input.ID];
}computeTargetCompletion(priority, raisedOn)
Maps a ticket's priority to its SLA response window (Critical 4h, High 8h, Medium 24h, Low 72h) and returns the datetime by which the work order must be completed. Called from On Validate and from every generator function that inserts a work order programmatically.
datetime computeTargetCompletion(string priority, datetime raisedOn)
{
// Priority -> SLA response window in clock hours
slaHours = 24; // Medium is the default / fallback
if(priority == "Critical")
{
slaHours = 4;
}
else if(priority == "High")
{
slaHours = 8;
}
else if(priority == "Low")
{
slaHours = 72;
}
// In Deluge, adding a DECIMAL to a datetime adds that many DAYS.
// Dividing SLA hours by 24 therefore shifts the clock by exactly slaHours.
dayFraction = slaHours / 24.0;
target = raisedOn + dayFraction;
return target;
}buildWorkOrderNumber(siteId)
Builds the work order number WO-{Center}{yyMM}-{4-digit serial}. Reads the SITE's Center code, scans existing work_orders for the highest serial already used this month at that center, and returns the next free number. Called for the preview On Load, on SITE change, at On Validate for the final stamp, and by every generator function. WHILE loops are not used - the serial pad and the max-scan both use bounded for-each logic.
string buildWorkOrderNumber(int siteId)
{
// No site chosen yet -> no number possible
if(siteId == null || siteId == 0)
{
return "";
}
// Read the chosen SITE to get its short Center code
siteRec = SITE[ID == siteId];
centerRaw = ifnull(siteRec.Center,"");
// Compact it: uppercase, keep only letters + digits (3rd arg true = regex)
centerCode = centerRaw.toUpperCase().replaceAll("[^A-Z0-9]","",true);
if(centerCode == "")
{
centerCode = "S" + siteId; // guarantees the number is never blank
}
// Period segment: 2-digit year + 2-digit month, e.g. Sep 2026 -> 2609
yymm = zoho.currentdate.toString("yyMM");
prefix = "WO-" + centerCode + yymm + "-";
// Highest serial already used for this center + month
maxSerial = 0;
existing = work_orders[work_order_number.startsWith(prefix)];
for each rec in existing
{
tail = rec.work_order_number.subString(prefix.length()); // the serial part
serialNum = tail.toLong();
if(serialNum > maxSerial)
{
maxSerial = serialNum;
}
}
nextSerial = maxSerial + 1;
// Left-pad to 4 digits WITHOUT a while loop
padded = "0000" + nextSerial;
serialStr = padded.subString(padded.length() - 4);
return prefix + serialStr;
}Emails the assigned technician the WO number and priority when a ticket is assigned to them. Covers assignment made on edit; the On Success - on add script covers assignment made at creation, so only send here on the transition into Assigned to avoid a double email.
Action: if(input.status == "Assigned" && input.assigned_technician != null) { tech = technicians[ID == input.assigned_technician]; if(ifnull(tech.email,"") != "") { sendmail [ from : zoho.adminuserid to : tech.email subject : "Work Order assigned - " + input.work_order_number message : "WO " + input.work_order_number + " priority " + input.priority + " is assigned to you. Please start work before the target completion time." ] } }
Emails the person who raised the ticket that the job is done. There is no raiser field on the form, so the raiser email is recovered from the Created row we wrote to audit_log (changed_by holds the raiser email).
Action: if(input.status == "Completed") { raiserEmail = ""; createdRows = audit_log[reference_id == input.work_order_number && action_1 == "Created"]; for each r in createdRows { if(ifnull(r.changed_by,"") != "") { raiserEmail = r.changed_by; } } if(raiserEmail != "") { sendmail [ from : zoho.adminuserid to : raiserEmail subject : "Work Order completed - " + input.work_order_number message : "Your Work Order " + input.work_order_number + " has been completed. Work performed - " + ifnull(input.work_performed,"") ] } }
When the SLA target passes and the ticket is still open, records a breach in audit_log (and can escalate to the supervisor). A record-level time-based workflow is preferred over polling because of the app's schedule-run limits. There is no dedicated breach field today - see devNotes for the recommended sla_breached checkbox.
Action: // Runs at target_completion. Only act on still-open tickets. openStatuses = {"Open","Assigned","In progress","On hold"}; if(openStatuses.contains(input.status)) { insert into audit_log [ SITE : input.SITE reference_type : "Work order" reference_id : input.work_order_number action_1 : "Status change" field_changed : "SLA" new_value : "SLA breached - target passed while status " + input.status changed_by : "System" changed_on : zoho.currenttime ]; // Optional: escalation email to the site supervisor can be added here. }
Compares completed_on against target_completion and ticks on_time when the SLA was met, clears it when missed. Runs as a field-update workflow on the form so input assignment persists on save.
Action: if(input.status == "Completed" && input.completed_on != null && input.target_completion != null) { if(input.completed_on <= input.target_completion) { input.on_time = true; // met SLA } else { input.on_time = false; // missed SLA } }
1. Supervisor raises a manual Corrective ticket
- Supervisor opens the Add Work Order form.
- On Load - Create: getSiteForUser(zoho.loginuserid) returns their SITE id, so input.SITE is auto-set and the SITE field is hidden.
- System defaults are stamped: raised_on = now, status = Open, priority = Medium, completion_percent = 0, scheduled_date = today, job_type = Corrective.
- buildWorkOrderNumber(input.SITE) fills work_order_number with a preview; work_order_number and raised_on are disabled; all lifecycle fields are hidden.
- Supervisor fills Section1 (trade), asset or General_Asset, floors/location_path, Description, and adjusts priority if needed.
- On Validate: work_order_number is re-stamped to the final unique value and target_completion is computed from priority + raised_on.
- On Success - on add: a Created row is inserted into audit_log (changed_by = supervisor email); if assigned_technician was set, an assignment email is sent.
- Fields set by the user: Section1, asset/General_Asset, Description, priority. Set by the system: SITE (auto), work_order_number, raised_on, status = Open, target_completion, completion_percent = 0.
2. Admin raises a ticket for any hospital
- Admin (portal or app user) opens the Add form.
- On Load - Create: getSiteForUser returns 0, so the SITE picker stays visible; the same defaults are applied.
- Admin selects a SITE; SITE - On user input fires and buildWorkOrderNumber rebuilds the WO preview for that center (clearing the site blanks it again).
- Admin fills the job details and may assign a technician from any site.
- On Validate stamps the final number and target_completion exactly as for the supervisor path.
- On Success - on add writes the audit Created row and sends the assignment email if a technician was chosen.
- Difference from scenario 1: SITE is chosen by the Admin (not auto-filled) and can be any of the six hospitals.
3. PPM auto-generated Work Order
- A scheduled owner-context generator function reads ppm_schedules where status = Active and next_due_date <= today + lead_time_days.
- For each due schedule it inserts a work_orders record - form On Load / On user input scripts do NOT run for a programmatic insert, so the function sets every field explicitly.
- Set from the schedule: SITE = schedule.SITE, job_type = Preventive, source_schedule = schedule ID, checklist_template = schedule.checklist_template, asset = schedule.asset, assigned_technician = schedule.assigned_technician, scheduled_date = schedule.next_due_date.
- Set by the function: work_order_number = buildWorkOrderNumber(schedule.SITE), status = Assigned (or Open), priority default, raised_on = now, target_completion = computeTargetCompletion(priority, raised_on).
- The function then advances the schedule (last_completed_date, next_due_date) and inserts a Created row in audit_log itself.
- Key marker: source_schedule is populated and a checklist_template is attached - this is how a PPM-origin ticket is recognised downstream.
4. Predictive Work Order from running hours
- A condition-based generator function monitors asset running-hours/meter readings against a threshold (running-hours source lives on the assets side - see devNotes assumption).
- When an asset crosses its threshold the function inserts a work_orders record with job_type = Predictive and asset = the triggering asset.
- Set by the function: SITE (from the asset's site), Description (auto text such as running-hours threshold reached), work_order_number via buildWorkOrderNumber, status = Open, raised_on = now, target_completion via computeTargetCompletion.
- source_schedule is normally left null (no PPM schedule); the trigger is the meter, not the calendar.
- A Created row is written to audit_log by the function.
- Key marker: job_type = Predictive with no source_schedule distinguishes it from a PPM ticket.
5. Desk-raised ticket via desk_ticket_id
- An external help-desk integration (e.g. Zoho Desk) calls an inbound function when an end user logs a facilities ticket.
- The function inserts a work_orders record with desk_ticket_id = the external ticket reference and job_type = Corrective.
- Set by the function: SITE (mapped from the desk department/site), Description (from the desk ticket body), priority (mapped from desk priority), work_order_number via buildWorkOrderNumber, status = Open, raised_on = now, target_completion via computeTargetCompletion.
- desk_ticket_id keeps the two-way link so status updates can be pushed back to the help desk.
- A Created row is written to audit_log with reference_id = work_order_number.
- Key marker: desk_ticket_id is populated - the ticket originated outside the CAFM app.
flowchart TD
A["New Work Order needed"] --> B{"Origin"}
B -->|Manual| C{"Role check getSiteForUser"}
B -->|Auto gen| D["PPM or Predictive or Desk job"]
C -->|Supervisor| E["SITE auto set and hidden"]
C -->|Admin| F["SITE picked manually"]
E --> G["Defaults set and WO preview built"]
F --> G
D --> H["Function inserts record with SITE and source fields"]
G --> I["On Validate stamps unique WO number and SLA target"]
H --> I
I --> J["Record saved with status Open"]
J --> K["On Success writes audit row and assignment email"]
K --> L["Technician works ticket status In progress"]
L --> M["Completed on set and on time evaluated"]
M --> N["Verified by supervisor status Verified"]
N --> O["Closed and locked from deletion"]Service Requests (Staff / Resident Intake)
This module gives hospital staff and residents a self-service way to raise facility issues that flow straight into the existing Work Orders engine. A new form, Service_Requests, is shared to a Staff/Resident portal so a requester logs in, describes the problem, attaches a photo, and submits a ticket that is auto-numbered SR-<Center><yyMM>-<serial> and defaulted to status New with the requester and site captured from their portal login. The facility supervisor for that site is notified immediately, and the requester gets a confirmation email. A supervisor then Acknowledges the ticket (which is the Ticket Confirmation step and emails the requester), Converts it into a Corrective Work Order that carries the site, section, location and priority across, or Rejects it. When the linked Work Order reaches Completed or Verified, the request auto-closes and the requester is emailed. The whole request lifecycle is New to Acknowledged to Converted to Closed, with a Rejected side branch, and every field, function, script and workflow below is to be built now against the live cafm app (owner demo1redecorporativa2).
Developer notes & pending
- Build Service_Requests as a Blank Form in the cafm app (owner demo1redecorporativa2, dev environment) with the 18 fields in the data model. Creator derives the link name from the label, so after adding each field open Field Properties and force the Link Name to the exact value listed (SITE, section, request_number, etc.) before adding the next field.
- Field types to pick in the builder: request_number location_text department requester_name acknowledged_by raised_by = Single Line; description = Multi Line; requester_type section priority status = Drop Down (type the exact choice values); SITE = Lookup to SITE, room = Lookup to rooms, work_order = Lookup to work_orders (all single select); photo = Image (allow multiple); contact_phone = Phone Number; acknowledged_on raised_on = Date-Time.
- Drop Down values must match exactly: requester_type = Staff, Resident, Patient area, Other; section = HVAC, CIVIL, Plumber, Electrical, Other; priority = Low, Medium, High, Critical; status = New, Acknowledged, Converted, Rejected, Closed.
- Portal setup: share the Service_Requests form to the Staff/Resident portal (Customer/User portal) with Add permission and let users see only their own records. Their portal login id is an email, which zoho.loginuserid returns and the scripts store in raised_by - that is why raised_by is used as the requester email everywhere. Hide work_order, status, request_number (read-only), acknowledged_by and acknowledged_on on the portal add layout; request_number is system generated.
- Put the Acknowledge, Convert to Work Order, Reject and Close Request buttons on the internal facilities report (a list report over Service_Requests for supervisors), NOT on the portal. Use button visibility rules by status: Acknowledge when New, Convert/Reject when New or Acknowledged, Close Request when Converted.
- getSiteForUser is the existing owner-context function; it returns the Supervisor SITE id for the logged-in email and 0 when there is no mapping - handle the 0 case by leaving SITE for the picker (internal users) so the SITE On User Input script can generate the number.
- work_orders has NO room lookup (confirmed from the live form). Room context is therefore written into General_Asset (text) and the location into location_path (text) on convert; desk_ticket_id stores the SR number so the Work Order traces back to the ticket.
- target_completion uses the documented datetime math: slaHours/24.0 gives a day fraction added to zoho.currenttime (Critical 4h, High 24h, Medium 72h, Low 168h). Keep the .0 so the division is decimal.
- sendmail from must be an org-verified sender; zoho.adminuserid is used here - swap to a verified Jood FM facilities from-address if one is configured. No while loops are used anywhere; the auto-close uses for each ... with an inner status guard.
- Concurrency note on the serial: genRequestNumber counts existing tickets with the same prefix, so two portal users submitting in the same second could theoretically collect the same serial. Optional hardening for the junior - mark request_number as a Unique field and, if a duplicate is caught on add, re-run genRequestNumber; for the current volume the count approach is sufficient.
| Field | Type | Notes |
|---|---|---|
request_number | Single Line | Form Service_Requests (NEW). System generated SR-<Center><yyMM>-<serial>. Set read-only on the portal layout. |
SITE | Lookup - Single Select -> SITE | Form Service_Requests (NEW). Auto-set from getSiteForUser for portal users, else picked. Drives Center for the number and the supervisor notify. |
requester_name | Single Line | Form Service_Requests (NEW). Free text name of the person reporting. |
requester_type | Drop Down | Form Service_Requests (NEW). Choices Staff, Resident, Patient area, Other. |
department | Single Line | Form Service_Requests (NEW). Ward or department; prepended into work order location_path on convert. |
room | Lookup - Single Select -> rooms | Form Service_Requests (NEW). Optional room reference; carried into General_Asset text on convert (work_orders has no room lookup). |
location_text | Single Line | Form Service_Requests (NEW). Free text location; copied to work_orders.location_path on convert. |
section | Drop Down | Form Service_Requests (NEW). Choices HVAC, CIVIL, Plumber, Electrical, Other. Maps to work_orders.Section1 (Other falls back to CIVIL). |
description | Multi Line | Form Service_Requests (NEW). Copied to work_orders.Description on convert. |
photo | Image | Form Service_Requests (NEW). Multi-image allowed; lets the requester attach a picture of the fault. |
contact_phone | Phone Number | Form Service_Requests (NEW). Callback number for the requester. |
priority | Drop Down | Form Service_Requests (NEW). Choices Low, Medium, High, Critical. Copied to work_orders.priority and used for the SLA target. |
status | Drop Down | Form Service_Requests (NEW). Choices New, Acknowledged, Converted, Rejected, Closed. Defaults to New on load. |
acknowledged_by | Single Line | Form Service_Requests (NEW). Holds zoho.loginuserid of the supervisor who acknowledged. |
acknowledged_on | Date-Time | Form Service_Requests (NEW). Set when the Acknowledge button runs. |
work_order | Lookup - Single Select -> work_orders | Form Service_Requests (NEW). Set only by convertToWorkOrder; hide on the portal layout. |
raised_by | Single Line | Form Service_Requests (NEW). Holds zoho.loginuserid (the portal login email) - used as the requester email for all mails. |
raised_on | Date-Time | Form Service_Requests (NEW). Set to now on load. |
Center | existing field on SITE | Read by genRequestNumber to build the number prefix. |
Email | existing field on Supervisor | Supervisor[SITE == thisSite].Email - recipient of the new-ticket notify and the site owner mailbox. |
General_Asset / location_path / desk_ticket_id / target_completion / Section1 / job_type | existing fields on work_orders | Written by convertToWorkOrder. desk_ticket_id stores the SR number for traceback; target_completion set from priority via hours/24 day math. |
Service_Requests > Form Workflow > On Load (event = Created)
Defaults the ticket. For portal users getSiteForUser returns their Supervisor SITE so both SITE and the number are auto-filled; for an internal user with no mapping (returns 0) SITE stays empty for the picker and the number is generated on SITE user-input instead.
if(input.status == null || input.status == "")
{
input.status = "New";
}
input.raised_on = zoho.currenttime;
input.raised_by = zoho.loginuserid;
sid = getSiteForUser(zoho.loginuserid);
if(sid != 0)
{
input.SITE = sid;
input.request_number = genRequestNumber(sid);
}Service_Requests > Field > SITE > On User Input
Only fires for internal users who pick the site manually. Generates the number once so a portal-generated number is never overwritten.
if(input.SITE != null)
{
if(input.request_number == null || input.request_number == "")
{
input.request_number = genRequestNumber(input.SITE);
}
}Service_Requests > Form Workflow > On Success (event = On Add)
Ticket Email + Ticket Confirmation-to-supervisor. Requester email is the portal login id (raised_by). from must be a verified sender.
supEmail = "";
supRec = Supervisor[SITE == input.SITE];
if(supRec != null)
{
supEmail = supRec.Email;
}
reqEmail = input.raised_by;
if(reqEmail != null && reqEmail.contains("@"))
{
sendmail
[
from : zoho.adminuserid
to : reqEmail
subject : "Service Request received - " + input.request_number
message : "Dear " + input.requester_name + ",<br><br>We have received your service request <b>" + input.request_number + "</b>.<br>Issue: " + input.description + "<br>Status: New.<br>Our facilities team will review it shortly.<br><br>Jood FM Facilities"
]
}
if(supEmail != null && supEmail != "" && supEmail.contains("@"))
{
sendmail
[
from : zoho.adminuserid
to : supEmail
subject : "New Service Request " + input.request_number + " [" + input.priority + "]"
message : "A new service request has been raised.<br>Number: " + input.request_number + "<br>Requester: " + input.requester_name + " (" + input.requester_type + ")<br>Location: " + input.location_text + "<br>Section: " + input.section + "<br>Priority: " + input.priority + "<br>Description: " + input.description
]
}Service_Requests > Custom Button 'Acknowledge' (facilities report, stateless On User Input)
This is the Ticket Confirmation step. Runs on the open record, sets status + who/when, emails the requester. Show this button only when status == New.
input.status = "Acknowledged";
input.acknowledged_by = zoho.loginuserid;
input.acknowledged_on = zoho.currenttime;
reqEmail = input.raised_by;
if(reqEmail != null && reqEmail.contains("@"))
{
sendmail
[
from : zoho.adminuserid
to : reqEmail
subject : "Your Service Request " + input.request_number + " is acknowledged"
message : "Dear " + input.requester_name + ",<br><br>Your request <b>" + input.request_number + "</b> has been acknowledged by our facilities team and is now in the queue. You will be updated as work proceeds.<br><br>Jood FM Facilities"
]
}Service_Requests > Custom Button 'Convert to Work Order' (facilities report)
Ticket Assigning starts here. Calls the function which creates the WO, links it back and flips status to Converted. Show only when status == Acknowledged (or New). Assign the technician on the created Work Order using the existing Work Orders screen.
convertToWorkOrder(input.ID);Service_Requests > Custom Button 'Reject' (facilities report)
Rejected side branch. Sets status and emails the requester so a ticket is never silently dropped.
input.status = "Rejected";
reqEmail = input.raised_by;
if(reqEmail != null && reqEmail.contains("@"))
{
sendmail
[
from : zoho.adminuserid
to : reqEmail
subject : "Service Request " + input.request_number + " could not be accepted"
message : "Dear " + input.requester_name + ",<br><br>Your request <b>" + input.request_number + "</b> was reviewed and could not be accepted as raised. Please contact the facilities desk for details.<br><br>Jood FM Facilities"
]
}Service_Requests > Custom Button 'Close Request' (facilities report)
Manual Ticket Closing step, for tickets closed without a Work Order. The automatic closure on WO completion is the work_orders workflow below.
input.status = "Closed";
reqEmail = input.raised_by;
if(reqEmail != null && reqEmail.contains("@"))
{
sendmail
[
from : zoho.adminuserid
to : reqEmail
subject : "Service Request " + input.request_number + " closed"
message : "Dear " + input.requester_name + ",<br><br>Your request <b>" + input.request_number + "</b> has been closed. If the issue persists please raise a new request.<br><br>Thank you,<br>Jood FM Facilities"
]
}work_orders > Form Workflow > On Success (event = On Edit) - auto close linked Service Request
Ticket Status update + Ticket Closing driven by the WO. When a Work Order reaches Completed or Verified, close every Service Request linked to it and email the requester. Uses for each with no while loop.
if(input.status == "Completed" || input.status == "Verified")
{
linkedReqs = Service_Requests[work_order == input.ID];
for each r in linkedReqs
{
if(r.status != "Closed")
{
r.status = "Closed";
rEmail = r.raised_by;
if(rEmail != null && rEmail.contains("@"))
{
sendmail
[
from : zoho.adminuserid
to : rEmail
subject : "Service Request " + r.request_number + " completed"
message : "Dear " + r.requester_name + ",<br><br>The work for your request <b>" + r.request_number + "</b> has been completed. If the issue persists please raise a new request.<br><br>Thank you,<br>Jood FM Facilities"
]
}
}
}
}genRequestNumber
Return the next ticket number for a site as SR-<Center><yyMM>-<serial>, reading Center from the SITE record and counting existing tickets with the same prefix. Serial is zero-padded to 4 digits.
string genRequestNumber(int siteId)
{
center = "GEN";
siteRec = SITE[ID == siteId];
if(siteRec != null && siteRec.Center != null && siteRec.Center.trim() != "")
{
center = siteRec.Center;
}
today = zoho.currentdate;
yymm = today.toString("yyMM");
prefix = "SR-" + center + yymm + "-";
usedCount = Service_Requests[request_number.startsWith(prefix)].count();
serial = usedCount + 1;
serialStr = serial.toString();
if(serial < 10)
{
serialStr = "000" + serial;
}
else if(serial < 100)
{
serialStr = "00" + serial;
}
else if(serial < 1000)
{
serialStr = "0" + serial;
}
return prefix + serialStr;
}convertToWorkOrder
Create a Corrective Work Order from a Service Request, carrying site, section, location, description and priority across, set an SLA target_completion from priority (hours/24 = days), then link the new WO back onto the request and set the request status to Converted. Guards against double conversion.
void convertToWorkOrder(int requestId)
{
req = Service_Requests[ID == requestId];
if(req == null)
{
return;
}
if(req.work_order != null)
{
return;
}
sec = req.section;
if(sec == null || sec == "" || sec == "Other")
{
sec = "CIVIL";
}
genAsset = "Staff or resident reported";
if(req.room != null)
{
genAsset = "Room ref " + req.room;
}
locPath = ifnull(req.location_text,"");
if(req.department != null && req.department != "")
{
locPath = req.department + " - " + locPath;
}
slaHours = 72;
if(req.priority == "Critical")
{
slaHours = 4;
}
else if(req.priority == "High")
{
slaHours = 24;
}
else if(req.priority == "Low")
{
slaHours = 168;
}
slaDays = slaHours / 24.0;
wo = insert into work_orders
[
SITE : req.SITE
job_type : "Corrective"
Section1 : sec
General_Asset : genAsset
location_path : locPath
Description : req.description
priority : req.priority
status : "Open"
raised_on : zoho.currenttime
target_completion : zoho.currenttime + slaDays
desk_ticket_id : req.request_number
];
req.work_order = wo.ID;
req.status = "Converted";
}On submit, email the requester a New-ticket confirmation and email the site's facility supervisor (Supervisor[SITE].Email) the ticket details so it is picked up.
Action: Run the On Add success script (Ticket Email).
Supervisor acknowledges the ticket. Status becomes Acknowledged, acknowledged_by and acknowledged_on are stamped, and the requester is emailed - this is the Ticket Confirmation.
Action: Run the Acknowledge button script.
Turn the acknowledged ticket into a Corrective Work Order carrying site, section, location, description, priority and an SLA target, link it back and set status Converted. Technician is then assigned on the Work Order.
Action: Call convertToWorkOrder(input.ID).
Reviewed tickets that cannot be accepted are set Rejected and the requester is emailed so nothing is dropped silently.
Action: Run the Reject button script.
Closure email when the linked work order finishes. Every Service Request pointing at that WO is set Closed and its requester emailed.
Action: Run the work_orders auto-close script.
Staff raises a request via the portal
- A ward nurse opens the Staff/Resident portal and clicks New Service Request
- On Load the form sets status New, raised_on now, raised_by to her login email, and because getSiteForUser returns her site it fills SITE and generates request_number e.g. SR-KKH2609-0007
- She fills requester_name, requester_type Staff, department, room, location_text, section Plumber, description, attaches a photo, sets priority High and submits
- On Add she receives a confirmation email and the site facility supervisor is emailed the new ticket
Supervisor acknowledges (Ticket Confirmation)
- The supervisor opens the ticket in the facilities report where status is New
- He clicks the Acknowledge button
- Status flips to Acknowledged and acknowledged_by and acknowledged_on are stamped with his login and the current time
- The requester is emailed that the ticket has been acknowledged and queued
Convert to Work Order and assign
- On the acknowledged ticket the supervisor clicks Convert to Work Order
- convertToWorkOrder creates a Corrective work_orders record with the same SITE, Section1 from section, location_path and General_Asset from room and department, Description, priority, status Open and a target_completion set from priority
- The new WO id is written to the ticket work_order lookup and the ticket status becomes Converted
- The supervisor opens that Work Order and sets assigned_technician on the normal Work Orders screen
Requester emailed when the job closes
- The assigned technician does the work and sets the Work Order status to Completed or Verified
- The work_orders On Success workflow finds the Service Request linked by work_order
- It sets that request status to Closed
- The requester receives the completion email closing the loop
flowchart TD
A["Requester raises request via portal"]
B["Status New"]
C["Supervisor reviews the ticket"]
D["Status Acknowledged"]
E["Confirmation email to requester"]
F["Convert to Work Order"]
G["Status Converted"]
H["Work Order Open then assigned"]
I["Technician completes the work"]
J["Work Order Completed or Verified"]
K["Status Closed"]
L["Closure email to requester"]
M["Status Rejected"]
N["Rejection email to requester"]
A --> B
B --> C
C --> D
D --> E
D --> F
F --> G
G --> H
H --> I
I --> J
J --> K
K --> L
C --> M
M --> NSLA Management
The SLA Management module puts a response clock and a resolution clock on every work order and watches both for breaches. A new SLA_Definitions form lets an admin register response and resolution targets per service type, priority and asset criticality, optionally scoped to one site. When a work order is saved, resolveSlaHours picks the best matching active definition (falling back to a built-in priority map) and the On Validate script stamps response_due and resolution_due onto the ticket and copies resolution_due into target_completion. As the ticket first moves to In progress the first response is captured and graded against response_due; when it reaches Completed the resolution time is graded against resolution_due. A daily scan escalates any ticket that is past response_due with no first response, or past resolution_due while still open, by emailing the site supervisor and cc-ing the assigned technicians. Two guard checkboxes make each escalation fire once so the daily scan does not re-spam an already-flagged ticket.
Developer notes & pending
- work_orders.asset and work_orders.assigned_technician are MULTI_SELECT_LOOKUP (verified live), so wo.asset / input.asset and wo.assigned_technician are lists. Read the first asset criticality with for each ... break, and cc all technicians by iterating; never treat them as a single record.
- work_orders.Section1 has only HVAC, CIVIL, Plumber, Electrical. SLA_Definitions.service_type adds General as the catch-all, and resolveSlaHours maps a blank/absent section to General so ungrouped tickets still get an SLA.
- raised_on, response_due, resolution_due, first_response_on, completed_on and target_completion are all Date-Time (type 11). Adding a decimal N to a Date-Time adds N days, so add hours as (hours * 1.0) / 24; keep the 1.0 so the division stays decimal.
- On Validate runs before the row is saved and has no record ID on create, so it writes input.* inline. stampSlaDue(woId) is the identical logic for a saved row and is what the daily scan and any Recompute SLA custom action call.
- minutesBetween is called earlier.minutesBetween(later) and returns whole minutes; raised_on is always the earlier value here.
- WHILE loops are not allowed; every first-match/lookup uses for each ... break.
- response_breach_notified and resolution_breach_notified are one-shot guards. To force a fresh escalation after you change a due date, clear the relevant checkbox on the ticket.
- resolveSlaHours parameters are renamed sectionIn/priorityIn/criticalityIn so the fetch criteria (service_type == svc && priority == prio) compare a field to a variable, not a field to itself.
- sendmail from uses zoho.adminuserid (a verified org sender). Supervisor is matched by Supervisor[SITE == wo.SITE] using the SITE single-select lookup; Supervisor.Email is capitalised while technicians.email is lowercase, as in the live schema.
- Recommended reports to add for visibility: an SLA Breaches list (sla_response_met is false OR sla_resolution_met is false) and an At-Risk list (open tickets where response_due or resolution_due is within the next few hours).
| Field | Type | Notes |
|---|---|---|
name | Single Line | SLA_Definitions (NEW form). Human label e.g. HVAC Critical. link_name name |
service_type | Dropdown | SLA_Definitions (NEW). Choices HVAC, CIVIL, Plumber, Electrical, General. First four match work_orders.Section1; General is the catch-all. link_name service_type |
priority | Dropdown | SLA_Definitions (NEW). Choices Low, Medium, High, Critical. Matches work_orders.priority. link_name priority |
applies_criticality | Dropdown | SLA_Definitions (NEW). Choices Any, Low, Medium, High, Critical. Narrows the row to the asset criticality; Any = applies to every criticality. link_name applies_criticality |
response_hours | Number | SLA_Definitions (NEW). Hours allowed from raised_on to first response. link_name response_hours |
resolution_hours | Number | SLA_Definitions (NEW). Hours allowed from raised_on to Completed. link_name resolution_hours |
SITE | Lookup (single-select) to SITE | SLA_Definitions (NEW). Optional. Blank = applies to all sites; set = a site-specific override row. link_name SITE |
active | Decision box | SLA_Definitions (NEW). Only active rows are matched by resolveSlaHours. Default true. link_name active |
response_due | Date-Time | work_orders (ADD). raised_on + response_hours/24. link_name response_due |
resolution_due | Date-Time | work_orders (ADD). raised_on + resolution_hours/24; also copied to target_completion. link_name resolution_due |
first_response_on | Date-Time | work_orders (ADD). Stamped the first time status becomes In progress. link_name first_response_on |
response_time_mins | Number | work_orders (ADD). Whole minutes raised_on to first_response_on. link_name response_time_mins |
resolution_time_mins | Number | work_orders (ADD). Whole minutes raised_on to completed_on. link_name resolution_time_mins |
sla_response_met | Decision box | work_orders (ADD). True when first_response_on <= response_due. Default false. link_name sla_response_met |
sla_resolution_met | Decision box | work_orders (ADD). True when completed_on <= resolution_due. Default false. link_name sla_resolution_met |
response_breach_notified | Decision box | work_orders (ADD). Guard so the response-breach email is sent once. Default false. link_name response_breach_notified |
resolution_breach_notified | Decision box | work_orders (ADD). Guard so the resolution-breach email is sent once. Default false. link_name resolution_breach_notified |
work_orders — On Validate (add On Add > On Validate AND On Edit > On Validate)
Paste under Form Workflow for work_orders on both On Add > On Validate and On Edit > On Validate so create and edit both stamp. It calls the standalone function resolveSlaHours via thisapp. Because asset is multi-select the criticality read must loop with break. target_completion (existing Date-Time field) is set to resolution_due as required.
// Stamp SLA due dates on every save. Runs before the row is saved, so it
// writes input.* directly (a new record has no ID yet). Mirrors stampSlaDue.
section = ifnull(input.Section1,"");
if(section == "")
{
section = "General";
}
prio = ifnull(input.priority,"Medium");
// asset is MULTI_SELECT_LOOKUP -- read the first selected asset criticality
crit = "Any";
for each aId in input.asset
{
crit = assets[ID == aId].criticality;
break;
}
if(crit == null)
{
crit = "Any";
}
slaMap = thisapp.resolveSlaHours(section,prio,crit);
respH = slaMap.get("response");
resltnH = slaMap.get("resolution");
baseTime = input.raised_on;
if(baseTime == null)
{
baseTime = zoho.currenttime;
input.raised_on = baseTime;
}
// adding decimal N to a Date-Time adds N days -> hours/24
input.response_due = baseTime + ((respH * 1.0) / 24);
input.resolution_due = baseTime + ((resltnH * 1.0) / 24);
input.target_completion = input.resolution_due;work_orders — field "status" On user input
Attach to the status Dropdown field, event On user input, on work_orders. This is the form path; captureFirstResponse / closeSla are the identical server-side twins the daily scan or a blueprint uses when status changes outside the form. response_due is normally already stamped from a prior save; if a ticket is created and set to In progress in one session before the first save, the daily scan reconciles sla_response_met once response_due exists.
// Capture first response and closure directly on the open form.
newStatus = input.status;
// first time the ticket enters In progress
if(newStatus == "In progress" && input.first_response_on == null)
{
fr = zoho.currenttime;
input.first_response_on = fr;
if(input.raised_on != null)
{
input.response_time_mins = input.raised_on.minutesBetween(fr);
}
input.sla_response_met = false;
if(input.response_due != null && fr <= input.response_due)
{
input.sla_response_met = true;
}
}
// closure
if(newStatus == "Completed")
{
cot = input.completed_on;
if(cot == null)
{
cot = zoho.currenttime;
input.completed_on = cot;
}
if(input.raised_on != null)
{
input.resolution_time_mins = input.raised_on.minutesBetween(cot);
}
input.sla_resolution_met = false;
if(input.resolution_due != null && cot <= input.resolution_due)
{
input.sla_resolution_met = true;
}
}SLA_Definitions — On Load (Create)
Attach to the SLA_Definitions form, On Load event, Create state only (so it does not overwrite an existing row on edit). Gives the admin a working Medium/General row to adjust.
// Sensible defaults for a new SLA definition row.
input.active = true;
input.applies_criticality = "Any";
input.priority = "Medium";
input.service_type = "General";
input.response_hours = 24;
input.resolution_hours = 72;resolveSlaHours
Return the response and resolution hours for a ticket. Picks the best matching active SLA_Definitions row (exact criticality beats Any), and if none exists falls back to a built-in priority map. Parameters are renamed sectionIn/priorityIn/criticalityIn so the fetch criteria compare a field to a variable, not a field to itself. Returns a Map with keys response and resolution.
map resolveSlaHours(string sectionIn, string priorityIn, string criticalityIn)
{
result = Map();
svc = ifnull(sectionIn,"");
if(svc == "")
{
svc = "General";
}
prio = ifnull(priorityIn,"Medium");
crit = ifnull(criticalityIn,"Any");
// fallback priority map (used when no definition row matches)
respH = 24;
resltnH = 72;
if(prio == "Critical")
{
respH = 4;
resltnH = 8;
}
else if(prio == "High")
{
respH = 8;
resltnH = 24;
}
else if(prio == "Medium")
{
respH = 24;
resltnH = 72;
}
else if(prio == "Low")
{
respH = 72;
resltnH = 168;
}
// best-match active definition -- exact criticality wins over Any
chosen = null;
anyRow = null;
defs = SLA_Definitions[active == true && service_type == svc && priority == prio];
for each d in defs
{
if(d.applies_criticality == crit)
{
chosen = d;
break;
}
if(d.applies_criticality == "Any" && anyRow == null)
{
anyRow = d;
}
}
if(chosen == null)
{
chosen = anyRow;
}
if(chosen != null)
{
respH = chosen.response_hours;
resltnH = chosen.resolution_hours;
}
result.put("response",respH);
result.put("resolution",resltnH);
return result;
}stampSlaDue
Saved-record twin of the On Validate stamping. For an existing work order it reads Section1, priority and the first selected asset criticality, calls resolveSlaHours, and writes response_due, resolution_due and target_completion. Called by the daily scan and by any Recompute SLA custom action; the On Validate form script does the same inline for the create/edit path.
void stampSlaDue(int woId)
{
for each wo in work_orders[ID == woId]
{
section = ifnull(wo.Section1,"");
if(section == "")
{
section = "General";
}
prio = ifnull(wo.priority,"Medium");
// asset is MULTI_SELECT_LOOKUP -- take the first selected asset criticality
crit = "Any";
for each aId in wo.asset
{
crit = assets[ID == aId].criticality;
break;
}
if(crit == null)
{
crit = "Any";
}
hoursMap = thisapp.resolveSlaHours(section,prio,crit);
respH = hoursMap.get("response");
resltnH = hoursMap.get("resolution");
baseTime = wo.raised_on;
if(baseTime == null)
{
baseTime = zoho.currenttime;
}
// adding a decimal N to a Date-Time adds N days, so hours -> hours/24
wo.response_due = baseTime + ((respH * 1.0) / 24);
wo.resolution_due = baseTime + ((resltnH * 1.0) / 24);
wo.target_completion = wo.resolution_due;
}
}captureFirstResponse
Saved-record twin of the status field script. Stamps first_response_on once, computes response_time_mins from raised_on, and sets sla_response_met when the response landed on or before response_due. Used by the daily scan and any blueprint transition that moves a ticket to In progress outside the form.
void captureFirstResponse(int woId)
{
for each wo in work_orders[ID == woId]
{
if(wo.first_response_on == null)
{
fr = zoho.currenttime;
wo.first_response_on = fr;
if(wo.raised_on != null)
{
wo.response_time_mins = wo.raised_on.minutesBetween(fr);
}
metFlag = false;
if(wo.response_due != null && fr <= wo.response_due)
{
metFlag = true;
}
wo.sla_response_met = metFlag;
}
}
}closeSla
Saved-record twin of the status field script for closure. Ensures completed_on is set, computes resolution_time_mins from raised_on, and sets sla_resolution_met when closure landed on or before resolution_due. Used by the daily scan and blueprint/API closures.
void closeSla(int woId)
{
for each wo in work_orders[ID == woId]
{
cot = wo.completed_on;
if(cot == null)
{
cot = zoho.currenttime;
wo.completed_on = cot;
}
if(wo.raised_on != null)
{
wo.resolution_time_mins = wo.raised_on.minutesBetween(cot);
}
metFlag = false;
if(wo.resolution_due != null && cot <= wo.resolution_due)
{
metFlag = true;
}
wo.sla_resolution_met = metFlag;
}
}sendSlaBreachMail
Compose and send one breach email for a work order. Recipient is the site supervisor found via Supervisor[SITE == wo.SITE]; assigned technicians (MULTI_SELECT_LOOKUP) are cc-ed by iterating their email field. If no supervisor is found it falls back to the org admin so a breach is never dropped. breachType is response or resolution and selects which due date is quoted.
void sendSlaBreachMail(int woId, string breachType)
{
for each wo in work_orders[ID == woId]
{
toAddr = "";
for each s in Supervisor[SITE == wo.SITE]
{
if(s.Email != null && s.Email != "")
{
toAddr = s.Email;
break;
}
}
ccAddr = "";
for each tId in wo.assigned_technician
{
te = technicians[ID == tId].email;
if(te != null && te != "")
{
if(ccAddr != "")
{
ccAddr = ccAddr + ",";
}
ccAddr = ccAddr + te;
}
}
if(toAddr == "")
{
toAddr = zoho.adminuserid;
}
label = "response";
dueVal = wo.response_due;
if(breachType == "resolution")
{
label = "resolution";
dueVal = wo.resolution_due;
}
subjectTxt = "SLA " + label + " breach -- WO " + wo.work_order_number;
bodyTxt = "<p>Work Order <b>" + wo.work_order_number + "</b> has breached its " + label + " SLA.</p>";
bodyTxt = bodyTxt + "<p>Priority " + ifnull(wo.priority,"-") + "<br>Section " + ifnull(wo.Section1,"-") + "<br>Due " + dueVal + "<br>Status " + wo.status + "</p>";
sendmail
[
from : zoho.adminuserid
to : toAddr
cc : ccAddr
subject : subjectTxt
message : bodyTxt
]
}
}scanSlaBreaches
Daily scheduled scan. Iterates open work orders (status not Completed/Verified/Cancelled/Draft) and applies both breach checks: past response_due with no first response, and past resolution_due while still open. Sends one email per breach through sendSlaBreachMail and sets the matching guard checkbox so it never re-sends.
void scanSlaBreaches()
{
nowT = zoho.currenttime;
openWos = work_orders[status != "Completed" && status != "Verified" && status != "Cancelled" && status != "Draft"];
for each wo in openWos
{
// response breach -- overdue with no first response yet
if(wo.first_response_on == null && wo.response_due != null && nowT > wo.response_due && wo.response_breach_notified == false)
{
thisapp.sendSlaBreachMail(wo.ID,"response");
wo.response_breach_notified = true;
}
// resolution breach -- overdue and still open
if(wo.resolution_due != null && nowT > wo.resolution_due && wo.resolution_breach_notified == false)
{
thisapp.sendSlaBreachMail(wo.ID,"resolution");
wo.resolution_breach_notified = true;
}
}
}Iterates open work orders and applies both breach checks. Daily is 30 runs per month, well under the 90 schedule-runs per user per month cap; do not lower it to hourly.
Action: Runs scanSlaBreaches() which calls sendSlaBreachMail for each breached ticket and sets the guard checkbox.
now > response_due AND first_response_on is empty AND response_breach_notified is false.
Action: Email the site supervisor, cc assigned technicians, then set response_breach_notified = true so it emails once.
now > resolution_due AND status is not Completed/Verified/Cancelled AND resolution_breach_notified is false.
Action: Email the site supervisor, cc assigned technicians, then set resolution_breach_notified = true.
Define SLA rows per service and priority
- Open the SLA_Definitions form.
- Create a row: name HVAC Critical, service_type HVAC, priority Critical, applies_criticality Any, response_hours 4, resolution_hours 8, active checked, SITE blank.
- Repeat for each service_type and priority you want to govern; use applies_criticality to add a tighter row (e.g. HVAC High for Critical assets).
- Leave SITE blank for a global rule, or pick a site to create a site-specific override that beats the global fallback.
Ticket first response within SLA
- Raise a work order: Section1 HVAC, priority Critical, an asset selected, raised_on 09:00.
- On Validate calls resolveSlaHours -> 4h/8h, stamps response_due 13:00, resolution_due 17:00, target_completion 17:00.
- Technician sets status to In progress at 10:30.
- status On user input stamps first_response_on 10:30, response_time_mins 90, and sla_response_met true because 10:30 <= 13:00.
First response over SLA then resolution met
- Same ticket, but the first In progress happens at 14:00.
- response_time_mins 300 and sla_response_met false because 14:00 > 13:00.
- Technician sets status Completed at 16:30; completed_on 16:30.
- resolution_time_mins recorded and sla_resolution_met true because 16:30 <= 17:00.
Breach escalates once
- Ticket reaches 13:00 still Assigned with first_response_on empty.
- The next Daily SLA Scan sees now > response_due and first_response_on null, so sendSlaBreachMail emails the supervisor and cc's the technicians, then sets response_breach_notified true.
- Following scans skip it (guard true) so it is not re-emailed.
- If the ticket is still open past 17:00, the resolution branch emails once and sets resolution_breach_notified true.
flowchart TD
A["Work order raised"] --> B["On Validate resolveSlaHours"]
B --> C["Stamp response due and resolution due"]
C --> D["Status moves to In progress"]
D --> E["Capture first response"]
E --> F{"Responded before due"}
F -->|Yes| G["sla response met true"]
F -->|No| H["Response breach email"]
G --> I["Status moves to Completed"]
H --> I
I --> J["closeSla computes minutes"]
J --> K{"Closed before due"}
K -->|Yes| L["sla resolution met true"]
K -->|No| M["Resolution breach email"]
C --> N["Daily scan flags overdue"]
N --> H
N --> MPPM Schedules & Auto-Generation
The PPM Schedules module is the engine that turns standing maintenance plans into dated work orders without anyone remembering to raise them. Each ppm_schedules record defines what to service (asset plus checklist_template), how often (frequency, or a custom day interval), from when (start_date), and how far ahead to raise the job (lead_time_days). A single daily Creator schedule runs two owner-context functions: generatePpmWorkOrders scans Active plans and raises a Preventive work order the moment a plan enters its lead-time window, then rolls next_due_date forward; generatePredictiveWorkOrders raises a Predictive work order for metered assets once run hours since the last service reach the asset threshold. Completing a generated work order writes back to the source plan (last_completed_date) or resets the asset hours baseline. The module uses a fixed-calendar model by default and is built entirely on real field link names verified against the development environment, and portal supervisors are auto-scoped to their own SITE via getSiteForUser.
Developer notes & pending
- Multi-select lookups: on both forms asset, checklist_template and assigned_technician are MULTI_SELECT_LOOKUP, and work_orders.source_schedule is too. Reading one returns a LIST of record ids, so copy schedule lists straight across (asset = sched.asset) and wrap a single id as a list (source_schedule = {sched.ID}). Test membership with field.contains(id), never field == id.
- Portal email: read the logged-in supervisor with zoho.loginuserid on this app - portal logins expose the email there, not in zoho.loginuser. getSiteForUser(email) already exists and returns the SITE record id, 0 when none.
- Function calls: invoke app functions by bare name - computeNextDueDate(...) and getSiteForUser(...); thisapp.<name>() also works. computeNextDueDate is declared to return a date.
- No WHILE loops (platform rule): every scan uses for each ... in Collection with a bounded break. The duplicate and lookup-membership checks follow that pattern.
- Schedule-run cap is 90 runs per user per month. One daily consolidated schedule is about 30 runs. Never split the generators into hourly jobs (about 720 per month) - keep both calls inside the single daily schedule.
- Roll-forward model is FIXED-CALENDAR: the scheduler advances next_due_date the moment it raises the WO, so plans stay on a predictable calendar and last_completed_date is only an audit stamp. To switch to FLOATING (measure the next date from actual completion) uncomment the recompute line in the work_orders On Success script and remove the roll-forward line from generatePpmWorkOrders.
- Overdue catch-up: the scheduler advances one cycle per daily run, so a plan overdue by several cycles catches up one step per day. If instant catch-up is needed, wrap the roll-forward in a bounded for-each range that repeats until next_due_date is past today.
- Duplicate guard: before inserting, the code checks for an existing Open Preventive WO on the same schedule and date, and only one Open Predictive WO per asset. Combined with the next_due_date advance this prevents double-raising.
- On Validate aborts a bad save with alert followed by cancel submit - confirm the exact keyword in your Creator build autocomplete.
- On Success persistence: assigning input.<field> inside On Success updates the just-saved record in Creator - that is how schedule_code is finalized and next_due_date is rolled after the optional immediate raise.
- Predictive hours reset happens on WO completion, not at raise, so the asset is not re-flagged every night while its Predictive WO is still Open.
- All field link names and lookup types were read from the development (working copy) environment on 2026-09-18 - publish dev to production after testing.
| Field | Type | Notes |
|---|---|---|
SITE | Lookup single -> SITE | Site that owns the plan. Auto-filled and locked for portal supervisors via getSiteForUser. Copied to the WO SITE field. |
schedule_code | Single line text | Human code. Provisional TMP- value on load, finalized to a unique PPM-yyyyMMdd-<recordID> in On Success. |
asset | Lookup MULTI -> assets | Asset(s) serviced. Multi-select stores a LIST of ids; copied straight across to work_orders.asset. |
checklist_template | Lookup MULTI -> checklist_templates | Checklist copied onto every generated WO. Multi-select list. |
frequency | Dropdown | Keys exactly: Daily, Weekly, Monthly, Quarterly, Half-yearly, Yearly, Custom days. Drives computeNextDueDate. |
custom_interval_days | Number integer | Interval in days. Required and validated only when frequency is Custom days. Hidden otherwise. |
start_date | Date | Anchor for the first next_due_date. Defaults to today on create. |
next_due_date | Date | Date the next WO is due. Seeded on load, rolled forward by the scheduler after each raise. |
last_completed_date | Date | Stamped by the work_orders completion write-back. Audit of actual completion, not the calendar driver. |
lead_time_days | Number integer | Raise the WO this many days before next_due_date. Defaults to 0 when blank. |
assigned_technician | Lookup MULTI -> technicians | Default technician copied to the generated WO. Multi-select list. |
estimated_duration_minutes | Number integer | Planned minutes, copied to the WO for scheduling. |
status | Dropdown Active Ended Paused | Only Active schedules are scanned. Scheduler auto-sets Ended once end_date passes. Paused is skipped. |
end_date | Date | Optional. Validated to be on or after start_date. Once passed the scheduler ends the plan. |
assets.track_running_hours | Checkbox | When ticked the asset is metered and eligible for Predictive WOs. |
assets.current_running_hours | Decimal | Latest meter reading, updated by ops. Compared against the baseline. |
assets.runtime_threshold | Decimal | Hours of runtime between predictive services. |
assets.hours_at_last_service | Decimal | Meter value at last service. Reset to current_running_hours when a Predictive WO is Completed. |
work_orders.job_type | Dropdown | Generators set Preventive or Predictive. Third value Corrective is manual. |
work_orders.source_schedule | Lookup MULTI -> ppm_schedules | Back-link to the plan. Set as a one-item list {schedule.ID}. Query with .contains(id). |
work_orders.scheduled_date | Date | Due date carried from next_due_date. Used by the duplicate guard. |
work_orders.status | Dropdown | New WOs are created as Open. Write-back fires when it becomes Completed. |
ppm_schedules — On Load — Create (new record)
Pre-fills defaults so a supervisor only picks the asset and cadence. Sets a provisional code, defaults, seeds next_due_date, toggles the custom-days field, and pins SITE for portal supervisors.
// PPM Schedules :: On Load :: Create
// 1) Provisional code - finalized to a unique PPM code in On Success
if(input.schedule_code == null || input.schedule_code == "")
{
seq = ppm_schedules.count() + 1;
input.schedule_code = "TMP-" + seq;
}
// 2) Cadence + lifecycle defaults
if(input.frequency == null || input.frequency == "")
{
input.frequency = "Monthly";
}
if(input.status == null || input.status == "")
{
input.status = "Active";
}
// 3) Anchor to today when no start date is carried in
if(input.start_date == null)
{
input.start_date = zoho.currentdate;
}
// 4) Seed the first due date from the start date
input.next_due_date = computeNextDueDate(input.frequency, input.custom_interval_days, input.start_date);
// 5) Custom interval only matters for the Custom days cadence
if(input.frequency == "Custom days")
{
show custom_interval_days;
}
else
{
hide custom_interval_days;
}
// 6) Portal supervisors are pinned to their own site
loginEmail = zoho.loginuserid; // portal login exposes the EMAIL here, not loginuser
siteId = getSiteForUser(loginEmail); // owner-context helper, returns 0 when none
if(siteId != 0)
{
input.SITE = siteId; // single-select lookup = one record id
disable SITE;
}ppm_schedules — On Load — Edit (existing record)
Keeps the custom-days field visibility correct when reopening a saved plan and keeps SITE locked for portal supervisors.
// PPM Schedules :: On Load :: Edit
if(input.frequency == "Custom days")
{
show custom_interval_days;
}
else
{
hide custom_interval_days;
}
// Portal supervisors cannot move a plan to another site
loginEmail = zoho.loginuserid;
siteId = getSiteForUser(loginEmail);
if(siteId != 0)
{
disable SITE;
}ppm_schedules — frequency — On user input
Toggles and clears the custom-days field, then recomputes next_due_date from the best available base date.
// PPM Schedules :: frequency :: On user input
if(input.frequency == "Custom days")
{
show custom_interval_days;
}
else
{
input.custom_interval_days = null; // clear stale value
hide custom_interval_days;
}
// Base the recompute on last completed date, else start date, else today
baseDate = input.start_date;
if(input.last_completed_date != null)
{
baseDate = input.last_completed_date;
}
if(baseDate == null)
{
baseDate = zoho.currentdate;
}
input.next_due_date = computeNextDueDate(input.frequency, input.custom_interval_days, baseDate);ppm_schedules — custom_interval_days — On user input
The interval drives the due date, so recompute whenever it changes on a Custom days plan.
// PPM Schedules :: custom_interval_days :: On user input
if(input.frequency == "Custom days" && input.custom_interval_days != null && input.custom_interval_days > 0)
{
baseDate = input.start_date;
if(input.last_completed_date != null)
{
baseDate = input.last_completed_date;
}
if(baseDate == null)
{
baseDate = zoho.currentdate;
}
input.next_due_date = computeNextDueDate(input.frequency, input.custom_interval_days, baseDate);
}ppm_schedules — start_date — On user input
A brand-new plan is anchored to its start date, so re-seed the first due date when it changes.
// PPM Schedules :: start_date :: On user input
if(input.start_date != null)
{
input.next_due_date = computeNextDueDate(input.frequency, input.custom_interval_days, input.start_date);
}ppm_schedules — On Validate — Create and Edit
Runs on Submit/Update before the save. Blocks bad combinations and repairs a missing next_due_date. Confirm the exact abort keyword (cancel submit) in your Creator build.
// PPM Schedules :: On Validate :: Create and Edit
// 1) Custom cadence needs a positive interval
if(input.frequency == "Custom days")
{
if(input.custom_interval_days == null || input.custom_interval_days <= 0)
{
alert "Enter a positive Custom Interval days value for a Custom days schedule.";
cancel submit;
}
}
// 2) End date cannot precede start date
if(input.end_date != null && input.start_date != null && input.end_date < input.start_date)
{
alert "End Date cannot be earlier than Start Date.";
cancel submit;
}
// 3) Guarantee a sane next due date
if(input.next_due_date == null || (input.start_date != null && input.next_due_date < input.start_date))
{
input.next_due_date = computeNextDueDate(input.frequency, input.custom_interval_days, input.start_date);
}ppm_schedules — On Success — Create and Edit
Finalizes a unique schedule_code once the record ID exists. Optional block raises the first WO immediately if the plan is already due; delete it if the nightly scheduler alone is enough. Assigning input.<field> here persists to the saved record.
// PPM Schedules :: On Success :: Create and Edit
// 1) Finalize a unique readable code now that the record ID exists
if(input.schedule_code == null || input.schedule_code.startsWith("TMP-"))
{
input.schedule_code = "PPM-" + zoho.currentdate.toString("yyyyMMdd") + "-" + input.ID;
}
// 2) OPTIONAL - raise the first WO now if already due within the lead window.
// Delete this block if the nightly scheduler is enough.
if(input.status == "Active" && input.next_due_date != null)
{
lead = 0;
if(input.lead_time_days != null)
{
lead = input.lead_time_days;
}
if(input.next_due_date <= zoho.currentdate.addDay(lead))
{
alreadyRaised = false;
sameDayWOs = work_orders[job_type == "Preventive" && scheduled_date == input.next_due_date];
for each wo in sameDayWOs
{
if(wo.source_schedule.contains(input.ID))
{
alreadyRaised = true;
break;
}
}
if(alreadyRaised == false)
{
insert into work_orders
[
job_type = "Preventive"
SITE = input.SITE
asset = input.asset
checklist_template = input.checklist_template
assigned_technician = input.assigned_technician
source_schedule = {input.ID}
scheduled_date = input.next_due_date
estimated_duration_minutes = input.estimated_duration_minutes
raised_on = zoho.currentdate
priority = "Medium"
status = "Open"
];
// keep the calendar moving so tonight scheduler does not raise it again
input.next_due_date = computeNextDueDate(input.frequency, input.custom_interval_days, input.next_due_date);
}
}
}ppm_schedules — On Delete
Preserves live work orders that point at the deleted plan by emptying their multi-select source_schedule link and annotating them, so no dangling reference is left.
// PPM Schedules :: On Delete
// Detach live WOs from the plan being deleted so history is preserved
openWOs = work_orders[status == "Open" || status == "Assigned" || status == "In progress"];
for each wo in openWOs
{
if(wo.source_schedule.contains(input.ID))
{
wo.source_schedule = list(); // empty the multi-select lookup - no dangling link
wo.Description = wo.Description + " [Source PPM schedule deleted on " + zoho.currentdate + "]";
}
}work_orders — On Success — Edit (closes the PPM loop; lives on the work_orders form)
When a generated WO becomes Completed: Preventive stamps last_completed_date on the source schedule; Predictive resets hours_at_last_service on the asset. Uncomment the recompute line to switch to a floating (completion-based) calendar.
// Work Orders :: On Success :: Edit (documented in this module because it closes the PPM loop)
if(input.status == "Completed")
{
// Preventive - stamp the schedule actual completion date
if(input.job_type == "Preventive")
{
for each schedId in input.source_schedule // multi-select lookup = list of ids
{
schedRecs = ppm_schedules[ID == schedId];
for each s in schedRecs
{
s.last_completed_date = zoho.currentdate;
// Fixed calendar - the scheduler already rolled next_due_date.
// For FLOATING PPM instead, uncomment the next line:
// s.next_due_date = computeNextDueDate(s.frequency, s.custom_interval_days, zoho.currentdate);
break;
}
}
}
// Predictive - reset the asset hours baseline so the meter starts fresh
if(input.job_type == "Predictive")
{
for each assetId in input.asset
{
astRecs = assets[ID == assetId];
for each a in astRecs
{
a.hours_at_last_service = a.current_running_hours;
break;
}
}
}
}computeNextDueDate(frequency, customDays, fromDate)
Shared helper. Returns the next due date for a cadence label from a base date. Used by On Load, the frequency and interval on-user-input handlers, On Validate, and both scheduled generators.
// FUNCTION (Standalone) : computeNextDueDate
// Returns the next due date for a cadence from a base date.
date computeNextDueDate(string frequency, int customDays, date fromDate)
{
// Anchor to today when no base date is supplied
baseDate = fromDate;
if(baseDate == null)
{
baseDate = zoho.currentdate;
}
nextDate = baseDate;
// Add the interval that matches the chosen cadence
if(frequency == "Daily")
{
nextDate = baseDate.addDay(1);
}
else if(frequency == "Weekly")
{
nextDate = baseDate.addDay(7);
}
else if(frequency == "Monthly")
{
nextDate = baseDate.addMonth(1);
}
else if(frequency == "Quarterly")
{
nextDate = baseDate.addMonth(3);
}
else if(frequency == "Half-yearly")
{
nextDate = baseDate.addMonth(6);
}
else if(frequency == "Yearly")
{
nextDate = baseDate.addYear(1);
}
else if(frequency == "Custom days")
{
// Fall back to 30 days when the interval is missing or invalid
interval = customDays;
if(interval == null || interval <= 0)
{
interval = 30;
}
nextDate = baseDate.addDay(interval);
}
return nextDate;
}generatePpmWorkOrders()
SCHEDULED (daily). Scans Active schedules whose next_due_date is within today plus lead_time_days, raises one Open Preventive work order per plan (guarded against duplicates), then rolls next_due_date forward one cycle. Auto-ends plans past their end_date.
// FUNCTION (Standalone / owner context) : generatePpmWorkOrders
// Called ONCE per day by the daily schedule. Raises Preventive WOs and rolls plans forward.
void generatePpmWorkOrders()
{
today = zoho.currentdate;
// Only Active plans that still have a due date are candidates
dueSchedules = ppm_schedules[status == "Active" && next_due_date != null];
for each sched in dueSchedules
{
// Auto-end a plan that has passed its end date
if(sched.end_date != null && sched.end_date < today)
{
sched.status = "Ended";
continue;
}
// Lead time defaults to 0 days when blank
lead = 0;
if(sched.lead_time_days != null)
{
lead = sched.lead_time_days;
}
// Fire once the due date is within (today + lead time)
if(sched.next_due_date <= today.addDay(lead))
{
// Duplicate guard : any Preventive WO for this plan + due date already?
// source_schedule is MULTI-select, so test with .contains, not ==
alreadyRaised = false;
sameDayWOs = work_orders[job_type == "Preventive" && scheduled_date == sched.next_due_date];
for each wo in sameDayWOs
{
if(wo.source_schedule.contains(sched.ID))
{
alreadyRaised = true;
break;
}
}
if(alreadyRaised == false)
{
// asset / checklist_template / assigned_technician are MULTI-select lookups:
// the plan already holds them as lists, so assign straight across.
// source_schedule is MULTI-select too, so wrap the single id in a list {}.
insert into work_orders
[
job_type = "Preventive"
SITE = sched.SITE
asset = sched.asset
checklist_template = sched.checklist_template
assigned_technician = sched.assigned_technician
source_schedule = {sched.ID}
scheduled_date = sched.next_due_date
estimated_duration_minutes = sched.estimated_duration_minutes
raised_on = zoho.currentdate
priority = "Medium"
status = "Open"
];
}
// Roll forward from the due date we just actioned (fixed calendar)
sched.next_due_date = computeNextDueDate(sched.frequency, sched.custom_interval_days, sched.next_due_date);
}
}
}generatePredictiveWorkOrders()
SCHEDULED (daily, right after the PPM pass). Scans metered assets where current_running_hours minus hours_at_last_service has reached runtime_threshold and raises one Open Predictive WO per asset. Hours are NOT reset here - that happens on WO completion.
// FUNCTION (Standalone / owner context) : generatePredictiveWorkOrders
// Called ONCE per day right after generatePpmWorkOrders. Raises Predictive WOs on run-hour breach.
void generatePredictiveWorkOrders()
{
// Metered assets with a real positive threshold only
meteredAssets = assets[track_running_hours == true && runtime_threshold != null && runtime_threshold > 0];
for each ast in meteredAssets
{
// Hours accumulated since the last service reset
baseline = 0.0;
if(ast.hours_at_last_service != null)
{
baseline = ast.hours_at_last_service;
}
current = 0.0;
if(ast.current_running_hours != null)
{
current = ast.current_running_hours;
}
hoursSinceService = current - baseline;
if(hoursSinceService >= ast.runtime_threshold)
{
// One Open Predictive WO per asset at a time
alreadyOpen = false;
openPred = work_orders[job_type == "Predictive" && status == "Open"];
for each wo in openPred
{
if(wo.asset.contains(ast.ID))
{
alreadyOpen = true;
break;
}
}
if(alreadyOpen == false)
{
// asset is MULTI-select on work_orders, so wrap the single id in a list {}
insert into work_orders
[
job_type = "Predictive"
SITE = ast.SITE
asset = {ast.ID}
scheduled_date = zoho.currentdate
raised_on = zoho.currentdate
priority = "High"
status = "Open"
Description = "Auto raised - run hours since last service " + hoursSinceService + " reached threshold " + ast.runtime_threshold
];
// Do NOT reset hours here. hours_at_last_service is reset to
// current_running_hours when this Predictive WO is marked Completed.
}
}
}
}The one consolidated nightly job. Calls generatePpmWorkOrders then generatePredictiveWorkOrders in order. One daily run is about 30 executions per month, well under the 90 runs per user per month cap. Do not split into hourly jobs.
Action: generatePpmWorkOrders(); generatePredictiveWorkOrders();
Closes the loop from a finished WO back to its plan. Preventive WOs stamp last_completed_date on the source schedule; Predictive WOs reset hours_at_last_service on the asset so the run-hour counter restarts.
Action: See the work_orders On Success script in the Form Scripts tab
Enforces a positive custom interval for Custom days plans, stops end_date landing before start_date, and repairs a missing or invalid next_due_date before the record saves.
Action: See the On Validate script in the Form Scripts tab
generatePpmWorkOrders sets status to Ended for any Active plan whose end_date has passed, so it stops generating work orders without manual cleanup.
Action: Part of generatePpmWorkOrders()
1. Create a monthly PPM plan
- Supervisor opens New PPM Schedule. On Load fills a provisional schedule_code, frequency Monthly, status Active, start_date today, and computes next_due_date one month out.
- SITE auto-fills to their site via getSiteForUser and is disabled so they cannot change it.
- They pick the asset(s) and checklist_template, set lead_time_days 7, assigned_technician and estimated_duration_minutes.
- Frequency stays Monthly so custom_interval_days remains hidden and On Validate needs no interval.
- On Save, On Success replaces the TMP- code with a unique PPM-yyyyMMdd-<recordID>. Result: an Active monthly plan with next_due_date set.
2. Daily scheduler raises due WOs
- At 01:00 the daily Creator schedule calls generatePpmWorkOrders.
- It loads Active schedules with a next_due_date and, for each, checks whether next_due_date is within today plus lead_time_days.
- For a due plan with no existing Open Preventive WO on that date, it inserts a work_orders record: job_type Preventive, SITE and asset and checklist_template and assigned_technician copied from the plan, source_schedule set to the plan as a one-item list, scheduled_date equal to next_due_date, status Open.
- It rolls next_due_date forward one cycle via computeNextDueDate so the plan will not re-raise tomorrow.
- Any Active plan past its end_date is set to Ended and skipped.
3. Predictive trigger from running hours
- Ops updates an asset current_running_hours to 1510. The asset has track_running_hours ticked, runtime_threshold 500 and hours_at_last_service 1000.
- The daily generatePredictiveWorkOrders computes 1510 minus 1000 = 510, which is greater than or equal to 500.
- No Open Predictive WO exists for the asset, so it inserts a Predictive WO with SITE and asset from the asset record, status Open, priority High, and a description noting the hours.
- hours_at_last_service is left unchanged, so the asset is not re-flagged every night while that WO is still Open.
4. Completing a PPM WO rolls the schedule forward
- The technician sets the generated work order status to Completed.
- work_orders On Success sees status Completed with job_type Preventive and writes last_completed_date = today onto the source_schedule record.
- next_due_date was already advanced by the scheduler when the WO was raised, so the plan sits ready for its next cycle under the fixed-calendar model.
- For a completed Predictive WO instead, hours_at_last_service is reset to current_running_hours so the run-hour counter restarts from zero.
flowchart TD
A["Daily scheduler runs at 0100"] --> B["Load Active PPM schedules"]
B --> C{"Due within lead time"}
C -->|No| G["Scan metered assets"]
C -->|Yes| D{"Open WO already raised"}
D -->|Yes| F["Roll next due date forward"]
D -->|No| E["Create Preventive WO Open"]
E --> F
F --> G
G --> H{"Run hours over threshold"}
H -->|No| Z["Run ends"]
H -->|Yes| I["Create Predictive WO Open"]
I --> Z
E --> J["Technician completes WO"]
I --> J
J --> K{"Job type Preventive"}
K -->|Yes| L["Write last completed date"]
K -->|No| M["Reset hours at last service"]Assets, O&M Docs, Contracts & Audit
This module runs the physical asset register for the Jood FM CAFM app across all six hospital sites, plus O&M documentation links, service contracts, and a central audit trail. Assets are captured on the `assets` form, which auto-binds each record to the logged-in supervisor's site, defaults new equipment to "In service", and generates a readable Asset ID from the site code + category prefix + serial number. Because Asset ID is NOT unique at the database level, an On Validate guard blocks duplicates within a site. Service agreements live on `Asset_Contract` (start/end dates plus up to ten uploaded files), and every meaningful change — create, status change, delete, contract, reminders — is written to `audit_log` through one `logAudit` helper stamped with the portal user's email and the current time. Two lightweight daily schedules email the FM team 30 days before a contract or a warranty lapses. Important: O&M manuals are LINKED (documents_folder is a URL field), not uploaded into Creator, to stay under the 1 GB per-user storage ceiling.
Developer notes & pending
- 1. PREREQUISITE — add a hidden single-line field prev_status to the assets form. On Load (Edit) writes the current status into it and On Success (Edit) reads it to detect transitions. Creator has no native old-value, so this snapshot is required; deploy the edit scripts only after the field exists.
- 2. Ownership does not exist on assets. Add owner_department (lookup to a departments/technicians form or a single line) so audit and reminders can be routed to an accountable owner.
- 3. Asset_Contract has no revision/version field. Renewals currently overwrite history. Add a revision number plus a self-lookup supersedes so prior terms are retained.
- 4. documents_folder is a URL field (type 17), NOT a file upload. O&M manuals must live in WorkDrive/S3 and this field stores the folder link. Do not instruct users to drag files here — Creator storage is ~1 GB/user and fills fast.
- 5. photo is a MULTI-LINE TEXT field (type 2), NOT an image/file field. Either change it to an Image or File-upload field, or repurpose it to hold an external image URL. Today it only holds text.
- 6. asset_category, room, manufacturer and service_vendor are MULTI-select lookups — Deluge sees them as lists of record IDs. Always iterate; never treat them as a single value.
- 7. asset_categories has no prefix field. buildAssetId currently derives the prefix from category_name (first 3 letters). Add a category_prefix single-line field for stable, human-chosen codes and read it instead.
- 8. audit_log.reference_type has no Contract option (Work order | Stock movement | Asset | PPM schedule | Parts master). Contracts are therefore logged against the parent Asset. Add a Contract choice if contract-level trails are wanted.
- 9. SITE is admin-only on all three forms, so non-admin portal users do not see it. The On Load scripts still set it server-side via getSiteForUser(zoho.loginuserid). Read the portal email from zoho.loginuserid, never zoho.loginuser.
- 10. In the On Validate event the alert statement both warns the user AND cancels the save — that is exactly how the Asset ID uniqueness guard blocks duplicates. Verify this behavior after pasting.
- 11. Both reminders use one DAILY scheduled run each (~30 runs/user/month, well under the 90/user/month cap). Replace facilities@jood.example with the real FM mailbox, and ideally resolve the site supervisor's email per SITE before enabling.
- 12. Standalone functions are invoked directly by name (buildAssetId, logAudit, getSiteForUser). In scheduled/automation context zoho.loginuserid resolves to the schedule owner — acceptable as the automation actor in changed_by.
- 13. Field link-name casing is exact and irregular: Voltage, Phase, Capacity, Serving_Area, Site_Code, Start_Date, End_Date, Files, action_1. Copy them verbatim — Deluge is case-sensitive on link names.
| Field | Type | Notes |
|---|---|---|
SITE | Single-select lookup -> SITE | [assets] Admin-only field. Set server-side in On Load via getSiteForUser(zoho.loginuserid); non-admin portal users never see it. |
asset_id | Single line | [assets] NOT DB-unique. Built by buildAssetId(); guarded in On Validate; locked on edit. |
asset_name | Single line | [assets] Equipment name. |
asset_category | Multi-select lookup -> asset_categories | [assets] LIST of record IDs (allow_new_entries). First selection drives the Asset ID prefix. Always iterate. |
room | Multi-select lookup -> rooms | [assets] LIST of record IDs. |
location_path | Single line | [assets] Free-text breadcrumb, e.g. Building > Floor > Room. |
make | Single line | [assets] |
Voltage | Single line | [assets] Capital V in link name. |
model | Single line | [assets] |
Phase | Single line | [assets] Capital P in link name. |
serial_number | Single line | [assets] Feeds the serial segment of Asset ID. |
Capacity | Single line | [assets] Capital C in link name. |
manufacturer | Multi-select lookup -> vendors | [assets] LIST of record IDs. |
Serving_Area | Single line | [assets] |
service_vendor | Multi-select lookup -> vendors | [assets] LIST of record IDs. |
install_date | Date | [assets] |
commissioning_date | Date | [assets] |
warranty_expiry | Date | [assets] Drives the warranty reminder schedule. |
criticality | Dropdown | [assets] High | Low | Medium | Critical. |
status | Dropdown | [assets] In service | Under repair | Standby | Decommissioned. Audited on edit. |
track_running_hours | Checkbox | [assets] Decision box. |
current_running_hours | Decimal | [assets] |
runtime_threshold | Decimal | [assets] |
hours_at_last_service | Decimal | [assets] |
documents_folder | URL (type 17) — NOT file upload | [assets] Holds the link to the external WorkDrive/S3 O&M folder. Do NOT tell users to drag files here. |
photo | Multi-line TEXT (type 2) — NOT an image field | [assets] Currently stores text only. See Dev Note 5 to convert to Image/File-upload or an image URL. |
notes | Multi-line | [assets] Free notes. |
SITE | Single-select lookup -> SITE | [Asset_Contract] Admin-only. Set On Load via getSiteForUser. |
assets | Single-select lookup -> assets | [Asset_Contract] Parent asset (single record ID, not a list). |
Start_Date | Date | [Asset_Contract] Defaults to today in On Load. |
End_Date | Date | [Asset_Contract] Drives the contract-expiry reminder. |
Files | Multi-file upload (max 10) | [Asset_Contract] Genuine upload field; the signed PDFs live here. |
SITE | Single-select lookup -> SITE | [audit_log] Populated by logAudit for Asset references (0 = blank). |
reference_type | Dropdown | [audit_log] Work order | Stock movement | Asset | PPM schedule | Parts master. No Contract option (see Dev Note 8). |
reference_id | Single line | [audit_log] Record ID of the referenced row (as text). |
action_1 | Dropdown | [audit_log] Created | Updated | Status change | Imported | Deleted. |
field_changed | Single line | [audit_log] Link name of the field that changed. |
old_value | Multi-line | [audit_log] |
new_value | Multi-line | [audit_log] |
changed_by | Single line | [audit_log] Set to zoho.loginuserid (portal email). |
changed_on | Date-time | [audit_log] Set to zoho.currenttime. |
prev_status | Single line, HIDDEN — ADD to assets | [assets] PREREQUISITE. Snapshot of status for edit auditing (Dev Note 1). |
owner_department | Lookup or single line — ADD to assets | [assets] Ownership does not exist yet (Dev Note 2). |
revision + supersedes | Number + self lookup — ADD to Asset_Contract | [Asset_Contract] Contract versioning does not exist yet (Dev Note 3). |
category_prefix | Single line — ADD to asset_categories | [asset_categories] Stable Asset ID prefix; today it is derived from category_name (Dev Note 7). |
assets — On Load (Create)
Binds the new asset to the supervisor's site, defaults status, and seeds a provisional Asset ID. The category prefix is refined later by the category On-user-input script.
// Fires when a NEW Assets form is opened.
// 1) Bind to the supervisor's site (SITE is admin-only, so this fills it server-side)
loginEmail = zoho.loginuserid; // portal login exposes the email HERE (not zoho.loginuser)
mySite = getSiteForUser(loginEmail); // owner-context fn -> SITE record id, 0 if none
if(mySite != 0)
{
input.SITE = mySite; // set the single-select lookup by record id
disable SITE; // lock it for admins who can see the field
}
// 2) Default operating status
if(input.status == null || input.status == "")
{
input.status = "In service";
}
// 3) Seed a provisional Asset ID using the site code + a generic prefix + serial
centerCode = "";
if(mySite != 0)
{
for each s in SITE[ID == mySite]
{
centerCode = s.Site_Code; // real field on the SITE form
break; // bounded loop, single record expected
}
}
input.asset_id = buildAssetId(centerCode, "GEN", input.serial_number);assets — On Load (Edit)
PREREQUISITE: add hidden field prev_status first (Dev Note 1). Locks identity fields and snapshots status so On Success (Edit) can detect a transition.
// Fires when an EXISTING Assets record is opened for editing.
// Lock the identity fields so Asset ID and site cannot drift
disable asset_id;
disable SITE;
// Snapshot the current status for change detection.
// Requires hidden single-line field prev_status - ADD IT FIRST (Dev Note 1).
input.prev_status = input.status;assets — asset_category — On user input
asset_category is a MULTI-select lookup (a list of IDs). Uses the first selected category to set the Asset ID prefix, then rebuilds the ID.
// Fires when the user selects/changes Asset Category (multi-select lookup -> asset_categories).
catPrefix = "GEN";
for each catId in input.asset_category // multi-select = list of record ids
{
for each c in asset_categories[ID == catId]
{
nm = c.category_name;
if(nm != null && nm.trim() != "")
{
clean = nm.trim().toUppercase().replaceAll(" ","");
prefixLen = if(clean.length() >= 3,3,clean.length());
if(prefixLen > 0)
{
catPrefix = clean.subString(0,prefixLen);
}
}
break; // use the first category record only
}
break; // bounded: stop after the first selected id
}
// Rebuild the Asset ID keeping the site code + serial
centerCode = "";
if(input.SITE != null && input.SITE != 0)
{
for each s in SITE[ID == input.SITE]
{
centerCode = s.Site_Code;
break;
}
}
input.asset_id = buildAssetId(centerCode, catPrefix, input.serial_number);assets — On Validate (Create)
asset_id is NOT DB-unique, so enforce per-site uniqueness here. In the On Validate event, the alert statement both warns the user AND cancels the save.
// Runs before the record is saved. WHILE is not allowed - use for-each + break.
idToCheck = input.asset_id;
if(idToCheck != null && idToCheck.trim() != "")
{
hit = false;
// Same-site scope: IDs are only meant to be unique per site
for each m in assets[asset_id == idToCheck && SITE == input.SITE]
{
hit = true;
break; // one match is enough
}
if(hit)
{
// alert in On Validate stops the submission
alert "Asset ID " + idToCheck + " already exists at this site. Change the Serial Number or Category and try again.";
}
}assets — On Success (Create)
Writes the creation entry to the audit trail.
// Fires after a NEW Assets record is saved.
logAudit("Asset", input.ID.toString(), "Created", "asset_id", "", input.asset_id);assets — On Success (Edit)
Detects a status transition using the prev_status snapshot and logs a Status change row.
// Fires after an EXISTING Assets record is saved.
if(input.status != input.prev_status)
{
logAudit("Asset", input.ID.toString(), "Status change", "status", input.prev_status, input.status);
}assets — On Delete
Set on the Assets report (On Delete). Keeps a tombstone in the audit log before the row is gone.
// Fires when an Assets record is deleted.
logAudit("Asset", input.ID.toString(), "Deleted", "asset_id", input.asset_id, "");Asset_Contract — On Load (Create)
Fills SITE from the supervisor and defaults Start_Date to today.
// Fires when a NEW Asset Contract form is opened.
loginEmail = zoho.loginuserid;
mySite = getSiteForUser(loginEmail);
if(mySite != 0)
{
input.SITE = mySite; // single-select lookup by record id
}
if(input.Start_Date == null)
{
input.Start_Date = zoho.currentdate; // default start = today
}Asset_Contract — On Success (Create)
audit_log has no Contract reference type, so the contract is logged against its parent Asset with the term in new_value.
// Fires after a contract is saved. assets is a single-select lookup (one record id).
astId = "";
if(input.assets != null && input.assets != 0)
{
astId = input.assets.toString();
}
logAudit("Asset", astId, "Updated", "service_contract", "", "Contract " + input.Start_Date + " to " + input.End_Date);Scheduled function — Contract expiry reminder (daily)
Standalone scheduled function, run once per day (~30 runs/month, well under the 90/user/month cap). Set the real FM recipient before enabling.
// Standalone function on a DAILY schedule. Emails 30 days before a contract ends.
void remindContractExpiry()
{
targetDate = zoho.currentdate.addDay(30); // the day exactly 30 days out
for each c in Asset_Contract[End_Date == targetDate]
{
astName = "Asset";
if(c.assets != null && c.assets != 0)
{
for each a in assets[ID == c.assets]
{
astName = a.asset_name;
break;
}
}
toAddr = "facilities@jood.example"; // TODO set real FM mailbox / per-site supervisor
sendmail
[
from : zoho.adminuserid
to : toAddr
subject : "Contract expiring in 30 days - " + astName
message : "The service contract for " + astName + " ends on " + c.End_Date + ". Please start the renewal."
];
logAudit("Asset", c.assets.toString(), "Updated", "contract_reminder", "", "30-day expiry notice sent " + zoho.currentdate);
}
}Scheduled function — Warranty expiry reminder (daily)
Standalone scheduled function, run once per day. Skips decommissioned assets.
// Standalone function on a DAILY schedule. Emails 30 days before warranty lapses.
void remindWarrantyExpiry()
{
targetDate = zoho.currentdate.addDay(30);
for each a in assets[warranty_expiry == targetDate && status != "Decommissioned"]
{
toAddr = "facilities@jood.example"; // TODO set real FM mailbox / per-site supervisor
sendmail
[
from : zoho.adminuserid
to : toAddr
subject : "Warranty expiring in 30 days - " + a.asset_name
message : a.asset_name + " " + a.asset_id + " warranty ends on " + a.warranty_expiry + "."
];
logAudit("Asset", a.ID.toString(), "Updated", "warranty_reminder", "", "30-day warranty notice sent " + zoho.currentdate);
}
}buildAssetId
Builds a readable, well-formed Asset ID from the site code, a category prefix, and the serial number. Falls back to safe defaults so the ID is never malformed, e.g. AST-HAIL-HVAC-004521.
// Standalone (custom) function. Return type: string.
// Example result: AST-HAIL-HVAC-004521
string buildAssetId(string siteCenterCode, string categoryPrefix, string serial)
{
// Normalise the three parts - trim, upper-case, and default when blank
cc = if(siteCenterCode == null || siteCenterCode.trim() == "","STE",siteCenterCode.trim().toUppercase());
cp = if(categoryPrefix == null || categoryPrefix.trim() == "","GEN",categoryPrefix.trim().toUppercase());
sn = if(serial == null,"",serial.trim().toUppercase());
sn = sn.replaceAll(" ","");
// No serial captured yet - use a timestamp so two blank-serial assets do not collide
if(sn == "")
{
sn = zoho.currenttime.toString("yyMMddHHmmss");
}
return "AST-" + cc + "-" + cp + "-" + sn;
}logAudit
Single choke-point that writes one row to audit_log. Stamps changed_by with the portal email (zoho.loginuserid) and changed_on with the current date-time. For Asset references it best-effort resolves and fills the SITE lookup.
// Standalone (custom) function. Return type: void.
// Call it as: logAudit("Asset", input.ID.toString(), "Created", "asset_id", "", input.asset_id);
void logAudit(string refType, string refId, string action, string field, string oldVal, string newVal)
{
// Best-effort site scoping. Only Asset references can be resolved to a SITE here.
siteId = 0;
if(refType == "Asset" && refId != null && refId.trim() != "")
{
if(refId.matches("[0-9]+")) // guard - refId must be a numeric record id
{
for each a in assets[ID == refId.toLong()]
{
siteId = a.SITE; // lookup returns the linked SITE record id
break; // one record expected - bounded loop (no WHILE)
}
}
}
// Write the row. SITE = 0 leaves the lookup blank (the app's convention for none).
insert into audit_log
[
SITE = siteId
reference_type = refType
reference_id = refId
action_1 = action
field_changed = field
old_value = oldVal
new_value = newVal
changed_by = zoho.loginuserid // portal email - NOT zoho.loginuser
changed_on = zoho.currenttime // current date-time
];
}Every new asset writes a Created row to audit_log.
Action: Runs the assets On Success (Create) script: logAudit("Asset", ID, "Created", "asset_id", "", asset_id).
When an asset is edited and its status differs from the prev_status snapshot, a Status change row is logged with old and new values.
Action: Runs the On Success (Edit) script; compares input.status to input.prev_status and calls logAudit with action Status change.
Deleting an asset leaves a tombstone in audit_log so the removal is traceable.
Action: Runs the On Delete script: logAudit("Asset", ID, "Deleted", "asset_id", asset_id, "").
30 days before End_Date the FM team is emailed and the notice is logged.
Action: Runs remindContractExpiry(): Asset_Contract[End_Date == currentdate.addDay(30)] -> sendmail + logAudit.
30 days before warranty_expiry the FM team is emailed (decommissioned assets skipped) and the notice is logged.
Action: Runs remindWarrantyExpiry(): assets[warranty_expiry == currentdate.addDay(30) && status != Decommissioned] -> sendmail + logAudit.
Register an asset and link its O&M manuals
- Supervisor opens the Assets form. On Load fills SITE (admin-only, set via getSiteForUser), defaults status to In service, and seeds a provisional Asset ID.
- They enter asset_name, make, model, serial_number, then pick asset_category (multi-select) — the On-user-input script rebuilds the Asset ID with the category prefix, e.g. AST-HAIL-HVAC-250918.
- They upload the actual O&M PDFs into the site's WorkDrive/S3 folder (Creator storage is capped) and paste that FOLDER LINK into documents_folder, which is a URL field, not an upload box.
- On Save, On Validate confirms the Asset ID is unique for that site (alert blocks a duplicate). On Success writes a Created row to audit_log stamped with the portal email and current time.
Add a service contract with files
- Open the Asset Contract form. On Load fills SITE and defaults Start_Date to today.
- Select the parent asset in the assets lookup, set End_Date, and upload up to 10 contract files into Files.
- On Save, On Success logs an Updated row against the PARENT ASSET (audit_log has no Contract type) with the term recorded in new_value.
- The daily Contract expiry reminder will email the FM team 30 days before End_Date.
An edit is captured in the audit log
- Open an existing asset. On Load (Edit) locks Asset ID and SITE and snapshots status into the hidden prev_status field.
- Change status from In service to Under repair and Save.
- On Success (Edit) sees input.status != input.prev_status and writes a Status change row: field_changed=status, old_value=In service, new_value=Under repair, changed_by=portal email, changed_on=now.
- If status is unchanged, no audit row is written — only real transitions are logged.
Planned enhancements (handover checklist)
- Add hidden prev_status to assets BEFORE deploying the edit scripts (Dev Note 1).
- Add an ownership field to assets and a revision/supersedes pair to Asset_Contract (Dev Notes 2 and 3).
- Convert photo to an Image/File field or store an image URL, and confirm documents_folder is treated as a link, not an upload (Dev Notes 4 and 5).
- Optionally add a category_prefix field to asset_categories and a Contract option to audit_log.reference_type (Dev Notes 7 and 8).
flowchart TD
A["Supervisor opens new Assets form"]
B["On Load sets SITE and default status"]
C["buildAssetId seeds Asset ID"]
D["Select Asset Category"]
E["On user input refreshes Asset ID prefix"]
F["On Validate duplicate check per site"]
G["Duplicate so alert blocks save"]
H["Assets record saved"]
I["On Success writes Created row"]
J["Add Asset Contract with files and dates"]
K["Later edit changes status"]
L["On Success Edit writes Status change row"]
M["Daily schedule checks contract and warranty dates"]
N["Reminder email sent"]
Z["Audit Log table"]
A --> B
B --> C
C --> D
D --> E
E --> F
F -->|duplicate| G
G --> D
F -->|unique| H
H --> I
H --> J
H --> K
K --> L
M --> N
I --> Z
L --> Z
J --> Z
N --> ZSpare Parts & Inventory
The Spare Parts & Inventory module keeps a live on-hand balance for every spare part at each of the six hospital sites. Three forms work together: parts_master is the catalogue and balance holder, stock_movements is the ledger where every Receipt, Issue, Return, Adjustment and Transfer is recorded, and parts_used captures parts consumed on a work order. Each saved movement is converted into a signed quantity and posted against parts_master.current_balance, with balance_after stamped on the ledger row for traceability. Issues and Transfers are blocked at validation when stock is insufficient, and a low-stock email fires whenever a movement pushes a part to or below its reorder level. All balance math is centralised in one reusable function (applyStockMovement) so the manual form path and the automatic parts_used path behave identically. This tab documents the data model, the three functions, every form event (on load, create, edit, delete), the two workflows, worked scenarios, and a flow of how data moves on add.
Developer notes & pending
- VERIFIED via getFormMetadata: in parts_used, both part and work_order are MULTI_SELECT_LOOKUP (not single lookups like in stock_movements). Read them by iterating and taking the first id, or loop to create one Issue movement per selected part. parts_used also has a multi-select stock_movement lookup used for the back-link.
- Records created with Deluge insert into do NOT trigger the target form's On Success workflow. That is why parts_used On Success and the Transfer workflow both call applyStockMovement explicitly after inserting.
- Opening stock is currently seeded directly in parts_master.current_balance. Recommend migrating each opening balance to a one-time Adjustment or Receipt movement so every balance change has a ledger row and balance_after is meaningful from the start.
- Inter-site Transfer is not fully built. It needs two NEW fields on stock_movements: from_site (Lookup -> SITE) and to_site (Lookup -> SITE). Because parts are per-site, the destination Receipt must resolve the destination site's parts_master row by part_code (e.g. parts_master[SITE == to_site && part_code == srcPart.part_code]).
- In a Validate workflow the alert statement both shows the message AND stops the save. Do not copy that pattern into On Success or field scripts expecting it to block.
- Creator Deluge forbids WHILE loops. genMovementNumber pads with a bounded for-each over the fixed list {1,2,3,4,5,6}; use the same for-each + break pattern anywhere a loop is needed.
- movement_number generated via count() + 1 is NOT concurrency-safe - two simultaneous adds can collide. For production, mark movement_number unique on the form and/or use a dedicated counter record, or prefix with the site code plus date to shrink collision risk.
- balance_after is a point-in-time snapshot. Editing or deleting old movements makes historical balance_after values approximate. Preferred production stance: forbid editing/deleting posted movements and correct with a reversing Adjustment, or run a nightly job that rebuilds current_balance and balance_after from the ordered ledger.
- Portal logins on this app expose the email at zoho.loginuserid (NOT zoho.loginuser). moved_by and every audit line use zoho.loginuserid.
- getSiteForUser(email) is the existing owner-context function; it returns the Supervisor SITE record id or 0 when the user has no site. Used in On Load to default input.SITE.
- Sign convention: keep quantity positive for Receipt, Issue, Return and Transfer (signForMovement handles the minus). For Adjustment, enter a NEGATIVE quantity to reduce stock - signForMovement preserves the sign for Adjustment only.
- Low-stock email recipient is a placeholder (stores@jood.example). Wire it to the site storekeeper/supervisor once the SITE form fields are fetched. Only real parts_master fields are referenced in the email body to avoid inventing SITE field names.
- On Delete cannot write an audit onto the row being deleted. Recommend a small stock_audit_log form (part, signed_quantity reversed, user, timestamp, reason) written from the On Delete script so reversals are traceable.
- SITE form field link names were not fetched for this module. Any reference to a SITE display name or the storekeeper email is marked TODO and must be confirmed with getFormMetadata for the SITE form before go-live.
| Field | Type | Notes |
|---|---|---|
parts_master.SITE | Lookup -> SITE | Site that owns this part row. Parts are per-site. |
parts_master.part_code | Text | Code, kept unique per site (enforce with a unique setting). |
parts_master.part_name | Text | Display name shown in remarks and emails. |
parts_master.unit_of_measure | Text | e.g. pcs, ltr, kg. Copied to movements for readability. |
parts_master.reorder_level | Number | Low-stock threshold that triggers the alert email. |
parts_master.Maximum_Level | Number | Ceiling / suggested top-up target quoted in the alert. |
parts_master.current_balance | Number | LIVE on-hand qty. Opening stock currently seeded here (see devNotes). |
parts_master.active | Checkbox | Inactive parts are skipped by the low-stock email. |
parts_master.Brand_Name | Text | Reference only. |
parts_master.Country_of_Origin | Text | Reference only. |
parts_master.compatible_categories | Multi-select | Which asset categories the part fits. |
stock_movements.SITE | Lookup -> SITE | Site the movement belongs to. |
stock_movements.movement_number | Text | Auto MV-000123 from genMovementNumber on load. |
stock_movements.part | Lookup -> parts_master | SINGLE-select here. mv.part returns the parts_master id. |
stock_movements.movement_type | Dropdown | Receipt | Issue | Return | Adjustment | Transfer. |
stock_movements.quantity | Decimal | Entered positive for all types except Adjustment (negative reduces). |
stock_movements.signed_quantity | Decimal | SET BY CODE via signForMovement. Drives the balance math. |
stock_movements.balance_after | Decimal | Snapshot of current_balance right after this movement posts. |
stock_movements.work_order | Lookup -> work_orders | SINGLE-select here. Links an Issue to the job that consumed it. |
stock_movements.reference | Text | Free reference, used to link auto-created rows back to source. |
stock_movements.moved_on | DateTime | Defaulted to zoho.currenttime on load. |
stock_movements.moved_by | Text | Defaulted to zoho.loginuserid (portal login email). |
stock_movements.remarks | Multiline | Helper note on input + appended audit trail. |
parts_used.SITE | Lookup -> SITE | Site the consumption happened at. |
parts_used.work_order | MULTI-select lookup -> work_orders | QUIRK multi-select. Iterate and take first id. |
parts_used.part | MULTI-select lookup -> parts_master | QUIRK multi-select. Iterate and take first id. |
parts_used.quantity_used | Decimal | Feeds the auto Issue movement quantity. |
parts_used.unit | Text | Unit label for the line. |
parts_used.unit_cost | Decimal | Cost per unit (costing only, not balance). |
parts_used.line_value | Decimal | quantity_used * unit_cost (costing only). |
parts_used.issued_on | DateTime | When the part was consumed. |
parts_used.stock_movement | MULTI-select lookup -> stock_movements | Back-link filled with the auto-created Issue movement. |
stock_movements — On Load - Create
Runs only when the Add form opens. Prefills the number, timestamp, user and a sensible default type, and defaults SITE to the supervisor's site via the existing owner-context helper.
// Auto number + stamps - only on the Create / Add form
input.movement_number = thisapp.genMovementNumber();
input.moved_on = zoho.currenttime;
input.moved_by = zoho.loginuserid; // portal login email lives here, NOT zoho.loginuser
if(input.movement_type == null)
{
input.movement_type = "Receipt"; // default type
}
// default SITE from the existing owner-context function
siteId = thisapp.getSiteForUser(zoho.loginuserid);
if(siteId != 0)
{
input.SITE = siteId;
}stock_movements — part - On user input
Fires when the Part lookup value changes. Shows the live on-hand balance in remarks so the storekeeper sees stock before saving, and copies the part's SITE if none is set.
// Show the part's current balance the moment it is chosen
if(input.part != null)
{
pm = parts_master[ID == input.part];
bal = ifnull(pm.current_balance, 0);
input.remarks = "On hand for " + pm.part_name + " is " + bal + " " + ifnull(pm.unit_of_measure, "");
if(input.SITE == null)
{
input.SITE = pm.SITE; // keep the movement on the part's own site
}
}stock_movements — On Validate (Add and Edit)
Guards stock. For Issue and Transfer it blocks the save when the requested quantity exceeds what is on hand. IMPORTANT: in a Validate workflow an alert both shows the message AND stops the submit - do not rely on that behaviour in other workflows.
// Block over-issue / over-transfer
if(input.movement_type == "Issue" || input.movement_type == "Transfer")
{
if(input.part != null)
{
pm = parts_master[ID == input.part];
onHand = ifnull(pm.current_balance, 0);
if(input.quantity > onHand)
{
// in a Validate script an alert stops the record from saving
alert "Cannot " + input.movement_type + " " + input.quantity + " - only " + onHand + " on hand for " + pm.part_name;
}
}
}stock_movements — On Success - On Add
After the row is saved, delegate the four balance steps (signed_quantity, current_balance, balance_after, audit) to applyStockMovement so there is exactly one code path. input.ID is the new record id. The low-stock email workflow runs right after this.
// Post the movement to inventory
thisapp.applyStockMovement(input.ID);
// (Low-stock alert workflow runs next - see Workflows tab)stock_movements — On Success - On Edit
When an existing movement is edited, reverse the previously applied signed_quantity and apply the new one, so the running balance stays correct. balance_after becomes a fresh snapshot (see devNotes on historical accuracy).
// Re-post an edited movement - reverse the old effect then apply the new
mv = stock_movements[ID == input.ID];
partId = mv.part;
if(partId != null)
{
pm = parts_master[ID == partId];
oldSigned = ifnull(mv.signed_quantity, 0); // what this row applied last time
newSigned = thisapp.signForMovement(mv.movement_type, ifnull(mv.quantity, 0));
pm.current_balance = ifnull(pm.current_balance, 0) - oldSigned + newSigned;
mv.signed_quantity = newSigned;
mv.balance_after = pm.current_balance;
mv.remarks = ifnull(mv.remarks, "") + "\n[audit-edit] " + zoho.loginuserid + " " + zoho.currenttime.toString() + " signed " + oldSigned + " to " + newSigned;
}stock_movements — On Delete
Before the ledger row disappears, undo its effect on the running balance. The deleted record is available as input. Audit cannot be written on the row being deleted - log it in a separate form (see devNotes).
// Reverse this movement before it is removed
partId = input.part;
signed = ifnull(input.signed_quantity, 0);
if(partId != null)
{
pm = parts_master[ID == partId];
pm.current_balance = ifnull(pm.current_balance, 0) - signed;
}
// NOTE cannot audit on the row being deleted - write to a stock_audit_log form insteadparts_used — On Success - On Add
Each parts_used line becomes an Issue movement tied to its work order. QUIRK: part and work_order are MULTI-SELECT lookups here, so take the first selected id. insert into does NOT fire the stock_movements workflow, so applyStockMovement must be called explicitly.
// Turn a consumed part into an Issue stock movement
partId = null;
for each p in input.part
{
partId = p;
break; // one part per usage line - take the first selected
}
woId = null;
for each w in input.work_order
{
woId = w;
break;
}
if(partId != null)
{
newMvId = insert into stock_movements
[
SITE = input.SITE
movement_number = thisapp.genMovementNumber()
part = partId
movement_type = "Issue"
quantity = ifnull(input.quantity_used, 0)
work_order = woId
reference = "parts_used " + input.ID
moved_on = zoho.currenttime
moved_by = zoho.loginuserid
remarks = "Auto Issue from parts consumption"
];
// insert does not trigger the movement workflow - post it manually
thisapp.applyStockMovement(newMvId);
// link the created movement back onto this row (multi-select field)
row = parts_used[ID == input.ID];
row.stock_movement = newMvId;
}signForMovement
Turn a movement type plus an entered quantity into a signed number. Receipt, Return and a positive Adjustment add stock (+qty); Issue and Transfer-out remove stock (-qty). Adjustment keeps whatever sign the user typed, so a negative quantity reduces stock.
// Standalone function - call as thisapp.signForMovement(type, qty)
decimal signForMovement(string movementType, decimal qty)
{
signed = qty; // default increases stock - Receipt, Return, positive Adjustment
if(movementType == "Issue" || movementType == "Transfer")
{
signed = qty * -1; // consumption or transfer-out lowers this site balance
}
// Adjustment falls through - the sign the user entered is kept as-is
return signed;
}applyStockMovement
The single place all balance math lives. Given a saved stock_movements record id it computes the signed quantity, reads the part's current balance, writes signed_quantity and balance_after on the movement, updates parts_master.current_balance, and appends an audit line to remarks. Both the manual form path and the automatic parts_used path call this so balances never drift.
// Standalone function - call as thisapp.applyStockMovement(movementRecordId)
void applyStockMovement(int movementRecordId)
{
// 1 - load the movement row that was just created or edited
mv = stock_movements[ID == movementRecordId];
partId = mv.part; // single-select lookup returns the parts_master id
if(partId == null)
{
return; // nothing to post without a part
}
// 2 - compute the signed quantity from type + quantity
signed = thisapp.signForMovement(mv.movement_type, ifnull(mv.quantity, 0));
// 3 - read the current on-hand balance
pm = parts_master[ID == partId];
oldBal = ifnull(pm.current_balance, 0);
newBal = oldBal + signed;
// 4 - stamp the movement row
mv.signed_quantity = signed;
mv.balance_after = newBal;
// 5 - move the running balance on the part
pm.current_balance = newBal;
// 6 - audit trail on the movement
mv.remarks = ifnull(mv.remarks, "") + "\n[audit] " + zoho.loginuserid + " " + zoho.currenttime.toString() + " bal " + oldBal + " to " + newBal;
}genMovementNumber
Produce the next human-readable movement number like MV-000042. Uses a bounded for-each to zero-pad because Creator Deluge forbids WHILE loops. See devNotes for the concurrency caveat.
// Standalone function - call as thisapp.genMovementNumber()
string genMovementNumber()
{
// running sequence = how many movements already exist + 1
cnt = stock_movements[ID != 0].count();
seq = cnt + 1;
padded = seq.toString();
// zero-pad to 6 chars without a WHILE loop - bounded for-each
for each idx in {1, 2, 3, 4, 5, 6}
{
if(padded.length() < 6)
{
padded = "0" + padded;
}
}
return "MV-" + padded;
}Emails the site storekeeper when a movement leaves the part at or below its reorder level so a purchase can be raised. Only fires for active parts. Recipient must be wired to the SITE storekeeper once the SITE form fields are confirmed.
Action: // mvPartId = the part id of the movement that just posted (input.part on the movement) pm = parts_master[ID == mvPartId]; if(pm.active == true && ifnull(pm.current_balance, 0) <= ifnull(pm.reorder_level, 0)) { sendmail [ from : zoho.adminuserid to : "stores@jood.example" // TODO wire to the SITE storekeeper - fetch SITE form fields first subject : "Low stock alert " + pm.part_name message : pm.part_name + " is at " + pm.current_balance + " which is at or below reorder level " + pm.reorder_level + " . Suggested top-up to " + ifnull(pm.Maximum_Level, 0) + " ." ] }
A Transfer row only records the outflow from the source site (signed_quantity is negative). This posts the matching Receipt at the destination site so the destination balance rises. Requires NEW fields - see devNotes for the from_site / to_site additions and the per-site part resolution.
Action: // Requires NEW fields on stock_movements - from_site (lookup SITE) and to_site (lookup SITE) if(input.movement_type == "Transfer") { // destPartId must be the same part_code under to_site - parts are per-site - see devNotes destRec = insert into stock_movements [ SITE = input.to_site movement_number = thisapp.genMovementNumber() part = destPartId movement_type = "Receipt" quantity = input.quantity reference = "Transfer in from " + input.movement_number moved_on = zoho.currenttime moved_by = zoho.loginuserid remarks = "Auto Receipt for inter site transfer" ]; thisapp.applyStockMovement(destRec); }
1 - Receipt increases balance
- Storekeeper opens the Stock Movements Add form. On Load sets movement_number MV-000101, moved_on now, moved_by their login email, type Receipt, and SITE their supervisor site.
- They pick the Part. The part On user input script shows On hand for Air Filter is 12 pcs in remarks.
- They keep type Receipt, enter quantity 20 and Submit.
- On Validate skips the stock check because Receipt is neither Issue nor Transfer.
- On Success calls applyStockMovement. signForMovement returns +20, balance_after becomes 32, parts_master.current_balance becomes 32, and an audit line is appended.
2 - Issue against a work order decreases balance
- Technician records a repair on Work Order WO-500 and needs 5 filters.
- On the movement form they choose type Issue, part Air Filter, quantity 5 and work_order WO-500.
- On Validate confirms 5 is not more than the 32 on hand, so the save proceeds.
- On Success applyStockMovement returns -5, balance_after 27, current_balance 27.
- Alternatively they log it on the parts_used form. Its On Success auto-creates the same Issue movement, calls applyStockMovement, and back-links the movement - balances end up identical.
3 - Transfer between sites
- Site A is short so 10 filters move from Site B to Site A.
- At Site B a movement type Transfer quantity 10 is saved. signForMovement returns -10 so Site B balance drops by 10.
- The Inter-site Transfer workflow creates a paired Receipt at Site A for the same part_code, raising Site A balance by 10.
- This needs the new from_site and to_site fields plus a lookup of the destination site's part by code - flagged in devNotes as not yet built.
4 - Adjustment and opening stock
- A physical count finds 3 fewer units than the system shows.
- A movement type Adjustment quantity -3 is entered. signForMovement keeps the sign, so current_balance drops by 3.
- For opening stock a one-time Adjustment or Receipt equal to the counted quantity is posted so every balance change has a ledger row.
- Until opening stock is migrated it sits directly in parts_master.current_balance and the first real movement stacks on top of it.
5 - Reorder alert fires
- Air Filter reorder_level is 10 and after an Issue the balance lands at 8.
- On Success finishes applyStockMovement, then the Low-stock alert workflow runs.
- Because current_balance 8 is at or below reorder_level 10 and the part is active, a sendmail goes to the storekeeper.
- The email names the part, current balance, reorder level and the suggested top-up to Maximum_Level.
flowchart TD
A["User opens Stock Movement form"] --> B["On Load sets number time user and default type"]
B --> C["User picks Part and enters quantity"]
C --> D["part On user input shows current balance"]
D --> E["On Validate checks stock for Issue or Transfer"]
E --> F{"Enough stock"}
F -- No --> G["Show alert and block save"]
G --> Q["End"]
F -- Yes --> H["Record saved"]
H --> I["On Success calls applyStockMovement"]
I --> J["signForMovement computes signed quantity"]
J --> K["Read parts_master current_balance"]
K --> L["newBalance equals oldBalance plus signed"]
L --> M["Write signed_quantity and balance_after"]
M --> N["Update parts_master current_balance"]
N --> O{"Balance at or below reorder level"}
O -- Yes --> P["Send low stock email"]
O -- No --> Q
P --> QChecklist Engine
The Checklist Engine turns reusable inspection checklists into per-work-order task lists that a technician ticks off on site. An admin builds a checklist_templates header (site, code, name, job type, frequency, safety notes, version, active) and attaches one or more checklist_template_items lines to it (check description, response type, acceptable range, mandatory flag). When a Preventive/Predictive/Corrective work order is raised with a Checklist Template selected, the engine copies every template line into a task_checklists row linked to that work order, so the technician answers a fresh, WO-specific copy rather than the master. As the technician records results or numeric readings, each task_checklists row is timestamped and (for numeric checks) auto-graded against its band, then rolled up into the work order's Completion Percent. When all mandatory items are done and compliance hits 100 percent the work order is auto-completed, and the same task_checklists data feeds the PPM compliance report. Two standalone owner-context functions carry the logic: instantiateChecklist (copy) and computeChecklistCompliance (percent done). This doc is a build-and-handover spec for the three forms plus their form events, functions and workflows; hospital-site scoping is preserved by stamping SITE from the parent work order and defaulting it from getSiteForUser on manual entry.
Developer notes & pending
- Verified against live metadata on 2026-09-18 for account_owner_name demo1redecorporativa2, app cafm. All field link names above are the real ones returned by getFormMetadata for checklist_templates, checklist_template_items, task_checklists and work_orders. Do not invent fields.
- MULTI-SELECT LOOKUPS: template (on checklist_template_items), work_order (on task_checklists) and checklist_template (on work_orders) are all MULTI_SELECT_LOOKUP, not single. Read them as lists: input.checklist_template.get(0). Filter records with fieldName.contains(id). If .contains returns nothing on your build, fall back to equality (template == templateId) which some Creator versions treat as membership.
- SETTING a multi-select lookup on insert: work_order = workOrderId assigns a single id and Creator links it. If a row shows a blank work_order after insert, wrap it as a list: work_order = list().add(workOrderId).
- SITE is admin_only on every form. instantiateChecklist runs in owner context and stamps SITE from the parent WO. siteId defaults to 0 when the WO has no site; assigning 0 to the lookup normally leaves it blank. If your environment throws on SITE = 0, remove the SITE line from the insert and set it in task_checklists On Load - Create via getSiteForUser instead.
- PORTAL LOGIN: read the logged-in email with zoho.loginuserid (NOT zoho.loginuser) on this app - portal logins expose the email in loginuserid. getSiteForUser(email) already exists and returns the supervisor SITE record id, or 0 if none.
- DATE-TIME: zoho.currenttime returns a full date-time value and is correct for the completed_on and completed_on/WO fields (all type DATE_TIME). Use zoho.currentdate only for pure DATE fields such as work_orders.scheduled_date.
- NO WHILE LOOPS: all iteration uses for each x in Collection. None of these loops need a break; if you add one, keep it bounded.
- FUNCTION INVOCATION: functions are called by bare name here (getSiteForUser(...), instantiateChecklist(...), computeChecklistCompliance(...)), matching the existing getSiteForUser usage. If your Creator build requires it, prefix with thisapp. (thisapp.instantiateChecklist(...)). Both functions should be created as Standalone functions running as Application Owner so they can read/write across all 6 sites.
- IDEMPOTENCY: instantiateChecklist checks for existing task_checklists rows before copying, so the work_orders On Success trigger (which fires on every save) will not create duplicates. This is why On Success is preferred over the checklist_template On user input alternative.
- COMPLETION DEFINITION: computeChecklistCompliance counts a row as done when completed_on != null. completed_on is written by the task_checklists On user input events. If you want mandatory-weighted PPM compliance, change the rollup to divide by mandatory rows only.
- TRIGGER CHOICE: use EITHER work_orders On Success OR the work_orders checklist_template On user input to instantiate - never both. On Success is safest because input.ID is always available after save, including brand-new WOs.
- STORAGE: task_checklists.Image allows up to 10 photos per row across many WOs and 6 sites - this fills Creator's 1 GB/user quota fast. Follow the project rule to route evidence photos to the client's S3 rather than storing in Creator where feasible.
- SCHEDULES: the PPM compliance rollup must be a single daily scheduled workflow, not per-record, to stay under Creator's 90 schedule-runs/user/month limit.
| Field | Type | Notes |
|---|---|---|
checklist_templates.SITE | Lookup (single-select) -> SITE | Admin-only. Hospital-site scope of the template. Set by referenced SITE record id. |
checklist_templates.template_code | Single line | Short code, e.g. AHU-M. |
checklist_templates.template_name | Single line | Human-readable template name. |
checklist_templates.asset_category | Lookup (multi-select) -> asset_categories | Which asset categories this template applies to. Allow new entries = true. |
checklist_templates.job_type | Dropdown | Preventive, Predictive, Corrective, Any. |
checklist_templates.frequency | Dropdown | Custom days, Daily, Monthly, Weekly, Yearly, Half-yearly, Quarterly. |
checklist_templates.estimated_duration_minutes | Number | Planned duration for the whole checklist. |
checklist_templates.permit_required | Decision box | True if a work permit is needed. |
checklist_templates.safety_notes | Multi line | PPE / lockout notes shown to the technician. |
checklist_templates.version | Number | Template version. Defaults to 1 on create. |
checklist_templates.active | Decision box | Only active templates should be offered on work orders. Defaults true. |
checklist_template_items.SITE | Lookup (single-select) -> SITE | Admin-only. Inherited from the parent template on load. |
checklist_template_items.template | Lookup (multi-select) -> checklist_templates | Parent template. Multi-select lookup, so filter in Deluge with template.contains(id). Allow new entries = true. |
checklist_template_items.check_description | Multi line | The instruction the technician reads. |
checklist_template_items.response_type | Dropdown | Yes-No, Numeric reading, Pass/Fail, Text, Photo. |
checklist_template_items.unit | Single line | Unit for numeric readings, e.g. bar, degC. |
checklist_template_items.min_acceptable | Decimal | Lower bound of the acceptable band (numeric readings). |
checklist_template_items.max_acceptable | Decimal | Upper bound of the acceptable band (numeric readings). |
checklist_template_items.mandatory | Decision box | Must be completed for the WO to auto-complete. Defaults true on load. |
checklist_template_items.remarks | Multi line | Optional guidance copied to the task row. |
task_checklists.SITE | Lookup (single-select) -> SITE | Admin-only. Stamped from the parent work order's SITE by instantiateChecklist. |
task_checklists.work_order | Lookup (multi-select) -> work_orders | Parent WO. Multi-select lookup: read with input.work_order.get(0), filter with work_order.contains(id). Allow new entries = true. |
task_checklists.check_description | Multi line | Copied from the template item. |
task_checklists.response_type | Dropdown | Yes-No, Numeric reading, Pass/Fail, Text, Photo (copied from item). |
task_checklists.result | Dropdown | Not applicable, Pass, Fail. Defaults to Not applicable on instantiate; drives completion timestamp. |
task_checklists.reading | Decimal | Numeric reading entered by technician; graded against min/max. |
task_checklists.unit | Single line | Copied from the template item. |
task_checklists.min_acceptable | Decimal | Copied from item; used by the within_range check. |
task_checklists.max_acceptable | Decimal | Copied from item; used by the within_range check. |
task_checklists.within_range | Decision box | Auto-set on reading input: true when min <= reading <= max. |
task_checklists.mandatory | Decision box | Copied from item. Open mandatory rows block WO auto-completion. |
task_checklists.remarks | Multi line | Copied from item; technician can append notes. |
task_checklists.Image | Multi-image (max 10) | Evidence photos, e.g. for Photo response type. Heavy on Creator storage - route to client S3 per storage policy where possible. |
task_checklists.completed_on | Date-time | Set to zoho.currenttime on user input. Presence of this value = row is done (counted by computeChecklistCompliance). |
work_orders.checklist_template | Lookup (multi-select) -> checklist_templates | Template attached to the WO. Trigger for instantiateChecklist. Read first id with .get(0). |
work_orders.completion_percent | Decimal | Written by the rollup from computeChecklistCompliance (0-100). |
work_orders.status | Dropdown | Assigned, In progress, Draft, Verified, On hold, Completed, Cancelled, Open. Set to Completed by the rollup when 100% and no open mandatory rows. |
work_orders.completed_on | Date-time | Stamped by the rollup when the WO auto-completes. |
work_orders.job_type | Dropdown | Preventive, Predictive, Corrective. Preventive/Predictive feed the PPM compliance report. |
work_orders — On Success (Create and Edit)
input.ID is the saved work order's id. Runs on both Create and Edit so attaching a template to an existing WO still builds the rows. If Creator rejects a bare function call, prefix with thisapp. (see devNotes).
// Work Orders > On Success (fires after the WO record is saved).
// If a checklist template is attached, build the technician's checklist.
if(input.checklist_template.size() > 0)
{
// checklist_template is a multi-select lookup -> take the first template.
templateId = input.checklist_template.get(0);
// instantiateChecklist is idempotent, so re-saving the WO is safe.
rowCount = instantiateChecklist(input.ID, templateId);
info "Checklist rows ready: " + rowCount;
}task_checklists — On user input (result field, and reading field)
On user input is a per-field event. Block A goes on result, block B goes on reading. Both write completed_on, which is what computeChecklistCompliance counts as done.
// ---- Paste block A on the RESULT field's On user input --------------------
// The moment a technician picks a result, stamp the completion time.
if(input.result != null && input.result != "Not applicable")
{
input.completed_on = zoho.currenttime; // zoho.currenttime is a date-time value
}
// ---- Paste block B on the READING field's On user input -------------------
// For numeric checks, grade the reading against the acceptable band.
if(input.response_type == "Numeric reading" && input.reading != null)
{
if(input.min_acceptable != null && input.max_acceptable != null)
{
input.within_range = (input.reading >= input.min_acceptable && input.reading <= input.max_acceptable);
if(input.within_range)
{
input.result = "Pass";
}
else
{
input.result = "Fail";
}
}
input.completed_on = zoho.currenttime; // reading entered = row done
}checklist_templates — On Load - Create
Use zoho.loginuserid (email) - zoho.loginuser would return a name/zuid on this portal. SITE is admin-only so this only matters for admin/owner data entry; on portal it is skipped silently when siteId is 0.
// Checklist Templates > On Load (Create) - sensible defaults for a new template.
input.version = 1;
input.active = true;
input.job_type = "Preventive";
input.frequency = "Monthly";
// Default the site to the supervisor's own site on portal logins.
if(input.SITE == null)
{
userEmail = zoho.loginuserid; // portal login exposes the EMAIL here, not zoho.loginuser
siteId = getSiteForUser(userEmail); // existing owner-context helper, returns 0 if none
if(siteId != 0)
{
input.SITE = siteId;
}
}checklist_template_items — On Load - Create
Optional but recommended - keeps every line mandatory-by-default and site-consistent with its template header.
// Checklist Template Items > On Load (Create) - defaults so lines are quick to add.
input.response_type = "Yes-No";
input.mandatory = true;
// Inherit the parent template's site (template is a multi-select lookup).
if(input.SITE == null && input.template.size() > 0)
{
for each t in checklist_templates[ID == input.template.get(0)]
{
input.SITE = t.SITE;
}
}task_checklists — On Success (Create and Edit) [compliance rollup]
This is the code behind the "all items done -> set WO completion" workflow. Kept as On Success (not a schedule) so the WO updates the instant the last item is answered.
// Task Checklists > On Success - roll the ticked row up to the parent work order.
if(input.work_order.size() > 0)
{
woId = input.work_order.get(0);
pct = computeChecklistCompliance(woId); // percent of rows done, 0 - 100
for each woRec in work_orders[ID == woId]
{
woRec.completion_percent = pct;
// Are any MANDATORY checks still open (no completion timestamp)?
openMandatory = task_checklists[work_order.contains(woId) && mandatory == true && completed_on == null].count();
// Auto-complete only when fully done and not already signed off/verified.
if(pct == 100 && openMandatory == 0 && woRec.status != "Verified" && woRec.status != "Completed")
{
woRec.status = "Completed";
woRec.completed_on = zoho.currenttime;
}
}
}work_orders — checklist_template (On user input) [alternative instantiate trigger]
Use EITHER this field event OR the On Success block, not both. On Success is preferred because input.ID is always present after save (covers brand-new WOs too).
// OPTIONAL alternative to the On Success trigger, if you prefer to build the
// checklist the moment a template is chosen on a SAVED work order.
// Note: input.ID is only reliable on an existing (edit) record here.
if(input.checklist_template.size() > 0 && input.ID != null)
{
templateId = input.checklist_template.get(0);
instantiateChecklist(input.ID, templateId);
}instantiateChecklist(workOrderId, templateId)
Owner-context standalone function. Copies every checklist_template_items line for the given template into task_checklists rows linked to the work order, stamping the work order's SITE onto each child row. Idempotent: if the WO already has checklist rows it does nothing and returns the existing count. Returns the number of rows present for the WO. Called from the work_orders On Success event.
// instantiateChecklist(long workOrderId, long templateId)
// Standalone > runs as Application Owner so it can read/write across all 6 sites.
// Returns the number of task_checklists rows that exist for this work order.
return int
{
createdCount = 0;
// --- Idempotency guard ------------------------------------------------
// work_order is a multi-select lookup, so filter with .contains(id).
existing = task_checklists[work_order.contains(workOrderId)];
if(existing.count() > 0)
{
// Already instantiated (e.g. WO re-saved) - never duplicate rows.
return existing.count();
}
// --- Resolve the WO's site to stamp onto each child row ----------------
// SITE is a single-select lookup, so the field returns the linked record id.
siteId = 0;
for each wo in work_orders[ID == workOrderId]
{
siteId = wo.SITE;
}
// --- Copy the template line items -------------------------------------
// template is a multi-select lookup on checklist_template_items.
items = checklist_template_items[template.contains(templateId)];
for each item in items
{
insert into task_checklists
[
work_order = workOrderId
SITE = siteId
check_description = item.check_description
response_type = item.response_type
unit = item.unit
min_acceptable = item.min_acceptable
max_acceptable = item.max_acceptable
mandatory = item.mandatory
remarks = item.remarks
result = "Not applicable"
];
createdCount = createdCount + 1;
}
return createdCount;
}computeChecklistCompliance(workOrderId)
Owner-context standalone function. Returns the percentage (0-100, 2 decimals) of task_checklists rows for the work order that are marked done, where done means completed_on is filled. Returns 0 when the WO has no checklist rows. Called from the task_checklists On Success rollup and by the PPM compliance report.
// computeChecklistCompliance(long workOrderId)
// Returns percent of checklist rows completed for a work order (0 - 100).
return double
{
// Total rows attached to this WO (multi-select lookup -> .contains).
total = task_checklists[work_order.contains(workOrderId)].count();
if(total == 0)
{
return 0; // no checklist on this WO
}
// "Done" = the row has a completion timestamp (set on user input).
doneCount = task_checklists[work_order.contains(workOrderId) && completed_on != null].count();
// Multiply by 100.0 (not 100) so integer division does not floor to 0.
pct = (doneCount * 100.0) / total;
return pct.round(2);
}When a work order is saved with a Checklist Template attached, the checklist rows for that WO are built automatically. Idempotent, so editing/re-saving the WO never duplicates rows.
Action: Calls instantiateChecklist(input.ID, first checklist_template id). Copies each checklist_template_items line into a task_checklists row, stamping work_order and SITE.
Every time a technician answers/updates a checklist row, the parent work order's Completion Percent is recomputed. When compliance reaches 100 percent and no mandatory item is left open, the WO status is set to Completed and completed_on is stamped.
Action: Calls computeChecklistCompliance(work_order id), writes work_orders.completion_percent, and conditionally sets work_orders.status = Completed with completed_on = zoho.currenttime.
Nightly job that aggregates checklist compliance for reporting on Preventive/Predictive (PPM) work orders per site, feeding the PPM compliance dashboard/report.
Action: For each work_orders record where job_type is Preventive or Predictive and status is Completed/Verified in the period, read completion_percent (or recompute via computeChecklistCompliance) and aggregate by SITE and job_type. Owner-context schedule; watch the 90 schedule-runs/user/month cap - keep it to one daily run, not per-record.
Build a reusable template with items
- Admin opens Checklist Templates > Add. On Load defaults version=1, active=true, job_type=Preventive, frequency=Monthly, SITE from getSiteForUser.
- Admin fills template_code (e.g. AHU-M), template_name, asset_category, adjusts job_type/frequency, sets estimated_duration_minutes, permit_required, safety_notes. Saves.
- Admin opens Checklist Template Items > Add, selects the Template lookup. On Load defaults response_type=Yes-No, mandatory=true, SITE inherited from the template.
- For each check: fills check_description, response_type (Yes-No / Numeric reading / Pass/Fail / Text / Photo). For Numeric reading sets unit, min_acceptable, max_acceptable. Repeats for every line.
- Result: one template header with N line items, ready to attach to work orders.
Raise a PPM or Corrective WO with a checklist
- User creates a Work Order, sets job_type (Preventive/Predictive/Corrective), asset, and picks the Checklist Template lookup.
- On Save, work_orders On Success fires: input.checklist_template.size() > 0, so it reads the first template id.
- instantiateChecklist(input.ID, templateId) runs in owner context. Idempotency guard finds no existing rows.
- It reads the WO's SITE, fetches checklist_template_items via template.contains(templateId), and inserts one task_checklists row per line (work_order, SITE, check_description, response_type, unit, min/max_acceptable, mandatory, remarks, result=Not applicable).
- The technician now sees a WO-specific checklist under the work order; the master template is untouched.
Technician completes items and compliance is computed
- Technician opens each task_checklists row on the WO. For a Yes-No/Pass-Fail row they pick result; result On user input sets completed_on = zoho.currenttime.
- For a Numeric reading row they enter reading; reading On user input sets within_range from min/max, auto-sets result Pass or Fail, and stamps completed_on.
- On each row save, task_checklists On Success calls computeChecklistCompliance(woId) and writes work_orders.completion_percent.
- When the last mandatory row is answered (openMandatory count = 0) and pct = 100, the WO status flips to Completed with completed_on stamped.
- The nightly PPM compliance rollup reads completion_percent per site/job_type for the compliance report.
flowchart TD
A["Build checklist template"] --> B["Add template items"]
C["Create work order and pick template"] --> D["Work order On Success fires"]
B --> E["instantiateChecklist copies items"]
D --> E
E --> F["task_checklists rows created"]
F --> G["Technician opens the checklist"]
G --> H["Tech records result or reading"]
H --> I["On user input stamps completed on"]
I --> J["task_checklists On Success runs"]
J --> K["computeChecklistCompliance calculates percent"]
K --> L["Work order completion percent updated"]
L --> M{"All mandatory items done"}
M -->|Yes| N["Work order status set Completed"]
M -->|No| O["Work order stays In progress"]Sites & Facility Hierarchy
This module is the location backbone of the Jood FM CAFM app (app link name "cafm", owner "demo1redecorporativa2"). It defines a five-level tree — SITE, then Buildings, Floors, Zones and Rooms — that every Asset and Work Order points into across the six hospital sites. Each level carries its own SITE lookup so records stay scoped per site, and the downstream child lookups (building, floor, zone, room, asset) are all MULTI_SELECT_LOOKUP, which is the single biggest thing a developer must remember before writing any Deluge here: their values are lists, not single ids. Rooms, Assets and Work Orders each store a denormalised location_path text snapshot (for example "HAIL > Tower A > L3 > HVAC Zone > Room 312") that is produced by walking the tree upward. SITE is admin-only on most child forms, so for portal Supervisors it is never picked by hand — it is stamped automatically On Load from the existing getSiteForUser() owner function using zoho.loginuserid. This document gives the On Load / Create / Edit / Delete Deluge, the reusable path-builder functions, the cascade-filter and referential-integrity guidance, and the add-time data-flow scenarios.
Developer notes & pending
- Center already EXISTS on the SITE form (link name 'Center', SINGLE_LINE). Do not create a new field — the 'to be added' centre short-code is this field; just populate it.
- BIGGEST GOTCHA: building, floor, zone, room and asset lookups are MULTI_SELECT_LOOKUP. Their values are LISTS. Read the first with a for-each + break (WHILE is banned), and write them with a list. Only SITE is SINGLE_SELECT (a single id).
- SITE is admin-only (is_admin_only) on Buildings, Zones, Rooms, Assets and Work Orders. Portal Supervisors cannot see or choose it, so it MUST be stamped On Load from getSiteForUser(zoho.loginuserid), or records are created with no site. Floors' SITE is not admin-only (minor inconsistency — flag to client).
- Portal user email is read as zoho.loginuserid on this app (NOT zoho.loginuser) — the login id carries the email for portal logins. getSiteForUser(email) already exists and returns the Supervisor's SITE id, 0 if none.
- location_path is plain text (SINGLE_LINE) on rooms, assets and work_orders — it is a denormalised snapshot, not a live join. It will drift if the tree is renamed; the refresh workflow handles that.
- SITE.Address is a composite ADDRESS field (type 30). Subfield link names: address_line_1, address_line_2, district_city, state_province, postal_Code (note the capital C), country, and hidden latitude/longitude. Access as input.Address.address_line_1, etc.
- Cascade drill-down is best done with builder-level Lookup Filters (criteria: child.SITE == this form's SITE), not Deluge. Deluge On user input is only used to clear a stale child chip when SITE changes. Zones/Rooms do not carry a building field, so a strict building->floor->zone->room filter chain would need extra lookup fields added — raise with the client if required.
- A room links to zone via multi-select, a zone to floor via multi-select, a floor to building via multi-select. The path builder deliberately uses the FIRST parent at each level. If one-parent-per-child is the real intent, recommend converting these to single-select lookups to remove ambiguity.
- Creator cannot cancel a delete from Deluge (cancel only works in On Validate for Add/Edit). Enforce referential integrity via permissions (remove hard-delete from Supervisor profile) + the Active soft-delete flag + the On Delete cascade safety net.
- Work Orders form already has has_on_load=true and On user input on SITE. Add the Asset->location_path block; do not overwrite the existing SITE handling.
- Save the two functions (locationPathFromZone first, then buildLocationPath) as application functions before wiring the form scripts, since the scripts call them. Dates/times in any added logic use zoho.currentdate / zoho.currenttime.
- audit_log field link names used in the audit script are ASSUMED — verify the Audit Log form and adjust before enabling.
| Field | Type | Notes |
|---|---|---|
SITE.Site_Code | Text (SINGLE_LINE) | Human site code. Optional dedupe guard in SITE On Validate. |
SITE.Site_Name | Text (SINGLE_LINE) | Full site name, used as the readable fallback in location_path. |
SITE.Center | Text (SINGLE_LINE) | Short centre code. ALREADY EXISTS (do not recreate). Reused in Work Order numbering; auto-derived if blank. |
SITE.Address | Address composite (type 30) | Subfields: address_line_1, address_line_2, district_city, state_province, postal_Code (capital C), country, plus hidden latitude/longitude. Access as input.Address.address_line_1 etc. |
buildings.SITE | Lookup SINGLE_SELECT -> SITE | admin-only. One id. Stamp on load for supervisors. |
buildings.building_code / building_name | Text | Code + name of the building. |
buildings.site_or_campus | Text | Free-text label; auto-filled from SITE.Site_Name on user input. |
buildings.total_floors | Number | Planned floor count. |
buildings.gross_area_sqm | Decimal | Gross area. |
buildings.facility_manager | Lookup MULTI_SELECT -> technicians | Multi-select; read/write as a list. |
buildings.active | Decision box | Soft-delete / retire flag. |
floors.SITE | Lookup SINGLE_SELECT -> SITE | Not admin-only on this form. |
floors.building | Lookup MULTI_SELECT -> buildings | GOTCHA: multi-select. A floor can list more than one building; path uses the first. |
floors.floor_code / floor_name | Text | Code + name of the floor. |
floors.level_index | Number | Ordering index (e.g. -1 basement, 0 ground). |
floors.active | Decision box | Retire flag. |
zones.SITE | Lookup SINGLE_SELECT -> SITE | admin-only. |
zones.floor | Lookup MULTI_SELECT -> floors | GOTCHA: multi-select. Path uses the first floor. |
zones.zone_code / zone_name | Text | Code + name of the zone. |
zones.zone_type | Dropdown | Office, Retail, External, Plant room, Car park, Common area, Riser (allow_other_choice). |
rooms.SITE | Lookup SINGLE_SELECT -> SITE | admin-only. |
rooms.zone | Lookup MULTI_SELECT -> zones | GOTCHA: multi-select. Path uses the first zone. |
rooms.room_code / room_name / room_number | Text | room_name preferred for the path, room_number is the fallback. |
rooms.location_path | Text (SINGLE_LINE) | Denormalised snapshot built On Validate by walking up the tree. |
rooms.access_notes | Multi-line | Free access/entry notes. |
rooms.active | Decision box | Retire flag; default true on create. |
assets.room | Lookup MULTI_SELECT -> rooms | Where the asset physically sits. First room drives its path. |
assets.location_path | Text (SINGLE_LINE) | Filled from buildLocationPath(roomId) on user input of Room. |
work_orders.asset | Lookup MULTI_SELECT -> assets | WO copies the asset's location_path. |
work_orders.floors | Lookup SINGLE_SELECT -> floors | Existing separate floor pointer on the WO form. |
work_orders.location_path | Text (SINGLE_LINE) | Inherited from the chosen asset. Form already has On Load + On user input on SITE. |
On Load — Buildings / Floors / Zones / Rooms (Add view)
Attach identically to all four child forms. getSiteForUser(email) already exists in owner context — do not rewrite it. This is why SITE being admin-only is safe: the code fills it.
// WHEN: form opens for a NEW record on any hierarchy child form.
// WHY: SITE is an admin-only lookup, so portal Supervisors never see or pick it.
// It must be stamped from their assigned site or the record is orphaned.
email = zoho.loginuserid; // portal logins expose the EMAIL here (NOT zoho.loginuser)
sid = getSiteForUser(email); // existing owner-context function, returns SITE id or 0
if(sid != 0)
{
input.SITE = sid; // single-select lookup takes ONE record id
disable SITE; // keep supervisors from changing it
}
// Admins (sid == 0) keep the SITE field open to choose any of the 6 sites.On Validate — SITE (Add & Edit)
Center field link name is exactly 'Center' and already exists on the SITE form — do not add a new field. cancel is the reserved On Validate variable that stops submission.
// WHEN: a site is added or edited. WHY: Center is a short code reused in Work Order
// numbering, so it must never be blank; also warn on a duplicate Site Code.
if(input.Center == null || input.Center.trim() == "")
{
base = input.Site_Code;
if(base == null || base.trim() == "")
{
base = input.Site_Name;
}
clean = base.replaceAll(" ","").toUpperCase();
if(clean.length() >= 4)
{
input.Center = clean.subString(0,4); // e.g. "Hail Hospital" -> "HAIL"
}
else
{
input.Center = clean;
}
}
// Optional duplicate guard (comment out during bulk import)
if(input.Site_Code != null && input.Site_Code != "")
{
dupes = SITE[Site_Code == input.Site_Code];
if(dupes.count() > 1)
{
alert "Site Code " + input.Site_Code + " already exists.";
cancel = true; // 'cancel = true' aborts the save in an On Validate event
}
}On user input — SITE (Buildings form)
input.SITE is a single id here (single-select). site_or_campus is a plain text field, not a lookup.
// WHEN: the SITE lookup changes on the Buildings form.
// WHY: mirror the readable site name into the free-text "Site or Campus" label.
if(input.SITE != null)
{
sRec = SITE[ID == input.SITE];
if(sRec.count() > 0)
{
input.site_or_campus = sRec.Site_Name;
}
}On user input — SITE cascade reset (Floors / Zones / Rooms)
Preferred cascade = builder Lookup Filter on each child lookup, criteria SITE == this form's SITE: Floors.building filtered to buildings of SITE; Zones.floor filtered to floors of SITE; Rooms.zone filtered to zones of SITE. Deeper building->floor->zone->room chaining needs the child form to also carry the grandparent field, which it currently does not — flag to client if strict drill-down is wanted.
// WHEN: the SITE lookup changes. WHY: the parent picker is narrowed by a builder-level
// LOOKUP FILTER (see notes); this Deluge only clears a now-invalid child chip so
// a stale selection from the previous site cannot be saved.
// --- Floors form (clears the Building selection) ---
input.building = null; // multi-select; null removes all chips
// --- Zones form (put this line on the Zones form instead) ---
// input.floor = null;
// --- Rooms form (put this line on the Rooms form instead) ---
// input.zone = null;On Validate — Rooms (Add & Edit) build location_path
On Add the record has no ID yet, so we build from input.* rather than calling buildLocationPath(ID). locationPathFromZone must be saved as an app function first.
// WHEN: a room is added or edited. WHY: stamp the denormalised location_path used by
// list views, assets and work orders. Uses the shared helper so logic stays in one place.
firstZone = null;
for each z in input.zone // rooms.zone is multi-select, take the first
{
firstZone = z;
break;
}
roomLabel = input.room_name;
if(roomLabel == null || roomLabel.trim() == "")
{
roomLabel = input.room_number;
}
if(firstZone != null)
{
input.location_path = locationPathFromZone(firstZone.toLong(), roomLabel);
}
else
{
input.location_path = roomLabel; // no zone yet -> at least show the room label
}
// Default a brand-new room to Active.
if(input.active == null)
{
input.active = true;
}On Success — Rooms Edited (path refresh to assets)
assets[room == input.ID] works because a multi-select lookup query matches any record that contains the id. Assigning a.location_path inside the for-each updates that record.
// WHEN: an existing room is renamed or moved (Edit success). WHY: the path snapshot on its
// assets is now stale — push the fresh path down. Keep this light; move to a scheduled
// function if a single room ever holds hundreds of assets.
freshPath = buildLocationPath(input.ID);
linkedAssets = assets[room == input.ID]; // '==' on a multi-select lookup matches "contains"
for each a in linkedAssets
{
a.location_path = freshPath; // updates the asset row in place
}On Success — Rooms Created / Edited (audit)
audit_log field link names are assumed (entity, record_id, action, by_user, at). Confirm against the Audit Log form before enabling; remove any field that does not exist.
// WHEN: a room is saved. WHY: light, non-blocking audit trail. Attach to BOTH the
// Created and Edited success actions. Field names below are ASSUMED — verify audit_log.
insert into audit_log
[
entity = "Room"
record_id = input.ID
action = "Room saved"
by_user = zoho.loginuserid
at = zoho.currenttime
];On user input — Room (Assets form)
This is where buildLocationPath earns its keep. SITE is admin-only on Assets too, so back-filling it from the room keeps supervisor-created assets scoped correctly.
// WHEN: the Room lookup changes on an Asset. WHY: auto-fill location_path and back-fill SITE.
firstRoom = null;
for each r in input.room // assets.room is multi-select
{
firstRoom = r;
break;
}
if(firstRoom != null)
{
input.location_path = buildLocationPath(firstRoom.toLong());
if(input.SITE == null)
{
rmRec = rooms[ID == firstRoom.toLong()];
if(rmRec.count() > 0)
{
input.SITE = rmRec.SITE; // single-select copy from the room
}
}
}On user input — Asset (Work Orders form)
work_orders metadata shows has_on_load=true and has_on_user_input on SITE. Coordinate with those existing scripts.
// WHEN: the Asset lookup changes on a Work Order. WHY: a WO inherits the location_path
// already stamped on its asset (cheaper than re-walking the tree).
firstAsset = null;
for each a in input.asset // work_orders.asset is multi-select
{
firstAsset = a;
break;
}
if(firstAsset != null)
{
aRec = assets[ID == firstAsset.toLong()];
if(aRec.count() > 0)
{
input.location_path = aRec.location_path;
if(input.SITE == null)
{
input.SITE = aRec.SITE;
}
}
}
// NOTE: this form ALREADY has On Load + On user input on SITE — ADD this block,
// do not overwrite the existing SITE logic.On Delete — Buildings / Floors / Zones (referential integrity)
There is no 'cancel' in an On Delete event. Real protection = remove hard-delete from the Supervisor profile and retire via the Active box instead; this script is the safety net if a hard delete slips through.
// WHEN: a parent level is deleted. WHY: Creator CANNOT block a delete from Deluge, so we
// protect the tree by cascade-retiring orphaned children (and by permissions, see notes).
// Example on the BUILDINGS delete event — retire floors that pointed at it:
orphanFloors = floors[building == input.ID]; // multi-select match = "contains this building"
for each f in orphanFloors
{
f.active = false; // soft-retire so they drop out of pickers
}
// Repeat the same shape on Floors delete (zones[floor == input.ID])
// and Zones delete (rooms[zone == input.ID]).locationPathFromZone
Shared helper. Given a saved Zone id (and an optional room label) it walks Zone -> Floor -> Building -> Site and returns a readable path such as "HAIL > Tower A > L3 > HVAC Zone > Room 312". Handles the multi-select lookups by taking the first linked parent at each level and skips any blank segment. Called by both buildLocationPath() and the Rooms On Validate script so the path logic lives in one place.
string locationPathFromZone(long zoneId, string roomLabel)
{
// Builds "Center > Building > Floor > Zone > Room" from a saved Zone id.
// roomLabel is appended only when it is not blank.
segs = List();
zRec = zones[ID == zoneId];
if(zRec.count() == 0)
{
return "";
}
// ---- Site segment: prefer the short Center code, else the Site Name ----
if(zRec.SITE != null)
{
sRec = SITE[ID == zRec.SITE];
if(sRec.count() > 0)
{
siteLabel = sRec.Site_Name;
if(sRec.Center != null && sRec.Center.trim() != "")
{
siteLabel = sRec.Center;
}
if(siteLabel != null && siteLabel.trim() != "")
{
segs.add(siteLabel);
}
}
}
// ---- Building + Floor: zones.floor is multi-select, take the FIRST ----
firstFloor = null;
for each f in zRec.floor
{
firstFloor = f;
break; // WHILE is banned; first element only
}
if(firstFloor != null)
{
fRec = floors[ID == firstFloor.toLong()];
if(fRec.count() > 0)
{
firstBuilding = null;
for each b in fRec.building // floors.building is multi-select
{
firstBuilding = b;
break;
}
if(firstBuilding != null)
{
bRec = buildings[ID == firstBuilding.toLong()];
if(bRec.count() > 0 && bRec.building_name != null && bRec.building_name != "")
{
segs.add(bRec.building_name);
}
}
if(fRec.floor_name != null && fRec.floor_name != "")
{
segs.add(fRec.floor_name);
}
}
}
// ---- Zone segment ----
if(zRec.zone_name != null && zRec.zone_name != "")
{
segs.add(zRec.zone_name);
}
// ---- Room segment ----
if(roomLabel != null && roomLabel.trim() != "")
{
segs.add(roomLabel);
}
return segs.toString(" > ");
}buildLocationPath
Primary entry point required by the module. Given a saved Room id it resolves the room, takes its first linked zone and delegates to locationPathFromZone() to return the full readable path. Returns an empty string if the room or its zone cannot be resolved. Assets and Work Orders call this to stamp their own location_path.
string buildLocationPath(long roomId)
{
// Walks Room -> Zone -> Floor -> Building -> Site and returns a readable path.
// Returns "" when the room or its zone cannot be resolved.
roomRec = rooms[ID == roomId];
if(roomRec.count() == 0)
{
return "";
}
// rooms.zone is a multi-select lookup: use the first linked zone.
firstZone = null;
for each z in roomRec.zone
{
firstZone = z;
break;
}
if(firstZone == null)
{
return "";
}
// Room label prefers Room Name, falls back to Room Number.
roomLabel = roomRec.room_name;
if(roomLabel == null || roomLabel.trim() == "")
{
roomLabel = roomRec.room_number;
}
return locationPathFromZone(firstZone.toLong(), roomLabel);
}Deletes cannot be cancelled from Deluge in Creator, so integrity is enforced by policy plus a safety net. Policy: strip the hard-delete permission from the Supervisor profile and retire records with the Active decision box instead of deleting them.
Action: If a hard delete still occurs, the On Delete script cascade-sets active = false on the immediate children (floors of the building / zones of the floor / rooms of the zone) so orphans disappear from lookups and reports.
location_path is a denormalised text snapshot, so renaming a Building/Floor/Zone/Room does not automatically update the paths already stored on rooms, assets and work orders.
Action: Room edits push buildLocationPath() down to their linked assets in real time (light). For edits high in the tree (building/floor/zone rename affecting many rooms), run a scheduled function nightly that re-stamps location_path on affected rooms and assets rather than doing it inline.
Retiring a parent should visually retire everything beneath it so stale locations stop appearing in pickers.
Action: When a Building/Floor/Zone is set inactive, set active = false on its descendant floors/zones/rooms (same multi-select query pattern as the delete safety net). Keep it optional per client preference.
Scenario 1 — Define a hospital hierarchy top-down
- Admin opens the SITE form and adds the hospital: Site_Code, Site_Name, and the Center short code (auto-derived to e.g. HAIL if left blank by On Validate). Fills the Address composite.
- Admin (or a Supervisor scoped to that site) opens Buildings; On Load stamps SITE for the supervisor and locks it. They enter building_code, building_name, total_floors; site_or_campus auto-fills from the site name.
- Opens Floors; the Building picker is filtered to that site's buildings (builder Lookup Filter). Picks the building, enters floor_code, floor_name, level_index. Changing SITE clears the stale building chip.
- Opens Zones; picks the floor (filtered by SITE), enters zone_code, zone_name and zone_type (Office / Plant room / Riser ...).
- Opens Rooms; picks the zone (filtered by SITE), enters room_code, room_name, room_number. On Validate walks zone -> floor -> building -> site and writes location_path, and defaults active = true. The five-level tree now exists and every level is SITE-scoped.
Scenario 2 — Place an asset in a room and auto-build its path
- Technician/Supervisor opens the Assets form; On Load stamps and hides SITE (admin-only) via getSiteForUser(zoho.loginuserid).
- They enter asset_id, asset_name, asset_category, then choose the Room from the multi-select Room lookup.
- On user input of Room fires: the script takes the first selected room and calls buildLocationPath(roomId).
- buildLocationPath resolves the room, takes its first zone, and hands off to locationPathFromZone, which walks Zone -> Floor -> Building -> Site and returns a string like "HAIL > Tower A > L3 > HVAC Zone > Room 312".
- input.location_path is filled and SITE is back-filled from the room when it was empty. On save the asset is fully located.
- Later, raising a Work Order and selecting that asset copies the asset's location_path straight onto the work order, so the crew sees the readable location without re-walking the tree.
flowchart TD
A["Open Add form"] --> B["Get portal email via loginuserid"]
B --> C["getSiteForUser returns SITE id"]
C --> D{"SITE id greater than zero"}
D -->|"Yes supervisor"| E["Prefill and lock SITE"]
D -->|"No admin"| F["User selects SITE"]
E --> G["Select parent level lookup"]
F --> G
G --> H["Enter code name and details"]
H --> I["On Validate builds location path"]
I --> J["Walk zone to floor to building to site"]
J --> K["Store path text then save"]
K --> L["Asset picks Room then buildLocationPath"]
L --> M["Work Order copies Asset path"]Access, Roles & Portal
This module governs who can open the CAFM forms and which hospital SITE each user's records are tied to. Jood FM runs across 6 hospital sites; hospital-side staff sign in through the Zoho Creator portal as Supervisors, and each Supervisor record locks one Email to exactly one SITE. Admins are either internal Creator app users or admin-tier portal users, and both are allowed to work across all sites. The plumbing is a single owner-context helper, getSiteForUser, plus a small On Load block that every supervisor-accessible form reuses to detect the role and, for Supervisors, silently stamp and hide the SITE lookup. Because portal Supervisors cannot themselves read the Supervisor form, the lookup MUST be resolved in owner context. This layer scopes new and edited records only; it is not by itself a read-security boundary, so report filters and record permissions still apply on top.
Developer notes & pending
- Field link names are verified from live form metadata (form Supervisor): Email, SITE, Type_field, Name (subfields first_name/last_name/prefix/suffix), Phone_Number. Do not invent alternatives.
- CRITICAL: read the portal user's email with zoho.loginuserid, NOT zoho.loginuser. On this app portal logins expose the email in loginuserid; loginuser holds a handle for portal users and a full email for app users.
- getSiteForUser MUST be a standalone function set to run in Application Owner context. Supervisors are portal users with no read permission on the Supervisor form, so an inline query in their own context would return nothing. Owner context is what makes the resolve work.
- No WHILE loops are allowed in Creator Deluge. The helper uses a bounded 'for each ... in Supervisor[Email == cleanEmail]' with an explicit break; the first match wins.
- Lookup fields are set by the referenced record id: input.SITE = sid where sid is the SITE record id (a numeric id). getSiteForUser returns 0 when there is no Supervisor row.
- The three-role detection uses a no-@ heuristic (zoho.loginuser.contains("@")) to separate admin portal users from admin app users. This is a heuristic: if any admin portal username ever contains an @, it will misclassify. If the app later exposes an explicit admin role/flag, prefer that over the heuristic.
- On Add: overwrite input.SITE for supervisors. On Edit: never overwrite an existing SITE (the record already belongs to a hospital) -- only hide the picker; the null check is a safety net for legacy rows.
- Text criteria in Creator queries are generally case-insensitive, but the lowercase+trim in getSiteForUser removes any doubt. Keep it.
- This module has NO create/edit/delete record-mutation scripts and NO workflows of its own -- it is access-control plumbing that gets pasted onto the On Load events of other supervisor-accessible forms. Delete events are not part of this module.
- On Load scoping is not a security boundary by itself. To stop a supervisor from SEEING other hospitals' records, apply report filters / record-level permissions (criteria such as SITE == getSiteForUser(zoho.loginuserid)) on every list/report they can open.
- Reuse: paste the Add snippet on the On Load (Add) of every supervisor-accessible form and the Edit variant on the On Load (Edit) of the same forms. Keep one canonical copy in this doc so juniors paste identical code everywhere.
| Field | Type | Notes |
|---|---|---|
Email | Email (text, max 80 chars) | Supervisor login email. Matched against zoho.loginuserid after trim + lowercase inside getSiteForUser. Store Supervisor emails lowercased to guarantee the match. |
SITE | Lookup -> SITE form (single select) | The single hospital this supervisor is locked to. This is the record id returned by getSiteForUser and written to input.SITE on other forms. ref_form = SITE, same app (cafm). |
Type_field | Dropdown | Values: JOOD Supervisor or UNM Supervisor. Supervisor sub-type only; NOT used for site scoping in this module. |
Name | Name (composite) | Supervisor display name. prefix and suffix subfields are hidden on the layout; first_name / last_name shown. |
Phone_Number | Phone | Contact number, informational only. |
On Load — Add / Create — reusable SITE auto-fill (minimal)
Drop this on the On Load (Add) event of EVERY supervisor-accessible form. Minimal version: stamp + hide for supervisors, leave the picker for admins. Use this when the form does not need the explicit role name.
// ================================================================
// REUSABLE -- paste on the On Load (Add / Create) event of EVERY
// supervisor-accessible form. Minimal version: auto-fill + hide.
// ================================================================
// Portal AND app logins expose the real email on zoho.loginuserid.
// Do NOT use zoho.loginuser here -- on this app portals do not put the email there.
loginEmail = zoho.loginuserid;
// Resolve the hospital via the owner-context helper (0 = not a supervisor).
sid = getSiteForUser(loginEmail);
if(sid != 0)
{
// Supervisor: stamp their hospital and remove the picker entirely.
input.SITE = sid; // set the lookup by SITE record id
hide SITE;
}
else
{
// Admin: let them choose the hospital.
show SITE;
}On Load — Add / Create — full three-role detection
The full version. Resolves SUPERVISOR / ADMIN_PORTAL / ADMIN_APP so downstream field logic can branch on userRole. The no-@ heuristic on zoho.loginuser separates admin portal users (handle, no @) from admin app users (full email). Remove the info line before go-live.
// ================================================================
// FULL ROLE DETECTION -- On Load (Add / Create).
// Resolves one of three roles and scopes SITE accordingly.
// ================================================================
loginEmail = zoho.loginuserid; // real email for portal + app users
loginName = zoho.loginuser; // app user = full email ; portal user = handle, usually no "@"
userRole = "";
sid = getSiteForUser(loginEmail);
if(sid != 0)
{
// 1) SUPERVISOR -- hospital-side portal user locked to one SITE.
userRole = "SUPERVISOR";
input.SITE = sid;
hide SITE;
}
else if(!loginName.contains("@"))
{
// 2) ADMIN PORTAL USER -- portal login, no email-style handle, not a Supervisor.
userRole = "ADMIN_PORTAL";
show SITE; // must pick the hospital manually
}
else
{
// 3) ADMIN APP USER -- internal Creator user with a full email -> works across all sites.
userRole = "ADMIN_APP";
show SITE;
}
// Handy while testing the handover; remove once verified.
info "Resolved role: " + userRole;On Load — Edit — SITE lock variant
Paste on the On Load (Edit) event of the same supervisor-accessible forms. On edit the record already HAS a SITE, so never overwrite it -- only enforce visibility. The null check is a safety net for legacy rows saved before scoping existed.
// ================================================================
// EDIT VARIANT -- paste on the On Load (Edit) event of the same
// supervisor-accessible forms. The record already HAS a SITE, so
// never overwrite it -- only enforce visibility by role.
// ================================================================
loginEmail = zoho.loginuserid;
sid = getSiteForUser(loginEmail);
if(sid != 0)
{
// Supervisor: keep the picker hidden so they cannot move a record
// to another hospital.
hide SITE;
// Safety net for legacy rows that were saved without a site.
if(input.SITE == null)
{
input.SITE = sid;
}
}
else
{
// Admin: SITE stays visible / editable.
show SITE;
}getSiteForUser
Owner-context standalone function. Given a login email, returns the SITE record id the matching Supervisor is locked to, or 0 when the email belongs to no Supervisor (i.e. an admin). Must run in Application Owner context so portal Supervisors, who have no read access to the Supervisor form, still resolve to their hospital.
// getSiteForUser -- STANDALONE function, set to run in Application Owner context.
// Given a login email, returns the SITE record id the Supervisor is locked to, or 0 if none.
// Owner context is required so portal Supervisors -- who have no read access to the
// Supervisor form themselves -- can still be resolved to their hospital.
int getSiteForUser(string userEmail)
{
// Normalise: portal logins can arrive with stray case or spaces.
cleanEmail = userEmail.trim().toLowerCase();
// Guard empty / null input.
if(cleanEmail == null || cleanEmail == "")
{
return 0;
}
// First matching Supervisor wins. No WHILE loops in Creator, so a bounded
// for-each with an explicit break is used instead.
for each sup in Supervisor[Email == cleanEmail]
{
if(sup.SITE != null)
{
return sup.SITE; // lookup yields the referenced SITE record id
}
break; // matched a Supervisor but no site set -> unscoped
}
// No Supervisor row for this email -> caller treats as admin / unscoped.
return 0;
}Supervisor logs in via portal and is scoped to their hospital
- Supervisor opens the CAFM portal URL and signs in with their hospital email.
- They open a supervisor-accessible form (Add mode); the On Load script fires.
- loginEmail = zoho.loginuserid returns their real email even though this is a portal session.
- getSiteForUser(loginEmail) runs in Application Owner context, finds the Supervisor row, and returns that Supervisor's SITE id (one of the 6 hospitals).
- sid != 0 so userRole = SUPERVISOR: input.SITE is set to sid and the SITE picker is hidden.
- The supervisor fills the rest of the form and submits; the record is saved already stamped with their hospital, with no way to pick another site.
Admin app user sees all sites
- An internal Creator user opens the app directly (not the portal) and opens the same form in Add mode.
- On Load runs: loginEmail = zoho.loginuserid = their full email.
- getSiteForUser returns 0 because there is no Supervisor row for that email.
- loginName = zoho.loginuser contains "@", so userRole = ADMIN_APP.
- The SITE picker stays visible and enabled; the admin can select any of the 6 hospitals.
- The admin submits with the site they chose.
Admin portal user picks a site
- An admin-tier portal user signs into the CAFM portal and opens the form in Add mode.
- On Load runs: getSiteForUser returns 0 (this email is not a Supervisor).
- loginName = zoho.loginuser has no "@" (portal handle), so userRole = ADMIN_PORTAL.
- The SITE picker is shown; the admin portal user manually selects the hospital.
- The record is saved with the chosen site, giving admin portal users the same cross-site reach as app admins.
flowchart TD
A["User opens a supervisor accessible form"] --> B["On Load reads zoho.loginuserid as loginEmail"]
B --> C["sid equals getSiteForUser of loginEmail"]
C --> D{"sid not zero"}
D -->|Yes| E["Role SUPERVISOR"]
E --> F["Set input.SITE to sid and hide SITE"]
F --> K["Record scoped to one hospital"]
D -->|No| G{"zoho.loginuser has no at sign"}
G -->|Yes| H["Role ADMIN PORTAL USER"]
G -->|No| I["Role ADMIN APP USER"]
H --> J["Show SITE picker to choose hospital"]
I --> J
J --> L["Record can target any hospital"]Master Data (Vendors, Technicians, Categories, Tools)
This module is the Master Data backbone of the Jood FM CAFM app: the Vendors, Technicians, Asset Categories and Tools forms that every transaction (work orders, PPM, spare parts, assets) looks up. Every master is scoped to a SITE lookup that is stamped automatically from the logged-in user via getSiteForUser(zoho.loginuserid), so a supervisor only ever files against their own hospital site while admins choose the site manually. Vendor and tool records receive a system-generated code built from the site's Site_Code plus a running sequence (genVendorCode / genToolCode); technicians use an HR-issued employee_code and asset categories are name-only. On Load defaults each record to Active, On Validate enforces per-site uniqueness of the code or name, and a daily schedule emails vendors before their contract lapses. Every field link name in this doc was pulled live from the form metadata on 2026-09-18 — build against these exact names and never invent new ones.
Developer notes & pending
- All field link names were verified live via getFormMetadata on 2026-09-18 (account demo1redecorporativa2, app cafm). Build against these exact names.
- asset_categories has ONLY SITE + category_name + active. There is NO 'prefix' field. The brief asked for name + prefix, but the live form cannot store a prefix. If category codes/prefixes are needed, add a 'prefix' field first, then extend the On Load script.
- Spec-vs-reality field-type corrections: vendors.vendor_type is a MULTI-SELECT list (not a single dropdown); vendors.documents_folder is a URL field; Tools.unit and Tools.condition_1 are dropdowns; the Condition field's link name is 'condition_1' (not 'condition'); Tools has NO 'active' field.
- SITE center-code prefix = SITE.Site_Code (assumption). SITE also has a separate 'Center' text field (facility name) and 'Site_Name'. Confirm with the client that Site_Code is the intended code prefix before go-live.
- The SITE lookup is admin-only on technicians, asset_categories and Tools, but NOT on vendors. Portal supervisors will not even see SITE on the first three; On Load still stamps input.SITE for them. On vendors we additionally 'disable SITE' so it is visible-but-locked.
- Portal logins expose the email in zoho.loginuserid, NOT zoho.loginuser. Every On Load uses zoho.loginuserid.
- getSiteForUser(email) is an existing owner-context function that returns the supervisor's SITE record id as an int, or 0 when none. Treat 0 as 'admin: let them pick SITE'.
- WHILE loops are not permitted. All iteration uses for-each with a bounded break; zero-padding is done with subString, not a loop.
- Function calls are written as genVendorCode(siteId) / genToolCode(siteId). If your org requires a namespace prefix (e.g. thisapp.genVendorCode), add it consistently across every call site.
- On Delete scripts and the technician downstream-reassignment block use placeholder tokens <WORK_ORDER_FORM>, <vendor_lookup>, <assigned_technician>. These are the ONLY invented tokens in this doc and are clearly commented. Fill them from the transactional-module handover; do not guess.
- Transactional forms in the flowchart (Work Orders, PPM Schedules, Spare Parts, Assets) are conceptual. Confirm their real link names from the transactions module doc.
- Uniqueness is enforced per-SITE (SITE + code/name), not globally, so two different hospitals may reuse a sequence number. To force globally-unique codes, drop the SITE criterion in the On Validate blocks.
- Metadata was read from the production form definitions. Per project setup the dev environment is the working copy: replicate and test these scripts in dev first, then publish.
- Suggested handover UI layout: an Overview tab, then one tab per master (Vendors, Technicians, Asset Categories, Tools), each with inner sub-tabs - Fields, Functions, On Load, Create/Edit/Delete, On Validate, Workflows.
- Tools cannot be soft-deleted (no active field). Retire a tool by setting condition_1 to Lost or Damaged, or hard-delete via the guarded On Delete.
| Field | Type | Notes |
|---|---|---|
SITE.Site_Code | Single line | The center code. Used as the prefix in genVendorCode / genToolCode. |
SITE.Site_Name | Single line | Human site name shown in the contract-expiry reminder email. |
SITE.Center | Single line | Separate facility/center name. Do NOT confuse with Site_Code. |
SITE.Address | Address | Composite address with geo-coordinate capture. |
vendors.SITE | Lookup to SITE | Set by referenced record id. NOT admin-only here, so visible to all; locked via disable for supervisors. |
vendors.vendor_code | Single line | Auto-generated, system-owned, unique within the site. Disabled on the form. |
vendors.vendor_name | Single line | Vendor display name. |
vendors.vendor_type | Multi-select list | Choices: Parts supplier, OEM, Consultant, Service contractor. Brief said dropdown; it is MULTI-select. |
vendors.contact_person | Single line | Named in the reminder email greeting. |
vendors.email | Recipient of the contract-expiry reminder. | |
vendors.phone | Phone | Contact number. |
vendors.contract_reference | Single line | Contract/PO reference quoted in the reminder. |
vendors.contract_start | Date | Contract start date. |
vendors.contract_expiry | Date | Drives the daily reminder schedule (30/15/7/1 day marks). |
vendors.response_sla_hours | Number | Integer, 0 decimals. |
vendors.documents_folder | URL | WorkDrive/link field, NOT plain text. Paste the folder link. |
vendors.active | Decision box | Field default is unchecked; On Load sets it true. |
technicians.SITE | Lookup to SITE | admin-only = hidden from portal supervisors; On Load still stamps input.SITE. |
technicians.employee_code | Single line | HR-issued, typed in manually (NOT auto-generated). Unique within the site. |
technicians.full_name | Single line | Technician full name. |
technicians.zoho_user | Single line | Their Zoho/portal login. |
technicians.role | Dropdown | Choices: Supervisor, Administrator, Technician, Storekeeper (allows other). |
technicians.discipline | Multi-select list | Choices: Plumbing, HVAC, Civil, Vertical transport, BMS, Electrical, Fire and life safety. |
technicians.phone | Phone | Contact number. |
technicians.email | Contact email. | |
technicians.shift | Dropdown | Choices: Night, Rotating, Day. |
technicians.active | Decision box | Default unchecked; On Load sets true. Drives assignment filtering. |
asset_categories.SITE | Lookup to SITE | admin-only. |
asset_categories.category_name | Single line | Unique within the site. |
asset_categories.active | Decision box | Default unchecked; On Load sets true. NOTE: this form has NO prefix field. |
Tools.SITE | Lookup to SITE | admin-only. Form link name is 'Tools' with a capital T. |
Tools.tool_code | Single line | Auto-generated, unique within the site. Disabled on the form. |
Tools.tool_name | Single line | Tool display name. |
Tools.tool_category | Dropdown | Choices: HVAC, Electrical, Plumbing, Civil (Carpenter), Safety Tools. |
Tools.make | Single line | Manufacturer/make. |
Tools.quantity | Number | Integer. On Load defaults to 1. |
Tools.unit | Dropdown | Choices: Each, Set, Pair, Box. |
Tools.condition_1 | Dropdown | Link name is condition_1 (NOT condition). Choices: Good, Needs repair, Damaged, Lost. |
Tools.store_location | Single line | Where the tool is stored. |
Vendors — On Load (Create)
Stamps + locks SITE for supervisors, defaults Active, pre-builds the vendor code. Admins with no site (0) pick SITE manually and the code fills on the SITE input event.
// Portal logins expose the user email in zoho.loginuserid (NOT zoho.loginuser)
email = zoho.loginuserid;
// Owner-context helper -> supervisor's SITE record id, or 0 for an admin with no site
siteId = getSiteForUser(email);
if(siteId != 0)
{
input.SITE = siteId; // a lookup is assigned by the referenced record id
disable SITE; // supervisors are pinned to their own site
}
// New masters open as Active (the field default is unchecked)
input.active = true;
// Pre-build the code the moment we already know the site
if(siteId != 0)
{
input.vendor_code = genVendorCode(siteId);
}
// The code is system-owned, never hand typed
disable vendor_code;Vendors — On User Input of SITE
Field action so an admin who picks/changes SITE gets a matching code. input.SITE holds the chosen record id.
// Fires when SITE is picked or changed on the form
if(input.SITE != null)
{
input.vendor_code = genVendorCode(input.SITE);
}Vendors — On Validate (Create and Edit)
Per-site uniqueness of vendor_code. Uses for-each + break (no while). Excludes the record's own row on edit.
// Guarantee the code is unique WITHIN the site (not globally)
if(input.vendor_code != null && input.vendor_code != "")
{
matches = vendors[SITE == input.SITE && vendor_code == input.vendor_code];
isDup = false;
if(input.ID == null)
{
// Create : any existing match is a clash
if(matches.count() > 0)
{
isDup = true;
}
}
else
{
// Edit : a match on a DIFFERENT record is a clash (skip our own row)
for each m in matches
{
if(m.ID != input.ID)
{
isDup = true;
break;
}
}
}
if(isDup)
{
alert "Vendor code " + input.vendor_code + " already exists for this site.";
cancel = true; // On Validate : cancel = true stops the save
}
}Vendors — On Success (Create)
Belt-and-braces: backfills the code for records created via import or API that arrived without one. On Success runs after save, so fetch the row by ID and set the field.
// Backfill the code if a record was created without one (import / API)
if((input.vendor_code == null || input.vendor_code == "") && input.SITE != null)
{
newCode = genVendorCode(input.SITE);
if(newCode != "")
{
rec = vendors[ID == input.ID];
rec.vendor_code = newCode;
}
}Vendors — On Success (Edit)
Keeps the code immutable: if someone blanked it, rebuild from the site so downstream transactions never lose the link.
// Never let an edited vendor lose its code
if((input.vendor_code == null || input.vendor_code == "") && input.SITE != null)
{
rec = vendors[ID == input.ID];
rec.vendor_code = genVendorCode(input.SITE);
}Vendors — On Delete (TEMPLATE)
Prefer soft-delete (untick Active). Hard-delete guard is a TEMPLATE: replace the placeholder transactional form + lookup once the Work Orders / Spare Parts module is handed over. Left commented so it is a safe no-op until then.
// PREFER soft-delete : untick 'active' instead of deleting so historical
// work orders keep a valid vendor link. Hard-delete guard below.
//
// Replace <WORK_ORDER_FORM> and <vendor_lookup> with the REAL names from the
// transactional module handover, then uncomment.
//
// refCount = <WORK_ORDER_FORM>[<vendor_lookup> == input.ID].count();
// if(refCount > 0)
// {
// alert "Cannot delete : this vendor is used on " + refCount + " transactions. Untick Active instead.";
// cancel = true;
// }Technicians — On Load (Create)
Stamps SITE (admin-only, so supervisors do not see it) and defaults Active. employee_code is HR-issued and typed in — not generated.
email = zoho.loginuserid;
siteId = getSiteForUser(email);
if(siteId != 0)
{
input.SITE = siteId; // admin-only lookup - stamped even though supervisors do not see it
disable SITE;
}
input.active = true; // new technicians are Active by default
// employee_code is issued by HR and typed in - it is NOT auto-generated.Technicians — On Validate (Create and Edit)
Per-site uniqueness of the HR employee_code.
if(input.employee_code != null && input.employee_code != "")
{
matches = technicians[SITE == input.SITE && employee_code == input.employee_code];
isDup = false;
if(input.ID == null)
{
if(matches.count() > 0)
{
isDup = true;
}
}
else
{
for each m in matches
{
if(m.ID != input.ID)
{
isDup = true;
break;
}
}
}
if(isDup)
{
alert "Employee code " + input.employee_code + " already exists for this site.";
cancel = true;
}
}Technicians — On Success (Create and Edit): active/inactive handling
The REAL enforcement is a filtered assignee lookup at the transaction form (technicians where active is true and SITE matches). This block handles the record itself + flags downstream. The reassignment lines are a TEMPLATE with placeholder names.
// When a technician is deactivated they must drop out of assignment
if(input.active == false)
{
// Reassign their OPEN work so nothing is stuck on an inactive tech.
// Replace <WORK_ORDER_FORM> and <assigned_technician> with the real names.
// openWos = <WORK_ORDER_FORM>[assigned_technician == input.ID && status != "Closed"];
// for each wo in openWos { wo.assigned_technician = ""; }
info "Technician " + input.full_name + " set INACTIVE - open work should be reassigned.";
}
else
{
info "Technician " + input.full_name + " is ACTIVE.";
}Tools — On Load (Create)
Stamps SITE, sets sensible defaults (quantity 1, condition Good), and pre-builds the tool code. NOTE: Tools has no 'active' field, so do not set one.
email = zoho.loginuserid;
siteId = getSiteForUser(email);
if(siteId != 0)
{
input.SITE = siteId; // admin-only lookup
disable SITE;
}
// Tools has NO 'active' field - do not set one.
input.quantity = 1; // sensible default
input.condition_1 = "Good";// valid choice from the Condition dropdown
if(siteId != 0)
{
input.tool_code = genToolCode(siteId);
}
disable tool_code;Tools — On User Input of SITE
Regenerates tool_code when an admin picks/changes SITE.
if(input.SITE != null)
{
input.tool_code = genToolCode(input.SITE);
}Tools — On Validate (Create and Edit)
Per-site uniqueness of tool_code.
if(input.tool_code != null && input.tool_code != "")
{
matches = Tools[SITE == input.SITE && tool_code == input.tool_code];
isDup = false;
if(input.ID == null)
{
if(matches.count() > 0)
{
isDup = true;
}
}
else
{
for each m in matches
{
if(m.ID != input.ID)
{
isDup = true;
break;
}
}
}
if(isDup)
{
alert "Tool code " + input.tool_code + " already exists for this site.";
cancel = true;
}
}Asset Categories — On Load (Create)
Minimal master: stamp SITE, default Active. There is NO prefix field on the live form, so no code is generated.
email = zoho.loginuserid;
siteId = getSiteForUser(email);
if(siteId != 0)
{
input.SITE = siteId; // admin-only lookup
disable SITE;
}
input.active = true;
// NOTE : this form has only SITE + category_name + active.
// There is NO 'prefix' field, so no code is generated here.Asset Categories — On Validate (Create and Edit)
Per-site uniqueness of category_name.
if(input.category_name != null && input.category_name != "")
{
matches = asset_categories[SITE == input.SITE && category_name == input.category_name];
isDup = false;
if(input.ID == null)
{
if(matches.count() > 0)
{
isDup = true;
}
}
else
{
for each m in matches
{
if(m.ID != input.ID)
{
isDup = true;
break;
}
}
}
if(isDup)
{
alert "Category " + input.category_name + " already exists for this site.";
cancel = true;
}
}Schedule — Daily 06:00 — vendorContractExpiryReminder
Create under Workflow > Schedules, frequency Daily, target form = vendors. Compares contract_expiry against computed marker dates (addDay) instead of a daysBetween call, so it stays reliable. Uses for-each, not while.
// Reminds active vendors before a contract lapses
today = zoho.currentdate;
marks = {30, 15, 7, 1}; // send at these day-marks before expiry
for each d in marks
{
targetDate = today.addDay(d);
// Active vendors whose contract expires EXACTLY on the mark date
dueList = vendors[active == true && contract_expiry == targetDate];
for each v in dueList
{
toAddr = v.email;
if(toAddr != null && toAddr != "")
{
siteName = SITE[ID == v.SITE].Site_Name;
sendmail
[
from : zoho.adminuserid
to : toAddr
subject : "Contract expiring in " + d + " days - " + v.vendor_name
message : "Dear " + v.contact_person + "<br><br>The contract " + v.contract_reference + " for site " + siteName + " expires on " + v.contract_expiry.toString("dd-MMM-yyyy") + ". Please start the renewal.<br><br>CAFM - Jood FM"
]
}
}
}genVendorCode
Build a per-site vendor code from SITE.Site_Code + a zero-padded running sequence (e.g. RYD-VEN-0007). Returns empty string when no site is known so the caller leaves vendor_code blank until a SITE is chosen.
string genVendorCode(int siteId)
{
// No site yet (admin has not picked one) -> return blank, caller keeps code empty
if(siteId == 0)
{
return "";
}
// Site_Code is the center prefix that opens every vendor code
prefix = SITE[ID == siteId].Site_Code;
if(prefix == null || prefix == "")
{
prefix = "GEN"; // safety net if Site_Code was left blank
}
// Running sequence = vendors this site already has + 1
seq = vendors[SITE == siteId].count() + 1;
// Zero-pad to 4 digits WITHOUT a while loop (while is not allowed in this app)
seqText = "000" + seq;
seqText = seqText.subString(seqText.length() - 4);
candidate = prefix + "-VEN-" + seqText;
// Guard against a code freed up by an earlier delete
if(vendors[SITE == siteId && vendor_code == candidate].count() > 0)
{
candidate = prefix + "-VEN-" + seqText + "-" + zoho.currenttime.toString("HHmmss");
}
return candidate;
}genToolCode
Same pattern as genVendorCode but for the Tools form: SITE.Site_Code + running sequence over Tools for that site (e.g. RYD-TL-0012). Returns empty string when siteId is 0.
string genToolCode(int siteId)
{
// No site chosen yet -> caller keeps tool_code blank
if(siteId == 0)
{
return "";
}
// Site_Code prefixes every tool code
prefix = SITE[ID == siteId].Site_Code;
if(prefix == null || prefix == "")
{
prefix = "GEN";
}
// Running sequence = tools already held by this site + 1
seq = Tools[SITE == siteId].count() + 1;
// Zero-pad to 4 digits without a while loop
seqText = "000" + seq;
seqText = seqText.subString(seqText.length() - 4);
candidate = prefix + "-TL-" + seqText;
// Skip a code left behind by a deleted tool
if(Tools[SITE == siteId && tool_code == candidate].count() > 0)
{
candidate = prefix + "-TL-" + seqText + "-" + zoho.currenttime.toString("HHmmss");
}
return candidate;
}Reads zoho.loginuserid, calls getSiteForUser, and stamps + locks SITE for supervisors while admins pick it manually. Also defaults Active.
Action: input.SITE = getSiteForUser(zoho.loginuserid); disable SITE; input.active = true
genVendorCode / genToolCode build a Site_Code + running-sequence code, regenerated whenever SITE changes. Codes are disabled on the form.
Action: input.vendor_code = genVendorCode(siteId) ; input.tool_code = genToolCode(siteId)
Blocks duplicate vendor_code, tool_code, employee_code and category_name within the same SITE. Uses for-each + break, excludes own row on edit.
Action: cancel = true when a same-site duplicate is found
Emails the vendor contact 30/15/7/1 days before contract_expiry, for Active vendors only.
Action: sendmail to v.email at each day-mark using SITE.Site_Name and contract_reference
Inactive technicians drop out of assignment (via a filtered assignee lookup at the transaction level) and their open work is flagged for reassignment.
Action: if input.active == false reassign open work orders (placeholder transactional form) and log
Prefer soft-delete via Active; hard delete is blocked when the master is still referenced by transactions. Template until the transactional module names are known.
Action: cancel = true when reference count > 0
Add a vendor
- Supervisor opens Add Vendor. On Load stamps their SITE, locks it, ticks Active and fills vendor_code (e.g. RYD-VEN-0007).
- An admin instead picks SITE from the lookup; On User Input of SITE regenerates vendor_code for that site.
- Enter vendor_name; tick one or more vendor_type values (multi-select: Parts supplier / OEM / Consultant / Service contractor); enter contact_person, email, phone.
- Enter contract_reference, contract_start, contract_expiry, response_sla_hours; paste the WorkDrive link into documents_folder (URL field).
- Submit. On Validate confirms vendor_code is unique within the site. The daily schedule will later email reminders 30/15/7/1 days before contract_expiry.
Add a technician
- Open Add Technician. On Load stamps SITE (admin-only, hidden from supervisors) and ticks Active.
- Type the HR-issued employee_code (manual, not generated) and full_name.
- Set zoho_user (their login), pick role (Supervisor / Administrator / Technician / Storekeeper), tick one or more discipline values, set phone, email, shift.
- Submit. On Validate blocks a duplicate employee_code within the same site.
- Later, unticking Active logs the change and flags their open work for reassignment; a filtered assignee lookup at the transaction form keeps them out of new assignments.
Add a tool to a site inventory
- Open Add Tool. On Load stamps SITE, defaults quantity to 1 and condition_1 to Good, and fills tool_code (e.g. RYD-TL-0012).
- Enter tool_name; pick tool_category (HVAC / Electrical / Plumbing / Civil (Carpenter) / Safety Tools); enter make.
- Adjust quantity, pick unit (Each / Set / Pair / Box), set condition_1, type store_location.
- Submit. On Validate ensures tool_code is unique within the site.
- Tools has no Active field, so retire a tool by setting condition_1 to Lost or Damaged, or hard-delete via the guarded On Delete.
Add an asset category
- Open Add Asset Category. On Load stamps SITE and ticks Active.
- Type category_name (e.g. Chillers). This form is intentionally minimal: SITE + category_name + active only.
- There is NO prefix field on the live form. If category prefixes/codes are required, add a prefix field first, then extend the On Load script.
- Submit. On Validate blocks a duplicate category_name within the site.
- Categories then classify Assets in the transactional module.
flowchart TD
A["SITE master"]
F["Login email resolver"]
B["Vendors master"]
C["Technicians master"]
D["Asset Categories master"]
E["Tools master"]
W["Work Orders"]
P["PPM Schedules"]
S["Spare Parts"]
Z["Assets"]
F --> A
A --> B
A --> C
A --> D
A --> E
B --> W
C --> W
E --> W
D --> Z
Z --> W
C --> P
Z --> P
B --> SNotifications & Email
The Notifications & Email module is the single outbound-messaging layer for the CAFM app, covering the Ticket Email and every alert. Every event that a technician, requester, supervisor, storekeeper or administrator must hear about is routed through one Deluge dispatcher, sendNotification(kind, recordId), which builds the subject and HTML body for that event and sends it with zoho.sendmail from the org sender address (zoho.adminuserid, the app owner demo1redecorporativa2). Recipient resolution is centralised so nobody hard-codes addresses: getRecipientForWO() picks the work order's assigned technician and falls back to the site Supervisor, while getRoleEmail() resolves administrators and storekeepers from the Technicians form by role and SITE. Work-order, PPM, SLA, contract/warranty, low-stock and service-request events each fire their own workflow that just resolves the record and calls the dispatcher, so a junior only wires the trigger and passes the record ID. Every message that is sent is written to a Notification_Log record so all sent mail is auditable. The two form-level samples below (work order assignment to the technician, and service request acknowledgement to the requester) show the exact field link names, record deep-link and sender to copy.
Developer notes & pending
- Sender: from must be a verified org address. zoho.adminuserid resolves to the app owner (demo1redecorporativa2) and is the org sender for every message - use it consistently.
- Notification_Log form is part of this build (kind, to_email, ref_type, ref_id, sent_on). Every successful send inserts one row so all sent mail is auditable. Keep to_email as Single Line because SLA and low-stock sends carry comma-separated addresses that an Email field would reject.
- assigned_technician on work_orders and ppm_schedules is a MULTI_SELECT_LOOKUP - it is a list of ids. Always loop with 'for each ... break' to take the first, never index directly, and never use a WHILE loop.
- input.ID is available in form On Add / On Edit On Success, so record deep-links built as https://creator.zoho.com/appbuilder/demo1redecorporativa2/cafm/#Report:<report>/<id> resolve correctly on first save. Report link names: work_orders_Report, ppm_schedules_Report, parts_master_Report, assets_Report, All_Asset_Contracts, and All_Service_Requests (created with the new form).
- Loop prevention: put status-changed criteria on the WO edit workflows (Assigned, On hold, Completed, Verified) or gate on a sent flag so an unrelated later edit does not re-fire the same email.
- Recipients come only from real fields: technician = technicians.email, supervisor = Supervisor.Email, administrator/storekeeper = technicians filtered by role (Administrator/Storekeeper) and SITE via getRoleEmail. getSiteForUser(zoho.loginuserid) gives the logged-in supervisor's SITE (0 if none) when a workflow needs the current user's site instead of the record's.
- Date maths for SLA and expiry uses the datetime+decimal rule: response deadline = wo.raised_on + (responseHours/24). vendors.response_sla_hours can feed responseHours where a WO is vendor-serviced. Use zoho.currentdate / zoho.currenttime for now.
- Schedule budget: Zoho Creator allows about 90 scheduled-workflow runs per user per month, so do not run the SLA, PPM, low-stock or expiry sweeps hourly. Run one daily scheduled workflow (SLA a few times a day at most) that loops the matching records and calls sendNotification per record.
- Email HTML: keep bodies to simple inline HTML with a single anchor link (as in both samples). Do not use flexbox or base64/embedded images - they break in Gmail and Outlook; host any image and reference it by URL if one is ever added.
| Field | Type | Notes |
|---|---|---|
service_requests (NEW FORM, display name Service Requests) | Form | New form to build - the intake for requests raised by building occupants. Its default list report All_Service_Requests is used in acknowledgement deep-links. Fields below. |
SITE | Lookup (Single Select) to SITE | On service_requests. Which of the 6 hospital sites the request belongs to. |
request_number | Single Line | On service_requests. Human reference shown in emails; auto-number it on add. |
requester_name | Single Line | On service_requests. Used in the email greeting. |
requester_email | On service_requests. The to address for SR_ACK and SR_CLOSED emails. | |
request_type | Drop Down | On service_requests. Values e.g. HVAC, Electrical, Plumbing, Civil, Other. |
priority | Drop Down | On service_requests. Values High, Medium, Low, Critical - matches work_orders.priority. |
status | Drop Down | On service_requests. Values New, Acknowledged, In progress, Closed, Cancelled. Acknowledged and Closed drive the two SR emails. |
acknowledged_on | Date-Time | On service_requests. Stamped when the acknowledgement email is sent. |
closed_on | Date-Time | On service_requests. Stamped when status becomes Closed. |
linked_work_order | Lookup (Single Select) to work_orders | On service_requests. Ties the request to the work order raised from it. |
assigned_technician | Lookup (Multi Select) to technicians | On service_requests. Who is handling the request. |
requester_email | Email (NEW FIELD on existing work_orders form) | Add to work_orders. Holds the raiser/requester address so WO_ONHOLD and WO_DONE can email the person who raised the WO. Populate it from the linked service request (or desk_ticket_id source) when the WO is created. |
Notification_Log (NEW FORM) | Form | New audit form written by sendNotification after every send. Fields below. |
kind | Single Line | On Notification_Log. The dispatcher kind, e.g. WO_ASSIGNED, SR_ACK. |
to_email | Single Line | On Notification_Log. The recipient(s). Single Line (not Email) because SLA and low-stock sends are comma-separated addresses. |
ref_type | Single Line | On Notification_Log. Source form link name, e.g. work_orders, service_requests, parts_master. |
ref_id | Number | On Notification_Log. The source record id the mail was about. |
sent_on | Date-Time | On Notification_Log. Set to zoho.currenttime when the mail is sent. |
Work Orders form > Workflow > On Add AND On Edit > On Success (assignment email to the technician)
from must be a verified org sender - zoho.adminuserid resolves to the app owner (demo1redecorporativa2). input.ID is populated on On Success of both Add and Edit, so the deep-link is valid on first save. input.SITE.Site_Name dereferences the SITE single-select lookup (SITE.Site_Name). To avoid a re-send on every later edit, add the criterion status changed to "Assigned" (Edit) or gate on a sent flag.
// Fires after a Work Order is saved. When it is Assigned, email the technician.
// input.<field> = the record just saved. input.ID = this work order's record id.
// assigned_technician is a MULTI_SELECT_LOOKUP, so it is a list of technician ids.
if(input.status == "Assigned")
{
techEmail = "";
techName = "";
for each techId in input.assigned_technician
{
techRec = technicians[ID == techId];
techEmail = techRec.email; // Technicians.email (EMAIL field)
techName = techRec.full_name; // Technicians.full_name (SINGLE_LINE)
break; // WHILE is not allowed - loop once and break
}
if(techEmail != null && techEmail != "")
{
// Record deep-link: opens this work order in the Work Orders report
recUrl = "https://creator.zoho.com/appbuilder/demo1redecorporativa2/cafm/#Report:work_orders_Report/" + input.ID;
subject = "Work Order " + input.work_order_number + " assigned to you";
msgBody = "<p>Hi " + techName + ",</p>";
msgBody = msgBody + "<p>The following work order has been assigned to you.</p>";
msgBody = msgBody + "<p><b>WO Number:</b> " + input.work_order_number + "<br>";
msgBody = msgBody + "<b>Site:</b> " + input.SITE.Site_Name + "<br>";
msgBody = msgBody + "<b>Section:</b> " + input.Section1 + "<br>";
msgBody = msgBody + "<b>Priority:</b> " + input.priority + "<br>";
msgBody = msgBody + "<b>Target Completion:</b> " + input.target_completion.toString("dd-MMM-yyyy hh:mm a") + "</p>";
msgBody = msgBody + "<p>Open the work order: <a href=\"" + recUrl + "\">" + input.work_order_number + "</a></p>";
sendmail
[
from : zoho.adminuserid
to : techEmail
subject : subject
message : msgBody
]
}
}Service Requests form > Workflow > On Add > On Success (acknowledgement email to the requester)
All_Service_Requests is the list report auto-created with the new service_requests form; use it in the deep-link. requester_email is the Service Requests EMAIL field. Same verified org sender (zoho.adminuserid). Keep the body simple inline HTML (no flexbox, no base64 images) so Gmail and Outlook render it.
// Fires when a new Service Request is logged. Acknowledge it to the requester.
// service_requests is the NEW form built in this module (see dataModelRows).
if(input.requester_email != null && input.requester_email != "")
{
recUrl = "https://creator.zoho.com/appbuilder/demo1redecorporativa2/cafm/#Report:All_Service_Requests/" + input.ID;
subject = "We received your request " + input.request_number;
msgBody = "<p>Hi " + input.requester_name + ",</p>";
msgBody = msgBody + "<p>Thank you for contacting the JOOD FM facilities team. Your service request has been logged and acknowledged.</p>";
msgBody = msgBody + "<p><b>Request Number:</b> " + input.request_number + "<br>";
msgBody = msgBody + "<b>Site:</b> " + input.SITE.Site_Name + "<br>";
msgBody = msgBody + "<b>Priority:</b> " + input.priority + "<br>";
msgBody = msgBody + "<b>Logged On:</b> " + zoho.currenttime.toString("dd-MMM-yyyy hh:mm a") + "</p>";
msgBody = msgBody + "<p>Track progress: <a href=\"" + recUrl + "\">" + input.request_number + "</a></p>";
sendmail
[
from : zoho.adminuserid
to : input.requester_email
subject : subject
message : msgBody
]
}getRecipientForWO
Return the best email address for a work order: the assigned technician, falling back to the site Supervisor. Used by the WO assignment and SLA paths.
string getRecipientForWO(int wo)
{
toEmail = "";
woRec = work_orders[ID == wo];
// 1) assigned technician first (assigned_technician is a MULTI_SELECT_LOOKUP)
for each techId in woRec.assigned_technician
{
techRec = technicians[ID == techId];
if(techRec.email != null && techRec.email != "")
{
toEmail = techRec.email;
}
break; // WHILE not allowed - take the first technician and break
}
// 2) fall back to the site Supervisor (Supervisor.SITE == this WO's SITE)
if(toEmail == "")
{
for each supRec in Supervisor[SITE == woRec.SITE]
{
toEmail = supRec.Email; // Supervisor.Email (EMAIL field)
break;
}
}
return toEmail;
}getRoleEmail
Resolve an Administrator or Storekeeper email from the Technicians form by role and SITE (Technicians.role holds Supervisor/Administrator/Technician/Storekeeper). Used for SLA, low-stock and expiry alerts.
string getRoleEmail(string roleName, int siteId)
{
found = "";
for each t in technicians[role == roleName && SITE == siteId && active == true]
{
if(t.email != null && t.email != "")
{
found = t.email;
break;
}
}
// Administrators may be app-wide (no site match) - fall back to any active admin
if(found == "" && roleName == "Administrator")
{
for each t in technicians[role == "Administrator" && active == true]
{
found = t.email;
break;
}
}
return found;
}sendNotification
Central dispatcher. Given a kind and a record id it builds the subject and HTML body for that event, resolves the recipient, sends via zoho.sendmail from the org sender, and writes a Notification_Log audit row. Every workflow calls this.
void sendNotification(string kind, int recordId)
{
senderAddress = zoho.adminuserid; // verified org sender (owner demo1redecorporativa2)
baseUrl = "https://creator.zoho.com/appbuilder/demo1redecorporativa2/cafm/#Report:";
toEmail = "";
subject = "";
body = "";
refType = "";
if(kind == "WO_ASSIGNED" || kind == "WO_ONHOLD" || kind == "WO_DONE")
{
refType = "work_orders";
wo = work_orders[ID == recordId];
recUrl = baseUrl + "work_orders_Report/" + recordId;
woLink = "<a href=\"" + recUrl + "\">" + wo.work_order_number + "</a>";
if(kind == "WO_ASSIGNED")
{
toEmail = getRecipientForWO(recordId);
subject = "Work Order " + wo.work_order_number + " assigned";
body = "<p>A work order has been assigned.</p><p>WO " + woLink + " at " + wo.SITE.Site_Name + " - priority " + wo.priority + ".</p>";
}
else if(kind == "WO_ONHOLD")
{
toEmail = wo.requester_email; // raiser of the work order
subject = "Work Order " + wo.work_order_number + " is on hold";
body = "<p>Your work order " + woLink + " has been placed on hold. We will update you when it resumes.</p>";
}
else if(kind == "WO_DONE")
{
toEmail = wo.requester_email; // raiser / requester
subject = "Work Order " + wo.work_order_number + " " + wo.status;
body = "<p>Your work order " + woLink + " is now " + wo.status + ".</p>";
}
}
else if(kind == "PPM_DUE")
{
refType = "ppm_schedules";
ppm = ppm_schedules[ID == recordId];
recUrl = baseUrl + "ppm_schedules_Report/" + recordId;
for each techId in ppm.assigned_technician
{
techRec = technicians[ID == techId];
toEmail = techRec.email;
break;
}
subject = "PPM " + ppm.schedule_code + " due on " + ppm.next_due_date.toString("dd-MMM-yyyy");
body = "<p>Preventive maintenance " + ppm.schedule_code + " is due on " + ppm.next_due_date.toString("dd-MMM-yyyy") + ".</p><p><a href=\"" + recUrl + "\">Open schedule</a></p>";
}
else if(kind == "SLA_BREACH")
{
refType = "work_orders";
wo = work_orders[ID == recordId];
recUrl = baseUrl + "work_orders_Report/" + recordId;
supEmail = "";
for each supRec in Supervisor[SITE == wo.SITE]
{
supEmail = supRec.Email;
break;
}
adminEmail = getRoleEmail("Administrator", wo.SITE);
toEmail = supEmail;
if(adminEmail != "")
{
toEmail = toEmail + "," + adminEmail; // to takes comma-separated addresses
}
subject = "SLA breach on Work Order " + wo.work_order_number;
body = "<p>Work order <a href=\"" + recUrl + "\">" + wo.work_order_number + "</a> has breached its SLA. Priority " + wo.priority + ", status " + wo.status + ", target " + wo.target_completion.toString("dd-MMM-yyyy hh:mm a") + ".</p>";
}
else if(kind == "CONTRACT_EXPIRY")
{
refType = "Asset_Contract";
con = Asset_Contract[ID == recordId];
recUrl = baseUrl + "All_Asset_Contracts/" + recordId;
toEmail = getRoleEmail("Administrator", con.SITE);
subject = "Asset contract expiring at " + con.SITE.Site_Name;
body = "<p>An asset contract ends on " + con.End_Date.toString("dd-MMM-yyyy") + ".</p><p><a href=\"" + recUrl + "\">Open contract</a></p>";
}
else if(kind == "WARRANTY_EXPIRY")
{
refType = "assets";
ast = assets[ID == recordId];
recUrl = baseUrl + "assets_Report/" + recordId;
toEmail = getRoleEmail("Administrator", ast.SITE);
subject = "Warranty expiring for asset " + ast.asset_name;
body = "<p>Asset " + ast.asset_id + " " + ast.asset_name + " warranty expires on " + ast.warranty_expiry.toString("dd-MMM-yyyy") + ".</p><p><a href=\"" + recUrl + "\">Open asset</a></p>";
}
else if(kind == "LOW_STOCK")
{
refType = "parts_master";
part = parts_master[ID == recordId];
recUrl = baseUrl + "parts_master_Report/" + recordId;
storeEmail = getRoleEmail("Storekeeper", part.SITE);
adminEmail = getRoleEmail("Administrator", part.SITE);
toEmail = storeEmail;
if(adminEmail != "")
{
if(toEmail != "")
{
toEmail = toEmail + "," + adminEmail;
}
else
{
toEmail = adminEmail;
}
}
subject = "Low stock - " + part.part_name;
body = "<p>Part " + part.part_code + " " + part.part_name + " is at " + part.current_balance + ", at or below its reorder level " + part.reorder_level + ".</p><p><a href=\"" + recUrl + "\">Open part</a></p>";
}
else if(kind == "SR_ACK" || kind == "SR_CLOSED")
{
refType = "service_requests";
sr = service_requests[ID == recordId];
recUrl = baseUrl + "All_Service_Requests/" + recordId;
toEmail = sr.requester_email;
if(kind == "SR_ACK")
{
subject = "Service Request " + sr.request_number + " received";
body = "<p>Hi " + sr.requester_name + ", we have received your request " + sr.request_number + " and our team is on it.</p><p><a href=\"" + recUrl + "\">Track your request</a></p>";
}
else
{
subject = "Service Request " + sr.request_number + " closed";
body = "<p>Hi " + sr.requester_name + ", your request " + sr.request_number + " has been closed. Thank you.</p>";
}
}
// Send only when a recipient was resolved, then write the audit row
if(toEmail != null && toEmail != "")
{
sendmail
[
from : senderAddress
to : toEmail
subject : subject
message : body
]
logInsert = insert into Notification_Log
[
kind : kind
to_email : toEmail
ref_type : refType
ref_id : recordId
sent_on : zoho.currenttime
];
}
}Emails the assigned technician the moment a work order is set to Assigned. Runs Script 1 inline, or call sendNotification("WO_ASSIGNED", input.ID). Recipient via getRecipientForWO (technician, else site Supervisor).
Action: sendNotification("WO_ASSIGNED", input.ID)
Notifies the person who raised the work order that it has been paused. Recipient is work_orders.requester_email.
Action: sendNotification("WO_ONHOLD", input.ID)
Tells the raiser/requester the job is done or verified. Recipient is work_orders.requester_email.
Action: sendNotification("WO_DONE", input.ID)
For each schedule coming due, emails the schedule's assigned technician. In the scheduled workflow loop each record and call the dispatcher with its ID.
Action: for each ppm in ppm_schedules[status == "Active"] { if(ppm.next_due_date <= zoho.currentdate.addDay(ppm.lead_time_days)) { sendNotification("PPM_DUE", ppm.ID); } }
Detects response and resolution breaches and alerts the site Supervisor plus the Administrator. Response deadline is computed from raised_on plus responseHours/24 (adding a decimal N days to a datetime), resolution deadline is target_completion. Recipients are Supervisor.Email for the site and getRoleEmail("Administrator").
Action: nowTime = zoho.currenttime; for each wo in work_orders[status != "Completed" && status != "Verified" && status != "Cancelled"] { if(wo.target_completion != null && nowTime > wo.target_completion) { sendNotification("SLA_BREACH", wo.ID); } }
Alerts the Administrator that an asset contract or an asset warranty is about to lapse. Two loops in one daily scheduled workflow, each calling the dispatcher.
Action: for each c in Asset_Contract[End_Date <= zoho.currentdate.addDay(30)] { sendNotification("CONTRACT_EXPIRY", c.ID); } for each a in assets[warranty_expiry <= zoho.currentdate.addDay(30)] { sendNotification("WARRANTY_EXPIRY", a.ID); }
When a part's balance (recomputed from Stock Movements) drops to or below its reorder level, emails the site Storekeeper and the Administrator. Fires from the Parts Master edit that lowers the balance and from a daily catch-up sweep.
Action: sendNotification("LOW_STOCK", input.ID)
Sends the requester an acknowledgement as soon as they log a request. Runs Script 2 inline, or call sendNotification("SR_ACK", input.ID).
Action: sendNotification("SR_ACK", input.ID)
Notifies the requester their request has been closed. Recipient is service_requests.requester_email.
Action: sendNotification("SR_CLOSED", input.ID)
Assigning a work order emails the technician
- Supervisor opens a work order, sets Assigned Technician and changes status to Assigned, then Update.
- Work Orders On Edit On Success fires; status == "Assigned" is true.
- The first id in input.assigned_technician is read and technicians[ID==techId].email is fetched.
- zoho.sendmail sends from zoho.adminuserid to that technician email with the WO deep-link (work_orders_Report/input.ID).
- A Notification_Log row (kind WO_ASSIGNED, ref_type work_orders, ref_id = WO id) is written.
Acknowledging a service request emails the requester
- An occupant logs a Service Request; the record is added with requester_email filled.
- Service Requests On Add On Success fires and sets status to Acknowledged / acknowledged_on.
- zoho.sendmail sends from zoho.adminuserid to input.requester_email with the request number and the All_Service_Requests deep-link.
- A Notification_Log row (kind SR_ACK, ref_type service_requests) is written.
SLA breach emails the supervisor
- The daily/several-times-daily scheduled workflow loops open work orders.
- For a WO whose target_completion is past and status is not Completed/Verified/Cancelled, it calls sendNotification("SLA_BREACH", wo.ID).
- The dispatcher reads Supervisor[SITE == wo.SITE].Email and adds getRoleEmail("Administrator", wo.SITE), joined by a comma.
- zoho.sendmail sends the breach notice to the supervisor and admin with the WO deep-link.
- A Notification_Log row (kind SLA_BREACH) records the send.
flowchart TD woAssign["WO Assigned"] --> disp["sendNotification dispatcher"] woHold["WO On hold"] --> disp woDone["WO Completed or Verified"] --> disp ppm["PPM due soon"] --> disp sla["SLA breach"] --> disp expiry["Contract or Warranty expiry"] --> disp low["Low stock"] --> disp srAck["Service Request Acknowledged"] --> disp srClose["Service Request Closed"] --> disp disp --> tech["Assigned Technician"] disp --> raiser["Raiser or Requester"] disp --> sup["Site Supervisor"] disp --> admin["Administrator"] disp --> store["Storekeeper"] tech --> mail["zoho.sendmail via org sender"] raiser --> mail sup --> mail admin --> mail store --> mail mail --> log["Notification_Log row"]
Reports & Compliance
The Reports & Compliance module turns the CAFM transactional data (work_orders, task_checklists, parts_master, assets, Asset_Contract, vendors) into the maintenance-compliance and performance reporting the Ministry contract requires across the six hospital SITEs. It delivers seven operational reports - PPM compliance, checklist compliance, SLA compliance, technician KPIs, downtime by asset, low stock, and contract/warranty expiry - plus a monthly compliance digest emailed to management. Because a Zoho Creator Summary or Pivot column cannot divide one aggregate by another, every ratio metric (compliance %, SLA %, on-time %) is produced with a Number formula field that returns 100 or 0, whose Average equals the percentage. A small set of stored fields (durations, SLA flags, checklist counts) is stamped by Deluge when a work order is completed so the reports read fast, pre-computed values instead of recalculating on open. All fields, forms, reports, functions, and the scheduled digest listed here are being built now for this delivery.
Developer notes & pending
- R1 PPM Compliance - Pivot report on work_orders. Filter job_type == Preventive. Rows = SITE, Columns = wo_month. Measures: Count(work_order_number) = scheduled, Sum(ppm_done_flag) = completed, Average(pct_ppm_complete) = compliance %. Build: New Report > Pivot Table > work_orders, drag SITE to Rows, wo_month to Columns, add the three measures.
- R2 Checklist Compliance - Summary report on work_orders. Group by SITE then asset. Columns: Sum(checklist_items_done), Sum(checklist_items_total), Average(checklist_compliance_percent). Also build a drill-down Summary on task_checklists grouped by work_order with Count(all) and Count(result is Pass or Fail). Values are written by computeChecklistCompliance and the task_checklists On Success script.
- R3 SLA Compliance - Summary report on work_orders. Filter status is Completed or Verified. Group by SITE, Section1, priority. Columns: Count = jobs, Average(pct_sla_response) = response met %, Average(pct_sla_resolution) = resolution met %.
- R4 Technician KPIs - Summary report on work_orders. Filter status is Completed or Verified. Group by assigned_technician. Columns: Count = jobs completed, Average(pct_on_time) = on-time %, Average(resolution_time_mins) = avg resolution mins, Sum(downtime_hours) = total downtime. The ZML KPI page (script 1) shows the same figures as a dashboard.
- R5 Downtime by Asset - Summary report on work_orders. Group by asset. Columns: Sum(downtime_hours), Count. Sort by Sum(downtime_hours) descending to put the worst assets on top.
- R6 Low Stock - List report on parts_master. Filter active == true AND low_stock_flag == 1. Columns: SITE, part_code, part_name, current_balance, reorder_level, Maximum_Level. Group by SITE. low_stock_flag exists because report criteria cannot compare two fields (current_balance vs reorder_level) directly.
- R7 Contract and Warranty Expiry - three grouped List reports within 0-90 days. R7a Warranty Expiry on assets, filter days_to_warranty_expiry between 0 and 90, group by warranty_bucket, columns asset_id, asset_name, SITE, warranty_expiry, criticality. R7b Contract Expiry on Asset_Contract, filter days_to_contract_end between 0 and 90, group by contract_bucket, columns assets, SITE, End_Date. R7c Vendor Contract Expiry on vendors, filter days_to_vendor_expiry between 0 and 90, columns vendor_code, vendor_name, SITE, contract_expiry, response_sla_hours. Buckets are 0-30, 31-60, 61-90.
- Percent-without-Analytics technique: every compliance % is a Number formula field returning 100 or 0 (for example pct_on_time = if(on_time, 100, 0)); a Summary or Pivot Average of that field is the percentage. This is the way around the Creator limit that a Summary column cannot be one aggregate divided by another.
- Field stamping: stampWorkOrderMetrics(woId) runs from the Work Order close step (status becomes Completed) and fills response_time_mins, resolution_time_mins, sla_response_met, sla_resolution_met and on_time. Priority response targets in minutes are Critical 30, High 60, Medium 240, Low 480 - change them in that one function.
- Multi-select caveat: assigned_technician and asset on work_orders are multi-select lookups, so grouping a Summary by them counts a WO under each selected value. WOs normally carry one technician and one asset; a WO with two will appear under both. The KPI page (script 1) handles this by looping each technician on the WO.
- Formula date fields: days_to_warranty_expiry = warranty_expiry - zoho.currentdate (Date minus Date returns whole days as a Number). A null expiry returns null and falls outside the 0-90 filter, so blank-dated records are excluded on their own - no extra guard needed.
- compliance_snapshots is written once per SITE per month by the Monthly Compliance Digest so PPM and SLA history survive even as live WOs are edited. The live R1 and R3 reports read current data; the snapshot holds the locked month figure for trend charts and audit.
- To add any formula field: form builder > drag Formula field > set Return Type (Number, Decimal or String) > paste the expression > Save > Deploy. Formula fields recompute automatically when their inputs change, including when Deluge sets sla_response_met.
- Report sharing: publish R1-R7 to the FM Manager and Supervisor profiles. Supervisors are already scoped to their site by getSiteForUser, so add the permission filter SITE == getSiteForUser(zoho.loginuserid) on each report for portal users.
| Field | Type | Notes |
|---|---|---|
response_time_mins | Number (integer) - work_orders | Minutes from raised_on to started_on. Stamped by stampWorkOrderMetrics on WO close. |
resolution_time_mins | Number (integer) - work_orders | Minutes from raised_on to completed_on. Feeds Technician KPIs avg resolution. |
sla_response_met | Decision box - work_orders | True when first response (started_on) is within the priority response target. |
sla_resolution_met | Decision box - work_orders | True when completed_on is on or before target_completion. |
checklist_items_total | Number - work_orders | Count of task_checklists linked to the WO. Stamped by computeChecklistCompliance. |
checklist_items_done | Number - work_orders | Count of linked checks whose result is Pass or Fail (not blank, not Not applicable). |
checklist_compliance_percent | Decimal 2dp - work_orders | done/total*100. This is the value R2 groups and averages on. |
pct_ppm_complete | Formula (Number) - work_orders | if(status == "Completed" || status == "Verified", 100, 0). Average = PPM compliance %. |
ppm_done_flag | Formula (Number) - work_orders | if(status == "Completed" || status == "Verified", 1, 0). Sum = completed count in R1. |
pct_sla_response | Formula (Number) - work_orders | if(sla_response_met, 100, 0). Average = SLA response met %. |
pct_sla_resolution | Formula (Number) - work_orders | if(sla_resolution_met, 100, 0). Average = SLA resolution met %. |
pct_on_time | Formula (Number) - work_orders | if(on_time, 100, 0). Average = on-time % in Technician KPIs. |
wo_month | Formula (String) - work_orders | scheduled_date.toString("yyyy-MM"). Column axis for the R1 Pivot. |
low_stock_flag | Formula (Number) - parts_master | if(current_balance <= reorder_level, 1, 0). Report criteria cannot compare two fields, so R6 filters this flag. |
days_to_warranty_expiry | Formula (Number) - assets | warranty_expiry - zoho.currentdate (Date minus Date returns whole days). Null expiry returns null and is excluded by the 0-90 filter. |
warranty_bucket | Formula (String) - assets | if(days_to_warranty_expiry<=30,"0-30", if(days_to_warranty_expiry<=60,"31-60","61-90")). Group-by for R7a. |
days_to_contract_end | Formula (Number) - Asset_Contract | End_Date - zoho.currentdate. Filter/group for R7b. |
contract_bucket | Formula (String) - Asset_Contract | 0-30 / 31-60 / 61-90 from days_to_contract_end. Group-by for R7b. |
days_to_vendor_expiry | Formula (Number) - vendors | contract_expiry - zoho.currentdate. Filter for R7c. |
compliance_snapshots | NEW FORM | One row per SITE per month written by the Monthly Compliance Digest; holds locked history for R1 trend and the email. |
SITE | Lookup single -> SITE - compliance_snapshots | The site this monthly rollup belongs to. |
period_month | Date - compliance_snapshots | First day of the reported month (e.g. 01-Aug-2026). |
ppm_scheduled | Number - compliance_snapshots | Preventive WOs scheduled in the month. |
ppm_completed | Number - compliance_snapshots | Preventive WOs completed or verified in the month. |
ppm_compliance_percent | Decimal - compliance_snapshots | ppm_completed/ppm_scheduled*100. |
sla_total_count | Number - compliance_snapshots | WOs completed in the month (denominator for SLA %). |
sla_response_percent | Decimal - compliance_snapshots | SLA response met % for the month. |
sla_resolution_percent | Decimal - compliance_snapshots | SLA resolution met % for the month. |
avg_downtime_hours | Decimal - compliance_snapshots | Average downtime_hours across the month's completed WOs. |
generated_on | Date-Time - compliance_snapshots | When the digest job wrote the row. |
Creator Page (ZML) - Technician KPI dashboard - server block plus render
Confirm the technicians name field link name; replace technician_name if the form uses Name. The same numbers back the R4 Summary report - use whichever the reviewer prefers.
<%
// ===== Technician KPI dashboard - group-by via for-each accumulate =====
acc = Map();
wos = work_orders[status == "Completed" || status == "Verified"];
for each wo in wos
{
for each t in wo.assigned_technician // multi-select lookup
{
k = t.toString();
r = ifnull(acc.get(k), Map());
r.put("jobs", ifnull(r.get("jobs"),0) + 1);
if(wo.on_time == true) { r.put("ot", ifnull(r.get("ot"),0) + 1); }
r.put("res", ifnull(r.get("res"),0) + ifnull(wo.resolution_time_mins,0));
r.put("dt", ifnull(r.get("dt"),0.0) + ifnull(wo.downtime_hours,0.0));
acc.put(k, r);
}
}
%>
<table border="1" cellpadding="6">
<tr><th>Technician</th><th>Jobs</th><th>On-time %</th><th>Avg Resolution min</th><th>Downtime h</th></tr>
<% for each k in acc.keys() { %>
<% r = acc.get(k); j = r.get("jobs"); %>
<tr>
<td><%= technicians[ID == k.toLong()].technician_name %></td>
<td><%= j %></td>
<td><%= round((ifnull(r.get("ot"),0) * 100.0)/ j, 1) %></td>
<td><%= round(r.get("res") * 1.0 / j, 0) %></td>
<td><%= r.get("dt") %></td>
</tr>
<% } %>
</table>task_checklists - On Success (Create / Edit / Delete) - checklist compliance formula logic
A native Creator formula field cannot count records in another form, so this Deluge writes the stored percent that R2 reports. On Delete, guard with a null check on input.work_order.
// ===== checklist_compliance_percent - formula-column equivalent =====
// input.work_order is a multi-select list, so one saved check can touch several WOs.
for each woId in input.work_order
{
total = task_checklists[work_order.contains(woId)].count();
done = task_checklists[work_order.contains(woId) && result != null && result != "Not applicable"].count();
pct = 0.0;
if(total > 0) { pct = round((done * 100.0)/ total, 2); }
wo = work_orders[ID == woId];
wo.checklist_items_total = total; // done vs total for R2
wo.checklist_items_done = done;
wo.checklist_compliance_percent = pct; // value R2 groups and averages on
}computeChecklistCompliance
Return the checklist completion percent for one work order and stamp the stored count/percent fields the R2 report groups on. Called from the WO close flow and from the task_checklists On Success script.
Decimal computeChecklistCompliance(int woId)
{
// task_checklists.work_order is a multi-select lookup -> use contains().
total = task_checklists[work_order.contains(woId)].count();
done = task_checklists[work_order.contains(woId) && result != null && result != "Not applicable"].count();
pct = 0.0;
if(total > 0)
{
pct = round((done * 100.0) / total, 2);
}
wo = work_orders[ID == woId];
if(wo.count() > 0)
{
wo.checklist_items_total = total;
wo.checklist_items_done = done;
wo.checklist_compliance_percent = pct;
}
return pct;
}stampWorkOrderMetrics
On work-order completion, compute response/resolution durations, the priority-based SLA response flag, the target-completion SLA flag, and on_time, so R3, R4 and R5 read stored values. Wire this into the Work Order close blueprint transition or the work_orders On Edit event when status becomes Completed.
void stampWorkOrderMetrics(int woId)
{
wo = work_orders[ID == woId];
if(wo.count() == 0) { return; }
respMins = 0;
resMins = 0;
if(wo.raised_on != null && wo.started_on != null)
{
respMins = wo.raised_on.minutesBetween(wo.started_on);
}
if(wo.raised_on != null && wo.completed_on != null)
{
resMins = wo.raised_on.minutesBetween(wo.completed_on);
}
// priority response target in minutes - change here only
respTarget = 240;
if(wo.priority == "Critical") { respTarget = 30; }
else if(wo.priority == "High") { respTarget = 60; }
else if(wo.priority == "Medium") { respTarget = 240; }
else if(wo.priority == "Low") { respTarget = 480; }
respMet = false;
if(wo.started_on != null && respMins <= respTarget) { respMet = true; }
resMet = false;
if(wo.completed_on != null && wo.target_completion != null && wo.completed_on <= wo.target_completion) { resMet = true; }
onTime = false;
if(wo.completed_on != null && wo.scheduled_date != null && wo.completed_on.toDate() <= wo.scheduled_date) { onTime = true; }
wo.response_time_mins = respMins;
wo.resolution_time_mins = resMins;
wo.sla_response_met = respMet;
wo.sla_resolution_met = resMet;
wo.on_time = onTime;
}getTechnicianKPIs
Deluge has no GROUP BY, so technician KPIs are built by fetching completed WOs in a window and accumulating into a Map keyed by technician id. Returns a list of KPI maps used by the R4 page and by any custom export.
list getTechnicianKPIs(date fromD, date toD)
{
out = List();
acc = Map();
wos = work_orders[(status == "Completed" || status == "Verified") && completed_on >= fromD && completed_on <= toD];
for each wo in wos
{
for each t in wo.assigned_technician // multi-select lookup
{
key = t.toString();
row = ifnull(acc.get(key), Map());
row.put("jobs", ifnull(row.get("jobs"),0) + 1);
if(wo.on_time == true) { row.put("ot", ifnull(row.get("ot"),0) + 1); }
row.put("res", ifnull(row.get("res"),0) + ifnull(wo.resolution_time_mins,0));
row.put("dt", ifnull(row.get("dt"),0.0) + ifnull(wo.downtime_hours,0.0));
acc.put(key, row);
}
}
for each k in acc.keys()
{
r = acc.get(k);
j = r.get("jobs");
m = Map();
m.put("technician_id", k);
m.put("jobs_completed", j);
m.put("on_time_percent", if(j > 0, round((ifnull(r.get("ot"),0) * 100.0)/ j, 1), 0));
m.put("avg_resolution_mins", if(j > 0, round(r.get("res") * 1.0 / j, 0), 0));
m.put("total_downtime_hours", r.get("dt"));
out.add(m);
}
return out;
}sendMonthlyComplianceDigest
Roll up the previous calendar month per SITE, write one compliance_snapshots row per SITE for history, and email the HTML PPM + SLA summary to facilities management. Called by the Monthly Compliance Digest scheduler.
void sendMonthlyComplianceDigest()
{
firstThis = zoho.currentdate.toStartOfMonth(); // 1st of current month
endPrev = firstThis.subDay(1); // last day prev month
startPrev = endPrev.toStartOfMonth(); // 1st of prev month
period = startPrev.toString("yyyy-MM");
html = "<h3>CAFM Compliance " + period + "</h3>";
html = html + "<table border='1' cellpadding='6'><tr><th>Site</th><th>PPM Sched</th><th>PPM Done</th><th>PPM %</th><th>SLA Resp %</th><th>SLA Resol %</th><th>Avg Downtime h</th></tr>";
for each s in SITE[ID != 0]
{
sid = s.ID;
ppmSched = work_orders[SITE == sid && job_type == "Preventive" && scheduled_date >= startPrev && scheduled_date <= endPrev].count();
ppmDone = work_orders[SITE == sid && job_type == "Preventive" && (status == "Completed" || status == "Verified") && scheduled_date >= startPrev && scheduled_date <= endPrev].count();
ppmPct = if(ppmSched > 0, round((ppmDone * 100.0)/ ppmSched, 1), 0);
base = work_orders[SITE == sid && completed_on >= startPrev && completed_on < firstThis];
total = base.count();
respMet = work_orders[SITE == sid && completed_on >= startPrev && completed_on < firstThis && sla_response_met == true].count();
resolMet = work_orders[SITE == sid && completed_on >= startPrev && completed_on < firstThis && sla_resolution_met == true].count();
respPct = if(total > 0, round((respMet * 100.0)/ total, 1), 0);
resolPct = if(total > 0, round((resolMet * 100.0)/ total, 1), 0);
downSum = 0.0;
for each w in base { downSum = downSum + ifnull(w.downtime_hours, 0.0); }
avgDown = if(total > 0, round(downSum / total, 2), 0);
insert into compliance_snapshots
[
SITE = sid
period_month = startPrev
ppm_scheduled = ppmSched
ppm_completed = ppmDone
ppm_compliance_percent = ppmPct
sla_total_count = total
sla_response_percent = respPct
sla_resolution_percent = resolPct
avg_downtime_hours = avgDown
generated_on = zoho.currenttime
];
html = html + "<tr><td>" + s.Site_Name + "</td><td>" + ppmSched + "</td><td>" + ppmDone + "</td><td>" + ppmPct + "</td><td>" + respPct + "</td><td>" + resolPct + "</td><td>" + avgDown + "</td></tr>";
}
html = html + "</table>";
sendmail
[
from : zoho.adminuserid
to : "facilities.management@joodfm.example"
subject : "CAFM Monthly Compliance - " + period
message : html
];
}On the first of each month, roll up the previous calendar month per SITE - preventive WOs scheduled vs completed with compliance %, SLA response and resolution %, and average downtime - write one compliance_snapshots row per SITE for history, and email the HTML summary to facilities management. Build in Workflow > Schedules > New Schedule > Frequency Monthly, day 1, and set the Deluge action to call the function.
Action: sendMonthlyComplianceDigest();
Monthly compliance review
- Open R1 PPM Compliance (Pivot) and set the column to the closed month; read compliance % per SITE and flag any SITE below the contract target.
- Open R3 SLA Compliance (Summary) and review response % and resolution % by SITE, Section and priority; note Critical/High rows first.
- Open R2 Checklist Compliance (Summary) and confirm high-criticality assets sit at 100% checklist completion.
- Cross-check the Monthly Compliance Digest email and the matching compliance_snapshots rows for the same period.
- Export or screenshot the three reports for the management pack.
Technician performance review
- Open R4 Technician KPIs (Summary or the ZML KPI page).
- Sort by on-time % ascending to surface laggards; read avg resolution mins and total downtime hours alongside.
- Click a technician to drill into their completed work_orders list for the period.
- Compare jobs_completed against workload to separate slow work from heavy load, and set next-month targets.
Upcoming expiries review
- Open R7a Warranty Expiry (0-90 days) grouped by bucket and action the 0-30 items first.
- Open R7b Contract Expiry (Asset_Contract) and R7c Vendor Contract Expiry and list renewals due.
- Raise renewal POs or replacement work orders; for lapsing warranties decide extend cover or replace.
- Re-run the reports after updates to confirm handled items drop out of the 0-30 bucket.
flowchart TD wo["Work Orders"] tc["Task Checklists"] pm["Parts Master"] ast["Assets"] ac["Asset Contract"] vn["Vendors"] snap["Compliance Snapshots"] r1["PPM Compliance"] r2["Checklist Compliance"] r3["SLA Compliance"] r4["Technician KPIs"] r5["Downtime by Asset"] r6["Low Stock"] r7["Contract and Warranty Expiry"] em["Monthly Digest Email"] wo --> snap snap --> r1 wo --> r3 tc --> r2 wo --> r2 wo --> r4 wo --> r5 pm --> r6 ast --> r7 ac --> r7 vn --> r7 snap --> em wo --> em
Dashboards & Reporting
This module is the CAFM Operations Dashboard: a Zoho Creator ZML Page (not a form) that renders live facility KPIs and mini-tables for the Jood FM team across 6 hospital sites. On every render it reads the logged-in portal user's email from zoho.loginuserid, calls the existing owner-context function getSiteForUser(email) to get their Supervisor SITE record id, and branches: supervisors see only their own hospital (queries filtered by SITE == mySite) while admins (no Supervisor row, mySite == 0) see all facilities. All KPI cards are produced by a single helper, render_kpi_card(title, value, subtitle, color, iconKey), which returns a styled HTML string the snippet concatenates into the page. KPIs cover work-order health (Open, Overdue, Critical, Completed Today, On-time %, Downtime hours), PPM (compliance %, due in 7 days), assets (Active, Under repair, Warranty expiry 90d), parts (Low stock) and contracts (expiring 60d), plus mini-tables for overdue WOs, PPM due and low-stock parts. Because it is a Page, it has no form create/edit/delete events or workflows — every value is computed at render time from the underlying forms. Field link names below are the real ones fetched from the live app; never invent new ones.
Developer notes & pending
- STILL TO BUILD — Technician KPI dashboard: per-technician jobs done, on-time %, and average resolution time using work_orders.assigned_technician, on_time, started_on and completed_on. render_kpi_card is reusable for it.
- STILL TO BUILD — SLA-compliance dashboard: breach counts by priority against target_completion, plus MTTR (completed_on minus started_on) and MTBF per asset/category. Needs an agreed SLA matrix per priority.
- Admin detection is inferred as mySite == 0 (no Supervisor row). If any admin is ALSO added as a Supervisor they would be scoped to one site — recommend an explicit admin flag (e.g. a Variables entry or role check) rather than relying on the 0 sentinel.
- getSiteForUser is an existing owner-context function; the version in functions[] is a REFERENCE only — do not redeploy it.
- Portal logins on this app expose the email in zoho.loginuserid (NOT zoho.loginuser). Always read the email from loginuserid.
- Date literals inside criteria strings use the app format dd-MMM-yyyy. Datetime-sensitive KPIs (Overdue, Completed Today) are computed in for-each loops comparing to zoho.currenttime / zoho.currentdate to avoid criteria date-format fragility.
- No WHILE loops (Zoho Creator restriction). Mini-tables use for-each with a bounded break (10 rows here).
- Every operational form (work_orders, ppm_schedules, assets, parts_master, Asset_Contract) carries the SITE lookup — that single field is what makes one-line site scoping possible. If a new form must appear on the dashboard, add a SITE lookup to it first.
- work_orders.asset is a MULTI_SELECT_LOOKUP; read the first linked asset with for-each + break rather than assuming a single value.
- PPM compliance % has no single stored field. The representative On-time % shown is the WO measure; define PPM compliance (e.g. Preventive WOs closed on/before next_due_date vs total due in period) with the client before wiring the card.
- Performance: the admin view fires ~13 aggregate reads plus loops per render across all 6 sites. If it feels slow, precompute a daily KPI snapshot form via a scheduled workflow and read that, or cache counts, instead of live-querying every load.
- This module is a Page (ZML + snippet Deluge). It intentionally has NO onLoad/onValidate/create/edit/delete form events and NO workflows — all logic is in the render snippet. Build it as a Page snippet whose Deluge returns the assembled html string (an inline <% %> ZML scriptlet block is an equivalent alternative).
| Field | Type | Notes |
|---|---|---|
work_orders.SITE | Lookup → SITE (single select) | The field that makes site scoping work; criteria SITE == mySite |
work_orders.status | Dropdown (Open/Assigned/In progress/On hold/Draft/Completed/Verified/Cancelled) | Drives Open WOs, Critical, Completed Today |
work_orders.priority | Dropdown (High/Low/Medium/Critical) | Critical Open KPI |
work_orders.target_completion | Date-Time | Overdue = open AND target_completion < zoho.currenttime |
work_orders.completed_on | Date-Time | Completed Today via .toDate() == zoho.currentdate |
work_orders.started_on | Date-Time | Needed for proposed avg-resolution technician KPI |
work_orders.on_time | Decision box (boolean) | On-time % numerator |
work_orders.downtime_hours | Decimal | Summed for Downtime hours KPI |
work_orders.asset | Multi-select lookup → assets | Mini-table asset name; read first via for-each + break |
work_orders.General_Asset | Single line | Fallback asset label when no lookup linked |
work_orders.assigned_technician | Multi-select lookup → technicians | Basis of the proposed Technician KPI dashboard |
ppm_schedules.SITE | Lookup → SITE | Scope filter for PPM Due 7d |
ppm_schedules.status | Dropdown (Active/Paused/Ended) | Only Active schedules count toward PPM Due |
ppm_schedules.next_due_date | Date | PPM Due 7d window today .. today.addDay(7) |
ppm_schedules.last_completed_date | Date | Input for a true PPM compliance % (still to define) |
assets.SITE | Lookup → SITE | Scope filter for asset KPIs |
assets.status | Dropdown (In service/Standby/Under repair/Decommissioned) | Active Assets = In service; Under Repair KPI |
assets.warranty_expiry | Date | Warranty Expiry 90d window |
assets.criticality | Dropdown (High/Low/Medium/Critical) | Available for a criticality breakdown card |
parts_master.SITE | Lookup → SITE | Scope filter for Low Stock |
parts_master.current_balance | Decimal | Compared to reorder_level for Low Stock |
parts_master.reorder_level | Decimal | Low Stock threshold |
parts_master.active | Decision box (boolean) | Only active parts counted |
Asset_Contract.SITE | Lookup → SITE | Scope filter for Contracts Expiring |
Asset_Contract.End_Date | Date | Contracts Expiring 60d window |
SITE.Site_Name / SITE.Site_Code | Single line | Facility name shown in the header |
Supervisor.Email | getSiteForUser matches this against zoho.loginuserid | |
Supervisor.SITE | Lookup → SITE | The record id getSiteForUser returns as mySite |
Supervisor.Type_field | Dropdown (JOOD Supervisor/UNM Supervisor) | Could refine scoping if JOOD vs UNM views ever differ |
Page snippet — Step 1: resolve scope and facility header
Top of the dashboard snippet. Runs on every render. Portal logins expose the email in zoho.loginuserid (NOT zoho.loginuser). Sets scopeCrit used by every KPI below and opens the cards flex container.
// ===== CAFM Operations Dashboard — page snippet Deluge =====
email = zoho.loginuserid; // portal login exposes email here
mySite = getSiteForUser(email); // existing fn -> SITE record id, 0 if none
isAdmin = (mySite == 0); // no Supervisor row -> all-sites admin view
scopeCrit = buildScopeCriteria(mySite); // "ID != 0" (admin) or "SITE == <id>"
// --- Facility name header ---
facilityName = "All Facilities";
if(!isAdmin)
{
for each st in SITE[ID == mySite]
{
facilityName = st.Site_Name + " (" + st.Site_Code + ")";
break;
}
}
html = "<h2 style='font-family:sans-serif;color:#263238;'>CAFM Operations — " + facilityName + "</h2>";
html = html + "<div style='display:flex;flex-wrap:wrap;'>"; // cards row (closed in step 4)Page snippet — Step 2: Work Order KPIs (site-scoped vs global branch shown)
Open WOs uses a pure criteria count (branches on scope automatically via scopeCrit). Overdue is date-sensitive so it is computed in a for-each comparing target_completion to zoho.currenttime — safer than putting datetimes in a criteria string.
now = zoho.currenttime;
// --- Open Work Orders (criteria count; scopeCrit already carries admin-vs-site branch) ---
openCrit = scopeCrit + " && (status != \"Completed\" && status != \"Cancelled\" && status != \"Verified\")";
openCount = work_orders[openCrit].count();
html = html + render_kpi_card("Open Work Orders", openCount + "", "Not yet completed", "#1565c0", "wo");
// --- Overdue (open AND past target_completion) — date compare in a loop ---
overdue = 0;
for each wo in work_orders[openCrit]
{
if(wo.target_completion != null && wo.target_completion < now)
{
overdue = overdue + 1;
}
}
html = html + render_kpi_card("Overdue", overdue + "", "Past target completion", "#c62828", "overdue");
// --- Critical open ---
critCount = work_orders[openCrit + " && priority == \"Critical\""].count();
html = html + render_kpi_card("Critical Open", critCount + "", "Priority Critical", "#ad1457", "critical");Page snippet — Step 3: Completed Today, On-time %, Downtime (single pass)
One for-each over completed WOs computes three KPIs at once (efficient). on_time is a Decision box (boolean). completed_on.toDate() is compared to zoho.currentdate for the today count. Reuse this loop pattern instead of 3 separate queries.
today = zoho.currentdate;
doneToday = 0; onTimeDone = 0; totalDone = 0; totalDowntime = 0.0;
for each wo in work_orders[scopeCrit + " && status == \"Completed\""]
{
totalDone = totalDone + 1;
if(wo.on_time == true) { onTimeDone = onTimeDone + 1; }
if(wo.downtime_hours != null) { totalDowntime = totalDowntime + wo.downtime_hours; }
if(wo.completed_on != null && wo.completed_on.toDate() == today) { doneToday = doneToday + 1; }
}
onTimePct = 0;
if(totalDone > 0) { onTimePct = round((onTimeDone * 100.0) / totalDone, 0); }
html = html + render_kpi_card("Completed Today", doneToday + "", "Closed today", "#2e7d32", "done");
html = html + render_kpi_card("On-time %", onTimePct + "%", onTimeDone + " of " + totalDone + " on time", "#00838f", "done");
html = html + render_kpi_card("Downtime hours", totalDowntime + "", "Total logged", "#6a1b9a", "wo");Page snippet — Step 4: Assets, Parts, PPM, Warranty, Contracts (then close row)
Low-stock needs a row-by-row compare (current_balance <= reorder_level) so it uses a loop; the rest are criteria counts. Date-window counts use today.addDay(n) and app date format dd-MMM-yyyy in the criteria string. Closes the cards flex container.
// --- Active assets & Under repair ---
activeAssets = assets[scopeCrit + " && status == \"In service\""].count();
underRepair = assets[scopeCrit + " && status == \"Under repair\""].count();
html = html + render_kpi_card("Active Assets", activeAssets + "", "In service", "#00695c", "asset");
html = html + render_kpi_card("Under Repair", underRepair + "", "Status under repair", "#ef6c00", "asset");
// --- Low stock parts (balance at or below reorder level) ---
lowStock = 0;
for each p in parts_master[scopeCrit + " && active == true"]
{
if(p.reorder_level != null && p.current_balance <= p.reorder_level) { lowStock = lowStock + 1; }
}
html = html + render_kpi_card("Low Stock Parts", lowStock + "", "At or below reorder", "#d84315", "stock");
// --- PPM due in 7 days / Warranty 90d / Contracts 60d (date-window criteria) ---
in7 = today.addDay(7);
in90 = today.addDay(90);
in60 = today.addDay(60);
ppmDue = ppm_schedules[scopeCrit + " && status == \"Active\" && next_due_date >= '" + today.toString("dd-MMM-yyyy") + "' && next_due_date <= '" + in7.toString("dd-MMM-yyyy") + "'"].count();
warr = assets[scopeCrit + " && warranty_expiry >= '" + today.toString("dd-MMM-yyyy") + "' && warranty_expiry <= '" + in90.toString("dd-MMM-yyyy") + "'"].count();
contr = Asset_Contract[scopeCrit + " && End_Date >= '" + today.toString("dd-MMM-yyyy") + "' && End_Date <= '" + in60.toString("dd-MMM-yyyy") + "'"].count();
html = html + render_kpi_card("PPM Due 7d", ppmDue + "", "Active schedules", "#3949ab", "ppm");
html = html + render_kpi_card("Warranty Expiry 90d", warr + "", "Assets expiring", "#5d4037", "asset");
html = html + render_kpi_card("Contracts Expiring 60d", contr + "", "Ending soon", "#455a64", "wo");
html = html + "</div>"; // close cards flex rowPage snippet — Step 5: mini-table (Overdue Work Orders) and return
Bounded to 10 rows with a for-each + break (no WHILE loops allowed). Reads the first linked asset name from the MULTI_SELECT_LOOKUP work_orders.asset via for-each+break, falling back to General_Asset. Return html (or assign to the snippet's output variable).
// ===== Mini table: Overdue Work Orders (max 10 rows) =====
html = html + "<h3 style='font-family:sans-serif;color:#263238;'>Overdue Work Orders</h3>";
html = html + "<table style='width:100%;border-collapse:collapse;font-family:sans-serif;font-size:13px;'>";
html = html + "<tr style='background:#eceff1;text-align:left;'><th style='padding:6px;'>WO</th><th>Priority</th><th>Asset</th><th>Target</th></tr>";
shown = 0;
for each wo in work_orders[openCrit]
{
if(wo.target_completion != null && wo.target_completion < now)
{
assetName = wo.General_Asset; // fallback for free-text assets
for each a in wo.asset { assetName = a.asset_name; break; } // first linked asset
html = html + "<tr style='border-bottom:1px solid #eee;'>";
html = html + "<td style='padding:6px;'>" + wo.work_order_number + "</td>";
html = html + "<td>" + wo.priority + "</td>";
html = html + "<td>" + assetName + "</td>";
html = html + "<td>" + wo.target_completion.toString("dd-MMM HH:mm") + "</td>";
html = html + "</tr>";
shown = shown + 1;
}
if(shown >= 10) { break; } // bounded loop
}
html = html + "</table>";
return html; // render the assembled dashboardrender_kpi_card
Reusable card builder. Returns a self-contained HTML string for one KPI tile. Called once per KPI from the page snippet; keeps card styling in ONE place so all cards look identical. title=label, value=big number (stringify counts with value + ""), subtitle=small caption, color=accent hex, iconKey=short key mapped to an emoji/icon.
string render_kpi_card(string title, string value, string subtitle, string color, string iconKey)
{
// Map a short key to an icon. Extend this list as new KPIs are added.
icon = "🔧";
if(iconKey == "overdue") { icon = "⏰"; }
else if(iconKey == "critical") { icon = "🚨"; }
else if(iconKey == "done") { icon = "✅"; }
else if(iconKey == "asset") { icon = "🏭"; }
else if(iconKey == "stock") { icon = "📦"; }
else if(iconKey == "ppm") { icon = "🗓️"; }
// Build the card. flex:1 lets cards wrap responsively in the row container.
card = "<div style='flex:1 1 180px;min-width:180px;background:#ffffff;border-radius:12px;";
card = card + "border-left:6px solid " + color + ";box-shadow:0 1px 4px rgba(0,0,0,0.08);";
card = card + "padding:14px 16px;margin:8px;font-family:sans-serif;'>";
card = card + "<div style='font-size:13px;color:#607d8b;'>" + icon + " " + title + "</div>";
card = card + "<div style='font-size:30px;font-weight:700;color:" + color + ";'>" + value + "</div>";
card = card + "<div style='font-size:12px;color:#90a4ae;'>" + subtitle + "</div>";
card = card + "</div>";
return card;
}buildScopeCriteria
The site-scoping pattern in one helper. Given mySite (0 = admin), returns the criteria-string prefix every KPI query starts from. Admin gets an always-true base (ID != 0) so all rows match; a supervisor gets SITE == <id>. Callers append their own conditions with && .
string buildScopeCriteria(int mySite)
{
// mySite == 0 -> admin / all-sites. Base "ID != 0" is always true so we can safely append && ...
crit = "ID != 0";
if(mySite != 0)
{
// SITE is a lookup on every operational form; filter by the referenced SITE record id.
crit = "SITE == " + mySite;
}
return crit;
}getSiteForUser
EXISTING owner-context function — shown for reference only, DO NOT recreate. Takes the portal user's email and returns their Supervisor SITE record id, or 0 if the user has no Supervisor row (i.e. treat as admin/all-sites). Matches against Supervisor.Email.
int getSiteForUser(string email)
{
// Reference implementation of the function already deployed in the app.
siteId = 0;
// for-each + break (Creator does not allow WHILE loops)
for each s in Supervisor[Email == email]
{
siteId = s.SITE; // lookup value resolves to the referenced SITE record id
break; // first match is enough
}
return siteId;
}Dashboards & Reporting is a ZML operations Page rendered by snippet Deluge, so it has no form create/edit/delete events, no field-level workflows, and no scheduled actions of its own. All computation happens at render time inside the page snippet. Any data-changing automation (e.g. generating a WO from a PPM schedule, stamping on_time) lives on the underlying forms — Work Orders, PPM Schedules, etc. — and is documented in those modules, not here.
Action: No workflow to configure on this module
Supervisor sees only their hospital KPIs
- Supervisor opens the Operations Dashboard page from the portal
- Snippet reads their email via zoho.loginuserid
- getSiteForUser(email) finds their Supervisor row and returns the linked SITE id (e.g. 342)
- isAdmin is false so buildScopeCriteria returns SITE == 342
- Every KPI count and the overdue mini-table is filtered with scopeCrit = SITE == 342
- Header shows their site name and code, e.g. King Fahad Hospital (KFH)
- They see only their hospital's numbers; the other 5 sites are never queried or shown
Admin sees all facilities
- An admin (no Supervisor row) opens the same page
- getSiteForUser returns 0 so isAdmin is true
- buildScopeCriteria returns the always-true base ID != 0
- All KPI queries aggregate across all 6 hospital sites
- Header reads All Facilities
- Cards show consolidated totals; a future site dropdown can re-scope by rebuilding scopeCrit
Proposed Technician-KPI dashboard (still to build)
- New page scoped to a technician instead of a SITE
- Jobs done = count of work_orders where assigned_technician contains the tech and status == Completed
- On-time % = share of those completed WOs with on_time == true
- Avg resolution = average of (completed_on minus started_on) in hours over completed WOs
- Cards rendered by reusing render_kpi_card with a technician icon key
- Add a date-range picker so leads can review per-technician performance by month
flowchart TD
A["Portal login"] --> B["zoho loginuserid email"]
B --> C["getSiteForUser email"]
C --> D["mySite id or zero"]
D -->|zero| E["Admin global view"]
D -->|nonzero| F["Supervisor site scope"]
E --> G["Build criteria string"]
F --> G
G --> H["Work Orders data"]
G --> I["PPM Schedules data"]
G --> J["Assets data"]
G --> K["Parts Master data"]
G --> L["Asset Contract data"]
H --> M["render_kpi_card helper"]
I --> M
J --> M
K --> M
L --> M
M --> N["KPI cards grid"]
M --> O["Mini tables section"]Automations & Scheduled Jobs
The Automations and Scheduled Jobs module is the heartbeat that keeps the CAFM app working without anyone opening it. A single Deluge scheduled function, runDailyCafmJobs(), fires once a day and fans out to five sub-jobs: raising due preventive work orders, raising predictive work orders when assets pass their runtime threshold, sending contract, warranty and vendor expiry reminders, re-checking low stock, and scanning for SLA breaches. Each sub-job is wrapped in its own try/catch so one failure never stops the others, and every sub-job returns a count that the orchestrator rolls into one audit_log summary row per run. Because Zoho Creator caps a user at about 90 schedule runs per month, the design deliberately uses this one consolidated daily schedule instead of many hourly jobs. A second monthly schedule, sendMonthlyComplianceEmail(), emails a rolled-up compliance summary to the FM manager. Idempotency guards inside each generator mean the daily job can be re-run by hand on the same day without creating duplicate work orders.
Developer notes & pending
- Schedule-run limit: Zoho Creator allows about 90 schedule runs per user per month. An hourly job is 24 x 30 = 720 runs and blows the cap; one daily job is about 30 runs and sits well under it. That is why everything is folded into runDailyCafmJobs() on ONE daily schedule instead of many hourly schedules.
- Build the daily schedule: open the app builder, go to the Workflow area, Schedules, New Schedule. Name it Daily CAFM automation, set Execution to Recurring, Frequency Daily, time 02:00, and in the Deluge editor put a single line: thisapp.runDailyCafmJobs(); Save and enable it.
- Build the monthly schedule the same way: New Schedule, Frequency Monthly, day 1, 06:00, Deluge line thisapp.sendMonthlyComplianceEmail(); Save and enable.
- The app time zone on these forms is America/Los_Angeles. Since Jood FM sites are in Saudi Arabia, set the schedule time with the site day in mind (02:00 app time), or change the app time zone to Asia/Riyadh first so 02:00 means 02:00 local for all six sites.
- Scheduled functions run as the application owner, so zoho.loginuserid and getSiteForUser(email) are not meaningful here - always carry SITE from the source record (sch.SITE, a.SITE) when inserting work orders.
- Idempotency lives in the generators, not the orchestrator: each checks for an existing work order with the same source_schedule (or asset + Predictive) and scheduled_date == today before inserting, so a manual re-run on the same day creates no duplicates.
- No WHILE loops - every pass over a collection uses for each ... (use break to stop early if ever needed).
- Date math the app relies on: a Date or Date-Time plus an int adds that many days (today + 30 is the 30-day horizon); to add hours add hours/24.
- audit_log.reference_type and action_1 both have Allow other choices enabled, so the custom values Automation run and Scheduled run save without editing the picklists; still add them as real choices so filters and dashboards group cleanly.
- sendmail from must be a verified sender in the app email settings; replace the placeholder fm.manager@joodfm.example with the real FM manager address before enabling the monthly schedule.
- Watch the summary row after go-live: if audit_log new_value shows Errors this run greater than 0, open the function debug log (info summaryText) to see which sub-job threw - the other jobs will have completed normally.
| Field | Type | Notes |
|---|---|---|
audit_log.reference_type | Drop Down (Allow other choices on) | Set to "Automation run" on the per-run summary row. Add this as a real choice in the Form builder for clean reporting; it also persists as typed because other-choices is on. |
audit_log.reference_id | Single Line | Run key, e.g. CAFM-DAILY-2026-09-18, built from zoho.currentdate. |
audit_log.action_1 | Drop Down (Allow other choices on) | Set to "Scheduled run" on the summary row. Add as a choice in the builder. |
audit_log.field_changed | Single Line | Holds the function name runDailyCafmJobs so the run is easy to filter. |
audit_log.new_value | Multi Line | Full run summary - one line per sub-job plus the Errors this run count, joined with newlines. |
audit_log.changed_by | Single Line | Literal "System scheduler" (scheduled functions run as the app owner, not a portal user). |
audit_log.changed_on | Date-Time | zoho.currenttime captured at the start of the run. |
work_orders.response_due | Date-Time (new field, built in the SLA module, on work_orders) | Deadline for first response. Read by scanSlaBreaches to detect a missed first response. |
work_orders.first_response_on | Date-Time (new field, SLA module, on work_orders) | Actual first-response stamp; empty means no response yet. Compared against response_due. |
work_orders.resolution_due | Date-Time (new field, SLA module, on work_orders) | Deadline to close the work order. Read by scanSlaBreaches to detect a missed resolution. |
work_orders.escalation_level | Number (new field, SLA module, on work_orders) | Raised by one each time a breach is escalated. Default 0. |
work_orders.escalated_on | Date-Time (new field, SLA module, on work_orders) | Timestamp of the most recent escalation set by scanSlaBreaches. |
ppm_schedules.status / next_due_date / lead_time_days | Existing Drop Down (Active/Ended/Paused) / Date / Number | Drive generatePpmWorkOrders: only Active schedules with next_due_date on or before today are raised. |
assets.track_running_hours / current_running_hours / hours_at_last_service / runtime_threshold | Existing Decision box / Decimal x3 | Drive generatePredictiveWorkOrders: raise when current_running_hours minus hours_at_last_service reaches runtime_threshold. |
parts_master.active / current_balance / reorder_level | Existing Decision box / Decimal / Decimal | Drive recheckLowStock: alert on active parts whose current_balance is at or below reorder_level. |
Scheduled function runDailyCafmJobs - runs once daily (this is the whole module)
Sub-jobs are called with thisapp.<functionName>() because they are standalone Creator functions living in their own modules. Each returns an int count; the orchestrator only reads the count. try/catch per job means one failure is logged into the summary and the remaining jobs still run - errorCount lands in the audit_log row so the FM team can spot a bad night. Idempotency is enforced INSIDE each generator (guard on source_schedule/asset + scheduled_date == today), so re-running this function by hand the same day adds nothing. e.toString() gives a readable error; e.get("message") is an alternative. reference_type and action_1 accept the custom strings because Allow-other-choices is enabled on both.
void automation.runDailyCafmJobs()
{
runStamp = zoho.currenttime;
runId = "CAFM-DAILY-" + zoho.currentdate.toString("yyyy-MM-dd");
lines = List();
errorCount = 0;
// 1. Preventive PPM work orders
try
{
c = thisapp.generatePpmWorkOrders();
lines.add("PPM work orders raised: " + c);
}
catch (e)
{
errorCount = errorCount + 1;
lines.add("PPM FAILED - " + e.toString());
}
// 2. Predictive (runtime threshold) work orders
try
{
c = thisapp.generatePredictiveWorkOrders();
lines.add("Predictive work orders raised: " + c);
}
catch (e)
{
errorCount = errorCount + 1;
lines.add("Predictive FAILED - " + e.toString());
}
// 3. Contract / warranty / vendor expiry reminders
try
{
c = thisapp.sendContractWarrantyReminders();
lines.add("Expiry reminders sent: " + c);
}
catch (e)
{
errorCount = errorCount + 1;
lines.add("Reminders FAILED - " + e.toString());
}
// 4. Low stock re-check
try
{
c = thisapp.recheckLowStock();
lines.add("Low stock alerts: " + c);
}
catch (e)
{
errorCount = errorCount + 1;
lines.add("Low stock FAILED - " + e.toString());
}
// 5. SLA breach scan and escalation
try
{
c = thisapp.scanSlaBreaches();
lines.add("SLA breaches escalated: " + c);
}
catch (e)
{
errorCount = errorCount + 1;
lines.add("SLA scan FAILED - " + e.toString());
}
lines.add("Errors this run: " + errorCount);
summaryText = lines.toString("\n");
// One audit trail row per run
insert into audit_log
[
reference_type = "Automation run"
reference_id = runId
action_1 = "Scheduled run"
field_changed = "runDailyCafmJobs"
new_value = summaryText
changed_by = "System scheduler"
changed_on = runStamp
];
info summaryText;
}Scheduled function sendMonthlyComplianceEmail - runs monthly
Uses a trailing 30-day window via the documented date arithmetic (a Date minus an int subtracts days). Replace the placeholder recipient with the real FM manager address; the from address must be a verified sender in the app email settings. lowParts is fetched then .count() is called (do not chain the criteria and count in one expression). pct.round(1) trims to one decimal. Attach to a Monthly schedule (day 1).
void automation.sendMonthlyComplianceEmail()
{
today = zoho.currentdate;
windowStart = today - 30;
dueCount = 0;
onTime = 0;
overdue = 0;
woList = work_orders[job_type == "Preventive" && scheduled_date >= windowStart && scheduled_date <= today];
for each wo in woList
{
dueCount = dueCount + 1;
if(wo.status == "Completed" || wo.status == "Verified")
{
if(wo.on_time == true)
{
onTime = onTime + 1;
}
}
else if(wo.target_completion != null && wo.target_completion < zoho.currenttime)
{
overdue = overdue + 1;
}
}
lowParts = parts_master[active == true && current_balance <= reorder_level];
lowStock = lowParts.count();
pct = 0.0;
if(dueCount > 0)
{
pct = (onTime * 100.0) / dueCount;
}
body = "<h3>CAFM Monthly Compliance</h3>";
body = body + "<p>Window " + windowStart.toString("dd-MMM-yyyy") + " to " + today.toString("dd-MMM-yyyy") + "</p><ul>";
body = body + "<li>Preventive work orders due " + dueCount + "</li>";
body = body + "<li>Completed on time " + onTime + "</li>";
body = body + "<li>Overdue or still open " + overdue + "</li>";
body = body + "<li>PPM compliance " + pct.round(1) + " percent</li>";
body = body + "<li>Parts below reorder level " + lowStock + "</li></ul>";
sendmail
[
from : zoho.adminuserid
to : "fm.manager@joodfm.example"
subject : "CAFM Compliance " + today.toString("MMM-yyyy")
message : body
]
}runDailyCafmJobs
Single scheduled entry point, run once per day. Fans out to the five sub-jobs, isolates each with its own try/catch so one failure never aborts the rest, and records one summary line per job plus an error count in a single audit_log row.
void automation.runDailyCafmJobs()
{
// Master daily orchestrator - full runnable body is in the scripts section.
// Pattern per sub-job:
// try { c = thisapp.<subJob>(); lines.add("<label>: " + c); }
// catch (e) { errorCount = errorCount + 1; lines.add("<label> FAILED - " + e.toString()); }
// Sub-jobs called: generatePpmWorkOrders, generatePredictiveWorkOrders,
// sendContractWarrantyReminders, recheckLowStock, scanSlaBreaches.
// Then: insert into audit_log [ reference_type="Automation run" ... new_value=lines.toString("\n") ];
}generatePpmWorkOrders
Owned by the PPM module. Raises a Preventive work order for every Active ppm_schedules row whose next_due_date has arrived, guarding on schedule + scheduled_date so the same schedule cannot be raised twice in one day (idempotency).
int automation.generatePpmWorkOrders()
{
created = 0;
today = zoho.currentdate;
dueList = ppm_schedules[status == "Active" && next_due_date <= today];
for each sch in dueList
{
// idempotency - skip if a WO already exists for this schedule today
exist = work_orders[source_schedule == sch.ID && scheduled_date == today];
if(exist.count() == 0)
{
insert into work_orders
[
SITE = sch.SITE
job_type = "Preventive"
asset = sch.asset
source_schedule = sch.ID
checklist_template = sch.checklist_template
status = "Open"
priority = "Medium"
scheduled_date = today
raised_on = zoho.currenttime
];
created = created + 1;
}
}
return created;
}generatePredictiveWorkOrders
Owned by the Predictive module. Raises a Predictive work order for each asset whose (current_running_hours - hours_at_last_service) has reached runtime_threshold, with the same same-day guard so it does not double-raise.
int automation.generatePredictiveWorkOrders()
{
created = 0;
today = zoho.currentdate;
dueAssets = assets[track_running_hours == true && current_running_hours - hours_at_last_service >= runtime_threshold];
for each a in dueAssets
{
exist = work_orders[asset == a.ID && job_type == "Predictive" && scheduled_date == today];
if(exist.count() == 0)
{
insert into work_orders
[
SITE = a.SITE
job_type = "Predictive"
asset = a.ID
General_Asset = a.asset_name
status = "Open"
priority = "High"
scheduled_date = today
raised_on = zoho.currenttime
];
created = created + 1;
}
}
return created;
}sendContractWarrantyReminders
Owned by the Contracts module. Emails owners when an asset warranty_expiry, an Asset_Contract End_Date, or a vendor contract_expiry falls inside the next 30 days.
int automation.sendContractWarrantyReminders()
{
sent = 0;
today = zoho.currentdate;
horizon = today + 30;
// assets warranty
wArr = assets[warranty_expiry != null && warranty_expiry >= today && warranty_expiry <= horizon];
for each a in wArr
{
// build message, sendmail to the site owner, then: sent = sent + 1;
}
// Asset_Contract[End_Date within horizon] and vendors[contract_expiry within horizon]
// are handled the same way and add to sent.
return sent;
}recheckLowStock
Owned by the Inventory module. Finds parts_master rows where current_balance has dropped to or below reorder_level and raises or refreshes a low-stock alert for the stores team.
int automation.recheckLowStock()
{
alerts = 0;
lowParts = parts_master[active == true && current_balance <= reorder_level];
for each p in lowParts
{
// notify stores / flag part, then: alerts = alerts + 1;
}
return alerts;
}scanSlaBreaches
Owned by the SLA module. Uses the new work_orders SLA fields to find overdue first responses and overdue resolutions, raises escalation_level, stamps escalated_on, and emails the site Supervisor.
int automation.scanSlaBreaches()
{
escalated = 0;
now = zoho.currenttime;
// missed first response
noResp = work_orders[first_response_on == null && response_due != null && response_due < now && status != "Completed" && status != "Verified" && status != "Cancelled"];
for each wo in noResp
{
// wo.escalation_level = ifnull(wo.escalation_level,0) + 1; wo.escalated_on = now; sendmail to Supervisor
escalated = escalated + 1;
}
// missed resolution
noRes = work_orders[resolution_due != null && resolution_due < now && status != "Completed" && status != "Verified" && status != "Cancelled"];
for each wo in noRes
{
escalated = escalated + 1;
}
return escalated;
}sendMonthlyComplianceEmail
Monthly scheduled function. Summarises the last 30 days of Preventive work orders (due, completed on time, overdue), computes PPM compliance percent, counts parts below reorder level, and emails the FM manager. Full body in the scripts section.
void automation.sendMonthlyComplianceEmail()
{
// window = last 30 days; loop Preventive work_orders counting due / onTime / overdue;
// lowStock = parts_master[active && current_balance <= reorder_level].count();
// pct = onTime*100.0/dueCount; build HTML body; sendmail to FM manager.
}The one consolidated schedule that drives the whole app. Fires runDailyCafmJobs() which raises due PPM and predictive work orders, sends expiry reminders, re-checks low stock, scans SLA breaches, and writes an audit_log summary row.
Action: Run Deluge: thisapp.runDailyCafmJobs()
Sends the FM manager a rolled-up compliance summary for the trailing 30 days - preventive work orders due, completed on time, overdue, PPM compliance percent, and parts below reorder level.
Action: Run Deluge: thisapp.sendMonthlyComplianceEmail()
A daily run raises due PPM and predictive work orders and sends reminders
- At 02:00 the Daily CAFM automation schedule fires and runs runDailyCafmJobs().
- generatePpmWorkOrders() fetches ppm_schedules where status is Active and next_due_date is on or before today, and for each schedule that has no work order already carrying that source_schedule with scheduled_date today it inserts a Preventive work order with status Open.
- generatePredictiveWorkOrders() fetches assets where current_running_hours minus hours_at_last_service is at or above runtime_threshold and inserts a Predictive work order for each that is not already raised today.
- sendContractWarrantyReminders() emails owners for asset warranty_expiry, Asset_Contract End_Date and vendors contract_expiry that fall within the next 30 days.
- recheckLowStock() flags parts_master rows where current_balance is at or below reorder_level.
- Each sub-job returns its count; the orchestrator appends one line per job and writes a single audit_log row with reference_type Automation run, reference_id CAFM-DAILY-2026-09-18, and the full summary text in new_value.
An SLA breach is caught and escalated
- scanSlaBreaches() runs inside the same daily orchestrator, inside its own try/catch.
- It fetches open work_orders where first_response_on is empty and response_due is before now (missed first response), plus work_orders not Completed, Verified or Cancelled where resolution_due is before now (missed resolution).
- For each breach it raises escalation_level by one, stamps escalated_on with the current time, and emails the site Supervisor via sendmail.
- It returns the number of escalations and the orchestrator adds the line SLA breaches escalated N to the summary.
- If scanSlaBreaches throws, the catch records SLA scan FAILED with the message, errorCount increments, and the other four jobs still complete - the failure is visible in the audit_log row for that run.
flowchart TD
SCH["Daily Schedule 0200"] --> ORCH["runDailyCafmJobs"]
ORCH --> A["generatePpmWorkOrders"]
ORCH --> B["generatePredictiveWorkOrders"]
ORCH --> C["sendContractWarrantyReminders"]
ORCH --> D["recheckLowStock"]
ORCH --> E["scanSlaBreaches"]
A --> WO["Work Orders raised"]
B --> WO
C --> MAIL["Reminder emails"]
D --> MAIL
E --> ESC["SLA escalation emails"]
ORCH --> LOG["Audit Log summary row"]
MSCH["Monthly Schedule"] --> MON["sendMonthlyComplianceEmail"]
MON --> RPT["Compliance email"]Data-Model Additions (build these)
This module adds five data-model changes to the live cafm Zoho Creator app for Jood FM (Ministry of National Guard Health Affairs), so the asset, document, stock and work-order requirements are fully covered across all six hospital sites. It introduces an asset ownership dropdown with an On Load default, an On Validate uniqueness guard that blocks duplicate Asset IDs, a new Asset_Document form plus contract-revision fields that give O&M manuals and Asset Contracts real version and current-flag revision control, from_site and to_site lookups on Stock Movements with an applyTransfer function that moves balances between site-specific parts_master records, and acknowledged_by and acknowledged_on fields with a role-gated Acknowledge button that confirms a raised ticket before it can be assigned. Every change writes to the existing Audit Log form (the action field's link name is action_1) so the client keeps a full trail. All field link names below are the exact strings a junior types into the form builder, and the Deluge is copy-paste ready. Follow the app rules: lookups are set by record ID, loops use for-each with break (never while), form events read input.field, and datetimes use zoho.currenttime.
Developer notes & pending
- action_1 is the real link name of the Action field on audit_log (the getFormMetadata read confirmed action_1, not action). Its choices are Created, Updated, Status change, Deleted, Imported, and Allow Other Choice is on, so custom strings are accepted; reference_type choices are Work order, Stock movement, Asset, PPM schedule, Parts master (also other-choice enabled).
- stock_movements.part and work_order are MULTI_SELECT_LOOKUP fields in the live app. applyTransfer assumes one part per Transfer row and reads mv.part as a single ID; if the client ever enters multiple parts per movement, loop mv.part and apply each. Add a small On Validate on Stock Movements to require exactly one part when movement_type is Transfer.
- from_site and to_site are new SINGLE_SELECT_LOOKUP fields to SITE. On the Transfer layout, pre-fill from_site with the movement's SITE and hide from_site/to_site for non-Transfer types via a layout rule keyed on movement_type.
- Setting input.version and input.is_current inside On Success persists onto the just-saved record in Creator; this is why the authoritative version is computed there rather than only On Load (where asset/doc_type may still be blank on a fresh form).
- assets.ownership uses a fixed dropdown; if the client prefers a shared list, build it as a Global Picklist instead so all four values stay consistent.
- Uniqueness is scoped per SITE (asset IDs can repeat across the six hospitals). If MNGHA wants one global Asset ID space, remove the && SITE == input.SITE clause in the Assets On Validate guard.
- getSiteForUser(email) returns 0 when the login is not in the Supervisor form. The Acknowledge button treats 0 as not-authorised. If facility admins must also acknowledge, add an OR check against an admin Supervisor row or a role check before isSup.
- Make Asset_Document.version and Asset_Contract.version read-only on their layouts so users cannot overwrite the auto-stamped number.
- Report to build alongside these: an Asset Documents list filtered to is_current = true grouped by asset, and a Tickets Awaiting Acknowledgement list filtered to acknowledged_on is null, so supervisors see their queue.
| Field | Type | Notes |
|---|---|---|
assets.ownership | Dropdown | Add on the Assets form. Choices exactly: Owned, Leased, Rented, Client-supplied. Enable Allow Other Choice = No. On Load defaults a new record to Client-supplied (MNGHA owns most plant). |
Asset_Document (NEW FORM) | Form (link_name Asset_Document) | Create a new form named Asset Document, link name Asset_Document. Holds one row per document revision. Fields listed below. |
Asset_Document.asset | Lookup single-select -> assets | Lookup field, single-select display, reference form Assets. Stores the parent asset ID. |
Asset_Document.doc_type | Dropdown | Choices exactly: O&M Manual, Drawing, Certificate, Contract. |
Asset_Document.title | Single Line | Document title. |
Asset_Document.version | Number | Auto-stamped 1,2,3 by nextDocVersion / On Success. Do not let users edit; set field permission to read-only on the layout. |
Asset_Document.file | File Upload | The actual manual/drawing/certificate file. |
Asset_Document.is_current | Decision box (checkbox) | True only on the newest revision for that asset + doc_type. Initial value true; managed by On Success. |
Asset_Document.effective_date | Date | Date the revision takes effect. |
Asset_Document.revision_notes | Multi Line | What changed in this revision. |
Asset_Document.uploaded_by | Single Line | Stamped with zoho.loginuserid on On Load. |
Asset_Document.uploaded_on | Date-Time | Stamped with zoho.currenttime on On Load. |
Asset_Contract.version | Number | Add to the existing Asset_Contract form. Auto-stamped by its On Success. Read-only on layout. |
Asset_Contract.is_current | Decision box (checkbox) | True only on the latest contract for that asset. Initial value true; managed by On Success. |
Asset_Contract.superseded_by | Single Line | On the prior contract row, holds the ID of the contract that replaced it. |
Asset_Contract.revision_notes | Multi Line | Why the contract was renewed/replaced. |
stock_movements.from_site | Lookup single-select -> SITE | Add to Stock Movements. Source site of a Transfer. Reference form SITE. |
stock_movements.to_site | Lookup single-select -> SITE | Add to Stock Movements. Destination site of a Transfer. Reference form SITE. |
work_orders.acknowledged_by | Single Line | Add to Work Orders. Login email of the supervisor who confirmed the raised ticket. |
work_orders.acknowledged_on | Date-Time | Add to Work Orders. Stamped when the Acknowledge button is clicked. |
work_orders.acknowledge_ticket | Button | Add a Button field to the Work Orders form (edit view), link name acknowledge_ticket, label Acknowledge. On-click runs the acknowledge Deluge below. |
Assets — On Validate
Blocks a duplicate Asset ID within the same site. Bounded: the for-each breaks on the first real duplicate. cancel submit stops the save. Scoped per SITE because Asset IDs may legitimately repeat across the six hospitals; drop the SITE clause if IDs must be globally unique.
// Assets - On Validate
dupFound = false;
for each d in assets[asset_id == input.asset_id && SITE == input.SITE]
{
// on add input.ID is null; on edit skip the row being edited
if(input.ID == null || d.ID != input.ID)
{
dupFound = true;
break;
}
}
if(dupFound)
{
alert "Asset ID " + input.asset_id + " already exists at this site. Enter a unique Asset ID.";
cancel submit;
}Asset_Document — On Load
Stamps who/when, defaults the current flag, and previews the version. When the form is opened from an asset (asset + doc_type pre-filled via URL) nextDocVersion computes the real next number; otherwise it shows 1. On Success re-stamps the authoritative version at save time.
// Asset_Document - On Load (Created/blank record)
if(input.ID == null)
{
input.uploaded_by = zoho.loginuserid;
input.uploaded_on = zoho.currenttime;
if(input.is_current == null)
{
input.is_current = true;
}
if(input.asset != null && input.doc_type != null)
{
input.version = thisapp.functions.nextDocVersion(input.asset.toString(), input.doc_type);
}
else
{
input.version = 1;
}
}Asset_Document — On Successful form submission
Authoritative revision control. Reads all prior rows for the same asset + doc_type (newest first), turns their is_current off, stamps this row's version = priorMax + 1 and is_current = true, then writes a Created audit row against the asset. Assigning input.field in On Success persists onto the just-saved record.
// Asset_Document - On Success (add)
priorMax = 0;
isFirst = true;
for each pd in Asset_Document[asset == input.asset && doc_type == input.doc_type && ID != input.ID] sort by version desc
{
if(isFirst)
{
priorMax = ifnull(pd.version,0);
isFirst = false;
}
if(pd.is_current == true)
{
pd.is_current = false;
}
}
newVer = priorMax + 1;
input.version = newVer;
input.is_current = true;
// resolve the asset SITE for the audit row
aSite = null;
for each a in assets[ID == input.asset]
{
aSite = a.SITE;
break;
}
insert into audit_log
[
SITE = aSite
reference_type = "Asset"
reference_id = input.asset.toString()
action_1 = "Created"
field_changed = input.doc_type + " revision"
old_value = ""
new_value = input.doc_type + " v" + newVer + " " + input.title
changed_by = zoho.loginuserid
changed_on = zoho.currenttime
];Asset_Contract — On Successful form submission
Same current-flag pattern for contracts. The lookup field on Asset_Contract is named assets. Turns prior contracts' is_current off, stamps their superseded_by with this contract's ID, and sets this row current with the next version.
// Asset_Contract - On Success (add)
priorMax = 0;
isFirst = true;
for each pc in Asset_Contract[assets == input.assets && ID != input.ID] sort by version desc
{
if(isFirst)
{
priorMax = ifnull(pc.version,0);
isFirst = false;
}
if(pc.is_current == true)
{
pc.is_current = false;
pc.superseded_by = input.ID.toString();
}
}
input.version = priorMax + 1;
input.is_current = true;Stock Movements — On Successful form submission
Fires applyTransfer only for Transfer rows, so the existing Receipt/Issue/Return/Adjustment behaviour is untouched. All balance maths, the destination find-or-create, and the two audit legs live inside applyTransfer.
// Stock Movements - On Success (add)
if(input.movement_type == "Transfer")
{
thisapp.functions.applyTransfer(input.ID.toString());
}Work Orders — Acknowledge button (on click)
Who can do it: only the supervisor mapped to the ticket's site. getSiteForUser(email) returns that supervisor's SITE id (0 if the login is not a supervisor), and the button refuses unless it equals input.SITE. Only an Open or Draft ticket can be acknowledged, and only once. Structured with else-if (no bare return) so it is valid in a form button block.
// Work Orders - Acknowledge button on click
loginEmail = zoho.loginuserid;
allowedSite = thisapp.functions.getSiteForUser(loginEmail);
okStatus = input.status == "Open" || input.status == "Draft";
isSup = allowedSite != 0 && input.SITE == allowedSite;
if(input.acknowledged_on != null)
{
alert "This ticket is already acknowledged.";
}
else if(!okStatus)
{
alert "Only an Open or Draft ticket can be acknowledged.";
}
else if(!isSup)
{
alert "Only the supervisor of this site can acknowledge this ticket.";
}
else
{
input.acknowledged_by = loginEmail;
input.acknowledged_on = zoho.currenttime;
insert into audit_log
[
SITE = input.SITE
reference_type = "Work order"
reference_id = input.work_order_number
action_1 = "Status change"
field_changed = "acknowledged"
old_value = ""
new_value = "Acknowledged by " + loginEmail
changed_by = loginEmail
changed_on = zoho.currenttime
];
}Work Orders — On Validate
Enforces the acknowledge-before-assign gate. A ticket cannot move to Assigned until acknowledged_on is stamped. cancel submit blocks the save and shows the alert.
// Work Orders - On Validate
if(input.status == "Assigned" && input.acknowledged_on == null)
{
alert "Acknowledge the ticket before assigning it to a technician.";
cancel submit;
}nextDocVersion
Returns the next version number (highest existing + 1, or 1 if none) for a given asset and document type. Called from Asset_Document On Load to preview the version. Standalone function, return type int, args: string assetId, string docType.
int nextDocVersion(string assetId, string docType)
{
nextVer = 1;
// newest revision first; break after the first row makes this bounded (no while loop)
docs = Asset_Document[asset == assetId.toLong() && doc_type == docType] sort by version desc;
for each d in docs
{
nextVer = ifnull(d.version,0) + 1;
break;
}
return nextVer;
}applyTransfer
Executes an inter-site stock Transfer: decrements the source-site part balance, increments (or creates) the destination-site part record, writes balance_after/signed_quantity on the movement, and logs both legs to Audit Log. Standalone function, return type void, arg: string movementId. Called from Stock Movements On Success when movement_type is Transfer.
void applyTransfer(string movementId)
{
mvList = stock_movements[ID == movementId.toLong()];
for each mv in mvList
{
// guard: only act on Transfer rows with a quantity and both sites set
qty = ifnull(mv.quantity,0.0);
fromSite = mv.from_site;
toSite = mv.to_site;
if(mv.movement_type == "Transfer" && qty > 0 && fromSite != null && toSite != null)
{
// resolve the source part (mv.part holds the parts_master ID)
partCode = "";
partName = "";
uom = "";
srcBalAfter = 0.0;
for each sp in parts_master[ID == mv.part]
{
partCode = sp.part_code;
partName = sp.part_name;
uom = sp.unit_of_measure;
srcBalAfter = ifnull(sp.current_balance,0.0) - qty;
sp.current_balance = srcBalAfter;
break;
}
// find or create the destination-site part with the same code
destBalAfter = qty;
destFound = false;
for each dp in parts_master[part_code == partCode && SITE == toSite]
{
destFound = true;
destBalAfter = ifnull(dp.current_balance,0.0) + qty;
dp.current_balance = destBalAfter;
break;
}
if(!destFound)
{
newDestId = insert into parts_master
[
SITE = toSite
part_code = partCode
part_name = partName
unit_of_measure = uom
current_balance = qty
active = true
];
}
// stamp the movement (source leg)
mv.signed_quantity = -qty;
mv.balance_after = srcBalAfter;
// audit the out leg
insert into audit_log
[
SITE = fromSite
reference_type = "Stock movement"
reference_id = movementId
action_1 = "Updated"
field_changed = "current_balance transfer out"
old_value = (srcBalAfter + qty).toString()
new_value = srcBalAfter.toString()
changed_by = zoho.loginuserid
changed_on = zoho.currenttime
];
// audit the in leg
insert into audit_log
[
SITE = toSite
reference_type = "Stock movement"
reference_id = movementId
action_1 = "Updated"
field_changed = "current_balance transfer in"
old_value = (destBalAfter - qty).toString()
new_value = destBalAfter.toString()
changed_by = zoho.loginuserid
changed_on = zoho.currenttime
];
}
break;
}
}Blank ownership is set to Client-supplied so every asset carries an owner without user effort.
Action: input.ownership = Client-supplied
Bounded for-each guard that blocks a duplicate asset_id within the same SITE.
Action: alert plus cancel submit on duplicate
Auto-numbers versions and keeps a single is_current row per asset and doc_type, with an audit trail.
Action: nextDocVersion preview then On Success stamps version and current flag and inserts audit
Mirrors document revision control for Asset Contracts, marking the prior contract superseded.
Action: set prior is_current false and superseded_by, stamp new version and is_current
Moves quantity from the source-site part to the destination-site part, creating the destination record if missing.
Action: thisapp.functions.applyTransfer(input.ID)
Site supervisor confirms a raised ticket; stamps who and when and audits it.
Action: stamp acknowledged_by and acknowledged_on when getSiteForUser matches the ticket SITE
Prevents a ticket reaching Assigned until it has been acknowledged.
Action: cancel submit when status is Assigned and acknowledged_on is null
Set asset ownership
- Open Assets and click Add.
- On Load defaults ownership to Client-supplied.
- Change it to Leased for a rented chiller and Save.
- The stored asset now carries ownership = Leased and reports/filters can group by owner.
Blocked duplicate Asset ID
- At King Fahad site an asset HVAC-CH-01 already exists.
- A junior adds a new asset, types asset_id = HVAC-CH-01, same SITE.
- On Validate loops assets[asset_id == input.asset_id && SITE == input.SITE], finds a different ID, sets dupFound = true and breaks.
- cancel submit fires; the alert asks for a unique Asset ID and the record is not saved.
Upload a new O&M revision that supersedes the old
- Chiller CH-01 already has Asset_Document O&M Manual v1 with is_current = true.
- From the asset, click Add Document; On Load stamps uploaded_by/on and previews version 2 via nextDocVersion.
- Pick doc_type O&M Manual, attach the revised PDF, add revision_notes, Save.
- On Success sets v1 is_current = false, stamps this row version = 2 and is_current = true, and writes a Created row to Audit Log. The asset now shows exactly one current manual.
Inter-site transfer moves balance
- Store issues a Transfer of 10 filters from Site A to Site B: movement_type = Transfer, part = FLT-100, quantity = 10, from_site = A, to_site = B.
- On Success calls applyTransfer(input.ID).
- Source parts_master (FLT-100 at A) drops by 10; the movement records signed_quantity = -10 and balance_after = the new A balance.
- Destination FLT-100 at B is found and raised by 10 (or created with balance 10 if B had none). Two Audit Log rows record the out and in legs.
Acknowledge a ticket
- A Corrective ticket is raised (status Open) at Site A.
- The Site A supervisor opens it and clicks Acknowledge.
- getSiteForUser returns Site A which equals input.SITE, so the guard passes; acknowledged_by and acknowledged_on are stamped and an audit row is written.
- When the coordinator later sets status = Assigned, On Validate sees acknowledged_on is filled and allows the save. Trying to assign an un-acknowledged ticket is blocked.
flowchart TD A["Assets form"] A1["ownership dropdown On Load default"] A2["On Validate unique asset_id guard"] DOC["Asset_Document new form"] DOCV["nextDocVersion function"] DOCS["On Success current flag plus audit"] CON["Asset_Contract version and is_current"] SM["Stock Movements form"] SMF["from_site and to_site lookups"] APT["applyTransfer function"] PM["Parts Master balances per site"] WO["Work Orders form"] ACK["acknowledged_by and acknowledged_on"] ACKB["Acknowledge button role gated"] GATE["On Validate block assign before ack"] AUD["Audit Log"] A --> A1 A --> A2 A --> DOC DOC --> DOCV DOC --> DOCS DOCS --> AUD A --> CON CON --> AUD SM --> SMF SM --> APT APT --> PM APT --> AUD WO --> ACK WO --> ACKB ACKB --> GATE