mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 00:03:26 +00:00
booking operations and trains scheduling also allocations
This commit is contained in:
@@ -159,18 +159,20 @@ export class BookingPricingService {
|
||||
|
||||
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
|
||||
const containers = await Promise.all(
|
||||
(booking.bookingContainers ?? []).map(async (bc) => {
|
||||
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||
const vgm = Number(bc.vgmPerUnitTons);
|
||||
const qty = bc.quantity;
|
||||
return {
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: qty,
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
};
|
||||
}),
|
||||
(booking.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map(async (bc) => {
|
||||
const ct = await this.containerTypesService.findById(bc.containerTypeId);
|
||||
const vgm = Number(bc.vgmPerUnitTons);
|
||||
const qty = bc.quantity;
|
||||
return {
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: qty,
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return {
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
@@ -179,6 +181,7 @@ export class BookingPricingService {
|
||||
paymentCurrency: booking.paymentCurrency,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
isHazardous: booking.isHazardous,
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation: booking.allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
containers,
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
} from '../../common/resolve-auth-user-id';
|
||||
import { assertFreightPermission } from '../../common/freight-permission.util';
|
||||
|
||||
@ApiTags('bookings')
|
||||
@Controller('bookings')
|
||||
@@ -75,10 +76,12 @@ export class BookingsController {
|
||||
create(
|
||||
@Body() dto: CreateBookingDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Request() req: { user?: { id?: string; sub?: string } },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const userId = req.user?.id ?? req.user?.sub;
|
||||
return this.bookingsService.create(dto, files ?? [], userId);
|
||||
if (dto.isGovernment) {
|
||||
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
|
||||
}
|
||||
return this.bookingsService.create(dto, files ?? [], user?.id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@@ -244,6 +247,20 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/government-expedite')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
|
||||
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
|
||||
async governmentExpedite(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.bookingsService.governmentExpedite(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.bookings.approveLineStaff,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
|
||||
function mockQueryBuilder() {
|
||||
const qb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
describe('BookingsRepository', () => {
|
||||
let repository: jest.Mocked<Repository<Booking>>;
|
||||
let dataSource: { getRepository: jest.Mock };
|
||||
let bookingsRepository: BookingsRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
createQueryBuilder: jest.fn(),
|
||||
} as unknown as jest.Mocked<Repository<Booking>>;
|
||||
dataSource = { getRepository: jest.fn() };
|
||||
bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource);
|
||||
});
|
||||
|
||||
it('findEligibleForScheduling does not filter by schedule date', async () => {
|
||||
const qb = mockQueryBuilder();
|
||||
const bookings = [
|
||||
{ id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') },
|
||||
{ id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') },
|
||||
];
|
||||
qb.getMany.mockResolvedValue(bookings);
|
||||
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||
|
||||
const result = await bookingsRepository.findEligibleForScheduling({
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
freightType: 'CONTAINER',
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
const dateFilters = qb.andWhere.mock.calls.filter(([clause]) =>
|
||||
String(clause).includes('scheduled_date'),
|
||||
);
|
||||
expect(dateFilters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => {
|
||||
const qb = mockQueryBuilder();
|
||||
repository.createQueryBuilder.mockReturnValue(qb as never);
|
||||
dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) });
|
||||
|
||||
await bookingsRepository.findAllPaginated({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
assignedToSchedule: 'false',
|
||||
});
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS'));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
@@ -9,6 +10,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
@@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
|
||||
export interface BookingListFilterOptions {
|
||||
statuses?: string[];
|
||||
status?: string;
|
||||
schedulingStatuses?: string[];
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
companyId?: string;
|
||||
contractType?: string;
|
||||
serviceTypeId?: string;
|
||||
@@ -427,17 +431,37 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
|
||||
const sortField =
|
||||
options.sortBy === 'priorityScore'
|
||||
? 'booking.priorityScore'
|
||||
: 'booking.createdAt';
|
||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||
if (options.sortBy === 'isGovernment') {
|
||||
qb.orderBy('booking.isGovernment', 'DESC')
|
||||
.addOrderBy('booking.priorityScore', 'DESC')
|
||||
.addOrderBy('booking.scheduledDate', 'ASC');
|
||||
} else {
|
||||
const sortField =
|
||||
options.sortBy === 'priorityScore'
|
||||
? 'booking.priorityScore'
|
||||
: options.sortBy === 'scheduledDate'
|
||||
? 'booking.scheduledDate'
|
||||
: 'booking.createdAt';
|
||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||
}
|
||||
|
||||
const [items, total] = await qb
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
|
||||
if (items.length) {
|
||||
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||
where: { bookingId: In(items.map((item) => item.id)) },
|
||||
select: { bookingId: true, trainScheduleId: true },
|
||||
});
|
||||
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
|
||||
for (const item of items) {
|
||||
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
|
||||
scheduleByBooking.get(item.id) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
@@ -556,6 +580,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
} else if (options.consolidationPaired === 'false') {
|
||||
qb.andWhere('booking.consolidation_partner_id IS NULL');
|
||||
}
|
||||
if (options.schedulingStatuses?.length) {
|
||||
qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', {
|
||||
schedulingStatuses: options.schedulingStatuses,
|
||||
});
|
||||
}
|
||||
if (options.assignedToSchedule === 'true') {
|
||||
qb.andWhere(
|
||||
`EXISTS (
|
||||
SELECT 1 FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
|
||||
)`,
|
||||
);
|
||||
} else if (options.assignedToSchedule === 'false') {
|
||||
qb.andWhere(
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
|
||||
)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
|
||||
@@ -605,4 +649,99 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
}
|
||||
return repo.save(repo.create(data));
|
||||
}
|
||||
|
||||
private bookingRepo(manager?: EntityManager) {
|
||||
return manager ? manager.getRepository(Booking) : this.repository;
|
||||
}
|
||||
|
||||
findEligibleForScheduling(options: {
|
||||
freightType?: string;
|
||||
originStationId?: string;
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.originYard', 'originYard')
|
||||
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.leftJoin(
|
||||
TrainScheduleBooking,
|
||||
'scheduleBooking',
|
||||
'scheduleBooking.booking_id = booking.id',
|
||||
)
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
if (options.freightType) {
|
||||
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
|
||||
}
|
||||
|
||||
if (options.originStationId) {
|
||||
qb.andWhere('booking.originYardId = :originStationId', {
|
||||
originStationId: options.originStationId,
|
||||
});
|
||||
}
|
||||
if (options.destinationStationId) {
|
||||
qb.andWhere('booking.destinationYardId = :destinationStationId', {
|
||||
destinationStationId: options.destinationStationId,
|
||||
});
|
||||
}
|
||||
if (options.schedulingStatus) {
|
||||
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
|
||||
schedulingStatus: options.schedulingStatus,
|
||||
});
|
||||
}
|
||||
|
||||
return qb
|
||||
.orderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.scheduled_date', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
where: { id: In(bookingIds) },
|
||||
relations: {
|
||||
company: true,
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
},
|
||||
order: { priorityScore: 'DESC', createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async updateSchedulingFields(
|
||||
bookingId: string,
|
||||
fields: Partial<
|
||||
Pick<
|
||||
Booking,
|
||||
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
|
||||
>
|
||||
>,
|
||||
manager?: EntityManager,
|
||||
): Promise<void> {
|
||||
await this.bookingRepo(manager).update(bookingId, fields as never);
|
||||
}
|
||||
|
||||
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
|
||||
await this.updateSchedulingFields(
|
||||
bookingId,
|
||||
{
|
||||
schedulingStatus: SchedulingStatus.Holding,
|
||||
holdStartedAt: now,
|
||||
holdExpiresAt: expires,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { FilesService } from '../files/files.service';
|
||||
@@ -64,6 +65,7 @@ export class BookingsService {
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
isHazardous?: boolean;
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
containers: CreateBookingContainerDto[];
|
||||
@@ -92,6 +94,7 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
isGovernment: dto.isGovernment ?? false,
|
||||
allowConsolidation:
|
||||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
@@ -178,8 +181,15 @@ export class BookingsService {
|
||||
// customerId = customer.id;
|
||||
// }
|
||||
|
||||
let companyId = dto.companyId;
|
||||
if (!companyId) {
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
if (!dto.governmentInstitution?.trim()) {
|
||||
throw new BadRequestException('governmentInstitution is required for government bookings');
|
||||
}
|
||||
companyId = dto.companyId ?? null;
|
||||
} else if (!companyId) {
|
||||
if (!userId) {
|
||||
throw new BadRequestException(
|
||||
'companyId is required or must be resolvable from auth token',
|
||||
@@ -209,6 +219,7 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
tradeDirection: dto.tradeDirection,
|
||||
isHazardous: dto.isHazardous,
|
||||
isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: dto.shippingLineId,
|
||||
containers,
|
||||
@@ -220,7 +231,9 @@ export class BookingsService {
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
reference,
|
||||
companyId,
|
||||
companyId: companyId ?? null,
|
||||
isGovernment,
|
||||
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
|
||||
trainId: dto.trainId,
|
||||
contractType: dto.contractType,
|
||||
previousContractId: dto.previousContractId,
|
||||
@@ -300,12 +313,13 @@ export class BookingsService {
|
||||
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
|
||||
let containers =
|
||||
dto.containers ??
|
||||
existing.bookingContainers?.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
})) ??
|
||||
[];
|
||||
(existing.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
}));
|
||||
|
||||
let cargoTypeId =
|
||||
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
|
||||
@@ -403,6 +417,19 @@ export class BookingsService {
|
||||
return { booking, warnings };
|
||||
}
|
||||
|
||||
/** Parse comma-separated scheduling status query values. */
|
||||
private parseSchedulingStatusFilter(filter: FilterBookingDto): {
|
||||
schedulingStatuses?: string[];
|
||||
} {
|
||||
const raw = filter.schedulingStatuses;
|
||||
if (!raw) return {};
|
||||
const schedulingStatuses = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return schedulingStatuses.length ? { schedulingStatuses } : {};
|
||||
}
|
||||
|
||||
/** Parse comma-separated or repeated status query values. */
|
||||
private parseStatusFilter(filter: FilterBookingDto): {
|
||||
statuses?: string[];
|
||||
@@ -433,11 +460,14 @@ export class BookingsService {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 20;
|
||||
const statusFilter = this.parseStatusFilter(filter);
|
||||
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
|
||||
|
||||
return this.bookingsRepository.findAllPaginated({
|
||||
page,
|
||||
pageSize,
|
||||
...statusFilter,
|
||||
...schedulingStatusFilter,
|
||||
assignedToSchedule: filter.assignedToSchedule,
|
||||
companyId: filter.companyId,
|
||||
contractType: filter.contractType,
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
@@ -695,12 +725,13 @@ export class BookingsService {
|
||||
return true;
|
||||
}
|
||||
if (dto.containers !== undefined) {
|
||||
const existingContainers =
|
||||
existing.bookingContainers?.map((bc) => ({
|
||||
const existingContainers = (existing.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
|
||||
})) ?? [];
|
||||
}));
|
||||
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
|
||||
return true;
|
||||
}
|
||||
@@ -714,4 +745,32 @@ export class BookingsService {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
|
||||
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
|
||||
const booking = await this.findById(id);
|
||||
if (!booking.isGovernment) {
|
||||
throw new BadRequestException('Only government bookings can be expedited');
|
||||
}
|
||||
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
|
||||
if (blocked.includes(booking.status)) {
|
||||
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(id, {
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
schedulingStatus: SchedulingStatus.Eligible,
|
||||
holdStartedAt: null,
|
||||
holdExpiresAt: null,
|
||||
});
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
id,
|
||||
`Government booking expedited to PAID by staff (${staffUserId})`,
|
||||
'STAFF_NOTE',
|
||||
staffUserId,
|
||||
);
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,11 +76,12 @@ export class ConsolidationService {
|
||||
}
|
||||
|
||||
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
||||
const lines =
|
||||
booking.bookingContainers?.map((bc) => ({
|
||||
const lines = (booking.bookingContainers ?? [])
|
||||
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
|
||||
.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
})) ?? [];
|
||||
}));
|
||||
return this.slotsFromContainerLines(lines);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
MinLength,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
@@ -66,7 +67,21 @@ export class CreateBookingDto {
|
||||
// @IsUUID()
|
||||
// customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Staff only: government booking flag' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isGovernment?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
|
||||
@ValidateIf((o) => o.isGovernment === true)
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
governmentInstitution?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
|
||||
@ValidateIf((o) => o.isGovernment !== true)
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
companyId?: string;
|
||||
|
||||
@@ -84,8 +84,25 @@ export class FilterBookingDto {
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
|
||||
pageSize?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)',
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
if (Array.isArray(value)) return value.map(String).join(',');
|
||||
return String(value);
|
||||
})
|
||||
schedulingStatuses?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' })
|
||||
@IsOptional()
|
||||
@IsIn(['true', 'false'])
|
||||
assignedToSchedule?: 'true' | 'false';
|
||||
|
||||
@ApiPropertyOptional({ default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment'])
|
||||
sortBy?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||
|
||||
@@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity {
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid' })
|
||||
containerTypeId!: string;
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType)
|
||||
@ManyToOne(() => ContainerType, { nullable: true })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType;
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'smallint' })
|
||||
quantity!: number;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
|
||||
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
|
||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'booking_review_note' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
// import { Customer } from '../../customers/entities/customer.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
@@ -51,6 +52,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
|
||||
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
|
||||
export type FreightType = (typeof FREIGHT_TYPES)[number];
|
||||
|
||||
export const SCHEDULING_STATUSES = [
|
||||
SchedulingStatus.NotScheduled,
|
||||
SchedulingStatus.Holding,
|
||||
SchedulingStatus.Eligible,
|
||||
SchedulingStatus.Scheduled,
|
||||
SchedulingStatus.Dispatched,
|
||||
] as const;
|
||||
|
||||
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
|
||||
|
||||
/** Statuses where the customer may edit booking fields. */
|
||||
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
|
||||
'DRAFT',
|
||||
@@ -69,16 +80,24 @@ export class Booking extends BaseEntity {
|
||||
// @JoinColumn({ name: 'customer_id' })
|
||||
// customer?: Customer;
|
||||
|
||||
@Column({ name: 'company_id', type: 'uuid' })
|
||||
companyId!: string;
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@ManyToOne(() => Company)
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company;
|
||||
company?: Company | null;
|
||||
|
||||
@Column({ name: 'is_government', type: 'boolean', default: false })
|
||||
isGovernment!: boolean;
|
||||
|
||||
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
|
||||
governmentInstitution?: string | null;
|
||||
|
||||
/** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */
|
||||
@Column({ name: 'train_id', type: 'uuid', nullable: true })
|
||||
trainId?: string | null;
|
||||
|
||||
/** @deprecated Use train_schedule_bookings for operational scheduling. */
|
||||
@ManyToOne(() => Train, { nullable: true })
|
||||
@JoinColumn({ name: 'train_id' })
|
||||
train?: Train | null;
|
||||
@@ -240,6 +259,21 @@ export class Booking extends BaseEntity {
|
||||
@JoinColumn({ name: 'consolidation_partner_id' })
|
||||
consolidationPartner?: Booking | null;
|
||||
|
||||
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
|
||||
wagonsRequired?: number | null;
|
||||
|
||||
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
|
||||
schedulingStatus!: string;
|
||||
|
||||
@Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true })
|
||||
holdStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
|
||||
holdExpiresAt?: Date | null;
|
||||
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
@OneToMany(() => BookingContainer, (bc) => bc.booking)
|
||||
bookingContainers?: BookingContainer[];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user