SeaTec Table API Reference
Fenton Innovation
Fenton Innovation
Customised Intelligence · data tables

SeaTec Table API Reference

The 14 data tables your CBM data lands in, the endpoints that read and write them, and the sync loop that keeps them current. Every table follows the same four-column convention and the same routes, so once one works, they all do.

Authentication

We issue you one bearer token for a dedicated sync account in your organisation, valid for a year. Send it on every request. It can read your tables and write rows, nothing else.

  • Keep it in a secret store; never in source control or logs.
  • To rotate or revoke, ask us. Old and new can overlap for a cut-over window.
  • We send your organisation id and base URL with the token.
Header
Authorization: Bearer eyJhbGciOi…
Environment
export BASE="https://<api-host>"
export TOKEN="<your token>"
export ORG_ID="<your organisation id>"

Sync flow

Your script holds no state. It asks us where it got to, selects everything your database changed since then, and posts it. It never decides whether a row is new, never stores our ids, and never tracks what it has already sent.

Three properties make that safe:

Why it can't lose or duplicate rows

source_id decides insert vs update
We match every row on it. Sending the same row twice updates it instead of duplicating, so any batch can be replayed after a timeout or a crash.
The mark only moves on success
It advances to the largest source_modified_at we committed, never our clock. A failed batch leaves it where it was, so the next run picks the same rows up again.
The overlap covers late commits
Querying from mark − 10 minutes catches rows that committed with an older timestamp after your last run. They re-send as no-ops.

Two things to get right

Order by source_modified_at, oldest first. The mark moves forward with each batch, so posting out of order can advance it past rows you have not sent yet.

Retry timeouts and 5xx, never a 400. A 400 means the batch was rejected whole and nothing was written; resending it unchanged fails identically.

Sync one table
# Runs on a schedule. Keeps nothing between runs.

table = GET /data-tables/{table_id}
mark  = table.sync_high_water_mark or "1900-01-01"

rows = SELECT ... FROM your_view
       WHERE COALESCE(ModifiedDate, CreatedDate)
             > mark - 10 minutes
       ORDER BY source_modified_at, source_id

for batch in chunks(rows, 500):
    POST /data-tables/{table_id}/batch
      {"key_columns": ["source_id"], "rows": batch}

    # 200 -> {"inserted": n, "updated": m}
    #        the mark has advanced
    # 400 -> nothing written; fix the payload,
    #        do not resend as-is
    # 5xx -> retry this same batch, with backoff
Deletes are ordinary rows
-- Union your shadow table into the same view.
-- No delete endpoint, no reconciliation pass.

SELECT CAST(RSID AS varchar) AS source_id,
       delDate                AS source_modified_at,
       CAST(1 AS bit)         AS source_deleted,
       ...
FROM   dbo.AssessmentReport_del
WHERE  delDate > @since
First run
sync_high_water_mark is null
  -> send everything, oldest first,
     in the load order under Conventions.

Conventions

Sync columns on every table

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.

Values

  • Column types are the nine the API accepts: text, int, bigint, numeric, boolean, timestamptz, date, uuid, jsonb.
  • JSON numbers for numeric columns, JSON booleans for boolean, ISO 8601 UTC strings for timestamps, null for missing (never an empty string).
  • Enumerated values (stream, band, status…) are lower-case text; the allowed set is in each attribute's description.
  • Cross-references are <table>_source_id columns carrying the referenced row's source_id, never our ids. Composite keys join with a colon, e.g. 184532:norm-12.
Minimal valid row
{
  "source_id": "184532",
  "source_modified_at": "2026-08-28T14:02:11Z",
  "source_deleted": false
}
Delete as a row
{
  "source_id": "184532",
  "source_modified_at": "2026-08-29T08:00:00Z",
  "source_deleted": true
}

Load order

On a first run, load the tables in this order. Each one only references tables above it, so every row lands with something to point at.

Nothing is enforced across tables: a row whose reference has not arrived yet is still accepted, and resolves once the other table catches up. The order matters for keeping the data queryable while it loads, not for whether a write succeeds.

1organisation
references itself (parent_source_id); load parents before children
2fleet
references organisation
3ship
references fleet, organisation
4equipment_unit
references ship
5sample_type
stands alone
6parameter
stands alone
7template
8template_parameter
references template, parameter
9limit_rule
references parameter
10report
11sample
12result_value
references sample, parameter
13report_limit_snapshot
references report, parameter
14assessment
references report, sample
Order
 1. organisation
 2. fleet
 3. ship
 4. equipment_unit
 5. sample_type
 6. parameter
 7. template
 8. template_parameter
 9. limit_rule
10. report
11. sample
12. result_value
13. report_limit_snapshot
14. assessment

Errors

StatusMeaningWhat to do
200 / 201CommittedContinue
400Payload rejected: unknown column, bad type, missing key, > 500 rows, or a key column without a unique constraint. Nothing written.Fix the mapping; do not retry as-is
401Token missing, expired or revokedCheck the header; ask us for a new token
403Token valid but not for this table's organisationCheck the table id against your list
404No table (or row) with that idRe-list tables
409A uniqueness or foreign-key rule other than the upsert key was violatedInspect the message
503Your organisation's data store is briefly unavailableRetry with backoff

Error bodies are { statusCode, message, error }; message names the column or row index where it can.

GET/data-tables

List your tables.

Returns every table in the organisation with its live columns. Match on name to find each table's id; ids are stable, cache them.

Parameters

organisation_idquery · uuidrequired
your organisation id
Request
curl "$BASE/data-tables?organisation_id=$ORG_ID" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "provisioning_state": "ready",
  "tables": [
    {
      "id": "6c1e…",
      "name": "ship",
      "title": "Ships",
      "columns": [
        {
          "name": "source_id",
          "type": "text",
          "required": true,
          "unique": true
        },
        "…"
      ],
      "last_synced_at": "2026-09-03T04:30:00Z",
      "sync_high_water_mark": "2026-08-28T14:02:11Z"
    },
    "…"
  ]
}

GET/data-tables/{table_id}

Read one table's schema and sync state.

Columns and relationships are read live from the database. sync_high_water_mark is your cursor for the next delta; last_synced_at is display metadata. Both are null until the first batch lands.

Parameters

table_idpath · uuidrequired
the data table's id
Request
curl "$BASE/data-tables/$TABLE_ID" \
  -H "Authorization: Bearer $TOKEN"
Response
{
  "id": "6c1e…",
  "organisation_id": "…",
  "name": "ship",
  "title": "Ships",
  "columns": [
    {
      "name": "source_id",
      "type": "text",
      "required": true,
      "unique": true
    },
    {
      "name": "imo_no",
      "type": "text",
      "required": true,
      "unique": true
    },
    "…"
  ],
  "relationships": [],
  "column_config": {},
  "last_synced_at": "2026-09-03T04:30:00Z",
  "sync_high_water_mark": "2026-08-28T14:02:11Z"
}

POST/data-tables/{table_id}/batch

Upsert up to 500 rows keyed by source_id.

One transaction: either every row is written or none is. A row whose source_id exists is updated (only the columns you send), otherwise inserted. Safe to replay. After a successful call the table's sync_high_water_mark advances to the largest source_modified_at in the batch, never backwards.

Parameters

table_idpath · uuidrequired
the data table's id
key_columnsbody · string[]required
always ["source_id"]
rowsbody · object[] · 1–500required
row objects keyed by column name; omit a column to leave it unchanged on update, send null to clear it
Request
curl -X POST "$BASE/data-tables/$TABLE_ID/batch" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "ship-2211",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "fleet_source_id": "flt-88",
      "organisation_source_id": "cst-1042",
      "owner_source_id": "own-31",
      "name": "NORDIC AURORA",
      "imo_no": "9451234",
      "ship_type": "Bulk carrier",
      "gross_tonnage": 43210,
      "year_built": 2014,
      "is_active": true
    }
  ]
}'
Response · 200
{
  "inserted": 1,
  "updated": 0
}
Response · 400
{
  "statusCode": 400,
  "message": "Unknown column 'imo' on row 0"
}

GET/data-tables/{table_id}/rows

Page through rows, with sort and filter.

For spot checks after a sync, not part of the loop. Filter operators: eq, neq, gt, gte, lt, lte, like, ilike, in, is_null.

Parameters

table_idpath · uuidrequired
the data table's id
limitquery · int
rows per page, max 500
offsetquery · int
rows to skip
sortquery · string
col:asc or col:desc, comma-separated
filterquery · JSON
{"col":{"op":"eq","value":…}}, URL-encoded
Request
# filter is URL-encoded JSON

curl -G "$BASE/data-tables/$TABLE_ID/rows" \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "limit=50" \
  --data-urlencode "sort=source_modified_at:desc" \
  --data-urlencode 'filter={"imo_no":
                            {"op":"eq","value":"9451234"}}'
Response
{
  "rows": [
    {
      "source_id": "ship-2211",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "fleet_source_id": "flt-88",
      "organisation_source_id": "cst-1042",
      "owner_source_id": "own-31",
      "name": "NORDIC AURORA",
      "imo_no": "9451234",
      "ship_type": "Bulk carrier",
      "gross_tonnage": 43210,
      "year_built": 2014,
      "is_active": true,
      "id": "3f9a…",
      "synced_at": "2026-09-03T04:30:00Z"
    }
  ],
  "total": 1
}

POST/data-tables/{table_id}/rows

Add one row.

Not needed for sync; the batch route covers it. Included for completeness.

Parameters

table_idpath · uuidrequired
the data table's id
Request
curl -X POST "$BASE/data-tables/$TABLE_ID/rows" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "source_id": "ship-2211",
  "source_modified_at": "2026-08-28T14:02:11Z",
  "source_deleted": false,
  "fleet_source_id": "flt-88",
  "organisation_source_id": "cst-1042",
  "owner_source_id": "own-31",
  "name": "NORDIC AURORA",
  "imo_no": "9451234",
  "ship_type": "Bulk carrier",
  "gross_tonnage": 43210,
  "year_built": 2014,
  "is_active": true
}'
Response · 201
{
  "source_id": "ship-2211",
  "source_modified_at": "2026-08-28T14:02:11Z",
  "source_deleted": false,
  "fleet_source_id": "flt-88",
  "organisation_source_id": "cst-1042",
  "owner_source_id": "own-31",
  "name": "NORDIC AURORA",
  "imo_no": "9451234",
  "ship_type": "Bulk carrier",
  "gross_tonnage": 43210,
  "year_built": 2014,
  "is_active": true,
  "id": "3f9a…"
}

PATCH/data-tables/{table_id}/rows/{row_id}

Update one row by our row id.

