mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
:Merge branch 'dev' of github.com:Tria-plc/edr-platform into uienhancement
Please enter a commit message to explain why this merge is necessary, :qa!
This commit is contained in:
@@ -43,3 +43,4 @@ EXPOSE 3001
|
||||
# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT.
|
||||
EXPOSE 5023
|
||||
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",
|
||||
"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: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: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",
|
||||
@@ -56,7 +57,7 @@
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^11.1.27",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.9.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.12.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* 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 & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
}): Promise<{
|
||||
@@ -640,6 +641,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
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') {
|
||||
qb.orderBy('booking.isGovernment', 'DESC')
|
||||
.addOrderBy('booking.priorityScore', 'DESC')
|
||||
|
||||
@@ -31,6 +31,7 @@ import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { VehiclesService } from '../vehicles/vehicles.service';
|
||||
@@ -1211,6 +1212,13 @@ export class BookingsService {
|
||||
destinationYardId: filter.destinationYardId,
|
||||
isGovernment: filter.isGovernment,
|
||||
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,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1262,6 +1270,7 @@ export class BookingsService {
|
||||
// Global Logistics only clears customs bookings; non-customs clearance is
|
||||
// reviewed by Marketing from the booking detail, not this queue.
|
||||
customsClearingEnabled: true,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -1286,6 +1295,7 @@ export class BookingsService {
|
||||
// Company-wide: payables span all of the customer's services.
|
||||
companyId: company.id,
|
||||
companyProfileId: filter.companyProfileId,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
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
|
||||
// can show the Arrival stage: the booking status stays IN_TRANSIT from
|
||||
// dispatch until delivery, so arrival is only knowable from the schedule.
|
||||
|
||||
@@ -106,6 +106,14 @@ export class FilterBookingDto {
|
||||
@IsIn(['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 })
|
||||
@IsOptional()
|
||||
@IsIn([...TRADE_DIRECTIONS])
|
||||
@@ -125,6 +133,16 @@ export class FilterBookingDto {
|
||||
@IsOptional()
|
||||
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 })
|
||||
@IsOptional()
|
||||
@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 })
|
||||
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. */
|
||||
@Column({ name: 'created_by_role', type: 'varchar', length: 20, default: 'CUSTOMER', nullable: true })
|
||||
createdByRole?: string | null;
|
||||
|
||||
@@ -199,6 +199,8 @@ export class BookingRequestService {
|
||||
reviewedByStaffId: staffId ?? null,
|
||||
reviewedAt: new Date(),
|
||||
} as never);
|
||||
const contract = await this.contractsService.findById(request.contractId);
|
||||
this.notifier.shipmentRequestRejected(contract, request.reference, note);
|
||||
return (await this.repo.findById(requestId)) ?? request;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // invoiceService
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return { service, contractsRepository };
|
||||
|
||||
@@ -59,6 +59,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
invoiceService as never,
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -24,9 +24,11 @@ import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
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 { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
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 { 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. */
|
||||
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 {
|
||||
booking: Booking;
|
||||
warnings: string[];
|
||||
@@ -74,6 +81,8 @@ export class ContractBookingService {
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingTransitionService))
|
||||
private readonly bookingTransitionService: BookingTransitionService,
|
||||
) {}
|
||||
@@ -90,10 +99,22 @@ export class ContractBookingService {
|
||||
// A contract whose quantity cap was fully booked is completed — no further
|
||||
// bookings, even while contract validity and a booking window are still
|
||||
// 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') {
|
||||
const capacity = await this.computeCapacity(contract);
|
||||
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
|
||||
let hasRoom: boolean;
|
||||
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) {
|
||||
throw new BadRequestException(
|
||||
'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
|
||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||
// 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') {
|
||||
const active = await this.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
'This one-time contract already has an active booking.',
|
||||
);
|
||||
if (await this.hasSplitBooking(contractId)) {
|
||||
await this.assertExactRemainder(contract, dto);
|
||||
} else {
|
||||
const active = await this.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
throw new BadRequestException(
|
||||
'This one-time contract already has an active booking.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// GENERAL: draw down against the cargo quantity cap until it is full.
|
||||
@@ -181,6 +211,10 @@ export class ContractBookingService {
|
||||
scheduledDate: dto.scheduledDate ?? 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
|
||||
@@ -573,6 +607,31 @@ export class ContractBookingService {
|
||||
Number(booking.cargoTotalWeightVgm) > 0;
|
||||
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
|
||||
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
|
||||
// the shipment day.
|
||||
@@ -869,6 +928,196 @@ export class ContractBookingService {
|
||||
.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) ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* every booking created under a GENERAL contract (including a split remainder
|
||||
* being rebooked): when no capped scope line has capacity left, the contract
|
||||
* moves to CONTRACT_CLOSED even though its validity window is still open —
|
||||
* blocking further bookings and shipment requests, including inside an open
|
||||
* booking window. Never throws: a status hiccup must not undo the booking
|
||||
* that was just created.
|
||||
* every booking created under a GENERAL contract, and under a ONE_TIME
|
||||
* contract in split-remainder mode (a split remainder being rebooked): when
|
||||
* no capped scope line has capacity left, the contract moves to
|
||||
* CONTRACT_CLOSED even though its validity window is still open — blocking
|
||||
* further bookings and shipment requests, including inside an open booking
|
||||
* window. Never throws: a status hiccup must not undo the booking that was
|
||||
* just created.
|
||||
*/
|
||||
private async maybeCompleteContract(contract: Contract): Promise<void> {
|
||||
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;
|
||||
const capacity = await this.computeCapacity(contract);
|
||||
if (capacity.length === 0) return; // uncapped — completes only by expiry
|
||||
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
|
||||
// 3 decimals); container caps are integers and unaffected.
|
||||
const exhausted = capacity.every(
|
||||
(c) => c.remaining != null && c.remaining <= 0.001,
|
||||
);
|
||||
if (!exhausted) return;
|
||||
|
||||
// ONE_TIME contracts are governed by the single-active-booking slot, so
|
||||
// they normally complete by expiry — EXCEPT once a booking was split: the
|
||||
// remainder chain draws down the split booking's pre-split snapshot, and
|
||||
// the contract completes when the outstanding remainder hits zero.
|
||||
// (An unsplit ONE_TIME never completes here, so re-booking after an
|
||||
// expired unpaid booking keeps working.)
|
||||
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, {
|
||||
status: 'CONTRACT_CLOSED',
|
||||
} as never);
|
||||
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) {
|
||||
this.logger.error(
|
||||
@@ -1025,7 +1336,7 @@ export class ContractBookingService {
|
||||
private async bookedQuantities(
|
||||
contract: Contract,
|
||||
): Promise<{ bySize: Map<string, number>; bulk: number }> {
|
||||
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
const releasing = RELEASING_BOOKING_STATUSES;
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingContainer)
|
||||
@@ -1213,6 +1524,8 @@ export class ContractBookingService {
|
||||
currency: string | null;
|
||||
pairingErrors: string[];
|
||||
capacityErrors: string[];
|
||||
containerClashErrors: string[];
|
||||
spaceErrors: string[];
|
||||
lineItems: PriceLineItemDto[];
|
||||
totalAmount: number;
|
||||
}> {
|
||||
@@ -1227,6 +1540,8 @@ export class ContractBookingService {
|
||||
currency: null,
|
||||
pairingErrors: [],
|
||||
capacityErrors: [],
|
||||
containerClashErrors: [],
|
||||
spaceErrors: [],
|
||||
lineItems: [],
|
||||
totalAmount: 0,
|
||||
};
|
||||
@@ -1312,12 +1627,50 @@ export class ContractBookingService {
|
||||
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 {
|
||||
overweightLines: computed.overweightLines,
|
||||
overweightSurchargeAmount,
|
||||
currency: computed.currency,
|
||||
pairingErrors,
|
||||
capacityErrors,
|
||||
containerClashErrors,
|
||||
spaceErrors,
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
};
|
||||
@@ -1445,6 +1798,37 @@ export class ContractBookingService {
|
||||
route: { originYardId?: string | null; destinationYardId?: string | null },
|
||||
excludeBookingId?: string,
|
||||
): 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
|
||||
.getRepository(BookingContainerUnit)
|
||||
.createQueryBuilder('unit')
|
||||
@@ -1474,18 +1858,7 @@ export class ContractBookingService {
|
||||
}
|
||||
const clashes: Array<{ containerNumber: string; reference: string }> =
|
||||
await qb.getRawMany();
|
||||
|
||||
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.',
|
||||
);
|
||||
}
|
||||
return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()];
|
||||
}
|
||||
|
||||
private async assert20ftPairableAtCreate(
|
||||
@@ -1528,8 +1901,8 @@ export class ContractBookingService {
|
||||
preferReefer: boolean,
|
||||
): Promise<ContainerType> {
|
||||
const sizeFt = parseInt(size, 10);
|
||||
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
|
||||
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
||||
const { items } = await this.containerTypesService.findAll({ pageSize: 100 });
|
||||
const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
|
||||
if (!types.length) {
|
||||
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
|
||||
* 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> {
|
||||
// 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({
|
||||
page: filter.page ?? 1,
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: ['CLEARANCE_UNDER_REVIEW'],
|
||||
statuses: requested.length ? requested : ['CLEARANCE_UNDER_REVIEW'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
@@ -896,6 +922,7 @@ export class ContractClearanceService {
|
||||
pageSize: filter.pageSize ?? 50,
|
||||
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
|
||||
customsClearingEnabled: false,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy ?? 'createdAt',
|
||||
sortOrder: filter.sortOrder ?? 'DESC',
|
||||
});
|
||||
|
||||
@@ -145,6 +145,19 @@ export class ContractNotifierService {
|
||||
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 ──────────────────────────
|
||||
|
||||
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */
|
||||
|
||||
@@ -80,9 +80,9 @@ export class ContractPricingService {
|
||||
const sizes = (contract.cargoScope ?? [])
|
||||
.map((c) => c.containerSize)
|
||||
.filter((s): s is string => !!s);
|
||||
const { data: containerTypes } = await this.containerTypesService.findAll({
|
||||
const { items: containerTypes } = await this.containerTypesService.findAll({
|
||||
isActive: true,
|
||||
pageSize: 500,
|
||||
pageSize: 100,
|
||||
});
|
||||
for (const size of sizes) {
|
||||
const sizeFt = size === '40ft' ? 40 : 20;
|
||||
|
||||
@@ -852,11 +852,12 @@ export class ContractsController {
|
||||
|
||||
@Get(':id/capacity')
|
||||
@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) {
|
||||
const contract = await this.contractsService.findById(id);
|
||||
return this.contractBookingService.computeCapacity(contract);
|
||||
return this.contractBookingService.capacityView(contract);
|
||||
}
|
||||
|
||||
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
|
||||
|
||||
@@ -98,6 +98,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
options: ContractListFilterOptions & {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'ASC' | 'DESC';
|
||||
},
|
||||
@@ -128,6 +129,16 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
|
||||
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 =
|
||||
options.sortBy === 'contractValidUntil'
|
||||
? 'contract.contractValidUntil'
|
||||
|
||||
@@ -579,6 +579,7 @@ export class ContractsService {
|
||||
paymentCurrency: filter.paymentCurrency,
|
||||
createdFrom: filter.createdFrom,
|
||||
createdTo: filter.createdTo,
|
||||
search: filter.search,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
@@ -71,6 +71,15 @@ export class FilterContractDto {
|
||||
@IsDateString()
|
||||
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 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { FreightAdmin } from "../../common/booking-guards";
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.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 { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownSettingsService } from "./dropdown-settings.service";
|
||||
@@ -34,6 +36,15 @@ export class DropdownSettingsController {
|
||||
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")
|
||||
@ApiOperation({ summary: "Get a dropdown setting by ID" })
|
||||
getById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/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 { DropdownSetting } from "./entities/dropdown-setting.entity";
|
||||
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(
|
||||
settingId: string,
|
||||
options: Array<Partial<DropdownOption>>,
|
||||
|
||||
@@ -5,8 +5,11 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { PaginatedResponse } from "@edr/types";
|
||||
|
||||
import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.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 { UpdateDropdownSettingDto } from "./dto/update-dropdown-setting.dto";
|
||||
import { DropdownOption } from "./entities/dropdown-option.entity";
|
||||
@@ -16,71 +19,6 @@ import {
|
||||
IDropdownSettingsRepository,
|
||||
} 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()
|
||||
export class DropdownSettingsService {
|
||||
constructor(
|
||||
@@ -92,6 +30,12 @@ export class DropdownSettingsService {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
listPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<DropdownSetting> {
|
||||
const setting = await this.repository.findById(id);
|
||||
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||
@@ -127,34 +71,6 @@ export class DropdownSettingsService {
|
||||
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(
|
||||
id: string,
|
||||
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 { DropdownSetting } from "../entities/dropdown-setting.entity";
|
||||
|
||||
@@ -11,6 +14,9 @@ export const DROPDOWN_SETTINGS_REPOSITORY = Symbol(
|
||||
|
||||
export interface IDropdownSettingsRepository {
|
||||
findAll(): Promise<DropdownSetting[]>;
|
||||
findPaged(
|
||||
query: ListDropdownSettingsQueryDto,
|
||||
): Promise<PaginatedResponse<DropdownSetting>>;
|
||||
findById(id: string): Promise<DropdownSetting | null>;
|
||||
findByCode(code: string): Promise<DropdownSetting | null>;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||
@@ -19,15 +20,8 @@ export class ApprovalRulesController {
|
||||
@Get()
|
||||
@RuleEngineView('approval-rules')
|
||||
@ApiOperation({ summary: 'List approval rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
requiresDirectorApproval:
|
||||
query['requiresDirectorApproval'] !== undefined
|
||||
? query['requiresDirectorApproval'] === 'true'
|
||||
: undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListApprovalRulesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get('chain')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
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 { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||
@@ -19,19 +20,8 @@ export class CargoTypesController {
|
||||
@Get()
|
||||
@RuleEngineView('cargo-types')
|
||||
@ApiOperation({ summary: 'List cargo types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
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',
|
||||
});
|
||||
findAll(@Query() query: ListCargoTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
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 { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||
@@ -19,12 +20,8 @@ export class ContainerTypesController {
|
||||
@Get()
|
||||
@RuleEngineView('container-types')
|
||||
@ApiOperation({ summary: 'List container types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListContainerTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
@@ -19,13 +20,8 @@ export class PriorityConfigsController {
|
||||
@Get()
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({ summary: 'List priority configs' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
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,
|
||||
});
|
||||
findAll(@Query() query: ListPriorityConfigsQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -22,13 +23,8 @@ export class RatesController {
|
||||
@Get()
|
||||
@RuleEngineView('rates')
|
||||
@ApiOperation({ summary: 'List rates' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
status: query['status'],
|
||||
rateType: query['rateType'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListRatesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||
@@ -19,16 +20,8 @@ export class ServiceTypesController {
|
||||
@Get()
|
||||
@RuleEngineView('service-types')
|
||||
@ApiOperation({ summary: 'List service types' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
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',
|
||||
});
|
||||
findAll(@Query() query: ListServiceTypesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Post('reorder')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { ShippingLinesService } from '../services/shipping-lines.service';
|
||||
|
||||
@@ -17,12 +18,8 @@ export class ShippingLinesController {
|
||||
@Get()
|
||||
@RuleEngineView('shipping-lines')
|
||||
@ApiOperation({ summary: 'List shipping lines' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
isActive: query['isActive'] !== undefined ? query['isActive'] === 'true' : undefined,
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListRuleEngineQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
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 { WeightLimitRulesService } from '../services/weight-limit-rules.service';
|
||||
|
||||
@@ -17,13 +18,8 @@ export class WeightLimitRulesController {
|
||||
@Get()
|
||||
@RuleEngineView('weight-limit-rules')
|
||||
@ApiOperation({ summary: 'List weight limit rules' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
tradeDirection: query['tradeDirection'],
|
||||
containerTypeId: query['containerTypeId'],
|
||||
page: query['page'] ? parseInt(query['page'], 10) : undefined,
|
||||
pageSize: query['pageSize'] ? parseInt(query['pageSize'], 10) : undefined,
|
||||
});
|
||||
findAll(@Query() query: ListWeightLimitRulesQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { MoveOrderDto } from '../dto/move-order.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||
@@ -19,13 +20,8 @@ export class YardsController {
|
||||
@Get()
|
||||
@RuleEngineView('yards')
|
||||
@ApiOperation({ summary: 'List yards' })
|
||||
findAll(@Query() query: Record<string, string>) {
|
||||
return this.service.findAll({
|
||||
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,
|
||||
});
|
||||
findAll(@Query() query: ListYardsQueryDto) {
|
||||
return this.service.findAll(query);
|
||||
}
|
||||
|
||||
@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 { ListApprovalRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
|
||||
export interface IApprovalRulesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IApprovalRulesRepository {
|
||||
findChainForCargo(requiresDirectorApproval: boolean): Promise<ApprovalRule[]>;
|
||||
findAll(options?: FindManyOptions<ApprovalRule>): Promise<ApprovalRule[]>;
|
||||
findAndCount(options?: FindManyOptions<ApprovalRule>): Promise<[ApprovalRule[], number]>;
|
||||
findPaged(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>>;
|
||||
create(data: Partial<ApprovalRule>): Promise<ApprovalRule>;
|
||||
update(id: string, data: Partial<ApprovalRule>): Promise<ApprovalRule | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListCargoTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { CargoType } from '../entities/cargo-type.entity';
|
||||
|
||||
export interface ICargoTypesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface ICargoTypesRepository {
|
||||
findByCode(code: string): Promise<CargoType | null>;
|
||||
findAll(options?: FindManyOptions<CargoType>): Promise<CargoType[]>;
|
||||
findAndCount(options?: FindManyOptions<CargoType>): Promise<[CargoType[], number]>;
|
||||
findPaged(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>>;
|
||||
create(data: Partial<CargoType>): Promise<CargoType>;
|
||||
update(id: string, data: Partial<CargoType>): Promise<CargoType | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListContainerTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ContainerType } from '../entities/container-type.entity';
|
||||
|
||||
export interface IContainerTypesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IContainerTypesRepository {
|
||||
findByCode(code: string): Promise<ContainerType | null>;
|
||||
findAll(options?: FindManyOptions<ContainerType>): Promise<ContainerType[]>;
|
||||
findAndCount(options?: FindManyOptions<ContainerType>): Promise<[ContainerType[], number]>;
|
||||
findPaged(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>>;
|
||||
create(data: Partial<ContainerType>): Promise<ContainerType>;
|
||||
update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListPriorityConfigsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { PriorityConfig } from '../entities/priority-config.entity';
|
||||
|
||||
export const PRIORITY_CONFIGS_REPOSITORY = Symbol('PRIORITY_CONFIGS_REPOSITORY');
|
||||
@@ -7,6 +9,7 @@ export interface IPriorityConfigsRepository {
|
||||
findById(id: string): Promise<PriorityConfig | null>;
|
||||
findAll(options?: FindManyOptions<PriorityConfig>): Promise<PriorityConfig[]>;
|
||||
findAndCount(options?: FindManyOptions<PriorityConfig>): Promise<[PriorityConfig[], number]>;
|
||||
findPaged(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>>;
|
||||
findAllActive(): Promise<PriorityConfig[]>;
|
||||
create(data: Partial<PriorityConfig>): Promise<PriorityConfig>;
|
||||
update(id: string, data: Partial<PriorityConfig>): Promise<PriorityConfig | null>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
|
||||
export interface IRatesRepository {
|
||||
@@ -13,6 +15,7 @@ export interface IRatesRepository {
|
||||
}): Promise<Rate | null>;
|
||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||
findPaged(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>>;
|
||||
create(data: Partial<Rate>): Promise<Rate>;
|
||||
update(id: string, data: Partial<Rate>): Promise<Rate | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListServiceTypesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ServiceType } from '../entities/service-type.entity';
|
||||
|
||||
export interface IServiceTypesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IServiceTypesRepository {
|
||||
findByCode(code: string): Promise<ServiceType | null>;
|
||||
findAll(options?: FindManyOptions<ServiceType>): Promise<ServiceType[]>;
|
||||
findAndCount(options?: FindManyOptions<ServiceType>): Promise<[ServiceType[], number]>;
|
||||
findPaged(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>>;
|
||||
create(data: Partial<ServiceType>): Promise<ServiceType>;
|
||||
update(id: string, data: Partial<ServiceType>): Promise<ServiceType | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListRuleEngineQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ShippingLine } from '../entities/shipping-line.entity';
|
||||
|
||||
export interface IShippingLinesRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IShippingLinesRepository {
|
||||
findByCode(code: string): Promise<ShippingLine | null>;
|
||||
findAll(options?: FindManyOptions<ShippingLine>): Promise<ShippingLine[]>;
|
||||
findAndCount(options?: FindManyOptions<ShippingLine>): Promise<[ShippingLine[], number]>;
|
||||
findPaged(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>>;
|
||||
create(data: Partial<ShippingLine>): Promise<ShippingLine>;
|
||||
update(id: string, data: Partial<ShippingLine>): Promise<ShippingLine | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListWeightLimitRulesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||
|
||||
export interface IWeightLimitRulesRepository {
|
||||
@@ -14,6 +16,7 @@ export interface IWeightLimitRulesRepository {
|
||||
): Promise<WeightLimitRule | null>;
|
||||
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
|
||||
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
|
||||
findPaged(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>>;
|
||||
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;
|
||||
update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { FindManyOptions } from 'typeorm';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
|
||||
export interface IYardsRepository {
|
||||
@@ -6,6 +8,7 @@ export interface IYardsRepository {
|
||||
findByCode(code: string): Promise<Yard | null>;
|
||||
findAll(options?: FindManyOptions<Yard>): Promise<Yard[]>;
|
||||
findAndCount(options?: FindManyOptions<Yard>): Promise<[Yard[], number]>;
|
||||
findPaged(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>>;
|
||||
create(data: Partial<Yard>): Promise<Yard>;
|
||||
update(id: string, data: Partial<Yard>): Promise<Yard | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IApprovalRulesRepository } from '../interfaces/approval-rules.repository.interface';
|
||||
|
||||
@@ -30,6 +33,31 @@ export class ApprovalRulesRepository implements IApprovalRulesRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { ICargoTypesRepository } from '../interfaces/cargo-types.repository.interface';
|
||||
|
||||
@@ -27,6 +30,33 @@ export class CargoTypesRepository implements ICargoTypesRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IContainerTypesRepository } from '../interfaces/container-types.repository.interface';
|
||||
|
||||
@@ -27,6 +30,24 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IPriorityConfigsRepository } from '../interfaces/priority-configs.repository.interface';
|
||||
|
||||
@@ -23,6 +26,28 @@ export class PriorityConfigsRepository implements IPriorityConfigsRepository {
|
||||
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[]> {
|
||||
return this.repo.find({
|
||||
where: { isActive: true },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IRatesRepository } from '../interfaces/rates.repository.interface';
|
||||
|
||||
@@ -68,6 +71,28 @@ export class RatesRepository implements IRatesRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IServiceTypesRepository } from '../interfaces/service-types.repository.interface';
|
||||
|
||||
@@ -27,6 +30,30 @@ export class ServiceTypesRepository implements IServiceTypesRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IShippingLinesRepository } from '../interfaces/shipping-lines.repository.interface';
|
||||
|
||||
@@ -27,6 +30,25 @@ export class ShippingLinesRepository implements IShippingLinesRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IWeightLimitRulesRepository } from '../interfaces/weight-limit-rules.repository.interface';
|
||||
|
||||
@@ -59,6 +62,34 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
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 { IYardsRepository } from '../interfaces/yards.repository.interface';
|
||||
|
||||
@@ -27,6 +30,29 @@ export class YardsRepository implements IYardsRepository {
|
||||
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> {
|
||||
const entity = this.repo.create(data);
|
||||
return this.repo.save(entity);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
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 { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
|
||||
import { ApprovalRule } from '../entities/approval-rule.entity';
|
||||
@@ -17,26 +19,9 @@ export class ApprovalRulesService {
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
/** List approval rules. */
|
||||
async findAll(filter: {
|
||||
requiresDirectorApproval?: boolean;
|
||||
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) } };
|
||||
/** List approval rules — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListApprovalRulesQueryDto): Promise<PaginatedResponse<ApprovalRule>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** 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 { ILike } from 'typeorm';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
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 { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
|
||||
import { CargoType } from '../entities/cargo-type.entity';
|
||||
@@ -19,33 +20,9 @@ export class CargoTypesService {
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
/** List cargo types with pagination and optional filtering. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
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) } };
|
||||
/** List cargo types — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListCargoTypesQueryDto): Promise<PaginatedResponse<CargoType>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** 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 { generateCode } from '../../../common/utils/generate-code.util';
|
||||
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 { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
|
||||
import { ContainerType } from '../entities/container-type.entity';
|
||||
@@ -18,24 +20,9 @@ export class ContainerTypesService {
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
/** List container types with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
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) } };
|
||||
/** List container types — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListContainerTypesQueryDto): Promise<PaginatedResponse<ContainerType>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** 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 { 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 { PriorityConfig } from '../entities/priority-config.entity';
|
||||
import {
|
||||
@@ -16,25 +18,9 @@ export class PriorityConfigsService {
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
async findAll(filter: {
|
||||
type?: 'WAGON' | 'CURRENCY' | 'CUSTOMS';
|
||||
isActive?: boolean;
|
||||
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) } };
|
||||
/** List priority configs — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListPriorityConfigsQueryDto): Promise<PaginatedResponse<PriorityConfig>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PriorityConfig> {
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
import { Rate } from '../entities/rate.entity';
|
||||
import { deriveRateType } from '../entities/rate-type.util';
|
||||
@@ -19,26 +21,9 @@ export class RatesService {
|
||||
private readonly repository: IRatesRepository,
|
||||
) {}
|
||||
|
||||
/** List rates with pagination. */
|
||||
async findAll(filter: {
|
||||
status?: string;
|
||||
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)) } };
|
||||
/** List rates — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** Return all currently LIVE rates. */
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ILike } from 'typeorm';
|
||||
import { generateCode } from '../../../common/utils/generate-code.util';
|
||||
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 { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
|
||||
import { ServiceType } from '../entities/service-type.entity';
|
||||
@@ -19,30 +20,9 @@ export class ServiceTypesService {
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
/** List service types with pagination and optional filtering. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
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) } };
|
||||
/** List service types — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListServiceTypesQueryDto): Promise<PaginatedResponse<ServiceType>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** 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 { 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 { ShippingLine } from '../entities/shipping-line.entity';
|
||||
import {
|
||||
@@ -14,24 +16,9 @@ export class ShippingLinesService {
|
||||
private readonly repository: IShippingLinesRepository,
|
||||
) {}
|
||||
|
||||
/** List shipping lines with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
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) } };
|
||||
/** List shipping lines — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListRuleEngineQueryDto): Promise<PaginatedResponse<ShippingLine>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** Get a shipping line by ID. */
|
||||
|
||||
@@ -5,7 +5,9 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PaginatedResponse } from '@edr/types';
|
||||
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 { WeightLimitRule } from '../entities/weight-limit-rule.entity';
|
||||
import {
|
||||
@@ -20,27 +22,9 @@ export class WeightLimitRulesService {
|
||||
private readonly repository: IWeightLimitRulesRepository,
|
||||
) {}
|
||||
|
||||
/** List weight limit rules with pagination. */
|
||||
async findAll(filter: {
|
||||
containerTypeId?: string;
|
||||
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) } };
|
||||
/** List weight limit rules — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListWeightLimitRulesQueryDto): Promise<PaginatedResponse<WeightLimitRule>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** 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 { generateCode } from '../../../common/utils/generate-code.util';
|
||||
import { CreateYardDto } from '../dto/create-yard.dto';
|
||||
import { ListYardsQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { ReorderItemsDto } from '../dto/reorder-items.dto';
|
||||
import { UpdateYardDto } from '../dto/update-yard.dto';
|
||||
import { Yard } from '../entities/yard.entity';
|
||||
@@ -15,26 +17,9 @@ export class YardsService {
|
||||
private readonly displayOrder: DisplayOrderService,
|
||||
) {}
|
||||
|
||||
/** List yards with pagination. */
|
||||
async findAll(filter: {
|
||||
isActive?: boolean;
|
||||
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) } };
|
||||
/** List yards — standard paginated envelope with server-side search. */
|
||||
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
||||
return this.repository.findPaged(query);
|
||||
}
|
||||
|
||||
/** Get a yard by ID. */
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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 { 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 })
|
||||
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)
|
||||
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(
|
||||
id: string,
|
||||
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,
|
||||
BatchBoardQueryDto,
|
||||
} 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 {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
|
||||
|
||||
import {
|
||||
@@ -83,6 +91,21 @@ export type { Capacity } from './corridor-capacity.util';
|
||||
*/
|
||||
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. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
@@ -241,15 +264,10 @@ export interface BatchBoardSchedule {
|
||||
bookings: BatchBoardBooking[];
|
||||
}
|
||||
|
||||
/** Paginated batch-board list. `items` (not `data`) — the API response wrapper
|
||||
* already uses `data`, and the frontend's unwrap() strips one `data` level. */
|
||||
export interface BatchBoardListResponse {
|
||||
items: BatchBoardSchedule[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
/** Paginated batch-board list in the shared `{items, meta}` envelope — the API
|
||||
* response wrapper already uses `data`, and the frontend's unwrap() strips one
|
||||
* `data` level. */
|
||||
export type BatchBoardListResponse = PaginatedResponse<BatchBoardSchedule>;
|
||||
|
||||
/**
|
||||
* 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}`,
|
||||
);
|
||||
}
|
||||
this.notifyBoardChanged(booking.trainScheduleId, "booking_paid_allocated");
|
||||
}
|
||||
|
||||
/** Customer paid — delegate to ensurePaidBookingAllocated. */
|
||||
@@ -542,12 +561,18 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// ---- export FCFS -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Whole-booking single-train space report for an EXPORT booking. Export
|
||||
* bookings never split — the entire booking must ride ONE train, so the
|
||||
* report scans every fillable export train on the booking's corridor/day
|
||||
* (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) {
|
||||
throw new BadRequestException('Booking has no scheduled date');
|
||||
}
|
||||
@@ -573,15 +598,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
(a, b) =>
|
||||
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 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) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
@@ -592,15 +621,101 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
corridorMatched = true;
|
||||
if (budget.fits(required, leg)) return schedule.id;
|
||||
report.corridorMatched = true;
|
||||
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(
|
||||
'No export train is accepting bookings for this day',
|
||||
|
||||
report.fullMessage = this.exportFullMessage(booking, report);
|
||||
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))) {
|
||||
await this.setWindow(scheduleId, 'FULL');
|
||||
}
|
||||
this.notifyBoardChanged(scheduleId, 'export_booking_accepted');
|
||||
}
|
||||
|
||||
/** 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}`,
|
||||
);
|
||||
}
|
||||
if (unlinked.length > 0) {
|
||||
this.notifyBoardChanged(scheduleId, "paid_reconciled");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- legacy fill entry point ----------------------------------------------
|
||||
@@ -700,8 +819,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async getBatchBoard(
|
||||
query: BatchBoardQueryDto = {},
|
||||
): Promise<BatchBoardListResponse> {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 12;
|
||||
// Board cards are heavy (per-schedule booking summaries), so the default
|
||||
// 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
|
||||
// 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 } },
|
||||
},
|
||||
order: { [sortBy]: sortOrder } as never,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
@@ -800,13 +922,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
board.push(this.buildScheduleSummary(s, items));
|
||||
}
|
||||
|
||||
return {
|
||||
items: board,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
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,
|
||||
): BatchBoardSchedule["capacity"] {
|
||||
const allocated = items.filter((i) => i.state === "ALLOCATED");
|
||||
const committed = items.filter(
|
||||
(i) => i.state === "ALLOCATED" || i.state === "SELECTED_FOR_BATCH",
|
||||
);
|
||||
// Every booking still targeting this train holds gross weight — including
|
||||
// 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
|
||||
? trainHardCaps({
|
||||
maxPullWeightTons: Number(loco.maxPullWeightTons),
|
||||
@@ -1218,6 +1341,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.resortPoolByPriority(pool);
|
||||
const units = this.groupConsolidatedPool(pool);
|
||||
let armed = false;
|
||||
let preempted = false;
|
||||
let reservedThisPass = 0;
|
||||
let commercialReserved = 0;
|
||||
|
||||
@@ -1256,6 +1380,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
budget,
|
||||
wagonDims,
|
||||
);
|
||||
preempted = true;
|
||||
if (!freed) continue; // still doesn't fit even after preempt
|
||||
} else {
|
||||
// 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 (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);
|
||||
return commercialReserved;
|
||||
}
|
||||
@@ -1404,8 +1533,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
|
||||
// Live per-schedule corridor budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: CorridorBudget; armed: boolean }> = [];
|
||||
// Live per-schedule corridor budget + arm/changed flags, in departure order.
|
||||
const trains: Array<{
|
||||
id: string;
|
||||
budget: CorridorBudget;
|
||||
armed: boolean;
|
||||
changed: boolean;
|
||||
}> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
@@ -1419,7 +1553,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive);
|
||||
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 };
|
||||
|
||||
@@ -1492,6 +1626,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
t.budget,
|
||||
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) {
|
||||
target = t;
|
||||
break;
|
||||
@@ -1536,6 +1673,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
commercialReserved += 1;
|
||||
}
|
||||
target.budget.subtract(need, legOn(target)!);
|
||||
target.changed = true;
|
||||
reservedThisPass += 1;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -1553,6 +1691,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const t of trains) {
|
||||
if (t.budget.isExhausted(minPerWagon)) await this.setWindow(t.id, "FULL");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1799,6 +1941,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`— 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;
|
||||
}
|
||||
|
||||
@@ -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 "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");
|
||||
}
|
||||
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) => {
|
||||
if (booking.trainScheduleId) {
|
||||
await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking(
|
||||
@@ -1932,6 +2095,11 @@ export class BookingBatchService implements OnModuleInit {
|
||||
selectedForBatchAt: null,
|
||||
} 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). */
|
||||
@@ -1949,6 +2117,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (topUpReserved > 0) {
|
||||
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 });
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||
return;
|
||||
}
|
||||
await this.reserve(booking, scheduleId);
|
||||
this.armSettle(scheduleId);
|
||||
this.notifyBoardChanged(scheduleId, 'intercity_accepted');
|
||||
}
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
@@ -2088,7 +2260,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
this.logger.log(
|
||||
`[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.markWagonAllocatedMilestone(booking.id);
|
||||
// Customer tracking: freight payment settled (commercial pay-window path).
|
||||
@@ -2254,7 +2426,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
day,
|
||||
);
|
||||
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) {
|
||||
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||||
await this.expire(booking, "no-capacity");
|
||||
}
|
||||
if (leftovers.length) {
|
||||
@@ -2263,6 +2440,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`expired ${leftovers.length} waiting booking(s)`,
|
||||
);
|
||||
}
|
||||
for (const id of touchedScheduleIds) {
|
||||
this.notifyBoardChanged(id, "day_pool_expired");
|
||||
}
|
||||
return leftovers.length;
|
||||
}
|
||||
|
||||
@@ -2325,7 +2505,12 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`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) {
|
||||
if (booking.trainScheduleId) touchedScheduleIds.add(booking.trainScheduleId);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "EXPIRED",
|
||||
schedulingStatus: "ELIGIBLE",
|
||||
@@ -2342,6 +2527,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
`[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 { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
|
||||
@Injectable()
|
||||
@@ -18,8 +19,37 @@ export class BookingNotifierService {
|
||||
constructor(
|
||||
private readonly notifications: NotificationsService,
|
||||
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 {
|
||||
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
|
||||
}
|
||||
@@ -127,12 +157,15 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov'): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
|
||||
reason === 'gov' ? ' (government)' : ''
|
||||
}.`;
|
||||
void this.notifyContact(b, msg, 'ALLOCATED');
|
||||
this.inApp(b, 'Wagon allocated', msg);
|
||||
})();
|
||||
}
|
||||
|
||||
expired(b: Booking): void {
|
||||
|
||||
@@ -6,11 +6,12 @@ import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
|
||||
/**
|
||||
* applySplit promotion behaviour: a ONE_TIME contract must be flipped to GENERAL
|
||||
* (both the parent contract row and the booking's denormalized copy) so the split
|
||||
* remainder can be rebooked. A GENERAL booking is left untouched.
|
||||
* applySplit split-marking behaviour: the reduced booking is flagged is_split
|
||||
* and keeps a pre_split_quantities snapshot (the remainder ledger for ONE_TIME
|
||||
* 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 contractId = 'ct-1';
|
||||
const offerId = 'of-1';
|
||||
@@ -34,6 +35,7 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||
id: bookingId,
|
||||
contractId,
|
||||
contractKind: bookingContractKind,
|
||||
cargoTotalWeightVgm: 50,
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
softDelete: jest.fn().mockResolvedValue(undefined),
|
||||
@@ -76,22 +78,35 @@ describe('BookingSplitService — applySplit ONE_TIME promotion', () => {
|
||||
return { service, bookingRepo, contractRepo };
|
||||
};
|
||||
|
||||
it('promotes a ONE_TIME booking + parent contract to GENERAL', async () => {
|
||||
const { service, bookingRepo, contractRepo } = buildService('ONE_TIME');
|
||||
it('flags the reduced booking is_split with a pre-split bulk snapshot', async () => {
|
||||
const { service, bookingRepo } = buildService('ONE_TIME');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
expect(bookingRepo.update).toHaveBeenCalledWith(
|
||||
bookingId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
);
|
||||
expect(contractRepo.update).toHaveBeenCalledWith(
|
||||
contractId,
|
||||
expect.objectContaining({ contractKind: 'GENERAL' }),
|
||||
expect.objectContaining({
|
||||
isSplit: true,
|
||||
preSplitQuantities: { bulkTons: 50 },
|
||||
cargoTotalWeightVgm: 30,
|
||||
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');
|
||||
|
||||
await service.applySplit(bookingId);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { BillingService } from '../billing/billing.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import {
|
||||
BookingBatchOffer,
|
||||
OfferedLine,
|
||||
@@ -34,11 +33,14 @@ export interface SizedOffer {
|
||||
* GENERAL and ONE_TIME commercial bookings are offered partials: the remainder
|
||||
* 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
|
||||
* any later window within contract validity. A ONE_TIME contract is promoted to
|
||||
* GENERAL on split (see applySplit) so its remainder is actually rebookable.
|
||||
* 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.
|
||||
* any later window within contract validity. The reduced booking is flagged
|
||||
* is_split (see applySplit); the contract kind never changes. On a ONE_TIME
|
||||
* contract a split booking releases the single-active-booking slot, but the
|
||||
* next booking must take the WHOLE remainder — the split chain is the only way
|
||||
* 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()
|
||||
export class BookingSplitService {
|
||||
@@ -215,11 +217,26 @@ export class BookingSplitService {
|
||||
if (!offer) return;
|
||||
|
||||
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) {
|
||||
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
|
||||
const lines = await manager.getRepository(BookingContainer).find({
|
||||
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) {
|
||||
const kept = keptByLine.get(line.id);
|
||||
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, {
|
||||
wagonsRequired: offer.offeredWagons,
|
||||
cargoTotalWeightVgm: offer.offeredWeightTons,
|
||||
totalAmount: offer.offeredAmount,
|
||||
pricingBreakdown: offer.offeredPricingBreakdown,
|
||||
isSplit: true,
|
||||
preSplitQuantities,
|
||||
} 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
|
||||
.getRepository(BookingBatchOffer)
|
||||
.update(offer.id, { status: 'APPLIED' });
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BOOKING_WINDOW_WS_EVENTS,
|
||||
BOOKING_WINDOW_WS_NAMESPACE,
|
||||
type BatchBoardChangedEvent,
|
||||
type BookingWindowPhaseEvent,
|
||||
} from '@edr/types';
|
||||
import { Logger } from '@nestjs/common';
|
||||
@@ -65,6 +66,20 @@ export class BookingWindowGateway implements OnGatewayConnection {
|
||||
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 {
|
||||
const authToken = socket.handshake.auth?.token as string | undefined;
|
||||
if (authToken) return authToken;
|
||||
|
||||
@@ -153,6 +153,16 @@ export class BookingWindowService implements OnModuleInit {
|
||||
.update(s.id, { docReviewCompletedAt: now });
|
||||
s.docReviewCompletedAt = 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);
|
||||
return fresh ?? schedule;
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { IsIn, IsISO8601, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
export const BATCH_BOARD_STATUSES = [
|
||||
'DRAFT',
|
||||
@@ -27,23 +19,13 @@ export const BATCH_BOARD_SORT_FIELDS = [
|
||||
] as const;
|
||||
export type BatchBoardSortField = (typeof BATCH_BOARD_SORT_FIELDS)[number];
|
||||
|
||||
/** Filters for the batch monitoring board list (import schedules, all statuses). */
|
||||
export class BatchBoardQueryDto {
|
||||
@ApiPropertyOptional({ default: 1, minimum: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 12, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
pageSize?: number;
|
||||
|
||||
/**
|
||||
* Filters for the batch monitoring board list (import schedules, all statuses).
|
||||
* `page`/`pageSize`/`search`/`sortOrder` come from the shared
|
||||
* {@link PaginationQueryDto}; search matches train number, route yards,
|
||||
* stations, or locomotive code (case-insensitive).
|
||||
*/
|
||||
export class BatchBoardQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Comma-separated schedule statuses (DRAFT,SCHEDULED,DISPATCHED,ARRIVED,CANCELLED). Omit for all.',
|
||||
@@ -58,15 +40,6 @@ export class BatchBoardQueryDto {
|
||||
@IsIn(['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).' })
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@@ -91,9 +64,4 @@ export class BatchBoardQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(BATCH_BOARD_SORT_FIELDS as unknown as string[])
|
||||
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 { BatchBoardQueryDto } from "./dto/batch-board-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 { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
@@ -743,16 +744,16 @@ export class TrainSchedulingController {
|
||||
|
||||
@Get("container/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List container train schedules" })
|
||||
getContainerTrainSchedules() {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||
@ApiOperation({ summary: "List container train schedules (paginated)" })
|
||||
getContainerTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||
}
|
||||
|
||||
@Get("bulk/schedules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List bulk train schedules" })
|
||||
getBulkTrainSchedules() {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules();
|
||||
@ApiOperation({ summary: "List bulk train schedules (paginated)" })
|
||||
getBulkTrainSchedules(@Query() query: ListTrainSchedulesQueryDto) {
|
||||
return this.trainSchedulingService.getContainerTrainSchedules(query);
|
||||
}
|
||||
|
||||
@Get("container/schedules/:id")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
SchedulingStatus,
|
||||
TrainCheckpointKind,
|
||||
TrainScheduleStatus as TrainScheduleStatusEnum,
|
||||
WagonAllocationSnapshot,
|
||||
WagonMovementKind,
|
||||
WagonStatus,
|
||||
} from '@edr/types';
|
||||
@@ -17,8 +18,21 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
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 { Booking } from '../bookings/entities/booking.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 { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-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 { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||
@@ -115,6 +133,8 @@ import {
|
||||
computeImportWindowTimes,
|
||||
earliestSchedulableDeparture,
|
||||
eatDay,
|
||||
eatDayToUtc,
|
||||
shiftEatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
@@ -378,6 +398,108 @@ 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 preferably a still-PRE_WINDOW sibling (its times are the live
|
||||
* group clock). If every sibling has already opened, we still copy the earliest
|
||||
* sibling's frozen times so the new train lines up with the group the customer
|
||||
* already sees rather than drifting onto its own `now`-based 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;
|
||||
const pending = withWindow.filter((s) => s.windowPhase === 'PRE_WINDOW');
|
||||
const pool = pending.length > 0 ? pending : withWindow;
|
||||
// Earliest-opening sibling defines the group clock — deterministic and the
|
||||
// one a customer would have seen first.
|
||||
return pool.reduce((earliest, s) =>
|
||||
s.windowOpensAt!.getTime() < earliest.windowOpensAt!.getTime() ? s : earliest,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The window fields (open/close times + frozen rule snapshot) an anchor sibling
|
||||
* hands down to the rest of its group. The shared open/close instants make every
|
||||
* schedule in the group advance through the SAME open, doc-review, payment, and
|
||||
* close instants on the shared 10s tick — doc-review/payment ends are derived
|
||||
* live from these shared times during phase advance, so they fall in sync.
|
||||
*
|
||||
* `targetDeparture` is the JOINING schedule's own departure: the shared times
|
||||
* are clamped to it so a group whose trains depart at different times of the
|
||||
* same day never hands an earlier-departing train a window that outlives its
|
||||
* departure (computeImport/ExportWindowTimes clamp to departure at source; this
|
||||
* preserves that invariant when the anchor departed later). A window that would
|
||||
* be entirely after this train's departure collapses to a zero-length window at
|
||||
* departure — truthful, not a window that never closes.
|
||||
*/
|
||||
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 {
|
||||
windowOpensAt: clamp(anchor.windowOpensAt),
|
||||
windowClosesAt: clamp(anchor.windowClosesAt),
|
||||
ruleWindowOpenHour: anchor.ruleWindowOpenHour,
|
||||
ruleWindowCloseHour: anchor.ruleWindowCloseHour,
|
||||
ruleWindowDurationHours: anchor.ruleWindowDurationHours,
|
||||
ruleReopenDelayMinutes: anchor.ruleReopenDelayMinutes,
|
||||
ruleImportWindowLeadDays: anchor.ruleImportWindowLeadDays,
|
||||
ruleExportBookingLeadHours: anchor.ruleExportBookingLeadHours,
|
||||
};
|
||||
}
|
||||
|
||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||
// 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
|
||||
@@ -544,15 +666,53 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
...windowRuleSnapshot(merged),
|
||||
});
|
||||
// Route+day grouping (IMPORT/DOMESTIC only): the override applies to the
|
||||
// WHOLE group — every schedule sharing this origin + destination + EAT
|
||||
// departure day. They all adopt the SAME rule snapshot and share the SAME
|
||||
// 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(
|
||||
`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);
|
||||
return fresh ?? schedule;
|
||||
@@ -617,14 +777,34 @@ export class TrainSchedulingService {
|
||||
? computeExportWindowTimes(departure, merged)
|
||||
: 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, {
|
||||
scheduledDepartureDate: departure,
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
...windowFields,
|
||||
});
|
||||
this.logger.log(
|
||||
`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);
|
||||
|
||||
@@ -837,21 +1017,44 @@ export class TrainSchedulingService {
|
||||
// already-open schedule keeps this snapshot, and the batch board draws its
|
||||
// windows from it rather than the live config.
|
||||
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'
|
||||
? {
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...ruleSnapshot,
|
||||
...computeExportWindowTimes(departure, windowCfg),
|
||||
}
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
manager,
|
||||
route.originYardId,
|
||||
route.destinationYardId,
|
||||
departure,
|
||||
);
|
||||
const computedTimes =
|
||||
direction === 'EXPORT'
|
||||
? { ...ruleSnapshot, ...computeExportWindowTimes(departure, windowCfg) }
|
||||
: {
|
||||
// IMPORT and DOMESTIC share the import booking-day window cycle.
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
windowPhase: 'PRE_WINDOW',
|
||||
...ruleSnapshot,
|
||||
...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))
|
||||
.maxWagonsPerTrain;
|
||||
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||||
@@ -1505,12 +1708,34 @@ export class TrainSchedulingService {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
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,
|
||||
);
|
||||
if (schedule.trainSetId) {
|
||||
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 ?? []) {
|
||||
await this.bookingsRepository.updateSchedulingFields(
|
||||
sb.bookingId,
|
||||
@@ -1587,7 +1812,24 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
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) {
|
||||
@@ -2544,7 +2786,15 @@ export class TrainSchedulingService {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
scheduleId,
|
||||
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,
|
||||
);
|
||||
|
||||
@@ -2641,8 +2891,42 @@ export class TrainSchedulingService {
|
||||
return Object.assign(detail, { warehouseAutomation });
|
||||
}
|
||||
|
||||
async getContainerTrainSchedules() {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
async getContainerTrainSchedules(query: ListTrainSchedulesQueryDto = {}) {
|
||||
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: {
|
||||
trainSet: { locomotive: true, locomotives: { locomotive: true } },
|
||||
// Yards carry the route's display name used by mapScheduleListItem;
|
||||
@@ -2652,10 +2936,14 @@ export class TrainSchedulingService {
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
},
|
||||
// Newest-created first (the client can re-sort; this is the default order).
|
||||
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
|
||||
order: { [sortBy]: sortOrder } as never,
|
||||
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) {
|
||||
@@ -2668,13 +2956,24 @@ export class TrainSchedulingService {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
id,
|
||||
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.
|
||||
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
|
||||
{
|
||||
// Retire the booking window so a canceled schedule never lingers as an
|
||||
// "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,
|
||||
);
|
||||
if (schedule.trainSetId) {
|
||||
@@ -2702,6 +3001,9 @@ export class TrainSchedulingService {
|
||||
currentTrainScheduleId: null,
|
||||
trainSetWagonId: null,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3241,6 +3543,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) {
|
||||
const slots = await manager.getRepository(TrainSetWagon).find({ where: { trainSetId } });
|
||||
for (const slot of slots) {
|
||||
@@ -3869,6 +4212,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(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||||
@@ -4441,6 +4811,20 @@ export class TrainSchedulingService {
|
||||
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 {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -4523,52 +4907,84 @@ export class TrainSchedulingService {
|
||||
})),
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((wagon) => ({
|
||||
id: wagon.id,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||||
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
||||
// frontend needs it to show the gross train weight.
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: wagon.status,
|
||||
physicalWagonId: wagon.physicalWagonId ?? null,
|
||||
physicalWagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
||||
: null,
|
||||
allocations:
|
||||
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,
|
||||
})) ?? [],
|
||||
})),
|
||||
.map((wagon) => {
|
||||
// Frozen schedules read the wagon number + allocations from the
|
||||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||
// comes live. Falls back to live if a slot is missing from the snap.
|
||||
const frozenSlot = isWagonAllocationFrozen
|
||||
? snapshotSlotByTrainSetWagonId.get(wagon.id)
|
||||
: undefined;
|
||||
return {
|
||||
id: wagon.id,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||||
// Empty-wagon weight — the pull limit hauls tare + cargo, so the
|
||||
// frontend needs it to show the gross train weight.
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: wagon.status,
|
||||
physicalWagonId: frozenSlot
|
||||
? frozenSlot.physicalWagonId
|
||||
: wagon.physicalWagonId ?? null,
|
||||
physicalWagonNumber: frozenSlot
|
||||
? frozenSlot.physicalWagonNumber
|
||||
: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? { id: wagon.wagonType.id, code: wagon.wagonType.code, name: wagon.wagonType.name }
|
||||
: null,
|
||||
allocations: frozenSlot
|
||||
? frozenSlot.allocations.map((allocation) => ({
|
||||
id: null,
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.bookingReference,
|
||||
allocatedWeightTons: roundTons(allocation.allocatedWeightTons),
|
||||
loadType: allocation.loadType,
|
||||
status: null,
|
||||
// Frozen: container detail collapses to the captured numbers;
|
||||
// per-container geometry isn't re-derivable post-release.
|
||||
containerItems: allocation.containerNumbers.map((containerNumber) => ({
|
||||
id: null,
|
||||
containerNumber,
|
||||
containerTypeId: null,
|
||||
grossWeightTons: 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,
|
||||
bookings:
|
||||
@@ -4586,6 +5002,11 @@ export class TrainSchedulingService {
|
||||
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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 { 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";
|
||||
|
||||
interface OnboardingField {
|
||||
@@ -576,88 +575,66 @@ export class FileUploadSettingsSeeder {
|
||||
constructor(private readonly dataSource: DataSource) { }
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const settingRepository = manager.getRepository(FileUploadSetting);
|
||||
const fieldRepository = manager.getRepository(FileUploadField);
|
||||
const settingRepository = this.dataSource.getRepository(FileUploadSetting);
|
||||
|
||||
const allSettings: Array<
|
||||
OnboardingDocumentSetting & { description: string }
|
||||
> = [
|
||||
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||
...s,
|
||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||
})),
|
||||
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
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.).",
|
||||
})),
|
||||
];
|
||||
// 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(
|
||||
`file_upload_settings already has ${existing} rows — skipping seed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const documentSetting of allSettings) {
|
||||
await settingRepository.upsert(
|
||||
{
|
||||
code: documentSetting.code,
|
||||
label: documentSetting.label,
|
||||
description: documentSetting.description,
|
||||
entity: documentSetting.entity,
|
||||
},
|
||||
{
|
||||
conflictPaths: { code: true },
|
||||
},
|
||||
);
|
||||
const allSettings: Array<
|
||||
OnboardingDocumentSetting & { description: string }
|
||||
> = [
|
||||
...COMPANY_ONBOARDING_DOCUMENTS.map((s) => ({
|
||||
...s,
|
||||
description: COMPANY_ONBOARDING_DESCRIPTION,
|
||||
})),
|
||||
...CLEARANCE_DOCUMENT_SETTINGS.map((s) => ({
|
||||
...s,
|
||||
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({
|
||||
where: { code: documentSetting.code },
|
||||
select: { id: true, code: true },
|
||||
});
|
||||
|
||||
if (!setting) {
|
||||
throw new Error(
|
||||
`file_upload_setting_seed_failed:${documentSetting.code}`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
});
|
||||
// Insert setting rows only — no FileUploadField rows. Fields start empty
|
||||
// and are configured from the backoffice file-settings editor; the field
|
||||
// definitions above are kept as reference defaults.
|
||||
await settingRepository.insert(
|
||||
allSettings.map((documentSetting) => ({
|
||||
code: documentSetting.code,
|
||||
label: documentSetting.label,
|
||||
description: documentSetting.description,
|
||||
entity: documentSetting.entity,
|
||||
})),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
"Ensured company onboarding + booking clearance file upload settings",
|
||||
`Seeded ${allSettings.length} file upload settings with empty fields`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{d as N,r as c,az as S,j as t,v as u}from"./index-Db-xuq0b.js";import{C as U,a as A,b as y,d as z}from"./card-BBWyxDss.js";import{S as I,a as k,b as w,c as P,d as E}from"./select-BoQxM42A.js";import{A as T}from"./AdvancedTable-CC9ioMU-.js";import{u as F,A as L}from"./ArchivedUserColumnDefn-DQXeUrrA.js";import{u as V}from"./useUnit-C4s9nepK.js";import"./table-D3n3VABd.js";import"./chevron-left-DCpEaNzo.js";import"./utils-BncSPdK1.js";import"./badge-D7JvaQeJ.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./organizationsService-BEVk8qa1.js";import"./alert-dialog-B5Y0wlSz.js";import"./useEmployeePostions-CMraotEg.js";import"./employeePositionsService-CkHoE9xG.js";import"./ellipsis-vertical-Cs1B3vez.js";import"./square-pen-B91TPB19.js";import"./user-plus-CBq7Z0dQ.js";import"./unitService-CmGVtFHQ.js";const ce=()=>{var p,h,x,g,f;const{user:i}=N(),{getList:j}=V(),[o,b]=c.useState(0),s=10,v=S(),l=i!=null&&i.employee&&i.employee.length>0?i.employee[0].organizationId:void 0,{data:e}=l?j(l,{take:300,skip:0}):{data:void 0},[d,n]=c.useState(((h=(p=e==null?void 0:e.data)==null?void 0:p.items[0])==null?void 0:h.id)||"All");c.useEffect(()=>{var r;((r=e==null?void 0:e.data)==null?void 0:r.items.length)>0&&n(e==null?void 0:e.data.items[0].id)},[(x=e==null?void 0:e.data)==null?void 0:x.items]);const m=r=>{b(r)},{data:a,refetch:C}=F(d,{take:s,skip:o*s});return t.jsx("div",{className:"p-6 space-y-6",children:t.jsxs(U,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[t.jsx(A,{className:"flex flex-row justify-between items-center px-0",children:t.jsx(y,{className:"text-xl font-semibold ",children:u("setting.archivedUsers")})}),((f=(g=e==null?void 0:e.data)==null?void 0:g.items)==null?void 0:f.length)>0&&t.jsxs("div",{className:"mb-4 w-1/2",children:[t.jsx("label",{className:"block text-sm font-medium text-gray-700",children:u("organization.selectUnit")}),t.jsxs(I,{value:d,onValueChange:r=>n(r),children:[t.jsx(k,{className:"mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm",children:t.jsx(w,{placeholder:"Select a Unit"})}),t.jsx(P,{children:e==null?void 0:e.data.items?.map(r=>t.jsx(E,{value:r.id,children:v(r.name)},r.id))})]})]}),t.jsx(z,{className:"px-0",children:t.jsx(T,{columns:L,data:(a==null?void 0:a.items)||[],tableName:"ArchivedUsers",toolBarPosition:"right",itemCount:(a==null?void 0:a.count)||0,pageIndex:o,onPageChange:m,nextFunction:a!=null&&a.count&&a.count>(o+1)*s?()=>m(o+1):()=>{},prevFunction:o>0?()=>m(Math.max(o-1,0)):()=>{},refresh:C})})]})})};export{ce as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{d as N,r as c,az as S,j as t,v as u}from"./index-7T7TTikv.js";import{C as U,a as A,b as y,d as z}from"./card-_ldW-koX.js";import{S as I,a as k,b as w,c as P,d as E}from"./select--i8koVXg.js";import{A as T}from"./AdvancedTable-vWyUWUDX.js";import{u as F,A as L}from"./ArchivedUserColumnDefn-Ja27QDEy.js";import{u as V}from"./useUnit-CRt6YBLp.js";import"./table-D_B7hhqb.js";import"./chevron-left-G_a9-f9o.js";import"./utils-N2wtfq7C.js";import"./badge-D4t6Wb1T.js";import"./command-B6S1IvD4.js";import"./search-y8VSu0lZ.js";import"./popover-CSKEA8hg.js";import"./Popover-BSKEfTKH.js";import"./use-uncontrolled-tg0LIUAX.js";import"./separator-BXi_hr72.js";import"./refresh-cw-B7fipPS4.js";import"./organizationsService-DIFVjoLn.js";import"./alert-dialog-DwppaTz4.js";import"./useEmployeePostions-BZG3u-zs.js";import"./employeePositionsService-C7MSoCNC.js";import"./ellipsis-vertical-CoMd89ns.js";import"./square-pen-Dh1zj83N.js";import"./user-plus-tzUBEFtH.js";import"./unitService-DTtkt-Pb.js";const ce=()=>{var p,h,x,g,f;const{user:i}=N(),{getList:j}=V(),[o,b]=c.useState(0),s=10,v=S(),l=i!=null&&i.employee&&i.employee.length>0?i.employee[0].organizationId:void 0,{data:e}=l?j(l,{take:300,skip:0}):{data:void 0},[d,n]=c.useState(((h=(p=e==null?void 0:e.data)==null?void 0:p.items[0])==null?void 0:h.id)||"All");c.useEffect(()=>{var r;((r=e==null?void 0:e.data)==null?void 0:r.items.length)>0&&n(e==null?void 0:e.data.items[0].id)},[(x=e==null?void 0:e.data)==null?void 0:x.items]);const m=r=>{b(r)},{data:a,refetch:C}=F(d,{take:s,skip:o*s});return t.jsx("div",{className:"p-6 space-y-6",children:t.jsxs(U,{className:"col-span-2 shadow-none border-none bg-transparent px-0",children:[t.jsx(A,{className:"flex flex-row justify-between items-center px-0",children:t.jsx(y,{className:"text-xl font-semibold ",children:u("setting.archivedUsers")})}),((f=(g=e==null?void 0:e.data)==null?void 0:g.items)==null?void 0:f.length)>0&&t.jsxs("div",{className:"mb-4 w-1/2",children:[t.jsx("label",{className:"block text-sm font-medium text-gray-700",children:u("organization.selectUnit")}),t.jsxs(I,{value:d,onValueChange:r=>n(r),children:[t.jsx(k,{className:"mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm",children:t.jsx(w,{placeholder:"Select a Unit"})}),t.jsx(P,{children:e==null?void 0:e.data.items.map(r=>t.jsx(E,{value:r.id,children:v(r.name)},r.id))})]})]}),t.jsx(z,{className:"px-0",children:t.jsx(T,{columns:L,data:(a==null?void 0:a.items)||[],tableName:"ArchivedUsers",toolBarPosition:"right",itemCount:(a==null?void 0:a.count)||0,pageIndex:o,onPageChange:m,nextFunction:a!=null&&a.count&&a.count>(o+1)*s?()=>m(o+1):()=>{},prevFunction:o>0?()=>m(Math.max(o-1,0)):()=>{},refresh:C})})]})})};export{ce as default};
|
||||
@@ -1,6 +0,0 @@
|
||||
import{y as S,u as R,d as T,az as B,r as l,j as e,B as d,A as F}from"./index-7T7TTikv.js";import{C as K,a as L,b as M,d as q}from"./card-_ldW-koX.js";import{S as E,a as V,b as _,c as H,d as G}from"./select--i8koVXg.js";import{A as j}from"./AdvancedTable-vWyUWUDX.js";import{u as J}from"./useUnit-CRt6YBLp.js";import{a as O,b as Q,u as W}from"./useArchived-5V0W60Gf.js";import{A as N}from"./archive-restore-CbEMfc7_.js";import"./table-D_B7hhqb.js";import"./chevron-left-G_a9-f9o.js";import"./utils-N2wtfq7C.js";import"./badge-D4t6Wb1T.js";import"./command-B6S1IvD4.js";import"./search-y8VSu0lZ.js";import"./popover-CSKEA8hg.js";import"./Popover-BSKEfTKH.js";import"./use-uncontrolled-tg0LIUAX.js";import"./separator-BXi_hr72.js";import"./refresh-cw-B7fipPS4.js";import"./unitService-DTtkt-Pb.js";import"./positionService-BwPU5sNe.js";import"./organizationsService-DIFVjoLn.js";/**
|
||||
* @license lucide-react v0.513.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const X=[["path",{d:"M18 21a6 6 0 0 0-12 0",key:"kaz2du"}],["circle",{cx:"12",cy:"11",r:"4",key:"1gt34v"}],["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}]],Y=S("square-user-round",X),fe=()=>{var y;const{t}=R(),{user:i}=T(),m=B(),{getList:A}=J(),h=i!=null&&i.employee&&i.employee.length>0?i.employee[0].organizationId:void 0,[r,x]=l.useState("units"),[u,g]=l.useState(""),{data:p}=h?A(h,{take:300,skip:0}):{data:void 0},c=((y=p==null?void 0:p.data)==null?void 0:y.items)??[];!u&&c.length>0&&g(c[0].id);const{data:a,refetch:b}=O(h),{data:n,refetch:k}=Q(u||void 0),{restoreUnit:C,isRestoringUnit:U,restorePosition:z,isRestoringPosition:P}=W(),v=l.useMemo(()=>(a==null?void 0:a.items)??a??[],[a]),f=l.useMemo(()=>(n==null?void 0:n.items)??n??[],[n]),I=[{id:"name",header:t("organization.name","Name"),cell:({row:s})=>{var o;return m((o=s.original)==null?void 0:o.name)||"—"}},{id:"key",header:t("organization.key","Key"),accessorKey:"key"},{id:"actions",header:t("userIncomingretun.Actions","Actions"),cell:({row:s})=>e.jsxs(d,{size:"sm",variant:"outline",disabled:U,onClick:()=>C(s.original.id),className:"flex items-center gap-2 text-primary-700 hover:bg-primary-50",children:[e.jsx(N,{className:"h-4 w-4"}),t("archive.restore","Restore")]})}],w=[{id:"name",header:t("organization.name","Name"),cell:({row:s})=>{var o;return m((o=s.original)==null?void 0:o.name)||"—"}},{id:"key",header:t("organization.key","Key"),accessorKey:"key"},{id:"actions",header:t("userIncomingretun.Actions","Actions"),cell:({row:s})=>e.jsxs(d,{size:"sm",variant:"outline",disabled:P,onClick:()=>z(s.original.id),className:"flex items-center gap-2 text-primary-700 hover:bg-primary-50",children:[e.jsx(N,{className:"h-4 w-4"}),t("archive.restore","Restore")]})}];return e.jsx("div",{className:"p-6 space-y-6",children:e.jsxs(K,{className:"shadow-none border-none bg-transparent px-0",children:[e.jsx(L,{className:"flex flex-row justify-between items-center px-0",children:e.jsx(M,{className:"text-xl font-semibold",children:t("archive.archivedItems","Archived Items")})}),e.jsxs("div",{className:"flex gap-2 mb-4",children:[e.jsxs(d,{variant:r==="units"?"default":"outline",onClick:()=>x("units"),className:"flex items-center gap-2",children:[e.jsx(F,{className:"h-4 w-4"}),t("archive.archivedUnits","Archived Units")]}),e.jsxs(d,{variant:r==="positions"?"default":"outline",onClick:()=>x("positions"),className:"flex items-center gap-2",children:[e.jsx(Y,{className:"h-4 w-4"}),t("archive.archivedPositions","Archived Positions")]})]}),r==="positions"&&c.length>0&&e.jsxs("div",{className:"mb-4 w-full sm:w-1/2",children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-200",children:t("organization.selectUnit","Select Unit")}),e.jsxs(E,{value:u,onValueChange:s=>g(s),children:[e.jsx(V,{className:"mt-1 block w-full",children:e.jsx(_,{placeholder:t("organization.selectUnit")})}),e.jsx(H,{children:c.map(s=>e.jsx(G,{value:s.id,children:m(s.name)},s.id))})]})]}),e.jsx(q,{className:"px-0",children:r==="units"?e.jsx(j,{columns:I,data:v,tableName:"ArchivedUnits",toolBarPosition:"right",itemCount:v.length,pageIndex:0,onPageChange:()=>{},nextFunction:()=>{},prevFunction:()=>{},refresh:b}):e.jsx(j,{columns:w,data:f,tableName:"ArchivedPositions",toolBarPosition:"right",itemCount:f.length,pageIndex:0,onPageChange:()=>{},nextFunction:()=>{},prevFunction:()=>{},refresh:k})})]})})};export{fe as default};
|
||||
@@ -1,6 +0,0 @@
|
||||
import{y as S,u as R,d as T,az as B,r as l,j as e,B as d,A as F}from"./index-Db-xuq0b.js";import{C as K,a as L,b as M,d as q}from"./card-BBWyxDss.js";import{S as E,a as V,b as _,c as H,d as G}from"./select-BoQxM42A.js";import{A as j}from"./AdvancedTable-CC9ioMU-.js";import{u as J}from"./useUnit-C4s9nepK.js";import{a as O,b as Q,u as W}from"./useArchived-D2u5xEKl.js";import{A as N}from"./archive-restore-CST5LuiK.js";import"./table-D3n3VABd.js";import"./chevron-left-DCpEaNzo.js";import"./utils-BncSPdK1.js";import"./badge-D7JvaQeJ.js";import"./command-Cr0tMDDu.js";import"./search-CM5F2ZRy.js";import"./popover-X-j_SRnG.js";import"./Popover-CzANYrEa.js";import"./use-uncontrolled-C3HRHW6t.js";import"./separator-BaOOgzZX.js";import"./refresh-cw-JB7N413f.js";import"./unitService-CmGVtFHQ.js";import"./positionService-JD0NEiGK.js";import"./organizationsService-BEVk8qa1.js";/**
|
||||
* @license lucide-react v0.513.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const X=[["path",{d:"M18 21a6 6 0 0 0-12 0",key:"kaz2du"}],["circle",{cx:"12",cy:"11",r:"4",key:"1gt34v"}],["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}]],Y=S("square-user-round",X),fe=()=>{var y;const{t}=R(),{user:i}=T(),m=B(),{getList:A}=J(),h=i!=null&&i.employee&&i.employee.length>0?i.employee[0].organizationId:void 0,[r,x]=l.useState("units"),[u,g]=l.useState(""),{data:p}=h?A(h,{take:300,skip:0}):{data:void 0},c=((y=p==null?void 0:p.data)==null?void 0:y.items)??[];!u&&c.length>0&&g(c[0].id);const{data:a,refetch:b}=O(h),{data:n,refetch:k}=Q(u||void 0),{restoreUnit:C,isRestoringUnit:U,restorePosition:z,isRestoringPosition:P}=W(),v=l.useMemo(()=>(a==null?void 0:a.items)??a??[],[a]),f=l.useMemo(()=>(n==null?void 0:n.items)??n??[],[n]),I=[{id:"name",header:t("organization.name","Name"),cell:({row:s})=>{var o;return m((o=s.original)==null?void 0:o.name)||"—"}},{id:"key",header:t("organization.key","Key"),accessorKey:"key"},{id:"actions",header:t("userIncomingretun.Actions","Actions"),cell:({row:s})=>e.jsxs(d,{size:"sm",variant:"outline",disabled:U,onClick:()=>C(s.original.id),className:"flex items-center gap-2 text-primary-700 hover:bg-primary-50",children:[e.jsx(N,{className:"h-4 w-4"}),t("archive.restore","Restore")]})}],w=[{id:"name",header:t("organization.name","Name"),cell:({row:s})=>{var o;return m((o=s.original)==null?void 0:o.name)||"—"}},{id:"key",header:t("organization.key","Key"),accessorKey:"key"},{id:"actions",header:t("userIncomingretun.Actions","Actions"),cell:({row:s})=>e.jsxs(d,{size:"sm",variant:"outline",disabled:P,onClick:()=>z(s.original.id),className:"flex items-center gap-2 text-primary-700 hover:bg-primary-50",children:[e.jsx(N,{className:"h-4 w-4"}),t("archive.restore","Restore")]})}];return e.jsx("div",{className:"p-6 space-y-6",children:e.jsxs(K,{className:"shadow-none border-none bg-transparent px-0",children:[e.jsx(L,{className:"flex flex-row justify-between items-center px-0",children:e.jsx(M,{className:"text-xl font-semibold",children:t("archive.archivedItems","Archived Items")})}),e.jsxs("div",{className:"flex gap-2 mb-4",children:[e.jsxs(d,{variant:r==="units"?"default":"outline",onClick:()=>x("units"),className:"flex items-center gap-2",children:[e.jsx(F,{className:"h-4 w-4"}),t("archive.archivedUnits","Archived Units")]}),e.jsxs(d,{variant:r==="positions"?"default":"outline",onClick:()=>x("positions"),className:"flex items-center gap-2",children:[e.jsx(Y,{className:"h-4 w-4"}),t("archive.archivedPositions","Archived Positions")]})]}),r==="positions"&&c.length>0&&e.jsxs("div",{className:"mb-4 w-full sm:w-1/2",children:[e.jsx("label",{className:"block text-sm font-medium text-gray-700 dark:text-gray-200",children:t("organization.selectUnit","Select Unit")}),e.jsxs(E,{value:u,onValueChange:s=>g(s),children:[e.jsx(V,{className:"mt-1 block w-full",children:e.jsx(_,{placeholder:t("organization.selectUnit")})}),e.jsx(H,{children:c?.map(s=>e.jsx(G,{value:s.id,children:m(s.name)},s.id))})]})]}),e.jsx(q,{className:"px-0",children:r==="units"?e.jsx(j,{columns:I,data:v,tableName:"ArchivedUnits",toolBarPosition:"right",itemCount:v.length,pageIndex:0,onPageChange:()=>{},nextFunction:()=>{},prevFunction:()=>{},refresh:b}):e.jsx(j,{columns:w,data:f,tableName:"ArchivedPositions",toolBarPosition:"right",itemCount:f.length,pageIndex:0,onPageChange:()=>{},nextFunction:()=>{},prevFunction:()=>{},refresh:k})})]})})};export{fe as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{d as v}from"./organizationsService-BEVk8qa1.js";import{n as j,r as d,u as A,k as f,j as e,B as g,aF as w,f as D,aA as y,aB as b,aC as C,aD as N,v as o,aE as x,b_ as S,az as E}from"./index-Db-xuq0b.js";import{A as O,a as M,b as R,c as U,d as T,e as B,f as L}from"./alert-dialog-B5Y0wlSz.js";import{u as z,T as F}from"./useEmployeePostions-CMraotEg.js";import{E as I}from"./ellipsis-vertical-Cs1B3vez.js";import{S as k}from"./square-pen-B91TPB19.js";import{U as q}from"./user-plus-CBq7Z0dQ.js";import{B as K}from"./badge-D7JvaQeJ.js";const Z=(s,r)=>j({queryKey:["archived-users",s,r],queryFn:async()=>{if(!s)return{items:[],count:0};const{data:a}=await v(s,r);return a},enabled:!!s}),P=({isOpen:s,onClose:r,userId:a})=>{const{activateUser:t,isActivatingUser:i}=z(),[n,c]=d.useState(!1),{t:l}=A(),{handleError:u}=f(l),h=async()=>{try{await t({payload:a,successCallback:()=>{r()}}),c(!0)}catch(m){u(m)}};return e.jsx(O,{open:s,onOpenChange:r,children:e.jsxs(M,{children:[e.jsxs(R,{children:[e.jsx(U,{children:"Remove team member from this position?"}),e.jsx(T,{children:"Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization."})]}),e.jsxs(B,{children:[e.jsx(L,{disabled:n,children:"Cancel"}),e.jsxs(g,{variant:"destructive",onClick:h,disabled:n,children:[i&&e.jsx(w,{className:"h-4 w-4 mr-2 animate-spin"}),"Confirm"]})]})]})})},H=({row:s})=>{const r=D(),[a,t]=d.useState(!1),[i,n]=d.useState(!1),[c,l]=d.useState(!1),u=()=>{r(`/user-management/archive/edit/${s==null?void 0:s.userId}`)},h=p=>{p.preventDefault(),t(!1),n(!0)},m=()=>{l(!0)};return e.jsxs(e.Fragment,{children:[e.jsxs(y,{open:a,onOpenChange:t,children:[e.jsx(b,{asChild:!0,children:e.jsxs(g,{variant:"ghost",className:"h-8 w-8 p-0",children:[e.jsx(I,{className:"h-4 w-4"}),e.jsx("span",{className:"sr-only",children:"Open actions menu"})]})}),e.jsxs(C,{align:"end",onInteractOutside:p=>{p.target.closest('[role="dialog"]')||t(!1)},children:[e.jsx(N,{children:o("userRecord.Actions")}),e.jsxs(x,{onSelect:u,className:"cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200",children:[e.jsx(k,{className:"mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Edit")]})]}),e.jsxs(x,{onSelect:m,className:"cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200",children:[e.jsx(q,{className:"mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Activate")]})]}),e.jsx(S,{}),e.jsxs(x,{onSelect:h,className:"text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200",children:[e.jsx(F,{className:"mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Delete")]})]})]})]}),c&&e.jsx(P,{isOpen:c,onClose:()=>l(!1),userId:s.id})]})},ee=[{accessorKey:"name",header:()=>o("setting.Name"),cell:({row:s})=>{var t;const r=E(),a=(t=s.original)==null?void 0:t.name;return e.jsx("span",{children:r(a)})}},{accessorKey:"status",header:()=>o("userRecord.Status"),cell:({row:s})=>{var i;const r=(i=s.original)==null?void 0:i.status,a=n=>{switch(n.toLowerCase()){case"inactive":return"bg-red-100 text-red-600 hover:bg-red-100";case"active":return"bg-primary-100 text-primary-600 hover:bg-primary-100";default:return"bg-gray-100 text-gray-600 hover:bg-gray-100"}},t=n=>{switch(n.toLowerCase()){case"inactive":return"InActive";case"active":return"Active";default:return"Not Available"}};return e.jsx("div",{children:e.jsx(K,{className:`${a(r)} rounded-full px-6 py-1 font-medium`,children:t(r)})})}},{id:"actions",header:()=>o("userRecord.Actions"),cell:({row:s})=>e.jsx(H,{row:s.original})}];export{ee as A,Z as u};
|
||||
@@ -1 +0,0 @@
|
||||
import{d as v}from"./organizationsService-DIFVjoLn.js";import{n as j,r as d,u as A,k as f,j as e,B as g,aF as w,f as D,aA as y,aB as b,aC as C,aD as N,v as o,aE as x,b_ as S,az as E}from"./index-7T7TTikv.js";import{A as O,a as M,b as R,c as U,d as T,e as B,f as L}from"./alert-dialog-DwppaTz4.js";import{u as z,T as F}from"./useEmployeePostions-BZG3u-zs.js";import{E as I}from"./ellipsis-vertical-CoMd89ns.js";import{S as k}from"./square-pen-Dh1zj83N.js";import{U as q}from"./user-plus-tzUBEFtH.js";import{B as K}from"./badge-D4t6Wb1T.js";const Z=(s,r)=>j({queryKey:["archived-users",s,r],queryFn:async()=>{if(!s)return{items:[],count:0};const{data:a}=await v(s,r);return a},enabled:!!s}),P=({isOpen:s,onClose:r,userId:a})=>{const{activateUser:t,isActivatingUser:i}=z(),[n,c]=d.useState(!1),{t:l}=A(),{handleError:u}=f(l),h=async()=>{try{await t({payload:a,successCallback:()=>{r()}}),c(!0)}catch(m){u(m)}};return e.jsx(O,{open:s,onOpenChange:r,children:e.jsxs(M,{children:[e.jsxs(R,{children:[e.jsx(U,{children:"Remove team member from this position?"}),e.jsx(T,{children:"Are you sure you want to activate this archived user? This action will restore the user's access and data within the organization."})]}),e.jsxs(B,{children:[e.jsx(L,{disabled:n,children:"Cancel"}),e.jsxs(g,{variant:"destructive",onClick:h,disabled:n,children:[i&&e.jsx(w,{className:"h-4 w-4 mr-2 animate-spin"}),"Confirm"]})]})]})})},H=({row:s})=>{const r=D(),[a,t]=d.useState(!1),[i,n]=d.useState(!1),[c,l]=d.useState(!1),u=()=>{r(`/user-management/archive/edit/${s==null?void 0:s.userId}`)},h=p=>{p.preventDefault(),t(!1),n(!0)},m=()=>{l(!0)};return e.jsxs(e.Fragment,{children:[e.jsxs(y,{open:a,onOpenChange:t,children:[e.jsx(b,{asChild:!0,children:e.jsxs(g,{variant:"ghost",className:"h-8 w-8 p-0",children:[e.jsx(I,{className:"h-4 w-4"}),e.jsx("span",{className:"sr-only",children:"Open actions menu"})]})}),e.jsxs(C,{align:"end",onInteractOutside:p=>{p.target.closest('[role="dialog"]')||t(!1)},children:[e.jsx(N,{children:o("userRecord.Actions")}),e.jsxs(x,{onSelect:u,className:"cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200",children:[e.jsx(k,{className:"mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Edit")]})]}),e.jsxs(x,{onSelect:m,className:"cursor-pointer hover:!text-primary-700 !bg-transparent !transition-colors duration-200",children:[e.jsx(q,{className:"mr-2 h-4 w-4 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Activate")]})]}),e.jsx(S,{}),e.jsxs(x,{onSelect:h,className:"text-red-600 cursor-pointer hover:!text-red-800 !bg-transparent !transition-colors duration-200",children:[e.jsx(F,{className:"mr-2 h-4 w-4 text-red-600 group-hover:text-white transition-colors duration-200"}),e.jsxs("span",{children:[" ",o("userRecord.Delete")]})]})]})]}),c&&e.jsx(P,{isOpen:c,onClose:()=>l(!1),userId:s.id})]})},ee=[{accessorKey:"name",header:()=>o("setting.Name"),cell:({row:s})=>{var t;const r=E(),a=(t=s.original)==null?void 0:t.name;return e.jsx("span",{children:r(a)})}},{accessorKey:"status",header:()=>o("userRecord.Status"),cell:({row:s})=>{var i;const r=(i=s.original)==null?void 0:i.status,a=n=>{switch(n.toLowerCase()){case"inactive":return"bg-red-100 text-red-600 hover:bg-red-100";case"active":return"bg-primary-100 text-primary-600 hover:bg-primary-100";default:return"bg-gray-100 text-gray-600 hover:bg-gray-100"}},t=n=>{switch(n.toLowerCase()){case"inactive":return"InActive";case"active":return"Active";default:return"Not Available"}};return e.jsx("div",{children:e.jsx(K,{className:`${a(r)} rounded-full px-6 py-1 font-medium`,children:t(r)})})}},{id:"actions",header:()=>o("userRecord.Actions"),cell:({row:s})=>e.jsx(H,{row:s.original})}];export{ee as A,Z as u};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user