feat: add hazardous goods declaration feature

- Introduced HazardDeclarationPanel component to display dangerous goods declaration details.
- Updated URL constants to include CLEARANCE_PROCEED endpoint for re-requesting operations.
- Enhanced permissions to include hazardous approval roles for contract approvals.
- Integrated HazardDeclarationPanel into ContractRequestDetailPage and ContractClearanceDetailPage.
- Added proceedToOperation method in bookings service for handling operation re-requests.
- Updated contract forms and schemas to include hazard class and UN number fields.
- Implemented validation for hazardous contracts in the contract creation flow.
- Added expiry notice functionality for contracts nearing validity end.
- Created tests for expiry notice calculations and labels.
- Updated UI components to reflect hazardous cargo information and validation errors.
This commit is contained in:
Marshal
2026-07-25 21:10:09 +00:00
parent fde5e6de4b
commit 9a1c8e5603
41 changed files with 1663 additions and 99 deletions

View File

@@ -0,0 +1,80 @@
import { ConflictException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { RoutesService } from './routes.service';
import type { RoutesRepository } from './routes.repository';
type StopSeq = Array<{ yardId: string; sequenceNo: number }>;
/** DataSource stub whose Route repository returns the given existing routes. */
const serviceWith = (
existing: Array<{ id: string; milestones: StopSeq }>,
): RoutesService => {
const dataSource = {
getRepository: () => ({ find: async () => existing }),
} as unknown as DataSource;
return new RoutesService(dataSource, {} as RoutesRepository);
};
const assertNotDuplicate = (
service: RoutesService,
yardIds: string[],
excludeRouteId?: string,
): Promise<void> =>
(
service as unknown as {
assertNotDuplicate: (
m: Array<{ yardId: string }>,
id?: string,
) => Promise<void>;
}
).assertNotDuplicate(
yardIds.map((yardId) => ({ yardId })),
excludeRouteId,
);
describe('RoutesService duplicate guard', () => {
const addisAdamaDire: StopSeq = [
{ yardId: 'addis', sequenceNo: 1 },
{ yardId: 'adama', sequenceNo: 2 },
{ yardId: 'dire', sequenceNo: 3 },
];
it('rejects an identical stop sequence', async () => {
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
).rejects.toBeInstanceOf(ConflictException);
});
it('allows the same endpoints with a different corridor', async () => {
// Same origin + destination, but skipping Adama is a genuinely other route.
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'dire']),
).resolves.toBeUndefined();
});
it('does not flag the route being edited against itself', async () => {
const service = serviceWith([{ id: 'r1', milestones: addisAdamaDire }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire'], 'r1'),
).resolves.toBeUndefined();
});
it('compares stops by sequence, not storage order', async () => {
const shuffled: StopSeq = [
{ yardId: 'dire', sequenceNo: 3 },
{ yardId: 'addis', sequenceNo: 1 },
{ yardId: 'adama', sequenceNo: 2 },
];
const service = serviceWith([{ id: 'r1', milestones: shuffled }]);
await expect(
assertNotDuplicate(service, ['addis', 'adama', 'dire']),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { TrainScheduleStatus } from '@edr/types';
import { DataSource, In } from 'typeorm';
import { DataSource, In, Not } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -15,7 +15,7 @@ import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity';
import { formatRouteLabel, Route } from './entities/route.entity';
import { formatRouteLabel, Route, type RouteStatus } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
/** Order-insensitive key: distances are symmetric. */
@@ -88,6 +88,7 @@ export class RoutesService {
async create(dto: CreateRouteDto): Promise<Route> {
const validated = await this.validateMilestones(dto.milestones);
await this.assertNotDuplicate(validated.milestones);
const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save(
@@ -123,6 +124,11 @@ export class RoutesService {
? await this.validateMilestones(dto.milestones)
: null;
// An edit can collide with another route just as easily as a create can.
if (milestoneInput) {
await this.assertNotDuplicate(milestoneInput.milestones, id);
}
// Milestones or endpoints are about to be rewritten — reject if any
// non-terminal schedule still references this route, otherwise its stop list
// and distances would silently shift under a live plan. Status-only /
@@ -187,6 +193,51 @@ export class RoutesService {
return this.findById(id);
}
/**
* A route IS its ordered stop list — "Addis → Adama → Dire Dawa" and
* "Addis → Dire Dawa" share endpoints but are different corridors. So the
* duplicate test compares the full yard sequence, not just origin/destination.
*
* Decommissioned routes (STOP_WORKING) are ignored: replacing a retired
* corridor with a fresh one is exactly what an admin does after deactivating,
* and there is no reactivate action to fall back on.
*/
private async assertNotDuplicate(
milestones: Array<{ yardId: string }>,
excludeRouteId?: string,
): Promise<void> {
const signature = milestones.map((m) => m.yardId).join('>');
const candidates = await this.dataSource.getRepository(Route).find({
where: {
originYardId: milestones[0].yardId,
destinationYardId: milestones[milestones.length - 1].yardId,
status: Not<RouteStatus>('STOP_WORKING'),
},
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
});
const duplicate = candidates.find((route) => {
if (route.id === excludeRouteId) return false;
const stops = [...(route.milestones ?? [])]
.sort((a, b) => a.sequenceNo - b.sequenceNo)
.map((m) => m.yardId)
.join('>');
return stops === signature;
});
if (duplicate) {
throw new ConflictException(
`This route already exists: ${formatRouteLabel(duplicate)}. ` +
'Edit the existing route instead of creating a duplicate.',
);
}
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');