Not used by the sync. row_id is the id we generate, not your source_id.

Parameters

table_idpath · uuidrequired
the data table's id
row_idpath · uuidrequired
our row id
Request
curl -X PATCH "$BASE/data-tables/$TABLE_ID/rows/$ROW_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"is_active": false}'
Response · 200
{
  "source_id": "ship-2211",
  "source_modified_at": "2026-08-28T14:02:11Z",
  "source_deleted": false,
  "fleet_source_id": "flt-88",
  "organisation_source_id": "cst-1042",
  "owner_source_id": "own-31",
  "name": "NORDIC AURORA",
  "imo_no": "9451234",
  "ship_type": "Bulk carrier",
  "gross_tonnage": 43210,
  "year_built": 2014,
  "is_active": false,
  "id": "3f9a…"
}

DELETE/data-tables/{table_id}/rows/{row_id}

Soft-delete one row by our row id.

Not used by the sync: send source_deleted = true through the batch route instead.

Parameters

table_idpath · uuidrequired
the data table's id
row_idpath · uuidrequired
our row id
Request
curl -X DELETE "$BASE/data-tables/$TABLE_ID/rows/$ROW_ID" \
  -H "Authorization: Bearer $TOKEN"
Response · 200
{
  "deleted": 1
}

Organisations

Group companies and the management companies under them. One row per commercial party.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
nametextrequired
Company name
kindtextrequired
group | manager | owner | client
parent_source_idtextreference
source_id of the parent organisation (group above a manager), null at the top
external_reftext
Your customer / company reference number
countrytext
ISO 3166 alpha-2
is_activebooleanrequired
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "cst-1042",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "name": "Northern Ship Management Ltd",
      "kind": "manager",
      "parent_source_id": "grp-7",
      "external_ref": "NSM-01",
      "country": "NO",
      "is_active": true
    }
  ]
}

Fleets

A named fleet within a management company.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
organisation_source_idtextrequiredreference
source_id of the owning organisation
nametextrequired
Fleet name
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "flt-88",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "organisation_source_id": "cst-1042",
      "name": "Aframax fleet"
    }
  ]
}

Ships

One vessel. The IMO number is the identity everything else hangs off.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
fleet_source_idtextreference
source_id of the fleet
organisation_source_idtextrequiredreference
source_id of the managing organisation
owner_source_idtextreference
source_id of the registered owner organisation
nametextrequired
Current vessel name
imo_notextrequiredunique
IMO number, 7 digits
ship_typetext
e.g. Bulk carrier, Semi-submersible
gross_tonnagenumeric
 
year_builtint
 
is_activebooleanrequired
false when disabled at your end
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "ship-2211",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "fleet_source_id": "flt-88",
      "organisation_source_id": "cst-1042",
      "owner_source_id": "own-31",
      "name": "NORDIC AURORA",
      "imo_no": "9451234",
      "ship_type": "Bulk carrier",
      "gross_tonnage": 43210,
      "year_built": 2014,
      "is_active": true
    }
  ]
}

Equipment units

One sampled machine, tank or water system on one ship: the thing a sample is drawn from.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
ship_source_idtextrequiredreference
source_id of the ship
nametextrequired
As it appears on reports, e.g. Main Engine, Generator Engine No.2, Boiler water
streamtextrequired
lube_oil | fuel_oil | water
applicationtext
engine | turbine | hydraulic | gear | stern_tube | grease | bunker | tank | boiler | cooling | potable
maketext
Manufacturer
modeltext
 
serial_notext
 
oil_gradetext
Oil grade in use
oil_brandtext
 
charge_volume_lnumeric
Oil charge volume in litres
sampling_interval_daysint
 
last_sampled_attimestamptz
 
is_criticalbooleanrequired
 
external_reftext
Planned-maintenance / SeaTec machinery id
is_activebooleanrequired
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "unit-30411",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "ship_source_id": "ship-2211",
      "name": "Main Engine",
      "stream": "lube_oil",
      "application": "engine",
      "make": "MAN B&W",
      "model": "6S60MC-C",
      "serial_no": "ME-77120",
      "oil_grade": "Talusia Universal 40",
      "oil_brand": "TotalEnergies",
      "charge_volume_l": 6200,
      "sampling_interval_days": 90,
      "last_sampled_at": "2026-08-20T00:00:00Z",
      "is_critical": true,
      "external_ref": "STM-30411",
      "is_active": true
    }
  ]
}

Sample types

The kinds of sample a lab analyses, per stream.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
streamtextrequired
lube_oil | fuel_oil | water
nametextrequired
e.g. Engine lube oil, Cylinder scrape-down, HFO bunker, Boiler water
short_codetext
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "st-lo-1",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "stream": "lube_oil",
      "name": "Engine lube oil",
      "short_code": "LO"
    }
  ]
}

Parameters

Every measured or derived quantity, across all streams: one row per parameter, never one column per parameter.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
streamtextrequired
lube_oil | fuel_oil | water
nametextrequired
Display name, e.g. Iron
short_nametext
e.g. Fe
codetextrequiredunique
Stable machine key, e.g. lo.iron_ppm
unittext
ppm, cSt, mgKOH/g, %, ISO code
data_typetextrequired
number | text | boolean | enum
methodtext
Test standard, e.g. ISO 8217 / ASTM D445
report_blocktext
Section of the report the parameter is printed in
sequence_noint
Print order within the block
is_activebooleanrequired
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "norm-12",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "stream": "lube_oil",
      "name": "Iron",
      "short_name": "Fe",
      "code": "lo.iron_ppm",
      "unit": "ppm",
      "data_type": "number",
      "method": "ASTM D5185",
      "report_block": "Wear metals",
      "sequence_no": 3,
      "is_active": true
    }
  ]
}

Templates

A report layout for a sample type: which parameters appear and the boilerplate around them.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
sample_type_source_idtextrequiredreference
 
nametextrequired
 
organisation_source_idtextreference
Set when the template is customer-specific
spec_referencetext
e.g. ISO 8217:2017
boilerplatejsonb
footer, certificate and background text
is_activebooleanrequired
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "tpl-fo-4",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "sample_type_source_id": "st-fo-1",
      "name": "HFO bunker RMG 380",
      "organisation_source_id": null,
      "spec_reference": "ISO 8217:2017",
      "boilerplate": {
        "footer": "Results relate only to the sample tested."
      },
      "is_active": true
    }
  ]
}

Template parameters

Which parameters a template carries, in what order.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
template_source_idtextrequiredreference
 
parameter_source_idtextrequiredreference
 
is_compulsorybooleanrequired
 
sequence_noint
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "tpl-fo-4:fo-31",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "template_source_id": "tpl-fo-4",
      "parameter_source_id": "fo-31",
      "is_compulsory": true,
      "sequence_no": 5
    }
  ]
}

Limit rules

A yellow or red threshold for one parameter in one scope. All seven legacy limit tables collapse into this one shape.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
parameter_source_idtextrequiredreference
 
scope_kindtextrequired
oil_grade | model | customer | fuel_grade | template | new_oil
scope_reftextrequired
The oil grade, model, customer, fuel grade or template the rule applies to
bandtextrequired
yellow | red
operatortextrequired
gt | gte | lt | lte | between | pct_change
value_1numeric
Threshold, or lower bound for between
value_2numeric
Upper bound for between
percentnumeric
For pct_change: deviation from new-oil baseline
precedenceintrequired
Lower wins when several rules match
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "on-5510-R",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "parameter_source_id": "norm-12",
      "scope_kind": "oil_grade",
      "scope_ref": "Talusia Universal 40",
      "band": "red",
      "operator": "gt",
      "value_1": 150,
      "value_2": null,
      "percent": null,
      "precedence": 20
    }
  ]
}

Reports

One issued lab report: a set of samples judged together and published as a certificate or PDF.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
ship_source_idtextrequiredreference
 
organisation_source_idtextrequiredreference
 
template_source_idtextreference
 
streamtextrequired
lube_oil | fuel_oil | water
report_notext
Certificate / test report number
statustextrequired
received | in_progress | assessed | published | unpublished
overall_ratingtext
Report-level grade or colour where the stream has one
received_attimestamptz
 
published_attimestamptz
 
pdf_urltext
Link to the issued PDF, if you host it
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "rpt-fo-77120",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "ship_source_id": "ship-2211",
      "organisation_source_id": "cst-1042",
      "template_source_id": "tpl-fo-4",
      "stream": "fuel_oil",
      "report_no": "FO-2026-77120",
      "status": "published",
      "overall_rating": "yellow",
      "received_at": "2026-08-22T09:15:00Z",
      "published_at": "2026-08-28T14:02:11Z",
      "pdf_url": null
    }
  ]
}

Samples

One physical sample within a report, drawn from one equipment unit. Carries the sampling context.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
report_source_idtextrequiredreference
 
equipment_unit_source_idtextrequiredreference
 
sample_type_source_idtextrequiredreference
 
sample_notextrequired
Lab sample number
bottle_notext
 
sampled_attimestamptz
 
dispatched_attimestamptz
 
received_attimestamptz
 
sampling_pointtext
 
sampled_bytext
 
unit_hoursnumeric
Unit running hours at sampling
oil_hoursnumeric
Hours since last oil change
oil_gradetext
Oil in use at sampling, as reported
ratingtext
Sample-level rating, e.g. A/B (oil/unit) or 1|2|3 traffic light
latitudenumeric
 
longitudenumeric
 
contextjsonb
Stream-specific extras: bunker details, engine load, feed rate, scavenge temps, seal numbers
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "184532",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "report_source_id": "rpt-lo-9931",
      "equipment_unit_source_id": "unit-30411",
      "sample_type_source_id": "st-lo-1",
      "sample_no": "LO-184532",
      "bottle_no": "B-00917",
      "sampled_at": "2026-08-20T06:30:00Z",
      "dispatched_at": "2026-08-21T00:00:00Z",
      "received_at": "2026-08-24T10:00:00Z",
      "sampling_point": "Before filter",
      "sampled_by": "C/E",
      "unit_hours": 41230,
      "oil_hours": 1980,
      "oil_grade": "Talusia Universal 40",
      "rating": "A/B",
      "latitude": 51.95,
      "longitude": 4.05,
      "context": {
        "engine_load_pct": 72,
        "fuel_sulphur_pct": 0.48
      }
    }
  ]
}

Result values

One measurement: a sample × a parameter. The largest table; every lab reading is one row here.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
sample_source_idtextrequiredreference
 
parameter_source_idtextrequiredreference
 
