Assets How-to Guide Public

ERPat Assets (IT Asset Management) — Implementation & Improvement Plan

ERPat Assets (IT Asset Management) — Implementation & Improvement Plan Status: Research complete · module audited · Phase 0 in progress. Target: A Snipe-IT-class IT Asset…

Guide version: r1 Module version: 1.0.0 Updated: 2026-07-22 Estimated time: 12 min 1 views

Status: Research complete · module audited · Phase 0 in progress. Target: A Snipe-IT-class IT Asset Management System (ITAM) folded into the ERPat Assets module (route prefix ams, module key assets, menu group Safekeep). Design principle: the data model is already ~"Snipe-IT MVP + most of Phase 2". Reuse the working runtime, make the modelled-but-dormant lifecycle actionable, fix the blockers, then layer the missing item types — defer anything without a real user today.

Visualize it: open docs/mockups/assets-blueprint.html in a browser. Self-contained interactive prototype (Dashboard → Inventory → Asset detail → Checkout/Acceptance → Audit → Maintenance → Depreciation schedule → Labels/QR → Import) using the ERPat brand tokens, left sidebar, and light/dark toggle. No PHP, no build step — just open it.


0. TL;DR

The Assets module is already a large, mostly high-quality Snipe-IT-style build — 11 controllers, 16 models, 33 views, 4 cron jobs, exemplary idempotent migrations, and full CRUD for assets, models, categories, locations, status labels, custom fields, fieldsets, depreciations, maintenances, groups, vendors, comments, import/export, and label PDFs. It even ships the schema for the checkout lifecycle and a chain-of-custody event log.

Three things block it from being a working ITAM:

  1. It fails the core test suiteconfig/default_menu.php is missing (every module must ship it; ModuleLoaderTest::test_every_module_ships_a_required_default_menu_config asserts it). Trivial fix, highest value.
  2. CSV import breaks on any new manufacturer — the importer writes created_at into the brands table whose date column is timestamp, so brand_id silently becomes 0. Headline feature, errors in real use.
  3. The flagship lifecycle is dormantasset_entries carries endorsement_status / issued_at / due_at / returned_at / acknowledged / asset_events, and the language file already has acknowledge_receipt / request_return / report_an_issue / endorsed_to — but no controller endpoint checks an asset out, records acceptance, returns it, or audits it. The module's own manifest advertises a "Snipe-IT-parity checkout/acknowledge/return lifecycle" that users cannot actually drive.

Fix those, wire the lifecycle to the UI, harden the injection/XSS surfaces, then add the missing item types (Licenses, Consumables, Components, stocked Accessories), a REST API, and reporting breadth. This plan sequences that as additive, value-ordered phases.


1. Current state

1.1 What already works (reuse as-is)

