ZONIC TECH SOLUTIONSZoho implementation partner
Document ref  ZTS-CAFM-BS-01
Prepared  July 2026
Companion to  ZTS-CAFM-01
Build specification · Zoho Creator

Every form, field and routine behind the CAFM application.

The technical counterpart to the CAFM proposal. Seventeen forms, 189 fields and seven Deluge routines — with the reasoning behind each design decision, so the next person to touch this system can tell an intentional constraint from an accident.

Companion to  CAFM Solution Proposal — ZTS-CAFM-01
17Creator forms
189Field definitions
7Deluge routines
4Roles
01

Scope and how to read this

This is the build document for the Zoho Creator application described in the CAFM proposal. It is written for whoever builds it, not for whoever buys it — the commercial case, the timeline and the exclusions live in the proposal and are not repeated here.

Provenance.  This specification was authored fresh from the CAFM proposal source (tpl.html and gen2.py in the handoff bundle), which is the current and authoritative description of scope. It is not a transcription of the earlier CAFM_Creator_Build_Spec.md — that file was referenced in the handoff README but was not included in the bundle and could not be located. Where the two disagree, reconcile them before building, and treat the proposal as the tie-breaker.

Two rules govern everything below.

The proposal is the contract. Every form, field and routine here traces to something the client has been shown. Where the spec adds detail the proposal does not mention, that detail is an implementation choice and is marked as such. Where a design decision was made to protect an assumption in section 09 of the proposal, it says so.

Spare parts live in Creator. Zoho Inventory was deliberately excluded. Parts, stock movements and consumption are all Creator forms, so a technician logs a part on the same screen as the job and there is no sync to reconcile. The cost of that decision — no purchase orders, no VAT-compliant procurement trail — is documented in the proposal exclusions and is not a gap in this build.

Reads this forWhere to start
Building the applicationSection 04 form by form, then section 05 in the order given by section 09.
Reviewing the designSections 02 and 03 for shape, then the design notes under each routine in 05.
Testing itThe completion gate in the state machine and the idempotency guard in the scheduler — those are where it breaks.
Taking it over laterSection 05. The Deluge layer is where the business rules are; the forms are just shape.
02

Application map

Seventeen forms in six groups, 189 fields in total. The groups are not folders — they are the order things have to be built in, because each depends on the one above it.

Locations  ·  4 forms

Four levels, each belonging to exactly one level above. Entered once, referenced everywhere.

Masters  ·  3 forms

The lists that carry defaults. Time spent here is time not spent on every asset afterwards.

Asset register  ·  2 forms

The spine, plus the append-only history of what changed and who changed it.

Scheduling  ·  3 forms

Reusable checks and the rules that turn them into work without anyone asking.

Work execution  ·  3 forms

What a technician actually opens. One job form, whatever raised it.

Spare parts  ·  2 forms

Catalogue and ledger. Inside Creator by design — no Inventory, no sync.

Why seventeen and not seven.  Every form here exists because something downstream needs to point at it. The four location levels look like an indulgence until a fault report has to name a room; the separation of Template Items from Task Checklist looks like duplication until a client revises a checklist and every completed job silently rewrites itself. Denormalising deliberately — copying checks onto the job, writing Location Path down onto the asset — is what keeps the history honest and the reports fast.
03

The location and asset spine

Location is entered once, at the room, and inherited upward. An asset points at a room and nothing else — building, floor and zone are derived. This is the single most important structural decision in the build, because everything downstream reports on it.

Location hierarchy and the asset spineFour levels of location, then the asset that sits in itBuildingsOne per structureFloorsBelongs to a buildingZonesBelongs to a floorRoomsWhere a job is sentAssetsThe spine of the systemLocation Path is written down onto the asset, so search and reporting never traverse four lookupsBuildings › Floors › Zones › Rooms — each level belongs to exactly one level above it
Four levels of location, then the asset. Location Path is denormalised onto both the asset and the work order so that search and Analytics never traverse the chain.

What this buys

DecisionConsequence
Asset points at Room onlyOne lookup to maintain. Moving an asset is a single field change, and its whole path follows.
Location Path denormalisedReports and the technician's search work on one indexed field instead of a four-table join.
Codes unique and stableRoom and asset codes end up on physical tags. Renumbering after go-live means re-tagging a building.
Level Index on FloorsFloors sort physically — basement, ground, mezzanine, level 3 — rather than alphabetically.
04

Form definitions

All seventeen forms, field by field. * marks a required field. Types are Creator field types as written in the form builder. Fields described as written by a routine should be read-only on every layout — if a user can type into them, the ledger and the schedule stop being trustworthy.

