-
Notifications
You must be signed in to change notification settings - Fork 15
Asset Import Developer Guide
Bringing assets in from a spreadsheet. The mapping screen is the easy half; this guide is mostly about the other one β reconciliation, the preview, and what happens to a row that can't be imported.
Read the typed fields engine and custom asset fields guides first β an import writes through both. User-facing page: Recording anything. Design doc: docs/design/flexible-asset-fields.md Β§6 (local only).
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/services/asset_import.php |
AssetImportService β parse, suggest, map, reconcile, apply, log |
| ποΈ | database/freeitsm.sql |
asset_import_profiles Β· _mappings Β· _runs Β· _run_entries
|
| ποΈ |
includes/db_verify_schema.php Β· db_verify_indexes.php Β· api/system/db_verify.php
|
upgrade path, indexes (generated), 7 FKs |
| π | api/assets/import_upload.php |
takes the CSV, stores it safely, returns columns + a suggested mapping |
| π | api/assets/import_run.php |
preview or live β the same call, one word different |
| π | api/assets/import_history.php |
past runs, one run's rows, the holding area |
| π | api/assets/import_resolve.php |
mark a parked row dealt with |
| π₯οΈ | asset-management/settings/index.php |
the four-step wizard, the holding area, past runs |
| π§ͺ | tests/asset-import.php |
45 assertions |
source -> rows -> map -> reconcile -> validate -> apply -> log
\_ differs _/ \_______________ all shared _______________/
CSV and API are the same feature. A source yields rows of [column => value]; everything after that is shared. Only readCsv() knows what a file is, so the API puller changes nothing below it.
That's why source_kind exists on the profile table from day one, and why target does too β only asset is built, but a CMDB import becomes a later commit rather than a new project.
What makes an incoming row "the same printer"? Get this wrong and run two silently duplicates 400 printers.
Declared per profile as an ordered list of match keys (hostname, asset_tag, service_tag). The first key yielding exactly one match wins.
| Matches | Action |
|---|---|
| 0 across every key | create |
| exactly 1 | update |
| more than 1 |
A key yielding many stops the search and reports the conflict. Falling through to the next key would quietly resolve an ambiguity nobody had looked at.
Matching is always within one company. Two customers may each hold a "LAPTOP-01", and merging them would be a tenancy breach dressed up as a data merge.
getActiveTenantId() returns the Default company's real id. createAsset() normalises Default to NULL when it stores.
So the match query looked for tenant_id = 1 while every row it had just written held tenant_id IS NULL. Nothing ever matched, every row looked new, and the only thing between the estate and a full duplicate set was the unique-hostname refusal β which reports as an error, not as the reconciliation failure it actually was.
Exactly the disaster the feature exists to prevent, and invisible until the same file was run twice. The fix is three lines normalising Default to NULL before matching; the lesson is that a live second run is not optional.
Preview is not a lesser run β it's the same run, stopped before it writes. run(..., 'preview') takes the identical path and skips only the writes.
Anything a preview can't tell you is something a live run would surprise you with, which is why it must never become a separate "validation" routine. It would drift, and the first time it drifted somebody would find out by importing 500 wrong records.
The UI enforces it: Import stays disabled until a preview has run, and any change to the mapping or match keys disables it again.
A row that can't be imported is kept, with action = 'error' (or 'conflict') and its source line verbatim in raw_row.
Not dropped β the data and the reason are both lost. Not auto-created β that invents an asset type called "Televsion" the first time somebody typos a spreadsheet.
resolved_datetime is what empties the list; the row itself stays as the record of what happened.
This was Ed's call over both of the options originally offered, and it's better than either.
A row that created the asset and then failed on a custom field was logged as an error β while the asset quietly existed. The holding area said "failed"; a retry silently became an update.
Fixed with one transaction per row. The log entry is written outside it, because a rollback must undo the asset and never the record of what went wrong.
The assertion that proves it: "a failed row leaves NO asset behind".
A spreadsheet says "Printer", not "20". resolveLookups() matches asset_type_id, asset_status_id, location_id and supplier_id on name, case-insensitively, scoped to what the company can see, with a company's own entry beating a global default of the same name. A numeric value passes straight through as an id, so an export from another FreeITSM still works.
Resolved in the importer, not in AssetsService β that service's id-only contract is the REST API's and should stay that way. Same split as translating its duplicate-hostname message for the UI.
An unknown name is an error, never an auto-create, and the message says what to do: No asset type called "Printer". Create it first, or map that column to nothing.
The UTF-8 BOM. Excel writes one by default. Without stripping it the first header arrives as \xEF\xBB\xBFHostname, maps to nothing, and the identity column silently goes missing β so every row looks new. It's the single most common reason a first import "does nothing".
The escape character. Passed explicitly as ''. Real CSV (RFC 4180) has no escape character β a literal quote is doubled. PHP's historic backslash default is non-standard, mangles C:\Users\ in a cell, and 8.4 deprecates relying on it.
Duplicate headers. Renamed to Serial, Serial (2) rather than silently overwriting each other.
5000 rows per file, and the cap is SURFACED β in the upload result and on screen. A run that quietly imported the first 5000 of 8000 reads as a complete success.
asset_tag can be matched on but not written. It's unique per company and that rule lives in save_asset_tag.php, not AssetsService::fieldMap(). fieldMap() is silently ignored by create/update, which is precisely the quiet nothing CORE_TARGETS exists to prevent.
Match keys are filtered to columns the install actually has. assets.asset_tag arrives with the QR-label update; naming it on an install that hasn't run Verification turns every row into "Unknown column".
Rows that vanish from the source next run: ignore | flag | deactivate, an explicit setting, never a default guess. Only ever compared against assets this profile has touched before β comparing against the whole estate would deactivate every asset the spreadsheet was never about. If deactivate is chosen and no inactive status exists, the entry says so rather than reporting a deactivation that didn't happen.
Unknown dropdown values: reject (park the row) or add (create the option).
row_number is a reserved word in MySQL 8 β the ROW_NUMBER() window function. Unbackticked, every insert into the run log was a syntax error, so the log recorded nothing at all.
started_datetime was local, finished_datetime UTC. The column default is CURRENT_TIMESTAMP (server-local) while the finish used UTC_TIMESTAMP(), so runs displayed as finishing an hour before they started. Directory sync names the column explicitly for exactly this reason.
Files go through uploadStoreFile() β the one place the upload rules live. Extension and mime whitelist narrowed to CSV only, a filename FreeITSM generates, and both execution guards (.htaccess and web.config) written into uploads/asset-imports/.
Those guards ship in git β uploads/asset-imports/* ignored, the two guards un-ignored β rather than waiting for a first upload to create them.
import_run.php re-reads the stored file by name rather than accepting a re-upload, so committing what you previewed can't accidentally commit a different file. The name is basename()'d and whitelisted, since it's one FreeITSM generated.
β οΈ Uploaded CSVs are never tidied up. They accumulate and want a cron sweep. Not built.
tests/asset-import.php β 45 assertions, built around what ruins an unattended import rather than "does it read a CSV". The one that matters most: the same file twice creates nothing the second time.
Also covers the BOM, duplicate headers, preview writing zero, fill vs overwrite, ambiguity refusing, the holding area keeping the row and the reason, unknown dropdown handling, rows with no identity, a typo not inventing a type, and the mapping suggester.
- Saved profiles have a table and no UI. The wizard runs one-off imports; you can't yet name a mapping and re-run it. Scheduling depends on this.
- Scheduling β and it must reuse directory sync's mechanism, not add a second one.
-
The API source β
source_kind = 'api'needs auth modes, a JSON root path, pagination, and πsslApplyCurl($ch)on every handle. -
Per-field source precedence.
write_mode(fill / overwrite) is the blunt version. Once an asset is fed by the agent and a CSV and an API, who wins onmodel? Without a rule the nightly job stomps a human's correction every night and nobody notices for months.
- Custom asset fields β Developer Guide
- Typed fields engine β Developer Guide
- Recording anything β the user-facing page
- Directory Sync β whose run/entries design this copies
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)