mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
feat: Implement general contract booking orders functionality
- Add DTOs for creating booking orders and viewing contract quantities. - Create entities for booking orders and booking order lines. - Implement service for managing general contract operations, including activation after payment and retrieving quantity lines. - Develop UI components for contract detail and list pages, including order placement dialog. - Integrate API service for booking orders, enabling listing and creating orders against contracts. - Enhance contract status display and quantity pool visualization in the UI.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@ApiTags('Booking Orders')
|
||||
@Controller('booking-orders')
|
||||
export class BookingOrdersController {
|
||||
constructor(
|
||||
private readonly ordersService: BookingOrdersService,
|
||||
private readonly generalContractService: GeneralContractService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Place a drawdown order against a general contract' })
|
||||
async create(
|
||||
@Body() dto: CreateBookingOrderDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.ordersService.create(dto, user?.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List orders placed against a contract' })
|
||||
async list(@Query('contractBookingId', ParseUUIDPipe) contractBookingId: string) {
|
||||
return this.ordersService.listByContract(contractBookingId);
|
||||
}
|
||||
|
||||
@Get('contract/:id/pool')
|
||||
@ApiOperation({
|
||||
summary: 'Contracted / ordered / remaining quantities for a general contract',
|
||||
})
|
||||
async pool(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.generalContractService.getQuantityLines(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get a single booking order' })
|
||||
async findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.ordersService.findById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { BookingOrdersController } from './booking-orders.controller';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
import { BookingOrdersService } from './booking-orders.service';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BookingOrder, BookingOrderLine]),
|
||||
BookingsModule,
|
||||
CompaniesModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
],
|
||||
controllers: [BookingOrdersController],
|
||||
providers: [
|
||||
BookingOrdersService,
|
||||
BookingOrdersRepository,
|
||||
GeneralContractService,
|
||||
],
|
||||
exports: [BookingOrdersService, GeneralContractService],
|
||||
})
|
||||
export class BookingOrdersModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
|
||||
@Injectable()
|
||||
export class BookingOrdersRepository extends BaseRepository<BookingOrder> {
|
||||
constructor(
|
||||
@InjectRepository(BookingOrder)
|
||||
repository: Repository<BookingOrder>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Orders placed against a given contract, newest first, with their lines. */
|
||||
findByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
return this.repository.find({
|
||||
where: { contractBookingId },
|
||||
relations: { lines: { containerType: true }, booking: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
override findById(id: string): Promise<BookingOrder | null> {
|
||||
return this.repository.findOne({
|
||||
where: { id },
|
||||
relations: { lines: { containerType: true }, booking: true, contractBooking: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Count this calendar year's orders, for reference generation. */
|
||||
async countByYear(year: number): Promise<number> {
|
||||
const start = new Date(Date.UTC(year, 0, 1));
|
||||
const end = new Date(Date.UTC(year + 1, 0, 1));
|
||||
return this.repository
|
||||
.createQueryBuilder('o')
|
||||
.where('o.createdAt >= :start AND o.createdAt < :end', { start, end })
|
||||
.getCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { BookingOrdersRepository } from './booking-orders.repository';
|
||||
import { CreateBookingOrderDto } from './dto/create-booking-order.dto';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { BookingOrderLine } from './entities/booking-order-line.entity';
|
||||
import { GeneralContractService } from './general-contract.service';
|
||||
|
||||
@Injectable()
|
||||
export class BookingOrdersService {
|
||||
private readonly logger = new Logger(BookingOrdersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly ordersRepository: BookingOrdersRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly generalContractService: GeneralContractService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** Orders placed against a contract, with their lines and child booking. */
|
||||
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
return this.ordersRepository.findByContract(contractBookingId);
|
||||
}
|
||||
|
||||
findById(id: string): Promise<BookingOrder | null> {
|
||||
return this.ordersRepository.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a drawdown order against an ACTIVE general contract.
|
||||
*
|
||||
* Validates the requested quantities against the remaining pool, then spawns a
|
||||
* ONE_TIME child Booking (PAID + FULLY_EXECUTED, inheriting the contract's
|
||||
* route/cargo/service) so it flows through the existing train-scheduling
|
||||
* pipeline. The order row is the ledger entry linking contract → child booking.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateBookingOrderDto,
|
||||
userId?: string,
|
||||
): Promise<BookingOrder> {
|
||||
const contract = await this.bookingsRepository.findById(dto.contractBookingId);
|
||||
if (!contract) {
|
||||
throw new NotFoundException(`Contract ${dto.contractBookingId} not found`);
|
||||
}
|
||||
if (!this.generalContractService.isGeneralContract(contract)) {
|
||||
throw new BadRequestException('Booking is not a general contract');
|
||||
}
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new BadRequestException(
|
||||
`Contract is ${contract.status} — orders can only be placed against an ACTIVE contract`,
|
||||
);
|
||||
}
|
||||
if (contract.expiresAt && contract.expiresAt.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('Contract ordering window has expired');
|
||||
}
|
||||
|
||||
// The customer placing the order must own the contract.
|
||||
if (userId && !(await this.userOwnsContract(userId, contract))) {
|
||||
throw new BadRequestException('You do not have access to this contract');
|
||||
}
|
||||
|
||||
// Validate the route has a departure on the chosen day.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
contract.originYardId,
|
||||
contract.destinationYardId,
|
||||
day,
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
|
||||
// Validate each line against the remaining pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||
if (!poolLine) {
|
||||
throw new BadRequestException(
|
||||
isContainer
|
||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||
: 'This contract has no matching quantity pool',
|
||||
);
|
||||
}
|
||||
if (line.quantity > poolLine.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist the order + its child shipment booking atomically.
|
||||
const order = await this.dataSource.transaction(async (manager) => {
|
||||
const childBooking = await this.spawnChildBooking(contract, dto, manager);
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const orderRow = manager.create(BookingOrder, {
|
||||
reference,
|
||||
contractBookingId: contract.id,
|
||||
bookingId: childBooking.id,
|
||||
companyId: contract.companyId ?? null,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
status: 'PAID',
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
});
|
||||
const savedOrder = await manager.save(orderRow);
|
||||
|
||||
const lines = dto.lines.map((l) =>
|
||||
manager.create(BookingOrderLine, {
|
||||
orderId: savedOrder.id,
|
||||
containerTypeId: isContainer ? (l.containerTypeId ?? null) : null,
|
||||
quantity: l.quantity,
|
||||
}),
|
||||
);
|
||||
await manager.save(lines);
|
||||
savedOrder.lines = lines;
|
||||
return savedOrder;
|
||||
});
|
||||
|
||||
// Feed the child booking into the day-pool batch so it allocates to a train.
|
||||
try {
|
||||
await this.bookingBatchService.processRouteDay({
|
||||
originYardId: contract.originYardId,
|
||||
destinationYardId: contract.destinationYardId,
|
||||
day,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Batch fill after order ${order.reference} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Close the contract once its pool is exhausted.
|
||||
if (await this.generalContractService.isExhausted(contract.id)) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(contract.id, { status: 'CONTRACT_CLOSED' });
|
||||
this.logger.log(
|
||||
`Contract ${contract.reference} CLOSED — quantity exhausted`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await this.ordersRepository.findById(order.id)) ?? order;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the ONE_TIME child booking for an order, inheriting the contract's
|
||||
* shipment context and entering the queue already PAID + FULLY_EXECUTED.
|
||||
*/
|
||||
private async spawnChildBooking(
|
||||
contract: Booking,
|
||||
dto: CreateBookingOrderDto,
|
||||
manager: import('typeorm').EntityManager,
|
||||
): Promise<Booking> {
|
||||
const reference = await this.generateChildBookingReference();
|
||||
const now = new Date();
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
|
||||
// Sum line quantities × the contract's per-unit weight for the child total.
|
||||
const containerByType = new Map(
|
||||
(contract.bookingContainers ?? []).map((c) => [c.containerTypeId, c]),
|
||||
);
|
||||
let totalWeight = 0;
|
||||
if (isContainer) {
|
||||
for (const line of dto.lines) {
|
||||
const src = containerByType.get(line.containerTypeId ?? '');
|
||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||
totalWeight += vgmPerUnit * line.quantity;
|
||||
}
|
||||
} else {
|
||||
totalWeight = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
}
|
||||
|
||||
const child = manager.create(Booking, {
|
||||
reference,
|
||||
companyId: contract.companyId ?? null,
|
||||
companyProfileId: contract.companyProfileId ?? null,
|
||||
isGovernment: contract.isGovernment,
|
||||
governmentInstitution: contract.governmentInstitution ?? null,
|
||||
contractType: contract.contractType,
|
||||
previousContractId: contract.id,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
equipmentReturn: contract.equipmentReturn,
|
||||
originYardId: contract.originYardId,
|
||||
destinationYardId: contract.destinationYardId,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
cargoTypeId: contract.cargoTypeId ?? null,
|
||||
cargoFreeText: contract.cargoFreeText ?? null,
|
||||
shippingLineId: contract.shippingLineId ?? null,
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: contract.isHazardous,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
bookingType: 'ONE_TIME',
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
// Already covered by the contract's one-time payment: enter the pool ready
|
||||
// and paid so the batch engine reserves → allocates it immediately.
|
||||
status: 'FULLY_EXECUTED',
|
||||
paymentStatus: 'PAID',
|
||||
fullyExecutedAt: now,
|
||||
customerSignedAt: now,
|
||||
priorityScore: contract.priorityScore,
|
||||
totalAmount: 0,
|
||||
allowConsolidation: false,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
});
|
||||
const savedChild = await manager.save(child);
|
||||
|
||||
if (isContainer) {
|
||||
for (const line of dto.lines) {
|
||||
const src = containerByType.get(line.containerTypeId ?? '');
|
||||
const ct = line.containerTypeId
|
||||
? await manager.getRepository(ContainerType).findOne({
|
||||
where: { id: line.containerTypeId },
|
||||
})
|
||||
: null;
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const vgmPerUnit = src ? Number(src.vgmPerUnitTons) : 0;
|
||||
const row = manager.create(BookingContainer, {
|
||||
bookingId: savedChild.id,
|
||||
containerTypeId: line.containerTypeId ?? null,
|
||||
quantity: line.quantity,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: vgmPerUnit * line.quantity,
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnit),
|
||||
isOverweight: false,
|
||||
});
|
||||
await manager.save(row);
|
||||
}
|
||||
}
|
||||
|
||||
return savedChild;
|
||||
}
|
||||
|
||||
private async userOwnsContract(
|
||||
userId: string,
|
||||
contract: Booking,
|
||||
): Promise<boolean> {
|
||||
if (!contract.companyId) return true; // government / staff-created
|
||||
try {
|
||||
const { company } = await this.companiesService.getCompanyInfoByUserId(
|
||||
userId,
|
||||
);
|
||||
return company.id === contract.companyId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.ordersRepository.countByYear(year);
|
||||
return `ORD-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private async generateChildBookingReference(): Promise<string> {
|
||||
const year = new Date().getFullYear();
|
||||
const count = await this.bookingsRepository.countByYear(year);
|
||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
/** A single contracted/ordered/remaining pool line for a general contract. */
|
||||
export class ContractQuantityLineView {
|
||||
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
||||
containerTypeId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
containerTypeName!: string | null;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true })
|
||||
unitOfMeasure!: CargoUnitOfMeasure | null;
|
||||
|
||||
@ApiProperty()
|
||||
contractedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
orderedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
remainingQuantity!: number;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateBookingOrderLineDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Container type for this line (CONTAINER contracts). Omit for bulk/break-bulk.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Quantity to draw down (containers, tons, or items)', minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateBookingOrderDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'The general contract to draw down from' })
|
||||
@IsUUID()
|
||||
contractBookingId!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-07-01T00:00:00.000Z', description: 'Shipment day for this order' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateBookingOrderLineDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingOrderLineDto)
|
||||
lines!: CreateBookingOrderLineDto[];
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||
import { BookingOrder } from './booking-order.entity';
|
||||
|
||||
/**
|
||||
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
||||
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
||||
* single line with a null containerTypeId carries the tons/items.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_order_lines' })
|
||||
export class BookingOrderLine extends BaseEntity {
|
||||
@Column({ name: 'order_id', type: 'uuid' })
|
||||
orderId!: string;
|
||||
|
||||
@ManyToOne(() => BookingOrder, (order) => order.lines, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'order_id' })
|
||||
order?: BookingOrder;
|
||||
|
||||
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
|
||||
containerTypeId?: string | null;
|
||||
|
||||
@ManyToOne(() => ContainerType, { nullable: true })
|
||||
@JoinColumn({ name: 'container_type_id' })
|
||||
containerType?: ContainerType | null;
|
||||
|
||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
||||
quantity!: number;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { SchedulingStatus } from '@edr/types';
|
||||
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Company } from '../../companies/entities/company.entity';
|
||||
import { BookingOrderLine } from './booking-order-line.entity';
|
||||
|
||||
/**
|
||||
* A single drawdown against a general contract. Each order spawns its own
|
||||
* ONE_TIME child Booking (the shipment that enters the train scheduling
|
||||
* pipeline); this row is the ledger entry linking the contract to that
|
||||
* shipment and recording the drawn-down quantities.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_orders' })
|
||||
export class BookingOrder extends BaseEntity {
|
||||
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
|
||||
reference!: string;
|
||||
|
||||
/** The general contract (a Booking with bookingType = GENERAL_CONTRACT). */
|
||||
@Column({ name: 'contract_booking_id', type: 'uuid' })
|
||||
contractBookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: 'contract_booking_id' })
|
||||
contractBooking?: Booking;
|
||||
|
||||
/** The ONE_TIME child shipment booking spawned for this order. */
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
/** Denormalized from the contract for fast company-scoped filtering. */
|
||||
@Column({ name: 'company_id', type: 'uuid', nullable: true })
|
||||
companyId?: string | null;
|
||||
|
||||
@ManyToOne(() => Company, { nullable: true })
|
||||
@JoinColumn({ name: 'company_id' })
|
||||
company?: Company | null;
|
||||
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||
scheduledDate!: Date;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'PAID' })
|
||||
status!: string;
|
||||
|
||||
@Column({
|
||||
name: 'scheduling_status',
|
||||
type: 'varchar',
|
||||
length: 30,
|
||||
default: SchedulingStatus.NotScheduled,
|
||||
})
|
||||
schedulingStatus!: string;
|
||||
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
@OneToMany(() => BookingOrderLine, (line) => line.order, { cascade: true })
|
||||
lines?: BookingOrderLine[];
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { BookingType, CargoUnitOfMeasure } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingOrder } from './entities/booking-order.entity';
|
||||
import { ContractQuantityLineView } from './dto/contract-view.dto';
|
||||
|
||||
/** Setting code holding the global ordering window (in months) for general contracts. */
|
||||
export const CONTRACT_PERIOD_SETTING_CODE = 'general_contract_period';
|
||||
/** Fallback when the setting is missing or unparseable. */
|
||||
export const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
|
||||
/**
|
||||
* Owns general-contract concerns that sit alongside the generic booking flow:
|
||||
* the configurable ordering period, post-payment activation, and computing the
|
||||
* remaining drawdown pool per contract.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GeneralContractService {
|
||||
private readonly logger = new Logger(GeneralContractService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
) {}
|
||||
|
||||
isGeneralContract(booking: Pick<Booking, 'bookingType'>): boolean {
|
||||
return booking.bookingType === BookingType.GeneralContract;
|
||||
}
|
||||
|
||||
/** The configured ordering window in months (defaults to 3). */
|
||||
async getPeriodMonths(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettings.getByCode(
|
||||
CONTRACT_PERIOD_SETTING_CODE,
|
||||
);
|
||||
const raw = setting.children?.[0]?.value;
|
||||
const months = Number(raw);
|
||||
if (Number.isFinite(months) && months > 0) return months;
|
||||
} catch {
|
||||
// Setting not seeded yet — fall back to the default.
|
||||
}
|
||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a general contract's payment succeeds: mark it ACTIVE (instead of
|
||||
* entering the train queue like a one-time booking) and stamp the ordering
|
||||
* window. Idempotent.
|
||||
*/
|
||||
async activateAfterPayment(bookingId: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(Booking);
|
||||
const booking = await repo.findOne({ where: { id: bookingId } });
|
||||
if (!booking || !this.isGeneralContract(booking)) return;
|
||||
if (booking.status === 'CONTRACT_ACTIVE' || booking.status === 'CONTRACT_CLOSED') {
|
||||
return;
|
||||
}
|
||||
|
||||
const months = await this.getPeriodMonths();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setMonth(expiresAt.getMonth() + months);
|
||||
|
||||
await repo.update(bookingId, {
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
paymentStatus: 'PAID',
|
||||
expiresAt,
|
||||
});
|
||||
this.logger.log(
|
||||
`General contract ${booking.reference} ACTIVE — ordering window ${months} month(s) (expires ${expiresAt.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The drawdown pool for a contract: contracted vs. ordered vs. remaining,
|
||||
* per container type for CONTAINER contracts, or a single total line for
|
||||
* BULK/BREAK_BULK (keyed on a null container type).
|
||||
*/
|
||||
async getQuantityLines(
|
||||
contractBookingId: string,
|
||||
): Promise<ContractQuantityLineView[]> {
|
||||
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: contractBookingId },
|
||||
relations: { bookingContainers: { containerType: true }, cargoType: true },
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Contract ${contractBookingId} not found`);
|
||||
|
||||
const ordered = await this.orderedByContainerType(contractBookingId);
|
||||
|
||||
if (booking.freightType === 'CONTAINER') {
|
||||
return (booking.bookingContainers ?? []).map((c) => {
|
||||
const orderedQty = ordered.get(c.containerTypeId ?? '') ?? 0;
|
||||
const contracted = Number(c.quantity);
|
||||
return {
|
||||
containerTypeId: c.containerTypeId ?? null,
|
||||
containerTypeName: c.containerType?.label ?? null,
|
||||
unitOfMeasure: null,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// BULK / BREAK_BULK — a single pool keyed on the contracted total weight/items.
|
||||
const orderedQty = ordered.get('') ?? 0;
|
||||
const contracted = Number(booking.cargoTotalWeightVgm);
|
||||
const uom: CargoUnitOfMeasure | null =
|
||||
(booking.cargoType?.unitOfMeasure as CargoUnitOfMeasure | undefined) ??
|
||||
CargoUnitOfMeasure.PerTon;
|
||||
return [
|
||||
{
|
||||
containerTypeId: null,
|
||||
containerTypeName: null,
|
||||
unitOfMeasure: uom,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||
private async orderedByContainerType(
|
||||
contractBookingId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingOrder)
|
||||
.createQueryBuilder('o')
|
||||
.innerJoin('o.lines', 'line')
|
||||
.select('COALESCE(line.container_type_id::text, :empty)', 'key')
|
||||
.addSelect('SUM(line.quantity)', 'total')
|
||||
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
||||
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
||||
.setParameter('empty', '')
|
||||
.groupBy('key')
|
||||
.getRawMany<{ key: string; total: string }>();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) map.set(row.key ?? '', Number(row.total));
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Convenience: how many units remain for a given container type ('' = bulk). */
|
||||
async remainingFor(
|
||||
contractBookingId: string,
|
||||
containerTypeKey: string,
|
||||
): Promise<number> {
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
const line = lines.find(
|
||||
(l) => (l.containerTypeId ?? '') === containerTypeKey,
|
||||
);
|
||||
return line?.remainingQuantity ?? 0;
|
||||
}
|
||||
|
||||
/** True once every contracted line is fully drawn down. */
|
||||
async isExhausted(contractBookingId: string): Promise<boolean> {
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
return lines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ export function buildCargoTypeTree(
|
||||
name: child.cargoTypeName,
|
||||
code: child.code,
|
||||
show_free_text_box: child.showFreeTextBox,
|
||||
unit_of_measure: child.unitOfMeasure ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface BookingListFilterOptions {
|
||||
serviceTypeId?: string;
|
||||
cargoTypeId?: string;
|
||||
freightType?: string;
|
||||
bookingType?: string;
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
paymentStatus?: string;
|
||||
@@ -585,6 +586,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
freightType: options.freightType,
|
||||
});
|
||||
}
|
||||
if (options.bookingType) {
|
||||
qb.andWhere('booking.booking_type = :bookingType', {
|
||||
bookingType: options.bookingType,
|
||||
});
|
||||
}
|
||||
if (options.tradeDirection) {
|
||||
qb.andWhere('booking.trade_direction = :tradeDirection', {
|
||||
tradeDirection: options.tradeDirection,
|
||||
|
||||
@@ -258,6 +258,7 @@ export class BookingsService {
|
||||
// }
|
||||
|
||||
const isGovernment = dto.isGovernment === true;
|
||||
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
|
||||
|
||||
let companyId: string | null | undefined = dto.companyId;
|
||||
if (isGovernment) {
|
||||
@@ -292,11 +293,12 @@ export class BookingsService {
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
} else {
|
||||
} else if (!isGeneralContract) {
|
||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||
// train later.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
// train later. General contracts skip this — they have no shipment date at
|
||||
// creation; each drawdown order validates its own day.
|
||||
const day = eatDay(new Date(dto.scheduledDate!));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
dto.originYardId,
|
||||
@@ -395,7 +397,8 @@ export class BookingsService {
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
pnrCode: dto.pnrCode,
|
||||
financialTerms: dto.financialTerms,
|
||||
scheduledDate: new Date(dto.scheduledDate),
|
||||
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
|
||||
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
|
||||
status: 'DRAFT',
|
||||
@@ -647,6 +650,7 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
@@ -815,6 +819,7 @@ export class BookingsService {
|
||||
serviceTypeId: filter.serviceTypeId,
|
||||
cargoTypeId: filter.cargoTypeId,
|
||||
freightType: filter.freightType,
|
||||
bookingType: filter.bookingType,
|
||||
tradeDirection: filter.tradeDirection,
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
paymentStatus: filter.paymentStatus,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
export class BookingReferenceYardDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@@ -73,6 +74,9 @@ export class BookingReferenceCargoTypeChildDto {
|
||||
|
||||
@ApiProperty()
|
||||
show_free_text_box!: boolean;
|
||||
|
||||
@ApiProperty({ enum: CargoUnitOfMeasure, nullable: true, required: false })
|
||||
unit_of_measure?: CargoUnitOfMeasure | null;
|
||||
}
|
||||
|
||||
export class BookingReferenceCargoTypeGroupDto {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||
import { BOOKING_STATUSES, BOOKING_TYPES, FREIGHT_TYPES } from '../entities/booking.entity';
|
||||
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
|
||||
|
||||
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
|
||||
@@ -27,6 +27,7 @@ const PAYMENT_CURRENCIES = ['ETB', 'USD'] as const;
|
||||
|
||||
export {
|
||||
BOOKING_STATUSES,
|
||||
BOOKING_TYPES,
|
||||
CONTRACT_TYPES,
|
||||
EQUIPMENT_RETURNS,
|
||||
FREIGHT_TYPES,
|
||||
@@ -105,10 +106,24 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
/** The day the customer wants to ship (the pool day key). */
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@ApiPropertyOptional({
|
||||
enum: BOOKING_TYPES,
|
||||
default: 'ONE_TIME',
|
||||
description:
|
||||
'ONE_TIME (default) for a normal booking; GENERAL_CONTRACT for an umbrella contract drawn down by orders.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...BOOKING_TYPES])
|
||||
bookingType?: string;
|
||||
|
||||
/**
|
||||
* The day the customer wants to ship (the pool day key). Required for one-time
|
||||
* bookings; omitted for general contracts, which pick the date per order.
|
||||
*/
|
||||
@ApiPropertyOptional({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@ValidateIf((o) => o.bookingType !== 'GENERAL_CONTRACT')
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
scheduledDate?: string;
|
||||
|
||||
@ApiProperty({ enum: CONTRACT_TYPES })
|
||||
@IsIn([...CONTRACT_TYPES])
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
BOOKING_TYPES,
|
||||
FREIGHT_TYPES,
|
||||
PAYMENT_CURRENCIES,
|
||||
TRADE_DIRECTIONS,
|
||||
@@ -56,6 +57,11 @@ export class FilterBookingDto {
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: BOOKING_TYPES, description: 'ONE_TIME or GENERAL_CONTRACT' })
|
||||
@IsOptional()
|
||||
@IsIn([...BOOKING_TYPES])
|
||||
bookingType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
|
||||
@@ -41,10 +41,15 @@ export const BOOKING_STATUSES = [
|
||||
'CANCELLED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CONSOLIDATED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
] as const;
|
||||
|
||||
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
||||
|
||||
export const BOOKING_TYPES = ['ONE_TIME', 'GENERAL_CONTRACT'] as const;
|
||||
export type BookingTypeValue = (typeof BOOKING_TYPES)[number];
|
||||
|
||||
export const PAYMENT_STATUSES = [
|
||||
'PENDING',
|
||||
'PNR_GENERATED',
|
||||
@@ -125,8 +130,28 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'DRAFT' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz' })
|
||||
scheduledDate!: Date;
|
||||
/**
|
||||
* ONE_TIME for a normal single-shipment booking; GENERAL_CONTRACT for an
|
||||
* umbrella contract that is signed/paid once and then drawn down by many
|
||||
* orders (each order spawns its own ONE_TIME child booking).
|
||||
*/
|
||||
@Column({ name: 'booking_type', type: 'varchar', length: 20, default: 'ONE_TIME' })
|
||||
bookingType!: string;
|
||||
|
||||
/**
|
||||
* Nullable: general contracts have no shipment date at creation — the date is
|
||||
* chosen per drawdown order. One-time bookings always set this (the pool day key).
|
||||
*/
|
||||
@Column({ name: 'scheduled_date', type: 'timestamptz', nullable: true })
|
||||
scheduledDate?: Date | null;
|
||||
|
||||
/**
|
||||
* General contracts only: when the ordering window closes, computed from the
|
||||
* global CONTRACT_PERIOD_MONTHS setting at activation. Null for one-time
|
||||
* bookings and for contracts that are not yet active.
|
||||
*/
|
||||
@Column({ name: 'expires_at', type: 'timestamptz', nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@Column({ name: 'total_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
totalAmount!: number;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { PaymentEventsConsumer } from "./payment-events.consumer";
|
||||
import { InternalPaymentController } from "./internal-payment.controller";
|
||||
import { ServiceAuthGuard } from "../../common/guards/service-auth.guard";
|
||||
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
|
||||
import { DropdownSettingsModule } from "../dropdown-settings/dropdown-settings.module";
|
||||
import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity";
|
||||
import { PaymentRefundEntity } from "./entities/payment-refund.entity";
|
||||
|
||||
@@ -27,6 +28,7 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
|
||||
imports: [
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ConfigModule,
|
||||
DropdownSettingsModule,
|
||||
forwardRef(() => TrainSchedulingModule),
|
||||
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
|
||||
RabbitMQModule.forRootAsync({
|
||||
|
||||
@@ -34,6 +34,11 @@ import {
|
||||
RefundDto,
|
||||
} from "./payments.dto";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { DropdownSettingsService } from "../dropdown-settings/dropdown-settings.service";
|
||||
|
||||
/** Setting code holding the global ordering window (months) for general contracts. */
|
||||
const CONTRACT_PERIOD_SETTING_CODE = "general_contract_period";
|
||||
const DEFAULT_CONTRACT_PERIOD_MONTHS = 3;
|
||||
|
||||
const STATUS_MAP: Record<string, ProviderPaymentStatus> = {
|
||||
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||
@@ -54,8 +59,23 @@ export class PaymentService {
|
||||
private readonly paymentClient: PaymentClientService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly dropdownSettings: DropdownSettingsService,
|
||||
) { }
|
||||
|
||||
/** Configured general-contract ordering window in months (defaults to 3). */
|
||||
private async contractPeriodMonths(): Promise<number> {
|
||||
try {
|
||||
const setting = await this.dropdownSettings.getByCode(
|
||||
CONTRACT_PERIOD_SETTING_CODE,
|
||||
);
|
||||
const months = Number(setting.children?.[0]?.value);
|
||||
if (Number.isFinite(months) && months > 0) return months;
|
||||
} catch {
|
||||
// Setting not seeded — fall back to the default.
|
||||
}
|
||||
return DEFAULT_CONTRACT_PERIOD_MONTHS;
|
||||
}
|
||||
|
||||
async getAll(filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
@@ -293,15 +313,44 @@ export class PaymentService {
|
||||
|
||||
const paidAt = input.paidAt ?? new Date();
|
||||
|
||||
// A general contract is paid once, up front; it does NOT enter the train
|
||||
// queue (nothing has been ordered yet). Instead it becomes ACTIVE and
|
||||
// opens its ordering window. Orders placed later spawn their own paid
|
||||
// child bookings that go through the normal pipeline.
|
||||
const booking = await this.datasource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: input.bookingId } });
|
||||
const isGeneralContract = booking?.bookingType === "GENERAL_CONTRACT";
|
||||
|
||||
let contractExpiresAt: Date | null = null;
|
||||
if (isGeneralContract) {
|
||||
const months = await this.contractPeriodMonths();
|
||||
contractExpiresAt = new Date(paidAt);
|
||||
contractExpiresAt.setMonth(contractExpiresAt.getMonth() + months);
|
||||
}
|
||||
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(
|
||||
PaymentEntity,
|
||||
{ id: intent.id },
|
||||
{ status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId },
|
||||
);
|
||||
await mg.update(Booking, { id: input.bookingId }, { paymentStatus: "PAID" ,status:"PAID"});
|
||||
await mg.update(
|
||||
Booking,
|
||||
{ id: input.bookingId },
|
||||
isGeneralContract
|
||||
? { paymentStatus: "PAID", status: "CONTRACT_ACTIVE", expiresAt: contractExpiresAt }
|
||||
: { paymentStatus: "PAID", status: "PAID" },
|
||||
);
|
||||
});
|
||||
|
||||
if (isGeneralContract) {
|
||||
this.logger.log(
|
||||
`General contract ${booking?.reference ?? input.bookingId} ACTIVE — ordering open until ${contractExpiresAt?.toISOString()}`,
|
||||
);
|
||||
return { alreadyFinalized: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await this.bookingBatchService.ensurePaidBookingAllocated(input.bookingId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateCargoTypeDto {
|
||||
@ApiProperty({ description: 'Cargo type display name', maxLength: 255 })
|
||||
@@ -7,6 +8,14 @@ export class CreateCargoTypeDto {
|
||||
@MaxLength(255)
|
||||
cargoTypeName!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: CargoUnitOfMeasure,
|
||||
description: 'How this cargo is measured (PER_TON for bulk, PER_ITEM for break-bulk)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(CargoUnitOfMeasure)
|
||||
unitOfMeasure?: CargoUnitOfMeasure;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Parent group ID for hierarchical cargo types' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'cargo_types' })
|
||||
@@ -19,6 +20,14 @@ export class CargoType extends BaseEntity {
|
||||
@Column({ name: 'show_free_text_box', type: 'boolean', default: false })
|
||||
showFreeTextBox!: boolean;
|
||||
|
||||
/**
|
||||
* How this cargo's quantity is measured: PER_TON (bulk) or PER_ITEM
|
||||
* (break-bulk). Nullable for container/legacy cargo, which is counted by
|
||||
* container. Drives the unit shown when ordering against a general contract.
|
||||
*/
|
||||
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
|
||||
unitOfMeasure?: CargoUnitOfMeasure | null;
|
||||
|
||||
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
|
||||
requiresDirectorApproval!: boolean;
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ export class CargoTypesService {
|
||||
showFreeTextBox: dto.showFreeTextBox ?? false,
|
||||
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
unitOfMeasure: dto.unitOfMeasure ?? null,
|
||||
displayOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export interface SchedulingPriorityBooking {
|
||||
isGovernment?: boolean;
|
||||
priorityScore?: number | null;
|
||||
scheduledDate: Date | string;
|
||||
// One-time bookings always carry a date; general contracts (never scheduled)
|
||||
// may be null — treated as epoch 0 so they sort last.
|
||||
scheduledDate?: Date | string | null;
|
||||
}
|
||||
|
||||
/** Government first, then priority score, then earliest scheduled date. */
|
||||
@@ -15,5 +17,7 @@ export function compareSchedulingPriority(
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
||||
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
return aTime - bTime;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ export function sortBookingsForScheduling(bookings: Booking[]): Booking[] {
|
||||
const priorityDiff = (b.priorityScore ?? 0) - (a.priorityScore ?? 0);
|
||||
if (priorityDiff !== 0) return priorityDiff;
|
||||
|
||||
return new Date(a.scheduledDate).getTime() - new Date(b.scheduledDate).getTime();
|
||||
const aTime = a.scheduledDate ? new Date(a.scheduledDate).getTime() : 0;
|
||||
const bTime = b.scheduledDate ? new Date(b.scheduledDate).getTime() : 0;
|
||||
return aTime - bTime;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1880,7 +1880,7 @@ export class TrainSchedulingService {
|
||||
origin: booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
|
||||
destination:
|
||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? 'Unknown destination',
|
||||
preferredDepartureDate: booking.scheduledDate.toISOString(),
|
||||
preferredDepartureDate: booking.scheduledDate?.toISOString() ?? null,
|
||||
status: booking.status,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user