BuildingsBuildings
Locations
10  fields  ·  Tens
Top of the four-level location hierarchy. One record per physical building on the site.
FieldTypeRule / note
Building Code*Single LineUnique. Feeds the location path and the asset ID prefix.
Building Name*Single LineDisplay name used across reports.
Site / CampusSingle LineGroups buildings where a client runs more than one site.
AddressMulti LinePostal address for vendor call-out.
CitySingle Line
Total FloorsNumberSense check against the Floors records created.
Gross Area (sqm)DecimalUsed for area-normalised cost reporting in Analytics.
Handover DateDateDrives warranty and DLP expiry views.
Facility ManagerLookup → TechniciansEscalation point for every asset below this building.
ActiveCheckboxDefault true. Inactive buildings drop out of all selection lists.
FloorsFloors
Locations
6  fields  ·  Hundreds
Second level. Always belongs to exactly one building.
FieldTypeRule / note
Building*Lookup → BuildingsParent. Cascades into every location path below.
Floor Code*Single LineUnique within the building, not globally.
Floor Name*Single Linee.g. Ground, Mezzanine, Level 3.
Level IndexNumberSort order. Basements negative, ground zero — so floors list in physical order rather than alphabetically.
Floor Area (sqm)Decimal
ActiveCheckboxDefault true.
ZonesZones
Locations
5  fields  ·  Hundreds
Third level. Splits a floor into functional areas so a fault report narrows quickly.
FieldTypeRule / note
Floor*Lookup → FloorsParent.
Zone Code*Single LineUnique within the floor.
Zone Name*Single Line
Zone TypeDropdownPlant room / Office / Common area / Retail / Car park / External / Riser. Drives permit rules on the work order.
ActiveCheckboxDefault true.
RoomsRooms
Locations
7  fields  ·  Thousands
Fourth and final level. The point a technician is actually sent to.
FieldTypeRule / note
Zone*Lookup → ZonesParent.
Room Code*Single LineGlobally unique. Printed on asset tags.
Room NameSingle Line
Room NumberSingle LineThe number on the door, which often differs from the code.
Location PathFormulaBuilding › Floor › Zone › Room, concatenated. Denormalised deliberately so reports and search do not traverse four lookups.
Access NotesMulti LineKey holder, permit requirement, restricted hours.
ActiveCheckboxDefault true.
VendorsVendors
Masters
12  fields  ·  Tens
Manufacturers, service contractors and parts suppliers. One list, typed by role.
FieldTypeRule / note
Vendor Code*Single LineUnique.
Vendor Name*Single Line
Vendor Type*Multi SelectOEM / Service contractor / Parts supplier / Consultant. Multi-select because most vendors are more than one.
Contact PersonSingle Line
EmailEmailUsed by the contract-expiry notification.
PhonePhone
Contract ReferenceSingle Line
Contract StartDate
Contract ExpiryDateDrives the 60-day expiry warning report.
Response SLA (hours)NumberContracted response time, reported against actual.
Documents FolderURLWorkDrive folder holding the signed contract.
ActiveCheckboxDefault true.
Asset CategoriesAsset_Categories
Masters
10  fields  ·  Tens
The classification that carries the defaults. Getting this right is what keeps asset creation fast.
FieldTypeRule / note
Category Code*Single LineUnique. Becomes the asset ID prefix.
Category Name*Single Linee.g. Chiller, Fire pump, Passenger lift.
Discipline*DropdownHVAC / Electrical / Plumbing / Fire & life safety / Vertical transport / Civil / BMS. Drives technician routing.
Default Checklist TemplateLookup → Checklist TemplatesCopied onto every schedule created for this category.
Default PPM FrequencyDropdownPre-fills the schedule so the supervisor confirms rather than composes.
Default CriticalityDropdownCritical / High / Medium / Low.
Track Running HoursCheckboxTurns on the predictive path for assets in this category.
Default Runtime ThresholdDecimalService interval in running hours.
Asset ID Prefix*Single LineTwo-to-four characters. Consumed by the asset ID generator.
ActiveCheckboxDefault true.
TechniciansTechnicians
Masters
9  fields  ·  Tens
Everyone who touches a work order, including supervisors and the storekeeper.
FieldTypeRule / note
Employee Code*Single LineUnique.
Full Name*Single Line
Zoho User*UsersBinds the record to a Creator login. Required for the technician's own-records-only filter to work.
Role*DropdownAdministrator / Supervisor / Technician / Storekeeper. Mirrors the permission set.
DisciplineMulti SelectMatches the category discipline for assignment suggestions.
PhonePhone
EmailEmailAssignment notifications.
ShiftDropdownDay / Night / Rotating. Reference only — SLA clocks run continuously (see assumptions).
ActiveCheckboxDefault true. Inactive technicians cannot be assigned new work.
AssetsAssets
Asset register
22  fields  ·  Thousands
The spine of the system. Every schedule, work order and part consumption points back here.
FieldTypeRule / note
Asset ID*Single LineUnique, system-generated on create, read-only thereafter. See the asset ID generator routine.
Asset Name*Single Line
Asset Category*Lookup → Asset CategoriesPulls criticality, checklist and runtime defaults on selection.
Room*Lookup → RoomsThe single location field. Building, floor and zone are derived, never entered twice.
Location PathFormulaFetched from the room. Denormalised for search and reporting.
MakeSingle Line
ModelSingle Line
Serial NumberSingle Line
ManufacturerLookup → Vendors
Service VendorLookup → VendorsWho is called when this fails, if not in-house.
Install DateDate
Commissioning DateDate
Warranty ExpiryDateFlagged on the work order so warranty work is not paid for twice.
Criticality*DropdownCritical / High / Medium / Low. Defaults from category, overridable per asset.
Status*DropdownIn service / Under repair / Standby / Decommissioned. Decommissioned assets pause their schedules.
Track Running HoursCheckboxDefaults from category.
Current Running HoursDecimalEntered manually — see the running-hours assumption.
Runtime ThresholdDecimalHours between services. Compared against hours-since-last-service.
Hours At Last ServiceDecimalWritten by the state machine on completion of a predictive or preventive job.
Documents FolderURLWorkDrive folder for this asset's O&M manuals.
PhotoImage
NotesMulti Line
Audit LogAudit_Log
Asset register
8  fields  ·  Tens of thousands
Append-only change history. Written by Deluge, never edited by a user — there is no edit layout.
FieldTypeRule / note
Reference Type*DropdownAsset / Work order / PPM schedule / Stock movement / Parts master.
Reference ID*Single LineThe business key of the record changed, not the Creator row ID.
Action*DropdownCreated / Updated / Status change / Deleted / Imported.
Field ChangedSingle Line
Old ValueMulti Line
New ValueMulti Line
Changed By*UsersDefault zoho.loginuser.
Changed On*Date-TimeDefault zoho.currenttime.
Checklist TemplatesChecklist_Templates
Scheduling
10  fields  ·  Tens
A reusable set of checks per asset category. Written once, copied into every job.
FieldTypeRule / note
Template Code*Single LineUnique.
Template Name*Single Line
Asset Category*Lookup → Asset Categories
Job Type*DropdownPreventive / Corrective / Predictive / Any.
FrequencyDropdownThe interval this template is written for — a monthly check list differs from an annual one.
Estimated Duration (minutes)NumberFeeds technician workload reporting.
Permit RequiredCheckboxSurfaces a permit warning on every work order using this template.
Safety NotesMulti LineIsolation, PPE, lock-out requirements. Shown to the technician before the first check.
VersionNumberDefault 1. Increment rather than edit in place, so completed jobs keep the checks that were actually performed.
ActiveCheckboxDefault true.
Template ItemsTemplate_Items
Scheduling
8  fields  ·  Hundreds
The individual checks inside a template. One row per line the technician will tick.
FieldTypeRule / note
Template*Lookup → Checklist TemplatesParent.
Sequence*NumberDisplay order. Numbered in tens so items can be inserted later without renumbering.
Check Description*Multi LinePhrased as an instruction, not a question.
Response Type*DropdownPass/Fail / Yes-No / Numeric reading / Text / Photo.
UnitSingle LineFor numeric readings — bar, °C, A, dB.
Min AcceptableDecimal
Max AcceptableDecimalReadings outside min–max flag the check as out of range on the job.
MandatoryCheckboxMandatory items block work order completion until answered.
PPM SchedulesPPM_Schedules
Scheduling
13  fields  ·  Thousands
The rule that generates work. One record per asset per maintenance regime.
FieldTypeRule / note
Schedule Code*Single LineUnique, system-generated.
Asset*Lookup → Assets
Checklist Template*Lookup → Checklist TemplatesDefaults from the asset's category.
Frequency*DropdownDaily / Weekly / Monthly / Quarterly / Half-yearly / Yearly / Custom days.
Custom Interval (days)NumberRequired only when frequency is Custom days.
Start Date*DateFirst occurrence.
Next Due Date*DateMaintained by the scheduler. The single field the daily routine queries.
Last Completed DateDateWritten by the state machine on job completion.
Lead Time (days)NumberDefault 3. How many days before the due date the work order is raised, so the technician has notice.
Assigned TechnicianLookup → TechniciansDefault assignee for generated jobs.
Estimated Duration (minutes)NumberDefaults from the template.
Status*DropdownActive / Paused / Ended. Only Active schedules are picked up by the daily run.
End DateDateOptional. Past this date the schedule stops generating.
Work OrdersWork_Orders
Work execution
24  fields  ·  Tens of thousands
One record type for all three job types. Preventive, corrective and predictive differ by field, never by form.
FieldTypeRule / note
Work Order Number*Single LineUnique, system-generated. WO-YYYYMM-nnnn.
Job Type*DropdownPreventive / Corrective / Predictive. Set by whichever routine created the record.
Asset*Lookup → Assets
Location PathFormulaFetched from the asset at creation.
Source ScheduleLookup → PPM SchedulesPopulated on preventive jobs only. The back-reference the state machine uses to book the next occurrence.
Desk Ticket IDSingle LinePopulated on corrective jobs only, written by Zoho Flow. The key that carries status back to the requester.
Checklist TemplateLookup → Checklist TemplatesCopied into Task Checklist rows at creation.
Priority*DropdownCritical / High / Medium / Low. Defaults from asset criticality.
Status*DropdownDraft / Open / Assigned / In progress / On hold / Completed / Verified / Cancelled. Transitions are enforced — see the state machine.
Assigned TechnicianLookup → Technicians
Raised On*Date-TimeDefault zoho.currenttime.
Scheduled DateDateThe date the work is due, distinct from the date it was raised.
Target CompletionDate-TimeDerived from priority. Drives the overdue flag.
Started OnDate-TimeStamped on transition to In progress.
Completed OnDate-TimeStamped on transition to Completed.
On TimeCheckboxSet by the state machine: Completed On ≤ Target Completion.
Completion %DecimalRolled up from Task Checklist rows answered.
Downtime (hours)DecimalAsset out of service. Feeds the downtime dashboard.
Failure CauseDropdownWear / Misuse / Design / Environment / No fault found / Consumable. Corrective and predictive only.
Work PerformedMulti Line
PhotosFile UploadMultiple. Before-and-after evidence.
Technician Sign-offCheckboxThe technician's own confirmation. Cannot be set by a supervisor.
Verified ByLookup → TechniciansSupervisor closing the loop. Drives the Verified status.
Verified OnDate-Time
Task ChecklistTask_Checklist
Work execution
14  fields  ·  Hundreds of thousands
The technician's actual worksheet. Rows are copied from Template Items when the job is created, so completed jobs keep their own copy of the checks.
FieldTypeRule / note
Work Order*Lookup → Work OrdersParent.
Sequence*NumberCarried over from the template item.
Check Description*Multi LineCopied, not looked up — the template can change later without rewriting history.
Response Type*DropdownCopied from the template item.
ResultDropdownPass / Fail / Not applicable.
ReadingDecimalFor numeric checks.
UnitSingle LineCopied.
Min AcceptableDecimalCopied, so the range test survives a template revision.
Max AcceptableDecimalCopied.
Within RangeCheckboxSet on save when a reading is entered.
MandatoryCheckboxCopied. Unanswered mandatory rows block completion.
RemarksMulti Line
PhotoImage
Completed OnDate-TimeStamped when the row is first answered.
Parts UsedParts_Used
Work execution
8  fields  ·  Tens of thousands
Consumption captured on the job itself. This is the record that makes the stock balance trustworthy.
FieldTypeRule / note
Work Order*Lookup → Work OrdersParent.
Part*Lookup → Parts Master
Quantity Used*DecimalValidated against the current balance before save.
UnitFormulaFetched from the part.
Unit CostDecimalSnapshot of standard cost at the time of issue, so historic job cost does not move when the price does.
Line ValueFormulaQuantity × unit cost.
Issued On*Date-TimeDefault zoho.currenttime.
Stock MovementLookup → Stock MovementsWritten back by the ledger routine. The audit link between consumption and balance.
Parts MasterParts_Master
Spare parts
12  fields  ·  Hundreds
The catalogue. Balance is derived from the ledger, never typed.
FieldTypeRule / note
Part Code*Single LineUnique.
Part Name*Single Line
DescriptionMulti Line
Unit of Measure*DropdownEach / Litre / Metre / Kilogram / Set / Roll.
Store LocationSingle LineFree text. One store only — see the single-store assumption.
Bin / ShelfSingle Line
Reorder Level*DecimalThe threshold the daily low-stock check compares against.
Current BalanceDecimalRead-only on every layout. Maintained solely by the stock ledger routine.
Standard CostDecimalUsed to value consumption. Not a purchase price — procurement is out of scope.
Preferred VendorLookup → Vendors
Compatible CategoriesMulti Select → Asset CategoriesFilters the part picker on the work order to plausible parts.
ActiveCheckboxDefault true.
Stock MovementsStock_Movements
Spare parts
11  fields  ·  Tens of thousands
The ledger. Every change to a balance is a row here — there is no other way stock moves.
FieldTypeRule / note
Movement Number*Single LineUnique, system-generated. SM-YYYYMM-nnnn.
Part*Lookup → Parts Master
Movement Type*DropdownReceipt / Issue / Return / Adjustment / Transfer.
Quantity*DecimalAlways positive. Direction comes from the movement type, not the sign.
Signed QuantityFormulaReceipt and Return positive, Issue negative, Adjustment as entered. What the balance actually sums.
Balance AfterDecimalWritten by the ledger routine. Makes the ledger auditable without replaying it.
Work OrderLookup → Work OrdersSet on issues and returns raised from a job.
ReferenceSingle LineDelivery note or invoice number for receipts. Free text — there is no purchase order in this build.
Moved On*Date-TimeDefault zoho.currenttime.
Moved By*UsersDefault zoho.loginuser.
RemarksMulti LineMandatory on Adjustment, so unexplained corrections cannot be saved.
05

