Asset Operations Platform
IT and biomedical asset lifecycle, across every branch
OBJECTIVE
Atulaya runs IT and biomedical hardware — laptops, desktops, network gear, and diagnostic imaging equipment ("modalities") — across 15 branches, with no lifecycle system tracking who held what, when it moved, or when its warranty or service contract expired. This platform is that system: assignment, transfer, repair, warranty, and AMC/CMC service-contract tracking, from a machine's first registration to the day it's scrapped.
Assign a laptop → auto-closes any prior assignment → bundles peripherals → generates a signable undertaking PDF → the whole exchange is one append-only history row.
Why this is hard: this isn't one asset type — it's twelve, each with genuinely different attributes (a laptop has RAM and a processor; a diagnostic modality has a service contract and a payment schedule), all needing the same cross-cutting concerns: who has it now, what's its full history, is it under warranty, is it under an active AMC. The system has to unify without pretending every asset is the same shape.
THE ASSIGNMENT MODEL
Every asset type — laptops, desktops, printers, network switches, modalities — is its own table with its own attributes. What unifies them isn't a shared assets table; it's a polymorphic Assign model that any asset type can relate to:
assigns: assignable_type, assignable_id, user_id, department_id,
branch_id, assigned_at, unassigned_at, static_items
The row with unassigned_at IS NULL is the asset's current holder — and the table itself, never purged, is the full audit trail. Each asset also keeps its own denormalized is_assigned boolean for fast list filtering, kept in sync manually inside every action rather than enforced by a database constraint — a pragmatic choice that trades a small consistency risk for query simplicity across twelve differently-shaped tables.
Assignment isn't one asset to one person — it's a bundle. The AssignAsset action attaches a checklist of up to 15 peripheral types (keyboard, charger, docking station, HDMI cable...) through an assign_peripheral pivot, validates there's no duplicate device type in the submission, and re-checks each peripheral is still unassigned immediately before attaching — closing a real race window between page load and submit.
THE SERVICE CONTRACT SUBSYSTEM
Diagnostic modalities carry AMC, CMC, warranty, or MNC service contracts with real payment schedules — this is the most load-bearing part of the codebase, because it's the part with direct financial consequences if it silently corrupts data.
Contract numbers auto-generate sequentially per year (SC-2026-08-0001), and creating a contract auto-spawns its scheduled service visits and payment installments from the payment frequency — monthly, quarterly, half-yearly, yearly. Editing a live contract's terms runs inside a transaction that deletes only future, not-yet-completed records before recalculating, so correcting a contract never destroys already-completed service history.
One invariant is enforced directly on the model, not just in a form. Click to expand.
A PaymentRecord's updating hook throws if a record's status is changed away from paid — a payment can't be silently un-marked, only superseded by an explicit new record. Real data-integrity enforcement at the model layer, not a UI convention someone could bypass from an API call.
Auto-scheduling service visits and payments normally reads flags off the current HTTP request — which doesn't exist during a bulk Excel import. Rather than force a request context into a batch job, a parallel createScheduledRecordsForImport() path exists. A comment in that path reads "get correct interval months — this was the main bug!" — left in deliberately, because it's a better warning than a clean-looking function that hides why the duplication exists.
PERMISSIONS & ACCESS CONTROL
- —Role column, not a permissions matrix — admin, superadmin, inventory, maintenance, modalities, modality_manager — a single role string gates every Nova resource policy.
- —Destructive actions are superadmin-only — delete and force-delete are restricted at the policy level, independent of who can view or edit.
- —LDAP-backed identity — authentication runs through the corporate directory, not a locally-managed password table.
SYSTEM ARCHITECTURE
Two diagrams: how the platform is organized, and what actually happens inside the one action this whole system exists to get right — assigning an asset.
DIAGRAM 1 — RUNTIME ARCHITECTURE
Boxes are components, lines are who calls whom. Hover a box.
DIAGRAM 2 — ASSIGNMENT ACTION FLOW
What AssignAsset does, step by step — including the race-condition guard that closes the exact bug class this action exists to prevent:
RESULT
Fifteen branches' worth of IT and biomedical hardware now runs through one lifecycle system instead of spreadsheets and institutional memory: every assignment produces a signed undertaking and an audit-trail row, every peripheral handover is bundled and tracked, every modality's service contract auto-schedules its own visits and payments, and a live per-branch stock-count card replaces manual counting.
BY THE NUMBERS
Branches, asset types, and peripheral types are verified from the schema and seed data. Assets tracked is an assumed scale for a ~600-employee, 15-branch organization — not a measured production count.
STACK
Laravel 11 · Laravel Nova 5 · MySQL · Redis / Horizon · LDAP (ldaprecord) · Maatwebsite Excel · Dompdf · Pest
LESSONS LEARNED
- •Departments were originally modeled one-to-one per branch — a production migration later found duplicate department rows sharing a name across branches, and had to dedupe them, re-point every existing Assign foreign key onto the survivor, and convert the relationship to a proper many-to-many pivot without losing referential integrity. I'd model organizational relationships as many-to-many by default now — collapsing to 1:1 is an assumption that's cheap to make and expensive to undo once real data depends on it.
- •The denormalized is_assigned flag kept manually in sync with the Assign table (§02) works today because every write path goes through a small set of Nova actions — but it has no database-level guarantee behind it. If I were redesigning today, I'd derive that flag from the `Assign` table at read time instead of storing it, even at some query cost, rather than trust every future write path to keep two sources of truth in sync by hand.
- •Peripheral cascade-unassignment (§02) walks and closes every attached peripheral's own Assign row outside of a database transaction — correct on the happy path, but a mid-loop failure could leave a peripheral's state inconsistent with its parent asset. I'd wrap that cascade in a transaction before it ever needs to run at higher volume.