value_rawtextrequired
Exactly as the lab reported it: '<0.05', '1,23', 'Trace'
value_numnumeric
Parsed number when value_raw is numeric
unittext
Unit at time of test, if it differs from the parameter default
flagtext
normal | yellow | red
is_retestbooleanrequired
 
revisionintrequired
1 for the first reading; incremented on correction
commenttext
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "184532:norm-12",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "sample_source_id": "184532",
      "parameter_source_id": "norm-12",
      "value_raw": "42",
      "value_num": 42,
      "unit": "ppm",
      "flag": "normal",
      "is_retest": false,
      "revision": 1,
      "comment": null
    }
  ]
}

Report limit snapshots

The limits a published report was actually judged against, frozen at publish time so old reports keep their meaning when rules change.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
report_source_idtextrequiredreference
 
parameter_source_idtextrequiredreference
 
bandtextrequired
yellow | red
operatortextrequired
 
value_1numeric
 
value_2numeric
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "rpt-fo-77120:fo-31:red",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "report_source_id": "rpt-fo-77120",
      "parameter_source_id": "fo-31",
      "band": "red",
      "operator": "gt",
      "value_1": 60,
      "value_2": null
    }
  ]
}

Assessments

The assessor's verdict and narrative for a report or a single sample.

Attributes

source_idtextrequiredunique
Your primary key for the row, as a string. The upsert key.
source_modified_attimestamptzrequired
Your ModifiedDate (fallback CreatedDate), UTC. Drives the high-water mark.
source_deletedbooleanrequired
true when the row no longer exists at your end. We never physically delete.
report_source_idtextrequiredreference
 
sample_source_idtextreference
Null for a report-level assessment
ratingtext
 
comment_oiltext
Oil / fuel / water condition
comment_unittext
Unit condition
summarytext
 
actiontext
Recommended action
assessed_bytext
 
assessed_attimestamptz
 
synced_attimestamptzserver-set
Stamped by us on every write. Do not send.
Request body · POST /data-tables/{table_id}/batch
{
  "key_columns": [
    "source_id"
  ],
  "rows": [
    {
      "source_id": "asm-184532",
      "source_modified_at": "2026-08-28T14:02:11Z",
      "source_deleted": false,
      "report_source_id": "rpt-lo-9931",
      "sample_source_id": "184532",
      "rating": "A/B",
      "comment_oil": "Condition satisfactory; TBN adequate.",
      "comment_unit": "Iron trending up over three samples.",
      "summary": "Suitable for further service.",
      "action": "Resample at 250 hours.",
      "assessed_by": "N. Vora",
      "assessed_at": "2026-08-27T16:40:00Z"
    }
  ]
}

Legacy mapping

How the CBM_Sandbox tables become the 14 tables above: one extraction query per target, the identity scheme that keeps them joined, and the places where the legacy schema does not carry what the target needs. Every column named here was checked against the schema dump of 28 August 2026; the joins and the limit precedence are proposals to confirm against the running system before a full load.

How the extraction works

Each of the 14 target tables is produced by one query on your SQL Server. Where several legacy tables feed one target, the query is a UNION ALL of one branch per source. Run the query, page the result, and post it to that table's batch endpoint.

Three rules make the whole thing re-runnable, which is what lets you develop it incrementally and re-run after a fix:

1. Prefix every source_id

Three legacy tables feed organisation, so their ids collide. A prefix per source branch keeps them distinct and makes every id self-describing when you are debugging: cst-1042 is obviously Customer.CstId = 1042. Use the prefixes in the identity map below and never change them once you have loaded, because they are the join key on our side.

2. Every query returns the three sync columns

source_id, source_modified_at and source_deleted come out of every branch, so the same query serves both the first full load and every later delta. Add WHERE COALESCE(ModifiedDate, CreatedDate) > @since to turn a full extract into a delta extract; nothing else changes.

3. References carry the prefixed id, not the raw one

A foreign key becomes 'ship-' + CAST(s.ShipId AS varchar(20)), not s.ShipId. Every *_source_id column in the target holds the value the referenced row will be loaded under.

Work in the order in the Load order section. References that have not landed yet are accepted, so a mistake in ordering costs nothing permanent, but loading parents first keeps the data queryable as it goes in.

Identity map

Which legacy table becomes which target row, and under what source_id.

Legacy tableTargetsource_id
GroupCompanyorganisationgrp-{GroupCompanyId}
Customerorganisationcst-{CstId}
CBM_Ownerorganisationown-{OwnerId}
CBM_Fleetfleetflt-{FleetID}
Shipshipship-{ShipId}
ShipModelequipment_unitunit-{shipModId}
ShipPurifierequipment_unitunit-pur-{shipPurId}
(synthetic, per ship)equipment_unitunit-fo-{ShipId}
(synthetic, per ship + water type)equipment_unitunit-wa-{ShipId}-{WaterTypeId}
LOTestTypesample_typest-lo-{TestTypeId}
FO_OilTypesample_typest-fo-{OilTypeId}
WA_WaterTypesample_typest-wa-{WaterTypeId}
(code list, see Parameters)parameterp-lo-{code}
FO_Parametersparameterp-fo-{ParameterId}
WA_Parameterparameterp-wa-{ParameterId}
FO_ParameterTemplatetemplatetpl-fo-{PTemplateId}
WA_Templatetemplatetpl-wa-{TemplateId}
OilNormslimit_rulelr-oil-{onId}
ModelNormslimit_rulelr-mod-{mnId}
CustomerNormslimit_rulelr-cst-{cnId}
FO_BlockParameterLimitlimit_rulelr-fo-{BlockParameterLimitId}
WA_ModelNormslimit_rulelr-wa-{TemplateParameterId}
AssessmentReport, groupedreportrpt-lo-{CsvID}-{VesselID}
FO_CSVHeaderreportrpt-fo-{FOReportHeaderId}
WA_CSVHeaderreportrpt-wa-{WAReportHeaderId}
AssessmentReportsamplesmp-lo-{RSID}
FO_CSVHeadersamplesmp-fo-{FOReportHeaderId}
WA_CSVHeaderDetailsMappingsamplesmp-wa-{WACSVHeaderDetailsMappingId}
AssessmentReport columnsresult_valuerv-lo-{RSID}-{code}
FO_CSVDetailsresult_valuerv-fo-{FOReportsDetailsId}
WA_CSVDetailsresult_valuerv-wa-{WAReportsDetailsId}

organisation, fleet, ship

Three legacy tables become organisation, distinguished by kind. Customer.GroupCompanyId becomes the parent link; owners have no parent.

organisation
SELECT 'grp-' + CAST(g.GroupCompanyId AS varchar(20))    AS source_id,
       COALESCE(g.ModifiedDate, g.CreatedDate)            AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       g.GroupCompanyName                                 AS name,
       'group'                                            AS kind,
       NULL                                               AS parent_source_id,
       NULL                                               AS external_ref,
       NULL                                               AS country,
       CASE WHEN ISNULL(g.Active, 1) = 1 THEN 1 ELSE 0 END AS is_active
FROM   dbo.GroupCompany g

UNION ALL

SELECT 'cst-' + CAST(c.CstId AS varchar(20)),
       COALESCE(c.modDate, c.crDate),
       CAST(0 AS bit),
       c.CstCompany,
       'manager',
       CASE WHEN c.GroupCompanyId IS NULL THEN NULL
            ELSE 'grp-' + CAST(c.GroupCompanyId AS varchar(20)) END,
       c.CustRefNo,
       co.CountryCode,
       CASE WHEN ISNULL(c.Active, 1) = 1 THEN 1 ELSE 0 END
FROM   dbo.Customer c
LEFT   JOIN dbo.CBM_Country co ON co.conId = c.conId

UNION ALL

SELECT 'own-' + CAST(o.OwnerId AS varchar(20)),
       COALESCE(o.modDate, o.crDate),
       CAST(0 AS bit),
       o.OwnerNm, 'owner', NULL, o.VatNo, co.CountryCode, 1
FROM   dbo.CBM_Owner o
LEFT   JOIN dbo.CBM_Country co ON co.conId = o.conId;
fleet
SELECT 'flt-' + CAST(f.FleetID AS varchar(20))          AS source_id,
       COALESCE(f.modDate, f.crDate)                      AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       'cst-' + CAST(f.fleetCustomer AS varchar(20))      AS organisation_source_id,
       f.fleetName                                        AS name
FROM   dbo.CBM_Fleet f;
ship
SELECT 'ship-' + CAST(s.ShipId AS varchar(20))           AS source_id,
       COALESCE(s.modDate, s.crDate)                      AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       CASE WHEN s.ShipFleet IS NULL THEN NULL
            ELSE 'flt-' + CAST(s.ShipFleet AS varchar(20)) END  AS fleet_source_id,
       'cst-' + CAST(s.ShipCompany AS varchar(20))        AS organisation_source_id,
       CASE WHEN s.OwnerId IS NULL THEN NULL
            ELSE 'own-' + CAST(s.OwnerId AS varchar(20)) END    AS owner_source_id,
       s.ShipName                                         AS name,
       s.ShipIMOno                                        AS imo_no,
       t.Typeofshipname                                   AS ship_type,
       TRY_CAST(s.grosstonnage AS decimal(18,2))          AS gross_tonnage,
       TRY_CAST(s.yearofbuild AS int)                     AS year_built,
       CASE WHEN ISNULL(s.IsDisabled, 0) = 1 THEN 0 ELSE 1 END  AS is_active
FROM   dbo.Ship s
LEFT   JOIN dbo.Typeofship t ON t.Typeofshipid = s.typeofshipid;

Ship names change. Ship.OldShipName holds previous names and ShipClone records re-registrations. The IMO number stays put, so load imo_no as the stable identity and let name update in place.

equipment_unit

The sampled unit. Lube-oil units come from ShipModel, one row per machine per vessel. Purifiers come from ShipPurifier. Fuel-oil and water samples have no machine row in the legacy schema, so they get one synthetic unit each, described in the Synthetic rows section below.

equipment_unit — lube oil, from ShipModel
SELECT 'unit-' + CAST(sm.shipModId AS varchar(20))       AS source_id,
       CASE WHEN sm.LastSampleDate > sm.crDate
            THEN sm.LastSampleDate ELSE sm.crDate END      AS source_modified_at,
       CAST(0 AS bit)                                      AS source_deleted,
       'ship-' + CAST(sm.shipId AS varchar(20))            AS ship_source_id,
       m.macName                                           AS name,
       'lube_oil'                                          AS stream,
       ot.ShortDesc                                        AS application,
       man.manName                                         AS make,
       mo.modNo                                            AS model,
       sm.SrNo                                             AS serial_no,
       og.oilGrade                                         AS oil_grade,
       ob.obrandName                                       AS oil_brand,
       TRY_CAST(sm.OilVol AS decimal(18,2))                AS charge_volume_l,
       sm.Schedule                                         AS sampling_interval_days,
       sm.LastSampleDate                                   AS last_sampled_at,
       CASE WHEN ISNULL(sm.IsCritical, 0) = 1 THEN 1 ELSE 0 END AS is_critical,
       sm.SeaTecMachineryId                                AS external_ref,
       CASE WHEN ISNULL(sm.IsDisabled, 0) = 1 THEN 0 ELSE 1 END AS is_active