The Deluge layer

Seven routines. Five of them — the scheduler, the next-due helper, the predictive trigger, the state machine and the asset ID generator — are named in the proposal. The stock ledger and the low-stock check are what the Creator-only parts design requires in place of Zoho Inventory.

Runtime write pathThree ways a job is bornPPM schedulerDaily 02:00, lead time reachedDesk ticketCorrective, arrives via FlowRunning hoursThreshold crossed on the assetWork OrdersOne record type for all three job types — the state machine governs every transition17 formsTask ChecklistChecks copied from the templateParts UsedConsumption on the job screenAsset + ScheduleBaseline reset, next date bookedStock MovementsBalance recomputed by summing the ledgerNothing leaves Creator.No sync, nothing to reconcile.
However a job is born, it lands in one Work Orders record, and completion writes back to the checklist, the ledger, the asset and the schedule.
Read the design notes, not just the code.  The code below is a working sketch of intent, not a paste-ready deployment — field API names must be confirmed against the forms as actually built, and every routine needs its own error handling and a dry-run against seeded data before it touches live records. The reasoning under each routine is the part that matters: it is what stops the next person from 'simplifying' a guard that exists for a reason.
Asset ID generatorCAFM.generateAssetId(int)

Issues the permanent, human-readable asset identifier. It runs once and never again: the guard on an existing ID is what stops a re-save from renumbering an asset that is already stencilled on a plate in a plant room.

