Asset Management — Blueprint
Asset Management — Implementation Blueprint
Architect Agent 4 of the Pancha · 2026-05-02 · Module 4 of 17 in the Aayojana catalog.
1. Module summary
- Name: Asset Management
- Slug:
asset-management - Kind: ERP
- Status: planned (subpackage
src/aayojana/asset_management/to be created; no stub today)
A Dharmika institution's physical body lives here. Movable equipment (computers, AV, kitchen, generators), immovable (lands and buildings with named codes like RSK / GK / BK / NM / Jamadagni), vehicles (with the multi-document expiry calendar nobody else models well), and the Jewel Vault (Abharana) which is a high-security custody surface with two-key writes via the Audit Trail. Insurance and disposal sit alongside as cross-cutting concerns. Buildings carry rooms; rooms carry resident allocations that link back to Members.
2. Data model
All classes inherit Base + AuditMixin + TenantMixin from aayojana.models.base. Sanskrit/Devanagari fields use Text. Numeric weights and valuations use NUMERIC for precision.
# src/aayojana/asset_management/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 Asset(Base, AuditMixin, TenantMixin):
"""Polymorphic root. Every physical thing the trust owns has a row here.
Subtype-specific data lives in the linked equipment_assets / vehicle_assets /
land_assets / building_assets / jewel_vault_items table.
"""
__tablename__ = "assets"
__table_args__ = (
UniqueConstraint("tenant_id", "asset_code", name="uq_asset_code_per_tenant"),
Index("ix_assets_branch", "branch_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_code: Mapped[str] = mapped_column(String(40), nullable=False)
asset_type: Mapped[str] = mapped_column(String(20), nullable=False)
# 'movable' | 'vehicle' | 'land' | 'building' | 'jewel'
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)
branch_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("branches.id"), nullable=True
)
acquisition_date: Mapped[date | None] = mapped_column(Date, nullable=True)
acquisition_cost: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
acquisition_mode: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'purchase' | 'donation' | 'gift' | 'inheritance' | 'lease'
statutory_acq_record_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("asset_acquisition_records.id"), nullable=True
) # Agent 2's Statutory module
current_location: Mapped[str | None] = mapped_column(String(255), nullable=True)
condition: Mapped[str] = mapped_column(String(20), default="good", nullable=False)
# 'new' | 'good' | 'fair' | 'poor' | 'disposed'
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class EquipmentAsset(Base, AuditMixin):
"""Movable equipment subtype — computers, AV, kitchen, generators, gensets."""
__tablename__ = "equipment_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_id: Mapped[str] = mapped_column(
String(36), ForeignKey("assets.id", ondelete="CASCADE"), unique=True, nullable=False
)
category: Mapped[str] = mapped_column(String(40), nullable=False)
# 'computer' | 'av' | 'kitchen' | 'generator' | 'office' | 'other'
make: Mapped[str | None] = mapped_column(String(127), nullable=True)
model: Mapped[str | None] = mapped_column(String(127), nullable=True)
serial_number: Mapped[str | None] = mapped_column(String(127), nullable=True)
warranty_expires_on: Mapped[date | None] = mapped_column(Date, nullable=True)
assigned_to_member_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("members.id"), nullable=True
)
class VehicleAsset(Base, AuditMixin):
"""Vehicle subtype with the multi-document expiry calendar."""
__tablename__ = "vehicle_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_id: Mapped[str] = mapped_column(
String(36), ForeignKey("assets.id", ondelete="CASCADE"), unique=True, nullable=False
)
registration_number: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
vehicle_class: Mapped[str] = mapped_column(String(20), nullable=False)
# 'two-wheeler' | 'car' | 'van' | 'bus' | 'truck' | 'tractor'
make: Mapped[str | None] = mapped_column(String(127), nullable=True)
model: Mapped[str | None] = mapped_column(String(127), nullable=True)
year_of_manufacture: Mapped[int | None] = mapped_column(Integer, nullable=True)
chassis_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
engine_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
rc_expires_on: Mapped[date | None] = mapped_column(Date, nullable=True)
insurance_expires_on: Mapped[date | None] = mapped_column(Date, nullable=True)
puc_expires_on: Mapped[date | None] = mapped_column(Date, nullable=True)
fitness_expires_on: Mapped[date | None] = mapped_column(Date, nullable=True)
road_tax_paid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
odometer_reading: Mapped[int | None] = mapped_column(Integer, nullable=True)
primary_driver_member_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("members.id"), nullable=True
)
class LandAsset(Base, AuditMixin):
"""Bhumi — land parcels. Legal documentation lives in Statutory's
asset_acquisition_records; this row holds operational metadata.
"""
__tablename__ = "land_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_id: Mapped[str] = mapped_column(
String(36), ForeignKey("assets.id", ondelete="CASCADE"), unique=True, nullable=False
)
survey_number: Mapped[str] = mapped_column(String(63), nullable=False)
sub_division: Mapped[str | None] = mapped_column(String(40), nullable=True)
extent_acres: Mapped[float | None] = mapped_column(Numeric(10, 4), nullable=True)
extent_guntas: Mapped[float | None] = mapped_column(Numeric(8, 2), nullable=True)
nature_of_land: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'agricultural' | 'residential' | 'commercial' | 'temple' | 'gomala' | 'forest'
village: Mapped[str | None] = mapped_column(String(127), nullable=True)
taluk: Mapped[str | None] = mapped_column(String(63), nullable=True)
district: Mapped[str | None] = mapped_column(String(63), nullable=True)
state: Mapped[str | None] = mapped_column(String(63), nullable=True)
ec_last_obtained_on: Mapped[date | None] = mapped_column(Date, nullable=True)
mutation_status: Mapped[str | None] = mapped_column(String(40), nullable=True)
mutation_completed_on: Mapped[date | None] = mapped_column(Date, nullable=True)
boundaries: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# {north, south, east, west: free-text}
class BuildingAsset(Base, AuditMixin):
"""Building subtype — RSK / GK / BK / NM / Jamadagni or any tenant-named
building. Tenant-defined codes; we don't enum them.
"""
__tablename__ = "building_assets"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_id: Mapped[str] = mapped_column(
String(36), ForeignKey("assets.id", ondelete="CASCADE"), unique=True, nullable=False
)
building_code: Mapped[str] = mapped_column(String(20), nullable=False)
building_full_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
floors: Mapped[int | None] = mapped_column(Integer, nullable=True)
total_area_sqft: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True)
construction_year: Mapped[int | None] = mapped_column(Integer, nullable=True)
use_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'residential' | 'admin' | 'kitchen' | 'shrine' | 'school' | 'auditorium' | 'mixed'
on_land_asset_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("land_assets.id"), nullable=True
)
class Room(Base, AuditMixin, TenantMixin):
__tablename__ = "rooms"
__table_args__ = (
UniqueConstraint("building_id", "room_number", name="uq_room_per_building"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
building_id: Mapped[str] = mapped_column(
String(36), ForeignKey("building_assets.id", ondelete="CASCADE"), nullable=False
)
room_number: Mapped[str] = mapped_column(String(20), nullable=False)
floor: Mapped[int | None] = mapped_column(Integer, nullable=True)
capacity: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
room_type: Mapped[str] = mapped_column(String(20), default="residential", nullable=False)
# 'residential' | 'office' | 'classroom' | 'hall' | 'kitchen' | 'storeroom'
is_ac: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
amenities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_bookable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class RoomAllocation(Base, AuditMixin, TenantMixin):
__tablename__ = "room_allocations"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
room_id: Mapped[str] = mapped_column(String(36), ForeignKey("rooms.id"), nullable=False)
member_id: Mapped[str] = mapped_column(
String(36), ForeignKey("members.id"), nullable=False
)
check_in_date: Mapped[date] = mapped_column(Date, nullable=False)
check_out_date: Mapped[date | None] = mapped_column(Date, nullable=True)
purpose: Mapped[str | None] = mapped_column(String(40), nullable=True)
# 'staff' | 'student' | 'guest' | 'event-guest' | 'long-term-resident'
daily_rate: Mapped[float | None] = mapped_column(Numeric(10, 2), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
class JewelVaultItem(Base, AuditMixin, TenantMixin):
"""Abharana. EVERY write goes through Audit Trail's two-key flow
(Agent 2 designs `audit_events` and the two-key wrapper).
"""
__tablename__ = "jewel_vault_items"
__table_args__ = (
UniqueConstraint("tenant_id", "vault_code", name="uq_jewel_code"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid4_str)
asset_id: Mapped[str] = mapped_column(
String(36), ForeignKey("assets.id", ondelete="CASCADE"), unique=True, nullable=False
)
vault_code: Mapped[str] = mapped_column(String(40), nullable=False)
item_name: Mapped[str] = mapped_column(String(255), nullable=False)
item_name_sanskrit: Mapped[str | None] = mapped_column(Text, nullable=True)
item_description: Mapped[str | None] = mapped_column(Text, nullable=True)
metal: Mapped[str] = mapped_column(String(20), nullable=False)
# 'gold' | 'silver' | 'panchaloha' | 'platinum' | 'mixed'
purity_caratage: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True)
gross_weight_g: Mapped[float] = mapped_column(Numeric(10, 3), nullable=False)
net_weight_g: Mapped[float] = mapped_column(Numeric(10, 3), nullable=False)
stones_present: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
stones_description: Mapped[str | None] = mapped_column(Text, nullable=True)
last_valuation_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
last_valuation_on: Mapped[date | None] = mapped_column(Date, nullable=True)
valuer_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
safe_location: Mapped[str] = mapped_column(String(127), nullable=False)
# 'main-vault' | 'shrine-safe' | 'bank-locker:SBI-Mysuru' etc
custody_status: Mapped[str] = mapped_column(String(20), default="vault", nullable=False)
# 'vault' | 'in-use-festival' | 'in-repair' | 'pledged' | 'deposited-bank-locker'
class InsurancePolicy(Base, AuditMixin, TenantMixin):
__tablename__ = "insurance_policies"
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
)
policy_number: Mapped[str] = mapped_column(String(63), nullable=False)
insurer_name: Mapped[str] = mapped_column(String(255), nullable=False)
policy_type: Mapped[str] = mapped_column(String(40), nullable=False)
# 'comprehensive' | 'third-party' | 'fire' | 'theft' | 'public-liability'
sum_insured: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False)
annual_premium: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False)
coverage_start: Mapped[date] = mapped_column(Date, nullable=False)
coverage_end: Mapped[date] = mapped_column(Date, nullable=False, index=True)
claim_history: Mapped[list | None] = mapped_column(JSON, nullable=True)
# [{date, amount, status, claim_no}]
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
class AssetDisposal(Base, AuditMixin, TenantMixin):
__tablename__ = "asset_disposals"
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, unique=True
)
disposal_date: Mapped[date] = mapped_column(Date, nullable=False)
disposal_mode: Mapped[str] = mapped_column(String(20), nullable=False)
# 'sale' | 'scrap' | 'donation' | 'destroyed' | 'lost' | 'transferred'
disposed_to: Mapped[str | None] = mapped_column(String(255), nullable=True)
realisation_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
reason: Mapped[str] = mapped_column(Text, nullable=False)
approval_resolution_no: Mapped[str | None] = mapped_column(String(63), nullable=True)
audit_event_id: Mapped[str | None] = mapped_column(
String(36), ForeignKey("audit_events.id"), nullable=True
)
3. Reuse map
| Existing model | Used here as | Why |
|---|---|---|
organisations.id |
tenant_id everywhere |
Multi-tenant scoping |
branches.id |
assets.branch_id |
Physical centre an asset is at |
members.id |
room_allocations.member_id, equipment_assets.assigned_to_member_id, vehicle_assets.primary_driver_member_id |
Residents/staff/students are all members |
asset_acquisition_records.id |
assets.statutory_acq_record_id |
Statutory module (Agent 2) holds sale/gift/link deeds; we link metadata, not duplicate it |
audit_events.id |
asset_disposals.audit_event_id and every jewel write |
Agent 2's two-key envelope |
tags |
Asset tagging via existing polymorphic tag table (extend with entity_type='asset') |
Reuse |
4. API surface
Routes mounted under /api/asset-management/.
| Method | Path | Purpose |
|---|---|---|
| GET | /assets |
List assets with type/branch/condition filters |
| POST | /assets |
Create asset (typed) — validates subtype payload |
| GET | /assets/{id} |
Detail with subtype-joined data |
| PATCH | /assets/{id} |
Edit common fields |
| POST | /assets/{id}/dispose |
Disposal (resolution_no required) |
| GET | /buildings |
List building assets |
| POST | /buildings/{id}/rooms |
Add room |
| GET | /buildings/{id}/rooms |
List rooms with current allocation |
| POST | /rooms/{id}/allocate |
Create RoomAllocation |
| POST | /rooms/{id}/checkout |
Close current allocation |
| GET | /vehicles |
List with expiry calendar |
| GET | /vehicles/expiring |
RC/insurance/PUC/fitness expiring in N days |
| POST | /vehicles/{id}/renew |
Renew one of the four documents |
| GET | /lands |
List land assets |
| GET | /jewels |
Vault inventory (returns custody_status filter) |
| POST | /jewels |
New jewel — initiates two-key flow |
| POST | /jewels/{id}/valuation |
Two-key revaluation |
| POST | /jewels/{id}/movement |
Two-key custody change (vault → shrine etc.) |
| GET | /jewels/{id}/audit-trail |
Read audit_events for this jewel |
| GET | /insurance |
All policies, expiring filter |
| POST | /insurance |
New policy |
| POST | /insurance/{id}/claim |
Append claim history |
| GET | /dashboard |
Summary KPIs per tenant/branch |
5. Service layer
Critical functions live in src/aayojana/asset_management/services.py. All accept a tenant-scoped session and return DTOs.
record_jewel_valuation_change(jewel_id, new_valuation, valuer_name, primary_signer, second_signer, reason) -> AuditEvent— wraps the write in a two-key audit envelope; rejects if signers are the same user; emits to Audit Trail.move_jewel_custody(jewel_id, new_safe_location, new_status, primary_signer, second_signer, reason)— same envelope.register_asset(payload, asset_type) -> Asset— dispatches to subtype creator; validates subtype-specific fields; resolvesstatutory_acq_record_idif a sale-deed reference is provided.dispose_asset(asset_id, mode, realisation, resolution_no, primary_signer, second_signer)— also two-key for jewels; single-key for other assets but with audit_event log.allocate_room(room_id, member_id, check_in, purpose) -> RoomAllocation— guards capacity; rejects if active allocation already at capacity.release_room(allocation_id, check_out_date)— closes and frees capacity slot.book_room_for_event(room_id, event_id, start, end)— soft hold consumed by Events module.vehicle_expiry_calendar(branch_id, days_ahead=60) -> list[VehicleExpiryRow]— flattens RC/insurance/PUC/fitness into one timeline.insurance_renewal_alerts(days_ahead=30) -> list[InsurancePolicy]— feeds Comms reminders.compute_jewel_valuation_summary(branch_id) -> dict— total gold-g, silver-g, last-valuation total; for trustee pack via Reports.
6. UI / Templates
- List view:
/admin/assets— filter by type chips (movable/vehicle/land/building/jewel), branch, condition. - Asset detail page — common header + subtype panel (e.g. vehicle expiry calendar widget; jewel weight+valuation card).
- Building Master grid — collapsible accordion per building, rooms inside; capacity / occupancy meter.
- Room booking calendar — day/week view; resident allocations + event holds in different colours.
- Vehicle dashboard — four expiry rings (RC / Insurance / PUC / Fitness) per vehicle; red = T-30.
- Jewel Vault list — gated by role
vault-custodian; sortable by weight, valuation, last-audited-on. - Jewel two-key sign-off modal — modal opens with Primary / Second signer dropdowns (must differ); reason required; OTP-on-mobile optional; audit event preview shown before commit.
- Insurance Register — premium calendar (12-month grid), claim ledger drilldown.
- Disposals Log — append-only feed with resolution document attachment.
- Asset Management dashboard — counts per type, vehicle expiry alerts, jewel valuation summary, building occupancy.
7. Migration plan
| Rev | Title | Tables added |
|---|---|---|
| 0019 | asset_management_base | assets, equipment_assets, vehicle_assets, land_assets, building_assets, rooms, room_allocations, asset_disposals |
| 0020 | asset_jewel_and_insurance | jewel_vault_items, insurance_policies; add Audit Trail FKs (depends on Agent 2's 0013-0014) |
Rationale for two-step: 0019 is independent of Audit Trail; 0020 must follow Audit Trail's audit_events table being live. Also lets rooms come up early so Events module (0023) can FK them.
8. Cross-module dependencies
Reads from
- Members Suite (Agent 3):
members.idfor room residents, vehicle drivers, equipment custodians. - Statutory (Agent 2):
asset_acquisition_records.idfor sale-deed/gift-deed linkage on land/building. - Tenancy:
branches.idfor physical centre.
Writes to
- Audit Trail (Agent 2): every jewel insert/update/move/dispose; every land/building disposal; every insurance claim.
- Vitta (Agent 1): insurance premium → Vendor Payable; asset acquisition cost → Fixed Asset capitalisation; disposal realisation → Fund-tagged income.
- Comms (Agent 5): vehicle expiry T-30 reminders; insurance renewal T-30 reminders; AMC-renewal flows belong to Maintenance but originate from asset linkage.
- Reports (Agent 5): jewel valuation summary, vehicle compliance status, building occupancy KPIs.
Reads/used by
- Events module (this agent):
rooms.idfor resource bookings;building_assets.idfor venue selection. - Maintenance module (this agent):
assets.idis the FK target for every maintenance task and AMC.
9. Implementation phases
- Phase A — Schemas + basic CRUD. Migrations 0019/0020. Asset, building, rooms, vehicle, land, insurance CRUD endpoints. Admin list + detail templates. No two-key yet (placeholder service).
- Phase B — Cross-module integrations. Wire Statutory FK; wire Vitta vendor-payable for premiums; wire Maintenance asset FK; emit Audit events; wire Members for room allocation.
- Phase C — Workflows + dashboards. Two-key jewel modal, vehicle expiry dashboard, occupancy report, insurance renewal Comms cron, trustee-pack jewel summary.
10. Open questions
- Asset depreciation — Vitta computes (sees acquisition_cost + useful_life) or Asset Mgmt stores per-asset depreciation_schedule? Recommend: Vitta computes from acquisition_cost; Asset Mgmt holds source data only.
- Jewel weight precision —
NUMERIC(10,3)(0.001g) chosen. Confirm scale; some institutions weigh to 0.01g only. - Land extent units — store both acres and guntas? Or canonical sq-m and compute? Recommend: store both as entered, compute sq-m derived in service.
- Building codes per tenant — RSK / GK etc are Mysuru-specific; codes are a tenant-defined free-text. Confirm: don't enum.
- Two-key signer set — does the second signer need a specific role (
vault-second-signer) or any tenant-admin? Recommend: explicit role separate from tenant-admin so it can be delegated. - Vehicle odometer logging — capture once-per-month or only on document renewal events? Lean on Maintenance module's vehicle inspection records.
- Insurance bundling — one policy can cover multiple assets (fleet policy). Should
insurance_policies.asset_idbecome a M:N? Recommend: addinsurance_policy_assetsjoin table in Phase B. - Room rate cards —
room_allocations.daily_rateis per-allocation today; should there be aroom_rate_cardstable (room_type + tier + rate)? Defer to Phase C. - Jewel photographs — store image references (S3 / GCS path) in
jewel_vault_items.photo_pathsJSON array? Sensitive. Recommend: yes, with role-gated reads. - Pledged jewels —
custody_status='pledged'requires ajewel_pledgeschild table (lender, pledge_date, redemption_date)? Defer; not in initial scope.