FROM   dbo.ShipModel sm
JOIN   dbo.Machinery m    ON m.macId    = sm.macId
LEFT   JOIN dbo.Model mo  ON mo.modId   = sm.modId
LEFT   JOIN dbo.Manufacturer man ON man.manId = mo.manId
LEFT   JOIN dbo.OilType ot ON ot.otypeId = m.otypeId
LEFT   JOIN dbo.OilGrade og ON og.oilId  = sm.OilGradeId
LEFT   JOIN dbo.OilBrand ob ON ob.obrandId = og.obrandId;

ShipModel has no modified date. The table carries crBy and crDate but no modDate, so an edit to a serial number or an oil grade leaves no timestamp behind and a delta query will miss it. LastSampleDate moves when samples arrive, which is why it is used above, but it does not cover edits.

So re-send this table in full on every run. It is a few thousand rows at most; the upsert makes a full re-send cheap and it is the only way to keep equipment metadata correct. The same applies to ShipPurifier.

sample_type and parameter

sample_type unions the three per-stream type tables. Nothing here is large; re-send it in full whenever it changes.

sample_type
SELECT 'st-lo-' + CAST(t.TestTypeId AS varchar(20)) AS source_id,
       GETUTCDATE()                                   AS source_modified_at,
       CAST(0 AS bit)                                 AS source_deleted,
       'lube_oil'                                     AS stream,
       t.TestType                                     AS name,
       NULL                                           AS short_code
FROM   dbo.LOTestType t

UNION ALL

SELECT 'st-fo-' + CAST(o.OilTypeId AS varchar(20)),
       COALESCE(o.ModifiedDate, o.CreatedDate),
       CAST(0 AS bit), 'fuel_oil', o.OilTypeName, NULL
FROM   dbo.FO_OilType o

UNION ALL

SELECT 'st-wa-' + CAST(w.WaterTypeId AS varchar(20)),
       COALESCE(w.ModifiedDate, w.CreatedDate),
       CAST(0 AS bit), 'water', w.WaterTypeName, w.WaterShortCode
FROM   dbo.WA_WaterType w;

The parameter table is where the three streams finally line up. Fuel oil and water already keep parameters as rows, so they map straight across. Lube oil does not: its parameters are columns on AssessmentReport, so they are seeded from the fixed code list below, the same list the pivot uses.

parameter — fuel oil and water map directly
SELECT 'p-fo-' + CAST(p.ParameterId AS varchar(20))  AS source_id,
       COALESCE(p.ModifiedDate, p.CreatedDate)        AS source_modified_at,
       CAST(0 AS bit)                                 AS source_deleted,
       'fuel_oil'                                     AS stream,
       p.PName                                        AS name,
       p.PShortName                                   AS short_name,
       'fo.' + p.PCSVName                             AS code,
       p.PUnit                                        AS unit,
       'number'                                       AS data_type,
       me.MethodName                                  AS method,
       b.BlockTitle                                   AS report_block,
       p.SequenceNo                                   AS sequence_no,
       CASE WHEN ISNULL(p.Active, 1) = 1 THEN 1 ELSE 0 END AS is_active
FROM   dbo.FO_Parameters p
LEFT   JOIN dbo.FO_Method me ON me.MethodId = p.PMethod
LEFT   JOIN dbo.FO_Block b   ON b.BlockId   = p.BlockId

UNION ALL

SELECT 'p-wa-' + CAST(p.ParameterId AS varchar(20)),
       COALESCE(p.ModifiedDate, p.CreatedDate),
       CAST(0 AS bit), 'water',
       p.ParamterName, p.ShortName,
       'wa.' + p.CsvHeaderName,
       p.Unit, 'number',
       me.MethodName, b.BlockName, p.SequenceNo,
       CASE WHEN ISNULL(p.IsActive, 1) = 1 THEN 1 ELSE 0 END
FROM   dbo.WA_Parameter p
LEFT   JOIN dbo.WA_Method me ON me.MethodId = p.MethodId
LEFT   JOIN dbo.WA_Block b   ON b.BlockId   = p.BlockId;

Nothing in the legacy schema maps an AssessmentReport column to a Norms row. Norms is the lube-oil parameter dictionary and carries the unit and the per-application flags, but the link to the result column is by naming convention only, and the conventions disagree in places (Vischk00 is viscosity at 100 °C, AcidNo is TAN, BaseNumber is TBN).

So the mapping is fixed once, by hand, in the list below, and both the parameter seed and the pivot read from it. Do not try to derive it at run time.

parameter — lube oil, 100 codes seeded from the fixed list
-- The same (code, column) pairs the pivot uses. Load once.
-- Unit and short name are picked up from Norms where the name matches;
-- fill the rest in by hand, once.

WITH lo(code, legacy_column, report_block) AS (
  SELECT * FROM (VALUES
    ('appearance', 'Appearance', 'Condition'),
    ('vis40', 'Vis40', 'Condition'),
    ('vis100', 'Vischk00', 'Condition'),
    ('vis_index', 'VisIndex', 'Condition'),
    ('flash_point', 'FlashPoint', 'Condition'),
    ('water_pct', 'Water', 'Condition'),
    ('water_kf_ppm', 'WaterKFppm', 'Condition'),
    ('emulsified_water', 'EmulsifiedWater', 'Condition'),
    ('tbn', 'BaseNumber', 'Condition'),
    ('tan', 'AcidNo', 'Condition'),
    ('soot_insolubles', 'SootInsolubles', 'Condition'),
    ('pentane_insolubles', 'PentaneInsolubles', 'Condition'),
    ('sulphated_ash', 'SulphatedAsh', 'Condition'),
    ('oxidation', 'Oxidation', 'Condition'),
    ('oxidation_abs', 'OxidationAbsolute', 'Condition'),
    ('oxidation_products', 'OxidationProducts', 'Condition'),
    ('nitration_abs', 'NitrationAbsolute', 'Condition'),
    ('sulphation_abs', 'SulphationAbsolute', 'Condition'),
    ('glycol', 'GLYCOL', 'Condition'),
    ('fuel_dilution', 'FUELDILUTION', 'Condition'),
    ('asphaltenes', 'Asphaltenes', 'Condition'),
    ('chlorine', 'Chlorine', 'Condition'),
    ('ph', 'pH', 'Condition'),
    ('specific_gravity', 'SpecificGravity', 'Condition'),
    ('conductivity', 'Conductivity', 'Condition'),
    ('liquid_corrosion', 'LiquidCorrosionConcentration', 'Condition'),
    ('vapour_corrosion', 'VapourPhaseCorrosion', 'Condition'),
    ('rocking_test', 'RockingTest', 'Condition'),
    ('sulphur', 'Sulpher', 'Condition'),
    ('fuel_p', 'FuelP', 'Condition'),
    ('oil_condition_index', 'OILCONDITIONINDEX', 'Condition'),
    ('water_scavenger', 'WaterScavenger', 'Condition'),
    ('calcium', 'Calcium', 'Additive elements'),
    ('magnesium', 'Magnesium', 'Additive elements'),
    ('zinc', 'Zinc', 'Additive elements'),
    ('phosphorus', 'Phosphorus', 'Additive elements'),
    ('barium', 'Barium', 'Additive elements'),
    ('boron', 'Boron', 'Additive elements'),
    ('iron', 'Iron', 'Wear metals'),
    ('copper', 'Copper', 'Wear metals'),
    ('lead', 'Lead', 'Wear metals'),
    ('tin', 'Tin', 'Wear metals'),
    ('chromium', 'Chromium', 'Wear metals'),
    ('aluminium', 'Aluminium', 'Wear metals'),
    ('nickel', 'Nickel', 'Wear metals'),
    ('titanium', 'Titanium', 'Wear metals'),
    ('silver', 'Silver', 'Wear metals'),
    ('manganese', 'Manganese', 'Wear metals'),
    ('vanadium', 'Vanadium', 'Wear metals'),
    ('molybdenum', 'Molybdenum', 'Wear metals'),
    ('silicon', 'Silicon', 'Contaminants'),
    ('sodium', 'Sodium', 'Contaminants'),
    ('lithium', 'Lithium', 'Contaminants'),
    ('pq_index', 'PQIndex', 'Ferrous debris and cleanliness'),
    ('ferrous_debris', 'FERROUSDEBRIS', 'Ferrous debris and cleanliness'),
    ('iso4406', 'ISOCODE', 'Ferrous debris and cleanliness'),
    ('iso_4um', '4um', 'Ferrous debris and cleanliness'),
    ('iso_6um', '6um', 'Ferrous debris and cleanliness'),
    ('iso_14um', '14um', 'Ferrous debris and cleanliness'),
    ('iso4407', 'ISOCODE4407', 'Ferrous debris and cleanliness'),
    ('iso4407_4um', '4um4407', 'Ferrous debris and cleanliness'),
    ('iso4407_6um', '6um4407', 'Ferrous debris and cleanliness'),
    ('iso4407_14um', '14um4407', 'Ferrous debris and cleanliness'),
    ('nas1638', 'NAS1638Class', 'Ferrous debris and cleanliness'),
    ('pc_4um', 'ParticleCount4', 'Ferrous debris and cleanliness'),
    ('pc_5um', 'ParticleCount5', 'Ferrous debris and cleanliness'),
    ('pc_6um', 'ParticleCount6', 'Ferrous debris and cleanliness'),
    ('pc_7um', 'ParticleCount7', 'Ferrous debris and cleanliness'),
    ('pc_10um', 'ParticleCount10', 'Ferrous debris and cleanliness'),
    ('pc_14um', 'ParticleCount14', 'Ferrous debris and cleanliness'),
    ('pc_20um', 'ParticleCount20', 'Ferrous debris and cleanliness'),
    ('pc_30um', 'ParticleCount30', 'Ferrous debris and cleanliness'),
    ('pc_5_15um', 'ParticleCount5-15um', 'Ferrous debris and cleanliness'),
    ('pc_15_25um', 'ParticleCount15-25um', 'Ferrous debris and cleanliness'),
    ('pc_25_50um', 'ParticleCount25-50um', 'Ferrous debris and cleanliness'),
    ('pc_50_100um', 'ParticleCount50-100um', 'Ferrous debris and cleanliness'),
    ('pc_gt_100um', 'ParticleCountGreater100um', 'Ferrous debris and cleanliness'),
    ('engine_hours', 'EngineHours', 'Operating context'),
    ('liner_hours', 'LinerHours', 'Operating context'),
    ('crown_hours', 'CrownHours', 'Operating context'),
    ('piston_ring_hours', 'PistonRingHours', 'Operating context'),
    ('feed_rate', 'FeedRate', 'Operating context'),
    ('fuel_sulphur_pct', 'FuelSulphurlevel', 'Operating context'),
    ('engine_load', 'EngineLoad', 'Operating context'),
    ('engine_rpm', 'EngineRpm', 'Operating context'),
    ('scav_temp', 'ScavTemp', 'Operating context'),
    ('scav_press', 'ScavPress', 'Operating context'),
    ('jcw_temp_in', 'JCWTmpIn', 'Operating context'),
    ('jcw_temp_out', 'JCWTmpOut', 'Operating context'),
    ('tc_rpm', 'TCRpm', 'Operating context'),
    ('sea_temp', 'SeaTemperature', 'Operating context'),
    ('ambient_temp', 'AmbientTemp', 'Operating context'),
    ('ambient_humidity', 'AmbientHumidity', 'Operating context'),
    ('mcr', 'MCR', 'Operating context'),
    ('sloc', 'SLOC', 'Operating context'),
    ('tc_cut_out', 'TcCutOut', 'Operating context'),
    ('fuel_valves_hr', 'FuelValvesHr', 'Operating context'),
    ('stroke_42', 'Stroke42', 'Operating context'),
    ('last_filter_change_hrs', 'LastFilterChangeHrs', 'Operating context'),
    ('whr_bypass', 'WHRByPass', 'Operating context')
  ) AS v(code, legacy_column, report_block)
)
SELECT 'p-lo-' + lo.code                          AS source_id,
       GETUTCDATE()                               AS source_modified_at,
       CAST(0 AS bit)                             AS source_deleted,
       'lube_oil'                                 AS stream,
       COALESCE(n.normName, lo.legacy_column)     AS name,
       n.ShortName                                AS short_name,
       'lo.' + lo.code                            AS code,
       COALESCE(n.UnitToDisplay, n.Unit)          AS unit,
       'number'                                   AS data_type,
       NULL                                       AS method,
       lo.report_block                            AS report_block,
       ROW_NUMBER() OVER (ORDER BY lo.report_block, lo.code) AS sequence_no,
       1                                          AS is_active
