Skip to content

Plant & Equipment

Status: Specified — ready to build Build order: 6 of 10 — depends on Projects & Sites


Purpose

Plant & Equipment is the asset register for all construction plant and equipment used on a project. It tracks LOLER and PUWER inspection certificates, hire return dates, and current site allocation. The notification system fires alerts before certificates lapse and when hire periods approach expiry. The module also triggers immediate alerts when plant with overdue inspection is allocated to an active site.


Entities

PlantItem

A single piece of plant or equipment on the project. Can be owned by the main contractor, hired from a plant hire company, or owned by a subcontractor.

Field Type Required Notes
id UUID System PK, generated
project_id UUID FK → projects.id
name VARCHAR(255) e.g. "360° Excavator — Liebherr R926"
plant_type VARCHAR(100) See suggested types below
make VARCHAR(100)
model VARCHAR(100)
serial_number VARCHAR(100)
registration VARCHAR(30) For road-going plant
ownership_type VARCHAR(20) owned | hired | subcontractor_owned
hire_company VARCHAR(255) Required if ownership_type = hired
hire_reference VARCHAR(100) Hire order/contract reference
hire_start_date DATE
hire_end_date DATE Alert threshold applies. NULL = open-ended hire
subcontractor_id UUID FK → subcontractors.id — if subcontractor_owned
allocated_site_id UUID FK → sites.id — current site allocation. NULL = not on site
allocated_at TIMESTAMPTZ When allocated to current site
status VARCHAR(20) Default: active
notes TEXT
photo_key VARCHAR(500) S3 key for plant photo
custom_fields JSONB Default {}
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System Trigger-maintained
created_by UUID System FK → users.id
updated_by UUID System
deleted_at TIMESTAMPTZ
CREATE INDEX idx_plant_project_id
    ON plant_items(project_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_plant_allocated_site
    ON plant_items(allocated_site_id) WHERE deleted_at IS NULL AND allocated_site_id IS NOT NULL;
CREATE INDEX idx_plant_status
    ON plant_items(project_id, status) WHERE deleted_at IS NULL;
CREATE INDEX idx_plant_hire_end
    ON plant_items(hire_end_date)
    WHERE deleted_at IS NULL AND hire_end_date IS NOT NULL;

Suggested plant types:

Type LOLER applies
Excavator (360°)
Excavator (180°)
Telehandler
Crane — mobile
Crane — tower
MEWP — scissor lift
MEWP — boom lift
Forklift
Dumper
Roller
Compressor
Concrete pump
Piling rig
Road sweeper
On-Track Plant (OTP)
Other lifting equipment
Other (non-lifting)

plant_type is stored as VARCHAR — no central type table.


PlantInspection

An inspection or examination record for a piece of plant. Covers both LOLER thorough examinations and other periodic inspection types.

Field Type Required Notes
id UUID System PK, generated
plant_item_id UUID FK → plant_items.id
project_id UUID Denormalised
inspection_type VARCHAR(100) See types below
inspector_name VARCHAR(255) Competent person or company
inspector_company VARCHAR(255)
inspection_date DATE
expiry_date DATE NULL = no fixed expiry (e.g. pre-use check records)
result VARCHAR(20) pass | pass_with_defects | fail
defects_noted TEXT Required if result = pass_with_defects or fail
certificate_key VARCHAR(500) S3 key for the inspection certificate
reference_number VARCHAR(100) Inspector's certificate reference
notes TEXT
custom_fields JSONB Default {}
created_at TIMESTAMPTZ System
updated_at TIMESTAMPTZ System
created_by UUID System
updated_by UUID System
deleted_at TIMESTAMPTZ
CREATE INDEX idx_plant_inspection_plant_id
    ON plant_inspections(plant_item_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_plant_inspection_project
    ON plant_inspections(project_id) WHERE deleted_at IS NULL;
-- Expiry scanner
CREATE INDEX idx_plant_inspection_expiry
    ON plant_inspections(expiry_date)
    WHERE deleted_at IS NULL AND expiry_date IS NOT NULL;

Inspection types:

Type Regulation Typical period
LOLER Thorough Examination LOLER 1998 6 months (lifting persons) / 12 months (other lifting)
PUWER Inspection PUWER 1998 Periodic — risk-based
Pre-use Inspection In-house Daily
Annual Inspection Various 12 months
Service Record Manufacturer Mileage/hours based
Other

PlantAllocationLog

Immutable log of all site allocation changes. The current allocation is stored on plant_items.allocated_site_id for fast query. This table provides history.

Field Type Required Notes
id UUID System PK, generated
plant_item_id UUID FK → plant_items.id
project_id UUID Denormalised
site_id UUID FK → sites.id — NULL = removed from all sites (returned/idle)
action VARCHAR(20) allocated | returned
actioned_at TIMESTAMPTZ
actioned_by UUID FK → users.id
notes TEXT
created_at TIMESTAMPTZ System
CREATE INDEX idx_plant_alloc_plant
    ON plant_allocation_log(plant_item_id, actioned_at DESC);
CREATE INDEX idx_plant_alloc_site
    ON plant_allocation_log(site_id, actioned_at DESC);

Status Lifecycle — Plant

Status Meaning
active In use or available on the project
off_hire Hired item returned to hire company
under_repair Out of service for repair or maintenance
archived Removed from the project — historical record retained
From Permitted transitions
active off_hire, under_repair, archived
off_hire active, archived
under_repair active, archived
archived — terminal

When transitioning to off_hire, any current site allocation is automatically cleared.


Business Rules

  1. ownership_type = hired requires hire_company. Validated in service layer.

  2. ownership_type = subcontractor_owned requires subcontractor_id.

  3. LOLER check on allocation: when plant is allocated to a site (allocated_site_id set), the service checks whether any LOLER Thorough Examination is present and current (expiry_date >= CURRENT_DATE). If the most recent LOLER is expired (or no LOLER exists for a plant type that requires it), the plant.inspection_overdue_in_use notification is triggered. The allocation is not blocked — this mirrors sign-in behaviour for expired licences.

  4. The LOLER check uses a lookup of whether the plant type requires LOLER. This is defined as a constant in the service layer based on the suggested types table. Custom plant types default to requiring LOLER (conservative).

  5. hire_end_date drives the plant.hire_expiry notification. When a hired item's return date passes and status = active, the alert remains active until either the hire is extended (date updated) or the item is transitioned to off_hire.

  6. A PlantInspection with result = fail flags the item with has_failed_inspection. The UI highlights it prominently. The plant is not automatically taken off-site but the flag is clearly visible.

  7. The "current" inspection for any type is the most recent non-deleted record for that (plant_item_id, inspection_type). Only the current one drives expiry alerts.


API Endpoints

Plant

Method Path Description
GET /projects/{projectId}/plant List all plant on project (paginated, filterable)
POST /projects/{projectId}/plant Add plant item
GET /projects/{projectId}/plant/{id} Full detail
PATCH /projects/{projectId}/plant/{id} Update
DELETE /projects/{projectId}/plant/{id} Soft delete
POST /projects/{projectId}/plant/{id}/transition Status transition
POST /projects/{projectId}/plant/{id}/allocate Allocate to a site
POST /projects/{projectId}/plant/{id}/return Remove from site
POST /projects/{projectId}/plant/{id}/photo Upload photo
GET /projects/{projectId}/plant/{id}/allocation-log Allocation history

Site-level view

Method Path Description
GET /sites/{siteId}/plant Plant currently allocated to this site

Inspections

Method Path Description
GET /projects/{projectId}/plant/{id}/inspections List inspections
POST /projects/{projectId}/plant/{id}/inspections Add inspection
PATCH /projects/{projectId}/plant/{id}/inspections/{insId} Update
DELETE /projects/{projectId}/plant/{id}/inspections/{insId} Soft delete
POST /projects/{projectId}/plant/{id}/inspections/{insId}/certificate Upload certificate

Request / Response Shapes

POST /projects/{projectId}/plant

// Request
{
  "name": "360° Excavator — Liebherr R926",
  "plant_type": "Excavator (360°)",
  "make": "Liebherr",
  "model": "R926",
  "serial_number": "LIE-2019-044321",
  "ownership_type": "hired",
  "hire_company": "Sunbelt Rentals",
  "hire_reference": "HR-2026-0421",
  "hire_start_date": "2026-05-01",
  "hire_end_date": "2026-08-31",
  "custom_fields": {}
}

// Response 201
{
  "id": "uuid-plant",
  "project_id": "uuid-project",
  "name": "360° Excavator — Liebherr R926",
  "plant_type": "Excavator (360°)",
  "make": "Liebherr",
  "model": "R926",
  "serial_number": "LIE-2019-044321",
  "ownership_type": "hired",
  "hire_company": "Sunbelt Rentals",
  "hire_reference": "HR-2026-0421",
  "hire_start_date": "2026-05-01",
  "hire_end_date": "2026-08-31",
  "hire_days_remaining": 95,
  "allocated_site_id": null,
  "allocated_site_name": null,
  "status": "active",
  "has_valid_loler": false,
  "has_failed_inspection": false,
  "loler_expiry_date": null,
  "photo_url": null,
  "custom_fields": {},
  "created_at": "2026-05-28T09:00:00Z"
}

POST /projects/{projectId}/plant/{id}/allocate

// Request
{ "site_id": "uuid-site", "notes": "Deployed to north face earthworks" }

// Response 200
{
  "id": "uuid-plant",
  "allocated_site_id": "uuid-site",
  "allocated_site_name": "Canary Wharf — Site A",
  "allocated_at": "2026-05-28T07:00:00Z",
  "inspection_warning": null
}

// Response 200 — with LOLER warning
{
  "id": "uuid-plant",
  "allocated_site_id": "uuid-site",
  "allocated_site_name": "Canary Wharf — Site A",
  "allocated_at": "2026-05-28T07:00:00Z",
  "inspection_warning": {
    "inspection_type": "LOLER Thorough Examination",
    "last_expiry_date": "2026-03-01",
    "days_overdue": 88
  }
}

Allocation succeeds in both cases. The inspection_warning is informational only. The plant.inspection_overdue_in_use notification is also fired on the SQS queue.

POST /projects/{projectId}/plant/{id}/inspections

// Request
{
  "inspection_type": "LOLER Thorough Examination",
  "inspector_name": "LOLER Inspections Ltd",
  "inspector_company": "LOLER Inspections Ltd",
  "inspection_date": "2026-05-20",
  "expiry_date": "2027-05-20",
  "result": "pass",
  "reference_number": "LOLER-2026-04421"
}

// Response 201
{
  "id": "uuid-inspection",
  "plant_item_id": "uuid-plant",
  "inspection_type": "LOLER Thorough Examination",
  "inspector_name": "LOLER Inspections Ltd",
  "inspection_date": "2026-05-20",
  "expiry_date": "2027-05-20",
  "result": "pass",
  "reference_number": "LOLER-2026-04421",
  "certificate_url": null,
  "days_until_expiry": 357,
  "status": "valid",
  "created_at": "2026-05-28T09:00:00Z"
}

Computed status on inspection responses:

Value Condition
no_expiry expiry_date IS NULL
valid expiry_date >= CURRENT_DATE + threshold
expiring_soon expiry_date within configured threshold
expired expiry_date < CURRENT_DATE
failed result = fail (regardless of expiry)

Permission Matrix

Action Platform Admin Tenant Admin Project Manager Site Manager Viewer
List / view plant ✓ assigned ✓ assigned ✓ assigned
Add plant ✓ assigned
Edit plant ✓ assigned
Transition status ✓ assigned
Allocate / return ✓ assigned ✓ own site
Delete plant ✓ assigned
Add / edit inspections ✓ assigned ✓ own site
Delete inspection ✓ assigned
View site plant ✓ assigned ✓ own site

Site Managers can record inspections (they're typically the ones on site when the inspection happens) but cannot delete records or remove plant from the project.


Notifications Triggered

Event Trigger point Notification type
Inspection cert approaching expiry Daily scanner plant.inspection_expiry
Hired plant return date approaching Daily scanner plant.hire_expiry
Plant with overdue inspection allocated to site POST /allocate plant.inspection_overdue_in_use

Inspection expiry scanner query:

SELECT pi.*, p.project_id
FROM plant_inspections pi
JOIN plant_items p ON p.id = pi.plant_item_id
WHERE pi.deleted_at IS NULL
  AND pi.expiry_date IS NOT NULL
  AND pi.expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + :advance_days
  AND p.status = 'active'
  AND p.deleted_at IS NULL
  -- Only the most recent inspection of each type per item
  AND pi.id = (
    SELECT id FROM plant_inspections pi2
    WHERE pi2.plant_item_id = pi.plant_item_id
      AND pi2.inspection_type = pi.inspection_type
      AND pi2.deleted_at IS NULL
    ORDER BY pi2.inspection_date DESC LIMIT 1
  )

Hire expiry scanner query:

SELECT *
FROM plant_items
WHERE deleted_at IS NULL
  AND ownership_type = 'hired'
  AND hire_end_date IS NOT NULL
  AND hire_end_date BETWEEN CURRENT_DATE AND CURRENT_DATE + :advance_days
  AND status = 'active'

Offline Sync Scope

Data Syncs to Notes
Plant items Project Manager, Site Manager All fields
Inspections Project Manager, Site Manager Current inspection per type only
Allocation log Site Manager Last 30 days for assigned sites

Site Manager sync includes all plant allocated to their sites. Useful for pre-use checks and certificate lookup on site without a data connection.


Cross-Module Dependencies

Upstream

Module Dependency
Projects & Sites project_id, site_id
Subcontractors subcontractor_id (optional, for subcontractor-owned plant)

Downstream

Module Usage
Incidents & Near Misses Plant items referenced as involved_plant
Site Diary plant_working field references plant by name (free text in V1)

Tests Required

Plant CRUD

  • Add plant — ownership_type = hired without hire_company → 422
  • Add plant — ownership_type = subcontractor_owned without subcontractor_id → 422
  • List plant — filter by status, ownership_type, allocated_site_id
  • hire_days_remaining computed correctly — positive and negative values
  • has_valid_loler and loler_expiry_date derived from inspections
  • Transition status — off_hire clears allocated_site_id

Allocation

  • Allocate — valid plant to site → 200, log entry created
  • Allocate — plant with expired LOLER → 200 with inspection_warning, notification queued
  • Allocate — plant already on same site → 409
  • Allocate — plant already on different site → automatically returns from old site first
  • Return — clears allocated_site_id, log entry created

Inspections

  • Add inspection — valid → 201
  • Add inspection — result = pass_with_defects without defects_noted → 422
  • Computed status correct for all values including failed
  • LOLER check: has_valid_loler updates when new LOLER inspection added

Notification triggers

  • Allocate plant with expired LOLER → plant.inspection_overdue_in_use pushed to SQS
  • Same plant, same site, same day → second push suppressed (idempotency)

Cross-tenant isolation (mandatory)

  • GET plant from another tenant's project → 404
  • POST inspection with plant_item_id from another tenant → 404
  • Allocate to site_id from another tenant → 404

Permission boundaries

  • Site Manager cannot add plant → 403
  • Site Manager can allocate plant to own site → 200
  • Site Manager cannot delete inspection → 403
  • Viewer can view plant and inspections → 200; cannot edit → 403

Default Tenant Labels

Key Default
module.plant Plant & Equipment
field.plant.plant_type Plant Type
field.plant.hire_company Hire Company
field.plant.hire_end_date Hire Return Date
field.plant.allocated_site_id Currently On Site
field.inspection.inspection_type Inspection Type
field.inspection.expiry_date Certificate Expiry
field.inspection.result Result
status.plant.active Active
status.plant.off_hire Off Hire
status.plant.under_repair Under Repair
status.plant.archived Archived

Foundation Dependencies

Component Location Used for
TenantMiddleware core/middleware.py Sets request.state.tenant
get_db core/db.py Schema-scoped AsyncSession
get_current_user core/security.py Auth
require_permission core/security.py RBAC
EntityMixin core/models.py Universal columns
PaginatedResponse core/pagination.py List endpoint wrapper
ConstruoError hierarchy core/exceptions.py Typed exceptions
presigned_put_url core/storage.py Photo and certificate upload
SQSClient core/events.py Notification trigger on allocation
audit_entity_change trigger DB foundation Applied to all three tables
factories.py tests/factories.py create_plant_item, create_plant_inspection