Bound toAssets — On Add › On Success
RunsOnce per new asset
CAFM.generateAssetId(int)DELUGE
// Assets > On Add > On Success
void CAFM.generateAssetId(int assetRecordId)
{
	rec = Assets[ID == assetRecordId];
	if(rec.count() == 0)
	{
		return;
	}
	// an asset is numbered once, ever
	if(rec.Asset_ID != null && rec.Asset_ID.trim() != "")
	{
		return;
	}
	prefix = rec.Asset_Category.Asset_ID_Prefix;
	if(prefix == null || prefix.trim() == "")
	{
		prefix = "AST";
	}
	yr = zoho.currentdate.toString("yyyy");
	stem = prefix + "-" + yr + "-";

	// highest number already issued under this prefix and year
	issued = Assets[Asset_ID.startsWith(stem)] sort by Asset_ID desc;
	seq = 1;
	if(issued.count() > 0)
	{
		tail = issued.Asset_ID.substring(stem.length());
		seq = tail.toLong() + 1;
	}
	padded = seq.toString();
	while(padded.length() < 4)
	{
		padded = "0" + padded;
	}
	rec.Asset_ID = stem + padded;

	thisapp.CAFM.writeAudit("Asset",rec.Asset_ID,"Created","","",rec.Asset_Name);
}

Why it is written this way

  • Sequence is per prefix per year, so IDs read as CHW-2026-0042 rather than as a global counter.
  • The read-then-increment below is not atomic. Two assets created in the same second can collide. For a five-user system that is acceptable; if the client ever bulk-imports in parallel, replace the lookup with a dedicated Counters form and a locked fetch-and-increment.
  • Never expose this field on an edit layout. It is set here and read everywhere else.
Next-due date helperCAFM.nextDueDate(date, string, int)

One place that knows what every frequency word means. Both the scheduler and the state machine call it, so a change to how 'Quarterly' is interpreted lands in one file rather than two.

Bound toStandalone function
RunsCalled by the scheduler and the state machine
CAFM.nextDueDate(date, string, int)DELUGE
// Standalone function — no record context
date CAFM.nextDueDate(date fromDate, string frequency, int customDays)
{
	nextDate = fromDate;
	if(frequency == "Daily")
	{
		nextDate = fromDate.addDay(1);
	}
	else if(frequency == "Weekly")
	{
		nextDate = fromDate.addDay(7);
	}
	else if(frequency == "Monthly")
	{
		nextDate = fromDate.addMonth(1);
	}
	else if(frequency == "Quarterly")
	{
		nextDate = fromDate.addMonth(3);
	}
	else if(frequency == "Half-yearly")
	{
		nextDate = fromDate.addMonth(6);
	}
	else if(frequency == "Yearly")
	{
		nextDate = fromDate.addYear(1);
	}
	else if(frequency == "Custom days")
	{
		days = if(customDays == null || customDays < 1,30,customDays);
		nextDate = fromDate.addDay(days);
	}
	return nextDate;
}

Why it is written this way

  • Deliberately pure — it takes a date and returns a date, touches no records, and is therefore trivially testable.
  • Anniversary-based, not completion-based: the next date is computed from the date that was due, not the date the job was finished. A job closed three days late does not push the whole year's schedule three days late. If the client wants drift-from-completion instead, this is the only function that changes.
PPM schedulerCAFM.ppmScheduler()

The routine the whole preventive promise rests on. It finds every active schedule whose lead-time window has opened, raises the work order, and copies the checklist onto it.

Bound toScheduled function
RunsDaily, 02:00 site time
CAFM.ppmScheduler()DELUGE
// Scheduled function — daily 02:00
void CAFM.ppmScheduler()
{
	today = zoho.currentdate;
	raised = 0;
	skipped = 0;
	openSchedules = PPM_Schedules[Status == "Active" && Next_Due_Date != null];

	for each sch in openSchedules
	{
		// past its end date — retire the schedule and move on
		if(sch.End_Date != null && sch.Next_Due_Date > sch.End_Date)
		{
			sch.Status = "Ended";
			continue;
		}
		// asset out of service: hold, do not end
		if(sch.Asset.Status == "Decommissioned")
		{
			skipped = skipped + 1;
			continue;
		}
		lead = if(sch.Lead_Time == null,0,sch.Lead_Time);
		raiseOn = sch.Next_Due_Date.subDay(lead);
		if(today < raiseOn)
		{
			continue;
		}
		// IDEMPOTENCY — never raise the same occurrence twice
		already = Work_Orders[Source_Schedule == sch.ID && Scheduled_Date == sch.Next_Due_Date];
		if(already.count() > 0)
		{
			continue;
		}
		prio = if(sch.Asset.Criticality == null,"Medium",sch.Asset.Criticality);
		wo = insert into Work_Orders
		[
			Job_Type = "Preventive"
			Asset = sch.Asset
			Location_Path = sch.Asset.Location_Path
			Source_Schedule = sch.ID
			Checklist_Template = sch.Checklist_Template
			Priority = prio
			Status = "Open"
			Assigned_Technician = sch.Assigned_Technician
			Raised_On = zoho.currenttime
			Scheduled_Date = sch.Next_Due_Date
			Target_Completion = sch.Next_Due_Date.toDateTime()
			Completion_Percent = 0
		];
		thisapp.CAFM.copyChecklist(wo.ID,sch.Checklist_Template);
		raised = raised + 1;
	}
	info "PPM scheduler " + today + " — raised: " + raised + ", held: " + skipped;
}

Why it is written this way

  • It raises work at due date minus lead time, not on the due date — a technician who first sees a job on the morning it is due has already lost it.
  • The duplicate guard is the single most important line here. A scheduled function that fails halfway and re-runs, or an admin who triggers it manually, must not double-raise. Keying on schedule plus occurrence date makes the routine safe to run as many times as you like.
  • It does not advance Next Due Date. That happens on completion, in the state machine — otherwise a job nobody ever finished would silently roll forward and the compliance report would show a clean sheet.
  • Decommissioned assets are skipped rather than having their schedules ended, so an asset returned to service picks its regime back up.
Predictive triggerCAFM.predictiveCheck(int)

Raises a service job when a machine has run its threshold hours, independently of the calendar. A generator that ran through a bad summer gets serviced early; one that sat idle does not get serviced for nothing.

Bound toAssets — On Edit › On Success, and a daily sweep
RunsOn running-hours entry, plus daily
CAFM.predictiveCheck(int)DELUGE
// Assets > On Edit > On Success, and from the daily sweep
void CAFM.predictiveCheck(int assetRecordId)
{
	ast = Assets[ID == assetRecordId];
	if(ast.count() == 0 || ast.Track_Running_Hours == false)
	{
		return;
	}
	if(ast.Status == "Decommissioned" || ast.Runtime_Threshold == null || ast.Runtime_Threshold <= 0)
	{
		return;
	}
	current = if(ast.Current_Running_Hours == null,0.0,ast.Current_Running_Hours);
	atLast = if(ast.Hours_At_Last_Service == null,0.0,ast.Hours_At_Last_Service);
	sinceService = current - atLast;

	if(sinceService < ast.Runtime_Threshold)
	{
		return;
	}
	// one open predictive job per asset at a time
	openJob = Work_Orders[Asset == ast.ID && Job_Type == "Predictive" && Status != "Completed" && Status != "Verified" && Status != "Cancelled"];
	if(openJob.count() > 0)
	{
		return;
	}
	tpl = ast.Asset_Category.Default_Checklist_Template;
	wo = insert into Work_Orders
	[
		Job_Type = "Predictive"
		Asset = ast.ID
		Location_Path = ast.Location_Path
		Checklist_Template = tpl
		Priority = if(ast.Criticality == "Critical","Critical","High")
		Status = "Open"
		Raised_On = zoho.currenttime
		Scheduled_Date = zoho.currentdate
		Target_Completion = zoho.currenttime.addDay(2)
		Completion_Percent = 0
		Work_Performed = "Raised on running hours: " + sinceService + " h since last service (threshold " + ast.Runtime_Threshold + " h)."
	];
	if(tpl != null)
	{
		thisapp.CAFM.copyChecklist(wo.ID,tpl);
	}
	thisapp.CAFM.writeAudit("Asset",ast.Asset_ID,"Status change","Running hours",atLast.toString(),current.toString());
}