FROM   lo
LEFT   JOIN dbo.Norms n
       ON  REPLACE(n.normName, ' ', '') = lo.legacy_column
       OR  REPLACE(n.ShortName, ' ', '') = lo.legacy_column;
lo_param_map — build once, then every other query joins it
-- One row per lube-oil code with its legacy column and, where the
-- name matched, the Norms row. The limit queries and the optional
-- ColourCode join read this. Build it once, hand-fix the rows where
-- normId came back null, and keep it.

WITH lo(code, legacy_column, report_block) AS (
  SELECT * FROM (VALUES
    ('appearance', 'Appearance', 'Condition'),
    ('vis40', 'Vis40', 'Condition'),
    ('vis100', 'Vischk00', 'Condition'),
    ('vis_index', 'VisIndex', 'Condition'),
    ('flash_point', 'FlashPoint', 'Condition'),
    ('water_pct', 'Water', 'Condition'),
    ('water_kf_ppm', 'WaterKFppm', 'Condition'),
    ('emulsified_water', 'EmulsifiedWater', 'Condition'),
    ('tbn', 'BaseNumber', 'Condition'),
    ('tan', 'AcidNo', 'Condition'),
    ('soot_insolubles', 'SootInsolubles', 'Condition'),
    ('pentane_insolubles', 'PentaneInsolubles', 'Condition'),
    ('sulphated_ash', 'SulphatedAsh', 'Condition'),
    ('oxidation', 'Oxidation', 'Condition'),
    ('oxidation_abs', 'OxidationAbsolute', 'Condition'),
    ('oxidation_products', 'OxidationProducts', 'Condition'),
    ('nitration_abs', 'NitrationAbsolute', 'Condition'),
    ('sulphation_abs', 'SulphationAbsolute', 'Condition'),
    ('glycol', 'GLYCOL', 'Condition'),
    ('fuel_dilution', 'FUELDILUTION', 'Condition'),
    ('asphaltenes', 'Asphaltenes', 'Condition'),
    ('chlorine', 'Chlorine', 'Condition'),
    ('ph', 'pH', 'Condition'),
    ('specific_gravity', 'SpecificGravity', 'Condition'),
    ('conductivity', 'Conductivity', 'Condition'),
    ('liquid_corrosion', 'LiquidCorrosionConcentration', 'Condition'),
    ('vapour_corrosion', 'VapourPhaseCorrosion', 'Condition'),
    ('rocking_test', 'RockingTest', 'Condition'),
    ('sulphur', 'Sulpher', 'Condition'),
    ('fuel_p', 'FuelP', 'Condition'),
    ('oil_condition_index', 'OILCONDITIONINDEX', 'Condition'),
    ('water_scavenger', 'WaterScavenger', 'Condition'),
    ('calcium', 'Calcium', 'Additive elements'),
    ('magnesium', 'Magnesium', 'Additive elements'),
    ('zinc', 'Zinc', 'Additive elements'),
    ('phosphorus', 'Phosphorus', 'Additive elements'),
    ('barium', 'Barium', 'Additive elements'),
    ('boron', 'Boron', 'Additive elements'),
    ('iron', 'Iron', 'Wear metals'),
    ('copper', 'Copper', 'Wear metals'),
    ('lead', 'Lead', 'Wear metals'),
    ('tin', 'Tin', 'Wear metals'),
    ('chromium', 'Chromium', 'Wear metals'),
    ('aluminium', 'Aluminium', 'Wear metals'),
    ('nickel', 'Nickel', 'Wear metals'),
    ('titanium', 'Titanium', 'Wear metals'),
    ('silver', 'Silver', 'Wear metals'),
    ('manganese', 'Manganese', 'Wear metals'),
    ('vanadium', 'Vanadium', 'Wear metals'),
    ('molybdenum', 'Molybdenum', 'Wear metals'),
    ('silicon', 'Silicon', 'Contaminants'),
    ('sodium', 'Sodium', 'Contaminants'),
    ('lithium', 'Lithium', 'Contaminants'),
    ('pq_index', 'PQIndex', 'Ferrous debris and cleanliness'),
    ('ferrous_debris', 'FERROUSDEBRIS', 'Ferrous debris and cleanliness'),
    ('iso4406', 'ISOCODE', 'Ferrous debris and cleanliness'),
    ('iso_4um', '4um', 'Ferrous debris and cleanliness'),
    ('iso_6um', '6um', 'Ferrous debris and cleanliness'),
    ('iso_14um', '14um', 'Ferrous debris and cleanliness'),
    ('iso4407', 'ISOCODE4407', 'Ferrous debris and cleanliness'),
    ('iso4407_4um', '4um4407', 'Ferrous debris and cleanliness'),
    ('iso4407_6um', '6um4407', 'Ferrous debris and cleanliness'),
    ('iso4407_14um', '14um4407', 'Ferrous debris and cleanliness'),
    ('nas1638', 'NAS1638Class', 'Ferrous debris and cleanliness'),
    ('pc_4um', 'ParticleCount4', 'Ferrous debris and cleanliness'),
    ('pc_5um', 'ParticleCount5', 'Ferrous debris and cleanliness'),
    ('pc_6um', 'ParticleCount6', 'Ferrous debris and cleanliness'),
    ('pc_7um', 'ParticleCount7', 'Ferrous debris and cleanliness'),
    ('pc_10um', 'ParticleCount10', 'Ferrous debris and cleanliness'),
    ('pc_14um', 'ParticleCount14', 'Ferrous debris and cleanliness'),
    ('pc_20um', 'ParticleCount20', 'Ferrous debris and cleanliness'),
    ('pc_30um', 'ParticleCount30', 'Ferrous debris and cleanliness'),
    ('pc_5_15um', 'ParticleCount5-15um', 'Ferrous debris and cleanliness'),
    ('pc_15_25um', 'ParticleCount15-25um', 'Ferrous debris and cleanliness'),
    ('pc_25_50um', 'ParticleCount25-50um', 'Ferrous debris and cleanliness'),
    ('pc_50_100um', 'ParticleCount50-100um', 'Ferrous debris and cleanliness'),
    ('pc_gt_100um', 'ParticleCountGreater100um', 'Ferrous debris and cleanliness'),
    ('engine_hours', 'EngineHours', 'Operating context'),
    ('liner_hours', 'LinerHours', 'Operating context'),
    ('crown_hours', 'CrownHours', 'Operating context'),
    ('piston_ring_hours', 'PistonRingHours', 'Operating context'),
    ('feed_rate', 'FeedRate', 'Operating context'),
    ('fuel_sulphur_pct', 'FuelSulphurlevel', 'Operating context'),
    ('engine_load', 'EngineLoad', 'Operating context'),
    ('engine_rpm', 'EngineRpm', 'Operating context'),
    ('scav_temp', 'ScavTemp', 'Operating context'),
    ('scav_press', 'ScavPress', 'Operating context'),
    ('jcw_temp_in', 'JCWTmpIn', 'Operating context'),
    ('jcw_temp_out', 'JCWTmpOut', 'Operating context'),
    ('tc_rpm', 'TCRpm', 'Operating context'),
    ('sea_temp', 'SeaTemperature', 'Operating context'),
    ('ambient_temp', 'AmbientTemp', 'Operating context'),
    ('ambient_humidity', 'AmbientHumidity', 'Operating context'),
    ('mcr', 'MCR', 'Operating context'),
    ('sloc', 'SLOC', 'Operating context'),
    ('tc_cut_out', 'TcCutOut', 'Operating context'),
    ('fuel_valves_hr', 'FuelValvesHr', 'Operating context'),
    ('stroke_42', 'Stroke42', 'Operating context'),
    ('last_filter_change_hrs', 'LastFilterChangeHrs', 'Operating context'),
    ('whr_bypass', 'WHRByPass', 'Operating context')
  ) AS v(code, legacy_column, report_block)
)
SELECT lo.code, lo.legacy_column, lo.report_block, n.normId
INTO   dbo.lo_param_map
FROM   lo
LEFT   JOIN dbo.Norms n
       ON  REPLACE(n.normName, ' ', '') = lo.legacy_column
       OR  REPLACE(n.ShortName, ' ', '') = lo.legacy_column;

