Cost Centres Developer Guide - edmozley/freeitsm GitHub Wiki

Cost centres โ€” Developer Guide

Since 3.1.0 ยท #160 stage 1 ยท User guide: Cost centres ยท API: REST API: Cost centres


Files

File Role
includes/services/cost_centres.php CostCentresService โ€” every rule, once: create, update, delete, list, sync()
api/system/cost_centres.php The screen's endpoint (list, save, delete, import preview/apply, export)
api/v1/resources/cost_centres.php REST: reads + serialiser here, every write through the service
includes/spreadsheet.php Pure-PHP .xlsx read/write and CSV โ€” reusable
system/cost-centres/index.php, system/help/cost-centres.php The screen and its help
cost_centres tenant_id NOT NULL, code, name, description, parent_id, is_active, is_demo
database/demo-data/cost-centres.json 24 demo codes for the Default company
tests/cost-centres.php 38 checks, rolled back

The rules

  • Always one company's. tenant_id is NOT NULL โ€” there's no "install-wide" cost centre. Every by-id method starts with load(), which 404s one outside the caller's companies (never 403, so ids can't be probed).
  • The code is text. Trimmed and nothing else โ€” never cast, padded or upper-cased. Unique per company ignoring case: the column's collation does that, and so does every comparison in the service.
  • The code is the sync key. sync() matches on lower-cased code, so the same file or API call can be sent every night without duplicating anything.
  • Inactive means "not for new assignments". Whatever is already charged keeps it (stage 2). Delete is refused while a cost centre has children.
  • A parent is in the same company, and is never the cost centre itself or anything below it โ€” sync() and update() both refuse a loop.

sync() โ€” the import and the API share it

CostCentresService::sync($conn, $actor, $tenantId, $rows, ['dry_run' => โ€ฆ, 'deactivate_missing' => โ€ฆ])

  1. Load the company's current list, keyed by lower-cased code.
  2. Validate every row first (codes, lengths, Active values, parents that exist in the file or the list, loops), collecting every error with its line.
  3. Any error โ†’ return the errors and write nothing.
  4. Otherwise plan create / update / unchanged (and deactivate for codes missing from the list, if asked), then apply in one transaction unless dry_run: every row is written first, then the parent links in a second pass, so a child can name a parent that comes later in the file.

The screen's Preview is dry_run; the REST /cost-centres/sync is the same call with JSON rows.

includes/spreadsheet.php

The Docker image has no zip extension, so .xlsx (a zip of XML) is read and written in pure PHP: a minimal zip reader/writer and just enough SpreadsheetML. CSV detection handles commas, semicolons (Excel in much of Europe) and tabs. Codes are written to the .xlsx as text cells, so a round trip through Excel keeps leading zeros; a numeric cell with a 0000 format is read the way it's shown. Reuse this for any future import or export rather than adding a library.

Traps

  • api/v1/lib/openapi_schemas.php has hand-written comments. Never round-trip it through var_export (api/v1/dev/openapi_fix.php does exactly that on write) โ€” add schemas by text insertion. The same for api/v1/spec.json: a JSON re-encode isn't byte-identical.
  • Excel turns 0010 into 10 in a CSV before FreeITSM sees it, and it can't be detected afterwards. The help steers people to the .xlsx export.

Stage 2 โ€” charging things to a cost centre (3.5.0)

assets, contracts and users each have cost_centre_id (FK ON DELETE SET NULL as a backstop, added by Database Verification). All the rules are in includes/cost_centre_assign.php, and every path goes through it:

Function Use
costCentreCheck($conn, $tenantId, $value, $currentId) The save rule: an active cost centre of the record's company, or the one it already holds (so an inactive one is kept, and a save that doesn't touch it still works). Throws ServiceError. Another company's id reads as unknown.
costCentreOptions($conn, $tenantId, $keepId) The picker list - active ones, plus $keepId when it's the record's current one. Served by api/cost-centres/options.php?company_id=&keep= (any of Assets / Contracts / Tickets, company must be reachable).
costCentreRecordTenant($conn, $tenantId) A record's company for this purpose: NULL โ†’ Default. cost_centres.tenant_id is never NULL, so always compare against the effective id.
costCentreByCode($conn, $tenantId, $code) By code (the column's collation ignores case). The asset import and the contracts API use it โ€” never ctype_digit โ†’ id: codes like 0010 are digits too.
costCentreSelectSql($conn, $table, $alias) , a.cost_centre_id, (โ€ฆ) AS cost_centre_label for any SELECT โ€” NULLs before Verification.
costCentreUsage($conn, $id) Counts per kind (assets, contracts, people, project budget lines). CostCentresService::delete() refuses while it's non-empty.

Where it is wired:

  • Assets โ€” AssetsService::fieldMap() kind cost_centre, checked in updateFields() and createAsset() (where the company is known โ€” validateField() has no context). update_asset_field.php allows it; get_assets.php adds cost_centre_id + cost_centre_label (and to the GROUP BY); Assets โ†’ Table column cost_centre_label; the import's CORE_TARGETS + resolveLookups(); REST lifecycle.cost_centre and ?cost_centre_id=.
  • Contracts โ€” ContractsService::costCentreFor() returns [id, legacy text]: cost_centre_id (checked), or cost_centre as a code (the API's old field), or neither (unchanged โ€” but moving company with the old company's cost centre is refused). The free-text column stays for text that matched nothing; choosing a cost centre clears it. The first Verification links text that equals a code in the contract's company ($contractsCcColWasMissing). REST keeps cost_centre a string (the code, or the old text) and adds cost_centre_id / cost_centre_name, so existing integrations don't break.
  • People โ€” api/tickets/save_user.php (the shared person editor, includes/person_editor.php, refills the box when the company changes), get_person.php, the People page (includes/people.php), REST cost_centre + cost_centre_id (apiUserCostCentre() in api/v1/resources/users.php).

tests/cost-centre-assign.php covers the rules on every service path with positive controls, in a rolled-back transaction.

Still not built (Daniel's original list): order cost, order date and depreciation on assets; service bookings. Domains keep their free-text cost_centre.


See also: Cost centres ยท REST API: Cost centres ยท Demo Data โ€” Developer Guide