mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
@@ -43,3 +43,4 @@ EXPOSE 3001
|
|||||||
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
|
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
|
||||||
EXPOSE 5023
|
EXPOSE 5023
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||||
|
"seed:dropdown-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-dropdown-settings.ts",
|
||||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||||
|
|||||||
44
apps/edr-freight-api/src/common/dto/pagination-query.dto.ts
Normal file
44
apps/edr-freight-api/src/common/dto/pagination-query.dto.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base query DTO for every paginated list endpoint. Extend it and add the
|
||||||
|
* module's own filter fields; sort-field whitelists stay in the subclass
|
||||||
|
* because the allowed columns differ per resource.
|
||||||
|
*
|
||||||
|
* All list endpoints built on this return the shared `PaginatedResponse<T>`
|
||||||
|
* envelope from `@edr/types` (`items` + `meta`), produced by
|
||||||
|
* `common/utils/pagination.util.ts`.
|
||||||
|
*/
|
||||||
|
export class PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => parseInt(String(value), 10) || 1)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => parseInt(String(value), 10) || 20)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(100)
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Free-text search, applied server-side (resource-specific columns).',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||||
|
)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => String(value).toUpperCase())
|
||||||
|
@IsIn(['ASC', 'DESC'])
|
||||||
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
|
}
|
||||||
85
apps/edr-freight-api/src/common/utils/pagination.util.ts
Normal file
85
apps/edr-freight-api/src/common/utils/pagination.util.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { PaginatedResponse, PaginationMeta } from '@edr/types';
|
||||||
|
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
|
||||||
|
|
||||||
|
/** Raw page/pageSize as they arrive from a query DTO (both optional). */
|
||||||
|
export interface PageRequest {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginationOptions {
|
||||||
|
defaultPageSize?: number;
|
||||||
|
maxPageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedPage {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
skip: number;
|
||||||
|
take: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
const MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/** Clamp raw query values into a safe page window (page ≥ 1, pageSize capped). */
|
||||||
|
export function normalizePagination(
|
||||||
|
request: PageRequest,
|
||||||
|
options: PaginationOptions = {},
|
||||||
|
): NormalizedPage {
|
||||||
|
const defaultPageSize = options.defaultPageSize ?? DEFAULT_PAGE_SIZE;
|
||||||
|
const maxPageSize = options.maxPageSize ?? MAX_PAGE_SIZE;
|
||||||
|
|
||||||
|
const page = Math.max(1, Math.floor(request.page ?? 1) || 1);
|
||||||
|
const requested = Math.floor(request.pageSize ?? defaultPageSize) || defaultPageSize;
|
||||||
|
const pageSize = Math.min(Math.max(1, requested), maxPageSize);
|
||||||
|
|
||||||
|
return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPaginationMeta(
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
): PaginationMeta {
|
||||||
|
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
hasNextPage: page < totalPages,
|
||||||
|
hasPreviousPage: page > 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply skip/take to a query builder, run it, and wrap the result in the
|
||||||
|
* shared `PaginatedResponse` envelope. Ordering and filtering must already be
|
||||||
|
* applied by the caller.
|
||||||
|
*/
|
||||||
|
export async function paginateQuery<T extends ObjectLiteral>(
|
||||||
|
qb: SelectQueryBuilder<T>,
|
||||||
|
request: PageRequest,
|
||||||
|
options?: PaginationOptions,
|
||||||
|
): Promise<PaginatedResponse<T>> {
|
||||||
|
const { page, pageSize, skip, take } = normalizePagination(request, options);
|
||||||
|
const [items, total] = await qb.skip(skip).take(take).getManyAndCount();
|
||||||
|
return { items, meta: buildPaginationMeta(total, page, pageSize) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paginate an already-materialized array. Prefer `paginateQuery` (DB-level
|
||||||
|
* LIMIT/OFFSET); use this only for lists that are inherently in-memory.
|
||||||
|
*/
|
||||||
|
export function paginateArray<T>(
|
||||||
|
rows: readonly T[],
|
||||||
|
request: PageRequest,
|
||||||
|
options?: PaginationOptions,
|
||||||
|
): PaginatedResponse<T> {
|
||||||
|
const { page, pageSize, skip } = normalizePagination(request, options);
|
||||||
|
return {
|
||||||
|
items: rows.slice(skip, skip + pageSize),
|
||||||
|
meta: buildPaginationMeta(rows.length, page, pageSize),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partial-batch splits no longer promote a ONE_TIME contract to GENERAL.
|
||||||
|
* Instead the reduced booking is flagged is_split, and the booking gate lets
|
||||||
|
* the customer book exactly the remainder under the still-ONE_TIME contract.
|
||||||
|
*/
|
||||||
|
export class AddBookingIsSplit2110000000000 implements MigrationInterface {
|
||||||
|
name = 'AddBookingIsSplit2110000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS is_split BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
`);
|
||||||
|
// Quantities the booking carried before the split — the remainder ledger
|
||||||
|
// for ONE_TIME contracts, which have no quantity cap to derive it from.
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS pre_split_quantities JSONB NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS pre_split_quantities
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS is_split
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proof of delivery for EDR last-mile: recipient name, a captured signature
|
||||||
|
* (stored as a file), delivery photos (file ids), notes, and the capture time.
|
||||||
|
* Recorded when the driver completes the delivery.
|
||||||
|
*/
|
||||||
|
export class AddLastMileProofOfDelivery2120000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddLastMileProofOfDelivery2120000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile
|
||||||
|
ADD COLUMN IF NOT EXISTS pod_recipient_name varchar(160),
|
||||||
|
ADD COLUMN IF NOT EXISTS pod_signature_file_id uuid,
|
||||||
|
ADD COLUMN IF NOT EXISTS pod_photo_file_ids text[] NOT NULL DEFAULT '{}',
|
||||||
|
ADD COLUMN IF NOT EXISTS pod_notes text,
|
||||||
|
ADD COLUMN IF NOT EXISTS pod_captured_at timestamptz
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.last_mile
|
||||||
|
DROP COLUMN IF EXISTS pod_recipient_name,
|
||||||
|
DROP COLUMN IF EXISTS pod_signature_file_id,
|
||||||
|
DROP COLUMN IF EXISTS pod_photo_file_ids,
|
||||||
|
DROP COLUMN IF EXISTS pod_notes,
|
||||||
|
DROP COLUMN IF EXISTS pod_captured_at
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a frozen wagon-allocation snapshot to each train schedule.
|
||||||
|
*
|
||||||
|
* Once a schedule leaves the editable DRAFT/SCHEDULED phase (dispatch / arrive /
|
||||||
|
* cancel), the same physical wagons get released and re-pinned onto later trains.
|
||||||
|
* The live wagon↔slot joins then no longer describe THIS train's plan, so an
|
||||||
|
* admin viewing a past schedule saw a mangled or "unavailable" allocation.
|
||||||
|
*
|
||||||
|
* This jsonb column stores a one-shot frozen copy of the wagon plan (per-slot
|
||||||
|
* physical wagon + booking allocations) captured at the transition. Non-editable
|
||||||
|
* schedules render from the snapshot; DRAFT/SCHEDULED still read live. NULL on
|
||||||
|
* legacy rows and while editable — the read path falls back to the live joins.
|
||||||
|
*/
|
||||||
|
export class AddScheduleWagonAllocationSnapshot2120000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = "AddScheduleWagonAllocationSnapshot2120000000000";
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS wagon_allocation_snapshot jsonb;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
DROP COLUMN IF EXISTS wagon_allocation_snapshot;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -605,6 +605,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
async findAllPaginated(options: BookingListFilterOptions & {
|
async findAllPaginated(options: BookingListFilterOptions & {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
@@ -640,6 +641,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
|
|
||||||
this.applyListFilters(qb, options);
|
this.applyListFilters(qb, options);
|
||||||
|
|
||||||
|
// Free-text search spans joined columns (company, contract) that only this
|
||||||
|
// list query joins — so it lives here, not in applyListFilters (shared
|
||||||
|
// with getListSummaryMetrics, whose query builder has no joins).
|
||||||
|
if (options.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
|
||||||
|
{ search: `%${options.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.sortBy === 'isGovernment') {
|
if (options.sortBy === 'isGovernment') {
|
||||||
qb.orderBy('booking.isGovernment', 'DESC')
|
qb.orderBy('booking.isGovernment', 'DESC')
|
||||||
.addOrderBy('booking.priorityScore', 'DESC')
|
.addOrderBy('booking.priorityScore', 'DESC')
|
||||||
@@ -1258,7 +1269,12 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
fields: Partial<
|
fields: Partial<
|
||||||
Pick<
|
Pick<
|
||||||
Booking,
|
Booking,
|
||||||
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
|
| 'schedulingStatus'
|
||||||
|
| 'wagonsRequired'
|
||||||
|
| 'scheduledAt'
|
||||||
|
| 'holdStartedAt'
|
||||||
|
| 'holdExpiresAt'
|
||||||
|
| 'trainScheduleId'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
manager?: EntityManager,
|
manager?: EntityManager,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
|||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||||
|
import { Contract } from '../contracts/entities/contract.entity';
|
||||||
import { BookingsRepository } from './bookings.repository';
|
import { BookingsRepository } from './bookings.repository';
|
||||||
import { ConsolidationService } from './consolidation.service';
|
import { ConsolidationService } from './consolidation.service';
|
||||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||||
@@ -1211,6 +1212,13 @@ export class BookingsService {
|
|||||||
destinationYardId: filter.destinationYardId,
|
destinationYardId: filter.destinationYardId,
|
||||||
isGovernment: filter.isGovernment,
|
isGovernment: filter.isGovernment,
|
||||||
consolidationPaired: filter.consolidationPaired,
|
consolidationPaired: filter.consolidationPaired,
|
||||||
|
// DTO carries 'true'/'false' strings (query params); the repo option is a
|
||||||
|
// real boolean — convert, preserving "not filtered" when absent.
|
||||||
|
customsClearingEnabled:
|
||||||
|
filter.customsClearingEnabled === undefined
|
||||||
|
? undefined
|
||||||
|
: filter.customsClearingEnabled === 'true',
|
||||||
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
@@ -1262,6 +1270,7 @@ export class BookingsService {
|
|||||||
// Global Logistics only clears customs bookings; non-customs clearance is
|
// Global Logistics only clears customs bookings; non-customs clearance is
|
||||||
// reviewed by Marketing from the booking detail, not this queue.
|
// reviewed by Marketing from the booking detail, not this queue.
|
||||||
customsClearingEnabled: true,
|
customsClearingEnabled: true,
|
||||||
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
@@ -1286,6 +1295,7 @@ export class BookingsService {
|
|||||||
// Company-wide: payables span all of the customer's services.
|
// Company-wide: payables span all of the customer's services.
|
||||||
companyId: company.id,
|
companyId: company.id,
|
||||||
companyProfileId: filter.companyProfileId,
|
companyProfileId: filter.companyProfileId,
|
||||||
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
@@ -1473,6 +1483,18 @@ export class BookingsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Surface the parent contract's reference for drawdown bookings — the
|
||||||
|
// portal detail header shows it (the entity has no contract relation, so
|
||||||
|
// the list attaches it via a raw join and the detail attaches it here).
|
||||||
|
if (booking.contractId) {
|
||||||
|
const contract = await this.dataSource.getRepository(Contract).findOne({
|
||||||
|
where: { id: booking.contractId },
|
||||||
|
select: { reference: true },
|
||||||
|
});
|
||||||
|
(booking as Booking & { contractReference?: string | null }).contractReference =
|
||||||
|
contract?.reference ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// Surface the assigned train's operational status so the portal stepper
|
// Surface the assigned train's operational status so the portal stepper
|
||||||
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||||
// dispatch until delivery, so arrival is only knowable from the schedule.
|
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
|
|
||||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||||
|
|
||||||
interface BookingGuardRow {
|
interface BookingGuardRow {
|
||||||
tradeDirection: string | null;
|
tradeDirection: string | null;
|
||||||
|
freightType: string | null;
|
||||||
firstMile: string | null;
|
firstMile: string | null;
|
||||||
lastMile: string | null;
|
lastMile: string | null;
|
||||||
paymentStatus: string | null;
|
paymentStatus: string | null;
|
||||||
@@ -29,9 +35,13 @@ interface BookingGuardRow {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerTruckService {
|
export class CustomerTruckService {
|
||||||
|
private readonly logger = new Logger(CustomerTruckService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly assignments: CustomerTruckAssignmentsRepository,
|
private readonly assignments: CustomerTruckAssignmentsRepository,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly notifications: NotificationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
@@ -41,14 +51,20 @@ export class CustomerTruckService {
|
|||||||
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
|
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
|
||||||
const booking = await this.loadBookingGuard(bookingId);
|
const booking = await this.loadBookingGuard(bookingId);
|
||||||
this.assertSelfHaulPaid(booking);
|
this.assertSelfHaulPaid(booking);
|
||||||
|
this.assertAssignmentWindow(booking);
|
||||||
|
|
||||||
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
// Bulk bookings have no containers — the truck hauls loose tonnage and is
|
||||||
|
// weighed out on departure (gross_weight_kg). Container bookings assign the
|
||||||
|
// 1–2 specific containers each truck carries.
|
||||||
|
const isBulk = booking.freightType === 'BULK';
|
||||||
|
const requested = isBulk
|
||||||
|
? []
|
||||||
|
: (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
|
||||||
// Both import and export specify the containers each truck carries. Capacity
|
// Container capacity is size-based: a 40ft container fills the truck (max 1);
|
||||||
// is size-based: a 40ft container fills the truck (max 1); two 20ft containers
|
// two 20ft containers fit (max 2), no size mixing. #trucks <= #containers
|
||||||
// fit (max 2), no size mixing. #trucks <= #containers follows naturally since
|
// follows naturally since each container is assigned to exactly one truck.
|
||||||
// each container is assigned to exactly one truck.
|
if (!isBulk && requested.length < 1) {
|
||||||
if (requested.length < 1) {
|
|
||||||
throw new BadRequestException('Select at least one container for this truck');
|
throw new BadRequestException('Select at least one container for this truck');
|
||||||
}
|
}
|
||||||
if (requested.length > 2) {
|
if (requested.length > 2) {
|
||||||
@@ -395,20 +411,74 @@ export class CustomerTruckService {
|
|||||||
});
|
});
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
|
const assignment = await m
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.findOne({ where: { id: container.assignmentId } });
|
||||||
|
const justArrived = Boolean(assignment) && !assignment?.arrivedAt;
|
||||||
|
|
||||||
await m
|
await m
|
||||||
.getRepository(CustomerTruckAssignment)
|
.getRepository(CustomerTruckAssignment)
|
||||||
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||||
|
|
||||||
await this.syncBookingArrival(bookingId, m);
|
await this.syncBookingArrival(bookingId, m);
|
||||||
|
|
||||||
|
if (justArrived && assignment) {
|
||||||
|
await this.notifyTruckArrival(bookingId, assignment.plateNumber, m);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark every truck on the booking arrived (fallback when no container is known). */
|
/** Mark every truck on the booking arrived (fallback when no container is known). */
|
||||||
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
|
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||||
const m = manager ?? this.dataSource.manager;
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
const justArrived = await m
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.find({ where: { bookingId, arrivedAt: IsNull() } });
|
||||||
await m
|
await m
|
||||||
.getRepository(CustomerTruckAssignment)
|
.getRepository(CustomerTruckAssignment)
|
||||||
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||||
await this.syncBookingArrival(bookingId, m);
|
await this.syncBookingArrival(bookingId, m);
|
||||||
|
for (const truck of justArrived) {
|
||||||
|
await this.notifyTruckArrival(bookingId, truck.plateNumber, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort truck-arrival notification to the booking's company across every
|
||||||
|
* channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||||
|
* provider or contact must not break the arrival flow.
|
||||||
|
*/
|
||||||
|
private async notifyTruckArrival(
|
||||||
|
bookingId: string,
|
||||||
|
plateNumber: string | null,
|
||||||
|
m: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||||
|
await m.query(
|
||||||
|
`SELECT company_id AS "companyId", reference
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!booking?.companyId) return;
|
||||||
|
const ref = booking.reference ?? bookingId;
|
||||||
|
const truck = plateNumber ? `Truck ${plateNumber}` : 'A customer truck';
|
||||||
|
const body = `${truck} has arrived at the terminal for booking ${ref}.`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { companyId: booking.companyId },
|
||||||
|
audience: NotificationAudience.PORTAL,
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
title: 'Truck arrived',
|
||||||
|
body,
|
||||||
|
link: `/bookings/${bookingId}`,
|
||||||
|
data: { bookingId, plateNumber, action: 'TRUCK_ARRIVED' },
|
||||||
|
});
|
||||||
|
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Truck-arrival notify failed for ${bookingId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -430,6 +500,7 @@ export class CustomerTruckService {
|
|||||||
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
|
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
|
||||||
const [row]: BookingGuardRow[] = await this.dataSource.query(
|
const [row]: BookingGuardRow[] = await this.dataSource.query(
|
||||||
`SELECT trade_direction AS "tradeDirection",
|
`SELECT trade_direction AS "tradeDirection",
|
||||||
|
freight_type AS "freightType",
|
||||||
first_mile_pickup_address AS "firstMile",
|
first_mile_pickup_address AS "firstMile",
|
||||||
last_mile_delivery_address AS "lastMile",
|
last_mile_delivery_address AS "lastMile",
|
||||||
payment_status AS "paymentStatus",
|
payment_status AS "paymentStatus",
|
||||||
@@ -463,6 +534,30 @@ export class CustomerTruckService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assignment window by direction:
|
||||||
|
* - IMPORT: pickup trucks are assigned only AFTER the train has arrived.
|
||||||
|
* - EXPORT / DOMESTIC: delivery trucks are assigned only BEFORE the cargo is
|
||||||
|
* loaded onto the train (booking still PAID / TRUCK_ASSIGNED). Once loaded
|
||||||
|
* (IN_TRANSIT and beyond) assignment is closed.
|
||||||
|
*/
|
||||||
|
private assertAssignmentWindow(booking: BookingGuardRow): void {
|
||||||
|
const status = booking.status ?? '';
|
||||||
|
if (booking.tradeDirection === 'IMPORT') {
|
||||||
|
if (status !== 'ARRIVED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Import pickup trucks can only be assigned after the train has arrived',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['PAID', 'TRUCK_ASSIGNED'].includes(status)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Export delivery trucks can only be assigned before the cargo is loaded onto the train',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
`SELECT bcu.container_number AS "containerNumber"
|
`SELECT bcu.container_number AS "containerNumber"
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ export class FilterBookingDto {
|
|||||||
@IsIn(['true', 'false'])
|
@IsIn(['true', 'false'])
|
||||||
isGovernment?: 'true' | 'false';
|
isGovernment?: 'true' | 'false';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
enum: ['true', 'false'],
|
||||||
|
description: 'Filter customs vs self-clearance (non-customs) bookings',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['true', 'false'])
|
||||||
|
customsClearingEnabled?: 'true' | 'false';
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...TRADE_DIRECTIONS])
|
@IsIn([...TRADE_DIRECTIONS])
|
||||||
@@ -125,6 +133,16 @@ export class FilterBookingDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
consolidationPaired?: string;
|
consolidationPaired?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Free-text search across booking reference, company name, and contract reference.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||||
|
)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 1 })
|
@ApiPropertyOptional({ default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||||
|
|||||||
@@ -166,6 +166,24 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: 'contract_kind', type: 'varchar', length: 20, nullable: true })
|
||||||
contractKind?: string | null;
|
contractKind?: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer paid a partial batch offer and this booking was reduced to the
|
||||||
|
* offered part (see BookingSplitService.applySplit). On a ONE_TIME contract a
|
||||||
|
* split booking releases the single-active-booking slot for the remainder —
|
||||||
|
* the contract kind itself is never changed.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'is_split', type: 'boolean', default: false })
|
||||||
|
isSplit!: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quantities this booking carried BEFORE it was reduced by a split — the
|
||||||
|
* split chain's source of truth for the outstanding remainder (ONE_TIME
|
||||||
|
* contracts have no quantity cap to derive it from). Bulk: total tons;
|
||||||
|
* container: units per size. Null until the booking is split.
|
||||||
|
*/
|
||||||
|
@Column({ name: 'pre_split_quantities', type: 'jsonb', nullable: true })
|
||||||
|
preSplitQuantities?: { bulkTons?: number; bySize?: Record<string, number> } | null;
|
||||||
|
|
||||||
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
|
/** Who created this booking: CUSTOMER (Path A), GL_ET (Path B), or STAFF. */
|
||||||
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
||||||
createdByRole?: string | null;
|
createdByRole?: string | null;
|
||||||
|
|||||||
@@ -199,6 +199,8 @@ export class BookingRequestService {
|
|||||||
reviewedByStaffId: staffId ?? null,
|
reviewedByStaffId: staffId ?? null,
|
||||||
reviewedAt: new Date(),
|
reviewedAt: new Date(),
|
||||||
} as never);
|
} as never);
|
||||||
|
const contract = await this.contractsService.findById(request.contractId);
|
||||||
|
this.notifier.shipmentRequestRejected(contract, request.reference, note);
|
||||||
return (await this.repo.findById(requestId)) ?? request;
|
return (await this.repo.findById(requestId)) ?? request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
|||||||
{} as never, // invoiceService
|
{} as never, // invoiceService
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
);
|
);
|
||||||
return { service, contractsRepository };
|
return { service, contractsRepository };
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
|||||||
invoiceService as never,
|
invoiceService as never,
|
||||||
{} as never, // dataSource
|
{} as never, // dataSource
|
||||||
{} as never, // trainSchedulingService
|
{} as never, // trainSchedulingService
|
||||||
|
{} as never, // bookingBatchService
|
||||||
{} as never, // bookingTransitionService
|
{} as never, // bookingTransitionService
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
|||||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||||
|
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||||
|
|
||||||
@@ -40,6 +42,11 @@ import { CreateBookingUnderContractDto } from './dto/create-booking-under-contra
|
|||||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||||
|
|
||||||
|
/** Bookings that never shipped release their quantity hold on the contract. */
|
||||||
|
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||||
|
|
||||||
|
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
|
||||||
|
|
||||||
export interface CreateBookingUnderContractResult {
|
export interface CreateBookingUnderContractResult {
|
||||||
booking: Booking;
|
booking: Booking;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
@@ -74,6 +81,8 @@ export class ContractBookingService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
@Inject(forwardRef(() => TrainSchedulingService))
|
@Inject(forwardRef(() => TrainSchedulingService))
|
||||||
private readonly trainSchedulingService: TrainSchedulingService,
|
private readonly trainSchedulingService: TrainSchedulingService,
|
||||||
|
@Inject(forwardRef(() => BookingBatchService))
|
||||||
|
private readonly bookingBatchService: BookingBatchService,
|
||||||
@Inject(forwardRef(() => BookingTransitionService))
|
@Inject(forwardRef(() => BookingTransitionService))
|
||||||
private readonly bookingTransitionService: BookingTransitionService,
|
private readonly bookingTransitionService: BookingTransitionService,
|
||||||
) {}
|
) {}
|
||||||
@@ -90,10 +99,22 @@ export class ContractBookingService {
|
|||||||
// A contract whose quantity cap was fully booked is completed — no further
|
// A contract whose quantity cap was fully booked is completed — no further
|
||||||
// bookings, even while contract validity and a booking window are still
|
// bookings, even while contract validity and a booking window are still
|
||||||
// open. Capacity released after closure (a cancelled/expired booking)
|
// open. Capacity released after closure (a cancelled/expired booking)
|
||||||
// reopens the contract on the next booking attempt.
|
// reopens the contract on the next booking attempt. A ONE_TIME contract
|
||||||
|
// only closes via a finished split chain, so its room is the outstanding
|
||||||
|
// split remainder rather than a cap line.
|
||||||
if (contract.status === 'CONTRACT_CLOSED') {
|
if (contract.status === 'CONTRACT_CLOSED') {
|
||||||
const capacity = await this.computeCapacity(contract);
|
let hasRoom: boolean;
|
||||||
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
|
if (contract.contractKind !== 'GENERAL') {
|
||||||
|
const outstanding = await this.splitOutstanding(contract);
|
||||||
|
hasRoom = outstanding
|
||||||
|
? contract.freightType === 'CONTAINER'
|
||||||
|
? [...outstanding.bySize.values()].some((s) => s.outstanding > 0)
|
||||||
|
: (outstanding.bulk?.outstanding ?? 0) > 0.001
|
||||||
|
: false;
|
||||||
|
} else {
|
||||||
|
const capacity = await this.computeCapacity(contract);
|
||||||
|
hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
|
||||||
|
}
|
||||||
if (!hasRoom) {
|
if (!hasRoom) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'This contract is completed — the full contracted quantity has been booked.',
|
'This contract is completed — the full contracted quantity has been booked.',
|
||||||
@@ -121,12 +142,21 @@ export class ContractBookingService {
|
|||||||
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||||
// letting the customer re-book within contract validity (doc §10.4).
|
// letting the customer re-book within contract validity (doc §10.4).
|
||||||
|
// EXCEPTION — split chain: a paid partial split (booking.isSplit) releases
|
||||||
|
// the slot for the leftover, but the next booking must take the WHOLE
|
||||||
|
// remainder; the customer cannot start any other booking on the contract.
|
||||||
|
// If the remainder splits again the same rule repeats until the cap is
|
||||||
|
// exhausted and the contract completes.
|
||||||
if (contract.contractKind === 'ONE_TIME') {
|
if (contract.contractKind === 'ONE_TIME') {
|
||||||
const active = await this.countActiveBookings(contractId);
|
if (await this.hasSplitBooking(contractId)) {
|
||||||
if (active > 0) {
|
await this.assertExactRemainder(contract, dto);
|
||||||
throw new BadRequestException(
|
} else {
|
||||||
'This one-time contract already has an active booking.',
|
const active = await this.countActiveBookings(contractId);
|
||||||
);
|
if (active > 0) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This one-time contract already has an active booking.',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// GENERAL: draw down against the cargo quantity cap until it is full.
|
// GENERAL: draw down against the cargo quantity cap until it is full.
|
||||||
@@ -181,6 +211,10 @@ export class ContractBookingService {
|
|||||||
scheduledDate: dto.scheduledDate ?? null,
|
scheduledDate: dto.scheduledDate ?? null,
|
||||||
direction: contract.tradeDirection ?? null,
|
direction: contract.tradeDirection ?? null,
|
||||||
});
|
});
|
||||||
|
// EXPORT rides whole or not at all (no split concept): reject the booking
|
||||||
|
// up front when no single open train on the day can carry it, telling the
|
||||||
|
// customer how much space is still bookable.
|
||||||
|
await this.assertExportTrainSpace(contract, route, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard capacity gate: a container line whose total weight exceeds the
|
// Hard capacity gate: a container line whose total weight exceeds the
|
||||||
@@ -573,6 +607,31 @@ export class ContractBookingService {
|
|||||||
Number(booking.cargoTotalWeightVgm) > 0;
|
Number(booking.cargoTotalWeightVgm) > 0;
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// EXPORT rides whole or not at all (no split concept): the chosen day must
|
||||||
|
// have a single open train that carries the whole booking. First completion
|
||||||
|
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
|
||||||
|
// persisted, only the day re-picked) sizes from the booking itself.
|
||||||
|
if (contract.tradeDirection === 'EXPORT') {
|
||||||
|
if (hasCargo) {
|
||||||
|
const probe = Object.assign(
|
||||||
|
Object.create(Object.getPrototypeOf(booking)),
|
||||||
|
booking,
|
||||||
|
{ scheduledDate: new Date(dto.scheduledDate) },
|
||||||
|
) as Booking;
|
||||||
|
const report = await this.bookingBatchService.exportSpaceReport(probe);
|
||||||
|
if (!report.scheduleId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
report.fullMessage ?? 'Not enough train space for this day.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.assertExportTrainSpace(contract, null, dto, {
|
||||||
|
originYardId: booking.originYardId ?? null,
|
||||||
|
destinationYardId: booking.destinationYardId ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// First completion persists cargo and draws contract capacity; a resubmit
|
// First completion persists cargo and draws contract capacity; a resubmit
|
||||||
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
||||||
// the shipment day.
|
// the shipment day.
|
||||||
@@ -869,6 +928,196 @@ export class ContractBookingService {
|
|||||||
.getCount();
|
.getCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the contract is in split-remainder mode: some booking on it was
|
||||||
|
* reduced by a paid partial batch offer and still holds capacity. A split
|
||||||
|
* booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its
|
||||||
|
* hold and the contract falls back to the plain single-slot rule — the
|
||||||
|
* customer can rebook the whole quantity again.
|
||||||
|
*/
|
||||||
|
private async hasSplitBooking(contractId: string): Promise<boolean> {
|
||||||
|
const count = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.contract_id = :contractId', { contractId })
|
||||||
|
.andWhere('b.is_split = true')
|
||||||
|
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
|
||||||
|
.getCount();
|
||||||
|
return count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outstanding split remainder of a ONE_TIME contract: what the FIRST split
|
||||||
|
* booking carried before its reduction (its pre_split_quantities snapshot —
|
||||||
|
* one-time contracts have no quantity cap to derive this from) minus
|
||||||
|
* everything currently booked on the contract. Bookings that never shipped
|
||||||
|
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
|
||||||
|
* contract has no live split booking.
|
||||||
|
*/
|
||||||
|
private async splitOutstanding(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
|
||||||
|
const first = await this.dataSource
|
||||||
|
.getRepository(Booking)
|
||||||
|
.createQueryBuilder('b')
|
||||||
|
.where('b.contract_id = :contractId', { contractId: contract.id })
|
||||||
|
.andWhere('b.is_split = true')
|
||||||
|
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
|
||||||
|
.orderBy('b.created_at', 'ASC')
|
||||||
|
.getOne();
|
||||||
|
if (!first?.preSplitQuantities) return null;
|
||||||
|
|
||||||
|
const booked = await this.bookedQuantities(contract);
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
const bySize = new Map<string, { total: number; outstanding: number }>();
|
||||||
|
for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) {
|
||||||
|
bySize.set(size, {
|
||||||
|
total: Number(total),
|
||||||
|
outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { bySize, bulk: null };
|
||||||
|
}
|
||||||
|
const total = Number(first.preSplitQuantities.bulkTons ?? 0);
|
||||||
|
return {
|
||||||
|
bySize: new Map(),
|
||||||
|
bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EXPORT whole-booking single-train gate. Export bookings never split — the
|
||||||
|
* entire booking must ride ONE open train on the chosen day. When no train
|
||||||
|
* fits it whole (trying every fillable train on the corridor, earliest
|
||||||
|
* first), reject BEFORE anything is written, with the largest still-bookable
|
||||||
|
* space (tons for bulk via the cargo type's wagon type; wagons/containers
|
||||||
|
* for container freight) so the customer knows what he CAN book.
|
||||||
|
*/
|
||||||
|
private async assertExportTrainSpace(
|
||||||
|
contract: Contract,
|
||||||
|
route: ContractRoute | null,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
yards?: { originYardId: string | null; destinationYardId: string | null },
|
||||||
|
): Promise<void> {
|
||||||
|
if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return;
|
||||||
|
const probe = await this.buildExportProbe(contract, route, dto, yards);
|
||||||
|
const report = await this.bookingBatchService.exportSpaceReport(probe);
|
||||||
|
if (report.scheduleId) return;
|
||||||
|
throw new BadRequestException(
|
||||||
|
report.fullMessage ?? 'Not enough train space for this day.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsaved booking twin carrying exactly what the batch engine's capacity
|
||||||
|
* math reads: yards + day for the leg, container lines WITH their container
|
||||||
|
* type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type
|
||||||
|
* (wagon-type FK) for tons→wagons conversion.
|
||||||
|
*/
|
||||||
|
private async buildExportProbe(
|
||||||
|
contract: Contract,
|
||||||
|
route: ContractRoute | null,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
yards?: { originYardId: string | null; destinationYardId: string | null },
|
||||||
|
): Promise<Booking> {
|
||||||
|
const probe = new Booking();
|
||||||
|
probe.freightType = contract.freightType;
|
||||||
|
probe.tradeDirection = contract.tradeDirection;
|
||||||
|
probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null;
|
||||||
|
// Entity types are non-nullable; a missing yard just makes legOf() match no
|
||||||
|
// train, which surfaces as "no export train for this day" — the right failure.
|
||||||
|
probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string;
|
||||||
|
probe.destinationYardId = (yards?.destinationYardId ??
|
||||||
|
route?.destinationYardId) as string;
|
||||||
|
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
const lines = await Promise.all(
|
||||||
|
(dto.containers ?? []).map(async (line) => {
|
||||||
|
const ct = await this.resolveContainerTypeForSize(
|
||||||
|
line.containerSize,
|
||||||
|
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||||
|
);
|
||||||
|
const bc = new BookingContainer();
|
||||||
|
bc.containerSize = line.containerSize;
|
||||||
|
bc.quantity = line.quantity;
|
||||||
|
bc.containerTypeId = ct.id;
|
||||||
|
bc.containerType = ct;
|
||||||
|
bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1));
|
||||||
|
bc.totalVgmTons = (line.units ?? []).reduce(
|
||||||
|
(sum, u) => sum + Number(u.vgmTons ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return bc;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
probe.bookingContainers = lines;
|
||||||
|
probe.cargoTotalWeightVgm = lines.reduce(
|
||||||
|
(sum, l) => sum + Number(l.totalVgmTons ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
||||||
|
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||||
|
probe.cargoTypeId = cargoTypeId;
|
||||||
|
if (cargoTypeId) {
|
||||||
|
probe.cargoType =
|
||||||
|
(await this.dataSource
|
||||||
|
.getRepository(CargoType)
|
||||||
|
.findOne({ where: { id: cargoTypeId } })) ?? undefined;
|
||||||
|
}
|
||||||
|
return probe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONE_TIME split chain: the next booking must take the WHOLE outstanding
|
||||||
|
* remainder — a one-time contract is a single shipment, so the only way it
|
||||||
|
* fragments is the system splitting it on train capacity, never the customer
|
||||||
|
* choosing a partial amount.
|
||||||
|
*/
|
||||||
|
private async assertExactRemainder(
|
||||||
|
contract: Contract,
|
||||||
|
dto: CreateBookingUnderContractDto,
|
||||||
|
): Promise<void> {
|
||||||
|
const outstanding = await this.splitOutstanding(contract);
|
||||||
|
if (!outstanding) return; // no live split booking — nothing to pin the remainder to
|
||||||
|
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
const sizes = new Set<string>([
|
||||||
|
...outstanding.bySize.keys(),
|
||||||
|
...(dto.containers ?? []).map((l) => l.containerSize ?? ''),
|
||||||
|
]);
|
||||||
|
for (const size of sizes) {
|
||||||
|
const remaining = outstanding.bySize.get(size)?.outstanding ?? 0;
|
||||||
|
const requested = (dto.containers ?? [])
|
||||||
|
.filter((l) => (l.containerSize ?? '') === size)
|
||||||
|
.reduce((sum, l) => sum + Number(l.quantity ?? 0), 0);
|
||||||
|
if (requested !== remaining) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This one-time contract was split — the next booking must take the whole remainder: ` +
|
||||||
|
`${remaining} × ${size || 'container'} container(s), got ${requested}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested =
|
||||||
|
(dto.bulkLines ?? []).reduce(
|
||||||
|
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
||||||
|
0,
|
||||||
|
) || this.resolveBulkTons(dto) || 0;
|
||||||
|
const remaining = outstanding.bulk?.outstanding ?? 0;
|
||||||
|
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
|
||||||
|
if (Math.abs(requested - remaining) > 0.001) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`This one-time contract was split — the next booking must take the whole ` +
|
||||||
|
`remaining ${remaining} tons, got ${requested}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
|
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -980,34 +1229,96 @@ export class ContractBookingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap
|
||||||
|
* lines as-is, or — for a ONE_TIME contract in split-remainder mode —
|
||||||
|
* synthesized lines whose cap is the first split booking's pre-split
|
||||||
|
* snapshot and whose remaining is the outstanding remainder, i.e. the exact
|
||||||
|
* quantity the next booking must take.
|
||||||
|
*/
|
||||||
|
async capacityView(
|
||||||
|
contract: Contract,
|
||||||
|
): Promise<
|
||||||
|
Array<{
|
||||||
|
containerSize?: string | null;
|
||||||
|
cargoTypeId?: string | null;
|
||||||
|
cap: number | null;
|
||||||
|
booked: number;
|
||||||
|
remaining: number | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const capacity = await this.computeCapacity(contract);
|
||||||
|
if (capacity.length > 0 || contract.contractKind === 'GENERAL') {
|
||||||
|
return capacity;
|
||||||
|
}
|
||||||
|
const outstanding = await this.splitOutstanding(contract);
|
||||||
|
if (!outstanding) return capacity;
|
||||||
|
if (contract.freightType === 'CONTAINER') {
|
||||||
|
return [...outstanding.bySize.entries()].map(([size, s]) => ({
|
||||||
|
containerSize: size,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cap: s.total,
|
||||||
|
booked: s.total - s.outstanding,
|
||||||
|
remaining: s.outstanding,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
const bulk = outstanding.bulk;
|
||||||
|
if (!bulk) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
containerSize: null,
|
||||||
|
cargoTypeId: null,
|
||||||
|
cap: bulk.total,
|
||||||
|
booked: round3(bulk.total - bulk.outstanding),
|
||||||
|
remaining: bulk.outstanding,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete the contract once its quantity cap is fully consumed. Runs after
|
* Complete the contract once its quantity cap is fully consumed. Runs after
|
||||||
* every booking created under a GENERAL contract (including a split remainder
|
* every booking created under a GENERAL contract, and under a ONE_TIME
|
||||||
* being rebooked): when no capped scope line has capacity left, the contract
|
* contract in split-remainder mode (a split remainder being rebooked): when
|
||||||
* moves to CONTRACT_CLOSED even though its validity window is still open —
|
* no capped scope line has capacity left, the contract moves to
|
||||||
* blocking further bookings and shipment requests, including inside an open
|
* CONTRACT_CLOSED even though its validity window is still open — blocking
|
||||||
* booking window. Never throws: a status hiccup must not undo the booking
|
* further bookings and shipment requests, including inside an open booking
|
||||||
* that was just created.
|
* window. Never throws: a status hiccup must not undo the booking that was
|
||||||
|
* just created.
|
||||||
*/
|
*/
|
||||||
private async maybeCompleteContract(contract: Contract): Promise<void> {
|
private async maybeCompleteContract(contract: Contract): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// ONE_TIME contracts are governed by the single-active-booking slot (and
|
|
||||||
// are promoted to GENERAL on split), so only GENERAL completes by cap.
|
|
||||||
if (contract.contractKind !== 'GENERAL') return;
|
|
||||||
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
|
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
|
||||||
const capacity = await this.computeCapacity(contract);
|
|
||||||
if (capacity.length === 0) return; // uncapped — completes only by expiry
|
// ONE_TIME contracts are governed by the single-active-booking slot, so
|
||||||
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
|
// they normally complete by expiry — EXCEPT once a booking was split: the
|
||||||
// 3 decimals); container caps are integers and unaffected.
|
// remainder chain draws down the split booking's pre-split snapshot, and
|
||||||
const exhausted = capacity.every(
|
// the contract completes when the outstanding remainder hits zero.
|
||||||
(c) => c.remaining != null && c.remaining <= 0.001,
|
// (An unsplit ONE_TIME never completes here, so re-booking after an
|
||||||
);
|
// expired unpaid booking keeps working.)
|
||||||
if (!exhausted) return;
|
if (contract.contractKind !== 'GENERAL') {
|
||||||
|
const outstanding = await this.splitOutstanding(contract);
|
||||||
|
if (!outstanding) return;
|
||||||
|
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round
|
||||||
|
// to 3 decimals); container quantities are integers and unaffected.
|
||||||
|
const exhausted =
|
||||||
|
contract.freightType === 'CONTAINER'
|
||||||
|
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
|
||||||
|
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
|
||||||
|
if (!exhausted) return;
|
||||||
|
} else {
|
||||||
|
const capacity = await this.computeCapacity(contract);
|
||||||
|
if (capacity.length === 0) return; // uncapped — completes only by expiry
|
||||||
|
const exhausted = capacity.every(
|
||||||
|
(c) => c.remaining != null && c.remaining <= 0.001,
|
||||||
|
);
|
||||||
|
if (!exhausted) return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.contractsRepository.update(contract.id, {
|
await this.contractsRepository.update(contract.id, {
|
||||||
status: 'CONTRACT_CLOSED',
|
status: 'CONTRACT_CLOSED',
|
||||||
} as never);
|
} as never);
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`,
|
`Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -1025,7 +1336,7 @@ export class ContractBookingService {
|
|||||||
private async bookedQuantities(
|
private async bookedQuantities(
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
): Promise<{ bySize: Map<string, number>; bulk: number }> {
|
): Promise<{ bySize: Map<string, number>; bulk: number }> {
|
||||||
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
const releasing = RELEASING_BOOKING_STATUSES;
|
||||||
if (contract.freightType === 'CONTAINER') {
|
if (contract.freightType === 'CONTAINER') {
|
||||||
const rows = await this.dataSource
|
const rows = await this.dataSource
|
||||||
.getRepository(BookingContainer)
|
.getRepository(BookingContainer)
|
||||||
@@ -1213,6 +1524,8 @@ export class ContractBookingService {
|
|||||||
currency: string | null;
|
currency: string | null;
|
||||||
pairingErrors: string[];
|
pairingErrors: string[];
|
||||||
capacityErrors: string[];
|
capacityErrors: string[];
|
||||||
|
containerClashErrors: string[];
|
||||||
|
spaceErrors: string[];
|
||||||
lineItems: PriceLineItemDto[];
|
lineItems: PriceLineItemDto[];
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
}> {
|
}> {
|
||||||
@@ -1227,6 +1540,8 @@ export class ContractBookingService {
|
|||||||
currency: null,
|
currency: null,
|
||||||
pairingErrors: [],
|
pairingErrors: [],
|
||||||
capacityErrors: [],
|
capacityErrors: [],
|
||||||
|
containerClashErrors: [],
|
||||||
|
spaceErrors: [],
|
||||||
lineItems: [],
|
lineItems: [],
|
||||||
totalAmount: 0,
|
totalAmount: 0,
|
||||||
};
|
};
|
||||||
@@ -1312,12 +1627,50 @@ export class ContractBookingService {
|
|||||||
contract.tradeDirection,
|
contract.tradeDirection,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A physical container rides one train only — surface a clash with another
|
||||||
|
// active booking on the same day + route in the preview, so the form can
|
||||||
|
// hard-block before the create call rejects with the same rule.
|
||||||
|
let containerClashErrors: string[] = [];
|
||||||
|
if (dto.scheduledDate) {
|
||||||
|
const numbers = lines.flatMap((line) =>
|
||||||
|
(line.units ?? [])
|
||||||
|
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
|
||||||
|
.filter((n) => n.length > 0),
|
||||||
|
);
|
||||||
|
const clashes = await this.findContainerClashesOnTrain(
|
||||||
|
[...new Set(numbers)],
|
||||||
|
dto.scheduledDate,
|
||||||
|
{
|
||||||
|
originYardId: route?.originYardId,
|
||||||
|
destinationYardId: route?.destinationYardId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
containerClashErrors = clashes.map(
|
||||||
|
(c) =>
|
||||||
|
`${c.containerNumber} is already booked on ${c.reference} for this shipment day.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXPORT rides whole or not at all — surface the single-train space check
|
||||||
|
// in the preview so the form hard-blocks BEFORE the create call rejects
|
||||||
|
// with the same message (including how much space is still bookable).
|
||||||
|
let spaceErrors: string[] = [];
|
||||||
|
if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) {
|
||||||
|
const probe = await this.buildExportProbe(contract, route, dto);
|
||||||
|
const report = await this.bookingBatchService.exportSpaceReport(probe);
|
||||||
|
if (!report.scheduleId) {
|
||||||
|
spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overweightLines: computed.overweightLines,
|
overweightLines: computed.overweightLines,
|
||||||
overweightSurchargeAmount,
|
overweightSurchargeAmount,
|
||||||
currency: computed.currency,
|
currency: computed.currency,
|
||||||
pairingErrors,
|
pairingErrors,
|
||||||
capacityErrors,
|
capacityErrors,
|
||||||
|
containerClashErrors,
|
||||||
|
spaceErrors,
|
||||||
lineItems: computed.lineItems,
|
lineItems: computed.lineItems,
|
||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
};
|
};
|
||||||
@@ -1445,6 +1798,37 @@ export class ContractBookingService {
|
|||||||
route: { originYardId?: string | null; destinationYardId?: string | null },
|
route: { originYardId?: string | null; destinationYardId?: string | null },
|
||||||
excludeBookingId?: string,
|
excludeBookingId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const clashes = await this.findContainerClashesOnTrain(
|
||||||
|
numbers,
|
||||||
|
scheduledDate,
|
||||||
|
route,
|
||||||
|
excludeBookingId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (clashes.length) {
|
||||||
|
const detail = clashes
|
||||||
|
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
|
||||||
|
.join(', ');
|
||||||
|
throw new ConflictException(
|
||||||
|
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
|
||||||
|
'A container can only be on one booking per train — remove it or pick another shipment day.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container numbers among `numbers` that already sit on another active
|
||||||
|
* booking of the same train — same day and same route. One row per clashing
|
||||||
|
* number. Bookings without route yards (legacy rows) match on the day alone
|
||||||
|
* rather than let through.
|
||||||
|
*/
|
||||||
|
private async findContainerClashesOnTrain(
|
||||||
|
numbers: string[],
|
||||||
|
scheduledDate: string,
|
||||||
|
route: { originYardId?: string | null; destinationYardId?: string | null },
|
||||||
|
excludeBookingId?: string,
|
||||||
|
): Promise<Array<{ containerNumber: string; reference: string }>> {
|
||||||
|
if (!numbers.length) return [];
|
||||||
const qb = this.dataSource
|
const qb = this.dataSource
|
||||||
.getRepository(BookingContainerUnit)
|
.getRepository(BookingContainerUnit)
|
||||||
.createQueryBuilder('unit')
|
.createQueryBuilder('unit')
|
||||||
@@ -1474,18 +1858,7 @@ export class ContractBookingService {
|
|||||||
}
|
}
|
||||||
const clashes: Array<{ containerNumber: string; reference: string }> =
|
const clashes: Array<{ containerNumber: string; reference: string }> =
|
||||||
await qb.getRawMany();
|
await qb.getRawMany();
|
||||||
|
return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()];
|
||||||
if (clashes.length) {
|
|
||||||
const detail = [
|
|
||||||
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
|
|
||||||
]
|
|
||||||
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
|
|
||||||
.join(', ');
|
|
||||||
throw new ConflictException(
|
|
||||||
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
|
|
||||||
'A container can only be on one booking per train — remove it or pick another shipment day.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async assert20ftPairableAtCreate(
|
private async assert20ftPairableAtCreate(
|
||||||
@@ -1528,8 +1901,8 @@ export class ContractBookingService {
|
|||||||
preferReefer: boolean,
|
preferReefer: boolean,
|
||||||
): Promise<ContainerType> {
|
): Promise<ContainerType> {
|
||||||
const sizeFt = parseInt(size, 10);
|
const sizeFt = parseInt(size, 10);
|
||||||
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
|
const { items } = await this.containerTypesService.findAll({ pageSize: 100 });
|
||||||
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
||||||
if (!types.length) {
|
if (!types.length) {
|
||||||
throw new BadRequestException(`No container type configured for size ${size}.`);
|
throw new BadRequestException(`No container type configured for size ${size}.`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -866,12 +866,38 @@ export class ContractClearanceService {
|
|||||||
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
* Operations queue: self-clearance (Path A) contracts awaiting Operations
|
||||||
* review of the customer's own clearance documents.
|
* review of the customer's own clearance documents.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Statuses a non-customs contract passes through around Operations
|
||||||
|
* clearance review — the set a caller may narrow {@link opsQueue} to.
|
||||||
|
*/
|
||||||
|
private static readonly OPS_CLEARANCE_STATUSES = [
|
||||||
|
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||||
|
'CLEARANCE_UNDER_REVIEW',
|
||||||
|
'CLEARANCE_READY_FOR_BOOKING',
|
||||||
|
'FULLY_EXECUTED',
|
||||||
|
'CONTRACT_ACTIVE',
|
||||||
|
'ACTIVE_SHIPMENT_IN_PROGRESS',
|
||||||
|
'CONTRACT_CLOSED',
|
||||||
|
'CANCELLED',
|
||||||
|
];
|
||||||
|
|
||||||
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
async opsQueue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||||
|
// Callers may narrow to any subset of the ops-clearance lifecycle (the
|
||||||
|
// hub's status filter sends an explicit list); anything outside the
|
||||||
|
// whitelist is dropped so this endpoint can't become a general contract
|
||||||
|
// browser. No statuses given → the original under-review queue.
|
||||||
|
const requested = (filter.statuses ?? filter.status ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) =>
|
||||||
|
ContractClearanceService.OPS_CLEARANCE_STATUSES.includes(s),
|
||||||
|
);
|
||||||
return this.contractsRepository.findAllPaginated({
|
return this.contractsRepository.findAllPaginated({
|
||||||
page: filter.page ?? 1,
|
page: filter.page ?? 1,
|
||||||
pageSize: filter.pageSize ?? 100,
|
pageSize: filter.pageSize ?? 100,
|
||||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
|
||||||
customsClearingEnabled: false,
|
customsClearingEnabled: false,
|
||||||
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
@@ -896,6 +922,7 @@ export class ContractClearanceService {
|
|||||||
pageSize: filter.pageSize ?? 50,
|
pageSize: filter.pageSize ?? 50,
|
||||||
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
||||||
customsClearingEnabled: false,
|
customsClearingEnabled: false,
|
||||||
|
search: filter.search,
|
||||||
sortBy: filter.sortBy ?? 'createdAt',
|
sortBy: filter.sortBy ?? 'createdAt',
|
||||||
sortOrder: filter.sortOrder ?? 'DESC',
|
sortOrder: filter.sortOrder ?? 'DESC',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,6 +145,19 @@ export class ContractNotifierService {
|
|||||||
this.inApp(c, 'Contract changes requested', msg);
|
this.inApp(c, 'Contract changes requested', msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GL rejected a shipment request filed under the contract. */
|
||||||
|
shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void {
|
||||||
|
const msg =
|
||||||
|
`Your shipment request ${requestRef} under contract ${c.reference} was rejected.` +
|
||||||
|
(note ? ` Reason: ${note}.` : '') +
|
||||||
|
` Please contact us for details.`;
|
||||||
|
void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED');
|
||||||
|
this.inApp(c, 'Shipment request rejected', msg, {
|
||||||
|
type: NotificationType.BOOKING_STATUS,
|
||||||
|
data: { contractId: c.id, reference: requestRef },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clearance milestones needing customer action ──────────────────────────
|
// ── Clearance milestones needing customer action ──────────────────────────
|
||||||
|
|
||||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||||
|
|||||||
@@ -80,9 +80,9 @@ export class ContractPricingService {
|
|||||||
const sizes = (contract.cargoScope ?? [])
|
const sizes = (contract.cargoScope ?? [])
|
||||||
.map((c) => c.containerSize)
|
.map((c) => c.containerSize)
|
||||||
.filter((s): s is string => !!s);
|
.filter((s): s is string => !!s);
|
||||||
const { data: containerTypes } = await this.containerTypesService.findAll({
|
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||||
isActive: true,
|
isActive: true,
|
||||||
pageSize: 500,
|
pageSize: 100,
|
||||||
});
|
});
|
||||||
for (const size of sizes) {
|
for (const size of sizes) {
|
||||||
const sizeFt = size === '40ft' ? 40 : 20;
|
const sizeFt = size === '40ft' ? 40 : 20;
|
||||||
|
|||||||
@@ -852,11 +852,12 @@ export class ContractsController {
|
|||||||
|
|
||||||
@Get(':id/capacity')
|
@Get(':id/capacity')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
|
summary:
|
||||||
|
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
|
||||||
})
|
})
|
||||||
async capacity(@Param('id', ParseUUIDPipe) id: string) {
|
async capacity(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
const contract = await this.contractsService.findById(id);
|
const contract = await this.contractsService.findById(id);
|
||||||
return this.contractBookingService.computeCapacity(contract);
|
return this.contractBookingService.capacityView(contract);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
options: ContractListFilterOptions & {
|
options: ContractListFilterOptions & {
|
||||||
page: number;
|
page: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
sortBy?: string;
|
sortBy?: string;
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
sortOrder?: 'ASC' | 'DESC';
|
||||||
},
|
},
|
||||||
@@ -128,6 +129,16 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
|
|
||||||
this.applyListFilters(qb, options);
|
this.applyListFilters(qb, options);
|
||||||
|
|
||||||
|
// Free-text search across contract reference and customer (company) name.
|
||||||
|
// Applied here (not in applyListFilters) because only this query joins the
|
||||||
|
// `company` alias — the summary-metrics query builder does not.
|
||||||
|
if (options.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(contract.reference ILIKE :search OR company.name ILIKE :search)',
|
||||||
|
{ search: `%${options.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const sortField =
|
const sortField =
|
||||||
options.sortBy === 'contractValidUntil'
|
options.sortBy === 'contractValidUntil'
|
||||||
? 'contract.contractValidUntil'
|
? 'contract.contractValidUntil'
|
||||||
|
|||||||
@@ -579,6 +579,7 @@ export class ContractsService {
|
|||||||
paymentCurrency: filter.paymentCurrency,
|
paymentCurrency: filter.paymentCurrency,
|
||||||
createdFrom: filter.createdFrom,
|
createdFrom: filter.createdFrom,
|
||||||
createdTo: filter.createdTo,
|
createdTo: filter.createdTo,
|
||||||
|
search: filter.search,
|
||||||
sortBy: filter.sortBy,
|
sortBy: filter.sortBy,
|
||||||
sortOrder: filter.sortOrder,
|
sortOrder: filter.sortOrder,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ export class FilterContractDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
createdTo?: string;
|
createdTo?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Free-text search across contract reference and company name.',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||||
|
)
|
||||||
|
search?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 1 })
|
@ApiPropertyOptional({ default: 1 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
|
Query,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { FreightAdmin } from "../../common/booking-guards";
|
import { FreightAdmin } from "../../common/booking-guards";
|
||||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||||
|
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||||
import { DropdownSettingsService } from "./dropdown-settings.service";
|
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||||
@@ -34,6 +36,15 @@ export class DropdownSettingsController {
|
|||||||
return this.service.list();
|
return this.service.list();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Must be declared before @Get(":id") so "paged" isn't captured as an id.
|
||||||
|
@Get("paged")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Paged admin listing of dropdown settings (server-side search)",
|
||||||
|
})
|
||||||
|
listPaged(@Query() query: ListDropdownSettingsQueryDto) {
|
||||||
|
return this.service.listPaged(query);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
@ApiOperation({ summary: "Get a dropdown setting by ID" })
|
@ApiOperation({ summary: "Get a dropdown setting by ID" })
|
||||||
getById(@Param("id", ParseUUIDPipe) id: string) {
|
getById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { BaseRepository } from "@edr/api-common";
|
import { BaseRepository } from "@edr/api-common";
|
||||||
|
import { PaginatedResponse } from "@edr/types";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { InjectRepository } from "@nestjs/typeorm";
|
import { InjectRepository } from "@nestjs/typeorm";
|
||||||
import { Repository } from "typeorm";
|
import { Repository } from "typeorm";
|
||||||
|
|
||||||
|
import { paginateQuery } from "../../common/utils/pagination.util";
|
||||||
|
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||||
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
import { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||||
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
|
import type { IDropdownSettingsRepository } from "./interfaces/dropdown-settings.repository.interface";
|
||||||
@@ -44,6 +47,27 @@ export class DropdownSettingsRepository
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findPaged(
|
||||||
|
query: ListDropdownSettingsQueryDto,
|
||||||
|
): Promise<PaginatedResponse<DropdownSetting>> {
|
||||||
|
// Soft-deleted rows are excluded automatically by the query builder
|
||||||
|
// (BaseEntity's deletedAt column). Ordering mirrors findAll (label ASC).
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder("setting")
|
||||||
|
.leftJoinAndSelect("setting.children", "option")
|
||||||
|
.orderBy("setting.label", query.sortOrder ?? "ASC")
|
||||||
|
.addOrderBy("option.order", "ASC");
|
||||||
|
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
"(setting.code ILIKE :search OR setting.label ILIKE :search OR setting.description ILIKE :search)",
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async replaceOptions(
|
async replaceOptions(
|
||||||
settingId: string,
|
settingId: string,
|
||||||
options: Array<Partial<DropdownOption>>,
|
options: Array<Partial<DropdownOption>>,
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
import { PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto";
|
||||||
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto";
|
||||||
|
import { ListDropdownSettingsQueryDto } from "./dto/list-dropdown-settings-query.dto";
|
||||||
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto";
|
||||||
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
import { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||||
@@ -16,71 +19,6 @@ import {
|
|||||||
IDropdownSettingsRepository,
|
IDropdownSettingsRepository,
|
||||||
} from "./interfaces/dropdown-settings.repository.interface";
|
} from "./interfaces/dropdown-settings.repository.interface";
|
||||||
|
|
||||||
const STATIONS_TER_CODE = "stations_ter";
|
|
||||||
|
|
||||||
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
|
|
||||||
{
|
|
||||||
value: "inside_addis_ababa",
|
|
||||||
label: "Addis Ababa",
|
|
||||||
note: "Inside country",
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "inside_adama",
|
|
||||||
label: "Adama",
|
|
||||||
note: "Inside country",
|
|
||||||
order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "inside_mojo",
|
|
||||||
label: "Mojo",
|
|
||||||
note: "Inside country",
|
|
||||||
order: 3,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "inside_awash",
|
|
||||||
label: "Awash",
|
|
||||||
note: "Inside country",
|
|
||||||
order: 4,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "inside_mieso",
|
|
||||||
label: "Mieso",
|
|
||||||
note: "Inside country",
|
|
||||||
order: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "inside_dire_dawa",
|
|
||||||
label: "Dire Dawa",
|
|
||||||
note: "Inside country",
|
|
||||||
order: 6,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "outside_ali_sabieh",
|
|
||||||
label: "Ali Sabieh",
|
|
||||||
note: "Outside country",
|
|
||||||
order: 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "outside_holhol",
|
|
||||||
label: "Holhol",
|
|
||||||
note: "Outside country",
|
|
||||||
order: 8,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "outside_djibouti_city",
|
|
||||||
label: "Djibouti City",
|
|
||||||
note: "Outside country",
|
|
||||||
order: 9,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "outside_doraleh_terminal",
|
|
||||||
label: "Doraleh Terminal",
|
|
||||||
note: "Outside country",
|
|
||||||
order: 10,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DropdownSettingsService {
|
export class DropdownSettingsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -92,6 +30,12 @@ export class DropdownSettingsService {
|
|||||||
return this.repository.findAll();
|
return this.repository.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listPaged(
|
||||||
|
query: ListDropdownSettingsQueryDto,
|
||||||
|
): Promise<PaginatedResponse<DropdownSetting>> {
|
||||||
|
return this.repository.findPaged(query);
|
||||||
|
}
|
||||||
|
|
||||||
async getById(id: string): Promise<DropdownSetting> {
|
async getById(id: string): Promise<DropdownSetting> {
|
||||||
const setting = await this.repository.findById(id);
|
const setting = await this.repository.findById(id);
|
||||||
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||||
@@ -127,34 +71,6 @@ export class DropdownSettingsService {
|
|||||||
return this.getById(setting.id);
|
return this.getById(setting.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async seedDefaultStations(): Promise<void> {
|
|
||||||
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
await this.create({
|
|
||||||
code: STATIONS_TER_CODE,
|
|
||||||
label: "Stations TER",
|
|
||||||
description:
|
|
||||||
"Temporary freight station list used by booking origin and destination yards.",
|
|
||||||
multiple: false,
|
|
||||||
meta: {
|
|
||||||
searchable: true,
|
|
||||||
clearable: true,
|
|
||||||
version: "temporary",
|
|
||||||
},
|
|
||||||
children: DEFAULT_STATION_OPTIONS,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((existing.children?.length ?? 0) === 0) {
|
|
||||||
await this.repository.replaceOptions(
|
|
||||||
existing.id,
|
|
||||||
DEFAULT_STATION_OPTIONS,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
id: string,
|
id: string,
|
||||||
dto: UpdateDropdownSettingDto,
|
dto: UpdateDropdownSettingDto,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query params for the paged admin listing (`GET /dropdown-settings/paged`).
|
||||||
|
* `search` matches code, label and description server-side. The entity has no
|
||||||
|
* status/isActive flag, so the base pagination fields are all that's needed.
|
||||||
|
*/
|
||||||
|
export class ListDropdownSettingsQueryDto extends PaginationQueryDto {}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from "@edr/types";
|
||||||
|
|
||||||
|
import { ListDropdownSettingsQueryDto } from "../dto/list-dropdown-settings-query.dto";
|
||||||
import { DropdownOption } from "../entities/dropdown-option.entity";
|
import { DropdownOption } from "../entities/dropdown-option.entity";
|
||||||
import { DropdownSetting } from "../entities/dropdown-setting.entity";
|
import { DropdownSetting } from "../entities/dropdown-setting.entity";
|
||||||
|
|
||||||
@@ -11,6 +14,9 @@ export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
|
|||||||
|
|
||||||
export interface IDropdownSettingsRepository {
|
export interface IDropdownSettingsRepository {
|
||||||
findAll(): Promise<DropdownSetting[]>;
|
findAll(): Promise<DropdownSetting[]>;
|
||||||
|
findPaged(
|
||||||
|
query: ListDropdownSettingsQueryDto,
|
||||||
|
): Promise<PaginatedResponse<DropdownSetting>>;
|
||||||
findById(id: string): Promise<DropdownSetting | null>;
|
findById(id: string): Promise<DropdownSetting | null>;
|
||||||
findByCode(code: string): Promise<DropdownSetting | null>;
|
findByCode(code: string): Promise<DropdownSetting | null>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proof of delivery captured by the EDR driver when a last-mile leg is
|
||||||
|
* completed. Sent as multipart/form-data — the recipient's signature (field
|
||||||
|
* `signature`) and proof photos (field `photos`) are uploaded alongside these
|
||||||
|
* text fields.
|
||||||
|
*/
|
||||||
|
export class RecordProofOfDeliveryDto {
|
||||||
|
@ApiProperty({ description: 'Name of the person who received the cargo.' })
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(160)
|
||||||
|
recipientName!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Optional delivery notes.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(1000)
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
@@ -70,4 +70,22 @@ export class LastMile extends BaseEntity {
|
|||||||
|
|
||||||
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
@OneToMany(() => LastMileVehicleAssignment, (va) => va.lastMile)
|
||||||
vehicleAssignments?: LastMileVehicleAssignment[];
|
vehicleAssignments?: LastMileVehicleAssignment[];
|
||||||
|
|
||||||
|
// ── Proof of delivery (captured by the EDR driver on completion) ──────────
|
||||||
|
@Column({ name: 'pod_recipient_name', type: 'varchar', length: 160, nullable: true })
|
||||||
|
podRecipientName?: string | null;
|
||||||
|
|
||||||
|
/** File id of the recipient's captured signature (PNG). */
|
||||||
|
@Column({ name: 'pod_signature_file_id', type: 'uuid', nullable: true })
|
||||||
|
podSignatureFileId?: string | null;
|
||||||
|
|
||||||
|
/** File ids of the delivery proof photos. */
|
||||||
|
@Column({ name: 'pod_photo_file_ids', type: 'text', array: true, default: '{}' })
|
||||||
|
podPhotoFileIds!: string[];
|
||||||
|
|
||||||
|
@Column({ name: 'pod_notes', type: 'text', nullable: true })
|
||||||
|
podNotes?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'pod_captured_at', type: 'timestamptz', nullable: true })
|
||||||
|
podCapturedAt?: Date | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
UploadedFiles,
|
||||||
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { BookingStaff } from '../../common/booking-guards';
|
import { BookingStaff } from '../../common/booking-guards';
|
||||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||||
@@ -21,6 +24,7 @@ import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
|||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
import { SetVehiclesDto } from './dto/set-vehicles.dto';
|
||||||
import { SetDistancesDto } from './dto/set-distances.dto';
|
import { SetDistancesDto } from './dto/set-distances.dto';
|
||||||
|
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||||
import { LastMileStatus } from './entities/last-mile.entity';
|
import { LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileService } from './last-mile.service';
|
import { LastMileService } from './last-mile.service';
|
||||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||||
@@ -115,6 +119,19 @@ export class LastMileController {
|
|||||||
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
return this.lastMileService.setDistances(id, dto.distances, dto.remainingPayment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/proof-of-delivery')
|
||||||
|
@BookingStaff(FREIGHT_PERMS.lastMile.update)
|
||||||
|
@UseInterceptors(AnyFilesInterceptor())
|
||||||
|
@ApiConsumes('multipart/form-data')
|
||||||
|
@ApiOperation({ summary: 'Record proof of delivery (signature + photos) and complete the leg' })
|
||||||
|
async recordProofOfDelivery(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: RecordProofOfDeliveryDto,
|
||||||
|
@UploadedFiles() files: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
return this.lastMileService.recordProofOfDelivery(id, dto, files ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/invoice')
|
@Post(':id/invoice')
|
||||||
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
|
@BookingStaff(FREIGHT_PERMS.lastMile.generateInvoice)
|
||||||
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
@ApiOperation({ summary: 'Generate the delivery-fee invoice for a last-mile leg' })
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
|
|
||||||
import { BillingModule } from '../billing/billing.module';
|
import { BillingModule } from '../billing/billing.module';
|
||||||
import { BookingsModule } from '../bookings/bookings.module';
|
import { BookingsModule } from '../bookings/bookings.module';
|
||||||
|
import { FilesModule } from '../files/files.module';
|
||||||
import { DriversModule } from '../drivers/drivers.module';
|
import { DriversModule } from '../drivers/drivers.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { VehiclesModule } from '../vehicles/vehicles.module';
|
import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||||
@@ -22,6 +23,7 @@ import { LastMileService } from './last-mile.service';
|
|||||||
VehiclesModule,
|
VehiclesModule,
|
||||||
DriversModule,
|
DriversModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
FilesModule,
|
||||||
],
|
],
|
||||||
controllers: [LastMileController],
|
controllers: [LastMileController],
|
||||||
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
providers: [LastMileRepository, LastMileService, LastMileInvoiceService],
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { VehiclesService } from '../vehicles/vehicles.service';
|
|||||||
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
|
||||||
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
import { CreateLastMileDto } from './dto/create-last-mile.dto';
|
||||||
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
import { UpdateLastMileDto } from './dto/update-last-mile.dto';
|
||||||
|
import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||||
import { LastMileRepository } from './last-mile.repository';
|
import { LastMileRepository } from './last-mile.repository';
|
||||||
|
import { FilesService } from '../files/files.service';
|
||||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||||
import { OnEvent } from '@nestjs/event-emitter';
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
import { FleetHistoryService } from '../fleet-history/fleet-history.service';
|
||||||
@@ -47,6 +49,7 @@ export class LastMileService {
|
|||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
private readonly history: FleetHistoryService,
|
private readonly history: FleetHistoryService,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly filesService: FilesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Attach real invoice info (number/status) to records so the UI can show an
|
/** Attach real invoice info (number/status) to records so the UI can show an
|
||||||
@@ -210,6 +213,49 @@ export class LastMileService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record proof of delivery (recipient signature + photos + notes) and complete
|
||||||
|
* the leg. Marking DELIVERED reuses {@link update}'s side effects (deliveredAt,
|
||||||
|
* vehicle release, history).
|
||||||
|
*/
|
||||||
|
async recordProofOfDelivery(
|
||||||
|
id: string,
|
||||||
|
dto: RecordProofOfDeliveryDto,
|
||||||
|
files: Express.Multer.File[],
|
||||||
|
): Promise<LastMile> {
|
||||||
|
const existing = await this.findById(id);
|
||||||
|
|
||||||
|
const signature = files.find((f) => f.fieldname === 'signature');
|
||||||
|
const photos = files.filter((f) => f.fieldname === 'photos');
|
||||||
|
|
||||||
|
const signatureFileId = signature
|
||||||
|
? (
|
||||||
|
await this.filesService.upload({
|
||||||
|
resourceId: id,
|
||||||
|
resource: 'last-mile',
|
||||||
|
code: 'pod-signature',
|
||||||
|
file: signature,
|
||||||
|
})
|
||||||
|
).id
|
||||||
|
: null;
|
||||||
|
const photoFileIds = photos.length
|
||||||
|
? (await this.filesService.uploadMany(id, 'last-mile', photos)).map((r) => r.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
await this.lastMileRepository.update(id, {
|
||||||
|
podRecipientName: dto.recipientName.trim(),
|
||||||
|
podSignatureFileId: signatureFileId,
|
||||||
|
podPhotoFileIds: photoFileIds,
|
||||||
|
podNotes: dto.notes?.trim() || null,
|
||||||
|
podCapturedAt: new Date(),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
if (existing.status !== 'DELIVERED') {
|
||||||
|
return this.update(id, { status: 'DELIVERED' } as UpdateLastMileDto);
|
||||||
|
}
|
||||||
|
return this.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
async create(dto: CreateLastMileDto): Promise<LastMile> {
|
||||||
const record = await this.lastMileRepository.create({
|
const record = await this.lastMileRepository.create({
|
||||||
bookingId: dto.bookingId,
|
bookingId: dto.bookingId,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
|
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||||
@@ -19,15 +20,8 @@ export class ApprovalRulesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('approval-rules')
|
@RuleEngineView('approval-rules')
|
||||||
@ApiOperation({ summary: 'List approval rules' })
|
@ApiOperation({ summary: 'List approval rules' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListApprovalRulesQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
requiresDirectorApproval:
|
|
||||||
query['requiresDirectorApproval'] !== undefined
|
|
||||||
? query['requiresDirectorApproval'] === 'true'
|
|
||||||
: undefined,
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('chain')
|
@Get('chain')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
|
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
@@ -19,19 +20,8 @@ export class CargoTypesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('cargo-types')
|
@RuleEngineView('cargo-types')
|
||||||
@ApiOperation({ summary: 'List cargo types' })
|
@ApiOperation({ summary: 'List cargo types' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListCargoTypesQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
|
||||||
requiresDirectorApproval: query['requiresDirectorApproval'] !== undefined
|
|
||||||
? query['requiresDirectorApproval'] === 'true'
|
|
||||||
: undefined,
|
|
||||||
parentGroupId: query['parentGroupId'],
|
|
||||||
search: query['search'],
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
sortBy: query['sortBy'],
|
|
||||||
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
|
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
@@ -19,12 +20,8 @@ export class ContainerTypesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('container-types')
|
@RuleEngineView('container-types')
|
||||||
@ApiOperation({ summary: 'List container types' })
|
@ApiOperation({ summary: 'List container types' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListContainerTypesQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||||
|
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
@@ -19,13 +20,8 @@ export class PriorityConfigsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('priority-configs')
|
@RuleEngineView('priority-configs')
|
||||||
@ApiOperation({ summary: 'List priority configs' })
|
@ApiOperation({ summary: 'List priority configs' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListPriorityConfigsQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
type: (query['type'] as 'WAGON' | 'CURRENCY' | 'CUSTOMS') || undefined,
|
|
||||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
|||||||
import { CurrentUser } from '@edr/api-common';
|
import { CurrentUser } from '@edr/api-common';
|
||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import {
|
import {
|
||||||
type AuthUserPayload,
|
type AuthUserPayload,
|
||||||
resolveAuthUserId,
|
resolveAuthUserId,
|
||||||
@@ -22,13 +23,8 @@ export class RatesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('rates')
|
@RuleEngineView('rates')
|
||||||
@ApiOperation({ summary: 'List rates' })
|
@ApiOperation({ summary: 'List rates' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListRatesQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
status: query['status'],
|
|
||||||
rateType: query['rateType'],
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('live')
|
@Get('live')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
|
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
@@ -19,16 +20,8 @@ export class ServiceTypesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('service-types')
|
@RuleEngineView('service-types')
|
||||||
@ApiOperation({ summary: 'List service types' })
|
@ApiOperation({ summary: 'List service types' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListServiceTypesQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
|
||||||
canBeBookedAlone: query['canBeBookedAlone'] !== undefined ? query['canBeBookedAlone'] === 'true' : undefined,
|
|
||||||
search: query['search'],
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
sortBy: query['sortBy'],
|
|
||||||
sortOrder: (query['sortOrder'] as 'ASC' | 'DESC') ?? 'ASC',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||||
|
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
||||||
import { ShippingLinesService } from '../services/shipping-lines.service';
|
import { ShippingLinesService } from '../services/shipping-lines.service';
|
||||||
|
|
||||||
@@ -17,12 +18,8 @@ export class ShippingLinesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('shipping-lines')
|
@RuleEngineView('shipping-lines')
|
||||||
@ApiOperation({ summary: 'List shipping lines' })
|
@ApiOperation({ summary: 'List shipping lines' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListRuleEngineQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||||
|
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
||||||
import { WeightLimitRulesService } from '../services/weight-limit-rules.service';
|
import { WeightLimitRulesService } from '../services/weight-limit-rules.service';
|
||||||
|
|
||||||
@@ -17,13 +18,8 @@ export class WeightLimitRulesController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('weight-limit-rules')
|
@RuleEngineView('weight-limit-rules')
|
||||||
@ApiOperation({ summary: 'List weight limit rules' })
|
@ApiOperation({ summary: 'List weight limit rules' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListWeightLimitRulesQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
tradeDirection: query['tradeDirection'],
|
|
||||||
containerTypeId: query['containerTypeId'],
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
|
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
@@ -19,13 +20,8 @@ export class YardsController {
|
|||||||
@Get()
|
@Get()
|
||||||
@RuleEngineView('yards')
|
@RuleEngineView('yards')
|
||||||
@ApiOperation({ summary: 'List yards' })
|
@ApiOperation({ summary: 'List yards' })
|
||||||
findAll(@Query() query: Record<string, string>) {
|
findAll(@Query() query: ListYardsQueryDto) {
|
||||||
return this.service.findAll({
|
return this.service.findAll(query);
|
||||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
|
||||||
country: query['country'],
|
|
||||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
|
||||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('reorder')
|
@Post('reorder')
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Transform, TransformFnParams } from 'class-transformer';
|
||||||
|
import { IsBoolean, IsIn, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
|
||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query-string booleans arrive as strings; implicit conversion is disabled
|
||||||
|
* app-wide, so coerce explicitly. Mirrors the previous controller behaviour
|
||||||
|
* (`query['flag'] === 'true'`): only the literal "true" is truthy.
|
||||||
|
*/
|
||||||
|
const toOptionalBoolean = ({ value }: TransformFnParams): boolean | undefined =>
|
||||||
|
value === undefined || value === null || value === '' ? undefined : value === true || value === 'true';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared list query for rule-engine resources. Every rule-engine list endpoint
|
||||||
|
* returns the standard `PaginatedResponse` envelope (`items` + `meta`) built by
|
||||||
|
* `common/utils/pagination.util.ts`; `search` is applied server-side against
|
||||||
|
* each resource's human-readable columns (see the repository `findPaged`).
|
||||||
|
*/
|
||||||
|
export class ListRuleEngineQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by active flag.' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toOptionalBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListCargoTypesQueryDto extends ListRuleEngineQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by director-approval requirement.' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toOptionalBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
requiresDirectorApproval?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by parent cargo-type group.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentGroupId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['displayOrder', 'cargoTypeName', 'code', 'createdAt'], default: 'displayOrder' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['displayOrder', 'cargoTypeName', 'code', 'createdAt'])
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListContainerTypesQueryDto extends ListRuleEngineQueryDto {
|
||||||
|
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['displayOrder'])
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListPriorityConfigsQueryDto extends ListRuleEngineQueryDto {
|
||||||
|
@ApiPropertyOptional({ enum: ['WAGON', 'CURRENCY', 'CUSTOMS'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['WAGON', 'CURRENCY', 'CUSTOMS'])
|
||||||
|
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['displayOrder'])
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListServiceTypesQueryDto extends ListRuleEngineQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by standalone-bookable flag.' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toOptionalBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
canBeBookedAlone?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['displayOrder', 'serviceName', 'code', 'createdAt'], default: 'displayOrder' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['displayOrder', 'serviceName', 'code', 'createdAt'])
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListYardsQueryDto extends ListRuleEngineQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by yard country.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
country?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['displayOrder'], default: 'displayOrder' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['displayOrder'])
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListApprovalRulesQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by approval chain (director vs standard).' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toOptionalBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
requiresDirectorApproval?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: ['stepOrder'], default: 'stepOrder' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['stepOrder'])
|
||||||
|
sortBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListRatesQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by rate status (DRAFT, PENDING_APPROVAL, LIVE...).' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(20)
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by derived rate type.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
rateType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListWeightLimitRulesQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by container type.' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
containerTypeId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Filter by trade direction (IMPORT/EXPORT/BOTH).' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(10)
|
||||||
|
tradeDirection?: string;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||||
|
|
||||||
export interface IApprovalRulesRepository {
|
export interface IApprovalRulesRepository {
|
||||||
@@ -6,6 +8,7 @@ export interface IApprovalRulesRepository {
|
|||||||
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
|
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
|
||||||
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
|
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
|
||||||
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
|
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
|
||||||
|
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>>;
|
||||||
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
|
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
|
||||||
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
|
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { CargoType } from '../entities/cargo-type.entity';
|
import { CargoType } from '../entities/cargo-type.entity';
|
||||||
|
|
||||||
export interface ICargoTypesRepository {
|
export interface ICargoTypesRepository {
|
||||||
@@ -6,6 +8,7 @@ export interface ICargoTypesRepository {
|
|||||||
findByCode(code: string): Promise<CargoType | null>;
|
findByCode(code: string): Promise<CargoType | null>;
|
||||||
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
|
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
|
||||||
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
|
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
|
||||||
|
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>>;
|
||||||
create(data: Partial<CargoType>): Promise<CargoType>;
|
create(data: Partial<CargoType>): Promise<CargoType>;
|
||||||
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
|
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ContainerType } from '../entities/container-type.entity';
|
import { ContainerType } from '../entities/container-type.entity';
|
||||||
|
|
||||||
export interface IContainerTypesRepository {
|
export interface IContainerTypesRepository {
|
||||||
@@ -6,6 +8,7 @@ export interface IContainerTypesRepository {
|
|||||||
findByCode(code: string): Promise<ContainerType | null>;
|
findByCode(code: string): Promise<ContainerType | null>;
|
||||||
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
|
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
|
||||||
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
|
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
|
||||||
|
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>>;
|
||||||
create(data: Partial<ContainerType>): Promise<ContainerType>;
|
create(data: Partial<ContainerType>): Promise<ContainerType>;
|
||||||
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
|
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||||
|
|
||||||
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
|
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
|
||||||
@@ -7,6 +9,7 @@ export interface IPriorityConfigsRepository {
|
|||||||
findById(id: string): Promise<PriorityConfig | null>;
|
findById(id: string): Promise<PriorityConfig | null>;
|
||||||
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
|
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
|
||||||
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
|
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
|
||||||
|
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>>;
|
||||||
findAllActive(): Promise<PriorityConfig[]>;
|
findAllActive(): Promise<PriorityConfig[]>;
|
||||||
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
|
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
|
||||||
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;
|
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { Rate } from '../entities/rate.entity';
|
import { Rate } from '../entities/rate.entity';
|
||||||
|
|
||||||
export interface IRatesRepository {
|
export interface IRatesRepository {
|
||||||
@@ -13,6 +15,7 @@ export interface IRatesRepository {
|
|||||||
}): Promise<Rate | null>;
|
}): Promise<Rate | null>;
|
||||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||||
|
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>>;
|
||||||
create(data: Partial<Rate>): Promise<Rate>;
|
create(data: Partial<Rate>): Promise<Rate>;
|
||||||
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
|
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ServiceType } from '../entities/service-type.entity';
|
import { ServiceType } from '../entities/service-type.entity';
|
||||||
|
|
||||||
export interface IServiceTypesRepository {
|
export interface IServiceTypesRepository {
|
||||||
@@ -6,6 +8,7 @@ export interface IServiceTypesRepository {
|
|||||||
findByCode(code: string): Promise<ServiceType | null>;
|
findByCode(code: string): Promise<ServiceType | null>;
|
||||||
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
|
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
|
||||||
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
|
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
|
||||||
|
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>>;
|
||||||
create(data: Partial<ServiceType>): Promise<ServiceType>;
|
create(data: Partial<ServiceType>): Promise<ServiceType>;
|
||||||
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
|
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||||
|
|
||||||
export interface IShippingLinesRepository {
|
export interface IShippingLinesRepository {
|
||||||
@@ -6,6 +8,7 @@ export interface IShippingLinesRepository {
|
|||||||
findByCode(code: string): Promise<ShippingLine | null>;
|
findByCode(code: string): Promise<ShippingLine | null>;
|
||||||
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
|
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
|
||||||
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
|
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
|
||||||
|
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>>;
|
||||||
create(data: Partial<ShippingLine>): Promise<ShippingLine>;
|
create(data: Partial<ShippingLine>): Promise<ShippingLine>;
|
||||||
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
|
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||||
|
|
||||||
export interface IWeightLimitRulesRepository {
|
export interface IWeightLimitRulesRepository {
|
||||||
@@ -14,6 +16,7 @@ export interface IWeightLimitRulesRepository {
|
|||||||
): Promise<WeightLimitRule | null>;
|
): Promise<WeightLimitRule | null>;
|
||||||
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
|
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
|
||||||
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
|
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
|
||||||
|
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>>;
|
||||||
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
|
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
|
||||||
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
|
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { FindManyOptions } from 'typeorm';
|
import { FindManyOptions } from 'typeorm';
|
||||||
|
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
|
|
||||||
export interface IYardsRepository {
|
export interface IYardsRepository {
|
||||||
@@ -6,6 +8,7 @@ export interface IYardsRepository {
|
|||||||
findByCode(code: string): Promise<Yard | null>;
|
findByCode(code: string): Promise<Yard | null>;
|
||||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
||||||
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
||||||
|
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
|
||||||
create(data: Partial<Yard>): Promise<Yard>;
|
create(data: Partial<Yard>): Promise<Yard>;
|
||||||
update(id: string, data: Partial<Yard>): Promise<Yard | null>;
|
update(id: string, data: Partial<Yard>): Promise<Yard | null>;
|
||||||
softDelete(id: string): Promise<void>;
|
softDelete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||||
import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
|
import { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
|
||||||
|
|
||||||
@@ -30,6 +33,31 @@ export class ApprovalRulesRepository implements IApprovalRulesRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paged list in the standard envelope. Chain grouping is preserved: rows are
|
||||||
|
* grouped by chain (requiresDirectorApproval) first, then step order.
|
||||||
|
*/
|
||||||
|
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('rule')
|
||||||
|
.orderBy('rule.requiresDirectorApproval', 'ASC')
|
||||||
|
.addOrderBy('rule.stepOrder', query.sortOrder ?? 'ASC');
|
||||||
|
|
||||||
|
if (query.requiresDirectorApproval !== undefined) {
|
||||||
|
qb.andWhere('rule.requiresDirectorApproval = :requiresDirectorApproval', {
|
||||||
|
requiresDirectorApproval: query.requiresDirectorApproval,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(rule.actionLabel ILIKE :search OR rule.requiredRole ILIKE :search OR rule.blocksRole ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> {
|
async create(data: Partial<ApprovalRule>): Promise<ApprovalRule> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { CargoType } from '../entities/cargo-type.entity';
|
import { CargoType } from '../entities/cargo-type.entity';
|
||||||
import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
|
import { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
|
||||||
|
|
||||||
@@ -27,6 +30,33 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paged list with server-side search (name/code) in the standard envelope. */
|
||||||
|
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('cargoType')
|
||||||
|
.leftJoinAndSelect('cargoType.parent', 'parent')
|
||||||
|
.orderBy(`cargoType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||||
|
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('cargoType.isActive = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
if (query.requiresDirectorApproval !== undefined) {
|
||||||
|
qb.andWhere('cargoType.requiresDirectorApproval = :requiresDirectorApproval', {
|
||||||
|
requiresDirectorApproval: query.requiresDirectorApproval,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.parentGroupId !== undefined) {
|
||||||
|
qb.andWhere('cargoType.parentGroupId = :parentGroupId', { parentGroupId: query.parentGroupId });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere('(cargoType.cargoTypeName ILIKE :search OR cargoType.code ILIKE :search)', {
|
||||||
|
search: `%${query.search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<CargoType>): Promise<CargoType> {
|
async create(data: Partial<CargoType>): Promise<CargoType> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ContainerType } from '../entities/container-type.entity';
|
import { ContainerType } from '../entities/container-type.entity';
|
||||||
import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
|
import { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
|
||||||
|
|
||||||
@@ -27,6 +30,24 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paged list with server-side search (label/code) in the standard envelope. */
|
||||||
|
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('containerType')
|
||||||
|
.orderBy(`containerType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||||
|
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('containerType.isActive = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere('(containerType.label ILIKE :search OR containerType.code ILIKE :search)', {
|
||||||
|
search: `%${query.search}%`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<ContainerType>): Promise<ContainerType> {
|
async create(data: Partial<ContainerType>): Promise<ContainerType> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||||
import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
|
import { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
|
||||||
|
|
||||||
@@ -23,6 +26,28 @@ export class PriorityConfigsRepository implements IPriorityConfigsRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paged list with server-side search (label/type/currency) in the standard envelope. */
|
||||||
|
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('config')
|
||||||
|
.orderBy(`config.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||||
|
|
||||||
|
if (query.type !== undefined) {
|
||||||
|
qb.andWhere('config.type = :type', { type: query.type });
|
||||||
|
}
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('config.isActive = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(config.label ILIKE :search OR config.type ILIKE :search OR config.currency ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async findAllActive(): Promise<PriorityConfig[]> {
|
async findAllActive(): Promise<PriorityConfig[]> {
|
||||||
return this.repo.find({
|
return this.repo.find({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { Rate } from '../entities/rate.entity';
|
import { Rate } from '../entities/rate.entity';
|
||||||
import { IRatesRepository } from '../interfaces/rates.repository.interface';
|
import { IRatesRepository } from '../interfaces/rates.repository.interface';
|
||||||
|
|
||||||
@@ -68,6 +71,28 @@ export class RatesRepository implements IRatesRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paged list with server-side search (type/status/unit/currency), newest first. */
|
||||||
|
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('rate')
|
||||||
|
.orderBy('rate.createdAt', query.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
|
if (query.status) {
|
||||||
|
qb.andWhere('rate.status = :status', { status: query.status });
|
||||||
|
}
|
||||||
|
if (query.rateType) {
|
||||||
|
qb.andWhere('rate.rateType = :rateType', { rateType: query.rateType });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(rate.rateType ILIKE :search OR rate.status ILIKE :search OR rate.rateUnit ILIKE :search OR rate.currency ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<Rate>): Promise<Rate> {
|
async create(data: Partial<Rate>): Promise<Rate> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ServiceType } from '../entities/service-type.entity';
|
import { ServiceType } from '../entities/service-type.entity';
|
||||||
import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
|
import { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
|
||||||
|
|
||||||
@@ -27,6 +30,30 @@ export class ServiceTypesRepository implements IServiceTypesRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paged list with server-side search (name/code/description) in the standard envelope. */
|
||||||
|
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('serviceType')
|
||||||
|
.orderBy(`serviceType.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC');
|
||||||
|
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('serviceType.isActive = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
if (query.canBeBookedAlone !== undefined) {
|
||||||
|
qb.andWhere('serviceType.canBeBookedAlone = :canBeBookedAlone', {
|
||||||
|
canBeBookedAlone: query.canBeBookedAlone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(serviceType.serviceName ILIKE :search OR serviceType.code ILIKE :search OR serviceType.description ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<ServiceType>): Promise<ServiceType> {
|
async create(data: Partial<ServiceType>): Promise<ServiceType> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||||
import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
|
import { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
|
||||||
|
|
||||||
@@ -27,6 +30,25 @@ export class ShippingLinesRepository implements IShippingLinesRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paged list with server-side search (label/code/mappedToCode), ordered by code. */
|
||||||
|
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('line')
|
||||||
|
.orderBy('line.code', query.sortOrder ?? 'ASC');
|
||||||
|
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('line.isActive = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(line.label ILIKE :search OR line.code ILIKE :search OR line.mappedToCode ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<ShippingLine>): Promise<ShippingLine> {
|
async create(data: Partial<ShippingLine>): Promise<ShippingLine> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||||
import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
|
import { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
|
||||||
|
|
||||||
@@ -59,6 +62,34 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paged list with the container relation loaded, newest first. `search`
|
||||||
|
* matches the trade direction and the joined container type's label/code.
|
||||||
|
*/
|
||||||
|
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('rule')
|
||||||
|
.leftJoinAndSelect('rule.containerType', 'containerType')
|
||||||
|
.orderBy('rule.createdAt', query.sortOrder ?? 'DESC');
|
||||||
|
|
||||||
|
if (query.containerTypeId) {
|
||||||
|
qb.andWhere('rule.containerTypeId = :containerTypeId', {
|
||||||
|
containerTypeId: query.containerTypeId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (query.tradeDirection) {
|
||||||
|
qb.andWhere('rule.tradeDirection = :tradeDirection', { tradeDirection: query.tradeDirection });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(rule.tradeDirection ILIKE :search OR containerType.label ILIKE :search OR containerType.code ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> {
|
async create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
import { DataSource, FindManyOptions, Repository } from 'typeorm';
|
||||||
|
import { paginateQuery } from '../../../common/utils/pagination.util';
|
||||||
|
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
import { IYardsRepository } from '../interfaces/yards.repository.interface';
|
import { IYardsRepository } from '../interfaces/yards.repository.interface';
|
||||||
|
|
||||||
@@ -27,6 +30,29 @@ export class YardsRepository implements IYardsRepository {
|
|||||||
return this.repo.findAndCount(options);
|
return this.repo.findAndCount(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Paged list with server-side search (label/code/country) in the standard envelope. */
|
||||||
|
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
||||||
|
const qb = this.repo
|
||||||
|
.createQueryBuilder('yard')
|
||||||
|
.orderBy(`yard.${query.sortBy ?? 'displayOrder'}`, query.sortOrder ?? 'ASC')
|
||||||
|
.addOrderBy('yard.label', 'ASC');
|
||||||
|
|
||||||
|
if (query.isActive !== undefined) {
|
||||||
|
qb.andWhere('yard.isActive = :isActive', { isActive: query.isActive });
|
||||||
|
}
|
||||||
|
if (query.country) {
|
||||||
|
qb.andWhere('yard.country = :country', { country: query.country });
|
||||||
|
}
|
||||||
|
if (query.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
'(yard.label ILIKE :search OR yard.code ILIKE :search OR yard.country ILIKE :search)',
|
||||||
|
{ search: `%${query.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return paginateQuery(qb, query);
|
||||||
|
}
|
||||||
|
|
||||||
async create(data: Partial<Yard>): Promise<Yard> {
|
async create(data: Partial<Yard>): Promise<Yard> {
|
||||||
const entity = this.repo.create(data);
|
const entity = this.repo.create(data);
|
||||||
return this.repo.save(entity);
|
return this.repo.save(entity);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
|
||||||
|
import { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||||
@@ -17,26 +19,9 @@ export class ApprovalRulesService {
|
|||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List approval rules. */
|
/** List approval rules — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
|
||||||
requiresDirectorApproval?: boolean;
|
return this.repository.findPaged(query);
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}): Promise<{ data: ApprovalRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 10;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.requiresDirectorApproval !== undefined) {
|
|
||||||
where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { requiresDirectorApproval: 'ASC', stepOrder: 'ASC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get approval chain for a cargo type flag. */
|
/** Get approval chain for a cargo type flag. */
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ILike } from 'typeorm';
|
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
|
||||||
|
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||||
import { CargoType } from '../entities/cargo-type.entity';
|
import { CargoType } from '../entities/cargo-type.entity';
|
||||||
@@ -19,33 +20,9 @@ export class CargoTypesService {
|
|||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List cargo types with pagination and optional filtering. */
|
/** List cargo types — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
|
||||||
isActive?: boolean;
|
return this.repository.findPaged(query);
|
||||||
requiresDirectorApproval?: boolean;
|
|
||||||
parentGroupId?: string;
|
|
||||||
search?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
|
||||||
}): Promise<{ data: CargoType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 10;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
|
||||||
if (filter.requiresDirectorApproval !== undefined) where.requiresDirectorApproval = filter.requiresDirectorApproval;
|
|
||||||
if (filter.parentGroupId !== undefined) where.parentGroupId = filter.parentGroupId;
|
|
||||||
if (filter.search) where.cargoTypeName = ILike(`%${filter.search}%`);
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
relations: { parent: true },
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single cargo type by ID. */
|
/** Get a single cargo type by ID. */
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
|
||||||
|
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||||
import { ContainerType } from '../entities/container-type.entity';
|
import { ContainerType } from '../entities/container-type.entity';
|
||||||
@@ -18,24 +20,9 @@ export class ContainerTypesService {
|
|||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List container types with pagination. */
|
/** List container types — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
|
||||||
isActive?: boolean;
|
return this.repository.findPaged(query);
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}): Promise<{ data: ContainerType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 10;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { displayOrder: 'ASC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single container type by ID. */
|
/** Get a single container type by ID. */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
import { CreatePriorityConfigDto } from '../dto/create-priority-config.dto';
|
||||||
|
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
import { UpdatePriorityConfigDto } from '../dto/update-priority-config.dto';
|
||||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||||
import {
|
import {
|
||||||
@@ -16,25 +18,9 @@ export class PriorityConfigsService {
|
|||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: {
|
/** List priority configs — standard paginated envelope with server-side search. */
|
||||||
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
async findAll(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
|
||||||
isActive?: boolean;
|
return this.repository.findPaged(query);
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}): Promise<{ data: PriorityConfig[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 20;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.type !== undefined) where.type = filter.type;
|
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { displayOrder: 'ASC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<PriorityConfig> {
|
async findById(id: string): Promise<PriorityConfig> {
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||||
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||||
import { Rate } from '../entities/rate.entity';
|
import { Rate } from '../entities/rate.entity';
|
||||||
import { deriveRateType } from '../entities/rate-type.util';
|
import { deriveRateType } from '../entities/rate-type.util';
|
||||||
@@ -19,26 +21,9 @@ export class RatesService {
|
|||||||
private readonly repository: IRatesRepository,
|
private readonly repository: IRatesRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List rates with pagination. */
|
/** List rates — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||||||
status?: string;
|
return this.repository.findPaged(query);
|
||||||
rateType?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}): Promise<{ data: Rate[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 20;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.status) where.status = filter.status;
|
|
||||||
if (filter.rateType) where.rateType = filter.rateType;
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return all currently LIVE rates. */
|
/** Return all currently LIVE rates. */
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { ILike } from 'typeorm';
|
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
|
||||||
|
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||||
import { ServiceType } from '../entities/service-type.entity';
|
import { ServiceType } from '../entities/service-type.entity';
|
||||||
@@ -19,30 +20,9 @@ export class ServiceTypesService {
|
|||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List service types with pagination and optional filtering. */
|
/** List service types — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
|
||||||
isActive?: boolean;
|
return this.repository.findPaged(query);
|
||||||
canBeBookedAlone?: boolean;
|
|
||||||
search?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
sortBy?: string;
|
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
|
||||||
}): Promise<{ data: ServiceType[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 10;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
|
||||||
if (filter.canBeBookedAlone !== undefined) where.canBeBookedAlone = filter.canBeBookedAlone;
|
|
||||||
if (filter.search) where.serviceName = ILike(`%${filter.search}%`);
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { [filter.sortBy ?? 'displayOrder']: filter.sortOrder ?? 'ASC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single service type by ID. */
|
/** Get a single service type by ID. */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
|
||||||
|
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
|
||||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||||
import {
|
import {
|
||||||
@@ -14,24 +16,9 @@ export class ShippingLinesService {
|
|||||||
private readonly repository: IShippingLinesRepository,
|
private readonly repository: IShippingLinesRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List shipping lines with pagination. */
|
/** List shipping lines — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
|
||||||
isActive?: boolean;
|
return this.repository.findPaged(query);
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}): Promise<{ data: ShippingLine[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 20;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { code: 'ASC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a shipping line by ID. */
|
/** Get a shipping line by ID. */
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
|
||||||
|
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
|
||||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||||
import {
|
import {
|
||||||
@@ -20,27 +22,9 @@ export class WeightLimitRulesService {
|
|||||||
private readonly repository: IWeightLimitRulesRepository,
|
private readonly repository: IWeightLimitRulesRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List weight limit rules with pagination. */
|
/** List weight limit rules — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
|
||||||
containerTypeId?: string;
|
return this.repository.findPaged(query);
|
||||||
tradeDirection?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}): Promise<{ data: WeightLimitRule[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 20;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.containerTypeId) where.containerTypeId = filter.containerTypeId;
|
|
||||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
relations: { containerType: true },
|
|
||||||
order: { createdAt: 'DESC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single weight limit rule by ID. */
|
/** Get a single weight limit rule by ID. */
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { PaginatedResponse } from '@edr/types';
|
||||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||||
|
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
@@ -15,26 +17,9 @@ export class YardsService {
|
|||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List yards with pagination. */
|
/** List yards — standard paginated envelope with server-side search. */
|
||||||
async findAll(filter: {
|
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
||||||
isActive?: boolean;
|
return this.repository.findPaged(query);
|
||||||
country?: string;
|
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
}): Promise<{ data: Yard[]; meta: { total: number; page: number; pageSize: number; totalPages: number } }> {
|
|
||||||
const page = filter.page ?? 1;
|
|
||||||
const pageSize = filter.pageSize ?? 10;
|
|
||||||
const where: Record<string, unknown> = {};
|
|
||||||
if (filter.isActive !== undefined) where.isActive = filter.isActive;
|
|
||||||
if (filter.country) where.country = filter.country;
|
|
||||||
|
|
||||||
const [data, total] = await this.repository.findAndCount({
|
|
||||||
where,
|
|
||||||
order: { displayOrder: 'ASC', label: 'ASC' },
|
|
||||||
skip: (page - 1) * pageSize,
|
|
||||||
take: pageSize,
|
|
||||||
});
|
|
||||||
return { data, meta: { total, page, pageSize, totalPages: Math.ceil(total / pageSize) } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a yard by ID. */
|
/** Get a yard by ID. */
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { BaseEntity } from '@edr/api-common';
|
import { BaseEntity } from '@edr/api-common';
|
||||||
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
|
import {
|
||||||
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||||
|
WagonAllocationSnapshot,
|
||||||
|
} from '@edr/types';
|
||||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
@@ -146,6 +149,14 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
|
@Column({ name: 'rule_export_booking_lead_hours', type: 'int', nullable: true })
|
||||||
ruleExportBookingLeadHours?: number | null;
|
ruleExportBookingLeadHours?: number | null;
|
||||||
|
|
||||||
|
// Frozen wagon plan captured once when the schedule leaves the editable
|
||||||
|
// DRAFT/SCHEDULED phase (dispatch / arrive / cancel). Admin views of a
|
||||||
|
// non-editable schedule read THIS instead of the live wagon↔slot joins, so the
|
||||||
|
// historical allocation survives the same physical wagons being re-pinned onto
|
||||||
|
// later trains. NULL while DRAFT/SCHEDULED (read live) and on legacy rows.
|
||||||
|
@Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true })
|
||||||
|
wagonAllocationSnapshot?: WagonAllocationSnapshot | null;
|
||||||
|
|
||||||
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
|
||||||
scheduleBookings?: TrainScheduleBooking[];
|
scheduleBookings?: TrainScheduleBooking[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light fetch for human-facing labels (notifications): reference, train
|
||||||
|
* number, departure and the two station names — none of the composition
|
||||||
|
* graph {@link findByIdWithFullGraph} drags in.
|
||||||
|
*/
|
||||||
|
findByIdWithStations(id: string): Promise<TrainSchedule | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: { id },
|
||||||
|
relations: { originStation: true, destinationStation: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updateStatus(
|
async updateStatus(
|
||||||
id: string,
|
id: string,
|
||||||
status: TrainScheduleStatus,
|
status: TrainScheduleStatus,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { ConflictException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export whole-booking single-train gate: an export booking never splits — it
|
||||||
|
* rides one train whole or is rejected. The report must try every fillable
|
||||||
|
* train on the day (first full → use the second), and when none fits, say how
|
||||||
|
* much space is still bookable so the customer knows what he CAN book.
|
||||||
|
*/
|
||||||
|
describe('BookingBatchService — exportSpaceReport (whole-booking, single train)', () => {
|
||||||
|
const DAY = new Date('2026-07-20T10:00:00Z');
|
||||||
|
|
||||||
|
const schedule = (id: string) => ({
|
||||||
|
id,
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
scheduledDepartureDate: DAY,
|
||||||
|
bookingWindowStatus: 'OPEN',
|
||||||
|
windowPhase: null, // legacy gate: OPEN alone makes it fillable
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullGraph = (id: string) => ({
|
||||||
|
id,
|
||||||
|
originStationId: 'yard-a',
|
||||||
|
destinationStationId: 'yard-b',
|
||||||
|
routeId: null, // legacy two-stop pseudo-route — no milestone query
|
||||||
|
scheduleBookings: [],
|
||||||
|
trainSet: {
|
||||||
|
locomotive: {
|
||||||
|
maxPullWeightTons: 500,
|
||||||
|
maxTrainLengthMeters: 140,
|
||||||
|
overageToleranceTons: 0,
|
||||||
|
overageToleranceMeters: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportBooking = (cargoTons: number) =>
|
||||||
|
({
|
||||||
|
id: 'bk-exp',
|
||||||
|
freightType: 'BULK',
|
||||||
|
tradeDirection: 'EXPORT',
|
||||||
|
scheduledDate: DAY,
|
||||||
|
originYardId: 'yard-a',
|
||||||
|
destinationYardId: 'yard-b',
|
||||||
|
cargoTotalWeightVgm: cargoTons,
|
||||||
|
bookingContainers: [],
|
||||||
|
}) as unknown as Booking;
|
||||||
|
|
||||||
|
// A reserved bulk booking heavy enough to exhaust the 500t pull budget.
|
||||||
|
const heavyReserved = {
|
||||||
|
id: 'bk-heavy',
|
||||||
|
freightType: 'BULK',
|
||||||
|
originYardId: 'yard-a',
|
||||||
|
destinationYardId: 'yard-b',
|
||||||
|
cargoTotalWeightVgm: 476,
|
||||||
|
bookingContainers: [],
|
||||||
|
} as unknown as Booking;
|
||||||
|
|
||||||
|
let service: BookingBatchService;
|
||||||
|
let trainSchedulesRepository: {
|
||||||
|
findAll: jest.Mock;
|
||||||
|
findByIdWithFullGraph: jest.Mock;
|
||||||
|
findById: jest.Mock;
|
||||||
|
};
|
||||||
|
let bookingsRepository: { findReservedForSchedule: jest.Mock };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trainSchedulesRepository = {
|
||||||
|
findAll: jest.fn().mockResolvedValue([]),
|
||||||
|
findByIdWithFullGraph: jest
|
||||||
|
.fn()
|
||||||
|
.mockImplementation(async (id: string) => fullGraph(id)),
|
||||||
|
findById: jest.fn(),
|
||||||
|
};
|
||||||
|
bookingsRepository = { findReservedForSchedule: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
|
// WagonType.find() → [] so representative default dims apply (bulk 60t
|
||||||
|
// payload / 23.4t tare / 14m); RouteMilestone is never queried (routeId null).
|
||||||
|
const genericRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
const dataSource = { getRepository: jest.fn().mockReturnValue(genericRepo) };
|
||||||
|
|
||||||
|
service = new BookingBatchService(
|
||||||
|
dataSource as never,
|
||||||
|
bookingsRepository as never,
|
||||||
|
trainSchedulesRepository as never,
|
||||||
|
{} as never, // trainScheduleBookingsRepository
|
||||||
|
{} as never, // notifier
|
||||||
|
{} as never, // scheduler
|
||||||
|
{} as never, // trainSchedulingService
|
||||||
|
{} as never, // billing
|
||||||
|
{} as never, // bookingWindowGateway
|
||||||
|
{} as never, // pricingService
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the other train when the first one is full', async () => {
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||||
|
schedule('train-1'),
|
||||||
|
schedule('train-2'),
|
||||||
|
]);
|
||||||
|
bookingsRepository.findReservedForSchedule.mockImplementation(
|
||||||
|
async (id: string) => (id === 'train-1' ? [heavyReserved] : []),
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = await service.exportSpaceReport(exportBooking(60));
|
||||||
|
|
||||||
|
expect(report.scheduleId).toBe('train-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a booking no single train fits and reports the bookable space', async () => {
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([schedule('train-1')]);
|
||||||
|
|
||||||
|
const report = await service.exportSpaceReport(exportBooking(900));
|
||||||
|
|
||||||
|
expect(report.scheduleId).toBeNull();
|
||||||
|
expect(report.bestAvailable).not.toBeNull();
|
||||||
|
expect(report.bestAvailable!.cargoTons).toBeGreaterThan(0);
|
||||||
|
expect(report.bestAvailable!.cargoTons).toBeLessThan(900);
|
||||||
|
expect(report.fullMessage).toMatch(/largest remaining space is about .* tons/);
|
||||||
|
expect(report.fullMessage).toMatch(/single train whole/);
|
||||||
|
|
||||||
|
await expect(service.pickExportSchedule(exportBooking(900))).rejects.toThrow(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says no train is accepting bookings when the day has none', async () => {
|
||||||
|
trainSchedulesRepository.findAll.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const report = await service.exportSpaceReport(exportBooking(60));
|
||||||
|
|
||||||
|
expect(report.scheduleId).toBeNull();
|
||||||
|
expect(report.fullMessage).toBe(
|
||||||
|
'No export train is accepting bookings for this day',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,8 +38,16 @@ import {
|
|||||||
BATCH_BOARD_STATUSES,
|
BATCH_BOARD_STATUSES,
|
||||||
BatchBoardQueryDto,
|
BatchBoardQueryDto,
|
||||||
} from './dto/batch-board-query.dto';
|
} from './dto/batch-board-query.dto';
|
||||||
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
|
import {
|
||||||
|
Freight,
|
||||||
|
PaginatedResponse,
|
||||||
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||||
|
} from "@edr/types";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
import {
|
||||||
|
buildPaginationMeta,
|
||||||
|
normalizePagination,
|
||||||
|
} from '../../common/utils/pagination.util';
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +91,21 @@ export type { Capacity } from './corridor-capacity.util';
|
|||||||
*/
|
*/
|
||||||
type TrainLimits = { base: Capacity; tolerance: OverageTolerance };
|
type TrainLimits = { base: Capacity; tolerance: OverageTolerance };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the export whole-booking single-train space check. `scheduleId`
|
||||||
|
* is the earliest fillable train that carries the whole booking, or null when
|
||||||
|
* none can — then `bestAvailable` reports the largest single-train leftover
|
||||||
|
* in the booking's own units and `fullMessage` is the customer-facing copy.
|
||||||
|
*/
|
||||||
|
export interface ExportSpaceReport {
|
||||||
|
scheduleId: string | null;
|
||||||
|
trainsForDay: boolean;
|
||||||
|
corridorMatched: boolean;
|
||||||
|
need: Capacity;
|
||||||
|
bestAvailable: { wagons: number; cargoTons: number } | null;
|
||||||
|
fullMessage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||||
interface RouteDayGroup {
|
interface RouteDayGroup {
|
||||||
originYardId: string;
|
originYardId: string;
|
||||||
@@ -241,15 +264,10 @@ export interface BatchBoardSchedule {
|
|||||||
bookings: BatchBoardBooking[];
|
bookings: BatchBoardBooking[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper
|
/** Paginated batch-board list in the shared `{items, meta}` envelope — the API
|
||||||
* already uses `data`, and the frontend's unwrap() strips one `data` level. */
|
* response wrapper already uses `data`, and the frontend's unwrap() strips one
|
||||||
export interface BatchBoardListResponse {
|
* `data` level. */
|
||||||
items: BatchBoardSchedule[];
|
export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
|
* Demand-batching engine: every 3h (EAT) it ranks each OPEN schedule's ready pool
|
||||||
@@ -514,6 +532,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
|
`Wagon allocation issue for ${booking.reference ?? bookingId}: ${issue?.issue ?? issue?.status}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Customer paid — delegate to ensurePaidBookingAllocated. */
|
/** Customer paid — delegate to ensurePaidBookingAllocated. */
|
||||||
@@ -542,12 +561,18 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
// ---- export FCFS -----------------------------------------------------------
|
// ---- export FCFS -----------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export is first-come-first-serve: no window cycle, no priority, no batch.
|
* Whole-booking single-train space report for an EXPORT booking. Export
|
||||||
* Pick the earliest open export train on the booking's corridor/day that still
|
* bookings never split — the entire booking must ride ONE train, so the
|
||||||
* fits the booking. Throws ConflictException when every train is full — the
|
* report scans every fillable export train on the booking's corridor/day
|
||||||
* staff accept fails and no more export bookings are taken.
|
* (earliest first) for one whose remaining budget fits the whole need. When
|
||||||
|
* none fits, `bestAvailable` carries the largest single-train leftover
|
||||||
|
* converted into the booking's own units (base caps, no overage tolerance)
|
||||||
|
* so the customer can be told exactly how much he COULD book on that day.
|
||||||
*/
|
*/
|
||||||
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
async exportSpaceReport(
|
||||||
|
booking: Booking,
|
||||||
|
need?: Capacity,
|
||||||
|
): Promise<ExportSpaceReport> {
|
||||||
if (!booking.scheduledDate) {
|
if (!booking.scheduledDate) {
|
||||||
throw new BadRequestException('Booking has no scheduled date');
|
throw new BadRequestException('Booking has no scheduled date');
|
||||||
}
|
}
|
||||||
@@ -573,15 +598,19 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
(a, b) =>
|
(a, b) =>
|
||||||
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
|
||||||
);
|
);
|
||||||
if (!candidates.length) {
|
|
||||||
throw new ConflictException(
|
|
||||||
'No export train is accepting bookings for this day',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
const required = need ?? this.needFor(booking, wagonDims);
|
const required = need ?? this.needFor(booking, wagonDims);
|
||||||
let corridorMatched = false;
|
const dims = this.dimsFor(booking, wagonDims);
|
||||||
|
const report: ExportSpaceReport = {
|
||||||
|
scheduleId: null,
|
||||||
|
trainsForDay: candidates.length > 0,
|
||||||
|
corridorMatched: false,
|
||||||
|
need: required,
|
||||||
|
bestAvailable: null,
|
||||||
|
fullMessage: null,
|
||||||
|
};
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||||
candidate.id,
|
candidate.id,
|
||||||
@@ -592,15 +621,101 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||||
corridorMatched = true;
|
report.corridorMatched = true;
|
||||||
if (budget.fits(required, leg)) return schedule.id;
|
if (budget.fits(required, leg)) {
|
||||||
|
// Earliest fitting train wins — no need to keep sizing leftovers.
|
||||||
|
report.scheduleId = schedule.id;
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
const available = this.bookableWithin(budget.remainingFor(leg), dims);
|
||||||
|
if (
|
||||||
|
!report.bestAvailable ||
|
||||||
|
available.cargoTons > report.bestAvailable.cargoTons ||
|
||||||
|
(available.cargoTons === report.bestAvailable.cargoTons &&
|
||||||
|
available.wagons > report.bestAvailable.wagons)
|
||||||
|
) {
|
||||||
|
report.bestAvailable = available;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!corridorMatched) {
|
|
||||||
throw new ConflictException(
|
report.fullMessage = this.exportFullMessage(booking, report);
|
||||||
'No export train is accepting bookings for this day',
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largest booking (in the requester's own wagon-type units) that a single
|
||||||
|
* train's leftover base capacity could still admit: bounded by free wagon
|
||||||
|
* slots, free train length, and the locomotive's remaining pull weight
|
||||||
|
* (gross — each wagon's tare eats into it before any cargo does).
|
||||||
|
*/
|
||||||
|
private bookableWithin(
|
||||||
|
remaining: Capacity,
|
||||||
|
dims: PerWagonDims,
|
||||||
|
): { wagons: number; cargoTons: number } {
|
||||||
|
const byLength =
|
||||||
|
dims.lengthMeters > 0
|
||||||
|
? Math.floor(Math.max(0, remaining.lengthMeters) / dims.lengthMeters)
|
||||||
|
: Math.floor(Math.max(0, remaining.wagons));
|
||||||
|
const maxWagons = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(Math.floor(Math.max(0, remaining.wagons)), byLength),
|
||||||
|
);
|
||||||
|
let bestTons = 0;
|
||||||
|
let usableWagons = 0;
|
||||||
|
for (let w = 1; w <= maxWagons; w++) {
|
||||||
|
if (w * dims.tareWeightTons > remaining.weightTons) break;
|
||||||
|
usableWagons = w;
|
||||||
|
const tons = Math.min(
|
||||||
|
w * dims.capacityTons,
|
||||||
|
remaining.weightTons - w * dims.tareWeightTons,
|
||||||
|
);
|
||||||
|
if (tons > bestTons) bestTons = tons;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
wagons: usableWagons,
|
||||||
|
cargoTons: Math.max(0, Math.floor(bestTons * 1000) / 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer-facing "train is full" copy carrying the bookable leftover. */
|
||||||
|
private exportFullMessage(booking: Booking, report: ExportSpaceReport): string {
|
||||||
|
if (!report.trainsForDay || !report.corridorMatched) {
|
||||||
|
return 'No export train is accepting bookings for this day';
|
||||||
|
}
|
||||||
|
const best = report.bestAvailable;
|
||||||
|
const base =
|
||||||
|
'Not enough train space — an export booking must ride a single train whole, ' +
|
||||||
|
'and no open train on this day can carry it. ';
|
||||||
|
if (!best || best.wagons <= 0) {
|
||||||
|
return base + 'No capacity is left on this day — pick another shipment day.';
|
||||||
|
}
|
||||||
|
if (booking.freightType === 'BULK') {
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
`The largest remaining space is about ${best.cargoTons} tons ` +
|
||||||
|
`(${best.wagons} wagon${best.wagons === 1 ? '' : 's'}) — book up to that amount or pick another day.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new ConflictException('Train is full — no export capacity left for this day');
|
return (
|
||||||
|
base +
|
||||||
|
`The largest remaining space is ${best.wagons} wagon${best.wagons === 1 ? '' : 's'} ` +
|
||||||
|
`(up to ${best.wagons * 2} × 20ft or ${best.wagons} × 40ft, weight permitting) — ` +
|
||||||
|
'reduce the booking or pick another day.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export is first-come-first-serve: no window cycle, no priority, no batch.
|
||||||
|
* Pick the earliest open export train on the booking's corridor/day that still
|
||||||
|
* fits the booking. Throws ConflictException when every train is full — the
|
||||||
|
* staff accept fails and no more export bookings are taken.
|
||||||
|
*/
|
||||||
|
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
|
||||||
|
const report = await this.exportSpaceReport(booking, need);
|
||||||
|
if (report.scheduleId) return report.scheduleId;
|
||||||
|
throw new ConflictException(
|
||||||
|
report.fullMessage ?? 'Train is full — no export capacity left for this day',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -652,6 +767,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (schedule && (await this.isTrainFull(schedule))) {
|
if (schedule && (await this.isTrainFull(schedule))) {
|
||||||
await this.setWindow(scheduleId, 'FULL');
|
await this.setWindow(scheduleId, 'FULL');
|
||||||
}
|
}
|
||||||
|
this.notifyBoardChanged(scheduleId, 'export_booking_accepted');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
|
||||||
@@ -664,6 +780,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
`Reconciled PAID booking ${booking.reference ?? booking.id} → schedule ${scheduleId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (unlinked.length > 0) {
|
||||||
|
this.notifyBoardChanged(scheduleId, "paid_reconciled");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- legacy fill entry point ----------------------------------------------
|
// ---- legacy fill entry point ----------------------------------------------
|
||||||
@@ -700,8 +819,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
async getBatchBoard(
|
async getBatchBoard(
|
||||||
query: BatchBoardQueryDto = {},
|
query: BatchBoardQueryDto = {},
|
||||||
): Promise<BatchBoardListResponse> {
|
): Promise<BatchBoardListResponse> {
|
||||||
const page = query.page ?? 1;
|
// Board cards are heavy (per-schedule booking summaries), so the default
|
||||||
const pageSize = query.pageSize ?? 12;
|
// page is smaller than the toolkit-wide 20.
|
||||||
|
const { page, pageSize, skip, take } = normalizePagination(query, {
|
||||||
|
defaultPageSize: 12,
|
||||||
|
});
|
||||||
|
|
||||||
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
// Status filter: any subset of the lifecycle. Omitted = all statuses, so
|
||||||
// arrived / cancelled / dispatched schedules stay visible as history.
|
// arrived / cancelled / dispatched schedules stay visible as history.
|
||||||
@@ -763,8 +885,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
route: { originYard: true, destinationYard: true, milestones: { yard: true } },
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder } as never,
|
order: { [sortBy]: sortOrder } as never,
|
||||||
skip: (page - 1) * pageSize,
|
skip,
|
||||||
take: pageSize,
|
take,
|
||||||
});
|
});
|
||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
@@ -800,13 +922,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
board.push(this.buildScheduleSummary(s, items));
|
board.push(this.buildScheduleSummary(s, items));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||||
items: board,
|
|
||||||
total,
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||||
@@ -1048,7 +1164,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
|
/** Run wagon-level allocation for all eligible linked bookings on a schedule. */
|
||||||
async runWagonAllocation(scheduleId: string) {
|
async runWagonAllocation(scheduleId: string) {
|
||||||
return this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
const result =
|
||||||
|
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||||
|
if (result.assignedBookingIds.length > 0) {
|
||||||
|
this.notifyBoardChanged(scheduleId, "wagon_allocation_run");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1070,9 +1191,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
maxWagons: number | null,
|
maxWagons: number | null,
|
||||||
): BatchBoardSchedule["capacity"] {
|
): BatchBoardSchedule["capacity"] {
|
||||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||||
const committed = items.filter(
|
// Every booking still targeting this train holds gross weight — including
|
||||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
// PAID ones waiting for wagon allocation (WAITING) and post-dispatch
|
||||||
);
|
// catch-all states. Counting only ALLOCATED + SELECTED_FOR_BATCH zeroed the
|
||||||
|
// board's weight the moment customers paid. Only EXPIRED released its hold.
|
||||||
|
const committed = items.filter((i) => i.state !== "EXPIRED");
|
||||||
const caps = loco
|
const caps = loco
|
||||||
? trainHardCaps({
|
? trainHardCaps({
|
||||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||||
@@ -1218,6 +1341,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.resortPoolByPriority(pool);
|
this.resortPoolByPriority(pool);
|
||||||
const units = this.groupConsolidatedPool(pool);
|
const units = this.groupConsolidatedPool(pool);
|
||||||
let armed = false;
|
let armed = false;
|
||||||
|
let preempted = false;
|
||||||
let reservedThisPass = 0;
|
let reservedThisPass = 0;
|
||||||
let commercialReserved = 0;
|
let commercialReserved = 0;
|
||||||
|
|
||||||
@@ -1256,6 +1380,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
budget,
|
budget,
|
||||||
wagonDims,
|
wagonDims,
|
||||||
);
|
);
|
||||||
|
preempted = true;
|
||||||
if (!freed) continue; // still doesn't fit even after preempt
|
if (!freed) continue; // still doesn't fit even after preempt
|
||||||
} else {
|
} else {
|
||||||
// Doesn't fit whole. A split-eligible import booking is offered the part
|
// Doesn't fit whole. A split-eligible import booking is offered the part
|
||||||
@@ -1304,6 +1429,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
|
if (budget.isExhausted(minPerWagon)) await this.setWindow(scheduleId, "FULL");
|
||||||
if (armed) this.armSettle(scheduleId);
|
if (armed) this.armSettle(scheduleId);
|
||||||
|
// One push per fill pass (never per booking) — only when rows changed.
|
||||||
|
if (reservedThisPass > 0 || armed || preempted) {
|
||||||
|
this.notifyBoardChanged(scheduleId, "batch_fill");
|
||||||
|
}
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
return commercialReserved;
|
return commercialReserved;
|
||||||
}
|
}
|
||||||
@@ -1404,8 +1533,13 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
|
|
||||||
const wagonDims = await this.loadWagonDims();
|
const wagonDims = await this.loadWagonDims();
|
||||||
|
|
||||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
// Live per-schedule corridor budget + arm/changed flags, in departure order.
|
||||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
const trains: Array<{
|
||||||
|
id: string;
|
||||||
|
budget: CorridorBudget;
|
||||||
|
armed: boolean;
|
||||||
|
changed: boolean;
|
||||||
|
}> = [];
|
||||||
for (const id of scheduleIds) {
|
for (const id of scheduleIds) {
|
||||||
const schedule =
|
const schedule =
|
||||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||||
@@ -1419,7 +1553,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
const limits = await this.capacityLimits(locomotive);
|
const limits = await this.capacityLimits(locomotive);
|
||||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||||
trains.push({ id, budget, armed: false });
|
trains.push({ id, budget, armed: false, changed: false });
|
||||||
}
|
}
|
||||||
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
if (trains.length === 0) return { scheduleIds, commercialReserved: 0 };
|
||||||
|
|
||||||
@@ -1492,6 +1626,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
t.budget,
|
t.budget,
|
||||||
wagonDims,
|
wagonDims,
|
||||||
);
|
);
|
||||||
|
// Preempt may have displaced (expired) victims even when the need
|
||||||
|
// still doesn't fit — the board must refresh either way.
|
||||||
|
t.changed = true;
|
||||||
if (freed) {
|
if (freed) {
|
||||||
target = t;
|
target = t;
|
||||||
break;
|
break;
|
||||||
@@ -1536,6 +1673,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
commercialReserved += 1;
|
commercialReserved += 1;
|
||||||
}
|
}
|
||||||
target.budget.subtract(need, legOn(target)!);
|
target.budget.subtract(need, legOn(target)!);
|
||||||
|
target.changed = true;
|
||||||
reservedThisPass += 1;
|
reservedThisPass += 1;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
@@ -1553,6 +1691,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
for (const t of trains) {
|
for (const t of trains) {
|
||||||
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
|
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
|
||||||
if (t.armed) this.armSettle(t.id);
|
if (t.armed) this.armSettle(t.id);
|
||||||
|
// One push per touched train per pass (never per booking). `armed` covers
|
||||||
|
// commercial reserves + partial offers; `changed` covers gov allocations
|
||||||
|
// and preemption.
|
||||||
|
if (t.armed || t.changed) this.notifyBoardChanged(t.id, "batch_fill");
|
||||||
void this.triggerWagonAllocation(t.id);
|
void this.triggerWagonAllocation(t.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1799,6 +1941,10 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`— payment phase extended for them`,
|
`— payment phase extended for them`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Emitted here (not in settleDueReservations/settleBatch, which both wrap
|
||||||
|
// this) so one settle produces one push, after every allocation/expiry/
|
||||||
|
// top-up extension for this schedule has been persisted.
|
||||||
|
this.notifyBoardChanged(scheduleId, "reservations_settled");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1851,6 +1997,21 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce that a schedule's batch-board data changed so open boards refetch.
|
||||||
|
* Called AFTER the state change is persisted; a push failure only logs — it
|
||||||
|
* must never break the business transaction that triggered it.
|
||||||
|
*/
|
||||||
|
private notifyBoardChanged(scheduleId: string, reason: string): void {
|
||||||
|
try {
|
||||||
|
this.bookingWindowGateway.emitBatchChanged(scheduleId, reason);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Batch-board push (${reason}) failed for ${scheduleId}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- staff override actions ----------------------------------------------
|
// ---- staff override actions ----------------------------------------------
|
||||||
|
|
||||||
/** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */
|
/** Staff "mark paid" override → set PAID and allocate immediately (don't wait for settle). */
|
||||||
@@ -1876,6 +2037,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
await this.setWindow(booking.trainScheduleId, "FULL");
|
await this.setWindow(booking.trainScheduleId, "FULL");
|
||||||
}
|
}
|
||||||
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
void this.triggerWagonAllocation(booking.trainScheduleId!);
|
||||||
|
this.notifyBoardChanged(booking.trainScheduleId, "booking_marked_paid");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1910,6 +2072,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceScheduleId = booking.trainScheduleId ?? null;
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
if (booking.trainScheduleId) {
|
if (booking.trainScheduleId) {
|
||||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||||
@@ -1932,6 +2095,11 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
selectedForBatchAt: null,
|
selectedForBatchAt: null,
|
||||||
} as never);
|
} as never);
|
||||||
});
|
});
|
||||||
|
// Both boards changed: the booking left the source train and joined the target.
|
||||||
|
if (sourceScheduleId && sourceScheduleId !== newScheduleId) {
|
||||||
|
this.notifyBoardChanged(sourceScheduleId, "booking_moved");
|
||||||
|
}
|
||||||
|
this.notifyBoardChanged(newScheduleId, "booking_moved");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
|
/** Staff "expire" override → free a reservation now (booking becomes EXPIRED). */
|
||||||
@@ -1949,6 +2117,8 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
if (topUpReserved > 0) {
|
if (topUpReserved > 0) {
|
||||||
await this.extendPaymentPhaseForTopUp(freedScheduleId);
|
await this.extendPaymentPhaseForTopUp(freedScheduleId);
|
||||||
}
|
}
|
||||||
|
// After the top-up + phase extension so one push carries the final state.
|
||||||
|
this.notifyBoardChanged(freedScheduleId, "reservation_expired");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1988,10 +2158,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
.update(booking.id, { trainScheduleId: scheduleId });
|
.update(booking.id, { trainScheduleId: scheduleId });
|
||||||
booking.trainScheduleId = scheduleId;
|
booking.trainScheduleId = scheduleId;
|
||||||
await this.allocate(scheduleId, booking, 'gov');
|
await this.allocate(scheduleId, booking, 'gov');
|
||||||
|
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.reserve(booking, scheduleId);
|
await this.reserve(booking, scheduleId);
|
||||||
this.armSettle(scheduleId);
|
this.armSettle(scheduleId);
|
||||||
|
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- mutations ------------------------------------------------------------
|
// ---- mutations ------------------------------------------------------------
|
||||||
@@ -2088,7 +2260,7 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
|
||||||
);
|
);
|
||||||
this.notifier.secured(booking, reason);
|
this.notifier.secured(booking, reason, scheduleId);
|
||||||
void this.triggerWagonAllocation(scheduleId);
|
void this.triggerWagonAllocation(scheduleId);
|
||||||
void this.markWagonAllocatedMilestone(booking.id);
|
void this.markWagonAllocatedMilestone(booking.id);
|
||||||
// Customer tracking: freight payment settled (commercial pay-window path).
|
// Customer tracking: freight payment settled (commercial pay-window path).
|
||||||
@@ -2254,7 +2426,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
day,
|
day,
|
||||||
);
|
);
|
||||||
const leftovers = pool.filter((b) => !b.isGovernment);
|
const leftovers = pool.filter((b) => !b.isGovernment);
|
||||||
|
// Capture pinned schedules BEFORE expire() clears trainScheduleId, so each
|
||||||
|
// touched board gets exactly one push at the end of the sweep.
|
||||||
|
const touchedScheduleIds = new Set<string>();
|
||||||
|
if (leftovers.length) touchedScheduleIds.add(scheduleId);
|
||||||
for (const booking of leftovers) {
|
for (const booking of leftovers) {
|
||||||
|
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||||||
await this.expire(booking, "no-capacity");
|
await this.expire(booking, "no-capacity");
|
||||||
}
|
}
|
||||||
if (leftovers.length) {
|
if (leftovers.length) {
|
||||||
@@ -2263,6 +2440,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`expired ${leftovers.length} waiting booking(s)`,
|
`expired ${leftovers.length} waiting booking(s)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const id of touchedScheduleIds) {
|
||||||
|
this.notifyBoardChanged(id, "day_pool_expired");
|
||||||
|
}
|
||||||
return leftovers.length;
|
return leftovers.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2325,7 +2505,12 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
|
`on ${group.originYardId}->${group.destinationYardId} ${group.day}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Only bookings pinned to a train show on a board — collect their schedules
|
||||||
|
// and push once per schedule after the sweep (most unaccepted rows are
|
||||||
|
// unpinned under day-level pooling, so this usually emits nothing).
|
||||||
|
const touchedScheduleIds = new Set<string>();
|
||||||
for (const booking of unaccepted) {
|
for (const booking of unaccepted) {
|
||||||
|
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
status: "EXPIRED",
|
status: "EXPIRED",
|
||||||
schedulingStatus: "ELIGIBLE",
|
schedulingStatus: "ELIGIBLE",
|
||||||
@@ -2342,6 +2527,9 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
|
`[BATCH] EXPIRED (unaccepted) ${booking.reference}:${booking.id} at doc-review end`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const id of touchedScheduleIds) {
|
||||||
|
this.notifyBoardChanged(id, "unaccepted_expired");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -18,8 +19,37 @@ export class BookingNotifierService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly notifications: NotificationsService,
|
private readonly notifications: NotificationsService,
|
||||||
private readonly inbox: NotificationInboxService,
|
private readonly inbox: NotificationInboxService,
|
||||||
|
private readonly trainSchedules: TrainSchedulesRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable description of a train schedule for customer messages:
|
||||||
|
* reference (or train number) + route + departure date. Never leaks a UUID —
|
||||||
|
* falls back to a generic phrase when the schedule can't be loaded.
|
||||||
|
*/
|
||||||
|
private async scheduleLabel(scheduleId?: string | null): Promise<string> {
|
||||||
|
const fallback = 'your selected train';
|
||||||
|
if (!scheduleId) return fallback;
|
||||||
|
try {
|
||||||
|
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
|
||||||
|
if (!s) return fallback;
|
||||||
|
const ref = s.reference ?? s.trainNumber ?? null;
|
||||||
|
const route =
|
||||||
|
s.originStation?.label && s.destinationStation?.label
|
||||||
|
? ` (${s.originStation.label} → ${s.destinationStation.label})`
|
||||||
|
: '';
|
||||||
|
const departure = s.scheduledDepartureDate
|
||||||
|
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}`
|
||||||
|
: '';
|
||||||
|
return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private ref(b: Booking): string {
|
private ref(b: Booking): string {
|
||||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||||
}
|
}
|
||||||
@@ -127,12 +157,15 @@ export class BookingNotifierService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
void (async () => {
|
||||||
reason === 'gov' ? ' (government)' : ''
|
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||||
}.`;
|
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
|
||||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
reason === 'gov' ? ' (government)' : ''
|
||||||
this.inApp(b, 'Wagon allocated', msg);
|
}.`;
|
||||||
|
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||||
|
this.inApp(b, 'Wagon allocated', msg);
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
expired(b: Booking): void {
|
expired(b: Booking): void {
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ import { Contract } from '../contracts/entities/contract.entity';
|
|||||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL
|
* applySplit split-marking behaviour: the reduced booking is flagged is_split
|
||||||
* (both the parent contract row and the booking's denormalized copy) so the split
|
* and keeps a pre_split_quantities snapshot (the remainder ledger for ONE_TIME
|
||||||
* remainder can be rebooked. A GENERAL booking is left untouched.
|
* contracts). The contract kind is NEVER changed — a ONE_TIME contract stays
|
||||||
|
* ONE_TIME through the split chain.
|
||||||
*/
|
*/
|
||||||
describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
describe('BookingSplitService — applySplit split marking', () => {
|
||||||
const bookingId = 'bk-1';
|
const bookingId = 'bk-1';
|
||||||
const contractId = 'ct-1';
|
const contractId = 'ct-1';
|
||||||
const offerId = 'of-1';
|
const offerId = 'of-1';
|
||||||
@@ -34,6 +35,7 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
|||||||
id: bookingId,
|
id: bookingId,
|
||||||
contractId,
|
contractId,
|
||||||
contractKind: bookingContractKind,
|
contractKind: bookingContractKind,
|
||||||
|
cargoTotalWeightVgm: 50,
|
||||||
}),
|
}),
|
||||||
find: jest.fn().mockResolvedValue([]),
|
find: jest.fn().mockResolvedValue([]),
|
||||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||||
@@ -76,22 +78,35 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
|||||||
return { service, bookingRepo, contractRepo };
|
return { service, bookingRepo, contractRepo };
|
||||||
};
|
};
|
||||||
|
|
||||||
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => {
|
it('flags the reduced booking is_split with a pre-split bulk snapshot', async () => {
|
||||||
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
const { service, bookingRepo } = buildService('ONE_TIME');
|
||||||
|
|
||||||
await service.applySplit(bookingId);
|
await service.applySplit(bookingId);
|
||||||
|
|
||||||
expect(bookingRepo.update).toHaveBeenCalledWith(
|
expect(bookingRepo.update).toHaveBeenCalledWith(
|
||||||
bookingId,
|
bookingId,
|
||||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
expect.objectContaining({
|
||||||
);
|
isSplit: true,
|
||||||
expect(contractRepo.update).toHaveBeenCalledWith(
|
preSplitQuantities: { bulkTons: 50 },
|
||||||
contractId,
|
cargoTotalWeightVgm: 30,
|
||||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
wagonsRequired: 3,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves a GENERAL booking untouched (no contract promotion)', async () => {
|
it('never changes the contract kind — ONE_TIME stays ONE_TIME', async () => {
|
||||||
|
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
||||||
|
|
||||||
|
await service.applySplit(bookingId);
|
||||||
|
|
||||||
|
expect(contractRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(bookingRepo.update).not.toHaveBeenCalledWith(
|
||||||
|
bookingId,
|
||||||
|
expect.objectContaining({ contractKind: expect.anything() }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a GENERAL contract untouched too', async () => {
|
||||||
const { service, contractRepo } = buildService('GENERAL');
|
const { service, contractRepo } = buildService('GENERAL');
|
||||||
|
|
||||||
await service.applySplit(bookingId);
|
await service.applySplit(bookingId);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { BillingService } from '../billing/billing.service';
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||||
import { Contract } from '../contracts/entities/contract.entity';
|
|
||||||
import {
|
import {
|
||||||
BookingBatchOffer,
|
BookingBatchOffer,
|
||||||
OfferedLine,
|
OfferedLine,
|
||||||
@@ -34,11 +33,14 @@ export interface SizedOffer {
|
|||||||
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
|
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
|
||||||
* returns to the contract's quantity cap (derived live from booking_container
|
* returns to the contract's quantity cap (derived live from booking_container
|
||||||
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
* rows, so reducing the lines releases it automatically) and can be rebooked in
|
||||||
* any later window within contract validity. A ONE_TIME contract is promoted to
|
* any later window within contract validity. The reduced booking is flagged
|
||||||
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
|
* is_split (see applySplit); the contract kind never changes. On a ONE_TIME
|
||||||
* Once the remainder is rebooked and the cap hits zero, ContractBookingService
|
* contract a split booking releases the single-active-booking slot, but the
|
||||||
* completes the contract (CONTRACT_CLOSED): no further bookings or shipment
|
* next booking must take the WHOLE remainder — the split chain is the only way
|
||||||
* requests, even while validity and a booking window are still open.
|
* a ONE_TIME contract produces multiple bookings. Once the remainder is
|
||||||
|
* rebooked and the cap hits zero, ContractBookingService completes the
|
||||||
|
* contract (CONTRACT_CLOSED): no further bookings or shipment requests, even
|
||||||
|
* while validity and a booking window are still open.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingSplitService {
|
export class BookingSplitService {
|
||||||
@@ -215,11 +217,26 @@ export class BookingSplitService {
|
|||||||
if (!offer) return;
|
if (!offer) return;
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
// Snapshot what the booking carried BEFORE the reduction: on a ONE_TIME
|
||||||
|
// contract this is the ledger the outstanding remainder is derived from
|
||||||
|
// (there is no contract quantity cap to fall back on).
|
||||||
|
const preSplit = await manager.getRepository(Booking).findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
select: { id: true, cargoTotalWeightVgm: true },
|
||||||
|
});
|
||||||
|
const preSplitQuantities: { bulkTons?: number; bySize?: Record<string, number> } = {};
|
||||||
|
|
||||||
if (offer.offeredLines?.length) {
|
if (offer.offeredLines?.length) {
|
||||||
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
|
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
|
||||||
const lines = await manager.getRepository(BookingContainer).find({
|
const lines = await manager.getRepository(BookingContainer).find({
|
||||||
where: { bookingId },
|
where: { bookingId },
|
||||||
});
|
});
|
||||||
|
const bySize: Record<string, number> = {};
|
||||||
|
for (const line of lines) {
|
||||||
|
const size = line.containerSize ?? '';
|
||||||
|
bySize[size] = (bySize[size] ?? 0) + Number(line.quantity ?? 0);
|
||||||
|
}
|
||||||
|
preSplitQuantities.bySize = bySize;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const kept = keptByLine.get(line.id);
|
const kept = keptByLine.get(line.id);
|
||||||
if (!kept) {
|
if (!kept) {
|
||||||
@@ -251,34 +268,24 @@ export class BookingSplitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
preSplitQuantities.bulkTons = Number(preSplit?.cargoTotalWeightVgm ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// is_split releases the ONE_TIME single-active-booking slot for the
|
||||||
|
// remainder (whole-remainder-only, enforced at booking creation) and
|
||||||
|
// switches the contract into remainder-based completion. The contract
|
||||||
|
// kind is NOT changed: a ONE_TIME contract stays ONE_TIME through the
|
||||||
|
// split chain.
|
||||||
await manager.getRepository(Booking).update(bookingId, {
|
await manager.getRepository(Booking).update(bookingId, {
|
||||||
wagonsRequired: offer.offeredWagons,
|
wagonsRequired: offer.offeredWagons,
|
||||||
cargoTotalWeightVgm: offer.offeredWeightTons,
|
cargoTotalWeightVgm: offer.offeredWeightTons,
|
||||||
totalAmount: offer.offeredAmount,
|
totalAmount: offer.offeredAmount,
|
||||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||||
|
isSplit: true,
|
||||||
|
preSplitQuantities,
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
// A ONE_TIME contract permits a single active booking, which would block the
|
|
||||||
// split remainder from ever being rebooked. Promote the parent contract (and
|
|
||||||
// the booking's denormalized copy) to GENERAL so the leftover quantity draws
|
|
||||||
// down against the cap like any general contract, within the same validity.
|
|
||||||
const booking = await manager.getRepository(Booking).findOne({
|
|
||||||
where: { id: bookingId },
|
|
||||||
select: { id: true, contractId: true, contractKind: true },
|
|
||||||
});
|
|
||||||
if (booking?.contractKind === 'ONE_TIME') {
|
|
||||||
await manager
|
|
||||||
.getRepository(Booking)
|
|
||||||
.update(bookingId, { contractKind: 'GENERAL' } as never);
|
|
||||||
if (booking.contractId) {
|
|
||||||
await manager
|
|
||||||
.getRepository(Contract)
|
|
||||||
.update(booking.contractId, { contractKind: 'GENERAL' } as never);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager
|
await manager
|
||||||
.getRepository(BookingBatchOffer)
|
.getRepository(BookingBatchOffer)
|
||||||
.update(offer.id, { status: 'APPLIED' });
|
.update(offer.id, { status: 'APPLIED' });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BOOKING_WINDOW_WS_EVENTS,
|
BOOKING_WINDOW_WS_EVENTS,
|
||||||
BOOKING_WINDOW_WS_NAMESPACE,
|
BOOKING_WINDOW_WS_NAMESPACE,
|
||||||
|
type BatchBoardChangedEvent,
|
||||||
type BookingWindowPhaseEvent,
|
type BookingWindowPhaseEvent,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
@@ -65,6 +66,20 @@ export class BookingWindowGateway implements OnGatewayConnection {
|
|||||||
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
|
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce that a schedule's batch-board data changed (payment, allocation,
|
||||||
|
* fill, expiry, …). Broadcast namespace-wide like PHASE — the payload carries
|
||||||
|
* no board data, only the scheduleId; clients holding that board refetch.
|
||||||
|
*/
|
||||||
|
emitBatchChanged(scheduleId: string, reason: string): void {
|
||||||
|
const payload: BatchBoardChangedEvent = {
|
||||||
|
scheduleId,
|
||||||
|
reason,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.server.emit(BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED, payload);
|
||||||
|
}
|
||||||
|
|
||||||
private extractToken(socket: Socket): string | undefined {
|
private extractToken(socket: Socket): string | undefined {
|
||||||
const authToken = socket.handshake.auth?.token as string | undefined;
|
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||||
if (authToken) return authToken;
|
if (authToken) return authToken;
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
.update(s.id, { docReviewCompletedAt: now });
|
.update(s.id, { docReviewCompletedAt: now });
|
||||||
s.docReviewCompletedAt = now;
|
s.docReviewCompletedAt = now;
|
||||||
await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
|
await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
|
||||||
|
// The batch fill emits only for trains it actually reserved onto — this
|
||||||
|
// covers the empty-pool case so every open board still refetches. Push
|
||||||
|
// failures only log; the doc-review completion itself already persisted.
|
||||||
|
try {
|
||||||
|
this.gateway.emitBatchChanged(s.id, 'doc_review_completed');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Batch-board push failed for ${s.id}: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { IsIn, IsISO8601, IsOptional, IsString } from 'class-validator';
|
||||||
import {
|
|
||||||
IsIn,
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
IsInt,
|
|
||||||
IsISO8601,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
MaxLength,
|
|
||||||
Min,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export const BATCH_BOARD_STATUSES = [
|
export const BATCH_BOARD_STATUSES = [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
@@ -27,23 +19,13 @@ export const BATCH_BOARD_SORT_FIELDS = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
|
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
|
||||||
|
|
||||||
/** Filters for the batch monitoring board list (import schedules, all statuses). */
|
/**
|
||||||
export class BatchBoardQueryDto {
|
* Filters for the batch monitoring board list (import schedules, all statuses).
|
||||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
* `page`/`pageSize`/`search`/`sortOrder` come from the shared
|
||||||
@IsOptional()
|
* {@link PaginationQueryDto}; search matches train number, route yards,
|
||||||
@Type(() => Number)
|
* stations, or locomotive code (case-insensitive).
|
||||||
@IsInt()
|
*/
|
||||||
@Min(1)
|
export class BatchBoardQueryDto extends PaginationQueryDto {
|
||||||
page?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(100)
|
|
||||||
pageSize?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description:
|
description:
|
||||||
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
|
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
|
||||||
@@ -58,15 +40,6 @@ export class BatchBoardQueryDto {
|
|||||||
@IsIn(['OPEN', 'FULL', 'CLOSED'])
|
@IsIn(['OPEN', 'FULL', 'CLOSED'])
|
||||||
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
|
bookingWindowStatus?: 'OPEN' | 'FULL' | 'CLOSED';
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description:
|
|
||||||
'Case-insensitive match on train number, route yards, stations, or locomotive code.',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(120)
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
|
@ApiPropertyOptional({ description: 'Departure date lower bound (ISO 8601).' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsISO8601()
|
@IsISO8601()
|
||||||
@@ -91,9 +64,4 @@ export class BatchBoardQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
|
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
|
||||||
sortBy?: BatchBoardSortField;
|
sortBy?: BatchBoardSortField;
|
||||||
|
|
||||||
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['ASC', 'DESC'])
|
|
||||||
sortOrder?: 'ASC' | 'DESC';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||||
|
import {
|
||||||
|
TRAIN_SCHEDULE_STATUSES,
|
||||||
|
TrainScheduleStatus,
|
||||||
|
} from '../../train-schedules/entities/train-schedule.entity';
|
||||||
|
|
||||||
|
export const TRAIN_SCHEDULE_SORT_FIELDS = [
|
||||||
|
'createdAt',
|
||||||
|
'scheduledDepartureDate',
|
||||||
|
'reference',
|
||||||
|
'trainNumber',
|
||||||
|
'status',
|
||||||
|
] as const;
|
||||||
|
export type TrainScheduleSortField = (typeof TRAIN_SCHEDULE_SORT_FIELDS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules have no freight-type column — the type is DERIVED from the
|
||||||
|
* bookings aboard (see `resolveScheduleFreightType`): a single kind yields
|
||||||
|
* CONTAINER or BULK, both kinds yield MIXED, no bookings yield null (never
|
||||||
|
* matched by this filter).
|
||||||
|
*/
|
||||||
|
export const TRAIN_SCHEDULE_FREIGHT_TYPES = ['CONTAINER', 'BULK', 'MIXED'] as const;
|
||||||
|
export type TrainScheduleFreightType = (typeof TRAIN_SCHEDULE_FREIGHT_TYPES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query for the train-schedule list (container/bulk boards). Pagination and
|
||||||
|
* free-text `search` come from the shared {@link PaginationQueryDto}; search
|
||||||
|
* matches schedule reference, train number, route yards, stations, or
|
||||||
|
* locomotive code (case-insensitive). The remaining fields are exact-match
|
||||||
|
* filters that the search never widens.
|
||||||
|
*/
|
||||||
|
export class ListTrainSchedulesQueryDto extends PaginationQueryDto {
|
||||||
|
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_SORT_FIELDS, default: 'createdAt' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TRAIN_SCHEDULE_SORT_FIELDS as unknown as string[])
|
||||||
|
sortBy?: TrainScheduleSortField;
|
||||||
|
|
||||||
|
/** Lifecycle status (exact match). */
|
||||||
|
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_STATUSES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TRAIN_SCHEDULE_STATUSES as unknown as string[])
|
||||||
|
status?: TrainScheduleStatus;
|
||||||
|
|
||||||
|
/** Derived freight type of the bookings aboard (exact match). */
|
||||||
|
@ApiPropertyOptional({ enum: TRAIN_SCHEDULE_FREIGHT_TYPES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(TRAIN_SCHEDULE_FREIGHT_TYPES as unknown as string[])
|
||||||
|
freightType?: TrainScheduleFreightType;
|
||||||
|
|
||||||
|
/** Origin station/yard id (exact match). */
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
originStationId?: string;
|
||||||
|
|
||||||
|
/** Destination station/yard id (exact match). */
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
destinationStationId?: string;
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||||
|
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
|
||||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||||
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||||
@@ -743,16 +744,16 @@ export class TrainSchedulingController {
|
|||||||
|
|
||||||
@Get("container/schedules")
|
@Get("container/schedules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "List container train schedules" })
|
@ApiOperation({ summary: "List container train schedules (paginated)" })
|
||||||
getContainerTrainSchedules() {
|
getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("bulk/schedules")
|
@Get("bulk/schedules")
|
||||||
@TrainSchedulingView()
|
@TrainSchedulingView()
|
||||||
@ApiOperation({ summary: "List bulk train schedules" })
|
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
|
||||||
getBulkTrainSchedules() {
|
getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("container/schedules/:id")
|
@Get("container/schedules/:id")
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
SchedulingStatus,
|
SchedulingStatus,
|
||||||
TrainCheckpointKind,
|
TrainCheckpointKind,
|
||||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||||
|
WagonAllocationSnapshot,
|
||||||
WagonMovementKind,
|
WagonMovementKind,
|
||||||
WagonStatus,
|
WagonStatus,
|
||||||
} from '@edr/types';
|
} from '@edr/types';
|
||||||
@@ -17,8 +18,21 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, In, Not, QueryFailedError } from 'typeorm';
|
import {
|
||||||
|
DataSource,
|
||||||
|
EntityManager,
|
||||||
|
FindOptionsWhere,
|
||||||
|
ILike,
|
||||||
|
In,
|
||||||
|
Not,
|
||||||
|
QueryFailedError,
|
||||||
|
Raw,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildPaginationMeta,
|
||||||
|
normalizePagination,
|
||||||
|
} from '../../common/utils/pagination.util';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
@@ -50,6 +64,10 @@ import { CreateContainerTrainScheduleDto } from './dto/create-container-train-sc
|
|||||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||||
|
import {
|
||||||
|
ListTrainSchedulesQueryDto,
|
||||||
|
TrainScheduleFreightType,
|
||||||
|
} from './dto/list-train-schedules-query.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||||
@@ -115,6 +133,8 @@ import {
|
|||||||
computeImportWindowTimes,
|
computeImportWindowTimes,
|
||||||
earliestSchedulableDeparture,
|
earliestSchedulableDeparture,
|
||||||
eatDay,
|
eatDay,
|
||||||
|
eatDayToUtc,
|
||||||
|
shiftEatDay,
|
||||||
} from './batch-window.util';
|
} from './batch-window.util';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
import { BookingJourneyService } from './booking-journey.service';
|
import { BookingJourneyService } from './booking-journey.service';
|
||||||
@@ -378,6 +398,142 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A schedule GROUP is every schedule sharing an origin, destination, and EAT
|
||||||
|
* departure day — regardless of intermediate stops (ADD→DJ and ADD→DIRE→DJ
|
||||||
|
* group together, since the route entity keys only origin + destination). All
|
||||||
|
* schedules in a group must run ONE shared booking-window timeline so a
|
||||||
|
* customer booking on a later-created train is never expired by a sibling
|
||||||
|
* train's payment window closing on a different clock.
|
||||||
|
*
|
||||||
|
* Grouping keys off the columns the schedule already carries — no new schema.
|
||||||
|
* Callers pass a live `manager` so both the create (inside its transaction) and
|
||||||
|
* the update paths see uncommitted siblings.
|
||||||
|
*/
|
||||||
|
private async findGroupSiblings(
|
||||||
|
manager: EntityManager,
|
||||||
|
originStationId: string,
|
||||||
|
destinationStationId: string,
|
||||||
|
departure: Date,
|
||||||
|
excludeScheduleId?: string,
|
||||||
|
): Promise<TrainSchedule[]> {
|
||||||
|
const day = eatDay(departure);
|
||||||
|
const dayStart = eatDayToUtc(day, 0);
|
||||||
|
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
|
||||||
|
const qb = manager
|
||||||
|
.getRepository(TrainSchedule)
|
||||||
|
.createQueryBuilder('s')
|
||||||
|
.where('s.originStationId = :originStationId', { originStationId })
|
||||||
|
.andWhere('s.destinationStationId = :destinationStationId', { destinationStationId })
|
||||||
|
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||||||
|
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart });
|
||||||
|
if (excludeScheduleId) {
|
||||||
|
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||||
|
}
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The window timeline a brand-new schedule must adopt to join its route+day
|
||||||
|
* group. Returns the canonical open/close times + rule snapshot copied from an
|
||||||
|
* existing sibling, or null when this is the first schedule in the group (the
|
||||||
|
* caller then computes its own times as before — nothing changes for the
|
||||||
|
* single-schedule case).
|
||||||
|
*
|
||||||
|
* The anchor is the sibling that best represents where the GROUP currently is
|
||||||
|
* on its shared clock, so a train created mid-cycle joins the group AT its
|
||||||
|
* current phase (with the group's exact doc-review / payment deadlines) instead
|
||||||
|
* of restarting the whole cycle on its own `now`. When the group has advanced
|
||||||
|
* past PRE_WINDOW we pick the MOST-ADVANCED live (non-DONE) sibling — that is
|
||||||
|
* the phase the joiner must adopt to see the same "N minutes left" the group
|
||||||
|
* already shows. When every sibling is still PRE_WINDOW we pick the
|
||||||
|
* earliest-opening one (the group's frozen open clock).
|
||||||
|
*/
|
||||||
|
private async findGroupWindowAnchor(
|
||||||
|
manager: EntityManager,
|
||||||
|
originStationId: string,
|
||||||
|
destinationStationId: string,
|
||||||
|
departure: Date,
|
||||||
|
): Promise<TrainSchedule | null> {
|
||||||
|
const siblings = await this.findGroupSiblings(
|
||||||
|
manager,
|
||||||
|
originStationId,
|
||||||
|
destinationStationId,
|
||||||
|
departure,
|
||||||
|
);
|
||||||
|
if (siblings.length === 0) return null;
|
||||||
|
const withWindow = siblings.filter((s) => s.windowOpensAt != null);
|
||||||
|
if (withWindow.length === 0) return null;
|
||||||
|
|
||||||
|
// A group whose window is live (some sibling has moved past PRE_WINDOW but is
|
||||||
|
// not yet DONE) — the joiner must land in that same phase on the same clock.
|
||||||
|
const PHASE_ORDER = ['PRE_WINDOW', 'OPEN', 'DOC_REVIEW', 'PAYMENT'];
|
||||||
|
const live = withWindow.filter(
|
||||||
|
(s) => s.windowPhase != null && PHASE_ORDER.includes(s.windowPhase) &&
|
||||||
|
s.windowPhase !== 'PRE_WINDOW',
|
||||||
|
);
|
||||||
|
if (live.length > 0) {
|
||||||
|
// Most-advanced phase leads; ties broken by earliest open for determinism.
|
||||||
|
return live.reduce((best, s) => {
|
||||||
|
const a = PHASE_ORDER.indexOf(s.windowPhase!);
|
||||||
|
const b = PHASE_ORDER.indexOf(best.windowPhase!);
|
||||||
|
if (a !== b) return a > b ? s : best;
|
||||||
|
return s.windowOpensAt!.getTime() < best.windowOpensAt!.getTime() ? s : best;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise the whole group is still PRE_WINDOW — earliest-opening sibling
|
||||||
|
// defines the group clock (the one a customer would have seen first).
|
||||||
|
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
|
||||||
|
const pool = pending.length > 0 ? pending : withWindow;
|
||||||
|
return pool.reduce((earliest, s) =>
|
||||||
|
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full window state an anchor sibling hands down to a schedule JOINING its
|
||||||
|
* group. Copies not just the open/close times + frozen rule snapshot but the
|
||||||
|
* anchor's LIVE PHASE and every phase-end timestamp (docReviewEndsAt,
|
||||||
|
* paymentPhaseEndsAt, bookingCycleNo, bookingWindowStatus). This is what makes a
|
||||||
|
* train created mid-cycle show the EXACT SAME "N minutes left" as the rest of
|
||||||
|
* the group: it enters directly at the group's current phase with the group's
|
||||||
|
* shared payment deadline, instead of restarting PRE_WINDOW→…→PAYMENT on its own
|
||||||
|
* `now` and stamping its own later `paymentPhaseEndsAt`.
|
||||||
|
*
|
||||||
|
* `targetDeparture` is the JOINING schedule's own departure: every time is
|
||||||
|
* clamped to it so a group whose trains depart at different times of the same
|
||||||
|
* day never hands an earlier-departing train a deadline that outlives its
|
||||||
|
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
|
||||||
|
* preserves that invariant when the anchor departed later).
|
||||||
|
*/
|
||||||
|
private groupWindowFieldsFrom(anchor: TrainSchedule, targetDeparture: Date) {
|
||||||
|
const cap = targetDeparture.getTime();
|
||||||
|
const clamp = (d: Date | null | undefined): Date | null =>
|
||||||
|
d == null ? null : d.getTime() > cap ? targetDeparture : d;
|
||||||
|
return {
|
||||||
|
// Live phase + its deadlines — a mid-cycle joiner lands here directly.
|
||||||
|
windowPhase: anchor.windowPhase,
|
||||||
|
bookingWindowStatus:
|
||||||
|
anchor.bookingWindowStatus === 'FULL'
|
||||||
|
? 'OPEN'
|
||||||
|
: anchor.bookingWindowStatus,
|
||||||
|
bookingCycleNo: anchor.bookingCycleNo,
|
||||||
|
windowOpensAt: clamp(anchor.windowOpensAt),
|
||||||
|
windowClosesAt: clamp(anchor.windowClosesAt),
|
||||||
|
docReviewEndsAt: clamp(anchor.docReviewEndsAt),
|
||||||
|
docReviewCompletedAt: clamp(anchor.docReviewCompletedAt),
|
||||||
|
paymentPhaseEndsAt: clamp(anchor.paymentPhaseEndsAt),
|
||||||
|
// Frozen rule snapshot.
|
||||||
|
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
|
||||||
|
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
|
||||||
|
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
|
||||||
|
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
|
||||||
|
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
|
||||||
|
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||||
@@ -544,15 +700,53 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
|
||||||
windowOpensAt: times.windowOpensAt,
|
// WHOLE group — every schedule sharing this origin + destination + EAT
|
||||||
windowClosesAt: times.windowClosesAt,
|
// departure day. They all adopt the SAME rule snapshot and share the SAME
|
||||||
...windowRuleSnapshot(merged),
|
// window open/close timeline (the whole point of grouping). The shared times
|
||||||
});
|
// are clamped to each train's OWN departure so a group whose trains depart at
|
||||||
|
// different times of the same day never hands an earlier-departing sibling a
|
||||||
|
// window that outlives its departure. Only still-PRE_WINDOW siblings are
|
||||||
|
// touched — a sibling that has already opened, finalized, or dispatched stays
|
||||||
|
// frozen on the times its customers were shown and simply drops out of the
|
||||||
|
// group; the remaining pending trains stay in sync. EXPORT is excluded
|
||||||
|
// (departure-anchored FCFS window, no cross-expiry), so an export override
|
||||||
|
// only touches its own schedule.
|
||||||
|
const ruleFields = windowRuleSnapshot(merged);
|
||||||
|
const repo = this.dataSource.getRepository(TrainSchedule);
|
||||||
|
const cap = (d: Date, departure: Date): Date =>
|
||||||
|
d.getTime() > departure.getTime() ? departure : d;
|
||||||
|
|
||||||
|
const targets: Array<{ id: string; departure: Date }> = [
|
||||||
|
{ id, departure: schedule.scheduledDepartureDate },
|
||||||
|
];
|
||||||
|
if (schedule.direction !== 'EXPORT') {
|
||||||
|
const siblings = await this.findGroupSiblings(
|
||||||
|
this.dataSource.manager,
|
||||||
|
schedule.originStationId,
|
||||||
|
schedule.destinationStationId,
|
||||||
|
schedule.scheduledDepartureDate,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
for (const sib of siblings) {
|
||||||
|
if (sib.windowPhase === 'PRE_WINDOW' && sib.scheduledDepartureDate) {
|
||||||
|
targets.push({ id: sib.id, departure: sib.scheduledDepartureDate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of targets) {
|
||||||
|
await repo.update(t.id, {
|
||||||
|
windowOpensAt: cap(times.windowOpensAt, t.departure),
|
||||||
|
windowClosesAt: cap(times.windowClosesAt, t.departure),
|
||||||
|
...ruleFields,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
|
`Booking-window rule overridden for schedule ${id} and ${targets.length - 1} ` +
|
||||||
|
`route+day sibling(s) — reopens ${times.windowOpensAt.toISOString()}`,
|
||||||
);
|
);
|
||||||
void this.emitWindowState(id);
|
for (const t of targets) void this.emitWindowState(t.id);
|
||||||
|
|
||||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||||
return fresh ?? schedule;
|
return fresh ?? schedule;
|
||||||
@@ -617,14 +811,34 @@ export class TrainSchedulingService {
|
|||||||
? computeExportWindowTimes(departure, merged)
|
? computeExportWindowTimes(departure, merged)
|
||||||
: computeImportWindowTimes(departure, merged, now);
|
: computeImportWindowTimes(departure, merged, now);
|
||||||
|
|
||||||
|
// Moving the departure moves this train between route+day GROUPS. If the
|
||||||
|
// destination day already has a group (a sibling on the same origin +
|
||||||
|
// destination + new EAT day), adopt that group's shared timeline instead of
|
||||||
|
// the times just derived, so the rescheduled train lines up with the group
|
||||||
|
// it lands in rather than drifting onto its own clock. Otherwise it keeps its
|
||||||
|
// own re-derived times and becomes the anchor for that day. EXPORT is
|
||||||
|
// excluded — its window is anchored to its own departure, not shared.
|
||||||
|
const anchor =
|
||||||
|
schedule.direction === 'EXPORT'
|
||||||
|
? null
|
||||||
|
: await this.findGroupWindowAnchor(
|
||||||
|
this.dataSource.manager,
|
||||||
|
schedule.originStationId,
|
||||||
|
schedule.destinationStationId,
|
||||||
|
departure,
|
||||||
|
);
|
||||||
|
const windowFields = anchor
|
||||||
|
? this.groupWindowFieldsFrom(anchor, departure)
|
||||||
|
: { windowOpensAt: times.windowOpensAt, windowClosesAt: times.windowClosesAt };
|
||||||
|
|
||||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||||
scheduledDepartureDate: departure,
|
scheduledDepartureDate: departure,
|
||||||
windowOpensAt: times.windowOpensAt,
|
...windowFields,
|
||||||
windowClosesAt: times.windowClosesAt,
|
|
||||||
});
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||||
`(window reopens ${times.windowOpensAt.toISOString()})`,
|
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
|
||||||
|
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
|
||||||
);
|
);
|
||||||
void this.emitWindowState(id);
|
void this.emitWindowState(id);
|
||||||
|
|
||||||
@@ -837,21 +1051,44 @@ export class TrainSchedulingService {
|
|||||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||||
// windows from it rather than the live config.
|
// windows from it rather than the live config.
|
||||||
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
const ruleSnapshot = windowRuleSnapshot(windowCfg);
|
||||||
const windowFields =
|
// Route+day grouping (IMPORT/DOMESTIC only): if a schedule already exists
|
||||||
|
// on this origin + destination + EAT departure day, this new train JOINS
|
||||||
|
// its group and adopts the group's shared window timeline (open/close +
|
||||||
|
// frozen rule) verbatim — it does NOT compute its own `now`-based times.
|
||||||
|
// That keeps every train on the day advancing through the same open/
|
||||||
|
// doc-review/payment/close instants, so a booking on one train is never
|
||||||
|
// expired by a sibling train's payment window closing on a different clock.
|
||||||
|
// First train in the group falls through to the normal computation.
|
||||||
|
//
|
||||||
|
// EXPORT is excluded: an export window is a single FCFS window anchored to
|
||||||
|
// each train's OWN departure (windowClosesAt = departure) with no
|
||||||
|
// doc-review/payment phase — so there is no cross-expiry to fix, and two
|
||||||
|
// export trains departing the same day at different times must keep their
|
||||||
|
// own departure-anchored windows.
|
||||||
|
const groupAnchor =
|
||||||
direction === 'EXPORT'
|
direction === 'EXPORT'
|
||||||
? {
|
? null
|
||||||
bookingWindowStatus: 'CLOSED',
|
: await this.findGroupWindowAnchor(
|
||||||
windowPhase: 'PRE_WINDOW',
|
manager,
|
||||||
...ruleSnapshot,
|
route.originYardId,
|
||||||
...computeExportWindowTimes(departure, windowCfg),
|
route.destinationYardId,
|
||||||
}
|
departure,
|
||||||
|
);
|
||||||
|
const computedTimes =
|
||||||
|
direction === 'EXPORT'
|
||||||
|
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||||
: {
|
: {
|
||||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||||
bookingWindowStatus: 'CLOSED',
|
|
||||||
windowPhase: 'PRE_WINDOW',
|
|
||||||
...ruleSnapshot,
|
...ruleSnapshot,
|
||||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||||
};
|
};
|
||||||
|
const windowFields = {
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
windowPhase: 'PRE_WINDOW',
|
||||||
|
...(groupAnchor
|
||||||
|
? this.groupWindowFieldsFrom(groupAnchor, departure)
|
||||||
|
: computedTimes),
|
||||||
|
};
|
||||||
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
||||||
.maxWagonsPerTrain;
|
.maxWagonsPerTrain;
|
||||||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||||||
@@ -1150,9 +1387,13 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
const booking = await this.bookingsRepository.findById(bookingId);
|
const booking = await this.bookingsRepository.findById(bookingId);
|
||||||
const schedulingStatus = this.resolvePostUnassignStatus(booking);
|
const schedulingStatus = this.resolvePostUnassignStatus(booking);
|
||||||
|
// Clear the schedule pointer too: unassign fully detaches the booking from
|
||||||
|
// this train. Leaving trainScheduleId set glued the booking to a schedule
|
||||||
|
// that may then be dispatched/cancelled/deleted, orphaning it — the
|
||||||
|
// assign-bookings parity guard would reject it from every OTHER schedule.
|
||||||
await this.bookingsRepository.updateSchedulingFields(
|
await this.bookingsRepository.updateSchedulingFields(
|
||||||
bookingId,
|
bookingId,
|
||||||
{ schedulingStatus, wagonsRequired: null },
|
{ schedulingStatus, wagonsRequired: null, trainScheduleId: null },
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1505,12 +1746,34 @@ export class TrainSchedulingService {
|
|||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
TrainScheduleStatusEnum.Dispatched,
|
TrainScheduleStatusEnum.Dispatched,
|
||||||
{ actualDepartureAt: now, trainNumber },
|
{
|
||||||
|
actualDepartureAt: now,
|
||||||
|
trainNumber,
|
||||||
|
// Freeze the wagon plan the moment the train leaves the editable phase.
|
||||||
|
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||||
|
schedule,
|
||||||
|
TrainScheduleStatusEnum.Dispatched,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
|
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'DISPATCHED' });
|
||||||
}
|
}
|
||||||
|
// The train is out — every pinned wagon is ASSIGNED to this schedule and
|
||||||
|
// stays pinned so no other schedule can pick it while it's rolling.
|
||||||
|
const dispatchedPhysicalIds = (schedule.trainSet?.wagons ?? [])
|
||||||
|
.map((slot) => slot.physicalWagonId)
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
if (dispatchedPhysicalIds.length) {
|
||||||
|
await manager
|
||||||
|
.getRepository(Wagon)
|
||||||
|
.update(
|
||||||
|
{ id: In(dispatchedPhysicalIds) },
|
||||||
|
{ status: WagonStatus.Assigned, currentTrainScheduleId: scheduleId },
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const sb of schedule.scheduleBookings ?? []) {
|
for (const sb of schedule.scheduleBookings ?? []) {
|
||||||
await this.bookingsRepository.updateSchedulingFields(
|
await this.bookingsRepository.updateSchedulingFields(
|
||||||
sb.bookingId,
|
sb.bookingId,
|
||||||
@@ -1587,7 +1850,24 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
void this.notifyScheduleBookings(schedule, 'dispatched');
|
void this.notifyScheduleBookings(schedule, 'dispatched');
|
||||||
return this.getTrainScheduleById(scheduleId);
|
|
||||||
|
const detail = await this.getTrainScheduleById(scheduleId);
|
||||||
|
// Surface a compact dispatch confirmation so the caller can toast the "train
|
||||||
|
// is out" info (train number, departure, wagons committed) without re-deriving it.
|
||||||
|
const dispatchedWagonCount = (schedule.trainSet?.wagons ?? []).filter(
|
||||||
|
(slot) => slot.physicalWagonId,
|
||||||
|
).length;
|
||||||
|
return Object.assign(detail, {
|
||||||
|
dispatchInfo: {
|
||||||
|
// The real train number was assigned inside the txn — read it back off
|
||||||
|
// the persisted detail (schedule.trainNumber is the pre-dispatch value).
|
||||||
|
trainNumber: detail.trainNumber ?? schedule.trainNumber ?? null,
|
||||||
|
departedAt: now.toISOString(),
|
||||||
|
wagonsDispatched: dispatchedWagonCount,
|
||||||
|
originStationId: schedule.originStationId,
|
||||||
|
destinationStationId: schedule.destinationStationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getImportDjiboutiOperation(scheduleId: string) {
|
async getImportDjiboutiOperation(scheduleId: string) {
|
||||||
@@ -2544,7 +2824,15 @@ export class TrainSchedulingService {
|
|||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
TrainScheduleStatusEnum.Arrived,
|
TrainScheduleStatusEnum.Arrived,
|
||||||
{ actualArrivalAt: now },
|
{
|
||||||
|
actualArrivalAt: now,
|
||||||
|
// Freeze the plan before the wagons below are released to their yards.
|
||||||
|
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||||
|
schedule,
|
||||||
|
TrainScheduleStatusEnum.Arrived,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2641,8 +2929,42 @@ export class TrainSchedulingService {
|
|||||||
return Object.assign(detail, { warehouseAutomation });
|
return Object.assign(detail, { warehouseAutomation });
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerTrainSchedules() {
|
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
|
||||||
const schedules = await this.trainSchedulesRepository.findAll({
|
const { page, pageSize, skip, take } = normalizePagination(query);
|
||||||
|
|
||||||
|
// Exact-match filters (enum/id semantics). Freight type is derived from
|
||||||
|
// the bookings aboard — no column to match — so it rides on `id` as an
|
||||||
|
// EXISTS fragment instead.
|
||||||
|
const base: FindOptionsWhere<TrainSchedule> = {};
|
||||||
|
if (query.status) base.status = query.status;
|
||||||
|
if (query.originStationId) base.originStationId = query.originStationId;
|
||||||
|
if (query.destinationStationId) base.destinationStationId = query.destinationStationId;
|
||||||
|
if (query.freightType) base.id = this.scheduleFreightTypeFilter(query.freightType) as never;
|
||||||
|
|
||||||
|
// Search fans out across every human-recognizable label; each OR variant
|
||||||
|
// repeats the base filters so the search never widens them.
|
||||||
|
const term = query.search?.trim();
|
||||||
|
let where: FindOptionsWhere<TrainSchedule> | FindOptionsWhere<TrainSchedule>[] =
|
||||||
|
base;
|
||||||
|
if (term) {
|
||||||
|
const like = ILike(`%${term}%`);
|
||||||
|
where = [
|
||||||
|
{ ...base, reference: like as never },
|
||||||
|
{ ...base, trainNumber: like as never },
|
||||||
|
{ ...base, originStation: { label: like } },
|
||||||
|
{ ...base, destinationStation: { label: like } },
|
||||||
|
{ ...base, route: { originYard: { label: like } } },
|
||||||
|
{ ...base, route: { destinationYard: { label: like } } },
|
||||||
|
{ ...base, trainSet: { locomotive: { code: like } } },
|
||||||
|
] as FindOptionsWhere<TrainSchedule>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newest-created first (the client can re-sort; this is the default order).
|
||||||
|
const sortBy = query.sortBy ?? 'createdAt';
|
||||||
|
const sortOrder = query.sortOrder ?? 'DESC';
|
||||||
|
|
||||||
|
const [schedules, total] = await this.trainSchedulesRepository.findAndCount({
|
||||||
|
where,
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||||
// Yards carry the route's display name used by mapScheduleListItem;
|
// Yards carry the route's display name used by mapScheduleListItem;
|
||||||
@@ -2652,10 +2974,14 @@ export class TrainSchedulingService {
|
|||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
scheduleBookings: { booking: true },
|
scheduleBookings: { booking: true },
|
||||||
},
|
},
|
||||||
// Newest-created first (the client can re-sort; this is the default order).
|
order: { [sortBy]: sortOrder } as never,
|
||||||
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
|
skip,
|
||||||
|
take,
|
||||||
});
|
});
|
||||||
return schedules.map((s) => this.mapScheduleListItem(s));
|
return {
|
||||||
|
items: schedules.map((s) => this.mapScheduleListItem(s)),
|
||||||
|
meta: buildPaginationMeta(total, page, pageSize),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContainerTrainScheduleById(id: string) {
|
async getContainerTrainScheduleById(id: string) {
|
||||||
@@ -2668,13 +2994,24 @@ export class TrainSchedulingService {
|
|||||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
await this.trainSchedulesRepository.updateStatus(
|
await this.trainSchedulesRepository.updateStatus(
|
||||||
id,
|
id,
|
||||||
TrainScheduleStatusEnum.Cancelled,
|
TrainScheduleStatusEnum.Cancelled,
|
||||||
// Retire the booking window so a canceled schedule never lingers as an
|
{
|
||||||
// "open window" in booking-window lists or the legacy batch fill.
|
// Retire the booking window so a canceled schedule never lingers as an
|
||||||
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
|
// "open window" in booking-window lists or the legacy batch fill.
|
||||||
|
bookingWindowStatus: 'CLOSED',
|
||||||
|
windowPhase: 'DONE',
|
||||||
|
// Freeze the plan before the wagons below are released back to the yard.
|
||||||
|
wagonAllocationSnapshot: this.buildWagonAllocationSnapshot(
|
||||||
|
schedule,
|
||||||
|
TrainScheduleStatusEnum.Cancelled,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
if (schedule.trainSetId) {
|
if (schedule.trainSetId) {
|
||||||
@@ -2702,14 +3039,23 @@ export class TrainSchedulingService {
|
|||||||
currentTrainScheduleId: null,
|
currentTrainScheduleId: null,
|
||||||
trainSetWagonId: null,
|
trainSetWagonId: null,
|
||||||
status: WagonStatus.Available,
|
status: WagonStatus.Available,
|
||||||
|
// A cancelled train never left — its wagons stay/return at the origin
|
||||||
|
// yard, free to be re-pinned onto another schedule from there.
|
||||||
|
currentYardId: schedule.originStationId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const sb of schedule.scheduleBookings ?? []) {
|
for (const sb of schedule.scheduleBookings ?? []) {
|
||||||
const booking = await this.bookingsRepository.findById(sb.bookingId);
|
const booking = await this.bookingsRepository.findById(sb.bookingId);
|
||||||
|
// Detach from the cancelled schedule — clear the pointer so the freed
|
||||||
|
// booking can be assigned to another train. Leaving it set orphans the
|
||||||
|
// booking against a schedule that is about to be gone.
|
||||||
await this.bookingsRepository.updateSchedulingFields(
|
await this.bookingsRepository.updateSchedulingFields(
|
||||||
sb.bookingId,
|
sb.bookingId,
|
||||||
{ schedulingStatus: this.resolvePostUnassignStatus(booking) },
|
{
|
||||||
|
schedulingStatus: this.resolvePostUnassignStatus(booking),
|
||||||
|
trainScheduleId: null,
|
||||||
|
},
|
||||||
manager,
|
manager,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3241,6 +3587,47 @@ export class TrainSchedulingService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freeze the schedule's live wagon plan into a snapshot. Built from the fully
|
||||||
|
* hydrated graph (findByIdWithFullGraph) BEFORE the transition releases the
|
||||||
|
* physical wagons, so the historical allocation survives those wagons being
|
||||||
|
* re-pinned onto later trains. `capturedStatus` is the status being applied.
|
||||||
|
*/
|
||||||
|
private buildWagonAllocationSnapshot(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
capturedStatus: TrainScheduleStatusEnum,
|
||||||
|
capturedAt: Date,
|
||||||
|
): WagonAllocationSnapshot {
|
||||||
|
const slots = [...(schedule.trainSet?.wagons ?? [])]
|
||||||
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
|
.map((wagon) => ({
|
||||||
|
sequenceNo: wagon.sequenceNo,
|
||||||
|
trainSetWagonId: wagon.id,
|
||||||
|
physicalWagonId: wagon.physicalWagonId ?? null,
|
||||||
|
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||||
|
wagonTypeId: wagon.wagonTypeId ?? null,
|
||||||
|
wagonTypeCode: wagon.wagonType?.code ?? null,
|
||||||
|
slotStatus: wagon.status ?? null,
|
||||||
|
boardYardId: wagon.boardYardId ?? null,
|
||||||
|
alightYardId: wagon.alightYardId ?? null,
|
||||||
|
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||||
|
bookingId: allocation.bookingId,
|
||||||
|
bookingReference: allocation.booking?.reference ?? null,
|
||||||
|
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||||
|
loadType: allocation.loadType ?? null,
|
||||||
|
containerNumbers: (allocation.containerItems ?? [])
|
||||||
|
.map((item) => item.containerNumber)
|
||||||
|
.filter((n): n is string => Boolean(n)),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
capturedStatus,
|
||||||
|
capturedAt: capturedAt.toISOString(),
|
||||||
|
slots,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
private async releasePinnedWagonsForTrainSet(manager: EntityManager, trainSetId: string) {
|
||||||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
||||||
for (const slot of slots) {
|
for (const slot of slots) {
|
||||||
@@ -3869,6 +4256,33 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHERE fragment matching the DERIVED schedule freight type — the SQL mirror
|
||||||
|
* of {@link resolveScheduleFreightType} (keep the two in sync). CONTAINER /
|
||||||
|
* BULK = has bookings and every one is that kind; MIXED = both kinds aboard.
|
||||||
|
* Schedules with no bookings (type null) match nothing. Applied to `id` so
|
||||||
|
* the list query stays on findAndCount instead of a query-builder rewrite.
|
||||||
|
*/
|
||||||
|
private scheduleFreightTypeFilter(freightType: TrainScheduleFreightType) {
|
||||||
|
const hasBookingOfType = (alias: string, cmp: '=' | '<>', param: string) =>
|
||||||
|
'EXISTS (SELECT 1 FROM freight.train_schedule_bookings tsb ' +
|
||||||
|
'JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL ' +
|
||||||
|
`WHERE tsb.train_schedule_id = ${alias} AND tsb.deleted_at IS NULL ` +
|
||||||
|
`AND b.freight_type ${cmp} :${param})`;
|
||||||
|
if (freightType === 'MIXED') {
|
||||||
|
return Raw(
|
||||||
|
(alias) =>
|
||||||
|
`${hasBookingOfType(alias, '=', 'ftContainer')} AND ${hasBookingOfType(alias, '=', 'ftBulk')}`,
|
||||||
|
{ ftContainer: 'CONTAINER', ftBulk: 'BULK' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Raw(
|
||||||
|
(alias) =>
|
||||||
|
`${hasBookingOfType(alias, '=', 'ftIs')} AND NOT ${hasBookingOfType(alias, '<>', 'ftNot')}`,
|
||||||
|
{ ftIs: freightType, ftNot: freightType },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private resolveScheduleFreightType(
|
private resolveScheduleFreightType(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||||||
@@ -4441,6 +4855,20 @@ export class TrainSchedulingService {
|
|||||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
|
||||||
|
// and re-pinned onto later trains — the live wagon↔slot joins no longer
|
||||||
|
// describe THIS train. If a frozen snapshot was captured at the transition,
|
||||||
|
// the per-slot wagon number + booking allocations are read from it instead.
|
||||||
|
const snapshot = schedule.wagonAllocationSnapshot ?? null;
|
||||||
|
const isWagonAllocationFrozen = Boolean(
|
||||||
|
snapshot &&
|
||||||
|
schedule.status !== TrainScheduleStatusEnum.Draft &&
|
||||||
|
schedule.status !== TrainScheduleStatusEnum.Scheduled,
|
||||||
|
);
|
||||||
|
const snapshotSlotByTrainSetWagonId = new Map(
|
||||||
|
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
@@ -4523,52 +4951,84 @@ export class TrainSchedulingService {
|
|||||||
})),
|
})),
|
||||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||||
.map((wagon) => ({
|
.map((wagon) => {
|
||||||
id: wagon.id,
|
// Frozen schedules read the wagon number + allocations from the
|
||||||
sequenceNo: wagon.sequenceNo,
|
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
// comes live. Falls back to live if a slot is missing from the snap.
|
||||||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
const frozenSlot = isWagonAllocationFrozen
|
||||||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
? snapshotSlotByTrainSetWagonId.get(wagon.id)
|
||||||
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
: undefined;
|
||||||
// frontend needs it to show the gross train weight.
|
return {
|
||||||
tareWeightTons: wagon.wagonType
|
id: wagon.id,
|
||||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
sequenceNo: wagon.sequenceNo,
|
||||||
: null,
|
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||||
status: wagon.status,
|
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||||||
physicalWagonId: wagon.physicalWagonId ?? null,
|
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||||||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
||||||
wagonType: wagon.wagonType
|
// frontend needs it to show the gross train weight.
|
||||||
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
tareWeightTons: wagon.wagonType
|
||||||
: null,
|
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||||
allocations:
|
: null,
|
||||||
wagon.allocations?.map((allocation) => ({
|
status: wagon.status,
|
||||||
id: allocation.id,
|
physicalWagonId: frozenSlot
|
||||||
bookingId: allocation.bookingId,
|
? frozenSlot.physicalWagonId
|
||||||
bookingReference: allocation.booking?.reference ?? null,
|
: wagon.physicalWagonId ?? null,
|
||||||
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
|
physicalWagonNumber: frozenSlot
|
||||||
loadType: allocation.loadType ?? null,
|
? frozenSlot.physicalWagonNumber
|
||||||
status: allocation.status,
|
: wagon.physicalWagon?.wagonNumber ?? null,
|
||||||
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
|
wagonType: wagon.wagonType
|
||||||
(item) => ({
|
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
||||||
id: item.id,
|
: null,
|
||||||
containerNumber: item.containerNumber ?? null,
|
allocations: frozenSlot
|
||||||
containerTypeId: item.containerTypeId,
|
? frozenSlot.allocations.map((allocation) => ({
|
||||||
grossWeightTons: item.grossWeightTons ?? null,
|
id: null,
|
||||||
containerId: item.containerId ?? null,
|
bookingId: allocation.bookingId,
|
||||||
positionOnWagon: item.positionOnWagon ?? null,
|
bookingReference: allocation.bookingReference,
|
||||||
bookingContainerId: item.bookingContainerId ?? null,
|
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
|
||||||
}),
|
loadType: allocation.loadType,
|
||||||
),
|
status: null,
|
||||||
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
|
// Frozen: container detail collapses to the captured numbers;
|
||||||
? {
|
// per-container geometry isn't re-derivable post-release.
|
||||||
id: bulkLoadsByAllocation.get(allocation.id)!.id,
|
containerItems: allocation.containerNumbers.map((containerNumber) => ({
|
||||||
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
|
id: null,
|
||||||
cargoDescription:
|
containerNumber,
|
||||||
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
|
containerTypeId: null,
|
||||||
}
|
grossWeightTons: null,
|
||||||
: null,
|
containerId: null,
|
||||||
})) ?? [],
|
positionOnWagon: null,
|
||||||
})),
|
bookingContainerId: null,
|
||||||
|
})),
|
||||||
|
bulkLoad: null,
|
||||||
|
}))
|
||||||
|
: wagon.allocations?.map((allocation) => ({
|
||||||
|
id: allocation.id,
|
||||||
|
bookingId: allocation.bookingId,
|
||||||
|
bookingReference: allocation.booking?.reference ?? null,
|
||||||
|
allocatedWeightTons: roundTons(Number(allocation.allocatedWeightTons)),
|
||||||
|
loadType: allocation.loadType ?? null,
|
||||||
|
status: allocation.status,
|
||||||
|
containerItems: (containerItemsByAllocation.get(allocation.id) ?? []).map(
|
||||||
|
(item) => ({
|
||||||
|
id: item.id,
|
||||||
|
containerNumber: item.containerNumber ?? null,
|
||||||
|
containerTypeId: item.containerTypeId,
|
||||||
|
grossWeightTons: item.grossWeightTons ?? null,
|
||||||
|
containerId: item.containerId ?? null,
|
||||||
|
positionOnWagon: item.positionOnWagon ?? null,
|
||||||
|
bookingContainerId: item.bookingContainerId ?? null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
bulkLoad: bulkLoadsByAllocation.get(allocation.id)
|
||||||
|
? {
|
||||||
|
id: bulkLoadsByAllocation.get(allocation.id)!.id,
|
||||||
|
weightTons: bulkLoadsByAllocation.get(allocation.id)!.weightTons,
|
||||||
|
cargoDescription:
|
||||||
|
bulkLoadsByAllocation.get(allocation.id)!.cargoDescription ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
bookings:
|
bookings:
|
||||||
@@ -4586,6 +5046,11 @@ export class TrainSchedulingService {
|
|||||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||||
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
|
||||||
})) ?? [],
|
})) ?? [],
|
||||||
|
// True when the wagon plan above is served from the frozen snapshot (schedule
|
||||||
|
// is dispatched/arrived/cancelled) rather than the live joins — the UI can badge
|
||||||
|
// it "historical" and skip re-pin affordances.
|
||||||
|
isWagonAllocationFrozen,
|
||||||
|
wagonAllocationSnapshot: snapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export class WarehouseInventoryController {
|
|||||||
return this.inventoryService.arrivalQueue();
|
return this.inventoryService.arrivalQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('zone-occupancy')
|
||||||
|
@ApiOperation({ summary: 'Live occupancy per zone (rated capacity vs held) — heatmap data' })
|
||||||
|
zoneOccupancy(@Query('yardId') yardId?: string) {
|
||||||
|
return this.inventoryService.zoneOccupancy(yardId);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('auto-unload-arrived')
|
@Post('auto-unload-arrived')
|
||||||
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
@ApiOperation({ summary: 'Bulk auto-unload all arrived bookings into the warehouse' })
|
||||||
autoUnloadArrived() {
|
autoUnloadArrived() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
@@ -396,6 +397,135 @@ export class WarehouseInventoryService {
|
|||||||
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
* but has no customer truck assigned yet, nudge the customer to assign one — with
|
||||||
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
* a deep-link to the booking's truck-assignment card. Fire-and-forget.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Live occupancy per zone: rated capacity vs the weight/items currently held
|
||||||
|
* (excludes items that have left — DELIVERED/DISPATCHED). Powers the yard
|
||||||
|
* occupancy heatmap. Optionally scoped to one yard.
|
||||||
|
*/
|
||||||
|
async zoneOccupancy(yardId?: string): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
yardId: string;
|
||||||
|
capacityWeight: number | null;
|
||||||
|
capacityContainers: number | null;
|
||||||
|
usedWeight: number;
|
||||||
|
usedItems: number;
|
||||||
|
occupancyPct: number | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const rows: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: string;
|
||||||
|
yardId: string;
|
||||||
|
capacityWeight: string | null;
|
||||||
|
capacityContainers: number | null;
|
||||||
|
usedWeight: number;
|
||||||
|
usedItems: number;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT z.id,
|
||||||
|
z.name,
|
||||||
|
z.code,
|
||||||
|
z.type,
|
||||||
|
z.yard_id AS "yardId",
|
||||||
|
z.capacity_weight AS "capacityWeight",
|
||||||
|
z.capacity_containers AS "capacityContainers",
|
||||||
|
COALESCE(SUM(inv.weight) FILTER (
|
||||||
|
WHERE inv.deleted_at IS NULL
|
||||||
|
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
|
||||||
|
), 0)::float8 AS "usedWeight",
|
||||||
|
COALESCE(COUNT(inv.id) FILTER (
|
||||||
|
WHERE inv.deleted_at IS NULL
|
||||||
|
AND inv.status NOT IN ('DELIVERED', 'DISPATCHED')
|
||||||
|
), 0)::int AS "usedItems"
|
||||||
|
FROM freight.warehouse_zones z
|
||||||
|
LEFT JOIN freight.warehouse_inventory inv ON inv.zone_id = z.id
|
||||||
|
WHERE z.is_active = true
|
||||||
|
AND z.deleted_at IS NULL
|
||||||
|
AND ($1::uuid IS NULL OR z.yard_id = $1)
|
||||||
|
GROUP BY z.id
|
||||||
|
ORDER BY z.name`,
|
||||||
|
[yardId ?? null],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((r) => {
|
||||||
|
const capWeight = r.capacityWeight != null ? Number(r.capacityWeight) : null;
|
||||||
|
const byWeight =
|
||||||
|
capWeight && capWeight > 0 ? (r.usedWeight / capWeight) * 100 : null;
|
||||||
|
const byItems =
|
||||||
|
r.capacityContainers && r.capacityContainers > 0
|
||||||
|
? (r.usedItems / r.capacityContainers) * 100
|
||||||
|
: null;
|
||||||
|
// Prefer container-count occupancy (unit-consistent). Weight capacity is
|
||||||
|
// tonnes while inventory weight is kg, so weight% is only a rough fallback.
|
||||||
|
const pct = byItems ?? byWeight;
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
code: r.code,
|
||||||
|
type: r.type,
|
||||||
|
yardId: r.yardId,
|
||||||
|
capacityWeight: capWeight,
|
||||||
|
capacityContainers: r.capacityContainers,
|
||||||
|
usedWeight: r.usedWeight,
|
||||||
|
usedItems: r.usedItems,
|
||||||
|
occupancyPct: pct == null ? null : Math.round(pct * 10) / 10,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recurring nudge: keep reminding self-haul IMPORT customers to assign a
|
||||||
|
* collection truck while their goods are still in the warehouse
|
||||||
|
* (READY_FOR_PICKUP) and no truck has been assigned yet. Stops once a truck is
|
||||||
|
* assigned (customer_truck_assigned_at set) or the goods leave (DELIVERED).
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_30_MINUTES, { name: 'import-truck-assignment-reminder' })
|
||||||
|
async remindImportTruckAssignment(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows: Array<{
|
||||||
|
bookingId: string;
|
||||||
|
companyId: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT DISTINCT b.id AS "bookingId",
|
||||||
|
b.company_id AS "companyId",
|
||||||
|
b.reference
|
||||||
|
FROM freight.warehouse_inventory inv
|
||||||
|
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||||
|
WHERE inv.deleted_at IS NULL
|
||||||
|
AND inv.status = 'READY_FOR_PICKUP'
|
||||||
|
AND b.trade_direction = 'IMPORT'
|
||||||
|
AND b.customer_truck_assigned_at IS NULL
|
||||||
|
AND COALESCE(NULLIF(TRIM(b.last_mile_delivery_address), ''), '') = ''`,
|
||||||
|
);
|
||||||
|
if (!rows.length) return;
|
||||||
|
this.logger.log(
|
||||||
|
`Import truck-assignment reminder: ${rows.length} booking(s) awaiting a collection truck`,
|
||||||
|
);
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.notifyTruckAssignmentNeeded(
|
||||||
|
{
|
||||||
|
companyId: row.companyId,
|
||||||
|
reference: row.reference,
|
||||||
|
hasFirstMile: false,
|
||||||
|
hasLastMile: false,
|
||||||
|
customerTruckAssignedAt: null,
|
||||||
|
},
|
||||||
|
row.bookingId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Import truck-assignment reminder tick failed: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async notifyTruckAssignmentNeeded(booking: {
|
private async notifyTruckAssignmentNeeded(booking: {
|
||||||
companyId?: string | null;
|
companyId?: string | null;
|
||||||
reference?: string | null;
|
reference?: string | null;
|
||||||
|
|||||||
23
apps/edr-freight-api/src/scripts/seed-dropdown-settings.ts
Normal file
23
apps/edr-freight-api/src/scripts/seed-dropdown-settings.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { AppDataSource } from "../data-source";
|
||||||
|
import { DropdownSettingsSeeder } from "../seed/dropdown-settings.seeder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the dropdown settings catalog (codes/labels only, no options) into an
|
||||||
|
* EMPTY dropdown_settings table; skips entirely if any rows exist. Run on
|
||||||
|
* demand:
|
||||||
|
* pnpm --filter @edr/freight-api seed:dropdown-settings
|
||||||
|
*/
|
||||||
|
async function run() {
|
||||||
|
await AppDataSource.initialize();
|
||||||
|
try {
|
||||||
|
const seeder = new DropdownSettingsSeeder(AppDataSource);
|
||||||
|
await seeder.run();
|
||||||
|
} finally {
|
||||||
|
await AppDataSource.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
console.error("Failed to seed dropdown settings:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
89
apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts
Normal file
89
apps/edr-freight-api/src/seed/dropdown-settings.seeder.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownSetting,
|
||||||
|
DropdownSettingMeta,
|
||||||
|
} from "../modules/dropdown-settings/entities/dropdown-setting.entity";
|
||||||
|
|
||||||
|
interface DefaultDropdownSetting {
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
multiple: boolean;
|
||||||
|
meta?: DropdownSettingMeta | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Known dropdown settings, seeded as empty catalogs (no options). The options
|
||||||
|
* are managed by the admin from the backoffice Dropdown Settings editor.
|
||||||
|
*/
|
||||||
|
const DEFAULT_DROPDOWN_SETTINGS: DefaultDropdownSetting[] = [
|
||||||
|
{
|
||||||
|
code: "stations_ter",
|
||||||
|
label: "Stations TER",
|
||||||
|
description:
|
||||||
|
"Temporary freight station list used by booking origin and destination yards.",
|
||||||
|
multiple: false,
|
||||||
|
meta: { searchable: true, clearable: true, version: "temporary" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "general_contract_period",
|
||||||
|
label: "General Contract Period (months)",
|
||||||
|
description:
|
||||||
|
"How many months a general contract stays open for ordering after activation.",
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "contract_validity_periods",
|
||||||
|
label: "Contract Validity Periods (days)",
|
||||||
|
description:
|
||||||
|
"Validity durations (in days) a staff can choose when accepting a submitted contract.",
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: "ro_vessel_min_days",
|
||||||
|
label: "RO vessel minimum lead time (days)",
|
||||||
|
description:
|
||||||
|
"Minimum days between today and the vessel departure date on an export Release Order.",
|
||||||
|
multiple: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DropdownSettingsSeeder {
|
||||||
|
private readonly logger = new Logger(DropdownSettingsSeeder.name);
|
||||||
|
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
const settingRepository = this.dataSource.getRepository(DropdownSetting);
|
||||||
|
|
||||||
|
// Seed only into an empty table: any existing rows (including
|
||||||
|
// soft-deleted ones, which would still conflict on the unique `code`)
|
||||||
|
// mean the data is admin-managed, so leave it untouched.
|
||||||
|
const existing = await settingRepository.count({ withDeleted: true });
|
||||||
|
if (existing > 0) {
|
||||||
|
this.logger.log(
|
||||||
|
`dropdown_settings already has ${existing} rows — skipping seed`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert setting rows only — no DropdownOption rows. Options start empty
|
||||||
|
// and are configured by the admin from the backoffice editor.
|
||||||
|
await settingRepository.insert(
|
||||||
|
DEFAULT_DROPDOWN_SETTINGS.map((setting) => ({
|
||||||
|
code: setting.code,
|
||||||
|
label: setting.label,
|
||||||
|
description: setting.description,
|
||||||
|
multiple: setting.multiple,
|
||||||
|
meta: setting.meta ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Seeded ${DEFAULT_DROPDOWN_SETTINGS.length} dropdown settings with empty options`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { DataSource } from "typeorm";
|
import { DataSource } from "typeorm";
|
||||||
|
|
||||||
import { FileUploadField } from "../modules/file-upload-settings/entities/file-upload-field.entity";
|
|
||||||
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
import { FileUploadSetting } from "../modules/file-upload-settings/entities/file-upload-setting.entity";
|
||||||
|
|
||||||
interface OnboardingField {
|
interface OnboardingField {
|
||||||
@@ -576,88 +575,66 @@ export class FileUploadSettingsSeeder {
|
|||||||
constructor(private readonly dataSource: DataSource) { }
|
constructor(private readonly dataSource: DataSource) { }
|
||||||
|
|
||||||
async run() {
|
async run() {
|
||||||
await this.dataSource.transaction(async (manager) => {
|
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
|
||||||
const settingRepository = manager.getRepository(FileUploadSetting);
|
|
||||||
const fieldRepository = manager.getRepository(FileUploadField);
|
|
||||||
|
|
||||||
const allSettings: Array<
|
// Seed only into an empty table: any existing rows (including
|
||||||
OnboardingDocumentSetting & { description: string }
|
// soft-deleted ones, which would still conflict on the unique `code`)
|
||||||
> = [
|
// mean the data is admin-managed, so leave it untouched.
|
||||||
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
const existing = await settingRepository.count({ withDeleted: true });
|
||||||
...s,
|
if (existing > 0) {
|
||||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
this.logger.log(
|
||||||
})),
|
`file_upload_settings already has ${existing} rows — skipping seed`,
|
||||||
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
);
|
||||||
...s,
|
return;
|
||||||
description: CLEARANCE_DESCRIPTION,
|
}
|
||||||
})),
|
|
||||||
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
|
||||||
...s,
|
|
||||||
description:
|
|
||||||
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
|
||||||
})),
|
|
||||||
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
|
||||||
...s,
|
|
||||||
description:
|
|
||||||
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
|
||||||
})),
|
|
||||||
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
|
||||||
...s,
|
|
||||||
description:
|
|
||||||
"Commercial/framework documents attached at contract submission.",
|
|
||||||
})),
|
|
||||||
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
|
|
||||||
...s,
|
|
||||||
description:
|
|
||||||
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const documentSetting of allSettings) {
|
const allSettings: Array<
|
||||||
await settingRepository.upsert(
|
OnboardingDocumentSetting & { description: string }
|
||||||
{
|
> = [
|
||||||
code: documentSetting.code,
|
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||||
label: documentSetting.label,
|
...s,
|
||||||
description: documentSetting.description,
|
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||||
entity: documentSetting.entity,
|
})),
|
||||||
},
|
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||||
{
|
...s,
|
||||||
conflictPaths: { code: true },
|
description: CLEARANCE_DESCRIPTION,
|
||||||
},
|
})),
|
||||||
);
|
...CONTRACT_CLEARANCE_SETTINGS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description:
|
||||||
|
"Pre-booking clearance documents collected on the contract (Path B), by operation and freight type.",
|
||||||
|
})),
|
||||||
|
...SELF_CLEARANCE_SETTINGS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description:
|
||||||
|
"Customer self-clearance documents (Path A, no EDR customs service), reviewed by Operations.",
|
||||||
|
})),
|
||||||
|
...CONTRACT_INTAKE_SETTINGS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description:
|
||||||
|
"Commercial/framework documents attached at contract submission.",
|
||||||
|
})),
|
||||||
|
...DRIVER_DOCUMENT_SETTINGS.map((s) => ({
|
||||||
|
...s,
|
||||||
|
description:
|
||||||
|
"Documents uploaded against a driver profile (license, ID, contracts, etc.).",
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
const setting = await settingRepository.findOne({
|
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
||||||
where: { code: documentSetting.code },
|
// and are configured from the backoffice file-settings editor; the field
|
||||||
select: { id: true, code: true },
|
// definitions above are kept as reference defaults.
|
||||||
});
|
await settingRepository.insert(
|
||||||
|
allSettings.map((documentSetting) => ({
|
||||||
if (!setting) {
|
code: documentSetting.code,
|
||||||
throw new Error(
|
label: documentSetting.label,
|
||||||
`file_upload_setting_seed_failed:${documentSetting.code}`,
|
description: documentSetting.description,
|
||||||
);
|
entity: documentSetting.entity,
|
||||||
}
|
})),
|
||||||
|
);
|
||||||
await fieldRepository.delete({ settingId: setting.id });
|
|
||||||
|
|
||||||
await fieldRepository.insert(
|
|
||||||
documentSetting.fields.map((field, index) => ({
|
|
||||||
settingId: setting.id,
|
|
||||||
fileKey: field.fileKey,
|
|
||||||
fileLabel: field.fileLabel,
|
|
||||||
helpText: field.helpText,
|
|
||||||
isRequired: field.isRequired,
|
|
||||||
isMultiple: field.isMultiple,
|
|
||||||
maxFiles: field.maxFiles,
|
|
||||||
allowedExtensions: [...field.allowedExtensions],
|
|
||||||
maxSizeMb: field.maxSizeMb,
|
|
||||||
displayOrder: field.displayOrder ?? index + 1,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
"Ensured company onboarding + booking clearance file upload settings",
|
`Seeded ${allSettings.length} file upload settings with empty fields`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
|||||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||||
|
import ClearanceDocumentsPage from "./pages/contracts/ClearanceDocumentsPage";
|
||||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||||
@@ -126,6 +127,7 @@ import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
|
|||||||
import { HealthCheck } from "./features/health/HealthCheck";
|
import { HealthCheck } from "./features/health/HealthCheck";
|
||||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||||
import { UserManagementRoutes } from "./user-management/route";
|
import { UserManagementRoutes } from "./user-management/route";
|
||||||
|
import SetPassword from "./shared/components/SetPassword";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -152,6 +154,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
icon: <FileSignature />,
|
icon: <FileSignature />,
|
||||||
permission: FREIGHT_PERMS.contracts.view,
|
permission: FREIGHT_PERMS.contracts.view,
|
||||||
},
|
},
|
||||||
|
// Operations hub: clearance-document review for contracts WITHOUT
|
||||||
|
// customs clearing (contract-level for one-time, per-booking for general).
|
||||||
|
{
|
||||||
|
label: "Clearance Documents",
|
||||||
|
href: "/dashboard/contracts/clearance-documents",
|
||||||
|
icon: <ShieldCheck />,
|
||||||
|
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Customers",
|
label: "Customers",
|
||||||
href: "/dashboard/customers",
|
href: "/dashboard/customers",
|
||||||
@@ -655,6 +665,10 @@ const App = () => {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/auth" element={<LoginPage />} />
|
<Route path="/auth" element={<LoginPage />} />
|
||||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||||
|
<Route
|
||||||
|
path="um/set-password"
|
||||||
|
element={<SetPassword />}
|
||||||
|
/>
|
||||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -788,6 +802,18 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{/* Operations hub: clearance documents for non-customs contracts —
|
||||||
|
Contracts tab (contract-level) + General tab (per-booking). */}
|
||||||
|
<Route
|
||||||
|
path="contracts/clearance-documents"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
|
||||||
|
>
|
||||||
|
<ClearanceDocumentsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{/* GL (Path B) contract clearance review hub */}
|
{/* GL (Path B) contract clearance review hub */}
|
||||||
<Route
|
<Route
|
||||||
path="contracts/clearance"
|
path="contracts/clearance"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,9 +24,10 @@ export interface GlShipmentQuantities {
|
|||||||
hazardousQuantity: number;
|
hazardousQuantity: number;
|
||||||
reeferQuantity: number;
|
reeferQuantity: number;
|
||||||
}>;
|
}>;
|
||||||
/** Bulk: tons (or item count) + hazardous qty. */
|
/** Bulk: tons (or item count) + hazardous/reefer qty. */
|
||||||
bulkQuantity: number;
|
bulkQuantity: number;
|
||||||
bulkHazardousQuantity: number;
|
bulkHazardousQuantity: number;
|
||||||
|
bulkReeferQuantity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,6 +114,30 @@ export function computeGlShipmentTotal(
|
|||||||
amount: rate.unitPrice * qty,
|
amount: rate.unitPrice * qty,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (contract.isHazardous && q.bulkHazardousQuantity > 0) {
|
||||||
|
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
|
||||||
|
if (hz) {
|
||||||
|
lines.push({
|
||||||
|
label: hz.label,
|
||||||
|
unitPrice: hz.unitPrice,
|
||||||
|
unit: hz.unit,
|
||||||
|
quantity: q.bulkHazardousQuantity,
|
||||||
|
amount: hz.unitPrice * q.bulkHazardousQuantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (contract.isReefer && q.bulkReeferQuantity > 0) {
|
||||||
|
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
|
||||||
|
if (rf) {
|
||||||
|
lines.push({
|
||||||
|
label: rf.label,
|
||||||
|
unitPrice: rf.unitPrice,
|
||||||
|
unit: rf.unit,
|
||||||
|
quantity: q.bulkReeferQuantity,
|
||||||
|
amount: rf.unitPrice * q.bulkReeferQuantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = lines.reduce((s, l) => s + l.amount, 0);
|
const total = lines.reduce((s, l) => s + l.amount, 0);
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FileInput,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Camera, PenLine } from "lucide-react";
|
||||||
|
|
||||||
|
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||||
|
import { lastMileService } from "@/services/last-mile.service";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
interface ProofOfDeliveryModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
lastMileId: string | null;
|
||||||
|
reference?: string | null;
|
||||||
|
/** Called after a successful capture so the caller can refetch. */
|
||||||
|
onDone: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proof of delivery capture for an EDR last-mile leg: recipient name, a drawn
|
||||||
|
* signature, and proof photos. On confirm it uploads everything and completes
|
||||||
|
* the delivery (marks the leg DELIVERED).
|
||||||
|
*/
|
||||||
|
export function ProofOfDeliveryModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
lastMileId,
|
||||||
|
reference,
|
||||||
|
onDone,
|
||||||
|
}: ProofOfDeliveryModalProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [recipient, setRecipient] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [signatureUrl, setSignatureUrl] = useState<string | null>(null);
|
||||||
|
const [photos, setPhotos] = useState<File[]>([]);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setRecipient("");
|
||||||
|
setNotes("");
|
||||||
|
setSignatureUrl(null);
|
||||||
|
setPhotos([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!lastMileId) throw new Error("No delivery selected");
|
||||||
|
const signature = signatureUrl
|
||||||
|
? await (await fetch(signatureUrl)).blob()
|
||||||
|
: null;
|
||||||
|
return lastMileService.recordProofOfDelivery(lastMileId, {
|
||||||
|
recipientName: recipient.trim(),
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
|
signature,
|
||||||
|
photos,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: "Proof of delivery recorded",
|
||||||
|
description: "The delivery has been completed.",
|
||||||
|
});
|
||||||
|
reset();
|
||||||
|
onDone();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Could not record delivery",
|
||||||
|
description: e instanceof Error ? e.message : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Require a recipient plus at least one form of proof (signature or a photo).
|
||||||
|
const canSubmit =
|
||||||
|
recipient.trim().length > 0 && (Boolean(signatureUrl) || photos.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title={`Record delivery${reference ? ` — ${reference}` : ""}`}
|
||||||
|
size="lg"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label="Received by"
|
||||||
|
placeholder="Recipient's name"
|
||||||
|
required
|
||||||
|
value={recipient}
|
||||||
|
onChange={(e) => setRecipient(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb={4}>
|
||||||
|
Recipient signature
|
||||||
|
</Text>
|
||||||
|
<ContractSignaturePad onChange={setSignatureUrl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FileInput
|
||||||
|
label="Proof photos"
|
||||||
|
placeholder="Attach delivery photo(s)"
|
||||||
|
leftSection={<Camera size={16} />}
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
clearable
|
||||||
|
value={photos}
|
||||||
|
onChange={setPhotos}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
label="Notes"
|
||||||
|
placeholder="Optional delivery notes"
|
||||||
|
autosize
|
||||||
|
minRows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
Provide a signature or at least one photo. Confirming completes the delivery.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" onClick={close} disabled={submit.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="green"
|
||||||
|
leftSection={<PenLine size={16} />}
|
||||||
|
loading={submit.isPending}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
onClick={() => submit.mutate()}
|
||||||
|
>
|
||||||
|
Confirm delivery
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -137,8 +137,12 @@ export function AllocateBookingWizard({
|
|||||||
enabled: opened,
|
enabled: opened,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Paginated {items, meta} list; the newest 100 schedules comfortably cover
|
||||||
|
// every DRAFT schedule the wizard can attach to.
|
||||||
const schedulesQuery = useQuery(
|
const schedulesQuery = useQuery(
|
||||||
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
|
api.trainScheduling.scheduleList.queryOptions({
|
||||||
|
input: { filters: { pageSize: 100 } },
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const routesQuery = useQuery(
|
const routesQuery = useQuery(
|
||||||
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
|
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
|
||||||
@@ -163,7 +167,7 @@ export function AllocateBookingWizard({
|
|||||||
|
|
||||||
const matchingSchedules = useMemo(
|
const matchingSchedules = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(schedulesQuery.data ?? []).filter(
|
(schedulesQuery.data?.items ?? []).filter(
|
||||||
(s: TrainScheduleListItem) =>
|
(s: TrainScheduleListItem) =>
|
||||||
s.status === "DRAFT" &&
|
s.status === "DRAFT" &&
|
||||||
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),
|
(!s.freightType || s.freightType === "MIXED" || s.freightType === bookingFreightType),
|
||||||
|
|||||||
@@ -232,6 +232,15 @@ export default function BookingWindowSettingsModal({
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
|
{!isExport ? (
|
||||||
|
<Alert variant="light" color="orange" icon={<Info size={16} />}>
|
||||||
|
These settings apply to every train on this route (same origin and
|
||||||
|
destination) departing the same day — they all share one booking
|
||||||
|
window, so it opens, moves to document review, opens for payment,
|
||||||
|
and closes at the same time for all of them.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isExport ? (
|
{isExport ? (
|
||||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||||
Export schedules use a single first-come-first-served window: it
|
Export schedules use a single first-come-first-served window: it
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { memo, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
@@ -267,7 +267,13 @@ function CapacityDivider({ used, max }: { used: number; max: number | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PriorityTrackingTab({ data, bookings }: Props) {
|
// Memoized: mounted in a keep-mounted Tabs panel, so it re-renders with every
|
||||||
|
// page render; both props keep their identity across unrelated page state
|
||||||
|
// (React Query structural sharing + the page's useMemo'd bookings).
|
||||||
|
export const PriorityTrackingTab = memo(function PriorityTrackingTab({
|
||||||
|
data,
|
||||||
|
bookings,
|
||||||
|
}: Props) {
|
||||||
const phase = data.windowPhase;
|
const phase = data.windowPhase;
|
||||||
const isPayPhase = phase === "PAYMENT";
|
const isPayPhase = phase === "PAYMENT";
|
||||||
|
|
||||||
@@ -558,7 +564,7 @@ export function PriorityTrackingTab({ data, bookings }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Contextual banner describing the current window phase in plain language. */
|
/** Contextual banner describing the current window phase in plain language. */
|
||||||
function PhaseBanner({ phase }: { phase: string | null }) {
|
function PhaseBanner({ phase }: { phase: string | null }) {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Badge, Card, Group, Loader, Progress, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||||
|
import { LayoutGrid } from 'lucide-react';
|
||||||
|
|
||||||
|
import { useZoneOccupancy } from '@/hooks/useWarehouses';
|
||||||
|
import type { ZoneOccupancy } from '@/types/warehouse';
|
||||||
|
|
||||||
|
/** Green < 60%, amber 60–85%, red > 85%. */
|
||||||
|
function tone(pct: number | null): { color: string; label: string } {
|
||||||
|
if (pct == null) return { color: 'gray', label: 'No capacity set' };
|
||||||
|
if (pct > 85) return { color: 'red', label: 'Full' };
|
||||||
|
if (pct >= 60) return { color: 'orange', label: 'Filling' };
|
||||||
|
return { color: 'teal', label: 'Space' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacityLabel(z: ZoneOccupancy): string {
|
||||||
|
if (z.capacityContainers && z.capacityContainers > 0) {
|
||||||
|
return `${z.usedItems} / ${z.capacityContainers} items`;
|
||||||
|
}
|
||||||
|
if (z.capacityWeight && z.capacityWeight > 0) {
|
||||||
|
return `${z.usedItems} item(s) · ${z.usedWeight.toLocaleString()} kg`;
|
||||||
|
}
|
||||||
|
return `${z.usedItems} item(s)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoneOccupancyHeatmapProps {
|
||||||
|
/** Scope to one yard; omit for all zones. */
|
||||||
|
yardId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Occupancy heatmap: one tile per zone, coloured by how full it is. Occupancy is
|
||||||
|
* container-count based (unit-consistent); weight is shown as context only.
|
||||||
|
*/
|
||||||
|
export function ZoneOccupancyHeatmap({ yardId }: ZoneOccupancyHeatmapProps) {
|
||||||
|
const { data: zones = [], isLoading } = useZoneOccupancy(yardId);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Group justify="center" py="lg">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zones.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||||
|
No active zones to show occupancy for.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group gap="xs">
|
||||||
|
<LayoutGrid size={16} />
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
Zone occupancy
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
({zones.length} zone{zones.length !== 1 ? 's' : ''})
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, xs: 2, sm: 3, lg: 4 }} spacing="sm">
|
||||||
|
{zones.map((z) => {
|
||||||
|
const t = tone(z.occupancyPct);
|
||||||
|
const pct = z.occupancyPct ?? 0;
|
||||||
|
return (
|
||||||
|
<Card key={z.id} withBorder radius="md" padding="sm">
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||||
|
<Text fw={600} size="sm" truncate title={z.name}>
|
||||||
|
{z.name}
|
||||||
|
</Text>
|
||||||
|
<Badge color={t.color} variant="light" size="sm">
|
||||||
|
{z.occupancyPct == null ? '—' : `${Math.round(pct)}%`}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Progress value={Math.min(pct, 100)} color={t.color} size="lg" radius="sm" />
|
||||||
|
<Group justify="space-between" gap="xs">
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{capacityLabel(z)}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c={t.color === 'gray' ? 'dimmed' : t.color}>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,3 +29,4 @@ export { VisualEmptyState } from './VisualEmptyState';
|
|||||||
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
export { WarehouseDashboardCharts } from './WarehouseDashboardCharts';
|
||||||
export { InspectionReportModal } from './InspectionReportModal';
|
export { InspectionReportModal } from './InspectionReportModal';
|
||||||
export { FeePreviewModal } from './FeePreviewModal';
|
export { FeePreviewModal } from './FeePreviewModal';
|
||||||
|
export { ZoneOccupancyHeatmap } from './ZoneOccupancyHeatmap';
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export const QUERY_KEYS = {
|
|||||||
locomotives: (routeId?: string) =>
|
locomotives: (routeId?: string) =>
|
||||||
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
["train-scheduling", "locomotives", routeId ?? "all"] as const,
|
||||||
stations: () => ["train-scheduling", "stations"] as const,
|
stations: () => ["train-scheduling", "stations"] as const,
|
||||||
schedules: () => ["train-scheduling", "schedules"] as const,
|
schedules: (filters?: unknown) =>
|
||||||
|
["train-scheduling", "schedules", filters ?? {}] as const,
|
||||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||||
batchBoard: (filters?: unknown) =>
|
batchBoard: (filters?: unknown) =>
|
||||||
|
|||||||
@@ -486,6 +486,10 @@ export const URL_CONSTANTS = {
|
|||||||
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
MOVE: (id: string) => `/warehouse-inventory/${id}/move`,
|
||||||
RESERVE: "/warehouse-inventory/reserve",
|
RESERVE: "/warehouse-inventory/reserve",
|
||||||
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
|
||||||
|
ZONE_OCCUPANCY: (yardId?: string) =>
|
||||||
|
yardId
|
||||||
|
? `/warehouse-inventory/zone-occupancy?yardId=${yardId}`
|
||||||
|
: "/warehouse-inventory/zone-occupancy",
|
||||||
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
AUTO_UNLOAD_ARRIVED: "/warehouse-inventory/auto-unload-arrived",
|
||||||
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
AUTO_LOAD_READY: "/warehouse-inventory/auto-load-ready",
|
||||||
UNLOAD_BOOKING: (bookingId: string) =>
|
UNLOAD_BOOKING: (bookingId: string) =>
|
||||||
@@ -617,6 +621,7 @@ export const URL_CONSTANTS = {
|
|||||||
BASE: "/last-mile",
|
BASE: "/last-mile",
|
||||||
BY_ID: (id: string) => `/last-mile/${id}`,
|
BY_ID: (id: string) => `/last-mile/${id}`,
|
||||||
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
ACCEPT: (reference: string) => `/last-mile/accept/${reference}`,
|
||||||
|
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
|
||||||
},
|
},
|
||||||
|
|
||||||
DRIVERS: {
|
DRIVERS: {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
BOOKING_WINDOW_WS_EVENTS,
|
BOOKING_WINDOW_WS_EVENTS,
|
||||||
BOOKING_WINDOW_WS_NAMESPACE,
|
BOOKING_WINDOW_WS_NAMESPACE,
|
||||||
|
type BatchBoardChangedEvent,
|
||||||
type BookingWindowPhaseEvent,
|
type BookingWindowPhaseEvent,
|
||||||
} from "@edr/types";
|
} from "@edr/types";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -82,9 +83,19 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
|||||||
|
|
||||||
// Deliberate console breadcrumbs: "live updates not arriving" is only
|
// Deliberate console breadcrumbs: "live updates not arriving" is only
|
||||||
// diagnosable from the browser when connect/reject outcomes are visible.
|
// diagnosable from the browser when connect/reject outcomes are visible.
|
||||||
socket.on("connect", () =>
|
let hadConnected = false;
|
||||||
console.debug("[booking-windows] socket connected", socket.id),
|
socket.on("connect", () => {
|
||||||
);
|
console.debug("[booking-windows] socket connected", socket.id);
|
||||||
|
// A RE-connect means pushes may have been missed while offline — refetch
|
||||||
|
// every board view (list + any open detail share the "batch-board" key
|
||||||
|
// prefix) once so the gap self-heals immediately.
|
||||||
|
if (hadConnected) {
|
||||||
|
void qc.invalidateQueries({
|
||||||
|
queryKey: ["train-scheduling", "batch-board"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
hadConnected = true;
|
||||||
|
});
|
||||||
socket.on("connect_error", (err) =>
|
socket.on("connect_error", (err) =>
|
||||||
console.warn("[booking-windows] socket connect failed:", err.message),
|
console.warn("[booking-windows] socket connect failed:", err.message),
|
||||||
);
|
);
|
||||||
@@ -144,6 +155,19 @@ export function useBookingWindowSocket(enabled: boolean = true) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Board-data pushes (payment settled, allocation, fill, expiry, …): refetch
|
||||||
|
// the schedule's detail immediately, refresh the list debounced. The event
|
||||||
|
// carries no data — the board views are rich, differently-shaped queries.
|
||||||
|
socket.on(
|
||||||
|
BOOKING_WINDOW_WS_EVENTS.BATCH_CHANGED,
|
||||||
|
(event: BatchBoardChangedEvent) => {
|
||||||
|
void qc.invalidateQueries({
|
||||||
|
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
|
||||||
|
});
|
||||||
|
scheduleRefetch(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (refetchTimer) clearTimeout(refetchTimer);
|
if (refetchTimer) clearTimeout(refetchTimer);
|
||||||
socket.off();
|
socket.off();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user