-- what still needs a hand-assigned normId
SELECT code, legacy_column FROM dbo.lo_param_map WHERE normId IS NULL;

Once lo_param_map is complete, the parameter seed above can read from it instead of repeating the list, and both the limit-rule queries and the optional flag join use map.normId.

report, sample, assessment

Fuel oil and water already have a report header. Lube oil does not: AssessmentReport is one row per sample, and the thing that was published as one PDF is a vessel within an upload batch. So the lube-oil report is grouped, not copied.

report — one row per published document
-- lube oil: one report per upload batch per vessel
SELECT 'rpt-lo-' + CAST(r.CsvID AS varchar(20))
                 + '-' + CAST(r.VesselID AS varchar(20))  AS source_id,
       MAX(COALESCE(r.CorrectionCompletedDate, r.FTSamplePublishDate, r.AssessmentDate, r.DateReceived)) AS source_modified_at,
       CAST(0 AS bit)                                      AS source_deleted,
       'ship-' + CAST(r.VesselID AS varchar(20))           AS ship_source_id,
       'cst-' + CAST(MAX(r.CompanyID) AS varchar(20))      AS organisation_source_id,
       NULL                                                AS template_source_id,
       'lube_oil'                                          AS stream,
       MAX(r.FORMNO)                                       AS report_no,
       CASE WHEN MAX(CAST(r.isAssess AS int)) = 1
            THEN 'assessed' ELSE 'received' END            AS status,
       MAX(r.MDoilUnitRating)                              AS overall_rating,
       MIN(r.DateReceived)                                 AS received_at,
       MAX(r.FTSamplePublishDate)                          AS published_at,
       NULL                                                AS pdf_url
FROM   dbo.AssessmentReport r
WHERE  r.VesselID IS NOT NULL AND r.CsvID IS NOT NULL
GROUP  BY r.CsvID, r.VesselID

UNION ALL

-- fuel oil: the header is already the report
SELECT 'rpt-fo-' + CAST(h.FOReportHeaderId AS varchar(20)),
       COALESCE(h.ModifiedDate, h.CreatedDate),
       CAST(0 AS bit),
       'ship-' + CAST(h.ShipId AS varchar(20)),
       'cst-' + CAST(cs.CstId AS varchar(20)),
       'tpl-fo-' + CAST(h.ParameterTemplateId AS varchar(20)),
       'fuel_oil',
       h.PDFName,
       CASE WHEN h.PublishDate IS NOT NULL THEN 'published'
            WHEN h.UnpublishDate IS NOT NULL THEN 'unpublished'
            ELSE 'in_progress' END,
       h.OverAllGrade,
       h.UploadDate,
       h.PublishDateUTC,
       h.PDFName
FROM   dbo.FO_CSVHeader h
JOIN   dbo.Ship s  ON s.ShipId = h.ShipId
JOIN   dbo.Customer cs ON cs.CstId = s.ShipCompany

UNION ALL

-- water: same shape, from WA_CSVHeader
SELECT 'rpt-wa-' + CAST(h.WAReportHeaderId AS varchar(20)),
       COALESCE(h.PublishDate, h.CreatedDate),
       CAST(0 AS bit),
       'ship-' + CAST(h.ShipId AS varchar(20)),
       'cst-' + CAST(h.CompanyId AS varchar(20)),
       'tpl-wa-' + CAST(h.TemplateId AS varchar(20)),
       'water',
       h.TestReportNoOrCertificateRefNo,
       CASE WHEN h.IsPublish = 1 THEN 'published' ELSE 'in_progress' END,
       NULL,
       NULL,
       h.PublishDateUTC,
       h.PDFName
FROM   dbo.WA_CSVHeader h;
sample — the sampling event
-- lube oil: one AssessmentReport row IS one sample
SELECT 'smp-lo-' + CAST(r.RSID AS varchar(20))            AS source_id,
       COALESCE(r.CorrectionCompletedDate, r.FTSamplePublishDate, r.AssessmentDate, r.DateReceived) AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       'rpt-lo-' + CAST(r.CsvID AS varchar(20))
                 + '-' + CAST(r.VesselID AS varchar(20))  AS report_source_id,
       'unit-' + CAST(sm.shipModId AS varchar(20))        AS equipment_unit_source_id,
       'st-lo-' + CAST(ISNULL(r.TestType, 1) AS varchar(20)) AS sample_type_source_id,
       r.SampleNumber                                     AS sample_no,
       r.BOTTLENO                                         AS bottle_no,
       r.DateSampled                                      AS sampled_at,
       r.DateDispatched                                   AS dispatched_at,
       r.DateReceived                                     AS received_at,
       r.Samplingpoint                                    AS sampling_point,
       r.SampledBy                                        AS sampled_by,
       TRY_CAST(r.UnitServiceHours AS decimal(18,2))      AS unit_hours,
       TRY_CAST(r.OilServiceHours AS decimal(18,2))       AS oil_hours,
       r.OilGrade                                         AS oil_grade,
       r.oilUnitRating                                    AS rating,
       NULL                                               AS latitude,
       NULL                                               AS longitude,
       (SELECT r.GOMId          AS gom_id,
               r.WorkOrderNumber AS work_order_no,
               r.DailyMakeUp     AS daily_make_up,
               r.FORMNO          AS form_no
        FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)             AS context
FROM   dbo.AssessmentReport r
LEFT   JOIN dbo.ShipModel sm
       ON  sm.shipId = r.VesselID
       AND sm.macId  = r.MachineryID
       AND ISNULL(sm.SrNo, '') = ISNULL(r.SerialNo, '')
WHERE  r.VesselID IS NOT NULL AND r.CsvID IS NOT NULL;

AssessmentReport.TestType is an integer with no foreign key. The query above assumes it keys LOTestType; the dump also has a TestType table with the same id name. Check SELECT DISTINCT TestType FROM dbo.AssessmentReport against both before loading sample_type_source_id.

AssessmentReport has no modified-date column. It carries the lifecycle stamps DateReceived, AssessmentDate, FTSamplePublishDate and CorrectionCompletedDate, and the queries take the latest of those as source_modified_at. That catches a sample being received, assessed, published or corrected. It does not catch a value edited outside the correction workflow. UploadCsvData does carry a ModifiedDate; if its RSID values line up with AssessmentReport.RSID (they should: the column is declared NOT FOR REPLICATION, which is how a copied identity is written), join it in and prefer u.ModifiedDate. Verify with SELECT COUNT(*) FROM dbo.AssessmentReport r JOIN dbo.UploadCsvData u ON u.RSID = r.RSID before relying on it.

The lube-oil sample has no direct link to its ShipModel row. AssessmentReport stores VesselID, MachineryID and SerialNo separately rather than a shipModId, so the unit is recovered by matching all three. Count the misses before you load:

SELECT COUNT(*) FROM dbo.AssessmentReport r
LEFT JOIN dbo.ShipModel sm
  ON sm.shipId = r.VesselID AND sm.macId = r.MachineryID
 AND ISNULL(sm.SrNo,'') = ISNULL(r.SerialNo,'')
WHERE sm.shipModId IS NULL;

If the count is material, relax the serial-number condition and match on vessel and machinery alone, taking MIN(shipModId). Samples that still find no unit should be loaded against a per-vessel fallback unit rather than dropped.

sample — fuel oil and water
-- fuel oil: the header is also the sample (FO_CSVDetails hangs off the header)
SELECT 'smp-fo-' + CAST(h.FOReportHeaderId AS varchar(20)),
       COALESCE(h.ModifiedDate, h.CreatedDate),
       CAST(0 AS bit),
       'rpt-fo-' + CAST(h.FOReportHeaderId AS varchar(20)),
       'unit-fo-' + CAST(h.ShipId AS varchar(20)),
       'st-fo-' + CAST(h.OilTypeId AS varchar(20)),
       h.PDFName, NULL, h.UploadDate, NULL, h.UploadDate,
       NULL, NULL, NULL, NULL, NULL, h.OverAllGrade, NULL, NULL,
       (SELECT h.LabComments    AS lab_comment,
               h.VesselComments AS vessel_comment,
               b.FO_BunkerOrTankName AS bunker_or_tank
        FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)
FROM   dbo.FO_CSVHeader h
LEFT   JOIN dbo.FO_BunkerOrTank b ON b.FO_BunkerOrTankID = h.FOBunkerOrTankId

UNION ALL

-- water: one row per sample in the report
SELECT 'smp-wa-' + CAST(d.WACSVHeaderDetailsMappingId AS varchar(20)),
       COALESCE(d.SampleCollectiondate, d.LabReceiptDate),
       CAST(0 AS bit),
       'rpt-wa-' + CAST(d.WAReportHeaderId AS varchar(20)),
       'unit-wa-' + CAST(h.ShipId AS varchar(20))
                  + '-' + CAST(t.WaterTypeId AS varchar(20)),
       'st-wa-' + CAST(t.WaterTypeId AS varchar(20)),
       d.SampleNumber, NULL, d.SampleCollectiondate, NULL, d.LabReceiptDate,
       d.SamplingPoint, NULL, NULL, NULL, NULL, NULL,
       TRY_CAST(d.Latitude AS decimal(9,6)),
       TRY_CAST(d.Longitude AS decimal(9,6)),
       (SELECT d.Vessel_Comment AS vessel_comment,
               d.Lab_Comment    AS lab_comment,
               d.AWBNo          AS awb_no
        FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)
FROM   dbo.WA_CSVHeaderDetailsMapping d
JOIN   dbo.WA_CSVHeader h  ON h.WAReportHeaderId = d.WAReportHeaderId
JOIN   dbo.WA_Template t   ON t.TemplateId = h.TemplateId;
assessment — the verdict and narrative
SELECT 'asm-lo-' + CAST(r.RSID AS varchar(20))           AS source_id,
       COALESCE(r.CorrectionCompletedDate, r.AssessmentDate) AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       'rpt-lo-' + CAST(r.CsvID AS varchar(20))
                 + '-' + CAST(r.VesselID AS varchar(20))  AS report_source_id,
       'smp-lo-' + CAST(r.RSID AS varchar(20))            AS sample_source_id,
       r.oilUnitRating                                    AS rating,
       r.CommentOil                                       AS comment_oil,
       r.CommentUnit                                      AS comment_unit,
       r.CommentSum                                       AS summary,
       COALESCE(r.ActionSum, r.Action)                    AS action,
       CAST(r.ReviewBy AS varchar(20))                    AS assessed_by,
       r.AssessmentDate                                   AS assessed_at
