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¶
-
ownership_type = hiredrequireshire_company. Validated in service layer. -
ownership_type = subcontractor_ownedrequiressubcontractor_id. -
LOLER check on allocation: when plant is allocated to a site (
allocated_site_idset), 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), theplant.inspection_overdue_in_usenotification is triggered. The allocation is not blocked — this mirrors sign-in behaviour for expired licences. -
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).
-
hire_end_datedrives theplant.hire_expirynotification. When a hired item's return date passes andstatus = active, the alert remains active until either the hire is extended (date updated) or the item is transitioned tooff_hire. -
A
PlantInspectionwithresult = failflags the item withhas_failed_inspection. The UI highlights it prominently. The plant is not automatically taken off-site but the flag is clearly visible. -
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 = hiredwithouthire_company→ 422 - Add plant —
ownership_type = subcontractor_ownedwithoutsubcontractor_id→ 422 - List plant — filter by
status,ownership_type,allocated_site_id hire_days_remainingcomputed correctly — positive and negative valueshas_valid_lolerandloler_expiry_datederived from inspections- Transition status —
off_hireclearsallocated_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_defectswithoutdefects_noted→ 422 - Computed
statuscorrect for all values includingfailed - LOLER check:
has_valid_lolerupdates when new LOLER inspection added
Notification triggers¶
- Allocate plant with expired LOLER →
plant.inspection_overdue_in_usepushed 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_idfrom another tenant → 404 - Allocate to
site_idfrom 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 |