diff --git a/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts b/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts new file mode 100644 index 000000000..94a9531a5 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3700000000000-HandlingStandards.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Standard loading-and-unloading time, so the handling figure can be reported + * the way the OCC scorecard reports it — hours against a target, with a rate. + * + * Nullable with NO default, unlike every other column in this table. The + * reporting spec publishes standards for a station stay (10h / 13h) and for a + * turn-around cycle (65 / 88 / 96) but none for handling, so there is no + * honest figure to seed. Until a planner enters one in Operating standards the + * rate reads empty rather than judging trains against an invented number. + */ +export class HandlingStandards3700000000000 implements MigrationInterface { + name = "HandlingStandards3700000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.operations_standards + ADD COLUMN IF NOT EXISTS handling_standard_hours_container numeric(6,2), + ADD COLUMN IF NOT EXISTS handling_standard_hours_bulk numeric(6,2); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.operations_standards + DROP COLUMN IF EXISTS handling_standard_hours_container, + DROP COLUMN IF EXISTS handling_standard_hours_bulk; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts index cbc686a22..3c45e2984 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/dto/update-operations-standards.dto.ts @@ -67,6 +67,24 @@ export class UpdateOperationsStandardsDto { @Min(0) delayToleranceMinutes?: number; + /** + * Handling standards have no spec figure, so they are the only two that may + * be cleared: null puts the report back to reporting hours without a rate. + */ + @ApiPropertyOptional({ example: 6.75, nullable: true }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + handlingStandardHoursContainer?: number | null; + + @ApiPropertyOptional({ example: 12, nullable: true }) + @IsOptional() + @Transform(toNumber) + @IsNumber() + @Min(0.01) + handlingStandardHoursBulk?: number | null; + @ApiPropertyOptional({ example: 20 }) @IsOptional() @Transform(toNumber) diff --git a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts index d5a31725f..655209d71 100644 --- a/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts +++ b/apps/edr-freight-api/src/modules/operations-reporting/entities/operations-standard.entity.ts @@ -179,6 +179,34 @@ export class OperationsStandard extends BaseEntity { @Column({ name: 'default_full_trainset_wagons', type: 'int', default: 50 }) defaultFullTrainsetWagons!: number; + /** + * Standard loading-and-unloading time for a container train's stop, in hours. + * + * Null until a planner sets it, and deliberately so: the reporting spec names + * no handling standard, so an unset value reports no rate rather than judging + * a train against a guess. Same for the bulk figure below. + */ + @Column({ + name: 'handling_standard_hours_container', + type: 'numeric', + precision: 6, + scale: 2, + nullable: true, + transformer: asNumber, + }) + handlingStandardHoursContainer?: number | null; + + /** Standard loading-and-unloading time for a bulk train's stop, in hours. */ + @Column({ + name: 'handling_standard_hours_bulk', + type: 'numeric', + precision: 6, + scale: 2, + nullable: true, + transformer: asNumber, + }) + handlingStandardHoursBulk?: number | null; + /** IAM user id of the last operator to change a standard. */ @Column({ name: 'updated_by_id', type: 'uuid', nullable: true }) updatedById?: string | null; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts index c6dfed921..753f71d3a 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/loading-unloading.report.ts @@ -1,10 +1,13 @@ -import { ReportDefinition } from '../report.types'; +import { ReportContext, ReportDefinition, ReportFilterDef } from '../report.types'; import { COUNTRY_FILTER, DIRECTION_FILTER, + TRAIN_TYPE_FILTER, + cycleRateExpr, handlingHours, hoursBetween, loadingHours, + loadingSource, otherActivityHours, stationStaysQb, unloadingHours, @@ -26,9 +29,9 @@ import { PERIOD_FILTER, periodExprOn, periodTruncExprOn } from '../revenue-class * because that is how the corridor measures a turnaround; bulk stations, which * load OR unload rather than both, get the two halves in their own columns. * - * Averages skip the stops nobody logged handling for rather than counting them - * as zero — `AVG` ignores nulls — so `Stops` is the population and `Handling - * logged` says how much of it the handling averages actually rest on. + * Averages skip the stops with no handling window rather than counting them as + * zero — `AVG` ignores nulls — so `Stops` is the population and `Handling + * measured` says how much of it the handling averages actually rest on. */ const STAYING_HOURS = hoursBetween('s.arrived_at', 's.departed_at'); const HANDLING_HOURS = handlingHours('s'); @@ -39,6 +42,29 @@ const avg = (expr: string): string => `ROUND(AVG((${expr})::numeric), 1)::float8 /** Reused verbatim in the GROUP BY — the group key is the coalesced value. */ const TRAIN_NUMBER = "COALESCE(s.train_number, '—')"; +const HANDLING_STANDARD = 'MAX(s.handling_standard_hours)'; + +/** + * Which way the rows roll up. + * + * Per train is the spec's container format; per station is its bulk one, which + * asks for the loading and unloading time AT Negad, BCC, DMP, Sebeta, GMP, + * Adama and Modjo rather than per train. Same measurements either way — only + * the group key moves — so one definition serves both. + */ +const GRAIN_FILTER: ReportFilterDef = { + key: 'grain', + label: 'Group by', + type: 'select', + options: [ + { value: 'train', label: 'Train' }, + { value: 'station', label: 'Station' }, + ], +}; + +/** Whitelisted here, so the user's value never reaches SQL. */ +const byStation = (ctx: ReportContext): boolean => ctx.params.grain === 'station'; + export const loadingUnloadingReport: ReportDefinition = { key: 'loading-unloading', title: 'Loading & Unloading', @@ -49,13 +75,19 @@ export const loadingUnloadingReport: ReportDefinition = { 'the stop’s handling window, unloading start to loading end, which is the container ' + 'measure; the unloading and loading columns split it for bulk stations that only do ' + 'one of the two (Nagad, BCC and DMP on the Djibouti side; Sebeta, GMP, Adama and Modjo ' + - 'on the Ethiopian). Other activity is the rest of the station stay, against the 10h ' + - 'Ethiopia / 13h Djibouti standard from Operating standards. Averages rest only on the ' + - 'stops whose handling was actually logged — “Handling logged” counts them.', + 'on the Ethiopian) — switch Group by to Station for that view. Other activity is the rest ' + + 'of the station stay, against the 10h Ethiopia / 13h Djibouti standard from Operating ' + + 'standards. The loading window falls back to the first and last booking loaded here when ' + + 'nobody recorded it by hand, which “Loading from” reports as Derived; unloading is only ' + + 'ever hand-recorded. Averages rest only on the stops that have a handling window at all — ' + + '“Handling measured” counts them. Handling rate needs a handling standard set in Operating ' + + 'standards and reads empty until there is one.', group: 'Operations', filters: [ PERIOD_FILTER, + GRAIN_FILTER, { key: 'date', label: 'Arrival', type: 'daterange' }, + TRAIN_TYPE_FILTER, DIRECTION_FILTER, { key: 'trainNumber', label: 'Train No.', type: 'text' }, { key: 'station', label: 'Station', type: 'text' }, @@ -66,8 +98,10 @@ export const loadingUnloadingReport: ReportDefinition = { { key: 'trainNumber', label: 'Train No.', type: 'string', sortable: true }, { key: 'station', label: 'Station', type: 'string', sortable: true }, { key: 'country', label: 'Country', type: 'string' }, + { key: 'trainType', label: 'Train type', type: 'string' }, { key: 'stops', label: 'Stops', type: 'number', sortable: true }, - { key: 'handlingLogged', label: 'Handling logged', type: 'number' }, + { key: 'handlingMeasured', label: 'Handling measured', type: 'number' }, + { key: 'loadingSource', label: 'Loading from', type: 'string' }, { key: 'avgUnloadingHours', label: 'Avg unloading (hrs)', type: 'number', sortable: true }, { key: 'avgLoadingHours', label: 'Avg loading (hrs)', type: 'number', sortable: true }, { @@ -78,8 +112,10 @@ export const loadingUnloadingReport: ReportDefinition = { }, { key: 'avgOtherActivityHours', label: 'Avg other activity (hrs)', type: 'number' }, { key: 'avgStayingHours', label: 'Avg staying (hrs)', type: 'number', sortable: true }, - { key: 'standardHours', label: 'Standard (hrs)', type: 'number' }, - { key: 'verdict', label: 'Verdict', type: 'string' }, + { key: 'stayStandardHours', label: 'Staying standard (hrs)', type: 'number' }, + { key: 'stayVerdict', label: 'Staying verdict', type: 'string' }, + { key: 'handlingStandardHours', label: 'Handling standard (hrs)', type: 'number' }, + { key: 'handlingRate', label: 'Handling rate', type: 'percent', sortable: true }, ], defaultSort: { key: 'period', dir: 'DESC' }, chart: { type: 'bar', x: 'trainNumber', y: ['avgLoadUnloadHours'] }, @@ -88,40 +124,57 @@ export const loadingUnloadingReport: ReportDefinition = { // Reused verbatim in the GROUP BY, per the trap documented on `periodExpr`. const bucket = periodTruncExprOn('s.arrived_at', params); - return stationStaysQb(ctx) + const perStation = byStation(ctx); + const qb = stationStaysQb(ctx) .select(periodExprOn('s.arrived_at', params), 'period') - .addSelect(TRAIN_NUMBER, 'trainNumber') + .addSelect(perStation ? "'All trains'" : TRAIN_NUMBER, 'trainNumber') .addSelect('s.station', 'station') .addSelect('s.country', 'country') + .addSelect('MAX(s.train_type)', 'trainType') .addSelect('COUNT(*)::int', 'stops') - .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingLogged') + .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'handlingMeasured') + // Which side of the COALESCE the loading columns came from. A group that + // mixes both says so rather than claiming either. + .addSelect( + `CASE WHEN COUNT(DISTINCT ${loadingSource('s')}) > 1 THEN 'Mixed' + ELSE MAX(${loadingSource('s')}) END`, + 'loadingSource', + ) .addSelect(avg(unloadingHours('s')), 'avgUnloadingHours') .addSelect(avg(loadingHours('s')), 'avgLoadingHours') .addSelect(avg(HANDLING_HOURS), 'avgLoadUnloadHours') .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOtherActivityHours') .addSelect(avg(STAYING_HOURS), 'avgStayingHours') - .addSelect('MAX(s.standard_hours)::float8', 'standardHours') + .addSelect('MAX(s.standard_hours)::float8', 'stayStandardHours') .addSelect( `CASE WHEN AVG((${STAYING_HOURS})::numeric) <= MAX(s.standard_hours) THEN 'Encouraging' ELSE 'Needs reason' END`, - 'verdict', + 'stayVerdict', + ) + .addSelect(`${HANDLING_STANDARD}::float8`, 'handlingStandardHours') + // Same formula the turnaround cycle publishes, so the two read alike. + // NULL standard in, NULL rate out — nothing to measure against yet. + .addSelect( + cycleRateExpr(`AVG((${HANDLING_HOURS})::numeric)`, HANDLING_STANDARD), + 'handlingRate', ) .groupBy(bucket) - .addGroupBy(TRAIN_NUMBER) .addGroupBy('s.station') .addGroupBy('s.country'); + if (!perStation) qb.addGroupBy(TRAIN_NUMBER); + return qb; }, async summary(ctx) { const row = await stationStaysQb(ctx) .select('COUNT(*)::int', 'stops') - .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'logged') + .addSelect(`COUNT(${HANDLING_HOURS})::int`, 'measured') .addSelect(avg(HANDLING_HOURS), 'avgHandling') .addSelect(avg(OTHER_ACTIVITY_HOURS), 'avgOther') - .getRawOne<{ stops: number; logged: number; avgHandling: number; avgOther: number }>(); + .getRawOne<{ stops: number; measured: number; avgHandling: number; avgOther: number }>(); return [ { label: 'Stops measured', value: Number(row?.stops ?? 0) }, - { label: 'Handling logged', value: Number(row?.logged ?? 0) }, + { label: 'Handling measured', value: Number(row?.measured ?? 0) }, { label: 'Average loading + unloading', value: Number(row?.avgHandling ?? 0), unit: 'h' }, { label: 'Average other activity', value: Number(row?.avgOther ?? 0), unit: 'h' }, ]; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts index 9ae3173f1..f874b1e0c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/turnaround-cycle.report.ts @@ -6,8 +6,8 @@ import { CYCLE_STANDARD_HOURS_EXPR, DIRECTION_FILTER, cycleRateExpr, - handlingEnd, - handlingStart, + handlingEndOn, + handlingStartOn, hoursBetween, otherActivityHours, scheduleLedgerQb, @@ -65,23 +65,38 @@ const AD_HOURS = hoursBetween('c.cycle_start', 'c.cycle_end'); const TRAVEL_HOURS = `ROUND(GREATEST((${AD_HOURS})::numeric - ${ETHIOPIA_HOURS} - ${DJIBOUTI_HOURS}, 0), 1)::float8`; /** - * Loading and unloading logged at the cycle's own stops, summed across both - * ends of the line. + * The leg the train leaves a stop on, which is the one it loads for. Inside a + * cycle the order is known outright — it arrives at the far end on the first + * schedule and departs on the return, arrives home on the return and departs on + * the next cycle's first leg — so the booking-derived loading window can be + * resolved here without the window function the stop-shaped query uses. + */ +const CYCLE_DEPARTING_SCHEDULE = `CASE + WHEN e.train_schedule_id = c.schedule_id THEN c.return_schedule_id + WHEN e.train_schedule_id = c.return_schedule_id THEN c.next_cycle_schedule_id +END`; + +const cycleHandlingStart = handlingStartOn('e', CYCLE_DEPARTING_SCHEDULE, 'e.yard_id'); +const cycleHandlingEnd = handlingEndOn('e', CYCLE_DEPARTING_SCHEDULE, 'e.yard_id'); + +/** + * Loading and unloading at the cycle's own stops, summed across both ends of + * the line. * - * NULL — not zero — when no stop in the cycle recorded a handling window: SUM - * over no rows is NULL, and a cycle nobody logged handling for has an unknown - * handling time. Other activity follows it, so an unlogged cycle shows both - * columns empty rather than claiming the whole stay was other activity. + * NULL — not zero — when no stop in the cycle has a handling window: SUM over + * no rows is NULL, and a cycle nobody measured has an unknown handling time. + * Other activity follows it, so such a cycle shows both columns empty rather + * than claiming the whole stay was other activity. */ const HANDLING_HOURS = `( SELECT ROUND(SUM( - EXTRACT(EPOCH FROM (${handlingEnd('e')} - ${handlingStart('e')})) / 3600 + EXTRACT(EPOCH FROM (${cycleHandlingEnd} - ${cycleHandlingStart})) / 3600 )::numeric, 1)::float8 FROM freight.train_checkpoint_events e WHERE e.deleted_at IS NULL AND e.train_schedule_id IN (c.schedule_id, c.return_schedule_id, c.next_cycle_schedule_id) - AND ${handlingStart('e')} IS NOT NULL - AND ${handlingEnd('e')} IS NOT NULL + AND ${cycleHandlingStart} IS NOT NULL + AND ${cycleHandlingEnd} IS NOT NULL )`; const STATION_STAY_HOURS = `(${ETHIOPIA_HOURS} + ${DJIBOUTI_HOURS})`; diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts index a37bc3f0c..228e24470 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.spec.ts @@ -4,9 +4,13 @@ import { CARGO_CATEGORY_LABEL_EXPR, CONTAINER_CLASSES, CONTAINER_CLASS_EXPR, + HANDLING_STANDARD_HOURS_EXPR, TARGET_DIMENSION_KEYS, cycleRateExpr, + handlingHours, implementRateExpr, + loadingHours, + otherActivityHours, plannedRowsSql, } from './operations-classification'; import { TARGET_DIMENSIONS, TARGET_METRICS } from '../operations-reporting/entities/operations-target.entity'; @@ -24,6 +28,41 @@ function emittedKeys(expr: string): string[] { } describe('operations classification', () => { + /** + * The loading window falls back to the bookings that boarded at the stop, off + * the DEPARTING schedule — a turnaround loads for the leg it leaves on, not + * the one it arrived on. Losing either half of that silently turns a + * populated report back into an empty one. + */ + it('falls back to the booking-derived loading window, off the departing leg', () => { + for (const expr of [loadingHours('s'), handlingHours('s')]) { + expect(expr).toContain('COALESCE(s.loading_started_at'); + expect(expr).toContain('s.departed_schedule_id'); + expect(expr).toContain('b.origin_yard_id = (s.yard_id)'); + } + }); + + /** Unloading is never derived — auto-unload would report it as ~0 hours. */ + it('never derives the unloading half', () => { + expect(handlingHours('s')).toContain('s.unloading_started_at'); + expect(handlingHours('s')).not.toContain('b.destination_yard_id'); + }); + + /** + * Every other standard coalesces to the spec's figure. This one must not: + * the spec names no handling standard, and a fallback would publish a rate + * against a number nobody agreed to. + */ + it('leaves the handling standard null when nobody has set one', () => { + expect(HANDLING_STANDARD_HOURS_EXPR).toContain('std.handling_standard_hours_container'); + expect(HANDLING_STANDARD_HOURS_EXPR).not.toContain('COALESCE(std.handling'); + }); + + /** An unmeasured stop reports unknown activity, not a full stay of it. */ + it('keeps other activity null when there is no handling window', () => { + expect(otherActivityHours('stay', 'handling')).toContain('IS NULL THEN NULL'); + }); + it('offers every cargo category the expression can emit as a filter option', () => { const offered = new Set(CARGO_CATEGORIES.map((o) => o.value)); const missing = [...new Set(emittedKeys(CARGO_CATEGORY_EXPR))].filter((k) => !offered.has(k)); diff --git a/apps/edr-freight-api/src/modules/reports/operations-classification.ts b/apps/edr-freight-api/src/modules/reports/operations-classification.ts index 340d02f06..f9d65d9b9 100644 --- a/apps/edr-freight-api/src/modules/reports/operations-classification.ts +++ b/apps/edr-freight-api/src/modules/reports/operations-classification.ts @@ -207,6 +207,20 @@ END`; export const DELAY_TOLERANCE_HOURS_EXPR = `(${stdRow('delay_tolerance_minutes', 30)} / 60.0)`; +/** + * Standard loading-and-unloading time for a stop, by what the train carries. + * + * Deliberately NOT wrapped in a fallback like every other standard here: the + * reporting spec publishes no figure for handling, so there is nothing honest + * to fall back to. Until a planner enters one in Operating standards this is + * NULL, and the rate and verdict that read it stay empty rather than judging a + * train against a number nobody agreed to. + */ +export const HANDLING_STANDARD_HOURS_EXPR = `CASE + WHEN ${SCHEDULE_IS_CONTAINER} THEN std.handling_standard_hours_container + ELSE std.handling_standard_hours_bulk +END`; + /** * Joins the single standards row. Restricted by id to the earliest live row so * a stray second row could never fan a report's result out. @@ -489,19 +503,87 @@ export function scheduleLedgerQb(ctx: ReportContext): SelectQueryBuilder `( + SELECT ${agg}(b.loaded_at) + FROM freight.bookings b + JOIN freight.train_schedule_bookings tsb + ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL + AND tsb.train_schedule_id = (${scheduleExpr}) + WHERE b.deleted_at IS NULL + AND b.origin_yard_id = (${yardExpr}) + AND b.loaded_at IS NOT NULL +)`; + +/** + * The departing schedule and the yard are passed in rather than read off a + * fixed alias: the stop-shaped query carries them as columns, while the + * turnaround report reads raw `train_checkpoint_events` rows and works out the + * departing leg from the cycle it already knows. + */ +export const loadingStartOn = ( + alias: string, + scheduleExpr: string, + yardExpr: string, +): string => + `COALESCE(${alias}.loading_started_at, ${derivedLoading('MIN', scheduleExpr, yardExpr)})`; +export const loadingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string => + `COALESCE(${alias}.loading_completed_at, ${derivedLoading('MAX', scheduleExpr, yardExpr)})`; + +/** The stop-shaped query's own columns — what every stay-based report uses. */ +const STOP_SCHEDULE = (alias: string): string => `${alias}.departed_schedule_id`; +const STOP_YARD = (alias: string): string => `${alias}.yard_id`; + +/** Hand-recorded times win; the booking-derived window is the fallback. */ +export const loadingStart = (alias: string): string => + loadingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); +export const loadingEnd = (alias: string): string => + loadingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); + +/** Which of the two the loading columns came from, so nobody mistakes one for the other. */ +export const loadingSource = (alias: string): string => `CASE + WHEN ${alias}.loading_started_at IS NOT NULL + OR ${alias}.loading_completed_at IS NOT NULL THEN 'Logged' + WHEN ${derivedLoading('MIN', STOP_SCHEDULE(alias), STOP_YARD(alias))} IS NOT NULL THEN 'Derived' + ELSE '—' +END`; + /** * When station work began and ended at a stop. * * LEAST and GREATEST ignore nulls, so a stop that only loaded (an export * origin) or only unloaded reports that half's window on its own, and where - * both halves are logged the pair spans exactly what the spec measures — - * unloading start to loading end. NULL when nothing was logged: an unlogged - * stop has an unknown handling time, not a zero one. + * both halves are known the pair spans exactly what the spec measures — + * unloading start to loading end. NULL when nothing was logged or derived: an + * unlogged stop has an unknown handling time, not a zero one. */ +export const handlingStartOn = ( + alias: string, + scheduleExpr: string, + yardExpr: string, +): string => + `LEAST(${alias}.unloading_started_at, ${loadingStartOn(alias, scheduleExpr, yardExpr)})`; +export const handlingEndOn = (alias: string, scheduleExpr: string, yardExpr: string): string => + `GREATEST(${loadingEndOn(alias, scheduleExpr, yardExpr)}, ${alias}.unloading_completed_at)`; + export const handlingStart = (alias: string): string => - `LEAST(${alias}.unloading_started_at, ${alias}.loading_started_at)`; + handlingStartOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); export const handlingEnd = (alias: string): string => - `GREATEST(${alias}.loading_completed_at, ${alias}.unloading_completed_at)`; + handlingEndOn(alias, STOP_SCHEDULE(alias), STOP_YARD(alias)); /** Total loading and unloading time at a stop, in hours. */ export const handlingHours = (alias: string): string => @@ -510,7 +592,7 @@ export const handlingHours = (alias: string): string => export const unloadingHours = (alias: string): string => hoursBetween(`${alias}.unloading_started_at`, `${alias}.unloading_completed_at`); export const loadingHours = (alias: string): string => - hoursBetween(`${alias}.loading_started_at`, `${alias}.loading_completed_at`); + hoursBetween(loadingStart(alias), loadingEnd(alias)); /** * What the stay was spent on other than handling — the spec's "other activity". @@ -549,15 +631,24 @@ export function stationStopsQb(ctx: ReportContext): SelectQueryBuilder= :dateFrom`, { dateFrom: params.dateFrom }); @@ -570,11 +661,25 @@ export function stationStopsQb(ctx: ReportContext): SelectQueryBuilder { const inner = stationStopsQb(ctx); diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx index e443fcffd..384a98d30 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/OperationsStandardsPage.tsx @@ -24,6 +24,8 @@ type Field = { hint: string; unit: string; integer?: boolean; + /** May be left blank, which clears it. Only the handling standards are. */ + optional?: boolean; }; type Section = { title: string; description: string; fields: Field[] }; @@ -84,6 +86,27 @@ const SECTIONS: Section[] = [ }, ], }, + { + title: "Loading and unloading", + description: + "What a stop's handling should take, for the rate on Loading & Unloading. The reporting spec names no figure here, so both start blank and the rate stays empty until one is set. Leave blank to clear.", + fields: [ + { + name: "handlingStandardHoursContainer", + label: "Container trains", + hint: "Unloading start to loading finish", + unit: "hrs", + optional: true, + }, + { + name: "handlingStandardHoursBulk", + label: "Bulk trains", + hint: "Loading or unloading at the station", + unit: "hrs", + optional: true, + }, + ], + }, { title: "Delay", description: @@ -189,6 +212,8 @@ export default function OperationsStandardsPage() { const invalid = (field: Field): boolean => { const raw = draft[field.name]; if (raw === undefined) return false; + // Blank on an optional field is a deliberate clear, not a bad number. + if (field.optional && raw.trim() === "") return false; const parsed = Number(raw); if (!Number.isFinite(parsed) || parsed <= 0) return true; return field.integer ? !Number.isInteger(parsed) : false; @@ -200,7 +225,10 @@ export default function OperationsStandardsPage() { const handleSave = async () => { if (anyInvalid || !dirty) return; const patch = Object.fromEntries( - Object.entries(draft).map(([key, value]) => [key, Number(value)]), + Object.entries(draft).map(([key, value]) => [ + key, + value.trim() === "" ? null : Number(value), + ]), ); await update.mutateAsync(patch); setDraft({}); diff --git a/apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts b/apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts index f9086570a..6b7cca09d 100644 --- a/apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/operationsStandards.service.ts @@ -19,6 +19,9 @@ export interface OperationsStandards { cycleStandardHoursBulkBcc: number; defaultLegStandardHours: number; delayToleranceMinutes: number; + /** Null until a planner sets one — the spec names no handling standard. */ + handlingStandardHoursContainer: number | null; + handlingStandardHoursBulk: number | null; chargedTonsFull20ft: number; chargedTonsFull40ft: number; chargedTonsEmpty20ft: number;