FROM   dbo.AssessmentReport r
WHERE  r.isAssess = 1

UNION ALL

-- fuel oil keeps three separate comment fields on the header
SELECT 'asm-fo-' + CAST(h.FOReportHeaderId AS varchar(20)),
       COALESCE(h.ModifiedDate, h.CreatedDate),
       CAST(0 AS bit),
       'rpt-fo-' + CAST(h.FOReportHeaderId AS varchar(20)),
       'smp-fo-' + CAST(h.FOReportHeaderId AS varchar(20)),
       h.OverAllGrade,
       h.LabGradeComments,
       h.LabSpecificationComments,
       h.LabOperationalComments,
       NULL,
       CAST(h.ReviewedBy AS varchar(20)),
       h.PublishDateUTC
FROM   dbo.FO_CSVHeader h
WHERE  COALESCE(h.LabGradeComments, h.LabSpecificationComments,
                h.LabOperationalComments) IS NOT NULL;

result_value — the pivot

This is the only genuinely hard query. Fuel oil and water already store one row per measurement, so they copy across. Lube oil stores 100 measurements as 100 columns on one wide row, and each has to become its own row.

CROSS APPLY (VALUES …) does it in one pass without a self-join per column. Every result column on AssessmentReport is varchar, so no casting is needed inside the VALUES list, and the blank filter at the bottom drops the columns that were never measured for that sample.

result_value — lube oil, 100 columns to rows
SELECT 'rv-lo-' + CAST(r.RSID AS varchar(20)) + '-' + v.code  AS source_id,
       COALESCE(r.CorrectionCompletedDate, r.FTSamplePublishDate, r.AssessmentDate, r.DateReceived) AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       'smp-lo-' + CAST(r.RSID AS varchar(20))            AS sample_source_id,
       'p-lo-' + v.code                                   AS parameter_source_id,
       LTRIM(RTRIM(v.value_raw))                          AS value_raw,
       TRY_CAST(REPLACE(LTRIM(RTRIM(v.value_raw)), ',', '.') AS decimal(18,4)) AS value_num,
       NULL                                               AS unit,
       NULL                                               AS flag,   -- see note below
       CAST(0 AS bit)                                     AS is_retest,
       1                                                  AS revision,
       NULL                                               AS comment
FROM   dbo.AssessmentReport r
CROSS  APPLY (VALUES
    -- Condition
    ('appearance', r.Appearance),
    ('vis40', r.Vis40),
    ('vis100', r.Vischk00),
    ('vis_index', r.VisIndex),
    ('flash_point', r.FlashPoint),
    ('water_pct', r.Water),
    ('water_kf_ppm', r.WaterKFppm),
    ('emulsified_water', r.EmulsifiedWater),
    ('tbn', r.BaseNumber),
    ('tan', r.AcidNo),
    ('soot_insolubles', r.SootInsolubles),
    ('pentane_insolubles', r.PentaneInsolubles),
    ('sulphated_ash', r.SulphatedAsh),
    ('oxidation', r.Oxidation),
    ('oxidation_abs', r.OxidationAbsolute),
    ('oxidation_products', r.OxidationProducts),
    ('nitration_abs', r.NitrationAbsolute),
    ('sulphation_abs', r.SulphationAbsolute),
    ('glycol', r.GLYCOL),
    ('fuel_dilution', r.FUELDILUTION),
    ('asphaltenes', r.Asphaltenes),
    ('chlorine', r.Chlorine),
    ('ph', r.pH),
    ('specific_gravity', r.SpecificGravity),
    ('conductivity', r.Conductivity),
    ('liquid_corrosion', r.LiquidCorrosionConcentration),
    ('vapour_corrosion', r.VapourPhaseCorrosion),
    ('rocking_test', r.RockingTest),
    ('sulphur', r.Sulpher),
    ('fuel_p', r.FuelP),
    ('oil_condition_index', r.OILCONDITIONINDEX),
    ('water_scavenger', r.WaterScavenger),
    -- Additive elements
    ('calcium', r.Calcium),
    ('magnesium', r.Magnesium),
    ('zinc', r.Zinc),
    ('phosphorus', r.Phosphorus),
    ('barium', r.Barium),
    ('boron', r.Boron),
    -- Wear metals
    ('iron', r.Iron),
    ('copper', r.Copper),
    ('lead', r.Lead),
    ('tin', r.Tin),
    ('chromium', r.Chromium),
    ('aluminium', r.Aluminium),
    ('nickel', r.Nickel),
    ('titanium', r.Titanium),
    ('silver', r.Silver),
    ('manganese', r.Manganese),
    ('vanadium', r.Vanadium),
    ('molybdenum', r.Molybdenum),
    -- Contaminants
    ('silicon', r.Silicon),
    ('sodium', r.Sodium),
    ('lithium', r.Lithium),
    -- Ferrous debris and cleanliness
    ('pq_index', r.PQIndex),
    ('ferrous_debris', r.FERROUSDEBRIS),
    ('iso4406', r.ISOCODE),
    ('iso_4um', r.[4um]),
    ('iso_6um', r.[6um]),
    ('iso_14um', r.[14um]),
    ('iso4407', r.ISOCODE4407),
    ('iso4407_4um', r.[4um4407]),
    ('iso4407_6um', r.[6um4407]),
    ('iso4407_14um', r.[14um4407]),
    ('nas1638', r.NAS1638Class),
    ('pc_4um', r.ParticleCount4),
    ('pc_5um', r.ParticleCount5),
    ('pc_6um', r.ParticleCount6),
    ('pc_7um', r.ParticleCount7),
    ('pc_10um', r.ParticleCount10),
    ('pc_14um', r.ParticleCount14),
    ('pc_20um', r.ParticleCount20),
    ('pc_30um', r.ParticleCount30),
    ('pc_5_15um', r.[ParticleCount5-15um]),
    ('pc_15_25um', r.[ParticleCount15-25um]),
    ('pc_25_50um', r.[ParticleCount25-50um]),
    ('pc_50_100um', r.[ParticleCount50-100um]),
    ('pc_gt_100um', r.ParticleCountGreater100um),
    -- Operating context
    ('engine_hours', r.EngineHours),
    ('liner_hours', r.LinerHours),
    ('crown_hours', r.CrownHours),
    ('piston_ring_hours', r.PistonRingHours),
    ('feed_rate', r.FeedRate),
    ('fuel_sulphur_pct', r.FuelSulphurlevel),
    ('engine_load', r.EngineLoad),
    ('engine_rpm', r.EngineRpm),
    ('scav_temp', r.ScavTemp),
    ('scav_press', r.ScavPress),
    ('jcw_temp_in', r.JCWTmpIn),
    ('jcw_temp_out', r.JCWTmpOut),
    ('tc_rpm', r.TCRpm),
    ('sea_temp', r.SeaTemperature),
    ('ambient_temp', r.AmbientTemp),
    ('ambient_humidity', r.AmbientHumidity),
    ('mcr', r.MCR),
    ('sloc', r.SLOC),
    ('tc_cut_out', r.TcCutOut),
    ('fuel_valves_hr', r.FuelValvesHr),
    ('stroke_42', r.Stroke42),
    ('last_filter_change_hrs', r.LastFilterChangeHrs),
    ('whr_bypass', r.WHRByPass)
) AS v(code, value_raw)
WHERE  v.value_raw IS NOT NULL
  AND  LTRIM(RTRIM(v.value_raw)) <> '';

The lube-oil flag is left null on purpose. ColourCode holds the computed red/yellow/green per value, but it is keyed by RSID and an integer RowID, and nothing in the dump says whether RowID is a normId or a column position. Comparing it to a text code fails at run time, so the join is not in the query. Run SELECT TOP 50 * FROM dbo.ColourCode to establish what RowID holds; if it is normId, join dbo.ColourCode cc ON cc.RSID = r.RSID AND cc.RowID = map.normId through dbo.lo_param_map and map Colour 1/2/3 to red/yellow/normal. Otherwise leave it null and we recompute flags from limit_rule.

result_value — fuel oil and water copy straight across
SELECT 'rv-fo-' + CAST(d.FOReportsDetailsId AS varchar(20)) AS source_id,
       COALESCE(d.ModifiedDate, d.CreatedDate)            AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       'smp-fo-' + CAST(d.FOReoprtsHeaderId AS varchar(20)) AS sample_source_id,
       'p-fo-' + CAST(d.ParameterId AS varchar(20))       AS parameter_source_id,
       d.Value                                            AS value_raw,
       TRY_CAST(REPLACE(d.Value, ',', '.') AS decimal(18,4)) AS value_num,
       NULL                                               AS unit,
       LOWER(d.ColorDesign)                               AS flag,
       ISNULL(d.ReTestFlag, 0)                            AS is_retest,
       ISNULL(d.Version, 1)                               AS revision,
       d.Comments                                         AS comment
FROM   dbo.FO_CSVDetails d
WHERE  ISNULL(d.ActiveVersion, 1) = 1

UNION ALL

SELECT 'rv-wa-' + CAST(d.WAReportsDetailsId AS varchar(20)),
       COALESCE(d.ModifiedDate, d.CreatedDate),
       CAST(0 AS bit),
       'smp-wa-' + CAST(d.WACSVHeaderDetailsMappingId AS varchar(20)),
       'p-wa-' + CAST(d.ParameterId AS varchar(20)),
       d.Value,
       TRY_CAST(REPLACE(d.Value, ',', '.') AS decimal(18,4)),
       NULL,
       CASE c.Colour WHEN 1 THEN 'red' WHEN 2 THEN 'yellow'
                     WHEN 3 THEN 'normal' END,
       ISNULL(d.ReTestFlag, 0), 1, d.Comments
FROM   dbo.WA_CSVDetails d
LEFT   JOIN dbo.WA_ColourCode c
       ON  c.WA_CSVHeaderDetailsMappingId = d.WACSVHeaderDetailsMappingId
       AND c.ParaID = d.ParameterId;

Note the two filters that matter: FO_CSVDetails.ActiveVersion = 1 keeps only the current version of a corrected value, and the _OriginalData twin tables are not loaded at all. Corrections are the truth; the pre-correction copy stays in your system.

limit_rule and report_limit_snapshot

Seven legacy tables hold the same fact in seven shapes. They collapse into one, with scope_kind saying which of them a row came from and precedence making the resolution order explicit instead of implicit in application code.

