Skip to content

Asset Review Developer Guide

Ed Mozley edited this page Oct 23, 2026 · 1 revision

πŸ› οΈ Asset review - Developer Guide

How part D of #200 is built: the review list, merging, protected values and name mappings. For what they do, see Asset review. For why, see the design. Matching itself is unchanged; see Asset reconciliation - Developer Guide.


1. πŸ“ The files

File What it does
includes/services/asset_review.php AssetReviewService: record(), settle(), sync(), listFor(), openFor(), decide(), createPeripheral(), item()
includes/services/asset_merge.php AssetMergeService: merge(), current(), intoForQrToken(); REFERENCES, POLYMORPHIC, NOT_REFERENCES
includes/services/asset_protection.php AssetProtectionService: FIELDS, forAsset(), set(), afterPersonEdit(), filterReported(), autoProtect()
includes/services/asset_name_map.php AssetNameMapService: all(), lookup(), show(), sqlExpr(), fromValuesMatching(), save(), delete(), namesInUse()
api/assets/review_list.php, review_decide.php, review_merge.php The Review tab, all behind Cap::ASSETS_RECONCILIATION
api/assets/protect_field.php The padlock (module access)
api/assets/name_mappings.php The names list (Cap::ASSETS_RECONCILIATION)
api/assets/update_asset_field.php Calls afterPersonEdit(); answers protected
api/assets/get_asset_inventory_status.php Also returns review (open cases for the asset) and protected
api/assets/get_assets.php manufacturer_display / model_display; search also matches mapped names
api/assets/get_widget_data.php Groups manufacturer and model charts by sqlExpr()
api/external/system-info/submit/index.php Skips a protected manufacturer or model
includes/services/assets.php updateAssetHostname() skips a protected name
includes/asset_labels.php assetIdForToken() falls back to intoForQrToken()
api/v1/resources/assets.php, includes/services/asset_connections.php Follow current() for a merged id
asset-management/index.php Merged-id redirect at the top; renderReviewNotice(), renderProtection(), setProtected(), renderShownAs()
asset-management/settings/index.php, manifest.php The Review tab and merge dialog; the protect setting and names section on Discovery & reconciliation
assets/js/asset-table.js The table shows the mapped names
tests/asset-merge.php, asset-protected-values.php, asset-name-mappings.php 26, 12 and 8 checks

2. πŸ“‹ The review list

asset_review_items
  tenant_id, kind, match_key   UNIQUE (tenant_id, match_key) - one row per case however often seen
  asset_id, other_asset_id     FK assets ON DELETE CASCADE
  source, detail (JSON)        e.g. {"serial": "...", "model": "...", "observed_at": "..."}
  first_seen_at, last_seen_at
  status                       open | merged | different | ignored | resolved
  resolved_by_id, resolved_at
kind Found by match_key
shared_serial sync() and openFor(), from assets serial:<lower serial>:<lowest id>:<other id>
hostname_reused sync(), from the hostname_reused history row createDiscoveredAsset() has always written hostname:<new id>:<old id>
unmatched_peripheral / ambiguous_match AssetConnectionsService::applyObserved() as reports arrive peripheral:<source>:<asset>:<lower serial>

Decisions stick. record() only reopens a resolved case (one that sorted itself out); merged, different and ignored stay. sync() and openFor() pass $touch = false, so finding a known case again never moves its dates.

Self-resolution. sync() closes shared_serial cases whose pair no longer shares a serial. applyObserved() calls settle() when a peripheral's serial now matches exactly one asset.

Why openFor() re-checks one serial. The asset page should say an asset shares a serial even if nobody has opened the Review tab yet. It runs sync()'s check for that one serial, with the same keys, so the two never disagree.


3. πŸ”— Merging

AssetMergeService::merge($conn, $ctx, $keepId, $goneId, $reviewItemId), in one transaction:

  1. Lock both assets; refuse different companies, and two assets both out on a lease.
  2. Walk REFERENCES, every column that refers to an asset, and apply its rule.
  3. polymorphic(): document_links (parent_type asset), asset_field_values.value_ref_id for fields whose ref_kind is asset, analyst_recent_trail.
  4. Fill the kept record's empty FILL fields from the other; widen first_seen and last_seen.
  5. Write asset_merges and a merged history row on the kept asset.
  6. Delete the merged asset, then apply the filled fields (so a filled asset tag or serial never meets its own original).
