mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user