Why it is written this way

  • Fires on hours since last service, not lifetime hours — the state machine writes Hours At Last Service on completion, and this measures from there.
  • The open-job guard stops a technician who updates the reading three times in a shift from raising three jobs.
  • Running hours are entered by hand. That is an explicit assumption in the proposal; if the client later connects a BMS, only the input to this routine changes, not the routine.
Work order state machineCAFM.workOrderState(int, string)

The gatekeeper. It decides which status transitions are legal, stamps the timestamps nobody should be typing by hand, blocks completion while mandatory checks are unanswered, and — on completion of a preventive job — books the next occurrence.

Bound toWork Orders — On Edit › On Success
RunsEvery status change
CAFM.workOrderState(int, string)DELUGE
// Work_Orders > On Edit > On Success
void CAFM.workOrderState(int woId, string oldStatus)
{
	wo = Work_Orders[ID == woId];
	if(wo.count() == 0 || wo.Status == oldStatus)
	{
		return;
	}
	allowed = Map();
	allowed.put("Draft",{"Open","Cancelled"});
	allowed.put("Open",{"Assigned","On hold","Cancelled"});
	allowed.put("Assigned",{"In progress","On hold","Open","Cancelled"});
	allowed.put("In progress",{"Completed","On hold","Cancelled"});
	allowed.put("On hold",{"Assigned","In progress","Cancelled"});
	allowed.put("Completed",{"Verified","In progress"});
	allowed.put("Verified",{});
	allowed.put("Cancelled",{});

	legal = ifnull(allowed.get(oldStatus),{});
	if(!legal.contains(wo.Status))
	{
		wo.Status = oldStatus;
		alert "That status change is not allowed from " + oldStatus + ".";
		return;
	}
	// ---- completion gate: every mandatory check must be answered
	if(wo.Status == "Completed")
	{
		unanswered = Task_Checklist[Work_Order == woId && Mandatory == true && Result == null];
		if(unanswered.count() > 0)
		{
			wo.Status = oldStatus;
			alert unanswered.count() + " mandatory check(s) are still unanswered.";
			return;
		}
		if(wo.Technician_Sign_off == false)
		{
			wo.Status = oldStatus;
			alert "The assigned technician must sign off before completion.";
			return;
		}
	}
	// ---- timestamps
	if(wo.Status == "In progress" && wo.Started_On == null)
	{
		wo.Started_On = zoho.currenttime;
	}
	if(wo.Status == "Completed")
	{
		wo.Completed_On = zoho.currenttime;
		wo.On_Time = wo.Target_Completion == null || wo.Completed_On <= wo.Target_Completion;

		total = Task_Checklist[Work_Order == woId].count();
		done = Task_Checklist[Work_Order == woId && Result != null].count();
		wo.Completion_Percent = if(total == 0,100.0,(done * 100.0) / total);

		// ---- roll the schedule forward — the ONLY place this happens
		if(wo.Job_Type == "Preventive" && wo.Source_Schedule != null)
		{
			sch = PPM_Schedules[ID == wo.Source_Schedule];
			if(sch.count() > 0 && sch.Status == "Active")
			{
				custom = if(sch.Custom_Interval == null,0,sch.Custom_Interval);
				sch.Last_Completed_Date = zoho.currentdate;
				sch.Next_Due_Date = thisapp.CAFM.nextDueDate(sch.Next_Due_Date,sch.Frequency,custom);
				if(sch.End_Date != null && sch.Next_Due_Date > sch.End_Date)
				{
					sch.Status = "Ended";
				}
			}
		}
		// ---- reset the predictive baseline
		if(wo.Job_Type == "Predictive" || wo.Job_Type == "Preventive")
		{
			ast = Assets[ID == wo.Asset];
			if(ast.count() > 0 && ast.Track_Running_Hours == true)
			{
				ast.Hours_At_Last_Service = ifnull(ast.Current_Running_Hours,0.0);
			}
		}
	}
	if(wo.Status == "Verified")
	{
		wo.Verified_On = zoho.currenttime;
	}
	thisapp.CAFM.writeAudit("Work order",wo.Work_Order_Number,"Status change","Status",oldStatus,wo.Status);
}

Why it is written this way

  • Completion is blocked, not warned, while a mandatory check is unanswered. A checklist that can be skipped is not evidence, and evidence is the reason the client is buying this.
  • This is the only place Next Due Date advances. Scheduling forward on completion rather than on raise is what makes the PPM compliance report honest.
  • On Time is computed here rather than in Analytics, so the flag is frozen at completion and does not change if the target is edited afterwards.
  • Creator has no native state-machine construct; the allowed map below is the substitute. Keep it as data, not as nested ifs, so a client asking for a new status is a one-line change.
Stock ledgerCAFM.postIssue(int)

Turns a line on a work order into a movement in the ledger and a new balance on the part. This is the routine that earns the decision to keep spare parts inside Creator instead of Zoho Inventory.

Bound toParts Used — On Add / On Edit / On Delete › On Success
RunsEvery part logged against a job
CAFM.postIssue(int)DELUGE
// Parts_Used > On Add > On Success
void CAFM.postIssue(int lineId)
{
	line = Parts_Used[ID == lineId];
	if(line.count() == 0)
	{
		return;
	}
	part = Parts_Master[ID == line.Part];
	if(part.count() == 0)
	{
		return;
	}
	// snapshot the cost so historic job value never moves
	if(line.Unit_Cost == null)
	{
		line.Unit_Cost = ifnull(part.Standard_Cost,0.0);
	}
	seq = Stock_Movements[ID != 0].count() + 1;
	ref = "SM-" + zoho.currentdate.toString("yyyyMM") + "-" + leftPad(seq.toString(),4,"0");

	mv = insert into Stock_Movements
	[
		Movement_Number = ref
		Part = part.ID
		Movement_Type = "Issue"
		Quantity = line.Quantity_Used
		Work_Order = line.Work_Order
		Moved_On = zoho.currenttime
		Moved_By = zoho.loginuser
		Remarks = "Consumed on " + line.Work_Order.Work_Order_Number
	];
	line.Stock_Movement = mv.ID;

	// ---- recompute from the ledger; never increment in place
	balance = 0.0;
	for each m in Stock_Movements[Part == part.ID]
	{
		if(m.Movement_Type == "Receipt" || m.Movement_Type == "Return")
		{
			balance = balance + m.Quantity;
		}
		else if(m.Movement_Type == "Issue")
		{
			balance = balance - m.Quantity;
		}
		else if(m.Movement_Type == "Adjustment" || m.Movement_Type == "Transfer")
		{
			balance = balance + m.Signed_Quantity;
		}
	}
	part.Current_Balance = balance;
	mv.Balance_After = balance;

	if(balance < 0)
	{
		info "NEGATIVE BALANCE on " + part.Part_Code + " — investigate.";
	}
}

Why it is written this way

  • Balance is recomputed by summing the ledger, never incremented in place. An incremented counter drifts the first time a routine fails midway; a sum cannot.
  • Balance After is stamped on the movement so the ledger can be audited row by row without replaying it from the beginning.
  • Editing or deleting a Parts Used line reverses its movement rather than rewriting it, so the history shows what actually happened.
  • Because the movement is written from the job screen, the technician never opens a stock module. That is the whole point of the single-system design.
Low-stock checkCAFM.lowStockCheck()

One digest a day to the storekeeper listing every part at or below its reorder level. A daily digest rather than an alert per movement — an alert that fires eleven times before lunch gets filtered into a folder nobody reads.

Bound toScheduled function
RunsDaily, 07:00 site time
CAFM.lowStockCheck()DELUGE
// Scheduled function — daily 07:00
void CAFM.lowStockCheck()
{
	low = Parts_Master[Active == true && Reorder_Level != null && Current_Balance <= Reorder_Level] sort by Part_Name;
	if(low.count() == 0)
	{
		return;    // nothing low — say nothing
	}
	rows = "";
	for each p in low
	{
		short = ifnull(p.Reorder_Level,0.0) - ifnull(p.Current_Balance,0.0);
		rows = rows + "<tr><td>" + p.Part_Code + "</td><td>" + p.Part_Name + "</td><td align='right'>" + ifnull(p.Current_Balance,0.0) + "</td><td align='right'>" + p.Reorder_Level + "</td><td align='right'>" + short + "</td></tr>";
	}
	body = "<p>" + low.count() + " part(s) at or below reorder level as at " + zoho.currentdate.toString("dd MMM yyyy") + ".</p>";
	body = body + "<table border='1' cellpadding='6' cellspacing='0'><tr><th>Code</th><th>Part</th><th>Balance</th><th>Reorder at</th><th>Short by</th></tr>" + rows + "</table>";

	storekeepers = Technicians[Role == "Storekeeper" && Active == true];
	for each s in storekeepers
	{
		sendmail
		[
			from: zoho.adminuserid
			to: s.Email
			subject: "Low stock — " + low.count() + " part(s) need reordering"
			message: body
		]
	}
}

Why it is written this way

  • Compares against the balance the ledger maintains, so the alert is only as trustworthy as the consumption capture — which is why capture sits on the work order.
  • Sends nothing when nothing is low. Silence is information.
  • Procurement is out of scope: this raises awareness, it does not raise a purchase order.
06

Reports and views

Reporting inside Creator is operational — what do I do next, what is late, what is running out. Management reporting is Analytics' job and is configured separately. The split matters: a technician should never need a dashboard to find their work.

ReportTypeVisible toWhat it is for
Asset registerListAllGrouped by building, filtered by category and criticality. The default landing view.
Asset detailDetailAllSingle asset with its schedules, open jobs, document link and full job history.
My workListTechnicianFiltered to the logged-in technician, status not Completed. The technician's home page.
Work boardKanbanSupervisor, AdminColumns by status. Drag between columns fires the state machine.
Overdue jobsListSupervisor, AdminTarget Completion in the past, status not Completed. Red flag view.
PPM calendarCalendarSupervisor, AdminNext Due Date across all active schedules, month view.
PPM complianceSummaryAdminScheduled versus completed by month, on-time percentage.
Checklist resultsListSupervisor, AdminFailed and out-of-range checks across all jobs — the early warning view.
Stock on handListStorekeeper, AdminBalance against reorder level, low rows highlighted.
Stock ledgerListStorekeeper, AdminEvery movement for a part in date order, with running balance.
Parts consumptionSummaryAdminQuantity and value by part, by asset and by period.
Warranty expiryListAdminAssets whose warranty expires within 90 days.
Contract expiryListAdminVendor contracts expiring within 60 days.
Audit trailListAdminRead-only, filtered by reference type and date.
07

Roles and permissions

Four roles against five named users. Permissions are built last but designed first, because the technician filter shapes how work orders are assigned.

