diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 5a9ae9ef9..a347b163b 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -275,6 +275,9 @@ export class AppModule implements OnApplicationBootstrap { // await this.demoUsersSeeder.run(); // await this.freightStaffUsersSeeder.run(); // await this.pricingDataSeeder.run(); + // IndodeFacilitySeeder keys its warehouses on INDODE_OPEN / INDODE_CLOSED, so + // it will not recognise a hand-created Indode warehouse and will seed a second + // one alongside it. Only enable it against an Indode that has no warehouse. // await this.indodeFacilitySeeder.run(); // await this.batch14TestDataSeeder.run(); // await this.batch5TestDataSeeder.run(); diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts new file mode 100644 index 000000000..dd22f3c37 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.spec.ts @@ -0,0 +1,72 @@ +import { validate } from 'class-validator'; +import { IsISO8601, IsOptional } from 'class-validator'; + +import { CLOCK_SKEW_TOLERANCE_MS, IsNotBackdated } from './is-not-backdated.validator'; + +class Subject { + @IsOptional() + @IsISO8601() + @IsNotBackdated() + occurredAt?: string; +} + +const subjectWith = (occurredAt?: string) => { + const subject = new Subject(); + subject.occurredAt = occurredAt; + return subject; +}; + +const errorsFor = async (occurredAt?: string) => validate(subjectWith(occurredAt)); + +const backdatedErrors = (errors: Awaited>) => + errors.filter((error) => Object.keys(error.constraints ?? {}).includes('IsNotBackdated')); + +describe('IsNotBackdated', () => { + it('rejects a timestamp from the past', async () => { + const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + + const errors = await errorsFor(yesterday); + + expect(backdatedErrors(errors)).toHaveLength(1); + expect(errors[0].constraints?.IsNotBackdated).toBe( + 'occurredAt cannot be backdated — it must be now or later', + ); + }); + + it('accepts now', async () => { + const errors = await errorsFor(new Date().toISOString()); + + expect(errors).toHaveLength(0); + }); + + it('accepts a value stale only by transit and clock skew', async () => { + // What an honest caller sends: "now" as of when the request was built. + const almostNow = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS - 5_000)).toISOString(); + + const errors = await errorsFor(almostNow); + + expect(errors).toHaveLength(0); + }); + + it('rejects a value staler than the skew allowance', async () => { + const tooStale = new Date(Date.now() - (CLOCK_SKEW_TOLERANCE_MS + 5_000)).toISOString(); + + const errors = await errorsFor(tooStale); + + expect(backdatedErrors(errors)).toHaveLength(1); + }); + + it('ignores an absent value so @IsOptional decides', async () => { + const errors = await errorsFor(undefined); + + expect(errors).toHaveLength(0); + }); + + it('leaves an unparseable value to the format validator', async () => { + const errors = await errorsFor('not-a-date'); + + // Reported as a format problem, not as a backdate. + expect(backdatedErrors(errors)).toHaveLength(0); + expect(errors[0].constraints).toHaveProperty('isIso8601'); + }); +}); diff --git a/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts new file mode 100644 index 000000000..7a767e7f8 --- /dev/null +++ b/apps/edr-freight-api/src/common/validators/is-not-backdated.validator.ts @@ -0,0 +1,56 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; + +/** + * A caller may not stamp an event as having happened before now. + * + * A request cannot reach the server at the instant it was built, and a caller's + * clock is not the server's, so a timestamp that honestly means "now" always + * arrives a little stale. Comparing straight against `Date.now()` would reject + * it. The skew allowance below is what makes an honest "now" pass — it is not a + * window for backdating, and it is deliberately far too small to reach any + * earlier event worth backdating to. + */ +export const CLOCK_SKEW_TOLERANCE_MS = 60_000; + +@ValidatorConstraint({ name: 'IsNotBackdated', async: false }) +export class IsNotBackdatedConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + // Absence is not this validator's business; pair with @IsOptional. + if (value === undefined || value === null || value === '') return true; + const parsed = new Date(value as string | Date); + // An unparseable value is a format error — let @IsISO8601/@IsDateString own + // that message rather than reporting it as a backdate. + if (Number.isNaN(parsed.getTime())) return true; + const toleranceMs = (args.constraints?.[0] as number | undefined) ?? CLOCK_SKEW_TOLERANCE_MS; + return parsed.getTime() >= Date.now() - toleranceMs; + } + + defaultMessage(args: ValidationArguments): string { + return `${args.property} cannot be backdated — it must be now or later`; + } +} + +/** + * Rejects a timestamp earlier than now, give or take {@link CLOCK_SKEW_TOLERANCE_MS}. + * Pass a different tolerance only with a reason. + */ +export function IsNotBackdated( + toleranceMs: number = CLOCK_SKEW_TOLERANCE_MS, + validationOptions?: ValidationOptions, +) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + constraints: [toleranceMs], + validator: IsNotBackdatedConstraint, + }); + }; +} diff --git a/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts new file mode 100644 index 000000000..d6e7ae273 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2320000000000-YardFacilityFreightTypes.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * A facility handles what its equipment can handle. Containers need a reach + * stacker or gantry, so only Indode, Modjo and Dire Dawa take them; bulk needs + * far less, so all five facilities load and unload it. + * + * Both default true — a facility handles everything unless someone says + * otherwise, which keeps existing rows working and makes the seeder the place + * where the real capability is stated. + */ +export class YardFacilityFreightTypes2320000000000 implements MigrationInterface { + name = 'YardFacilityFreightTypes2320000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + ADD COLUMN IF NOT EXISTS handles_container boolean NOT NULL DEFAULT true, + ADD COLUMN IF NOT EXISTS handles_bulk boolean NOT NULL DEFAULT true + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.yard_facilities + DROP COLUMN IF EXISTS handles_container, + DROP COLUMN IF EXISTS handles_bulk + `); + } +} diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 59dad2248..810232993 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service'; import { BookingsRepository } from '../bookings/bookings.repository'; import { BookingsService } from '../bookings/bookings.service'; import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { + ClearanceMilestone, + type RiskAssignmentRecord, +} from './entities/clearance-milestone.entity'; import { Booking } from '../bookings/entities/booking.entity'; import { clearanceCodesForBooking } from '../bookings/clearance.util'; import { ClearanceWorkflowService } from './clearance-workflow.service'; @@ -85,6 +88,8 @@ export interface BookingClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; @@ -282,6 +287,12 @@ export class BookingClearanceService { riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, + // Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are + // the current one; this is the trail behind it. + riskHistory: + riskMilestone?.status === 'COMPLETED' + ? (riskMilestone.metadata?.riskHistory ?? []) + : [], secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts index eb77533ac..4ad6dab75 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.risk.spec.ts @@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => { expect(saved.status).toBe('COMPLETED'); expect(saved.metadata?.riskLevel).toBe('YELLOW'); }); + + /** + * The level is customer-visible and stays correctable until duty is advised, + * so a changed level must leave a trail rather than overwrite the last one. + */ + describe('risk history', () => { + it('records the first assignment with no previous level', async () => { + const { service } = makeService('COMPLETED'); + + const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.'); + + expect(saved.metadata?.riskHistory).toHaveLength(1); + expect(saved.metadata?.riskHistory?.[0]).toMatchObject({ + level: 'RED', + assignedByUserId: 'user-1', + assignedBy: 'Abebe K.', + note: 'initial rating', + }); + expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel'); + }); + + it('keeps the earlier decision when the level is reassigned', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.'); + const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.'); + + expect(saved.metadata?.riskLevel).toBe('GREEN'); + expect(saved.metadata?.riskHistory).toHaveLength(2); + // The original RED decision survives, with who made it. + expect(saved.metadata?.riskHistory?.[0]).toMatchObject({ + level: 'RED', + assignedBy: 'Abebe K.', + }); + expect(saved.metadata?.riskHistory?.[1]).toMatchObject({ + level: 'GREEN', + previousLevel: 'RED', + assignedByUserId: 'user-2', + assignedBy: 'Sara M.', + note: 'downgraded', + }); + }); + + it('keeps the whole chain across several reassignments, oldest first', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'GREEN'); + await service.assignRisk('b-1', 'YELLOW'); + const saved = await service.assignRisk('b-1', 'RED'); + + expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([ + 'GREEN', + 'YELLOW', + 'RED', + ]); + }); + + it('does not record a repeat of the level already assigned', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'GREEN'); + const saved = await service.assignRisk('b-1', 'GREEN'); + + expect(saved.metadata?.riskHistory).toHaveLength(1); + }); + + it('always leaves riskLevel equal to the last history entry', async () => { + const { service } = makeService('COMPLETED'); + + await service.assignRisk('b-1', 'RED'); + const saved = await service.assignRisk('b-1', 'YELLOW'); + + const history = saved.metadata?.riskHistory ?? []; + expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index ed3597d57..b6d57263a 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -210,15 +210,50 @@ export class ClearanceMilestoneService { * Customs cannot risk-rate cargo still moving under transit: the T1 must be * closed (accepted by GL Ethiopia after the train arrives) first, which is the * catalog order T1_CLOSED → RISK_ASSIGNED. + * + * The level stays correctable until duty is advised off it, so each assignment + * is appended to `riskHistory` instead of silently replacing the last one — a + * customer-visible level that changes needs a trail of who changed it and when. */ async assignRisk( bookingId: string, riskLevel: CustomsRiskLevel, userId?: string, note?: string, + actor?: string, ): Promise { await this.assertT1Closed(bookingId); - return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note); + + const existing = await this.repo.findOne({ + where: { bookingId, milestoneCode: 'RISK_ASSIGNED' }, + }); + const previousLevel = existing?.metadata?.riskLevel; + const history = existing?.metadata?.riskHistory ?? []; + + // A repeat of the level already assigned is not a decision — recording it + // would pad the trail with entries that changed nothing. + const entries = + previousLevel === riskLevel + ? history + : [ + ...history, + { + level: riskLevel, + ...(previousLevel ? { previousLevel } : {}), + assignedAt: new Date().toISOString(), + assignedByUserId: userId ?? null, + assignedBy: actor ?? null, + note: note ?? null, + }, + ]; + + return this.completeWithMetadata( + bookingId, + 'RISK_ASSIGNED', + { riskLevel, riskHistory: entries }, + userId, + note, + ); } /** Guard: the booking's T1 must be closed before customs risk can be assigned. */ diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index d3bbaf098..d752d071a 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ContractNotifierService } from './contract-notifier.service'; import { GlOperationsService } from './gl-operations.service'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; +import { + ClearanceMilestone, + type RiskAssignmentRecord, +} from './entities/clearance-milestone.entity'; import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; @@ -102,6 +105,8 @@ export interface ContractClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; @@ -365,6 +370,11 @@ export class ContractClearanceService { riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt ? riskMilestone.triggeredAt.toISOString() : null, + // Every risk decision, oldest first — see booking-clearance.service. + riskHistory: + riskMilestone?.status === 'COMPLETED' + ? (riskMilestone.metadata?.riskHistory ?? []) + : [], secondDuty, importReleaseGranted: bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index b62b89e32..cb91fbee6 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -31,6 +31,7 @@ import { ApiTags, } from '@nestjs/swagger'; +import { actorLabel } from '../warehouses/current-actor.util'; import { BookingStaff } from '../../common/booking-guards'; import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; import { @@ -968,13 +969,16 @@ export class ContractsController { assignRisk( @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignRiskDto, - @CurrentUser() user: AuthUserPayload, + @CurrentUser() user: TCurrentUser, ) { return this.milestoneService.assignRisk( bookingId, dto.riskLevel, resolveAuthUserId(user), dto.note, + // Risk history is read by people, so resolve the name now — the id alone + // would render as a UUID in the trail. + actorLabel(user), ); } diff --git a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts index d4676b8cd..14e3b86dd 100644 --- a/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts +++ b/apps/edr-freight-api/src/modules/contracts/entities/clearance-milestone.entity.ts @@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number]; export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const; export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number]; +/** + * One customs risk decision. Risk stays correctable until duty is advised off + * it, and the level is customer-visible, so every assignment is kept rather than + * overwritten — a disputed level needs to show what was set, by whom, and when. + */ +export interface RiskAssignmentRecord { + level: CustomsRiskLevel; + /** The level this replaced; absent on the first assignment. */ + previousLevel?: CustomsRiskLevel; + assignedAt: string; + assignedByUserId?: string | null; + /** Display name resolved at assignment time, so the trail never shows a UUID. */ + assignedBy?: string | null; + note?: string | null; +} + /** * Structured payload some milestones carry beyond a plain note (doc §11.3): - * - RISK_ASSIGNED → `riskLevel` + * - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment) * - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial` * Stored on the milestone so the timeline can render the value inline. */ export interface MilestoneMetadata { riskLevel?: CustomsRiskLevel; + /** + * Append-only, oldest first. `riskLevel` is the current value and always + * equals the last entry's `level`. + */ + riskHistory?: RiskAssignmentRecord[]; dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts index ba5a0d671..5712eed03 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/yard-facility.entity.ts @@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity { @Column({ name: 'has_warehouse', type: 'boolean', default: false }) hasWarehouse!: boolean; + /** + * Containers need a reach stacker or gantry, so only the equipped facilities + * (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled + * everywhere. + */ + @Column({ name: 'handles_container', type: 'boolean', default: true }) + handlesContainer!: boolean; + + @Column({ name: 'handles_bulk', type: 'boolean', default: true }) + handlesBulk!: boolean; + @Column({ name: 'equipment_notes', type: 'text', nullable: true }) equipmentNotes?: string | null; diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts index f32b0129a..4b69c328f 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/yard-facilities.service.ts @@ -10,80 +10,91 @@ export interface YardFacilityInfo { hasFacility: boolean; /** The facility stores cargo — enables the warehouse flow (storage, demurrage). */ hasWarehouse: boolean; + /** Containers need a reach stacker/gantry — not every facility has one. */ + handlesContainer: boolean; + handlesBulk: boolean; } /** - * Which yards can handle cargo, and how. + * Which yards can handle cargo, and what kind. * * A yard is a load/unload point when `yards.has_facility` is set; the matching - * `yard_facilities` record says whether it also stores cargo. Facilities without a - * warehouse move cargo on and off the train and nothing more — no storage, no - * demurrage. This is the single resolver the journey and handling flows use, so - * they can't drift on what a facility is. + * `yard_facilities` record says what it can actually do — whether it stores cargo + * (storage/demurrage), and which freight types its equipment can lift. Containers + * need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them; + * bulk is handled at all five. + * + * This is the single resolver the journey and handling flows use, so they can't + * drift on what a facility is or what it can lift. */ @Injectable() export class YardFacilitiesService { constructor(private readonly dataSource: DataSource) {} - /** Resolve a yard's handling capability. Null when the yard doesn't exist. */ - async facilityForYard(yardId: string): Promise { - const [row]: Array<{ - yardId: string; - yardCode: string | null; - yardLabel: string | null; - hasFacility: boolean; - hasWarehouse: boolean | null; - }> = await this.dataSource.query( - `SELECT y.id AS "yardId", - y.code AS "yardCode", - y.label AS "yardLabel", - y.has_facility AS "hasFacility", - f.has_warehouse AS "hasWarehouse" - FROM freight.yards y - LEFT JOIN freight.yard_facilities f - ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true - WHERE y.id = $1 AND y.deleted_at IS NULL`, - [yardId], - ); - if (!row) return null; + private readonly SELECT = ` + SELECT y.id AS "yardId", + y.code AS "yardCode", + y.label AS "yardLabel", + y.has_facility AS "hasFacility", + f.has_warehouse AS "hasWarehouse", + f.handles_container AS "handlesContainer", + f.handles_bulk AS "handlesBulk" + FROM freight.yards y + LEFT JOIN freight.yard_facilities f + ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`; + + private toInfo(row: { + yardId: string; + yardCode: string | null; + yardLabel: string | null; + hasFacility: boolean; + hasWarehouse: boolean | null; + handlesContainer: boolean | null; + handlesBulk: boolean | null; + }): YardFacilityInfo { + // No facility record means no capability, whatever the flag says. + const hasFacility = Boolean(row.hasFacility); return { yardId: row.yardId, yardCode: row.yardCode, yardLabel: row.yardLabel, - hasFacility: Boolean(row.hasFacility), - // No facility record means no warehouse, whatever the flag says. - hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse), + hasFacility, + hasWarehouse: hasFacility && Boolean(row.hasWarehouse), + handlesContainer: hasFacility && Boolean(row.handlesContainer), + handlesBulk: hasFacility && Boolean(row.handlesBulk), }; } + /** Resolve a yard's handling capability. Null when the yard doesn't exist. */ + async facilityForYard(yardId: string): Promise { + const [row] = await this.dataSource.query( + `${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`, + [yardId], + ); + return row ? this.toInfo(row) : null; + } + /** Every yard that can load/unload, for pickers and the intercity queues. */ async listFacilityYards(): Promise { - const rows: Array<{ - yardId: string; - yardCode: string | null; - yardLabel: string | null; - hasFacility: boolean; - hasWarehouse: boolean | null; - }> = await this.dataSource.query( - `SELECT y.id AS "yardId", - y.code AS "yardCode", - y.label AS "yardLabel", - y.has_facility AS "hasFacility", - f.has_warehouse AS "hasWarehouse" - FROM freight.yards y - LEFT JOIN freight.yard_facilities f - ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true - WHERE y.deleted_at IS NULL - AND y.is_active = true - AND y.has_facility = true + const rows = await this.dataSource.query( + `${this.SELECT} + WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true ORDER BY y.display_order ASC, y.label ASC`, ); - return rows.map((r) => ({ - yardId: r.yardId, - yardCode: r.yardCode, - yardLabel: r.yardLabel, - hasFacility: true, - hasWarehouse: Boolean(r.hasWarehouse), - })); + return rows.map((r: Parameters[0]) => this.toInfo(r)); + } + + /** + * Can this facility lift this cargo? Keeps the freight-type rule in one place + * so callers can't get it subtly wrong. + */ + canHandleFreight( + facility: YardFacilityInfo | null, + freightType: string | null | undefined, + ): boolean { + if (!facility?.hasFacility) return false; + return String(freightType).toUpperCase() === 'CONTAINER' + ? facility.handlesContainer + : facility.handlesBulk; } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 6f366d060..fb140158c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -352,10 +352,19 @@ export class BookingJourneyService { ): Promise { if (booking.tradeDirection !== 'DOMESTIC') return; const facility = await this.yardFacilities.facilityForYard(yardId); + const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'; + if (!facility?.hasFacility) { throw new BadRequestException( - `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` + - `${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`, + `${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`, + ); + } + // A facility only handles what its equipment can lift: containers need a + // reach stacker/gantry, bulk does not. + if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) { + throw new BadRequestException( + `${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` + + `an intercity booking cannot be ${where} here.`, ); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.spec.ts new file mode 100644 index 000000000..58cf5da12 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.spec.ts @@ -0,0 +1,37 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +import { RecordCheckpointDto } from './record-checkpoint.dto'; + +const validateBody = (body: Record) => + validate(plainToInstance(RecordCheckpointDto, body)); + +describe('RecordCheckpointDto', () => { + // The final checkpoint arrives the schedule, so a backdated one rewrites the + // journey after the fact. No UI sends occurredAt; the endpoint still accepts it. + it('rejects a backdated occurredAt', async () => { + const errors = await validateBody({ + sequenceNo: 3, + occurredAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(), + }); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('occurredAt'); + expect(errors[0].constraints).toHaveProperty('IsNotBackdated'); + }); + + it('accepts occurredAt of now', async () => { + const errors = await validateBody({ + sequenceNo: 3, + occurredAt: new Date().toISOString(), + }); + + expect(errors).toHaveLength(0); + }); + + it('accepts a body that omits occurredAt, leaving the service to stamp it', async () => { + const errors = await validateBody({ sequenceNo: 0 }); + + expect(errors).toHaveLength(0); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index 7f778760d..da7ebc0e7 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -10,6 +10,8 @@ import { Min, } from 'class-validator'; +import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator'; + export class RecordCheckpointDto { @ApiProperty({ description: 'Station position along the route (0 = origin).' }) @IsInt() @@ -21,9 +23,18 @@ export class RecordCheckpointDto { @IsEnum(TrainCheckpointKind) kind?: TrainCheckpointKind; - @ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' }) + /** + * A checkpoint records where the train is as staff observe it, and the final + * one arrives the schedule — so a backdated value rewrites the journey after + * the fact. Only "now" is accepted; omit the field and the service stamps it. + */ + @ApiProperty({ + required: false, + description: 'ISO timestamp; defaults to now. Cannot be earlier than now.', + }) @IsOptional() @IsISO8601() + @IsNotBackdated() occurredAt?: string; @ApiProperty({ required: false }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index af0f410a6..b35650e16 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -67,10 +67,18 @@ export class IntercityService { ts.status AS "scheduleStatus", oy.id AS "originYardId", COALESCE(oy.label, oy.code) AS "origin", - oy.has_facility AS "originHasFacility", + -- Can that end actually handle THIS booking's cargo? A container + -- booking needs a facility with a stacker; bulk needs any facility. + (oy.has_facility AND COALESCE( + CASE WHEN b.freight_type = 'CONTAINER' + THEN ofac.handles_container ELSE ofac.handles_bulk END, false)) + AS "originHasFacility", dy.id AS "destinationYardId", COALESCE(dy.label, dy.code) AS "destination", - dy.has_facility AS "destinationHasFacility", + (dy.has_facility AND COALESCE( + CASE WHEN b.freight_type = 'CONTAINER' + THEN dfac.handles_container ELSE dfac.handles_bulk END, false)) + AS "destinationHasFacility", -- Where the train actually is, so the operator knows if the cargo -- can be worked right now. cp.yard_id AS "trainAtYardId", @@ -80,6 +88,10 @@ export class IntercityService { LEFT JOIN freight.companies company ON company.id = b.company_id LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id + LEFT JOIN freight.yard_facilities ofac + ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true + LEFT JOIN freight.yard_facilities dfac + ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true LEFT JOIN freight.train_schedules ts ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL LEFT JOIN LATERAL ( diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts index c0ff19b25..af1a9eb45 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts @@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => { expect(result).toHaveLength(2); }); }); + + describe('marshalling documents', () => { + // Staff check these against the physical consist, so every wagon on the + // train set has to appear — an empty wagon that renders no row reads as a + // wagon that is not on the train. + const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({ + sequenceNo, + wagonNumber, + physicalWagon: { wagonNumber }, + wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 }, + lengthMeters: 14, + capacityTons: 70, + allocations, + }); + + const loadedAllocation = { + bookingId: 'booking-1', + bookingReference: 'BK-2026-000001', + loadType: 'CONTAINER', + allocatedWeightTons: 24.5, + containerNumbers: ['CONT-001'], + booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' }, + containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }], + }; + + const countRows = (html: string) => (html.match(/\s* { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { + wagons: [ + makeWagon(1, 'W-001', [loadedAllocation]), + makeWagon(2, 'W-002', []), + makeWagon(3, 'W-003', []), + ], + }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown) => string; + }).buildExportLoadListHtml(schedule); + + expect(countRows(html)).toBe(3); + expect(html).toContain('W-002'); + expect(html).toContain('W-003'); + expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2); + // The wagon count must agree with the rows the reader can see. + expect(html).toContain('3 (2 empty)'); + }); + + it('lists an empty wagon on the import document and marks it EMPTY', () => { + const loadList = { + generatedAt: '2026-07-17T08:00:00.000Z', + trainScheduleId: 'schedule-1', + trainNumber: '8002', + route: 'Djibouti → Indode', + origin: 'Djibouti Port', + destination: 'Indode', + totalBookings: 1, + wagons: [ + { sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] }, + { sequenceNo: 2, wagonNumber: 'W-002', allocations: [] }, + ], + operation: { status: {} }, + }; + + const html = (service as never as { + buildImportLoadListHtml: (l: unknown) => string; + }).buildImportLoadListHtml(loadList); + + expect(countRows(html)).toBe(2); + expect(html).toContain('W-002'); + expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1); + expect(html).toContain('2 (1 empty)'); + }); + + it('renders wagons in consist order regardless of the order the relation returns', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { + wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])], + }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown) => string; + }).buildExportLoadListHtml(schedule); + + expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002')); + expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003')); + }); + + it('omits the empty-count suffix when every wagon is loaded', () => { + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] }, + scheduleBookings: [], + }; + + const html = (service as never as { + buildExportLoadListHtml: (s: unknown) => string; + }).buildExportLoadListHtml(schedule); + + expect(html).not.toContain('empty)'); + expect(html).not.toContain('EMPTY'); + }); + }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index cccf9e57e..9d426077e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2686,19 +2686,24 @@ export class TrainSchedulingService { origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, totalBookings: schedule.scheduleBookings?.length ?? 0, - wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({ - sequenceNo: wagon.sequenceNo, - wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, - allocations: (wagon.allocations ?? []).map((allocation) => ({ - bookingId: allocation.bookingId, - bookingReference: allocation.booking?.reference ?? null, - loadType: allocation.loadType ?? null, - allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, - containerNumbers: (allocation.containerItems ?? []) - .map((item) => item.containerNumber) - .filter(Boolean), + // Every wagon on the train set, loaded or not, in consist order. An empty + // wagon has an empty `allocations` array — it is still part of the train + // and still belongs on the marshalling document. + wagons: [...(schedule.trainSet?.wagons ?? [])] + .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)) + .map((wagon) => ({ + sequenceNo: wagon.sequenceNo, + wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + allocations: (wagon.allocations ?? []).map((allocation) => ({ + bookingId: allocation.bookingId, + bookingReference: allocation.booking?.reference ?? null, + loadType: allocation.loadType ?? null, + allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0, + containerNumbers: (allocation.containerItems ?? []) + .map((item) => item.containerNumber) + .filter(Boolean), + })), })), - })), operation: await this.getImportDjiboutiOperation(schedule.id), }; } @@ -2748,9 +2753,33 @@ export class TrainSchedulingService { const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); - const rows = (schedule.trainSet?.wagons ?? []) - .flatMap((wagon) => - (wagon.allocations ?? []).map((allocation) => { + // The document is checked against the physical train, so it has to run in + // consist order — the relation comes back unordered. + const wagons = [...(schedule.trainSet?.wagons ?? [])].sort( + (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), + ); + const rows = wagons + .flatMap((wagon) => { + // Wagon identity is the same on every row the wagon produces, loaded or not. + const wagonCells = `${esc(wagon.sequenceNo)} + ${esc(wagon.physicalWagon?.wagonNumber)} + ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} + ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} + ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} + ${esc(Number(wagon.capacityTons || 0).toFixed(3))}`; + const allocations = wagon.allocations ?? []; + // An empty wagon still runs in the consist, so it still gets a line. Staff + // check this document against the physical train — a wagon with no row + // reads as a wagon that is not there, and the count stops matching. + if (allocations.length === 0) { + return [ + ` + ${wagonCells} + EMPTY — no cargo allocated + `, + ]; + } + return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const company = booking?.company as Record | null | undefined; const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; @@ -2760,12 +2789,7 @@ export class TrainSchedulingService { const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', '); const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` - ${esc(wagon.sequenceNo)} - ${esc(wagon.physicalWagon?.wagonNumber)} - ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} - ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} - ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} - ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${wagonCells} ${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)} ${esc(booking?.companyId)} ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} @@ -2773,10 +2797,11 @@ export class TrainSchedulingService { ${esc(chassisNumbers)} ${esc(sealNumbers)} `; - }), - ) + }); + }) .join(''); - const totalWeight = (schedule.trainSet?.wagons ?? []).reduce( + const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length; + const totalWeight = wagons.reduce( (sum, wagon) => sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, @@ -2804,6 +2829,8 @@ export class TrainSchedulingService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } + tr.empty td { background: #f8fafc; color: #64748b; } + tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } .line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; } @@ -2831,7 +2858,7 @@ export class TrainSchedulingService {
Total loaded weight${esc(totalWeight.toFixed(3))} T
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
-
Wagons${esc(schedule.trainSet?.wagons?.length ?? 0)}
+
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Bookings${esc(schedule.scheduleBookings?.length ?? 0)}
Status${esc(schedule.status)}
Direction${esc(schedule.direction)}
@@ -2855,7 +2882,7 @@ export class TrainSchedulingService { - ${rows || 'No wagon allocations found for this export train.'} + ${rows || 'No wagons on this train set.'} @@ -2898,19 +2925,31 @@ export class TrainSchedulingService { sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); + const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; const allocationRows = loadList.wagons - .flatMap((wagon) => - wagon.allocations.map( + .flatMap((wagon) => { + const wagonCells = `${esc(wagon.sequenceNo)} + ${esc(wagon.wagonNumber)}`; + // An empty wagon still runs in the consist, so it still gets a line — see + // buildExportLoadListHtml. + if (wagon.allocations.length === 0) { + return [ + ` + ${wagonCells} + EMPTY — no cargo allocated + `, + ]; + } + return wagon.allocations.map( (allocation) => ` - ${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)} + ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `, - ), - ) + ); + }) .join(''); return ` @@ -2942,6 +2981,8 @@ export class TrainSchedulingService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; } .num { text-align: right; } + tr.empty td { background: #f8fafc; color: #64748b; } + tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; } .notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; } .signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; } .line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; } @@ -2968,7 +3009,7 @@ export class TrainSchedulingService {
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
-
Wagons${esc(loadList.wagons.length)}
+
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
@@ -2996,7 +3037,7 @@ export class TrainSchedulingService { - ${allocationRows || 'No wagon allocations found for this train.'} + ${allocationRows || 'No wagons on this train set.'} diff --git a/apps/edr-freight-api/src/seed/indode-facility.seeder.ts b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts index 4b4ae23f0..763bc6c54 100644 --- a/apps/edr-freight-api/src/seed/indode-facility.seeder.ts +++ b/apps/edr-freight-api/src/seed/indode-facility.seeder.ts @@ -13,11 +13,13 @@ const INDODE_FACILITY = { facilityType: 'DRY_PORT' as const, facilityStatus: 'ACTIVE' as const, locationName: 'Indode', - country: 'Djibouti', - city: 'Djibouti', - address: 'Indode, Djibouti', - latitude: 11.5447, - longitude: 43.145, + // Indode is the Gelan Multipurpose Port outside Addis — the yard carries it as + // KALITY, country Ethiopia. It was seeded as Djibouti, which is the wrong end + // of the line. Coordinates are left unset rather than guessed; fill them in + // when the real position is to hand. + country: 'Ethiopia', + city: 'Addis Ababa', + address: 'Indode (Gelan), Addis Ababa, Ethiopia', capacity: 50000, isActive: true, notes: 'Primary dry port for container consolidation and distribution', @@ -72,20 +74,22 @@ export class IndodeFacilitySeeder { const yardRepo = manager.getRepository(WarehouseYard); const zoneRepo = manager.getRepository(WarehouseZone); - // Ensure facility exists - const facility = await facilityRepo.findOne({ + // Ensure facility exists. An existing facility row is not proof the + // warehouses under it survived, so reuse it and carry on rather than + // returning — otherwise a facility with no warehouses stays that way. + const existing = await facilityRepo.findOne({ where: { code: INDODE_FACILITY.code }, }); - if (facility) { - this.logger.log('Indode facility already exists, skipping seed'); - return; + let savedFacility: Facility; + if (existing) { + savedFacility = await facilityRepo.save({ ...existing, ...INDODE_FACILITY }); + this.logger.log(`Facility ${savedFacility.code} already exists, reusing`); + } else { + savedFacility = await facilityRepo.save(facilityRepo.create(INDODE_FACILITY)); + this.logger.log(`Created facility: ${savedFacility.code}`); } - const newFacility = facilityRepo.create(INDODE_FACILITY); - const savedFacility = await facilityRepo.save(newFacility); - this.logger.log(`Created facility: ${savedFacility.code}`); - // Create warehouses for the facility for (const warehouseData of WAREHOUSES) { try { diff --git a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts index 47754bd93..1822a877f 100644 --- a/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts +++ b/apps/edr-freight-api/src/seed/yard-facilities.seeder.ts @@ -16,12 +16,19 @@ import { DataSource } from 'typeorm'; * Djibouti and `NEGAD_FY_BCC` in Ethiopia, currently inactive) and it is not yet * settled which is the intercity facility. */ -const FACILITY_YARDS: Array<{ code: string; facility: string; hasWarehouse: boolean }> = [ - { code: 'KALITY', facility: 'Indode', hasWarehouse: true }, - { code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false }, - { code: 'MOJO', facility: 'Modjo', hasWarehouse: false }, - { code: 'ADAMA', facility: 'Adama', hasWarehouse: false }, - { code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false }, +const FACILITY_YARDS: Array<{ + code: string; + facility: string; + hasWarehouse: boolean; + handlesContainer: boolean; +}> = [ + // Containers need a reach stacker or gantry — only these three are equipped. + // Bulk needs far less, so every facility handles it. + { code: 'KALITY', facility: 'Indode', hasWarehouse: true, handlesContainer: true }, + { code: 'MOJO', facility: 'Modjo', hasWarehouse: false, handlesContainer: true }, + { code: 'DIRE_DAWA', facility: 'Dire Dawa', hasWarehouse: false, handlesContainer: true }, + { code: 'LEGACY_DEST', facility: 'Sebeta', hasWarehouse: false, handlesContainer: false }, + { code: 'ADAMA', facility: 'Adama', hasWarehouse: false, handlesContainer: false }, ]; @Injectable() @@ -35,7 +42,7 @@ export class YardFacilitiesSeeder { * yards — a missing code is logged and skipped rather than invented. */ async run(): Promise { - for (const { code, facility, hasWarehouse } of FACILITY_YARDS) { + for (const { code, facility, hasWarehouse, handlesContainer } of FACILITY_YARDS) { const [yard]: Array<{ id: string }> = await this.dataSource.query( `SELECT id FROM freight.yards WHERE code = $1 AND deleted_at IS NULL`, [code], @@ -53,11 +60,15 @@ export class YardFacilitiesSeeder { ); await this.dataSource.query( - `INSERT INTO freight.yard_facilities (yard_id, has_warehouse, equipment_notes) - VALUES ($1, $2, $3) + `INSERT INTO freight.yard_facilities + (yard_id, has_warehouse, handles_container, handles_bulk, equipment_notes) + VALUES ($1, $2, $3, true, $4) ON CONFLICT (yard_id) WHERE deleted_at IS NULL - DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, updated_at = NOW()`, - [yard.id, hasWarehouse, `${facility} load/unload facility`], + DO UPDATE SET has_warehouse = EXCLUDED.has_warehouse, + handles_container = EXCLUDED.handles_container, + handles_bulk = EXCLUDED.handles_bulk, + updated_at = NOW()`, + [yard.id, hasWarehouse, handlesContainer, `${facility} load/unload facility`], ); } this.logger.log( diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 699706654..9d5dfe65e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { Alert, Badge, @@ -72,6 +72,7 @@ export type ClearanceViewLike = Pick< | "linkedBookingId" | "riskLevel" | "riskAssignedAt" + | "riskHistory" | "secondDuty" | "importReleaseGranted" > & { operationReady?: boolean }; @@ -1040,11 +1041,28 @@ function RiskStep({ done: boolean; onChanged?: () => void; }) { - const [level, setLevel] = useState("GREEN"); + const assigned = done || Boolean(clearance.riskLevel); + // Duty is advised off the risk level, so once that is done the decision is + // final. Until then a mis-assigned level must stay correctable — the server + // overwrites the milestone metadata on reassignment. Mirrors AssignRiskCard. + const locked = isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED"); + + const [level, setLevel] = useState(clearance.riskLevel ?? "GREEN"); const [loading, setLoading] = useState(false); - if (done || clearance.riskLevel) { - return ( + // The clearance view loads (and refetches after a reassignment) after first + // render, so mirror the persisted level onto the control whenever it changes — + // otherwise reopening the step offers GREEN whatever is actually assigned. + useEffect(() => { + if (clearance.riskLevel) setLevel(clearance.riskLevel); + }, [clearance.riskLevel]); + + // Only the decisions before the current one — the badge above already states + // the level in force, so repeating it as a trail entry reads as a duplicate. + const priorDecisions = (clearance.riskHistory ?? []).slice(0, -1); + + const assignedSummary = assigned ? ( + - ); + {priorDecisions.length > 0 ? ( + + + Previously + + {priorDecisions.map((entry, index) => ( + + {entry.level} + {" · "} + {new Date(entry.assignedAt).toLocaleString()} + {entry.assignedBy ? ` · ${entry.assignedBy}` : ""} + {entry.note ? ` · ${entry.note}` : ""} + + ))} + + ) : null} + + ) : null; + + // Assigned and final: the badge is all that is left to show. + if (assigned && (locked || !canAct || !bookingId)) { + return assignedSummary; } // Customs cannot rate cargo still under transit — the server rejects the - // assignment until the T1 is closed, so do not offer the control yet. - if (!clearance.t1?.closed) { + // assignment until the T1 is closed, so do not offer the control yet. Skipped + // once a level exists: risk cannot have been assigned without a closed T1, so + // a still-open T1 here is stale data and must not hide the assigned badge. + if (!assigned && !clearance.t1?.closed) { return ( + {assignedSummary} - The customer sees the assigned risk level. + {assigned + ? "Correctable until duty is advised. The customer sees the assigned risk level." + : "The customer sees the assigned risk level."} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx index 6a45be0f2..bce1f52e3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/IntercityPage.tsx @@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) => const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT"; const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED"; -/** Yards with no equipment can never load/unload — surface it before the train arrives. */ -function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) { +/** + * A yard that can't handle THIS booking's cargo can never work it — surface that + * while the train is still coming, not when the load is refused. Containers need + * a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of + * them. + */ +function FacilityCell({ + yard, + has, + freightType, +}: { + yard: string | null; + has: boolean | null; + freightType: string | null; +}) { if (!yard) return ; if (has) return {yard}; return ( @@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { {r.customer ?? "—"} - + {atOrigin(r) && isWaiting(r) && ( train here @@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) { - + {atDestination(r) && isRiding(r) && ( train here @@ -215,7 +228,7 @@ export default function IntercityPage() { } label="Completed" value={done.length} /> } - label="No facility" + label="Cannot handle" value={blocked.length} color={blocked.length > 0 ? "red" : undefined} /> @@ -229,8 +242,9 @@ export default function IntercityPage() { title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`} mb="md" > - Their origin or destination yard has no load/unload facility. Mark the yard as - a facility in Configuration → Yards, or the cargo can never be worked there. + Their origin or destination yard cannot handle that cargo — no facility, or no + equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at + any facility. Adjust the yard in Configuration → Yards. )} diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 362d3c6ef..299c25dc9 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -407,6 +407,8 @@ export interface ContractClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: ClearanceSecondDuty | null; importReleaseGranted?: boolean; @@ -440,9 +442,27 @@ export type MilestoneStatus = "PENDING" | "COMPLETED" | "SKIPPED"; export const CUSTOMS_RISK_LEVELS = ["GREEN", "YELLOW", "RED"] as const; export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number]; +/** + * One customs risk decision. The level stays correctable until duty is advised + * off it and is customer-visible, so every assignment is kept rather than + * overwritten. + */ +export interface RiskAssignmentRecord { + level: CustomsRiskLevel; + /** The level this replaced; absent on the first assignment. */ + previousLevel?: CustomsRiskLevel; + assignedAt: string; + assignedByUserId?: string | null; + /** Display name resolved at assignment time, so the trail never shows a UUID. */ + assignedBy?: string | null; + note?: string | null; +} + /** Structured payload carried by RISK_ASSIGNED / DUTY_TAXES_ADVISED milestones. */ export interface MilestoneMetadata { riskLevel?: CustomsRiskLevel; + /** Every risk decision, oldest first; the last entry matches `riskLevel`. */ + riskHistory?: RiskAssignmentRecord[]; dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string; diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index 8b50fecba..1dabbca19 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -780,6 +780,8 @@ export interface ClearanceView { /** Customs risk level assigned by GL ET (import; visible to the customer). */ riskLevel?: string | null; riskAssignedAt?: string | null; + /** Every risk decision, oldest first; the last entry is the current level. */ + riskHistory?: import("./contracts").RiskAssignmentRecord[]; /** Post-arrival additional duty/tax round (import). */ secondDuty?: import("./contracts").ClearanceSecondDuty | null; importReleaseGranted?: boolean;