The lube-oil limit tables store the operator as a signId into Sign. Only OilNorms carries separate yellow and red column pairs, so each of its rows becomes two target rows. ModelNorms, CustomerNorms and MultiGradeLimits hold a single limit (signId, val1, val2, percent) with no band; load those as band = 'red', one row each, unless your tribologists say the single limit was treated as the caution level. NewOil_Limits has both bands as high/low pairs with no operator, so it loads with operator = 'between'.

limit_rule — oil-grade limits, one row per band
-- yellow band
SELECT 'lr-oil-' + CAST(o.onId AS varchar(20)) + '-y'   AS source_id,
       GETUTCDATE()                                     AS source_modified_at,
       CAST(0 AS bit)                                   AS source_deleted,
       'p-lo-' + map.code                               AS parameter_source_id,
       'oil_grade'                                      AS scope_kind,
       og.oilGrade                                      AS scope_ref,
       'yellow'                                         AS band,
       sy.sign                                          AS operator,
       o.val1Y                                          AS value_1,
       o.Val2Y                                          AS value_2,
       o.PercentY                                       AS percent,
       20                                               AS precedence
FROM   dbo.OilNorms o
JOIN   dbo.OilGrade og ON og.oilId = o.oilId
JOIN   dbo.lo_param_map map ON map.normId = o.normId
LEFT   JOIN dbo.Sign sy ON sy.signId = o.signIdY
WHERE  o.signIdY IS NOT NULL

UNION ALL

-- red band: same row, the R columns
SELECT 'lr-oil-' + CAST(o.onId AS varchar(20)) + '-r',
       GETUTCDATE(), CAST(0 AS bit),
       'p-lo-' + map.code, 'oil_grade', og.oilGrade, 'red',
       sr.sign, o.val1R, o.Val2R, o.PercentR, 20
FROM   dbo.OilNorms o
JOIN   dbo.OilGrade og ON og.oilId = o.oilId
JOIN   dbo.lo_param_map map ON map.normId = o.normId
LEFT   JOIN dbo.Sign sr ON sr.signId = o.signIdR
WHERE  o.signIdR IS NOT NULL;

The other lube-oil limit sources use one branch each, not two, with the prefix, scope_kind, scope_ref and precedence below:

Sourcescope_kindscope_refprecedence
NewOil_Limitsnew_oiloil type + component group10
OilNormsoil_gradeOilGrade.oilGrade20
ModelNormsmodelModel.modNo30
MultiGradeLimitsoil_grademultigrade set40
CustomerNormscustomerCustomer.CstCompany50

Confirm the precedence with your tribologists before loading. The legacy application decides which limit wins in code, not in the schema, so the numbers above are a starting proposal, not something recovered from the database. Getting them wrong changes which samples come out red.

limit_rule — fuel oil and water already carry both bands
-- fuel oil: FO_BlockParameterLimit holds Yellow1/2 and Red1/2 side by side
SELECT 'lr-fo-' + CAST(l.BlockParameterLimitId AS varchar(20)) + '-' + b.band,
       COALESCE(l.ModifiedDate, l.CreatedDate),
       CAST(0 AS bit),
       'p-fo-' + CAST(l.ParameterId AS varchar(20)),
       'fuel_grade',
       lim.FuelGradeName,
       b.band,
       op.Sign,
       b.v1, b.v2, NULL, 20
FROM   dbo.FO_BlockParameterLimit l
JOIN   dbo.FO_Limits lim ON lim.LimitsId = l.LimitId
LEFT   JOIN dbo.FO_Operator op ON op.OperatorId = l.OperatorId
CROSS  APPLY (VALUES ('yellow', l.Yellow1, l.Yellow2),
                     ('red',    l.Red1,    l.Red2)) AS b(band, v1, v2)
WHERE  b.v1 IS NOT NULL

UNION ALL

-- water: WA_ModelNorms, same idea, scoped to a template
SELECT 'lr-wa-' + CAST(n.TemplateParameterId AS varchar(20)) + '-' + b.band,
       GETUTCDATE(), CAST(0 AS bit),
       'p-wa-' + CAST(n.ParameterId AS varchar(20)),
       'template', t.TemplateName, b.band,
       sg.sign, b.v1, b.v2, NULL, 20
FROM   dbo.WA_ModelNorms n
JOIN   dbo.WA_Template t ON t.TemplateId = n.TemplateId
CROSS  APPLY (VALUES ('yellow', n.Val1Yellow, n.Val2Yellow, n.SignIdYellow),
                     ('red',    n.Val1Red,    n.Val2Red,    n.SignIdRed)) AS b(band, v1, v2, sid)
LEFT   JOIN dbo.WA_Sign sg ON sg.signId = b.sid
WHERE  b.v1 IS NOT NULL;

report_limit_snapshot only exists for the two streams that froze their limits at publish time. Fuel oil stamped them onto each value row, water kept them in a reviewed table:

report_limit_snapshot
SELECT DISTINCT
       'rls-fo-' + CAST(d.FOReoprtsHeaderId AS varchar(20))
                 + '-' + CAST(d.ParameterId AS varchar(20)) + '-' + b.band AS source_id,
       COALESCE(d.ModifiedDate, d.CreatedDate)            AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       'rpt-fo-' + CAST(d.FOReoprtsHeaderId AS varchar(20)) AS report_source_id,
       'p-fo-' + CAST(d.ParameterId AS varchar(20))       AS parameter_source_id,
       b.band                                             AS band,
       op.Sign                                            AS operator,
       b.v1                                               AS value_1,
       b.v2                                               AS value_2
FROM   dbo.FO_CSVDetails d
LEFT   JOIN dbo.FO_Operator op ON op.OperatorId = d.OperatorId
CROSS  APPLY (VALUES ('yellow', d.Yellow1, d.Yellow2),
                     ('red',    d.Red1,    d.Red2)) AS b(band, v1, v2)
WHERE  ISNULL(d.ActiveVersion, 1) = 1 AND b.v1 IS NOT NULL

UNION ALL

SELECT 'rls-wa-' + CAST(n.Id AS varchar(20)) + '-' + b.band,
       GETUTCDATE(), CAST(0 AS bit),
       'rpt-wa-' + CAST(n.WAReportHeaderId AS varchar(20)),
       'p-wa-' + CAST(n.ParameterId AS varchar(20)),
       b.band, sg.sign, b.v1, b.v2
FROM   dbo.WA_ModelNorms_Reviewed n
CROSS  APPLY (VALUES ('yellow', n.Val1Yellow, n.Val2Yellow, n.SignIdYellow),
                     ('red',    n.Val1Red,    n.Val2Red,    n.SignIdRed)) AS b(band, v1, v2, sid)
LEFT   JOIN dbo.WA_Sign sg ON sg.signId = b.sid
WHERE  b.v1 IS NOT NULL;

Lube oil has no snapshot, so its published reports are judged against whatever limit_rule says today. That is the legacy behaviour, carried across unchanged. If you want historical reports frozen, that is a new decision, not a migration step.

Synthetic rows

Two target tables need rows that do not exist anywhere in the legacy schema. Both are small and both are generated, not copied.

Fuel-oil and water equipment units

A fuel-oil sample is drawn from a bunker delivery or a tank, and a water sample from a boiler or cooling circuit. Neither has a ShipModel row. Rather than leave equipment_unit_source_id null, generate one unit per vessel for fuel oil and one per vessel and water type for water, so every sample has a unit and per-unit trends work the same way across all three streams.

synthetic equipment units
-- one bunker unit per vessel that has ever had a fuel-oil report
SELECT DISTINCT
       'unit-fo-' + CAST(h.ShipId AS varchar(20))         AS source_id,
       GETUTCDATE()                                       AS source_modified_at,
       CAST(0 AS bit)                                     AS source_deleted,
       'ship-' + CAST(h.ShipId AS varchar(20))            AS ship_source_id,
       'Bunker fuel'                                      AS name,
       'fuel_oil'                                         AS stream,
       'bunker'                                           AS application,
       NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
       0                                                  AS is_critical,
       NULL                                               AS external_ref,
       1                                                  AS is_active
FROM   dbo.FO_CSVHeader h
WHERE  h.ShipId IS NOT NULL

UNION ALL

-- one unit per vessel per water type
SELECT DISTINCT
       'unit-wa-' + CAST(h.ShipId AS varchar(20))
                  + '-' + CAST(t.WaterTypeId AS varchar(20)),
       GETUTCDATE(), CAST(0 AS bit),
       'ship-' + CAST(h.ShipId AS varchar(20)),
       w.WaterTypeName + ' system',
       'water',
       LOWER(REPLACE(w.WaterTypeName, ' ', '_')),
       NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
       0, NULL, 1
FROM   dbo.WA_CSVHeader h
JOIN   dbo.WA_Template t  ON t.TemplateId = h.TemplateId
JOIN   dbo.WA_WaterType w ON w.WaterTypeId = t.WaterTypeId;

Lube-oil reports

Covered in the report query above: grouping AssessmentReport by CsvID and VesselID reconstructs the document that was published, since the legacy system has no lube-oil report header table.

Verifying the load

Every target row keeps the id it came from, so reconciliation is a count on each side. Run these before you call the migration done.

counts to reconcile
-- lube oil
SELECT COUNT(*) FROM dbo.AssessmentReport
WHERE VesselID IS NOT NULL AND CsvID IS NOT NULL;          -- = sample rows (lo)

SELECT COUNT(DISTINCT CAST(CsvID AS varchar(20)) + '-'
                    + CAST(VesselID AS varchar(20)))
FROM   dbo.AssessmentReport
WHERE  VesselID IS NOT NULL AND CsvID IS NOT NULL;          -- = report rows (lo)

-- result values: samples x populated columns, the number to expect
SELECT COUNT(*)
FROM   dbo.AssessmentReport r
CROSS  APPLY (VALUES (r.Iron), (r.Copper), (r.Vis40) /* … all columns … */) AS v(val)
WHERE  v.val IS NOT NULL AND LTRIM(RTRIM(v.val)) <> '';

-- fuel oil and water
SELECT COUNT(*) FROM dbo.FO_CSVDetails WHERE ISNULL(ActiveVersion,1) = 1;
SELECT COUNT(*) FROM dbo.WA_CSVDetails;

Then read the same counts back from us: GET /data-tables/{table_id}/rows?limit=1 returns total for the table, and a filter on source_id with the like operator counts one stream inside a shared table, for example every lube-oil result with {"source_id":{"op":"like","value":"rv-lo-%"}}.

Two things worth checking by eye rather than by count, because both are silent when wrong: pick ten samples across different vessels and confirm each landed against the right equipment_unit, and pick a handful of red-flagged results and confirm the value and the flag survived the pivot intact.

Generated 2026-09-03 from the agreed target model. Table ids are assigned when we create the tables in your organisation and are sent with your token.