RoleScopeWhat it means in practice
AdministratorEverythingFull create, edit and delete on every form; the only role that can delete. Sole access to the audit trail and to masters.
SupervisorEverything except masters and deletionCreates and assigns work, verifies completed jobs, manages schedules. Cannot edit the asset register structure or delete records.
TechnicianOwn assigned work onlySees Work Orders where Assigned Technician resolves to their own user. Edits Task Checklist rows and Parts Used on those jobs. Read-only on assets. Cannot reassign, cannot verify, cannot sign off for anyone else.
StorekeeperParts and stockFull access to Parts Master and Stock Movements for receipts, returns and adjustments. Read-only on work orders, so consumption can be traced but not altered.
The technician filter is the one to get right.  A technician sees work orders where Assigned Technician resolves to their own Creator login — which is why the Technicians form carries a Users field rather than just a name. If that binding is missing, the filter silently returns everything, and nobody notices until a technician closes someone else's job.
08

Integration surface

Creator is the system of record. Everything else either feeds it or reads from it — nothing else writes a work order except Desk, and it does so through one Flow with one key.

RouteMechanismBehaviour
Zoho Desk → CreatorZoho FlowNew ticket in a CAFM department creates a corrective Work Order with Desk Ticket ID stamped on it. Asset is matched on the ticket's asset field, falling back to unassigned for the supervisor to resolve.
Creator → Zoho DeskZoho FlowWork order reaching Completed or Verified posts a comment and status back to the originating ticket, so the requester is updated without anyone retyping it.
Creator → Zoho AnalyticsNative connectorScheduled sync of Assets, Work Orders, Task Checklist, PPM Schedules, Parts Used and Stock Movements. Desk syncs separately; the two are joined inside Analytics.
Creator → WorkDriveURL fieldEach asset and vendor carries a folder link. Deliberately a link, not an embedded file store — documents stay where document version control already works.
OutlookZoho FlowEmail triggers for assignment and escalation notifications.
ERPNot builtConnection points prepared only. Scoped and quoted once the client's ERP APIs are documented — see the proposal exclusions.
One key, one direction each way.  Desk Ticket ID on the work order is the only join between the two systems. Keeping it single means a failed Flow run leaves an orphan that is trivially findable, rather than a half-synced record nobody can reconcile.
09

Build sequence

Dependency order, not preference. Each step is testable on its own, and each one is blocked by the step above it. This maps onto weeks 2–3 of the proposal timeline.

  1. Locations, parent-first. Buildings, then Floors, Zones and Rooms. Nothing else can be imported until rooms exist.
  2. Masters. Vendors, Asset Categories, Technicians. Categories carry the ID prefix and the defaults, so they gate the asset import.
  3. Assets, plus the ID generator. Build the form, write the generator, then import. Importing before the generator exists means renumbering afterwards.
  4. Checklist Templates and Template Items. Client-supplied content, loaded once the maintenance team has signed off the check wording.
  5. PPM Schedules and the next-due helper. The helper first — the schedule form is not testable without it.
  6. Work Orders and the state machine. The largest single piece. Build the form, then the transition map, then the completion gate.
  7. Task Checklist and the copy routine. Proves the template-to-job path end to end.
  8. The PPM scheduler. Only now, because it depends on schedules, work orders and the checklist copy all working.
  9. Parts Master, Stock Movements and the ledger. Opening stock enters as receipts, never as typed balances.
  10. Parts Used. Wires consumption on the job to the ledger, closing the parts loop.
  11. Predictive trigger. Depends on the state machine writing Hours At Last Service, so it comes after completion works.
  12. Reports, roles and the low-stock digest. Access control last, so the build is not fought at every step by permissions that are still moving.
  13. Desk, Flow, Analytics and WorkDrive. Configuration rather than build — the proposal's week 4.
The scheduler comes eighth, not first.  It is the headline feature and the temptation is to build it early. It depends on schedules, work orders and the checklist copy all being correct — built early, it generates wrong work at volume and the corrections cost more than the build.
10

Data import

One import, at week 5, in this order. The proposal covers a single import of client-supplied spreadsheets; historic cleansing and repeat migrations are excluded.

StageFormsNotes
LocationsBuildings, Floors, Zones, RoomsFour sheets, imported parent-first. Rooms cannot import until Zones exist. Codes must be unique and stable — they end up on asset tags.
MastersVendors, Asset Categories, TechniciansCategories carry the ID prefix and defaults, so they must land before assets. Getting the prefix right before import avoids renumbering afterwards.
AssetsAssetsThe big one. Asset ID is left blank on import and generated on insert. Room Code is the join key — a room that does not exist fails the row rather than creating an orphan.
ChecklistsChecklist Templates, Template ItemsSupplied by the client's maintenance team. Sequence numbered in tens.
SchedulesPPM SchedulesStart Date and Next Due Date seeded from the client's existing maintenance calendar, so the first daily run does not raise a year of backlog on day one.
Opening stockParts Master, Stock MovementsBalances enter as Receipt movements dated at go-live, never typed into Current Balance. The ledger has to be the only source of the balance from the first day.
Opening stock enters as movements, never as balances.  It is tempting to type the balances straight onto Parts Master. Do not. From the first day, the ledger has to be the only thing that sets a balance — otherwise the first reconciliation has nothing to reconcile against.
11

Open items

Carried forward from the handoff, plus what this specification surfaced. Each needs an answer before the phase it affects.

ItemDetail
End client nameThe proposal cover reads Prepared for DSquare Global. If this document reaches the Saudi facility management company directly, the cover and this spec's header both change.
Data residencyGovernment exposure may force the Saudi data centre. This is a Zoho account-provisioning decision and cannot be changed after the account is created — it needs answering before week 2.
Import templatesThe xlsx templates for locations, assets and opening stock are listed above but not yet built.
Backlog seedingWhether historic completed maintenance is loaded at go-live. Currently assumed not — the first PPM compliance report therefore starts from an empty baseline.
Sequence atomicityThe asset ID and movement number generators read-then-increment. Safe at five users; if parallel bulk import is ever expected, they need a locked Counters form.
Working-hours SLASLA clocks currently run continuously. If the client wants shift-based or business-hours-only timers, that is a Desk rebuild, not a Creator change — flagged in the proposal assumptions.