SeaTec Table API Reference
The 14 data tables your CBM data lands in, the endpoints that read and write them, and the sync loop that keeps them current. Every table follows the same four-column convention and the same routes, so once one works, they all do.
Authentication
We issue you one bearer token for a dedicated sync account in your organisation, valid for a year. Send it on every request. It can read your tables and write rows, nothing else.
- Keep it in a secret store; never in source control or logs.
- To rotate or revoke, ask us. Old and new can overlap for a cut-over window.
- We send your organisation id and base URL with the token.
Authorization: Bearer eyJhbGciOi…
export BASE="https://<api-host>" export TOKEN="<your token>" export ORG_ID="<your organisation id>"
Sync flow
Your script holds no state. It asks us where it got to, selects everything your database changed since then, and posts it. It never decides whether a row is new, never stores our ids, and never tracks what it has already sent.
Three properties make that safe:
Why it can't lose or duplicate rows
source_id decides insert vs 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
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
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
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
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
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
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
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
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:fo-31",
"source_modified_at": "2026-08-28T14:02:11Z",
"source_deleted": false,
"template_source_id": "tpl-fo-4",
"parameter_source_id": "fo-31",
"is_compulsory": true,
"sequence_no": 5
}
]
}Limit rules
A yellow or red threshold for one parameter in one scope. All seven legacy limit tables collapse into this one shape.
Attributes
source_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
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
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
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
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
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.
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} |
| 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} |
| 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.
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.
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.