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.
- Your base URL is
https://data.customisedintelligence.aiand your organisation id isf98ea413-6e14-4e1e-960c-02c6fde09376. The token itself reaches you separately.
Authorization: Bearer eyJhbGciOi…
export BASE="https://data.customisedintelligence.ai" export TOKEN="<your token>" export ORG_ID="f98ea413-6e14-4e1e-960c-02c6fde09376"
Table ids
Each of your 14 tables has a fixed id. Use it in place of {table_id} in every route below. These ids do not change, so you can hard-code them in your sync script.
A table's batch URL is https://data.customisedintelligence.ai/data-tables/{table_id}/batch.
| Table | Table id |
|---|---|
organisation | ee4d5b6f-6176-499f-b19f-1605af706471 |
fleet | 3471fe81-38b9-4430-82b0-177e6f2cd4a4 |
ship | aedca807-fd16-4c65-891d-259cfaa881ce |
equipment_unit | 0597ae1f-4b98-4333-bd27-cce963016430 |
sample_type | 455b158b-b832-42ab-aa4f-05e9a0bc5c8b |
parameter | 6aea5685-2c1a-4346-9683-a6121eb25379 |
template | 30db50b3-0892-4d95-8e8d-e676bdeddfdb |
template_parameter | ad6af87b-2771-479e-a52c-6208f198cb53 |
limit_rule | cb18607c-9c42-44fa-8785-21e704430784 |
report | e838e8b6-57cf-47a3-b4b4-31bcf31ad317 |
sample | 05c7d42c-e69d-4ba5-bf58-5334f634af88 |
result_value | af15281f-2031-42f7-8c53-eef96a079932 |
report_limit_snapshot | e8436871-d369-4b65-a79d-7caa5bb76517 |
assessment | 2bf28ff0-725b-4fdd-9588-0d4286b06437 |
You can also look them up at any time with GET /data-tables, matching on name.
export ORGANISATION_ID="ee4d5b6f-6176-499f-b19f-1605af706471" export FLEET_ID="3471fe81-38b9-4430-82b0-177e6f2cd4a4" export SHIP_ID="aedca807-fd16-4c65-891d-259cfaa881ce" export EQUIPMENT_UNIT_ID="0597ae1f-4b98-4333-bd27-cce963016430" export SAMPLE_TYPE_ID="455b158b-b832-42ab-aa4f-05e9a0bc5c8b" export PARAMETER_ID="6aea5685-2c1a-4346-9683-a6121eb25379" export TEMPLATE_ID="30db50b3-0892-4d95-8e8d-e676bdeddfdb" export TEMPLATE_PARAMETER_ID="ad6af87b-2771-479e-a52c-6208f198cb53" export LIMIT_RULE_ID="cb18607c-9c42-44fa-8785-21e704430784" export REPORT_ID="e838e8b6-57cf-47a3-b4b4-31bcf31ad317" export SAMPLE_ID="05c7d42c-e69d-4ba5-bf58-5334f634af88" export RESULT_VALUE_ID="af15281f-2031-42f7-8c53-eef96a079932" export REPORT_LIMIT_SNAPSHOT_ID="e8436871-d369-4b65-a79d-7caa5bb76517" export ASSESSMENT_ID="2bf28ff0-725b-4fdd-9588-0d4286b06437"
curl -X POST "$BASE/data-tables/$SHIP_ID/batch" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d @ships.json
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 updatesource_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.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.
# 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-- 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 > @sincesync_high_water_mark is null
-> send everything, oldest first,
in the load order under Conventions.Conventions
Sync columns on every table
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredsynced_attimestamptzserver-setValues
- 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,
nullfor 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_idcolumns carrying the referenced row'ssource_id, never our ids. Composite keys join with a colon, e.g.184532:norm-12.
{
"source_id": "184532",
"source_modified_at": "2026-08-28T14:02:11Z",
"source_deleted": false
}{
"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.
organisationparent_source_id); load parents before childrenfleetshipequipment_unitsample_typeparametertemplatelimit_rulereportsample1. 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
| Status | Meaning | What to do |
|---|---|---|
| 200 / 201 | Committed | Continue |
| 400 | Payload 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 |
| 401 | Token missing, expired or revoked | Check the header; ask us for a new token |
| 403 | Token valid but not for this table's organisation | Check the table id against your list |
| 404 | No table (or row) with that id | Re-list tables |
| 409 | A uniqueness or foreign-key rule other than the upsert key was violated | Inspect the message |
| 503 | Your organisation's data store is briefly unavailable | Retry 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 · uuidrequiredcurl "$BASE/data-tables?organisation_id=$ORG_ID" \ -H "Authorization: Bearer $TOKEN"
{
"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 · uuidrequiredcurl "$BASE/data-tables/$TABLE_ID" \ -H "Authorization: Bearer $TOKEN"
{
"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 · uuidrequiredkey_columnsbody · string[]required["source_id"]rowsbody · object[] · 1–500requirednull to clear itcurl -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
}
]
}'{
"inserted": 1,
"updated": 0
}{
"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 · uuidrequiredlimitquery · intoffsetquery · intsortquery · stringcol:asc or col:desc, comma-separatedfilterquery · JSON{"col":{"op":"eq","value":…}}, URL-encoded# 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"}}'{
"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 · uuidrequiredcurl -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
}'{
"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 · uuidrequiredrow_idpath · uuidrequiredcurl -X PATCH "$BASE/data-tables/$TABLE_ID/rows/$ROW_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"is_active": false}'{
"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 · uuidrequiredrow_idpath · uuidrequiredcurl -X DELETE "$BASE/data-tables/$TABLE_ID/rows/$ROW_ID" \ -H "Authorization: Bearer $TOKEN"
{
"deleted": 1
}Organisations
Table id ee4d5b6f-6176-499f-b19f-1605af706471
Batch URL https://data.customisedintelligence.ai/data-tables/ee4d5b6f-6176-499f-b19f-1605af706471/batch
Group companies and the management companies under them. One row per commercial party.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequirednametextrequiredkindtextrequiredparent_source_idtextreferenceexternal_reftextcountrytextis_activebooleanrequiredsynced_attimestamptzserver-set{
"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
Table id 3471fe81-38b9-4430-82b0-177e6f2cd4a4
Batch URL https://data.customisedintelligence.ai/data-tables/3471fe81-38b9-4430-82b0-177e6f2cd4a4/batch
A named fleet within a management company.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredorganisation_source_idtextrequiredreferencenametextrequiredsynced_attimestamptzserver-set{
"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
Table id aedca807-fd16-4c65-891d-259cfaa881ce
Batch URL https://data.customisedintelligence.ai/data-tables/aedca807-fd16-4c65-891d-259cfaa881ce/batch
One vessel. The IMO number is the identity everything else hangs off.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredfleet_source_idtextreferenceorganisation_source_idtextrequiredreferenceowner_source_idtextreferencenametextrequiredimo_notextrequireduniqueship_typetextgross_tonnagenumericyear_builtintis_activebooleanrequiredsynced_attimestamptzserver-set{
"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
Table id 0597ae1f-4b98-4333-bd27-cce963016430
Batch URL https://data.customisedintelligence.ai/data-tables/0597ae1f-4b98-4333-bd27-cce963016430/batch
One sampled machine, tank or water system on one ship: the thing a sample is drawn from.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredship_source_idtextrequiredreferencenametextrequiredstreamtextrequiredapplicationtextmaketextmodeltextserial_notextoil_gradetextoil_brandtextcharge_volume_lnumericsampling_interval_daysintlast_sampled_attimestamptzis_criticalbooleanrequiredexternal_reftextis_activebooleanrequiredsynced_attimestamptzserver-set{
"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
Table id 455b158b-b832-42ab-aa4f-05e9a0bc5c8b
Batch URL https://data.customisedintelligence.ai/data-tables/455b158b-b832-42ab-aa4f-05e9a0bc5c8b/batch
The kinds of sample a lab analyses, per stream.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredstreamtextrequirednametextrequiredshort_codetextsynced_attimestamptzserver-set{
"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
Table id 6aea5685-2c1a-4346-9683-a6121eb25379
Batch URL https://data.customisedintelligence.ai/data-tables/6aea5685-2c1a-4346-9683-a6121eb25379/batch
Every measured or derived quantity, across all streams: one row per parameter, never one column per parameter.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredstreamtextrequirednametextrequiredshort_nametextcodetextrequireduniqueunittextdata_typetextrequiredmethodtextreport_blocktextsequence_nointis_activebooleanrequiredsynced_attimestamptzserver-set{
"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
Table id 30db50b3-0892-4d95-8e8d-e676bdeddfdb
Batch URL https://data.customisedintelligence.ai/data-tables/30db50b3-0892-4d95-8e8d-e676bdeddfdb/batch
A report layout for a sample type: which parameters appear and the boilerplate around them.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredsample_type_source_idtextrequiredreferencenametextrequiredorganisation_source_idtextreferencespec_referencetextboilerplatejsonbis_activebooleanrequiredsynced_attimestamptzserver-set{
"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
Table id ad6af87b-2771-479e-a52c-6208f198cb53
Batch URL https://data.customisedintelligence.ai/data-tables/ad6af87b-2771-479e-a52c-6208f198cb53/batch
Which parameters a template carries, in what order.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredtemplate_source_idtextrequiredreferenceparameter_source_idtextrequiredreferenceis_compulsorybooleanrequiredsequence_nointsynced_attimestamptzserver-set{
"key_columns": [
"source_id"
],
"rows": [
{
"source_id": "tpl-fo-4:p-fo-31",
"source_modified_at": "2026-08-28T14:02:11Z",
"source_deleted": false,
"template_source_id": "tpl-fo-4",
"parameter_source_id": "p-fo-31",
"is_compulsory": true,
"sequence_no": 5
}
]
}Limit rules
Table id cb18607c-9c42-44fa-8785-21e704430784
Batch URL https://data.customisedintelligence.ai/data-tables/cb18607c-9c42-44fa-8785-21e704430784/batch
A yellow or red threshold for one parameter in one scope. All seven legacy limit tables collapse into this one shape.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredparameter_source_idtextrequiredreferencescope_kindtextrequiredscope_reftextrequiredbandtextrequiredoperatortextrequiredvalue_1numericvalue_2numericpercentnumericprecedenceintrequiredsynced_attimestamptzserver-set{
"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
Table id e838e8b6-57cf-47a3-b4b4-31bcf31ad317
Batch URL https://data.customisedintelligence.ai/data-tables/e838e8b6-57cf-47a3-b4b4-31bcf31ad317/batch
One issued lab report: a set of samples judged together and published as a certificate or PDF.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredship_source_idtextrequiredreferenceorganisation_source_idtextrequiredreferencetemplate_source_idtextreferencestreamtextrequiredreport_notextstatustextrequiredoverall_ratingtextreceived_attimestamptzpublished_attimestamptzpdf_urltextsynced_attimestamptzserver-set{
"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
Table id 05c7d42c-e69d-4ba5-bf58-5334f634af88
Batch URL https://data.customisedintelligence.ai/data-tables/05c7d42c-e69d-4ba5-bf58-5334f634af88/batch
One physical sample within a report, drawn from one equipment unit. Carries the sampling context.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredreport_source_idtextrequiredreferenceequipment_unit_source_idtextrequiredreferencesample_type_source_idtextrequiredreferencesample_notextrequiredbottle_notextsampled_attimestamptzdispatched_attimestamptzreceived_attimestamptzsampling_pointtextsampled_bytextunit_hoursnumericoil_hoursnumericoil_gradetextratingtextlatitudenumericlongitudenumericcontextjsonbsynced_attimestamptzserver-set{
"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
Table id af15281f-2031-42f7-8c53-eef96a079932
Batch URL https://data.customisedintelligence.ai/data-tables/af15281f-2031-42f7-8c53-eef96a079932/batch
One measurement: a sample × a parameter. The largest table; every lab reading is one row here.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredsample_source_idtextrequiredreferenceparameter_source_idtextrequiredreferencevalue_rawtextrequiredvalue_numnumericunittextflagtextis_retestbooleanrequiredrevisionintrequiredcommenttextsynced_attimestamptzserver-set{
"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
Table id e8436871-d369-4b65-a79d-7caa5bb76517
Batch URL https://data.customisedintelligence.ai/data-tables/e8436871-d369-4b65-a79d-7caa5bb76517/batch
The limits a published report was actually judged against, frozen at publish time so old reports keep their meaning when rules change.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredreport_source_idtextrequiredreferenceparameter_source_idtextrequiredreferencebandtextrequiredoperatortextrequiredvalue_1numericvalue_2numericsynced_attimestamptzserver-set{
"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
Table id 2bf28ff0-725b-4fdd-9588-0d4286b06437
Batch URL https://data.customisedintelligence.ai/data-tables/2bf28ff0-725b-4fdd-9588-0d4286b06437/batch
The assessor's verdict and narrative for a report or a single sample.
Attributes
source_idtextrequireduniquesource_modified_attimestamptzrequiredsource_deletedbooleanrequiredreport_source_idtextrequiredreferencesample_source_idtextreferenceratingtextcomment_oiltextcomment_unittextsummarytextactiontextassessed_bytextassessed_attimestamptzsynced_attimestamptzserver-set{
"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.
Corrected 29 October 2026. Several queries below have been revised. Where one differs from Changes, 29 Oct 2026, use the version there.
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 table | Target | source_id |
|---|---|---|
| GroupCompany | organisation | grp-{GroupCompanyId} |
| Customer | organisation | cst-{CstId} |
| CBM_Owner | organisation | own-{OwnerId} |
| CBM_Fleet | fleet | flt-{FleetID} |
| Ship | ship | ship-{ShipId} |
| ShipModel | equipment_unit | unit-{shipModId} |
| ShipPurifier | equipment_unit | unit-pur-{shipPurId} |
| (synthetic, per ship) | equipment_unit | unit-fo-{ShipId} |
| (synthetic, per ship + water type) | equipment_unit | unit-wa-{ShipId}-{WaterTypeId} |
| LOTestType | sample_type | st-lo-{TestTypeId} |
| FO_OilType | sample_type | st-fo-{OilTypeId} |
| WA_WaterType | sample_type | st-wa-{WaterTypeId} |
| (code list, see Parameters) | parameter | p-lo-{code} |
| FO_Parameters | parameter | p-fo-{ParameterId} |
| WA_Parameter | parameter | p-wa-{ParameterId} |
| FO_ParameterTemplate | template | tpl-fo-{PTemplateId} |
| WA_Template | template | tpl-wa-{TemplateId} |
| FO_ParameterTemplateBlockDetails | template_parameter | tpl-fo-{PTemplateId}:p-fo-{ParameterId} |
| WA_ParameterTemplateMapping | template_parameter | tpl-wa-{TemplateId}:p-wa-{ParameterId} |
| OilNorms | limit_rule | lr-oil-{onId} |
| ModelNorms | limit_rule | lr-mod-{mnId} |
| CustomerNorms | limit_rule | lr-cst-{cnId} |
| FO_BlockParameterLimit | limit_rule | lr-fo-{BlockParameterLimitId} |
| WA_ModelNorms | limit_rule | lr-wa-{TemplateParameterId}-{band} |
| AssessmentReport, grouped | report | rpt-lo-{CsvID}-{VesselID} |
| FO_CSVHeader | report | rpt-fo-{FOReportHeaderId} |
| WA_CSVHeader | report | rpt-wa-{WAReportHeaderId} |
| AssessmentReport | sample | smp-lo-{RSID} |
| FO_CSVHeader | sample | smp-fo-{FOReportHeaderId} |
| WA_CSVHeaderDetailsMapping | sample | smp-wa-{WACSVHeaderDetailsMappingId} |
| AssessmentReport columns | result_value | rv-lo-{RSID}-{code} |
| FO_CSVDetails | result_value | rv-fo-{FOReportsDetailsId} |
| WA_CSVDetails | result_value | rv-wa-{WAReportsDetailsId} |
organisation, fleet, ship
Three legacy tables become organisation, distinguished by kind. Customer.GroupCompanyId becomes the parent link; owners have no parent.
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;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;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.
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.
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.
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.
-- 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;-- 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.
template and template_parameter
Only fuel oil and water have parameter templates. Lube oil has none: TemplateMaster, Templates and TemplatewiseSequence describe report layout files and a text blob of parameter order, not which parameters a sample carries, so lube-oil reports load with template_source_id left NULL. Load both tables after sample_type and parameter, and before report.
-- fuel oil
SELECT 'tpl-fo-' + CAST(t.PTemplateId AS varchar(20)) AS source_id,
COALESCE(t.ModifiedDate, t.CreatedDate) AS source_modified_at,
CAST(0 AS bit) AS source_deleted,
'st-fo-' + CAST(t.OilTypeId AS varchar(20)) AS sample_type_source_id,
t.PTemplateName AS name,
NULL AS organisation_source_id,
NULL AS spec_reference,
NULL AS boilerplate,
CASE WHEN ISNULL(t.Active, 1) = 1 THEN 1 ELSE 0 END AS is_active
FROM dbo.FO_ParameterTemplate t
UNION ALL
-- water: the report boilerplate lives on the template
SELECT 'tpl-wa-' + CAST(t.TemplateId AS varchar(20)),
COALESCE(t.ModifiedDate, t.CreatedDate),
CAST(0 AS bit),
'st-wa-' + CAST(t.WaterTypeId AS varchar(20)),
CASE WHEN st.SubTypeName IS NULL THEN t.TemplateName
ELSE t.TemplateName + ' (' + st.SubTypeName + ')' END,
NULL,
NULL,
(SELECT t.FooterText AS footer,
t.CertificateText AS certificate,
t.BackgroundText AS background,
t.No_of_Analysis AS no_of_analysis
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
CASE WHEN ISNULL(t.IsActive, 1) = 1 THEN 1 ELSE 0 END
FROM dbo.WA_Template t
LEFT JOIN dbo.WA_SubType st ON st.SubTypeId = t.SubTypeId;-- fuel oil: parameters sit in blocks; order by block, then by insertion
SELECT 'tpl-fo-' + CAST(d.PTemplateId AS varchar(20))
+ ':p-fo-' + CAST(d.ParameterId AS varchar(20)) AS source_id,
COALESCE(d.ModifiedDate, d.CreatedDate) AS source_modified_at,
CAST(0 AS bit) AS source_deleted,
'tpl-fo-' + CAST(d.PTemplateId AS varchar(20)) AS template_source_id,
'p-fo-' + CAST(d.ParameterId AS varchar(20)) AS parameter_source_id,
CASE WHEN ISNULL(p.IsCompulsory, 0) = 1 THEN 1 ELSE 0 END AS is_compulsory,
ROW_NUMBER() OVER (PARTITION BY d.PTemplateId
ORDER BY b.TitleOrder, d.PTemplate_Block_ParameterId) AS sequence_no
FROM dbo.FO_ParameterTemplateBlockDetails d
JOIN dbo.FO_Parameters p ON p.ParameterId = d.ParameterId
LEFT JOIN dbo.FO_ParameterTemplateBlock b
ON b.PTemplateId = d.PTemplateId AND b.BlockId = d.BlockId
UNION ALL
-- water: a flat mapping with no order column
SELECT 'tpl-wa-' + CAST(m.TemplateId AS varchar(20))
+ ':p-wa-' + CAST(m.ParameterId AS varchar(20)),
COALESCE(m.ModifiedDate, m.CreatedDate),
CAST(0 AS bit),
'tpl-wa-' + CAST(m.TemplateId AS varchar(20)),
'p-wa-' + CAST(m.ParameterId AS varchar(20)),
CASE WHEN ISNULL(p.IsCompulsory, 0) = 1 THEN 1 ELSE 0 END,
NULL
FROM dbo.WA_ParameterTemplateMapping m
JOIN dbo.WA_Parameter p ON p.ParameterId = m.ParameterId;The source_id is the pair, not the legacy row id, so a parameter removed from a template and added back lands on the same row. If the same parameter can appear in two blocks of one fuel-oil template, the pair is no longer unique: check with SELECT PTemplateId, ParameterId FROM dbo.FO_ParameterTemplateBlockDetails GROUP BY PTemplateId, ParameterId HAVING COUNT(*) > 1 and, if it returns rows, append BlockId to the id.
Templates are referenced from three other places: report.template_source_id (from FO_CSVHeader.ParameterTemplateId, which is a foreign key to FO_ParameterTemplate.PTemplateId, and from WA_CSVHeader.TemplateId), the water limit_rule rows from WA_ModelNorms (scope_kind = 'template'), and the synthetic water equipment units, which take their water type from WA_Template.WaterTypeId.
Confirm before loading:
FO_CustomerTemplate(CustomerId,OilTypeId,TemplateId,ISOId) assigns fuel-oil templates to customers and an ISO 8217 edition (FO_ISO.ISOName). One template can serve many customers, so it does not fit the singleorganisation_source_idandspec_referenceontemplate. Those two columns are leftNULLabove; tell us how the assignment is used and we will add a link table or a per-customer template row.FO_ParameterTemplate.PTemplateReferenceIdlooks like a pointer to the template a version was copied from. If so, older versions should load withis_active = false; confirm what it means.- Fuel-oil
sequence_nois ordered by block, then by the order rows were inserted, becauseFO_ParameterTemplateBlockDetailshas no order column. Water has no order at all. If the printed report follows another order, tell us where it is kept. FO_HeaderTemplate(PDF header layout) and theTO_*templates are not mapped.
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.
-- 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;-- 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.
-- 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;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.
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.
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'.
-- 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:
| Source | scope_kind | scope_ref | precedence |
|---|---|---|---|
| NewOil_Limits | new_oil | oil type + component group | 10 |
| OilNorms | oil_grade | OilGrade.oilGrade | 20 |
| ModelNorms | model | Model.modNo | 30 |
| MultiGradeLimits | oil_grade | multigrade set | 40 |
| CustomerNorms | customer | Customer.CstCompany | 50 |
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.
-- 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:
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.
-- 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.
-- 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.
Changes, 29 October 2026
A review of the data pushed between 23 and 28 October found problems with two causes. Some come from the extraction queries in the Legacy mapping appendix, and are corrected below (D1 to D8). The rest are in the extract as run, and are listed for you to fix (K1 to K9). Where a query below differs from the one in Legacy mapping, the version here is the one to use.
Apply the D changes to your queries, work through the K list, then run one full extract in load order with no date filter, so every fix lands in a single pass.
Summary
| # | Change | Fixes | Tables affected |
|---|---|---|---|
| D1 | Modified dates that can never be in the future | High-water marks pushed to 2026-12-17 and 2028-08-31 | sample, equipment_unit |
| D2 | Fuel-oil limits filtered and sent as numbers | 306 rules and 584,229 snapshots without a threshold | limit_rule, report_limit_snapshot |
| D3 | Queries for ModelNorms, CustomerNorms and ShipPurifier | No model or customer limits; no purifier units | limit_rule, equipment_unit |
| D4 | Lube-oil sample to unit join | 34,448 lube-oil samples with no unit | sample |
| D5 | Sample details moved from results to the sample | About 512,000 result values that are dates, names and places | sample, result_value, parameter |
| D6 | Plain-text water parameter names and units | HTML in 149 names and 153 units | parameter |
| D7 | Only the current revision of a fuel-oil value | 322,640 values sent in two revisions | result_value, report_limit_snapshot |
| D8 | Legacy 0 sent as NULL, not as an id | flt-0, own-0, grp-0, ship-0 references | all tables with references |
| K1–K9 | Fixes in the extract as run | See the checklist | all |
D1. Modified dates that can never be in the future
source_modified_at drives the high-water mark, which only moves forward. Two queries used a sample date where the legacy table has no modified date, so a mistyped collection date (2028 for 2026) moved the mark years ahead. A future date now stops a delta sync for that table.
Water samples take their date from the report header, and the lube-oil unit date is capped at the extract time. Apply the same cap to any other query whose date can be entered by hand.
-- was: COALESCE(d.SampleCollectiondate, d.LabReceiptDate)
(SELECT MIN(v) FROM (VALUES (COALESCE(h.PublishDate, h.CreatedDate)), (GETUTCDATE())) AS c(v))
AS source_modified_at,(SELECT MIN(v) FROM (VALUES (CASE WHEN sm.LastSampleDate > sm.crDate
THEN sm.LastSampleDate ELSE sm.crDate END),
(GETUTCDATE())) AS c(v)) AS source_modified_at,Once you have pushed the corrected rows, tell us. We will reset the sample and equipment_unit high-water marks to the latest genuine date, because a push cannot lower them.
SELECT COUNT(*) FROM (<extract query>) q WHERE q.source_modified_at > GETUTCDATE();
D2. Fuel-oil limits filtered and sent as numbers
FO_BlockParameterLimit and FO_CSVDetails hold their limits as varchar(50). The filter b.v1 IS NOT NULL lets empty strings and non-numeric text through, and those arrive as a rule or snapshot with no threshold. Convert first, then filter on the number.
CROSS APPLY (VALUES ('yellow', l.Yellow1, l.Yellow2),
('red', l.Red1, l.Red2)) AS raw(band, t1, t2)
CROSS APPLY (SELECT raw.band,
TRY_CAST(REPLACE(NULLIF(LTRIM(RTRIM(raw.t1)), ''), ',', '.') AS decimal(18,4)) AS v1,
TRY_CAST(REPLACE(NULLIF(LTRIM(RTRIM(raw.t2)), ''), ',', '.') AS decimal(18,4)) AS v2) AS b
WHERE b.v1 IS NOT NULLCROSS APPLY (VALUES ('yellow', d.Yellow1, d.Yellow2),
('red', d.Red1, d.Red2)) AS raw(band, t1, t2)
CROSS APPLY (SELECT raw.band,
TRY_CAST(REPLACE(NULLIF(LTRIM(RTRIM(raw.t1)), ''), ',', '.') AS decimal(18,4)) AS v1,
TRY_CAST(REPLACE(NULLIF(LTRIM(RTRIM(raw.t2)), ''), ',', '.') AS decimal(18,4)) AS v2) AS b
WHERE <current-revision filter from D7> AND b.v1 IS NOT NULLKeep the select lists as they are. b.band, b.v1 and b.v2 now come from the converted values. Text that does not convert is dropped, so look at what it is before loading:
SELECT t, COUNT(*) AS n FROM dbo.FO_BlockParameterLimit l CROSS APPLY (VALUES (l.Yellow1), (l.Yellow2), (l.Red1), (l.Red2)) AS x(t) WHERE NULLIF(LTRIM(RTRIM(x.t)), '') IS NOT NULL AND TRY_CAST(REPLACE(LTRIM(RTRIM(x.t)), ',', '.') AS decimal(18,4)) IS NULL GROUP BY t ORDER BY n DESC;
Send the 306 rules and 584,229 snapshots already loaded without a threshold once more with source_deleted = true (K9).
D3. Queries for ModelNorms, CustomerNorms and ShipPurifier
The Legacy mapping lists these three sources but gave no query for them. ModelNorms and CustomerNorms hold one limit per row, not a yellow and a red pair. The queries load that limit as the red band. Confirm whether the legacy application treats it as the warning or the action limit, and change 'red' to 'yellow' if it is the warning.
SELECT 'lr-mod-' + CAST(n.mnId AS varchar(20)) AS source_id,
GETUTCDATE() AS source_modified_at,
CAST(0 AS bit) AS source_deleted,
'p-lo-' + map.code AS parameter_source_id,
'model' AS scope_kind,
mo.modNo AS scope_ref,
'red' AS band,
s.sign AS operator,
n.val1 AS value_1,
n.val2 AS value_2,
n.[percent] AS [percent],
30 AS precedence
FROM dbo.ModelNorms n
JOIN dbo.Model mo ON mo.modId = n.modId
JOIN dbo.lo_param_map map ON map.normId = n.normId
LEFT JOIN dbo.Sign s ON s.signId = n.signId
WHERE n.val1 IS NOT NULL OR n.[percent] IS NOT NULL
UNION ALL
SELECT 'lr-cst-' + CAST(n.cnId AS varchar(20)),
GETUTCDATE(), CAST(0 AS bit),
'p-lo-' + map.code, 'customer', c.CstCompany, 'red',
s.sign, n.val1, n.val2, n.[percent], 50
FROM dbo.CustomerNorms n
JOIN dbo.Customer c ON c.CstId = n.cstId
JOIN dbo.lo_param_map map ON map.normId = n.normId
LEFT JOIN dbo.Sign s ON s.signId = n.signId
WHERE n.val1 IS NOT NULL OR n.[percent] IS NOT NULL;Also check that the OilNorms extract returns every row: only 16 oil-grade rules were loaded.
SELECT 'unit-pur-' + CAST(sp.shipPurId AS varchar(20)) AS source_id,
(SELECT MIN(v) FROM (VALUES (COALESCE(sp.modDate, sp.crDate)), (GETUTCDATE())) AS c(v))
AS source_modified_at,
CAST(0 AS bit) AS source_deleted,
'ship-' + CAST(NULLIF(sp.ShipId, 0) AS varchar(20)) AS ship_source_id,
'Purifier' AS name,
'lube_oil' AS stream,
'purifier' AS application,
man.manName AS make,
pm.purNo AS model,
NULL AS serial_no,
og.oilGrade AS oil_grade,
ob.obrandName AS oil_brand,
TRY_CAST(sp.OilVol AS decimal(18,2)) AS charge_volume_l,
sp.Schedule AS sampling_interval_days,
sp.LastSampleDate AS last_sampled_at,
0 AS is_critical,
NULL AS external_ref,
1 AS is_active
FROM dbo.ShipPurifier sp
LEFT JOIN dbo.PurifierModel pm ON pm.purId = sp.PurId
LEFT JOIN dbo.Manufacturer man ON man.manId = pm.manId
LEFT JOIN dbo.OilGrade og ON og.oilId = sp.OilGradeId
LEFT JOIN dbo.OilBrand ob ON ob.obrandId = og.obrandId;The schema does not show how a lube-oil sample taken from a purifier identifies it. Tell us which AssessmentReport column does, and we will add it to the sample query.
D4. Lube-oil sample to unit join
The sample query matched ShipModel on vessel, machinery and an exact serial number. A serial number typed differently on the two sides gave no match, and 34,448 of 65,756 lube-oil samples arrived without a unit. Both tables also carry the SeaTec machinery id, which is a better key where it is filled in. The join below tries it first. It then falls back to the serial number compared without spaces, and finally to the only unit of that machinery on the vessel.
OUTER APPLY (SELECT TOP 1 c.shipModId
FROM dbo.ShipModel c
WHERE c.shipId = r.VesselID
AND ( (r.SEATECMACHINERYID IS NOT NULL AND c.SeaTecMachineryId = r.SEATECMACHINERYID)
OR (c.macId = r.MachineryID
AND ( REPLACE(ISNULL(c.SrNo, ''), ' ', '') = REPLACE(ISNULL(r.SerialNo, ''), ' ', '')
OR (SELECT COUNT(*) FROM dbo.ShipModel c2
WHERE c2.shipId = r.VesselID AND c2.macId = r.MachineryID) = 1)))
ORDER BY CASE WHEN c.SeaTecMachineryId = r.SEATECMACHINERYID THEN 0
WHEN REPLACE(ISNULL(c.SrNo, ''), ' ', '') = REPLACE(ISNULL(r.SerialNo, ''), ' ', '') THEN 1
ELSE 2 END,
c.shipModId) AS smThe select keeps 'unit-' + CAST(sm.shipModId AS varchar(20)). A sample that still finds no unit sends NULL, never an empty string (K4). To see what is left unmatched and why:
SELECT CASE WHEN NOT EXISTS (SELECT 1 FROM dbo.ShipModel c WHERE c.shipId = r.VesselID) THEN 'vessel has no units'
WHEN NOT EXISTS (SELECT 1 FROM dbo.ShipModel c WHERE c.shipId = r.VesselID AND c.macId = r.MachineryID) THEN 'no unit of that machinery'
ELSE 'several units, serial does not match' END AS reason,
COUNT(*) AS samples
FROM dbo.AssessmentReport r
OUTER APPLY (<the join above>) sm
WHERE r.VesselID IS NOT NULL AND r.CsvID IS NOT NULL AND sm.shipModId IS NULL
GROUP BY CASE WHEN NOT EXISTS (SELECT 1 FROM dbo.ShipModel c WHERE c.shipId = r.VesselID) THEN 'vessel has no units'
WHEN NOT EXISTS (SELECT 1 FROM dbo.ShipModel c WHERE c.shipId = r.VesselID AND c.macId = r.MachineryID) THEN 'no unit of that machinery'
ELSE 'several units, serial does not match' END;D5. Sample details moved from results to the sample
The fuel-oil and water screens keep facts about the sample as parameters: dates, sampling point, analyst, seal numbers, bunker port, barge. The Legacy mapping copied every parameter across as a measurement, so about 512,000 result values are not results. These now go to the matching sample column, or into context, and drop out of result_value and the active parameter list.
First create a mapping table, as for lo_param_map. The ids below are the 41 found in the pushed data. Check for others with the last query in this section.
CREATE TABLE dbo.sample_detail_map (
stream varchar(2) NOT NULL,
parameter_id int NOT NULL,
target varchar(40) NOT NULL,
PRIMARY KEY (stream, parameter_id)
);
INSERT INTO dbo.sample_detail_map (stream, parameter_id, target) VALUES
('fo', 5, 'sampled_at'),
('fo', 6, 'dispatched_at'),
('fo', 8, 'received_at'),
('fo', 11, 'sampling_point'),
('fo', 98, 'bottle_no'),
('fo', 3, 'sample_seal_no'),
('fo', 18, 'seal_no_lab'),
('fo', 19, 'seal_no_vessel'),
('fo', 20, 'seal_no_supplier'),
('fo', 21, 'seal_no_marpol'),
('fo', 4, 'seal_integrity'),
('fo', 7, 'bunker_port'),
('fo', 9, 'bunker_supplier'),
('fo', 10, 'barge_name'),
('fo', 12, 'sampling_method'),
('wa', 165, 'sampled_at'),
('wa', 56, 'sampled_at'),
('wa', 70, 'sampled_at'),
('wa', 26, 'received_at'),
('wa', 55, 'received_at'),
('wa', 106, 'received_at'),
('wa', 30, 'sampling_point'),
('wa', 82, 'sampling_point'),
('wa', 29, 'sampled_by'),
('wa', 60, 'sampled_by'),
('wa', 27, 'analyst'),
('wa', 58, 'analyst'),
('wa', 28, 'container'),
('wa', 57, 'container'),
('wa', 71, 'sampling_time'),
('wa', 158, 'sampling_time'),
('wa', 50, 'test_started'),
('wa', 4, 'test_started'),
('wa', 67, 'test_ended'),
('wa', 68, 'test_ended'),
('wa', 151, 'port'),
('wa', 166, 'port'),
('wa', 148, 'make'),
('wa', 149, 'model'),
('wa', 146, 'model'),
('wa', 102, 'bunker_port_and_date');OUTER APPLY (SELECT
MAX(CASE WHEN m.target = 'sampled_at' THEN v.Value END) AS sampled_at,
MAX(CASE WHEN m.target = 'dispatched_at' THEN v.Value END) AS dispatched_at,
MAX(CASE WHEN m.target = 'received_at' THEN v.Value END) AS received_at,
MAX(CASE WHEN m.target = 'sampling_point' THEN v.Value END) AS sampling_point,
MAX(CASE WHEN m.target = 'bottle_no' THEN v.Value END) AS bottle_no,
MAX(CASE WHEN m.target = 'sample_seal_no' THEN v.Value END) AS sample_seal_no,
MAX(CASE WHEN m.target = 'seal_no_lab' THEN v.Value END) AS seal_no_lab,
MAX(CASE WHEN m.target = 'seal_no_vessel' THEN v.Value END) AS seal_no_vessel,
MAX(CASE WHEN m.target = 'seal_no_supplier' THEN v.Value END) AS seal_no_supplier,
MAX(CASE WHEN m.target = 'seal_no_marpol' THEN v.Value END) AS seal_no_marpol,
MAX(CASE WHEN m.target = 'seal_integrity' THEN v.Value END) AS seal_integrity,
MAX(CASE WHEN m.target = 'bunker_port' THEN v.Value END) AS bunker_port,
MAX(CASE WHEN m.target = 'bunker_supplier' THEN v.Value END) AS bunker_supplier,
MAX(CASE WHEN m.target = 'barge_name' THEN v.Value END) AS barge_name,
MAX(CASE WHEN m.target = 'sampling_method' THEN v.Value END) AS sampling_method
FROM dbo.FO_CSVDetails v
JOIN dbo.sample_detail_map m ON m.stream = 'fo' AND m.parameter_id = v.ParameterId
WHERE v.FOReoprtsHeaderId = h.FOReportHeaderId
AND <current-revision filter from D7, on v>) AS pv
-- in the select list:
-- bottle_no pv.bottle_no
-- sampled_at COALESCE(COALESCE(TRY_CONVERT(datetime2, pv.sampled_at, 126), TRY_CONVERT(datetime2, REPLACE(pv.sampled_at, '-', ' '), 106)), h.UploadDate)
-- dispatched_at COALESCE(TRY_CONVERT(datetime2, pv.dispatched_at, 126), TRY_CONVERT(datetime2, REPLACE(pv.dispatched_at, '-', ' '), 106))
-- received_at COALESCE(COALESCE(TRY_CONVERT(datetime2, pv.received_at, 126), TRY_CONVERT(datetime2, REPLACE(pv.received_at, '-', ' '), 106)), h.UploadDate)
-- sampling_point pv.sampling_point
-- context (SELECT h.LabComments AS lab_comment, h.VesselComments AS vessel_comment,
-- b.FO_BunkerOrTankName AS bunker_or_tank,
-- pv.sample_seal_no, pv.seal_no_lab, pv.seal_no_vessel, pv.seal_no_supplier,
-- pv.seal_no_marpol, pv.seal_integrity, pv.bunker_port, pv.bunker_supplier,
-- pv.barge_name, pv.sampling_method
-- FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)OUTER APPLY (SELECT
MAX(CASE WHEN m.target = 'sampled_at' THEN v.Value END) AS sampled_at,
MAX(CASE WHEN m.target = 'received_at' THEN v.Value END) AS received_at,
MAX(CASE WHEN m.target = 'sampling_point' THEN v.Value END) AS sampling_point,
MAX(CASE WHEN m.target = 'sampled_by' THEN v.Value END) AS sampled_by,
MAX(CASE WHEN m.target = 'analyst' THEN v.Value END) AS analyst,
MAX(CASE WHEN m.target = 'container' THEN v.Value END) AS container,
MAX(CASE WHEN m.target = 'sampling_time' THEN v.Value END) AS sampling_time,
MAX(CASE WHEN m.target = 'test_started' THEN v.Value END) AS test_started,
MAX(CASE WHEN m.target = 'test_ended' THEN v.Value END) AS test_ended,
MAX(CASE WHEN m.target = 'port' THEN v.Value END) AS port,
MAX(CASE WHEN m.target = 'make' THEN v.Value END) AS make,
MAX(CASE WHEN m.target = 'model' THEN v.Value END) AS model,
MAX(CASE WHEN m.target = 'bunker_port_and_date' THEN v.Value END) AS bunker_port_and_date
FROM dbo.WA_CSVDetails v
JOIN dbo.sample_detail_map m ON m.stream = 'wa' AND m.parameter_id = v.ParameterId
WHERE v.WACSVHeaderDetailsMappingId = d.WACSVHeaderDetailsMappingId) AS pv
-- in the select list, the sample's own column first, the parameter where it is empty:
-- sampled_at COALESCE(d.SampleCollectiondate, COALESCE(TRY_CONVERT(datetime2, pv.sampled_at, 126), TRY_CONVERT(datetime2, REPLACE(pv.sampled_at, '-', ' '), 106)))
-- received_at COALESCE(d.LabReceiptDate, COALESCE(TRY_CONVERT(datetime2, pv.received_at, 126), TRY_CONVERT(datetime2, REPLACE(pv.received_at, '-', ' '), 106)))
-- sampling_point COALESCE(d.SamplingPoint, pv.sampling_point)
-- sampled_by pv.sampled_by
-- context (SELECT d.Vessel_Comment AS vessel_comment, d.Lab_Comment AS lab_comment,
-- d.AWBNo AS awb_no, pv.analyst, pv.container, pv.sampling_time,
-- pv.test_started, pv.test_ended, pv.port, pv.make, pv.model,
-- pv.bunker_port_and_date
-- FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)Dates are read as ISO (2025-07-15) first, then as 15-Jul-2025: these are the two formats in the pushed data. A value in any other format comes through as NULL, so check for those before loading.
-- fuel-oil branch AND d.ParameterId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM dbo.sample_detail_map m WHERE m.stream = 'fo' AND m.parameter_id = d.ParameterId) -- water branch AND NOT EXISTS (SELECT 1 FROM dbo.sample_detail_map m WHERE m.stream = 'wa' AND m.parameter_id = d.ParameterId)
-- fuel-oil branch (FO_Parameters.Active)
CASE WHEN EXISTS (SELECT 1 FROM dbo.sample_detail_map m
WHERE m.stream = 'fo' AND m.parameter_id = p.ParameterId) THEN 0
WHEN ISNULL(p.Active, 1) = 1 THEN 1 ELSE 0 END AS is_active
-- water branch (WA_Parameter.IsActive)
CASE WHEN EXISTS (SELECT 1 FROM dbo.sample_detail_map m
WHERE m.stream = 'wa' AND m.parameter_id = p.ParameterId) THEN 0
WHEN ISNULL(p.IsActive, 1) = 1 THEN 1 ELSE 0 END AS is_activeSELECT 'wa' AS stream, ParameterId, ParamterName FROM dbo.WA_Parameter WHERE ParamterName LIKE '%date%' OR ParamterName LIKE '%time%' OR ParamterName LIKE '%by%' OR ParamterName LIKE '%port%' OR ParamterName LIKE '%seal%' OR ParamterName LIKE '%bottle%' UNION ALL SELECT 'fo', ParameterId, PName FROM dbo.FO_Parameters WHERE PName LIKE '%date%' OR PName LIKE '%seal%' OR PName LIKE '%port%' OR PName LIKE '%name%' OR PName LIKE '%bottle%';
Tell us if you would rather keep any of these as parameters, for example because the lab reports them as test results.
D6. Plain-text water parameter names and units
WA_Parameter stores names and units as rich text from the legacy editor, for example <p>pH</p>, or <p><br></p> for no unit. The query passed them through as they were. Strip the markup, decode entities, and send an empty unit as NULL.
NULLIF(LTRIM(RTRIM(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(p.ParamterName,
'<p>', ''), '</p>', ''), '<br>', ''), '<br/>', ''), '<strong>', ''), '</strong>', ''),
'<sup>', ''), '</sup>', ''), '<sub>', ''), '</sub>', ''), '&', '&'), ' ', ' ')
)), '') AS name,
NULLIF(LTRIM(RTRIM(
REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(p.Unit,
'<p>', ''), '</p>', ''), '<br>', ''), '<br/>', ''), '<strong>', ''), '</strong>', ''),
'<sup>', ''), '</sup>', ''), '<sub>', ''), '</sub>', ''), '&', '&'), ' ', ' ')
)), '') AS unit,This covers every tag found in the data: p, br, strong, sup, sub. Superscripts become plain digits (m3/h), so correct the 6 names that use them by hand if you want m³/h. Send all 153 water parameters again after the change.
D7. Only the current revision of a fuel-oil value
The filter ISNULL(d.ActiveVersion, 1) = 1 treats a missing flag as current. 322,640 values arrived as both revision 1 and revision 2, so superseded rows evidently have no flag either. The filter below keeps a row only if it is flagged current or, when unflagged, has the highest version for its sample and parameter. The 13 rows of an injection viscosity table share one version, so all 13 are kept.
WHERE ( d.ActiveVersion = 1
OR (d.ActiveVersion IS NULL
AND ISNULL(d.Version, 1) = (SELECT MAX(ISNULL(d2.Version, 1))
FROM dbo.FO_CSVDetails d2
WHERE d2.FOReoprtsHeaderId = d.FOReoprtsHeaderId
AND d2.ParameterId = d.ParameterId)))Use the same filter on v inside the D5 fuel-oil apply. Before loading, send us the result of this query, so we can confirm the rule matches how the legacy system marks corrections (K6):
SELECT ISNULL(CAST(Version AS varchar(10)), 'NULL') AS version,
ISNULL(CAST(ActiveVersion AS varchar(1)), 'NULL') AS active_version,
COUNT(*) AS rows
FROM dbo.FO_CSVDetails
GROUP BY Version, ActiveVersion
ORDER BY version, active_version;D8. Legacy 0 sent as NULL, not as an id
Several legacy columns use 0 for "none". The queries turned NULL into NULL but 0 into an id, so 5,033 ships point at fleet flt-0, 4,051 at owner own-0, 167 organisations at group grp-0, and 4 units at ship-0. None of these exist. Build every reference through NULLIF:
'ship-' + CAST(NULLIF(sm.shipId, 0) AS varchar(20)) AS ship_source_id
-- ship 'flt-' + CAST(NULLIF(s.ShipFleet, 0) AS varchar(20)) AS fleet_source_id, 'own-' + CAST(NULLIF(s.OwnerId, 0) AS varchar(20)) AS owner_source_id, -- organisation, Customer branch 'grp-' + CAST(NULLIF(c.GroupCompanyId, 0) AS varchar(20)) AS parent_source_id,
Concatenating a string with NULL gives NULL, so the CASE wrappers are no longer needed.
K1–K9. Fixes in the extract as run
These do not come from the published queries. They are in the extract as run, or need a decision from your side.
| # | Fix | Done when |
|---|---|---|
| K1 | Correct the 14 future-dated rows at source: water samples smp-wa-40202 to 40205 (2028-08-31), smp-wa-42444–42446, 42562–42566, 42886, 42887, 43171 (Nov–Dec 2026), and unit unit-124199. | D1 check returns 0 |
| K2 | Remove the date filters left on the extract. Fuel-oil and water results start on 1 January 2023, lube-oil samples and assessments on 28 October 2025. Then run every table in load order with no filter. | “Verifying the load” counts match on both sides |
| K3 | Load the synthetic water units (the “synthetic equipment units” query), which were never pushed. Load all fuel-oil units: exactly 1,000 arrived, and 1,232 fuel-oil samples point at units that are missing. | Every sample’s unit exists |
| K4 | Send NULL for a missing reference, never an empty string. 34,448 sample unit references and 26,616 parameter references arrived as ''. | No *_source_id equals '' |
| K5 | Send the fuel-oil lab comment paragraphs (about four per sample, 26,616 rows with no parameter) to assessment, as summary or comment_oil on the fuel-oil report. Only 1,779 fuel-oil assessments are loaded. | No result value without a parameter |
| K6 | Run the “how revisions are flagged” query in D7 and send us the result before loading. | We confirm the D7 rule |
| K7 | Correct the 6 fuel-oil between rules that have a missing bound or a lower bound above the upper. Confirm with your tribologists the precedence between oil-grade, model and customer limits, and whether single-limit norms are yellow or red (D3). | Precedence confirmed in writing |
| K8 | Send the parents that are missing: 206 ships referenced by 819 units and 238 reports, the reports behind 400 samples, 197 snapshots and 123 assessments, the samples behind 414 water results and 8 assessments, and 1 customer. Inactive or unpublished parents are still sent, with is_active = false or their status. | Every reference resolves |
| K9 | After the corrected full run, send each row it no longer produces once more with source_deleted = true: the revision-1 duplicates (D7), result values that are now sample details (D5), empty-parameter comments (K5), and rules and snapshots without a threshold (D2). Leaving a row out of a push does not remove it. | We send you counts per table to compare |
Reference checks
Run these against your extract before each push. Each must return 0.
SELECT COUNT(*) FROM (<child extract>) c LEFT JOIN (<parent extract>) p ON p.source_id = c.<parent>_source_id WHERE c.<parent>_source_id IS NOT NULL AND p.source_id IS NULL;
SELECT sample_source_id, parameter_source_id, COUNT(*) FROM (<result_value extract>) v GROUP BY sample_source_id, parameter_source_id HAVING COUNT(*) > 1; -- p-fo-71 and p-fo-72 (injection table) are expected
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.