Maintenance Schedule — Blueprint
Maintenance Schedule — Implementation Blueprint
Architect Agent 4 of the Pancha · 2026-05-02 · Module 6 of 17 in the Aayojana catalog.
1. Module summary
- Name: Maintenance Schedule
- Slug:
maintenance - Kind: ERP
- Status: planned (subpackage
src/aayojana/maintenance/to be created).
The institution's upkeep calendar. Tasks are tied to assets — buildings, equipment, vehicles, lands. Three task patterns: annual (water tank cleaning, electrical audit, deep cleaning), periodical (daily/weekly/monthly — generator rotation, ventilation runs, security drills), one-time (ad-hoc repairs, emergency replacements). AMC contracts model recurring vendor service (lifts, fire-systems, ACs); they auto-spawn maintenance schedules and feed Vitta as recurring vendor invoices. Inspection reports + completion records close the loop with photos and findings.
2. Data model
# src/aayojana/maintenance/models.py
from datetime import date, datetime
from sqlalchemy import (
Boolean, Date, DateTime, ForeignKey, Index, Integer, JSON, Numeric, String, Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from aayojana.models.base import AuditMixin, Base, TenantMixin, uuid4_str
class MaintenanceTask(Base, AuditMixin, TenantMixin):
"""Master template — defines WHAT to do, on which asset, how often.
Instances of execution live in maintenance_schedules.
"""
__tablename__ = "maintenance_tasks"
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_maint_task_code"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
code: Mapped[str] = mapped_column(String(40), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
name_sanskrit: Mapped[str | None] = mapped_column(Text, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
instructions: Mapped[str | None] = mapped_column(Text, nullable=True)
# e.g. "Run generator 15 min weekly to prevent seizure; check oil before start"
asset_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("assets.id"), nullable=True, index=True
)
# Nullable: a task may apply to a class of assets (e.g. "all buildings") via asset_scope.
asset_scope: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'all-buildings' | 'all-vehicles' | 'all-equipment' | 'all-rooms-AC' | NULL (specific asset)
branch_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("branches.id"), nullable=True
)
frequency: Mapped[str] = mapped_column(String(20), nullable=False)
# 'daily' | 'weekly' | 'monthly' | 'quarterly' | 'half-yearly' | 'annual' | 'one-time' | 'ad-hoc'
frequency_interval: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
# e.g. frequency='monthly', interval=3 → every 3 months
estimated_duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
estimated_cost: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
is_compliance_task: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Fire system, lift, lightning arrester — statutory inspection cycles.
compliance_authority: Mapped[str | None] = mapped_column(String(127), nullable=True)
default_assignee_member_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("members.id"), nullable=True
)
amc_contract_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("amc_contracts.id"), nullable=True
)
# If the task is fulfilled under an AMC, link it.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
next_due_on: Mapped[date | None] = mapped_column(Date, nullable=True)
# Cached: computed by scheduler from last completion + frequency.
class MaintenanceSchedule(Base, AuditMixin, TenantMixin):
"""A scheduled occurrence of a task on a specific date."""
__tablename__ = "maintenance_schedules"
__table_args__ = (
Index("ix_maint_sched_due", "due_date", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
task_id: Mapped[str] = mapped_column(
String(36), ForeignKey("maintenance_tasks.id"), nullable=False
)
asset_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("assets.id"), nullable=True
)
# When task.asset_scope is set, instances enumerate matching assets.
branch_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("branches.id"), nullable=True
)
due_date: Mapped[date] = mapped_column(Date, nullable=False)
grace_until: Mapped[date | None] = mapped_column(Date, nullable=True)
assigned_to_member_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("members.id"), nullable=True
)
assigned_to_amc_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("amc_contracts.id"), nullable=True
)
status: Mapped[str] = mapped_column(String(20), default="scheduled", nullable=False)
# 'scheduled' | 'in-progress' | 'completed' | 'overdue' | 'skipped' | 'cancelled'
priority: Mapped[str] = mapped_column(String(10), default="normal", nullable=False)
# 'low' | 'normal' | 'high' | 'urgent'
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class MaintenanceCompletion(Base, AuditMixin, TenantMixin):
"""A completion record — what was actually done, by whom, with what findings."""
__tablename__ = "maintenance_completions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
schedule_id: Mapped[str] = mapped_column(
String(36), ForeignKey("maintenance_schedules.id"), nullable=False, unique=True
)
completed_on: Mapped[date] = mapped_column(Date, nullable=False)
completed_by_member_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("members.id"), nullable=True
)
completed_by_external: Mapped[str | None] = mapped_column(String(255), nullable=True)
# If executed by AMC vendor or external contractor — not in members.
duration_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
findings: Mapped[str | None] = mapped_column(Text, nullable=True)
defects_found: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
follow_up_required: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
follow_up_schedule_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("maintenance_schedules.id"), nullable=True
)
materials_used: Mapped[list | None] = mapped_column(JSON, nullable=True)
# [{item_id, quantity, dispense_record_id}]
cost_incurred: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
photo_paths: Mapped[list | None] = mapped_column(JSON, nullable=True)
signed_off_by_user_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("users.id"), nullable=True
)
class AMCContract(Base, AuditMixin, TenantMixin):
"""Annual / multi-year service contracts with vendors.
Triggers recurring vendor invoices in Vitta and spawns scheduled maintenance.
"""
__tablename__ = "amc_contracts"
__table_args__ = (
UniqueConstraint("tenant_id", "contract_number", name="uq_amc_contract_no"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
contract_number: Mapped[str] = mapped_column(String(40), nullable=False)
vendor_id: Mapped[str] = mapped_column(
String(36), ForeignKey("vendors.id"), nullable=False
)
coverage_description: Mapped[str] = mapped_column(Text, nullable=False)
covered_assets: Mapped[list | None] = mapped_column(JSON, nullable=True)
# [asset_id, ...]
contract_type: Mapped[str] = mapped_column(String(20), nullable=False)
# 'comprehensive' | 'non-comprehensive' | 'callout-only' | 'preventive-only'
start_date: Mapped[date] = mapped_column(Date, nullable=False)
end_date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
annual_value: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
payment_frequency: Mapped[str] = mapped_column(String(20), default="annual", nullable=False)
# 'annual' | 'half-yearly' | 'quarterly' | 'monthly'
next_invoice_due: Mapped[date | None] = mapped_column(Date, nullable=True)
visit_frequency: Mapped[str | None] = mapped_column(String(20), nullable=True)
# 'monthly' | 'quarterly' etc — schedules visits
visits_per_year: Mapped[int | None] = mapped_column(Integer, nullable=True)
callouts_used_in_year: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
callouts_allowed_in_year: Mapped[int | None] = mapped_column(Integer, nullable=True)
contact_person: Mapped[str | None] = mapped_column(String(127), nullable=True)
contact_phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
contact_email: Mapped[str | None] = mapped_column(String(127), nullable=True)
auto_renew: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class InspectionReport(Base, AuditMixin, TenantMixin):
"""Standalone inspection — separate from a maintenance task completion.
e.g. structural survey, fire-safety drill report, electrical audit summary.
"""
__tablename__ = "inspection_reports"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_id: Mapped[str] = mapped_column(
String(36), ForeignKey("assets.id"), nullable=False, index=True
)
inspection_type: Mapped[str] = mapped_column(String(40), nullable=False)
# 'structural' | 'fire-safety' | 'electrical' | 'lift' | 'lightning-arrester' |
# 'water-quality' | 'pest-control' | 'security-drill' | 'general'
inspection_date: Mapped[date] = mapped_column(Date, nullable=False)
inspector_name: Mapped[str] = mapped_column(String(255), nullable=False)
inspector_organisation: Mapped[str | None] = mapped_column(String(255), nullable=True)
inspector_credentials: Mapped[str | None] = mapped_column(String(255), nullable=True)
overall_rating: Mapped[str | None] = mapped_column(String(20), nullable=True)
# 'pass' | 'pass-with-defects' | 'fail' | 'critical-fail'
findings: Mapped[str] = mapped_column(Text, nullable=False)
defects: Mapped[list | None] = mapped_column(JSON, nullable=True)
# [{description, severity, recommended_action, target_date, status}]
report_document_path: Mapped[str | None] = mapped_column(String(511), nullable=True)
photo_paths: Mapped[list | None] = mapped_column(JSON, nullable=True)
next_inspection_due: Mapped[date | None] = mapped_column(Date, nullable=True)
is_compliance_inspection: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
compliance_certificate_no: Mapped[str | None] = mapped_column(String(63), nullable=True)
compliance_certificate_expiry: Mapped[date | None] = mapped_column(Date, nullable=True)
3. Reuse map
| Existing model | Used here as | Why |
|---|---|---|
organisations.id |
tenant_id | Multi-tenant |
branches.id |
branch_id on tasks/schedules | Maintenance is branch-local |
assets.id |
task and schedule asset binding | Tasks attach to assets |
members.id |
assignees (tasks, schedules, completions) | Internal staff doing work |
vendors.id (Vitta) |
amc_contracts.vendor_id |
Same vendor master |
users.id |
sign-off user | Auth identity |
dispense_records.id (Inventory — this agent) |
materials_used in completion JSON |
Trace consumables used in maintenance |
4. API surface
Routes under /api/maintenance/.
| Method | Path | Purpose |
|---|---|---|
| GET | /tasks |
List task templates |
| POST | /tasks |
Create task template |
| GET | /tasks/{id} |
Detail with upcoming schedules |
| PATCH | /tasks/{id} |
Edit |
| POST | /tasks/{id}/generate-schedules |
Project schedules N months ahead |
| GET | /schedules |
Calendar view: filters by date/asset/branch/status |
| GET | /schedules/today |
Today's tasks for branch |
| GET | /schedules/overdue |
Overdue list |
| POST | /schedules |
Create one-time/ad-hoc schedule |
| POST | /schedules/{id}/start |
Mark in-progress |
| POST | /schedules/{id}/complete |
Submit completion record |
| POST | /schedules/{id}/skip |
Skip with reason |
| GET | /completions/{id} |
Completion detail |
| GET | /amc |
AMC list |
| POST | /amc |
New AMC; spawns recurring schedules |
| GET | /amc/{id} |
Detail with contract, schedules, callout log |
| POST | /amc/{id}/renew |
Renew AMC; rolls schedules forward |
| POST | /amc/{id}/callout |
Log callout (decrements callouts_used_in_year) |
| GET | /inspections |
List inspection reports |
| POST | /inspections |
New inspection report |
| GET | /inspections/expiring-certificates |
Compliance certs expiring |
| GET | /dashboard |
Branch maintenance dashboard |
| GET | /calendar/ical |
Subscribe-able iCal feed of upcoming tasks |
5. Service layer
src/aayojana/maintenance/services.py:
generate_schedules_from_task(task_id, until=date) -> list[MaintenanceSchedule]— uses frequency + interval to project forward; idempotent (skips dates already scheduled).mark_task_complete(schedule_id, completed_by, findings, defects_found, materials_used, photos) -> MaintenanceCompletion— closes schedule; ifdefects_found and follow_up_required, spawns ad-hoc schedule and links viafollow_up_schedule_id; updates task.next_due_on; if materials_used, posts dispense_records via Inventory service.bulk_close_recurring(task_id, on_date, completed_by)— for daily nitya maintenance: one button closes today's row.spawn_amc_schedules(amc_id) -> list[MaintenanceSchedule]— at AMC creation/renewal, generates visit schedules acrossvisits_per_year.record_amc_invoice(amc_id, invoice_no, amount, posting_date) -> VendorPayable— posts to Vitta; decrements remaining contract value.compute_compliance_status(branch_id) -> dict— returns dict of compliance categories (fire, lift, lightning, electrical) with status and next-expiry.escalate_overdue_schedules() -> list[MaintenanceSchedule]— cron; sets status='overdue' beyond grace_until; emits Comms alert.recommend_assignee(task_id) -> Member | None— based on past completions and current workload.inspection_register_export(branch_id, year) -> bytes— for trustee pack and statutory inspections.link_inspection_to_compliance(inspection_id, statutory_record_id)— bridges to Statutory module's certificate registry.
6. UI / Templates
- Calendar view — month grid; tasks colour-coded by status (green=done, amber=due, red=overdue). Drag to reschedule.
- Today list for the assigned member — checkbox-tap done with optional photo.
- Task template editor — frequency picker, asset binding (single asset or scope), instructions textarea.
- AMC list — vendor name, coverage period, value, callouts used, next visit, expiry ring.
- AMC detail — contract docs attachment, scheduled visits timeline, callout log, invoice ledger.
- Inspection report form — multi-section (general info, findings, defects table, photos, next-due) with PDF print.
- Compliance dashboard — fire-safety / lift / lightning / electrical / water-quality cards; each shows last cert + expiry + status.
- Maintenance KPI dashboard — completion-rate per task, overdue count, top-3 defective assets, AMC utilisation.
- Asset-side panel — on Asset detail page, show "maintenance history" tab (linked schedules + completions for that asset).
7. Migration plan
| Rev | Title | Tables added |
|---|---|---|
| 0022 | maintenance_module | maintenance_tasks, maintenance_schedules, maintenance_completions, amc_contracts, inspection_reports |
Single migration. Depends on Asset Management migration 0019 (asset FK target) and Vitta vendor master migration. Use use_alter=True on vendors.id FK if vendor master lands later than 0022.
8. Cross-module dependencies
Reads from
- Assets (this agent): every task/schedule/inspection FKs
assets.id. - Members (Agent 3): assignees, completers.
- Vitta vendors (Agent 1): AMC vendor counterparty.
Writes to
- Vitta (Agent 1): every AMC invoice →
vendor_payables; every external maintenance bill →vendor_payables. Fund tag default 'Building' for building maintenance, 'General' otherwise. - Inventory (this agent):
dispense_recordsfor materials used in maintenance (purpose='maintenance'). - Comms (Agent 5): T-7 / T-1 reminders to assignees; overdue escalation; AMC renewal alert (T-30); compliance-cert expiry (T-60, T-30).
- Audit Trail (Agent 2): inspection failures with critical defects → audit_event for trustee visibility; AMC contract creation/renewal → audit_event.
- Reports (Agent 5): maintenance compliance %, asset-wise defect history, AMC value vs callout utilisation.
- Statutory (Agent 2): compliance inspection certificates link to
statutory_certificatesfor renewal calendar.
9. Implementation phases
- Phase A — Schemas + basic CRUD. Migration 0022. Tasks, schedules CRUD, completion form, AMC CRUD, inspection report CRUD, calendar view.
- Phase B — Cross-module integrations. Schedule generation cron, AMC invoice posting to Vitta, dispense linkage from completions, Comms reminders, compliance-cert linkage to Statutory.
- Phase C — Workflows + dashboards. KPI dashboard, compliance dashboard, ical feed, defect follow-up auto-spawn, recommend-assignee.
10. Open questions
- Asset-class scope tasks — when
asset_scope='all-buildings', scheduler enumerates per-asset rows. Acceptable cardinality? For 5 buildings × 12 monthly inspections = 60 rows/year. Fine. - Daily nitya maintenance volume — 365 rows per task per year is heavy. Recommend:
frequency='daily'tasks materialise schedule rows lazily (only generate next 30 days at a time). - AMC callout vs scheduled visit — if a visit is unscheduled (callout), do we still create a
maintenance_schedulerow, or only a completion + AMC.callouts_used++? Recommend: yes, retroactive schedule + completion for full audit trail. - Inspection report immutability — once signed by inspector, lock from edit. Recommend: yes; set
is_locked=Trueafter sign-off; corrections require new versioned report. - Photos storage — local filesystem vs GCS? Defer to infra; store paths.
- Compliance certificate cross-reference — does Statutory's certificate registry own these or do we duplicate them? Recommend: Statutory owns; we hold reference + inspection-cycle.
- Vendor performance scoring — track on-time vs delayed, defect rate per AMC vendor? Defer to Phase C.
- Mobile capture — completion-by-phone-camera flow critical for field staff; mobile-first form design. Plan for v1.5.
- Predictive maintenance — defect frequency on an asset triggers earlier inspections. Out of scope for v1.
- Holidays / fixed days — daily tasks skip festival-closed days? Let task have
skip_on_branch_closed=Trueflag.