Capability Where Status
Asset CRUD (tag, serial, model, category, status, location, vendor, cost, warranty, image, meta) Entries.php + Asset_entries_model Works.
Asset Models / Categories (typed, EULA/acceptance flags) / Locations (hierarchical) / Groups Asset_models, Categories, Locations, Asset_groups Works.
Status Labels (deployable / pending / archived semantics + color + default) Asset_status_labels + seed Works (data only — see §1.3).
Custom Fields + Fieldsets (element types, format validation incl. regex, encrypted, required, ordering) Asset_custom_fields, Asset_fieldsets, Asset_fieldset_fields Works. Values stored as JSON on asset_entries.meta; compiled field_schema cache on the category — a deliberate multi-tenant-safe divergence from Snipe-IT's per-field ALTER.
Depreciations (straight-line) + EOL date + nightly book-value compute Asset_depreciations + ComputeAssetDepreciationJob Works.
Maintenance log (type, supplier, dates, cost, warranty flag) → writes asset_events Asset_maintenances Works.
CSV import (header auto-map, template, checkout-on-import) + CSV export Entries::import/export, Asset_importer_model Works except the brands bug (§1.3 / Bug #2).
Label / QR PDF (single + bulk) Entries + export_labels_* views Works.
Comments (@mentions + notifications) Entries + Asset_entry_comments_model Works.
Reminders: warranty expiry · return due · audit due · depreciation compute RemindAsset*Job (×3) + ComputeAssetDepreciationJob Cron wiring correct.
Reports: activity log · depreciation schedule · audit-due · maintenance log Asset_reports (4 read-only) Works.
Chain-of-custody event log asset_events + Asset_events_model Model + reads only — no lifecycle writes (§1.3).

1.2 Data model (as built)

asset_entries is the hub. Reference tables: asset_categories (typed, require_acceptance/eula_text, fieldset_id, compiled field_schema), asset_models (→ category, brand, fieldset, depreciation, eol_months, requestable), asset_locations (hierarchical), asset_status_labels (deployable/pending/archived/color), asset_depreciations (months, depreciation_min, depreciation_type), shared brands/vendors. Custom fields: asset_custom_fields (master) ↔ asset_fieldset_fields (pivot) ↔ asset_fieldsets, bound to a model via asset_models.fieldset_id. Child records of an asset: asset_events (chain of custody / activity), asset_maintenances, asset_accessories, asset_entry_comments. asset_groups.asset_entries holds a CSV of entry IDs (FIND_IN_SET-style — consistent with team/establishment membership conventions).

Lifecycle columns already on asset_entries: status_id, assigned_type (user/location/asset), issued_to, issued_at, due_at, returned_at, endorsement_status, acknowledged, acknowledged_at, endorsed_by, endorsement_reference, last_audit_date, next_audit_date, condition, requestable. asset_events: kind, title, body, target_type, target_id, occurred_at, accepted_at, signature_file, stored_eula. Everything the lifecycle needs to persist already exists.

1.3 Gaps & defects (from the audit — full list in Appendix A)

  • Missing config/default_menu.php → the core PHPUnit suite is red because of this module. (Bug #1, HIGH)
  • Importer manufacturer bugAsset_importer_model.php:562 inserts created_at into brands (column is timestamp). (Bug #2, HIGH)
  • Lifecycle not actionable → no checkout()/checkin()/acknowledge()/request_return()/audit() endpoints, and no auto-checkin when a non-deployable status is set (Snipe-IT's defining behavior). (Bug #3, HIGH)
  • SQL-injection defense gapsAsset_vendors_model.php:19-49 and Asset_entry_comments_model.php:23-28 interpolate $options into SQL without (int)/escape() (not proven-exploitable at current call sites, but inconsistent with the rest of the module). (MEDIUM)
  • Maintenance ignores its own permitAsset_maintenances.php:95,159 gate on the coarse asset view permit, not asset_maintenance. (MEDIUM)
  • Stored-XSS surfaces → asset detail header + several _make_row() server strings echo free-text (serial/model/type/color) unescaped. (MEDIUM)
  • Undeclared cross-module dependencyEntries.php:16 / Asset_models.php:13 load Inventory's items helper; works only because every module dir is registered regardless of enablement. (MEDIUM)
  • ~30 hardcoded strings → notably the entire asset change-log message set (Entries.php:331-422) and dropdown placeholders; violate the lang() rule. (LOW)
  • Missing page-shell wrappers on report/master full-page views; soft-delete filter omitted on some secondary/breadcrumb JOINs; orphaned asset_accessories table+model (no controller/views); module.json api_endpoints:true is unbacked. (LOW)

1.4 Missing standard files (vs a complete module — Content template)

config/default_menu.php (REQUIRED — test-enforced), CHANGELOG.md, README.md, composer.json, docs/ (this plan + the mockup), tests/, and — because api_endpoints:true is declared — the API config family (config/api_routes.php, config/euapi_routes.php, doc sidecars) or correcting the flag until the API phase lands.


2. Recommended architecture (target simplicity)

  1. Make the dormant lifecycle the centerpiece, don't rebuild it. Add explicit controller actions on Entries (checkout, checkin, acknowledge, request_return, audit) that write the existing asset_entries lifecycle columns and an asset_events row each. Every mutation = one event = the chain of custody. No schema change needed for the MVP lifecycle.
  2. Honor the status-label state machine. Setting a status whose type is archived/undeployable auto-checks-in (clears assignment); a pending status keeps the assignment (out-for-repair). This is the single behavior that makes status labels meaningful and is Snipe-IT's signature.
  3. Acceptance/EULA reuses the schema that's already there. On checkout to a user, if the category has require_acceptance/eula_text, present the EULA and capture acknowledgement + optional e-signature into asset_events.stored_eula/accepted_at/signature_file and asset_entries.acknowledged/acknowledged_at.
  4. Keep custom fields as JSON meta. Do not adopt Snipe-IT's per-field ALTER — the JSON approach is correct for DB-per-tenant. Merge (never replace) meta on partial updates/imports.
  5. New item types are additive tables + their own controllers, mirroring the existing entity pattern (get_details($options) model, permission-gated controller, index/modal_form views, idempotent migration).
  6. One shared dropdown/helper surface. Move the Inventory items-helper calls behind assets_helper.php (or declare a real dependencies.modules entry) so the asset/model forms don't silently break.

3. Data model — additions by phase

No new tables for the MVP lifecycle (Phases 0–2 use existing columns). Later phases add:

  • asset_licenses (name, product_key(encrypted), seats, category_id, manufacturer_id, supplier_id, purchase_cost, purchase_date, expiration_date, maintained, notes) + asset_license_seats (license_id, assigned_type+assigned_to = user OR asset, notes; one row per seat).
  • asset_consumables (name, category_id, qty, min_amt, purchase_cost) + asset_consumable_users pivot (checkout-to-user-only, no check-in).
  • asset_components (name, category_id, qty, min_amt, serial, purchase_cost) + asset_component_assets pivot (assigned_qty, partial return).
  • Stocked accessories — promote the orphaned asset_accessories into a catalog + checkout pivot (qty, min_amt, checkout to user/location), or repurpose the table.
  • Status labels: add show_in_nav for parity; Depreciations: add a method column (straight-line / declining-balance / half-year) for finance parity.

All new tables: idempotent (table_exists/SHOW COLUMNS/SHOW INDEX guards), reversible down(), and the audit block created_by, date_created, updated_at, deleted in order.


4. Phased delivery plan

Effort tags: S ≈ hours · M ≈ 1–2 days · L ≈ several days.

Phase 0 — Unblock & harden — S (this pass)

  • config/default_menu.php (REQUIRED) — Safekeep slice matching the 8 menu.php item names. Fixes the red test.
  • Importer brands bugcreated_attimestamp; check insert_id(); wrap the multi-write upsert in a transaction; merge (not replace) meta; write an asset_events row for import-checkouts.
  • Injection hardening(int)/$this->db->escape() in Asset_vendors_model + Asset_entry_comments_model.
  • Maintenance permit — gate create/update/delete on asset_maintenance.
  • XSShtml_escape() the free-text asset fields in the detail header + _make_row() strings; validate color.
  • Cross-module helper — front the Inventory items dropdowns behind assets_helper.php or declare the dependency.
  • Standard files: README.md, CHANGELOG.md, composer.json; reconcile module.json api_endpoints with reality (flip false until Phase 4, or ship the API config).

Phase 1 — Make the lifecycle actionable (the flagship) — M (this pass)

  • Entries::checkout() — pick target (user / location / asset), set assigned_type/issued_to/issued_at/due_at, flip status to a deployed/deployable label, write asset_events(kind=checkout), optionally notify assignee.
  • Acceptance/EULA — if the category requires it, present the EULA on checkout and store acceptance + e-signature (stored_eula/accepted_at/signature_file, acknowledged/acknowledged_at).
  • Entries::checkin() — record returned_at/condition, clear assignment, back to a "Ready" label; a pending status keeps the assignment (repair), archived/undeployable auto-checks-in.
  • Entries::acknowledge() (assignee accepts receipt) and Entries::request_return() / report_issue() (the lang keys already exist).
  • Gate all of the above on asset_checkout; every action writes one asset_events row.
  • Localize the change-log messages (Entries.php:331-422) → lang() keys.

Phase 2 — Audit, labels, notifications, reporting breadth — M

  • Entries::audit() — confirm presence/location/condition, set last_audit_date=today + next_audit_date from a configurable cadence (default +90d), write asset_events(kind=audit). Surfaces via the existing RemindAssetAuditDueJob.
  • Page-shell wrappers on the report/master views; QR deep-link on labels → the asset page.
  • Reports: add checked-out-to-user and warranty-expiry; low-inventory once consumables/accessories land.
  • Requestable self-service scaffolding — surface the requestable flag (full request→approve→checkout is Phase 3+).

Phase 3 — Missing item types — L

  • Licenses + seats, Consumables (checkout-only), Components (checkout-to-asset w/ assigned_qty), stocked Accessories — each: migration + model + controller + index/modal_form + menu/default-menu/permission entries.
  • Low-inventory alerts (accessories/consumables below min_amt).

Phase 4 — REST API — M

  • config/api_routes.php (/api/v1/assets…) + config/euapi_routes.php (/v1/api/…) + doc sidecars, covering assets, models, categories, statuses, locations, suppliers, custom fields, depreciations, maintenances, and checkout/checkin — enabling MDM/discovery CSV import. Only then set module.json api_endpoints:true honestly.

Phase 5 — Finance & differentiators — M/L

  • Depreciation methods (declining-balance, half-year) for finance parity; inventory-valuation & per-department rollup reports; department scoping (reuse HR FIND_IN_SET membership); optional PWA QR-audit, MDM/discovery import (Intune/Jamf CSV), procurement/PO + contract linkage to ERPat Finance, secure-disposal record.

5. Prototype spec (what docs/mockups/assets-blueprint.html shows)

Left sidebar on every page (mirrors the Learn portal shell), Safekeep brand teal #1d97a6, light/dark:

  • Dashboard — KPI stats (total / deployed / ready / maintenance / audit-due / warranty-expiring), assets-by-status and assets-by-category strips, "needs attention" and "assigned to me" lists.
  • Inventory — searchable/filterable asset grid + table (status ribbon, category icon, assignee), status/category/location filters.
  • Asset detail — header (tag, serial, model, status, assignee), meta strip (cost, purchase, warranty, book value), tabs: Details / Custom fields / Activity (chain of custody) / Maintenance / Comments, and the action bar (Checkout / Checkin / Audit / Edit / Label).
  • Checkout + Acceptance — target picker (user/location/asset), due date, and the EULA acceptance + e-signature step.
  • Audit — scan-tag panel, confirm location/condition, next-audit date.
  • Maintenance — log form (type, supplier, dates, cost, warranty flag) + history.
  • Depreciation schedule — cost → book value curve + table (straight-line).
  • Labels / QR — per-asset + bulk PDF label sheet preview with QR deep-links.
  • Import — CSV column-mapper with checkout-on-import.

6. Risks & simplicity guardrails

  • Don't rebuild what exists. The lifecycle is a wiring job, not a schema job. Resist re-modelling.
  • Keep the JSON meta custom-field approach. Per-field ALTER is wrong for DB-per-tenant.
  • Every mutation writes exactly one asset_events row — the chain of custody is the product's spine; keep it complete.
  • Idempotent + reversible migrations only, audit block in order — the module's migrations already model this; hold the line.
  • No raw SQL outside models; save() by reference; lang() everything; deleted=0 everywhere — the module mostly complies; the fixes close the exceptions, not open new ones.
  • Ship the API flag honest. api_endpoints:true only once Phase 4 exists.

Appendix A — Audit findings (prioritized)

HIGH — (1) missing config/default_menu.php (fails ModuleLoaderTest:527); (2) importer created_atbrands bug (Asset_importer_model.php:562); (3) checkout/audit lifecycle has no controller endpoints. MEDIUM — (4) SQLi defense gaps (Asset_vendors_model.php:19-49, Asset_entry_comments_model.php:23-28); (5) maintenance gated on asset not asset_maintenance (Asset_maintenances.php:95,159); (6) stored-XSS in detail/_make_row views; (7) undeclared Inventory items-helper dependency (Entries.php:16, Asset_models.php:13). LOW — (8) ~30 hardcoded strings (esp. Entries.php:331-422); (9) missing page-shell wrappers on report/master views; (10) soft-delete omitted on some secondary JOINs; (11) importer robustness (no transaction, unchecked insert(), meta replaced not merged, no event on import-checkout); (12) orphaned asset_accessories; api_endpoints:true unbacked; dead Asset_locations_model load in Asset_groups.php.

Appendix B — Standard-file checklist

File Required? Status
config/default_menu.php REQUIRED (test-enforced) add (Phase 0)
README.md Expected add (Phase 0)
CHANGELOG.md Expected add (Phase 0)
composer.json If src/ exists add stub (Phase 0)
docs/ + docs/mockups/ Polish this plan + prototype
config/api_routes.php / euapi_routes.php (+ sidecars) If api_endpoints:true Phase 4 (until then flip the flag)
tests/ Polish Phase 1+ (cover the lifecycle)
module.json, config/{menu,module_config,permissions,routes,widgets}.php, helpers/, jobs/, language/, migrations/ present & correct
Was this guide helpful?

Report a content problem