Rule Tables What it does
move history, custody log, import entries, Intune links, hide rules, software log, asset_merges.into_asset_id UPDATE ... SET col = keep WHERE col = gone
unique holders, ticket/contract/project/lease links, field sets, field values, software, volumes, adapters, protected fields Repoint unless the kept asset already has a row with the same key; drop those duplicates
agent_only physical drives, Device Manager No identity: if the kept asset has rows, the other's go (the agent rewrites them); else they move
sources asset_inventory_sources A source on both keeps the kept asset's link; one only on the other moves (the agent's reference becomes asset:<kept id>)
connections asset_connections, both columns Repoint; drop a connection between the two; close a duplicate open row per (pair, source)
review asset_review_items, both columns The case between these two closes as merged with the gone side set to NULL (it would cascade away otherwise); others repoint

The unique rule is one self-join:

UPDATE `$table` g LEFT JOIN `$table` k ON k.`$col` = :keep AND k.key <=> g.key ...
   SET g.`$col` = :keep WHERE g.`$col` = :gone AND k.`$col` IS NULL;
DELETE FROM `$table` WHERE `$col` = :gone;   -- what was left is a duplicate

TRAP: every column that refers to an asset is in REFERENCES

One left out keeps pointing at a deleted asset, or behind ON DELETE CASCADE is silently deleted with it. tests/asset-merge.php scans db_verify_schema.php for *asset_id and host_id columns and fails naming any that is not in REFERENCES or NOT_REFERENCES. Taking one entry out makes it fail. Add a polymorphic reference to polymorphic() and POLYMORPHIC by hand; the scan cannot see those.

What still finds the kept asset

asset_merges   merged_asset_id (UNIQUE, no FK - it is gone), into_asset_id (FK CASCADE),
               merged_hostname, merged_service_tag, merged_asset_tag, merged_qr_token, merged_by_id, merged_at, summary
  • current($id) follows asset_merges (chains included) to the asset an id means today. Used by the asset page (a redirect at the top of index.php), POST /assets/{id}/inventory (before apiLoadAsset()), and a REST connection's connected_asset_id.
  • intoForQrToken($token) is the fallback in assetIdForToken(), so a scanned label on the merged device opens the kept asset.

4. πŸ”’ Protected values

asset_protected_fields   asset_id (FK CASCADE), field_name   UNIQUE (asset_id, field_name)
                         protected_by_id, protected_at, reported_value, reported_source, reported_at

FIELDS is hostname, manufacturer, model: the built-in fields a person can edit on Key info that a source also writes. The serial is excluded on purpose (it is the matching key).

Enforcement is in exactly the two places a source writes them:

Writer How
AssetsService::updateAssetHostname() The rename every source goes through (agent, software agent, Intune). filterReported() returns nothing β†’ skip
The agent's report manufacturer = IF(?, manufacturer, ?) with the protected flag from filterReported()

filterReported($conn, $assetId, ['field' => value], $source) returns what the source may write and stores what it said for each protected field, or clears it when it agrees again. $source is shown on the page: the agent, Intune, software inventory.

People are not stopped. updateFields() (the page, REST PATCH, the spreadsheet import) writes as before. update_asset_field.php then calls afterPersonEdit(), which protects the field when the setting asset_protect_on_edit is on (the default; '0' turns it off) and the asset has last_seen (a source reports it).


5. 🏷️ Name mappings

asset_name_mappings   field ('manufacturer' | 'model'), from_value, to_value   UNIQUE (field, from_value)

Install-wide; matching ignores case (the collation, and mb_strtolower() in lookup()). Never written back to assets.

Where How
get_assets.php manufacturer_display / model_display per row; the search also matches fromValuesMatching()
asset-table.js Copies the display values over manufacturer / model for the table, so columns and filters use them
get_widget_data.php GROUP BY label over sqlExpr(): COALESCE((SELECT to_value ... WHERE from_value = TRIM(col)), col)
The asset page renderShownAs(): Shown as HP under the stored, editable value

save() refuses a name mapped to itself, and refuses mapping to a name that is itself mapped. It repoints anything that mapped to the new from, so there are never chains to follow. Not applied to labels, handover documents or other printouts: they show the stored value.


6. ⬜ Not done

  • Merging two assets from anywhere but the review list. There is deliberately no free-form "merge any two assets" button: a merge should start from a reason.
  • Watchtower does not count open review cases.
  • Name mappings on printouts.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally