mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
feat(api): accept several yards on each end of a route filter
Bookings, contracts and train schedules all validated originYardId / destinationYardId (originStationId / destinationStationId) as a single @IsUUID and matched with `=`, so a list could be narrowed to exactly one lane. The filter bar can now ask for several stations per end, and each end independently, which needs the same on the server. @IdListParam() is the shared transform: one id, a comma-separated list, or a repeated query param, always landing as a string[]. It yields undefined rather than [] when nothing usable is left — a repository that branches on `?.length` can then never hand TypeORM an empty array, which compiles to the syntax error IN (). It stays backwards compatible with the single-value form, so existing deep links and saved views are unaffected. Matching moves to IN (:...ids) — for contracts inside the two existing EXISTS subqueries, which keeps meaning "has a route from one of these origins" AND "has a route to one of these destinations", not necessarily the same route. All three statements were EXPLAIN-validated against edr_dev.
This commit is contained in:
@@ -48,8 +48,10 @@ export interface ContractListFilterOptions {
|
||||
hasClearanceDocuments?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
originYardId?: string;
|
||||
destinationYardId?: string;
|
||||
/** Any of these origin yards (OR). ANDed with `destinationYardId`. */
|
||||
originYardId?: string[];
|
||||
/** Any of these destination yards (OR). ANDed with `originYardId`. */
|
||||
destinationYardId?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -494,20 +496,25 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
// Routes are one-to-many (a contract can list several lanes), so origin
|
||||
// and destination each need their own EXISTS — a plain join would
|
||||
// duplicate the contract row per matching route.
|
||||
if (omit !== 'originYardId' && options.originYardId) {
|
||||
// Each end is an OR-list, the two ends AND together. Note this still means
|
||||
// "has a route from one of these origins" AND "has a route to one of these
|
||||
// destinations" — not necessarily the SAME route, which is what the two
|
||||
// separate EXISTS have always meant and what the filter bar's two
|
||||
// independent pickers describe.
|
||||
if (omit !== 'originYardId' && options.originYardId?.length) {
|
||||
qb.andWhere(
|
||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_o ' +
|
||||
'WHERE cr_o.contract_id = contract.id AND cr_o.deleted_at IS NULL ' +
|
||||
'AND cr_o.origin_yard_id = :originYardId)',
|
||||
{ originYardId: options.originYardId },
|
||||
'AND cr_o.origin_yard_id IN (:...originYardIds))',
|
||||
{ originYardIds: options.originYardId },
|
||||
);
|
||||
}
|
||||
if (omit !== 'destinationYardId' && options.destinationYardId) {
|
||||
if (omit !== 'destinationYardId' && options.destinationYardId?.length) {
|
||||
qb.andWhere(
|
||||
'EXISTS (SELECT 1 FROM freight.contract_routes cr_d ' +
|
||||
'WHERE cr_d.contract_id = contract.id AND cr_d.deleted_at IS NULL ' +
|
||||
'AND cr_d.destination_yard_id = :destinationYardId)',
|
||||
{ destinationYardId: options.destinationYardId },
|
||||
'AND cr_d.destination_yard_id IN (:...destinationYardIds))',
|
||||
{ destinationYardIds: options.destinationYardId },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { CONTRACT_STATUSES, CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
import { IdListParam } from '../../../common/dto/id-list.transform';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
@@ -62,20 +63,22 @@ export class FilterContractDto {
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Only contracts with a route starting at this yard.',
|
||||
description:
|
||||
'Only contracts with a route starting at one of these yards — a single id or a comma-separated list.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
originYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Only contracts with a route ending at this yard.',
|
||||
description:
|
||||
'Only contracts with a route ending at one of these yards — a single id or a comma-separated list. ANDed with originYardId.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
@IdListParam()
|
||||
@IsUUID(undefined, { each: true })
|
||||
destinationYardId?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter contracts created on/after this date (ISO)' })
|
||||
@IsOptional()
|
||||
|
||||
Reference in New Issue
Block a user