4
🏛

Asset Management — Blueprint

Movable · Immovable · Vehicles · Insurance

Asset Management — Implementation Blueprint

Architect Agent 4 of the Pancha · 2026-05-02 · Module 4 of 17 in the Aayojana catalog.

1. Module summary

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.

6. UI / Templates

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

Writes to

Reads/used by

9. Implementation phases

10. Open questions

  1. 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.
  2. Jewel weight precisionNUMERIC(10,3) (0.001g) chosen. Confirm scale; some institutions weigh to 0.01g only.
  3. 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.
  4. Building codes per tenant — RSK / GK etc are Mysuru-specific; codes are a tenant-defined free-text. Confirm: don't enum.
  5. 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.
  6. Vehicle odometer logging — capture once-per-month or only on document renewal events? Lean on Maintenance module's vehicle inspection records.
  7. Insurance bundling — one policy can cover multiple assets (fleet policy). Should insurance_policies.asset_id become a M:N? Recommend: add insurance_policy_assets join table in Phase B.
  8. Room rate cardsroom_allocations.daily_rate is per-allocation today; should there be a room_rate_cards table (room_type + tier + rate)? Defer to Phase C.
  9. Jewel photographs — store image references (S3 / GCS path) in jewel_vault_items.photo_paths JSON array? Sensitive. Recommend: yes, with role-gated reads.
  10. Pledged jewelscustody_status='pledged' requires a jewel_pledges child table (lender, pledge_date, redemption_date)? Defer; not in initial scope.