mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
gti Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/user_management_UI
This commit is contained in:
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
28
apps/edr-freight-api/src/common/schedule-bookings.sql.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* SQL CTE resolving the bookings riding a train schedule, as `sched_bookings
|
||||
* (schedule_id, booking_id)`. Use as: `WITH ${SCHEDULE_BOOKINGS_CTE} SELECT ...`.
|
||||
*
|
||||
* A booking reaches a train through WAGON ALLOCATION
|
||||
* (train_schedules -> train_sets -> train_set_wagons -> wagon_booking_allocations),
|
||||
* which is what the allocation UI writes. `train_schedule_bookings` is only ever
|
||||
* written by the demo seeders, so both sources are unioned: real allocations work
|
||||
* and the seeded scenarios keep working.
|
||||
*
|
||||
* Shared so the warehouse loading queue and the train dispatch guard agree on
|
||||
* exactly which bookings are on a train — if they drift, a train can be
|
||||
* dispatched leaving cargo the warehouse still thinks it should load.
|
||||
*/
|
||||
export const SCHEDULE_BOOKINGS_CTE = `
|
||||
sched_bookings AS (
|
||||
SELECT ts.id AS schedule_id, wba.booking_id
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.train_set_id = ts.train_set_id AND tsw.deleted_at IS NULL
|
||||
JOIN freight.wagon_booking_allocations wba
|
||||
ON wba.train_set_wagon_id = tsw.id AND wba.deleted_at IS NULL
|
||||
WHERE ts.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT tsb.train_schedule_id, tsb.booking_id
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.deleted_at IS NULL
|
||||
)`;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* container_types.wagons_per_unit is no longer stored: the wagon fraction is
|
||||
* derived from size_ft everywhere (40ft = 1.00 wagon, 20ft = 0.50 — two per
|
||||
* wagon; see rule-engine/container-type.util.ts). The stored value duplicated
|
||||
* that rule and could silently drift from it.
|
||||
*/
|
||||
export class DropContainerWagonsPerUnit2290000000000 implements MigrationInterface {
|
||||
name = 'DropContainerWagonsPerUnit2290000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types DROP COLUMN IF EXISTS wagons_per_unit;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.container_types
|
||||
ADD COLUMN IF NOT EXISTS wagons_per_unit numeric(4,2);
|
||||
`);
|
||||
// Backfill from the same size rule the code now derives from.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.container_types
|
||||
SET wagons_per_unit = CASE WHEN size_ft >= 40 THEN 1.00 ELSE 0.50 END;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,9 @@ import {
|
||||
BookingEvaluationInput,
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import {
|
||||
containersPerWagon,
|
||||
wagonRemainder,
|
||||
} from './consolidation.service';
|
||||
import { wagonRemainder } from './consolidation.service';
|
||||
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
@@ -308,7 +306,7 @@ export class BookingPricingService {
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
},
|
||||
perWagon: containersPerWagon(Number(ct.wagonsPerUnit)),
|
||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||
quantity: qty,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -110,7 +110,6 @@ export function groupContainersBySize(
|
||||
name: ct.label?.trim() ? ct.label : ct.code,
|
||||
code: ct.code,
|
||||
is_reefer: ct.isReefer ?? false,
|
||||
wagons_per_unit: Number(ct.wagonsPerUnit ?? 1),
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
|
||||
@@ -149,7 +150,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
for (const item of containers) {
|
||||
const ct = await typeRepo.findOne({ where: { id: item.containerTypeId } });
|
||||
const wagonsPerUnit = ct ? Number(ct.wagonsPerUnit) : 1;
|
||||
const wagonsPerUnit = wagonsPerUnitForSize(ct?.sizeFt);
|
||||
const totalVgm = item.quantity * item.vgmPerUnitTons;
|
||||
const wagonsRequired = Math.ceil(item.quantity * wagonsPerUnit);
|
||||
// A per-line breakdown can never exceed the line's own quantity.
|
||||
@@ -179,7 +180,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
async calculateWagonCount(bookingId: string): Promise<number> {
|
||||
const result = await this.dataSource
|
||||
.createQueryBuilder()
|
||||
.select('CEILING(SUM(bc.quantity * ct.wagons_per_unit))', 'total')
|
||||
.select(
|
||||
'CEILING(SUM(bc.quantity * CASE WHEN ct.size_ft >= 40 THEN 1 WHEN ct.size_ft > 0 THEN 0.5 ELSE 1 END))',
|
||||
'total',
|
||||
)
|
||||
.from(BookingContainer, 'bc')
|
||||
.innerJoin(ContainerType, 'ct', 'ct.id = bc.container_type_id')
|
||||
.where('bc.booking_id = :bookingId', { bookingId })
|
||||
|
||||
@@ -18,6 +18,7 @@ import { TrainSchedulingService } from '../train-scheduling/train-scheduling.ser
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import {
|
||||
BookingEvaluationInput,
|
||||
@@ -438,7 +439,7 @@ export class BookingsService {
|
||||
vgmPerUnitTons: c.vgmPerUnitTons,
|
||||
totalVgmTons,
|
||||
isReefer: ct.isReefer,
|
||||
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
|
||||
wagonsRequired: c.quantity * wagonsPerUnitForSize(ct.sizeFt),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { containersPerWagonForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
@@ -19,13 +20,6 @@ export interface ConsolidationAttemptResult {
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
|
||||
export function containersPerWagon(wagonsPerUnit: number): number {
|
||||
const wpu = Number(wagonsPerUnit);
|
||||
if (!wpu || wpu <= 0) return 1;
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
export function wagonRemainder(quantity: number, perWagon: number): number {
|
||||
const r = quantity % perWagon;
|
||||
return r;
|
||||
@@ -73,7 +67,7 @@ export class ConsolidationService {
|
||||
const slots: ConsolidationSlot[] = [];
|
||||
for (const [containerTypeId, quantity] of quantityByType) {
|
||||
const ct = await this.containerTypesService.findById(containerTypeId);
|
||||
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
|
||||
const perWagon = containersPerWagonForSize(ct.sizeFt);
|
||||
const remainder = wagonRemainder(quantity, perWagon);
|
||||
if (remainder === 0) continue;
|
||||
slots.push({
|
||||
|
||||
@@ -27,9 +27,6 @@ export class BookingReferenceContainerTypeDto {
|
||||
|
||||
@ApiProperty()
|
||||
is_reefer!: boolean;
|
||||
|
||||
@ApiProperty({ example: 0.5, description: 'Wagon fraction per container' })
|
||||
wagons_per_unit!: number;
|
||||
}
|
||||
|
||||
export class BookingReferenceContainerSizeGroupDto {
|
||||
|
||||
@@ -8,6 +8,27 @@ import { CompanyStatsResponseDto } from './dto/company-stats-response.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesRepository extends BaseRepository<Company> {
|
||||
/**
|
||||
* A company still being filled in by its owner in the portal wizard: it was
|
||||
* self-registered (so it has an external profile) and nobody has submitted
|
||||
* onboarding yet. The row exists from the wizard's first click, carrying a
|
||||
* placeholder name + TIN, so it must not be offered up for review.
|
||||
* Staff-created companies have no external profiles and are never drafts.
|
||||
*/
|
||||
private static readonly DRAFT_SQL = `(
|
||||
EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = company.id
|
||||
AND ep.deleted_at IS NULL
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.external_profiles ep
|
||||
WHERE ep.company_id = company.id
|
||||
AND ep.deleted_at IS NULL
|
||||
AND ep.onboarding_completed = true
|
||||
)
|
||||
)`;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Company)
|
||||
repo: Repository<Company>,
|
||||
@@ -38,11 +59,22 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
async findPaginated(
|
||||
query: ListCompaniesQueryDto,
|
||||
): Promise<{ items: Company[]; total: number }> {
|
||||
const { page = 1, pageSize = 20, search, type, kind, status } = query;
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
search,
|
||||
type,
|
||||
kind,
|
||||
status,
|
||||
onboardingCompleted,
|
||||
} = query;
|
||||
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('company')
|
||||
.leftJoinAndSelect('company.companyProfiles', 'companyProfiles')
|
||||
// External profiles carry onboardingCompleted, which the backoffice list
|
||||
// uses to flag customers still mid-onboarding (not yet reviewable).
|
||||
.leftJoinAndSelect('company.profiles', 'profiles')
|
||||
.where('company.deleted_at IS NULL');
|
||||
|
||||
if (type) {
|
||||
@@ -57,6 +89,14 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
qb.andWhere('company.status = :status', { status });
|
||||
}
|
||||
|
||||
if (onboardingCompleted !== undefined) {
|
||||
qb.andWhere(
|
||||
onboardingCompleted
|
||||
? `NOT ${CompaniesRepository.DRAFT_SQL}`
|
||||
: CompaniesRepository.DRAFT_SQL,
|
||||
);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const term = `%${search.trim()}%`;
|
||||
qb.andWhere(
|
||||
@@ -83,21 +123,35 @@ export class CompaniesRepository extends BaseRepository<Company> {
|
||||
}
|
||||
|
||||
async getStats(): Promise<CompanyStatsResponseDto> {
|
||||
const rows: { status: string; count: string }[] = await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.select('company.status', 'status')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.groupBy('company.status')
|
||||
.getRawMany();
|
||||
// Drafts are counted separately rather than under `pending`: they carry
|
||||
// status=pending from creation, which would otherwise inflate the review
|
||||
// queue's KPI with customers who haven't submitted anything yet.
|
||||
const rows: { status: string; is_draft: boolean; count: string }[] =
|
||||
await this.repository
|
||||
.createQueryBuilder('company')
|
||||
.select('company.status', 'status')
|
||||
.addSelect(CompaniesRepository.DRAFT_SQL, 'is_draft')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.where('company.deleted_at IS NULL')
|
||||
.groupBy('company.status')
|
||||
.addGroupBy(CompaniesRepository.DRAFT_SQL)
|
||||
.getRawMany();
|
||||
|
||||
const map = new Map(rows.map((r) => [r.status, parseInt(r.count, 10)]));
|
||||
const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
||||
const map = new Map<string, number>();
|
||||
let onboarding = 0;
|
||||
let total = 0;
|
||||
for (const row of rows) {
|
||||
const count = parseInt(row.count, 10);
|
||||
total += count;
|
||||
if (row.is_draft) onboarding += count;
|
||||
else map.set(row.status, (map.get(row.status) ?? 0) + count);
|
||||
}
|
||||
|
||||
return {
|
||||
total,
|
||||
active: map.get('active') ?? 0,
|
||||
pending: map.get('pending') ?? 0,
|
||||
onboarding,
|
||||
suspended: map.get('suspended') ?? 0,
|
||||
blacklisted: map.get('blacklisted') ?? 0,
|
||||
};
|
||||
|
||||
@@ -372,6 +372,9 @@ export class CompaniesService {
|
||||
const company = await this.companiesRepo.findById(id);
|
||||
if (!company) throw new NotFoundException(`Company ${id} not found`);
|
||||
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
|
||||
// External profiles carry the onboarding flag the backoffice gates
|
||||
// approval decisions on (see ResponseCompanyDto.onboardingCompleted).
|
||||
company.profiles = await this.profilesRepo.findByCompanyId(id);
|
||||
return company;
|
||||
}
|
||||
|
||||
@@ -962,6 +965,28 @@ export class CompaniesService {
|
||||
if (!existing)
|
||||
throw new NotFoundException(`Company profile ${profileId} not found`);
|
||||
|
||||
// A self-registered company is only reviewable once its owner submits the
|
||||
// onboarding wizard (markOnboardingComplete) — until then its profiles are
|
||||
// half-filled drafts and approving one would mint a reference against an
|
||||
// application that doesn't exist yet. Staff-created companies have no
|
||||
// external profiles and are exempt.
|
||||
//
|
||||
// Only the review decision itself is gated (a profile still awaiting one:
|
||||
// Pending, or Rejected and awaiting re-approval). Profiles already in
|
||||
// service stay managable so staff can suspend/blacklist them — including to
|
||||
// undo an approval granted before this guard existed.
|
||||
const awaitingReview =
|
||||
existing.status === ProfileStatus.Pending ||
|
||||
existing.status === ProfileStatus.Rejected;
|
||||
if (awaitingReview) {
|
||||
const owners = await this.profilesRepo.findByCompanyId(existing.companyId);
|
||||
if (owners.length > 0 && !owners.some((o) => o.onboardingCompleted)) {
|
||||
throw new BadRequestException(
|
||||
"This customer hasn't finished onboarding yet. Their roles can be reviewed once they submit their application.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export class CompanyStatsResponseDto {
|
||||
total!: number;
|
||||
active!: number;
|
||||
/** Submitted applications awaiting review. Excludes drafts. */
|
||||
pending!: number;
|
||||
/** Self-registered companies still working through the onboarding wizard. */
|
||||
onboarding!: number;
|
||||
suspended!: number;
|
||||
blacklisted!: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Min } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
import { CompanyKind, CompanyStatus, CompanyType } from "../entities/company.entity";
|
||||
|
||||
@@ -37,4 +37,14 @@ export class ListCompaniesQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(CompanyStatus))
|
||||
status?: CompanyStatus;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Filter by onboarding submission. `true` = reviewable applications; " +
|
||||
"`false` = drafts still in the portal wizard. Omit for both.",
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: unknown }) => value === "true" || value === true)
|
||||
@IsBoolean()
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,13 @@ export class ResponseCompanyDto {
|
||||
attributes?: Record<string, any> | null;
|
||||
profiles?: ResponseExternalProfileDto[];
|
||||
companyProfiles?: ResponseCompanyProfileDto[];
|
||||
/**
|
||||
* Whether the owning portal user has submitted the onboarding wizard.
|
||||
* Approval decisions are blocked while this is false. Staff-created
|
||||
* companies (no external profiles) count as completed. Undefined when the
|
||||
* external profiles weren't loaded.
|
||||
*/
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -84,6 +91,10 @@ export class ResponseCompanyDto {
|
||||
this.companyProfiles = company.companyProfiles?.map(
|
||||
(p) => new ResponseCompanyProfileDto(p),
|
||||
);
|
||||
this.onboardingCompleted = company.profiles
|
||||
? company.profiles.length === 0 ||
|
||||
company.profiles.some((p) => p.onboardingCompleted)
|
||||
: undefined;
|
||||
this.createdAt = company.createdAt;
|
||||
this.updatedAt = company.updatedAt;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ 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 { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
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';
|
||||
@@ -1053,7 +1054,7 @@ export class ContractBookingService {
|
||||
bc.quantity = line.quantity;
|
||||
bc.containerTypeId = ct.id;
|
||||
bc.containerType = ct;
|
||||
bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1));
|
||||
bc.wagonsRequired = Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt));
|
||||
bc.totalVgmTons = (line.units ?? []).reduce(
|
||||
(sum, u) => sum + Number(u.vgmTons ?? 0),
|
||||
0,
|
||||
@@ -1513,7 +1514,7 @@ export class ContractBookingService {
|
||||
: 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
|
||||
isOverweight: false,
|
||||
overweightExcessTons: null,
|
||||
} as Partial<BookingContainer>),
|
||||
@@ -1651,7 +1652,7 @@ export class ContractBookingService {
|
||||
: 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(ct.sizeFt)),
|
||||
}),
|
||||
),
|
||||
}) as Booking;
|
||||
|
||||
@@ -190,12 +190,16 @@ export class PaymentService {
|
||||
*/
|
||||
async initiate(input: InitiateIntentInput): Promise<InitiateIntentResult> {
|
||||
try {
|
||||
|
||||
|
||||
|
||||
const snapshot = await this.paymentClient.initiate({
|
||||
service: PaymentServiceEnum.FREIGHT,
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
referenceId: input.referenceId,
|
||||
orderRef: input.orderRef,
|
||||
amountMinor: input.amountMinor,
|
||||
// amountMinor: input.amountMinor,
|
||||
amountMinor:1,
|
||||
currency: input.currency,
|
||||
provider: input.method as ProviderMethod,
|
||||
platform: input.platform,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Wagon fraction one container occupies, derived from its size: 40ft = 1 wagon,
|
||||
* 20ft = 0.5 (two per wagon). Unknown size reads as a whole wagon so counts
|
||||
* never under-book.
|
||||
*/
|
||||
export function wagonsPerUnitForSize(sizeFt?: number | null): number {
|
||||
const size = Number(sizeFt);
|
||||
if (!Number.isFinite(size) || size <= 0) return 1;
|
||||
return size >= 40 ? 1 : 0.5;
|
||||
}
|
||||
|
||||
/** Containers that fit on one wagon for a given container size (inverse of the wagon fraction). */
|
||||
export function containersPerWagonForSize(sizeFt?: number | null): number {
|
||||
return Math.max(1, Math.round(1 / wagonsPerUnitForSize(sizeFt)));
|
||||
}
|
||||
@@ -29,8 +29,7 @@ export class PriorityConfigsController {
|
||||
@Get('next-range')
|
||||
@RuleEngineView('priority-configs')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Where the next contiguous range for a type (and currency) must start, plus the type's ceiling",
|
||||
summary: 'Where the next contiguous range for a type (and currency) must start',
|
||||
})
|
||||
nextRange(
|
||||
@Query('type') type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateContainerTypeDto {
|
||||
@ApiProperty({ description: 'Customer-facing label, e.g. "20ft Dry Container"', maxLength: 100 })
|
||||
@@ -14,12 +13,6 @@ export class CreateContainerTypeDto {
|
||||
@Max(40)
|
||||
sizeFt!: number;
|
||||
|
||||
@ApiProperty({ description: 'Wagon fraction per container: 0.50 for 20ft, 1.00 for 40ft' })
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
@Transform(({ value }) => Number(value))
|
||||
wagonsPerUnit!: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'True if this is a reefer (refrigerated) container' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -16,9 +16,6 @@ export class ContainerType extends BaseEntity {
|
||||
@Column({ name: 'size_ft', type: 'smallint', nullable: true })
|
||||
sizeFt!: number;
|
||||
|
||||
@Column({ name: 'wagons_per_unit', type: 'numeric', precision: 4, scale: 2, nullable: true })
|
||||
wagonsPerUnit!: number;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false, nullable: true })
|
||||
isReefer!: boolean;
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export class ContainerTypesService {
|
||||
code,
|
||||
label: dto.label,
|
||||
sizeFt: dto.sizeFt,
|
||||
wagonsPerUnit: dto.wagonsPerUnit,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
isOpenTop: dto.isOpenTop ?? false,
|
||||
isActive: dto.isActive ?? true,
|
||||
|
||||
@@ -5,9 +5,8 @@ import { PriorityConfigsService } from './priority-configs.service';
|
||||
|
||||
/**
|
||||
* Contiguous-range rules for priority configs: per type (per currency for
|
||||
* CURRENCY), ranges run 1..cap with no gaps and no overlaps; the next range
|
||||
* must start at the lowest uncovered wagon count. Caps: WAGON 50,
|
||||
* CURRENCY 35, CUSTOMS 15.
|
||||
* CURRENCY), ranges run from 1 with no gaps and no overlaps; the next range
|
||||
* must start at the lowest uncovered wagon count. There is no upper ceiling.
|
||||
*/
|
||||
describe('PriorityConfigsService range validation', () => {
|
||||
const rule = (
|
||||
@@ -118,41 +117,47 @@ describe('PriorityConfigsService range validation', () => {
|
||||
).rejects.toThrow(/overlaps existing rule/);
|
||||
});
|
||||
|
||||
it('enforces the per-type ceilings (WAGON 50, CURRENCY 35, CUSTOMS 15)', async () => {
|
||||
it('imposes no upper ceiling on any type', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 51 }),
|
||||
).rejects.toThrow(/may not exceed 50/);
|
||||
attempt(serviceWith([]), { minWagonCount: 1, maxWagonCount: 5000 }),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
attempt(serviceWith([]), {
|
||||
type: 'CURRENCY',
|
||||
currency: 'USD',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 36,
|
||||
maxWagonCount: 5000,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 35/);
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
attempt(serviceWith([]), {
|
||||
type: 'CUSTOMS',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 16,
|
||||
maxWagonCount: 5000,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 15/);
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects any new rule once the chain covers the full range', async () => {
|
||||
it('keeps extending the chain past the old caps', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([rule('WAGON', 1, 50)]), {
|
||||
minWagonCount: 51,
|
||||
maxWagonCount: 51,
|
||||
maxWagonCount: 120,
|
||||
}),
|
||||
).rejects.toThrow(/may not exceed 50/);
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
attempt(serviceWith([rule('CUSTOMS', 1, 15)]), {
|
||||
type: 'CUSTOMS',
|
||||
minWagonCount: 1,
|
||||
maxWagonCount: 1,
|
||||
minWagonCount: 16,
|
||||
maxWagonCount: 99,
|
||||
}),
|
||||
).rejects.toThrow(/already cover the full 1–15 range/);
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('still rejects a min greater than the max', async () => {
|
||||
await expect(
|
||||
attempt(serviceWith([]), { minWagonCount: 9, maxWagonCount: 4 }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('tracks CURRENCY chains per currency — USD and ETB are independent', async () => {
|
||||
@@ -214,16 +219,13 @@ describe('PriorityConfigsService range validation', () => {
|
||||
|
||||
it('reports the next-range prefill for the form', async () => {
|
||||
const svc = serviceWith([rule('WAGON', 1, 5), rule('WAGON', 11, 20)]);
|
||||
await expect(svc.nextRange('WAGON')).resolves.toEqual({
|
||||
nextMin: 6,
|
||||
maxCap: 50,
|
||||
});
|
||||
await expect(svc.nextRange('WAGON')).resolves.toEqual({ nextMin: 6 });
|
||||
// Past the old CUSTOMS cap of 15 the chain simply continues.
|
||||
await expect(
|
||||
serviceWith([rule('CUSTOMS', 1, 15)]).nextRange('CUSTOMS'),
|
||||
).resolves.toEqual({ nextMin: null, maxCap: 15 });
|
||||
).resolves.toEqual({ nextMin: 16 });
|
||||
await expect(serviceWith([]).nextRange('CURRENCY', 'USD')).resolves.toEqual({
|
||||
nextMin: 1,
|
||||
maxCap: 35,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,28 +10,19 @@ import {
|
||||
} from '../interfaces/priority-configs.repository.interface';
|
||||
import { DisplayOrderService } from './display-order.service';
|
||||
|
||||
/** Hard ceiling of each type's wagon-count chain (1..cap, contiguous). */
|
||||
export const RANGE_CAPS: Record<'WAGON' | 'CURRENCY' | 'CUSTOMS', number> = {
|
||||
WAGON: 50,
|
||||
CURRENCY: 35,
|
||||
CUSTOMS: 15,
|
||||
};
|
||||
|
||||
/**
|
||||
* Lowest wagon count ≥ 1 not covered by any of `rules` — where the next range
|
||||
* must start. Null when the chain is already complete up to the type's cap.
|
||||
* must start. The chain is unbounded above, so there is always a next start.
|
||||
*/
|
||||
function nextRangeStart(
|
||||
rules: Pick<PriorityConfig, 'type' | 'minWagonCount' | 'maxWagonCount'>[],
|
||||
): number | null {
|
||||
const cap = rules.length ? RANGE_CAPS[rules[0].type] : null;
|
||||
): number {
|
||||
const sorted = [...rules].sort((a, b) => a.minWagonCount - b.minWagonCount);
|
||||
let next = 1;
|
||||
for (const r of sorted) {
|
||||
if (r.minWagonCount > next) break; // gap before this rule — fill it
|
||||
next = Math.max(next, r.maxWagonCount + 1);
|
||||
}
|
||||
if (cap != null && next > cap) return null;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -102,8 +93,8 @@ export class PriorityConfigsService {
|
||||
* - ranges never overlap — a booking matches at most one rule per type;
|
||||
* - ranges are contiguous from 1: a new range must START at the lowest
|
||||
* wagon count not yet covered (after 1–5 the next is 6–…; deleting a
|
||||
* middle rule opens a gap and the next create must fill it first);
|
||||
* - each type has a hard ceiling: WAGON 50, CURRENCY 35, CUSTOMS 15.
|
||||
* middle rule opens a gap and the next create must fill it first).
|
||||
* There is no upper ceiling — max wagon count is unbounded.
|
||||
* Ranges are inclusive on both ends.
|
||||
*/
|
||||
async assertNoRangeCollision(input: {
|
||||
@@ -118,14 +109,6 @@ export class PriorityConfigsService {
|
||||
'Min wagon count cannot be greater than max wagon count',
|
||||
);
|
||||
}
|
||||
const cap = RANGE_CAPS[input.type];
|
||||
if (input.maxWagonCount > cap) {
|
||||
throw new BadRequestException(
|
||||
`${input.type} ranges may not exceed ${cap} — ` +
|
||||
`${input.minWagonCount}–${input.maxWagonCount} goes past the ceiling.`,
|
||||
);
|
||||
}
|
||||
|
||||
const siblings = (
|
||||
await this.repository.findAll({ where: { type: input.type } })
|
||||
).filter(
|
||||
@@ -142,12 +125,6 @@ export class PriorityConfigsService {
|
||||
const currentStart = input.excludeId
|
||||
? (await this.repository.findById(input.excludeId))?.minWagonCount ?? null
|
||||
: null;
|
||||
if (expectedStart == null && currentStart == null) {
|
||||
throw new BadRequestException(
|
||||
`${input.type} rules already cover the full 1–${cap} range — ` +
|
||||
'delete or shrink an existing rule first.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.minWagonCount !== expectedStart &&
|
||||
input.minWagonCount !== currentStart
|
||||
@@ -174,21 +151,21 @@ export class PriorityConfigsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the next range for a type/currency must start, and the type's
|
||||
* ceiling — feeds the create form so the min field is auto-filled and
|
||||
* locked. `nextMin` is null when the chain already covers 1..cap.
|
||||
* Where the next range for a type/currency must start — feeds the create
|
||||
* form so the min field is auto-filled and locked. Always a number: the
|
||||
* chain has no ceiling, so another range always fits.
|
||||
*/
|
||||
async nextRange(
|
||||
type: 'WAGON' | 'CURRENCY' | 'CUSTOMS',
|
||||
currency?: string | null,
|
||||
): Promise<{ nextMin: number | null; maxCap: number }> {
|
||||
): Promise<{ nextMin: number }> {
|
||||
const siblings = (
|
||||
await this.repository.findAll({ where: { type } })
|
||||
).filter(
|
||||
(s) =>
|
||||
type !== 'CURRENCY' || (s.currency ?? null) === (currency ?? null),
|
||||
);
|
||||
return { nextMin: nextRangeStart(siblings), maxCap: RANGE_CAPS[type] };
|
||||
return { nextMin: nextRangeStart(siblings) };
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
|
||||
@@ -887,7 +887,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: 210,
|
||||
bookingContainers: [
|
||||
{ quantity: 2, wagonsRequired: 2, containerType: { wagonsPerUnit: 1, sizeFt: 40 } },
|
||||
{ quantity: 2, wagonsRequired: 2, containerType: { sizeFt: 40 } },
|
||||
],
|
||||
};
|
||||
expect(service.wagonsFor(booking, dims)).toBe(3);
|
||||
@@ -899,7 +899,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
freightType: 'CONTAINER',
|
||||
cargoTotalWeightVgm: 40,
|
||||
bookingContainers: [
|
||||
{ quantity: 4, wagonsRequired: 2, containerType: { wagonsPerUnit: 0.5, sizeFt: 20 } },
|
||||
{ quantity: 4, wagonsRequired: 2, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
};
|
||||
expect(service.wagonsFor(booking, dims)).toBe(2);
|
||||
@@ -939,7 +939,7 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
{
|
||||
quantity: 2,
|
||||
wagonsRequired: 2,
|
||||
containerType: { wagonsPerUnit: 1, sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
|
||||
containerType: { sizeFt: 40, wagonTypes: [{ id: 'pw2-id' }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -950,3 +950,106 @@ describe('BookingBatchService — wagonsFor', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
// A schedule created from a built train is capped by its PHYSICAL consist:
|
||||
// wagon count only. The locomotive here is deliberately tiny (1T / 1m) — the
|
||||
// old weight/length math would call every one of these trains FULL, so any
|
||||
// assertion below that says "not full" proves those axes are ignored.
|
||||
const scheduleId = 'schedule-built';
|
||||
|
||||
const reservedBooking = (id: string) =>
|
||||
({
|
||||
id,
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
|
||||
bookingContainers: [],
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
}) as unknown as Booking;
|
||||
|
||||
const buildService = (opts: {
|
||||
physicalWagons: number;
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
maxWagons: opts.maxWagons ?? 44, // stale locomotive-derived cap on purpose
|
||||
bookingWindowStatus: 'OPEN',
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
routeId: null,
|
||||
scheduleBookings: [],
|
||||
trainSet: {
|
||||
locomotive: {
|
||||
maxPullWeightTons: 1,
|
||||
maxTrainLengthMeters: 1,
|
||||
overageToleranceTons: 0,
|
||||
overageToleranceMeters: 0,
|
||||
},
|
||||
train: { id: 'train-built-1' },
|
||||
},
|
||||
};
|
||||
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
|
||||
const genericRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) =>
|
||||
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
|
||||
),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
const service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
{
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue(opts.reserved),
|
||||
} as never,
|
||||
{
|
||||
findByIdWithFullGraph: jest.fn().mockResolvedValue(schedule),
|
||||
findById: jest.fn().mockResolvedValue(schedule),
|
||||
} as never,
|
||||
null as never,
|
||||
null as never,
|
||||
null as never,
|
||||
null as never,
|
||||
null as never,
|
||||
{ emitPhase: jest.fn() } as never,
|
||||
null as never,
|
||||
);
|
||||
return { service, wagonRepo };
|
||||
};
|
||||
|
||||
it('is FULL when bookings hold every physical wagon, even with loco-derived slots free', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
maxWagons: 44, // stale: the old slot cap would say 42 slots remain
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 3,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
});
|
||||
// 1T pull cap would have been exhausted long ago under the old math.
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
reserved: [reservedBooking('b1'), reservedBooking('b2')],
|
||||
});
|
||||
await expect(service.scheduleWagonUsage(scheduleId)).resolves.toEqual({
|
||||
maxWagons: 1,
|
||||
allocatedWagons: 2,
|
||||
remainingSlots: 0,
|
||||
overAllocatedBy: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
wagonTypeDimensionsFromEntity,
|
||||
} from './train-capacity.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
@@ -305,6 +306,9 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainScheduleBookingsRepository: TrainScheduleBookingsRepository,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
private readonly scheduler: SchedulerRegistry,
|
||||
// forwardRef: TrainSchedulingService injects this service back (window
|
||||
// refresh after adjust-consist), so the classes load in a cycle.
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly bookingWindowGateway: BookingWindowGateway,
|
||||
@@ -2915,7 +2919,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
? Math.ceil(booking.wagonsRequired)
|
||||
: 0;
|
||||
|
||||
// TEU-aware: two 20ft share one wagon (wagonsPerUnit = 0.5). The old fallback
|
||||
// TEU-aware: two 20ft share one wagon (half a wagon each). The old fallback
|
||||
// summed raw container QUANTITY, so 20×20ft counted as 20 wagons, not 10.
|
||||
const byLength = containerWagonsForLines(booking.bookingContainers ?? []);
|
||||
|
||||
@@ -2993,17 +2997,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep schedule.max_wagons aligned with the train's boarding limit: the
|
||||
* locomotive's length-derived slot count. The physical wagons currently in
|
||||
* the train set do NOT cap this — bookings are admitted on length/weight
|
||||
* alone and yard staff attach the wagons manually before departure.
|
||||
* Keep schedule.max_wagons aligned with the train's boarding limit. A built
|
||||
* train's limit is its physical consist — the wagon count staff marshalled
|
||||
* (and may change via adjust-consist). Only schedules WITHOUT a built train
|
||||
* fall back to the locomotive's length-derived slot count, where bookings
|
||||
* are admitted on length/weight alone and yard staff attach the wagons
|
||||
* manually before departure.
|
||||
*/
|
||||
private async syncScheduleMaxWagons(
|
||||
schedule: TrainSchedule,
|
||||
locomotive: Locomotive,
|
||||
): Promise<void> {
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const maxWagons = limits.base.wagons;
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
const maxWagons =
|
||||
physicalWagons ?? (await this.capacityLimits(locomotive)).base.wagons;
|
||||
if ((schedule.maxWagons ?? 0) !== maxWagons) {
|
||||
await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
@@ -3122,16 +3129,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* reserved bookings already use ON THEIR OWN LEGS. A booking riding only
|
||||
* Dire→Djibouti leaves the Addis→Dire edges untouched.
|
||||
*
|
||||
* The wagon axis is the locomotive's length-derived slot count only — the
|
||||
* physical wagons currently marshalled in the train set do NOT cap it.
|
||||
* Bookings are admitted on length/weight capacity and yard staff attach
|
||||
* the missing wagons manually before wagon assignment.
|
||||
* Two capacity regimes, decided by the schedule's train:
|
||||
* - Built train (Train Builder consist with physical wagons): the consist IS
|
||||
* the capacity. Wagon slots = physical wagon count; weight and length are
|
||||
* NOT re-checked here — the builder and adjust-consist already enforced the
|
||||
* locomotive's pull/length limits when the consist was assembled.
|
||||
* - No built train (legacy schedules): the locomotive's length-derived slot
|
||||
* count plus its weight/length budgets, as before — yard staff attach the
|
||||
* missing wagons manually before wagon assignment.
|
||||
*/
|
||||
private async remainingBudget(
|
||||
schedule: TrainSchedule,
|
||||
limits: TrainLimits,
|
||||
wagonDims: WagonDims,
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
limits = {
|
||||
base: {
|
||||
wagons: physicalWagons,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
}
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
@@ -3149,6 +3171,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return budget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical wagons marshalled in the schedule's built train, or null when the
|
||||
* schedule has no built train (or the consist is still empty) and the legacy
|
||||
* locomotive-derived capacity must apply. This count is what caps a built
|
||||
* train's bookings: 50 wagons coupled → 50 wagon slots, no more.
|
||||
*/
|
||||
private async builtTrainWagonCount(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<number | null> {
|
||||
const trainId = schedule.trainSet?.train?.id;
|
||||
if (!trainId) return null;
|
||||
const count = await this.dataSource
|
||||
.getRepository(Wagon)
|
||||
.count({ where: { trainId } });
|
||||
return count > 0 ? count : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon slots still boardable somewhere on the corridor (most-open edge).
|
||||
* ≤ 0 means no leg can take another booking. Slot axis ONLY — the train-wide
|
||||
@@ -3219,11 +3258,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* FULL on ANY capacity axis: out of wagon slots, or out of pull weight /
|
||||
* train length for even one more loaded wagon. The old slot-only check let
|
||||
* a weight-bound train (PW2: weight binds at 37 wagons = 3522.4T of
|
||||
* 3500+90T, slots bind at 44) cycle its booking window forever instead of
|
||||
* finalizing — 7 phantom slots kept it "not full" while nothing could board.
|
||||
* Built train: FULL when every physical wagon slot is taken — the consist is
|
||||
* the capacity, weight/length were settled at build time.
|
||||
* No built train: FULL on ANY capacity axis — out of wagon slots, or out of
|
||||
* pull weight / train length for even one more loaded wagon. The old
|
||||
* slot-only check let a weight-bound train (PW2: weight binds at 37 wagons =
|
||||
* 3522.4T of 3500+90T, slots bind at 44) cycle its booking window forever
|
||||
* instead of finalizing — 7 phantom slots kept it "not full" while nothing
|
||||
* could board.
|
||||
*/
|
||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||
const schedule =
|
||||
@@ -3232,9 +3274,53 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return this.isTrainFull(schedule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon-slot usage snapshot for staff UIs (adjust-consist dialog): the
|
||||
* schedule's slot capacity, how many slots allocated + reserved bookings
|
||||
* already hold on the busiest edge, how many are still free on the most-open
|
||||
* edge, and by how many slots the consist has been trimmed BELOW what is
|
||||
* already committed (0 when nothing is over-allocated).
|
||||
*/
|
||||
async scheduleWagonUsage(scheduleId: string): Promise<{
|
||||
maxWagons: number;
|
||||
allocatedWagons: number;
|
||||
remainingSlots: number;
|
||||
overAllocatedBy: number;
|
||||
} | null> {
|
||||
const schedule =
|
||||
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) return null;
|
||||
const capacity =
|
||||
(await this.builtTrainWagonCount(schedule)) ?? schedule.maxWagons ?? 0;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const budget = await this.remainingBudget(
|
||||
schedule,
|
||||
{
|
||||
base: {
|
||||
wagons: capacity,
|
||||
weightTons: Number.POSITIVE_INFINITY,
|
||||
lengthMeters: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
},
|
||||
wagonDims,
|
||||
);
|
||||
const tightest = budget.remainingFor(budget.fullLeg()).wagons;
|
||||
return {
|
||||
maxWagons: capacity,
|
||||
allocatedWagons: capacity - tightest,
|
||||
remainingSlots: Math.max(0, budget.maxRemaining().wagons),
|
||||
overAllocatedBy: Math.max(0, -tightest),
|
||||
};
|
||||
}
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
// Built train: the physical consist is the only capacity axis. Weight and
|
||||
// length were enforced when the consist was assembled (builder /
|
||||
// adjust-consist), so a free wagon slot means the train genuinely has room.
|
||||
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
|
||||
@@ -56,7 +56,7 @@ export function wagonsRequiredForBooking(booking: Booking, bulkWagonCapacity?: n
|
||||
}
|
||||
|
||||
// TEU-aware, ceiled once at the booking level (40ft = 1 wagon, two 20ft = 1
|
||||
// wagon). Honors containerType.wagonsPerUnit; falls back to the line's stored
|
||||
// wagon). Derived from containerType.sizeFt; falls back to the line's stored
|
||||
// fraction. Ceiling per line would over-count split 20ft lines.
|
||||
return Math.max(1, containerWagonsForLines(booking.bookingContainers ?? []));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
@@ -95,6 +98,7 @@ import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import {
|
||||
computeFleetAvailability,
|
||||
summarizeFleetWarnings,
|
||||
@@ -317,6 +321,11 @@ export class TrainSchedulingService {
|
||||
private readonly bookingNotifier: BookingNotifierService,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
private readonly configService?: ConfigService,
|
||||
// forwardRef: BookingBatchService injects this service back; @Optional so
|
||||
// existing specs that construct the service without it keep working.
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService?: BookingBatchService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -2029,6 +2038,58 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
|
||||
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
|
||||
* loaded onto the wagons allocated to the booking, so anything still in the
|
||||
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
|
||||
* booking has warehouse inventory that never made it onto a wagon (received /
|
||||
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
|
||||
* or drop the booking's wagon allocation so it rides a later train.
|
||||
*
|
||||
* Import/domestic are untouched: their cargo isn't loaded out of an origin
|
||||
* warehouse, so warehouse inventory says nothing about what's aboard.
|
||||
*
|
||||
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
|
||||
* wagon before the goods arrive is normal planning; they simply aren't aboard.
|
||||
*/
|
||||
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
|
||||
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!route) return;
|
||||
const direction = deriveTradeDirection(
|
||||
{ country: route.originCountry },
|
||||
{ country: route.destinationCountry },
|
||||
);
|
||||
if (direction !== 'EXPORT') return;
|
||||
|
||||
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = $1
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
|
||||
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -2038,6 +2099,8 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
// Export only: don't leave received cargo behind in the warehouse.
|
||||
await this.assertAllocatedCargoLoaded(scheduleId);
|
||||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
@@ -5054,6 +5117,11 @@ export class TrainSchedulingService {
|
||||
wagons.reduce((sum, w) => sum + Number(w.wagonType?.lengthMeters ?? 0), 0),
|
||||
);
|
||||
|
||||
// Wagon-slot picture for the dialog: the consist IS the schedule's booking
|
||||
// capacity, so trimming/coupling wagons moves the FULL line live.
|
||||
const wagonUsage =
|
||||
(await this.bookingBatchService?.scheduleWagonUsage(scheduleId)) ?? null;
|
||||
|
||||
const mapWagon = (wagon: Wagon) => ({
|
||||
id: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
@@ -5092,6 +5160,12 @@ export class TrainSchedulingService {
|
||||
grossTons: roundTons(cargoTons + consistTareTons),
|
||||
consistLengthMeters,
|
||||
},
|
||||
scheduleCapacity: wagonUsage
|
||||
? {
|
||||
...wagonUsage,
|
||||
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
|
||||
}
|
||||
: null,
|
||||
wagons: wagons.map((wagon) => ({
|
||||
...mapWagon(wagon),
|
||||
loaded: loadedWagonIds.has(wagon.id),
|
||||
@@ -5284,7 +5358,37 @@ export class TrainSchedulingService {
|
||||
);
|
||||
});
|
||||
|
||||
return this.getScheduleConsist(scheduleId);
|
||||
// The consist IS the schedule's booking capacity, so an edit moves the
|
||||
// FULL line: freeing slots on a FULL schedule reopens its window, taking
|
||||
// the last slot closes it. Staff may shrink below what is already
|
||||
// committed — allowed, but reported back as a warning (never silently).
|
||||
const warnings: string[] = [];
|
||||
const wasFull = schedule.bookingWindowStatus === 'FULL';
|
||||
const usage = await this.bookingBatchService?.scheduleWagonUsage(scheduleId);
|
||||
if (usage) {
|
||||
const nowFull = usage.remainingSlots <= 0;
|
||||
if (usage.overAllocatedBy > 0) {
|
||||
warnings.push(
|
||||
`The consist now has ${usage.maxWagons} wagon slot(s) but bookings already hold ` +
|
||||
`${usage.allocatedWagons} — ${usage.overAllocatedBy} wagon(s) over capacity. ` +
|
||||
'Couple more wagons or free bookings before departure.',
|
||||
);
|
||||
}
|
||||
if (wasFull && !nowFull) {
|
||||
await this.bookingBatchService?.refreshWindowStatus(scheduleId);
|
||||
warnings.push(
|
||||
`This schedule was FULL — the consist change freed ${usage.remainingSlots} wagon slot(s), ` +
|
||||
'so it is no longer FULL and can take bookings again.',
|
||||
);
|
||||
} else if (!wasFull && nowFull) {
|
||||
await this.bookingBatchService?.setWindow(scheduleId, 'FULL');
|
||||
warnings.push(
|
||||
'Every wagon slot is now taken — the schedule is FULL and stops accepting bookings.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...(await this.getScheduleConsist(scheduleId)), warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -77,7 +77,6 @@ describe('planWagonsWithStock — shortage detail', () => {
|
||||
fortyFooter.bookingContainers![0]!.containerType = {
|
||||
code: '40GP',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
} as never;
|
||||
const result = planWagonsWithStock({
|
||||
bookings: [fortyFooter],
|
||||
|
||||
@@ -106,7 +106,7 @@ describe('wagon-plan.util', () => {
|
||||
});
|
||||
|
||||
it('6×20ft containers = 3 wagon slots (2 per wagon)', () => {
|
||||
// 20ft containers have wagonsPerUnit = 0.5, so 6 * 0.5 = 3 wagons
|
||||
// 20ft containers take half a wagon each, so 6 * 0.5 = 3 wagons
|
||||
const booking = makeContainerBooking('b6x20', [{ quantity: 6, wagonsRequired: 3 }]);
|
||||
expect(sumWagonsRequired(booking)).toBe(3);
|
||||
const plan = buildContainerWagonPlan([booking], nw5);
|
||||
@@ -227,7 +227,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
||||
const line = (quantity: number, wagonsPerUnit: number, wagonsRequired?: number) => ({
|
||||
quantity,
|
||||
wagonsRequired: wagonsRequired ?? quantity * wagonsPerUnit,
|
||||
containerType: { wagonsPerUnit, sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
||||
containerType: { sizeFt: wagonsPerUnit >= 1 ? 40 : 20 },
|
||||
});
|
||||
|
||||
it('20×20ft = 10 wagons (not 20)', () => {
|
||||
@@ -266,7 +266,7 @@ describe('containerWagonsForLines — TEU-aware, ceil booking total once', () =>
|
||||
expect(containerWagonsForLines([line(21, 1)])).toBe(21);
|
||||
});
|
||||
|
||||
it('falls back to line wagonsRequired when containerType/wagonsPerUnit missing', () => {
|
||||
it('falls back to line wagonsRequired when containerType/sizeFt missing', () => {
|
||||
// No containerType relation loaded → use the stored (0.5-aware) fraction.
|
||||
expect(
|
||||
containerWagonsForLines([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { consistViolations } from './train-capacity.util';
|
||||
|
||||
@@ -61,7 +62,6 @@ export type ContainerUnitRow = {
|
||||
label: string;
|
||||
grossWeightTons: number;
|
||||
sizeFt?: number;
|
||||
wagonsPerUnit?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
containerNumber?: string | null;
|
||||
@@ -95,33 +95,28 @@ export function teuSlotsForSizeFt(sizeFt: number): number {
|
||||
return sizeFt >= 40 ? 2 : 1;
|
||||
}
|
||||
|
||||
export function containersPerWagonFromType(wagonsPerUnit: number): number {
|
||||
const wpu = Number(wagonsPerUnit);
|
||||
if (!wpu || wpu <= 0) return 1;
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
type ContainerLine = {
|
||||
quantity?: number | null;
|
||||
wagonsRequired?: number | null;
|
||||
containerType?: { wagonsPerUnit?: number | null; sizeFt?: number | null } | null;
|
||||
containerType?: { sizeFt?: number | null } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* RAW (un-ceiled) wagon fraction one container line occupies: qty × wagonsPerUnit
|
||||
* (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept fractional so
|
||||
* the BOOKING total is ceiled once — ceiling per line over-counts a booking that
|
||||
* splits its 20ft units across several lines (3×20 + 3×20 = 3 wagons, not 4).
|
||||
* RAW (un-ceiled) wagon fraction one container line occupies: qty × size-derived
|
||||
* fraction (40ft = 1, 20ft = 0.5). Two 20ft = 1.0, three 20ft = 1.5. Kept
|
||||
* fractional so the BOOKING total is ceiled once — ceiling per line over-counts a
|
||||
* booking that splits its 20ft units across several lines (3×20 + 3×20 = 3
|
||||
* wagons, not 4).
|
||||
*/
|
||||
function lineWagonsRaw(line: ContainerLine): number {
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
if (qty <= 0) return 0;
|
||||
const wpu = Number(line.containerType?.wagonsPerUnit);
|
||||
if (Number.isFinite(wpu) && wpu > 0) {
|
||||
return qty * wpu;
|
||||
const sizeFt = Number(line.containerType?.sizeFt);
|
||||
if (Number.isFinite(sizeFt) && sizeFt > 0) {
|
||||
return qty * wagonsPerUnitForSize(sizeFt);
|
||||
}
|
||||
// No wagonsPerUnit on the type: fall back to the line's stored fraction, else
|
||||
// treat the whole line as one wagon.
|
||||
// No size on the type: fall back to the line's stored fraction, else treat
|
||||
// the whole line as one wagon.
|
||||
const stored = Number(line.wagonsRequired);
|
||||
return Number.isFinite(stored) && stored > 0 ? stored : 1;
|
||||
}
|
||||
@@ -250,8 +245,7 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
|
||||
const qty = Number(line.quantity ?? 0);
|
||||
const code = line.containerType?.code ?? line.containerType?.label ?? 'Container';
|
||||
const sizeFt = Number(line.containerType?.sizeFt ?? (code.includes('40') ? 40 : 20));
|
||||
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
|
||||
const perWagon = containersPerWagonFromType(wagonsPerUnit);
|
||||
const perWagon = containersPerWagonForSize(sizeFt);
|
||||
const teuSlots = teuSlotsForSizeFt(sizeFt);
|
||||
// The REAL per-container numbers/weights entered at booking time. Unit i of
|
||||
// the line maps to units[i] (sortOrder order); the line-level number is only
|
||||
@@ -271,7 +265,6 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
|
||||
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
|
||||
grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
|
||||
sizeFt,
|
||||
wagonsPerUnit,
|
||||
containersPerWagon: perWagon,
|
||||
teuSlots,
|
||||
containerNumber:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { Company } from '../companies/entities/company.entity';
|
||||
@@ -1031,6 +1032,8 @@ export class WarehouseInventoryService {
|
||||
result.results.push({ bookingId: booking.id, status: 'FAILED', reason: 'No warehouse/yard/zone configured' });
|
||||
continue;
|
||||
}
|
||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads
|
||||
// onto a train without one. Import GRN handling is left untouched.
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
@@ -1040,6 +1043,9 @@ export class WarehouseInventoryService {
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
...(booking.tradeDirection === 'EXPORT'
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', booking.id, new Date()) }
|
||||
: {}),
|
||||
notes: allocated?.rule ? `Auto-unloaded → ${allocated.path}` : 'Auto-unloaded from arrival queue',
|
||||
});
|
||||
result.processedCount += 1;
|
||||
@@ -1060,6 +1066,14 @@ export class WarehouseInventoryService {
|
||||
/** Unload a single arrived booking into a chosen (or default) location. */
|
||||
async unloadBooking(bookingId: string, dto: UnloadBookingDto): Promise<WarehouseInventory> {
|
||||
const existing = await this.inventoryRepository.findAll({ where: { bookingId } });
|
||||
// EXPORT goods get their GRN on arrival at the warehouse — nothing loads onto
|
||||
// a train without one. Import GRN handling is left untouched.
|
||||
const [bookingRow]: Array<{ tradeDirection: string | null }> = await this.dataSource.query(
|
||||
`SELECT trade_direction AS "tradeDirection"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const isExport = bookingRow?.tradeDirection === 'EXPORT';
|
||||
|
||||
let location: DefaultLocation | null =
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
@@ -1080,6 +1094,10 @@ export class WarehouseInventoryService {
|
||||
zoneId: location.zoneId,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
// Export only, and keep an already-issued GRN rather than reissuing.
|
||||
...(isExport && !existing[0].grnNumber
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
notes: dto.notes ?? existing[0].notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(existing[0].id);
|
||||
@@ -1094,6 +1112,9 @@ export class WarehouseInventoryService {
|
||||
weight: 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt,
|
||||
...(isExport
|
||||
? { grnNumber: this.generateGrnNumber('EXPORT', bookingId, arrivedAt) }
|
||||
: {}),
|
||||
notes: dto.notes ?? 'Unloaded',
|
||||
});
|
||||
return this.findById(saved.id);
|
||||
@@ -1542,11 +1563,19 @@ export class WarehouseInventoryService {
|
||||
// their already-allocated wagons. Reuses the single-item load() machinery.
|
||||
|
||||
/** Pre-dispatch EXPORT trains that have inventory waiting to be (or already) loaded. */
|
||||
/**
|
||||
* Export flow this queue serves: booked -> paid -> received at the warehouse
|
||||
* (first-mile or self-haul) -> GRN -> loaded onto the wagons allocated to the
|
||||
* booking. Which bookings ride a train comes from the shared CTE.
|
||||
*/
|
||||
private readonly SCHEDULE_BOOKINGS_CTE = SCHEDULE_BOOKINGS_CTE;
|
||||
|
||||
async loadableTrains(): Promise<LoadableTrainRow[]> {
|
||||
const rows: Array<
|
||||
LoadableTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS "scheduleId",
|
||||
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT ts.id AS "scheduleId",
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
@@ -1554,15 +1583,15 @@ export class WarehouseInventoryService {
|
||||
dy.country AS "destinationCountry",
|
||||
ts.status AS "status",
|
||||
ts.scheduled_departure_date AS "departureTime",
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||
(SELECT count(*) FROM sched_bookings sb
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING')) AS "readyCount",
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = ts.id
|
||||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING')) AS "readyCount",
|
||||
(SELECT count(*) FROM sched_bookings sb
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = tsb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL
|
||||
ON inv.booking_id = sb.booking_id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = ts.id
|
||||
AND inv.status = 'LOADED') AS "loadedCount"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
@@ -1570,11 +1599,11 @@ export class WarehouseInventoryService {
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status = ANY($1)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM freight.train_schedule_bookings tsb2
|
||||
SELECT 1 FROM sched_bookings sb2
|
||||
JOIN freight.warehouse_inventory inv2
|
||||
ON inv2.booking_id = tsb2.booking_id AND inv2.deleted_at IS NULL
|
||||
WHERE tsb2.train_schedule_id = ts.id AND tsb2.deleted_at IS NULL
|
||||
AND inv2.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||
ON inv2.booking_id = sb2.booking_id AND inv2.deleted_at IS NULL
|
||||
WHERE sb2.schedule_id = ts.id
|
||||
AND inv2.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||
)
|
||||
ORDER BY ts.scheduled_departure_date ASC NULLS LAST`,
|
||||
[['DRAFT', 'SCHEDULED']],
|
||||
@@ -1599,22 +1628,28 @@ export class WarehouseInventoryService {
|
||||
*/
|
||||
async trainLoadableItems(scheduleId: string): Promise<TrainLoadableItemRow[]> {
|
||||
const rows: Array<Omit<TrainLoadableItemRow, 'loadable'>> = await this.dataSource.query(
|
||||
`SELECT inv.id AS "id",
|
||||
`WITH ${this.SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT inv.id AS "id",
|
||||
inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
ct.container_number AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
inv.weight AS "weight",
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)') AS "grnNumber",
|
||||
-- receive() stamps the GRN onto the row and mirrors it into the
|
||||
-- note; prefer the column and fall back for legacy/seeded rows.
|
||||
COALESCE(
|
||||
inv.grn_number,
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) AS "grnNumber",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
inv.status AS "status",
|
||||
wl.wagon_id AS "wagonId",
|
||||
wl.wagon_number AS "wagonNumber",
|
||||
wl.sequence_no AS "sequenceNo"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
@@ -1631,15 +1666,19 @@ export class WarehouseInventoryService {
|
||||
ORDER BY tsw.sequence_no ASC NULLS LAST
|
||||
LIMIT 1
|
||||
) wl ON true
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED')
|
||||
WHERE sb.schedule_id = $1
|
||||
AND inv.status IN ('RECEIVED','STORED','READY_FOR_LOADING','LOADED')
|
||||
ORDER BY wl.sequence_no ASC NULLS LAST, b.reference ASC NULLS LAST, ct.container_number ASC NULLS LAST`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
loadable: r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId),
|
||||
// Export flow: received at the warehouse -> GRN -> loaded onto its wagon.
|
||||
// The row only exists once the goods were received, so requiring a GRN and
|
||||
// an allocated wagon completes the chain.
|
||||
loadable:
|
||||
r.status === 'READY_FOR_LOADING' && Boolean(r.wagonId) && Boolean(r.grnNumber),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1692,6 +1731,9 @@ export class WarehouseInventoryService {
|
||||
if (!item) { skip('Not assigned to this train'); continue; }
|
||||
if (item.status === 'LOADED') { skip('Already loaded'); continue; }
|
||||
if (item.status !== 'READY_FOR_LOADING') { skip(`Not ready for loading (status ${item.status})`); continue; }
|
||||
// Export: the GRN is raised when the goods arrive at the warehouse, and
|
||||
// nothing rides a train without one.
|
||||
if (!item.grnNumber) { skip('No GRN — receive the goods and generate the GRN first'); continue; }
|
||||
if (!item.wagonId) { skip('No wagon allocated — allocate a wagon first'); continue; }
|
||||
|
||||
try {
|
||||
|
||||
@@ -209,7 +209,6 @@ async function ensureReferences(manager: any) {
|
||||
code: '40FT',
|
||||
label: '40FT',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
|
||||
@@ -118,7 +118,6 @@ async function main() {
|
||||
code: '40FT',
|
||||
label: '40FT',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
@@ -115,7 +116,7 @@ async function main() {
|
||||
reeferQuantity: 0,
|
||||
vgmPerUnitTons: Number((weightKg / containerQuantity / 1000).toFixed(3)),
|
||||
totalVgmTons: Number((weightKg / 1000).toFixed(3)),
|
||||
wagonsRequired: Math.max(1, containerQuantity * Number(containerType!.wagonsPerUnit ?? 1)),
|
||||
wagonsRequired: Math.max(1, containerQuantity * wagonsPerUnitForSize(containerType!.sizeFt)),
|
||||
isOverweight: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '../modules/companies/entities/company.entity';
|
||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||
import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
@@ -223,7 +224,6 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
await manager.getRepository(ContainerType).upsert(
|
||||
CONTAINER_TYPES.map((containerType, index) => ({
|
||||
...containerType,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
@@ -276,7 +276,7 @@ export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
}
|
||||
|
||||
const wagonsRequired =
|
||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||
Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
|
||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||
|
||||
await manager.getRepository(Booking).upsert(
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ServiceType } from "../modules/rule-engine/entities/service-type.entity
|
||||
import { Yard } from "../modules/rule-engine/entities/yard.entity";
|
||||
import { WagonType } from "../modules/wagon-types/entities/wagon-type.entity";
|
||||
import { CargoType } from "../modules/rule-engine/entities/cargo-type.entity";
|
||||
import { wagonsPerUnitForSize } from "../modules/rule-engine/container-type.util";
|
||||
import { ContainerType } from "../modules/rule-engine/entities/container-type.entity";
|
||||
import { Container } from "../modules/container-management/entities/container.entity";
|
||||
import { Route } from "../modules/routes/entities/route.entity";
|
||||
@@ -300,7 +301,6 @@ export class DemoBookingsSeeder {
|
||||
await manager.getRepository(ContainerType).upsert(
|
||||
CONTAINER_TYPES.map((containerType, index) => ({
|
||||
...containerType,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
@@ -400,7 +400,7 @@ export class DemoBookingsSeeder {
|
||||
.getRepository(BookingContainer)
|
||||
.delete({ bookingId: booking.id });
|
||||
const wagonsRequired =
|
||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||
Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
|
||||
|
||||
await manager.getRepository(BookingContainer).insert({
|
||||
id: randomUUID(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||
import { wagonsPerUnitForSize } from '../modules/rule-engine/container-type.util';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
@@ -145,7 +146,6 @@ export class PaidImportExportMileDemoSeeder {
|
||||
await manager.getRepository(ContainerType).upsert(
|
||||
CONTAINER_TYPES.map((containerType, index) => ({
|
||||
...containerType,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
@@ -199,7 +199,7 @@ export class PaidImportExportMileDemoSeeder {
|
||||
|
||||
const isImport = demoBooking.tradeDirection === 'IMPORT';
|
||||
const wagonsRequired =
|
||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||
Number(demoBooking.quantity) * wagonsPerUnitForSize(containerType.sizeFt);
|
||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||
|
||||
await manager.getRepository(Booking).upsert(
|
||||
|
||||
@@ -115,7 +115,6 @@ export class PricingDataSeeder {
|
||||
code: "20FT",
|
||||
label: "20FT Standard",
|
||||
sizeFt: 20,
|
||||
wagonsPerUnit: 0.5,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
@@ -125,7 +124,6 @@ export class PricingDataSeeder {
|
||||
code: "40FT",
|
||||
label: "40FT Standard",
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
@@ -135,7 +133,6 @@ export class PricingDataSeeder {
|
||||
code: "20FT_REEFER",
|
||||
label: "20FT Reefer",
|
||||
sizeFt: 20,
|
||||
wagonsPerUnit: 0.5,
|
||||
isReefer: true,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
@@ -145,7 +142,6 @@ export class PricingDataSeeder {
|
||||
code: "40FT_REEFER",
|
||||
label: "40FT Reefer",
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: true,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Settings2,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
@@ -142,14 +143,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Staff",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
icon: <Building2 />,
|
||||
},
|
||||
{
|
||||
label: "Contracts",
|
||||
@@ -157,6 +153,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
// Operations hub: clearance-document review for contracts WITHOUT
|
||||
// customs clearing (contract-level for one-time, per-booking for general).
|
||||
{
|
||||
@@ -165,11 +166,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
href: "/dashboard/customers",
|
||||
icon: <Building2 />,
|
||||
},
|
||||
{
|
||||
label: "Payments",
|
||||
href: "/dashboard/payments",
|
||||
@@ -186,183 +182,185 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Operations",
|
||||
// title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
label: "Operations",
|
||||
icon: <Settings />,
|
||||
children: [
|
||||
{
|
||||
label: "Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: [
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
],
|
||||
},
|
||||
// {
|
||||
// label: "Shipment Requests",
|
||||
// href: "/dashboard/shipment-requests",
|
||||
// icon: <Send />,
|
||||
// permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
// },
|
||||
// Operations Path A queue: per-booking self-clearance review for
|
||||
// GENERAL non-customs booking instances (and legacy self-clear bookings).
|
||||
// {
|
||||
// label: "Self-Clearance Review",
|
||||
// href: "/dashboard/contracts/ops-clearance",
|
||||
// icon: <ShieldCheck />,
|
||||
// permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
// },
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "First Mile",
|
||||
href: "/dashboard/operations/first-mile",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.firstMile.view,
|
||||
},
|
||||
{
|
||||
label: "Last Mile",
|
||||
href: "/dashboard/operations/last-mile",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.lastMile.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Shipment Requests",
|
||||
href: "/dashboard/shipment-requests",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
},
|
||||
// Operations Path A queue: per-booking self-clearance review for
|
||||
// GENERAL non-customs booking instances (and legacy self-clear bookings).
|
||||
{
|
||||
label: "Self-Clearance Review",
|
||||
href: "/dashboard/contracts/ops-clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.opsClearanceReview,
|
||||
},
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling-v2",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "Batch Board",
|
||||
href: "/dashboard/operations/batch-board",
|
||||
icon: <LayoutGrid />,
|
||||
permission: FREIGHT_PERMS.trainScheduling.view,
|
||||
},
|
||||
{
|
||||
label: "First Mile",
|
||||
href: "/dashboard/operations/first-mile",
|
||||
label: "Fleet Management",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.firstMile.view,
|
||||
},
|
||||
{
|
||||
label: "Last Mile",
|
||||
href: "/dashboard/operations/last-mile",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.lastMile.view,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fleet Management",
|
||||
items: [
|
||||
{
|
||||
label: "Fleet Dashboard",
|
||||
href: "/dashboard/fleet-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.fleetDashboard.view,
|
||||
},
|
||||
{
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
label: "Fleet Dashboard",
|
||||
href: "/dashboard/fleet-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.fleetDashboard.view,
|
||||
},
|
||||
{
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
// href: "/dashboard/wagon-types",
|
||||
// icon: <Boxes />,
|
||||
// },
|
||||
{
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
// {
|
||||
// label: "Wagon types",
|
||||
// href: "/dashboard/wagon-types",
|
||||
// icon: <Boxes />,
|
||||
// },
|
||||
{
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
href: "/dashboard/vehicles",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.vehicles.view,
|
||||
},
|
||||
{
|
||||
label: "Drivers",
|
||||
href: "/dashboard/drivers",
|
||||
icon: <Users />,
|
||||
permission: FREIGHT_PERMS.drivers.view,
|
||||
},
|
||||
{
|
||||
label: "Track Vehicles",
|
||||
href: "/dashboard/tracking",
|
||||
icon: <MapPin />,
|
||||
permission: FREIGHT_PERMS.tracking.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Purchases",
|
||||
href: "/dashboard/fuel-purchases",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Analytics",
|
||||
href: "/dashboard/fuel-stats",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Maintenance",
|
||||
href: "/dashboard/maintenance",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Work Orders",
|
||||
href: "/dashboard/work-orders",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Compliance & Alerts",
|
||||
href: "/dashboard/compliance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Incidents",
|
||||
href: "/dashboard/incidents",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Procurement",
|
||||
href: "/dashboard/procurement",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Financial Reports",
|
||||
href: "/dashboard/financial-reports",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.fleetReports.view,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
// href: "/dashboard/containers",
|
||||
// icon: <Container />,
|
||||
// },
|
||||
// {
|
||||
// label: "Cargoes",
|
||||
// href: "/dashboard/cargoes",
|
||||
// icon: <Package />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
href: "/dashboard/vehicles",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.vehicles.view,
|
||||
},
|
||||
{
|
||||
label: "Drivers",
|
||||
href: "/dashboard/drivers",
|
||||
icon: <Users />,
|
||||
permission: FREIGHT_PERMS.drivers.view,
|
||||
},
|
||||
{
|
||||
label: "Track Vehicles",
|
||||
href: "/dashboard/tracking",
|
||||
icon: <MapPin />,
|
||||
permission: FREIGHT_PERMS.tracking.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Purchases",
|
||||
href: "/dashboard/fuel-purchases",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Fuel Analytics",
|
||||
href: "/dashboard/fuel-stats",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fuel.view,
|
||||
},
|
||||
{
|
||||
label: "Maintenance",
|
||||
href: "/dashboard/maintenance",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Work Orders",
|
||||
href: "/dashboard/work-orders",
|
||||
icon: <SlidersHorizontal />,
|
||||
permission: FREIGHT_PERMS.maintenance.view,
|
||||
},
|
||||
{
|
||||
label: "Compliance & Alerts",
|
||||
href: "/dashboard/compliance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Incidents",
|
||||
href: "/dashboard/incidents",
|
||||
icon: <FileText />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Procurement",
|
||||
href: "/dashboard/procurement",
|
||||
icon: <Package />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
},
|
||||
{
|
||||
label: "Financial Reports",
|
||||
href: "/dashboard/financial-reports",
|
||||
icon: <Wallet />,
|
||||
permission: FREIGHT_PERMS.fleetReports.view,
|
||||
},
|
||||
// {
|
||||
// label: "Containers",
|
||||
// href: "/dashboard/containers",
|
||||
// icon: <Container />,
|
||||
// },
|
||||
// {
|
||||
// label: "Cargoes",
|
||||
// href: "/dashboard/cargoes",
|
||||
// icon: <Package />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Imports",
|
||||
href: "/dashboard/import-warehouse",
|
||||
@@ -437,35 +435,37 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Warehouse Management",
|
||||
items: [
|
||||
{
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
label: "Warehouse Management",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
children: [
|
||||
{
|
||||
label: "Warehouse Dashboard",
|
||||
href: "/dashboard/warehouse-dashboard",
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "Warehouses",
|
||||
href: "/dashboard/warehouses",
|
||||
icon: <Container />,
|
||||
},
|
||||
{
|
||||
label: "Allocation & Fees",
|
||||
href: "/dashboard/warehouse-rules",
|
||||
icon: <SlidersHorizontal />,
|
||||
},
|
||||
{
|
||||
label: "Fee Invoices",
|
||||
href: "/dashboard/warehouse-fee-invoices",
|
||||
icon: <Wallet />,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Administration",
|
||||
title: "Freight configuration",
|
||||
mutedTitle: true,
|
||||
items: [
|
||||
{
|
||||
label: "File settings",
|
||||
@@ -485,12 +485,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <ScrollText />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Freight configuration",
|
||||
mutedTitle: true,
|
||||
items: [
|
||||
{
|
||||
label: "Configuration",
|
||||
href: "/dashboard/configuration",
|
||||
@@ -513,6 +507,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <SlidersHorizontal />,
|
||||
children: getCategorySidebarChildren("rules"),
|
||||
},
|
||||
|
||||
{
|
||||
label: "Staff",
|
||||
href: "/user-management",
|
||||
icon: <Users />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -599,7 +599,10 @@ const findActiveSidebarLabel = (
|
||||
): string | undefined => {
|
||||
const path = pathname.toLowerCase();
|
||||
const candidates = flattenSidebarItems(sections)
|
||||
.map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() }))
|
||||
.map(({ href, label }) => ({
|
||||
label,
|
||||
href: href.split("?")[0].toLowerCase(),
|
||||
}))
|
||||
.sort((a, b) => b.href.length - a.href.length);
|
||||
|
||||
return candidates.find(
|
||||
@@ -674,10 +677,7 @@ const App = () => {
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
{/* <Route path="/um/*" element={<UserManagementHostPage />} /> */}
|
||||
<Route
|
||||
path="um/set-password"
|
||||
element={<SetPassword />}
|
||||
/>
|
||||
<Route path="um/set-password" element={<SetPassword />} />
|
||||
<Route path="/callback" element={<FaydaCallbackPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -280,13 +280,20 @@ export function InvoiceStatusBadge({
|
||||
* Transitions: pending → approve / reject-with-note | rejected → approve (override) |
|
||||
* active → suspend | suspended → reactivate/blacklist | blacklisted → reinstate.
|
||||
* Rejecting captures a note the customer sees so they can fix and reapply.
|
||||
*
|
||||
* `locked` (customer hasn't submitted onboarding) withholds the review decision
|
||||
* only — there's no application to judge yet, and the API rejects the call
|
||||
* regardless (setCompanyProfileStatus). Suspend/blacklist/reinstate stay live so
|
||||
* an already-active profile is still managable.
|
||||
*/
|
||||
export function ProfileApprovalActions({
|
||||
profileId,
|
||||
status,
|
||||
locked = false,
|
||||
}: {
|
||||
profileId: string;
|
||||
status: ProfileStatus;
|
||||
locked?: boolean;
|
||||
}) {
|
||||
const { mutate, isPending } = useMutation(
|
||||
api.customers.setProfileStatus.mutationOptions(),
|
||||
@@ -346,6 +353,18 @@ export function ProfileApprovalActions({
|
||||
</Modal>
|
||||
);
|
||||
|
||||
// Pending/rejected are the two states awaiting a reviewer's decision — the
|
||||
// exact pair the API gates on until the customer submits.
|
||||
if (locked && (status === "pending" || status === "rejected")) {
|
||||
return (
|
||||
<Tooltip label="Available once the customer submits their onboarding application">
|
||||
<Text size="xs" c="dimmed" fs="italic">
|
||||
Awaiting submission
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -92,7 +92,9 @@ const FreightSidebar = ({
|
||||
walk(item.children, key);
|
||||
});
|
||||
};
|
||||
sections.forEach((section) => walk(section.items, section.title));
|
||||
sections.forEach((section, i) =>
|
||||
walk(section.items, section?.title ?? "" + i++),
|
||||
);
|
||||
return acc;
|
||||
}, [sections, isHrefActive, branchActive]);
|
||||
|
||||
@@ -126,6 +128,7 @@ const FreightSidebar = ({
|
||||
opened={isOpen}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={() => toggle(key)}
|
||||
childrenOffset="sm"
|
||||
rightSection={
|
||||
<Box
|
||||
component="span"
|
||||
@@ -142,7 +145,7 @@ const FreightSidebar = ({
|
||||
size={16}
|
||||
className="text-edr-muted transition-transform duration-200"
|
||||
style={{
|
||||
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
|
||||
transform: isOpen ? "rotate(-180deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -166,6 +169,7 @@ const FreightSidebar = ({
|
||||
active={active}
|
||||
component={Link}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={onClose}
|
||||
to={item.href!}
|
||||
/>
|
||||
);
|
||||
@@ -177,19 +181,21 @@ const FreightSidebar = ({
|
||||
() =>
|
||||
sections.map((section) => (
|
||||
<Box key={section.title}>
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={"text-edr-muted!"}
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
{section.title && (
|
||||
<Text
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={"text-edr-muted!"}
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<Stack gap={2}>
|
||||
{section.items.map((item, i) =>
|
||||
renderItem(item, itemKey(section.title, item, i)),
|
||||
renderItem(item, itemKey(section.title ?? "" + i, item, i)),
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -257,7 +263,7 @@ const FreightSidebar = ({
|
||||
px="sm"
|
||||
pb="md"
|
||||
>
|
||||
<Stack gap="lg">{renderedSections}</Stack>
|
||||
<Stack gap="md">{renderedSections}</Stack>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface SidebarItem {
|
||||
|
||||
export interface SidebarSection {
|
||||
/** Section label shown above a group of nav items (e.g. "Main menu"). */
|
||||
title: string;
|
||||
title?: string;
|
||||
items: SidebarItem[];
|
||||
/** When true, section title uses muted grey instead of dark text. */
|
||||
mutedTitle?: boolean;
|
||||
|
||||
@@ -65,7 +65,10 @@ export default function AdjustConsistModal({
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds).
|
||||
// Live projection: gross = cargo + tare of (consist − trims + adds), plus
|
||||
// the schedule's wagon-slot picture — the consist IS the booking capacity
|
||||
// (weight/length only bind while assembling the consist), so trims/adds
|
||||
// move the FULL line in real time.
|
||||
const projection = useMemo(() => {
|
||||
if (!data) return null;
|
||||
const removed = new Set(removeIds);
|
||||
@@ -79,8 +82,11 @@ export default function AdjustConsistModal({
|
||||
const tare = keptTare + addedWagons.reduce((s, w) => s + tareOf(w), 0);
|
||||
const length = keptLength + addedWagons.reduce((s, w) => s + lengthOf(w), 0);
|
||||
const gross = round2(data.totals.cargoTons + tare);
|
||||
const wagonCount = data.totals.wagonCount - removeIds.length + addIds.length;
|
||||
const cap = data.scheduleCapacity;
|
||||
const freeSlots = cap ? wagonCount - cap.allocatedWagons : null;
|
||||
return {
|
||||
wagonCount: data.totals.wagonCount - removeIds.length + addIds.length,
|
||||
wagonCount,
|
||||
tare: round2(tare),
|
||||
gross,
|
||||
length: round2(length),
|
||||
@@ -93,16 +99,33 @@ export default function AdjustConsistModal({
|
||||
overWeight: data.limits.pullCapTons > 0 && gross > data.limits.pullCapTons,
|
||||
overLength:
|
||||
data.limits.lengthCapMeters > 0 && length > data.limits.lengthCapMeters,
|
||||
slots:
|
||||
cap && freeSlots != null
|
||||
? {
|
||||
allocated: cap.allocatedWagons,
|
||||
free: freeSlots,
|
||||
pct:
|
||||
wagonCount > 0
|
||||
? Math.round((cap.allocatedWagons / wagonCount) * 100)
|
||||
: null,
|
||||
isFullNow: cap.bookingWindowStatus === "FULL",
|
||||
willBeFull: freeSlots <= 0,
|
||||
overAllocated: freeSlots < 0,
|
||||
willReopen: cap.bookingWindowStatus === "FULL" && freeSlots > 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}, [data, removeIds, addIds]);
|
||||
|
||||
const hasChanges = removeIds.length > 0 || addIds.length > 0;
|
||||
|
||||
const toggle = (setter: typeof setRemoveIds) => (id: string, checked: boolean) =>
|
||||
setter((prev) => (checked ? [...prev, id] : prev.filter((x) => x !== id)));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!removeIds.length && !addIds.length) return;
|
||||
try {
|
||||
await adjust.mutateAsync({
|
||||
const result = await adjust.mutateAsync({
|
||||
scheduleId,
|
||||
payload: {
|
||||
...(addIds.length ? { addWagonIds: addIds } : {}),
|
||||
@@ -114,6 +137,18 @@ export default function AdjustConsistModal({
|
||||
removeIds.length && addIds.length ? ", " : ""
|
||||
}${addIds.length ? `${addIds.length} added` : ""}`,
|
||||
});
|
||||
// Schedule-impact warnings from the API: window reopened / now FULL /
|
||||
// consist trimmed below what bookings already hold.
|
||||
for (const warning of result.warnings ?? []) {
|
||||
toast({
|
||||
title: "Schedule capacity",
|
||||
description: warning,
|
||||
duration: 8000,
|
||||
...(warning.includes("over capacity")
|
||||
? { variant: "destructive" as const }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
setRemoveIds([]);
|
||||
setAddIds([]);
|
||||
} catch (err) {
|
||||
@@ -169,8 +204,57 @@ export default function AdjustConsistModal({
|
||||
over={projection?.overLength ?? false}
|
||||
/>
|
||||
</Grid.Col>
|
||||
{projection?.slots ? (
|
||||
<Grid.Col span={12}>
|
||||
<LimitGauge
|
||||
label="Booking slots — the consist is the schedule's capacity"
|
||||
detail={`${projection.slots.allocated} of ${projection.wagonCount} projected wagon slot(s) held by bookings${
|
||||
projection.slots.free > 0
|
||||
? ` — ${projection.slots.free} free`
|
||||
: projection.slots.free === 0
|
||||
? " — none free (FULL)"
|
||||
: ""
|
||||
}`}
|
||||
pct={projection.slots.pct}
|
||||
over={projection.slots.overAllocated}
|
||||
/>
|
||||
</Grid.Col>
|
||||
) : null}
|
||||
</Grid>
|
||||
|
||||
{projection?.slots?.isFullNow && !hasChanges ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
This schedule is FULL — all {projection.wagonCount} wagon slots are
|
||||
taken. You can still edit the train: coupling wagons adds capacity
|
||||
and reopens booking; trimming free wagons keeps it FULL.
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasChanges && projection?.slots?.overAllocated ? (
|
||||
<Alert color="red" icon={<AlertTriangle size={16} />}>
|
||||
This change leaves {-projection.slots.free} booked wagon(s) without
|
||||
a slot — bookings already hold {projection.slots.allocated} of the{" "}
|
||||
{projection.wagonCount} remaining. You can apply it, but couple
|
||||
wagons back or free bookings before departure.
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasChanges &&
|
||||
projection?.slots &&
|
||||
!projection.slots.overAllocated &&
|
||||
projection.slots.willBeFull &&
|
||||
!projection.slots.isFullNow ? (
|
||||
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
|
||||
This change takes the last free wagon slot — the schedule becomes
|
||||
FULL and stops accepting bookings.
|
||||
</Alert>
|
||||
) : null}
|
||||
{hasChanges && projection?.slots?.willReopen ? (
|
||||
<Alert color="blue" icon={<AlertTriangle size={16} />}>
|
||||
This schedule is currently FULL — applying frees{" "}
|
||||
{projection.slots.free} wagon slot(s) and reopens its booking
|
||||
window.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<Stack gap="xs">
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { ContainerUnitRow } from '@/types/trainScheduling';
|
||||
function makeUnits(containerType: string, sizeFt: number, quantity: number): ContainerUnitRow[] {
|
||||
const units: ContainerUnitRow[] = [];
|
||||
const containersPerWagon = sizeFt >= 40 ? 1 : 2;
|
||||
const wagonsPerUnit = sizeFt >= 40 ? 1 : 0.5;
|
||||
|
||||
for (let i = 0; i < quantity; i++) {
|
||||
units.push({
|
||||
@@ -18,7 +17,6 @@ function makeUnits(containerType: string, sizeFt: number, quantity: number): Con
|
||||
label: `${containerType} ${i + 1}/${quantity}`,
|
||||
grossWeightTons: 25,
|
||||
sizeFt,
|
||||
wagonsPerUnit,
|
||||
containersPerWagon,
|
||||
teuSlots: sizeFt >= 40 ? 2 : 1,
|
||||
});
|
||||
|
||||
@@ -61,7 +61,6 @@ interface RefContainerType {
|
||||
name: string;
|
||||
code: string;
|
||||
is_reefer?: boolean;
|
||||
wagons_per_unit?: number;
|
||||
}
|
||||
interface RefContainerGroup {
|
||||
size: string;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
Download,
|
||||
Eye,
|
||||
FileText,
|
||||
Hourglass,
|
||||
IdCard,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
@@ -60,6 +62,7 @@ import type {
|
||||
CustomerDocument,
|
||||
CustomerPayment,
|
||||
} from "@/types/customer";
|
||||
import { hasSubmittedOnboarding, isOnboardingDraft } from "@/types/customer";
|
||||
import type { Invoice } from "@/types/invoice";
|
||||
import {
|
||||
DataTable,
|
||||
@@ -166,6 +169,13 @@ export default function CustomerDetailPage() {
|
||||
);
|
||||
const paidCurrency = payments[0]?.currency ?? "ETB";
|
||||
|
||||
// The company row is created on the wizard's first click, so a draft reaches
|
||||
// this page with a placeholder name/TIN. `stillOnboarding` drives the banner
|
||||
// and badge; `canReview` gates the approve/reject buttons and mirrors the
|
||||
// API's rule exactly, so no button is offered that the server would reject.
|
||||
const stillOnboarding = company ? isOnboardingDraft(company) : false;
|
||||
const canReview = company ? hasSubmittedOnboarding(company) : true;
|
||||
|
||||
const profileColumns: ColumnDef<CompanyProfile>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -273,11 +283,12 @@ export default function CustomerDetailPage() {
|
||||
<ProfileApprovalActions
|
||||
profileId={row.original.id}
|
||||
status={row.original.status}
|
||||
locked={!canReview}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[view],
|
||||
[view, canReview],
|
||||
);
|
||||
|
||||
const bookingColumns: ColumnDef<CustomerBooking>[] = useMemo(
|
||||
@@ -602,7 +613,13 @@ export default function CustomerDetailPage() {
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
{stillOnboarding ? (
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
Onboarding in progress
|
||||
</Badge>
|
||||
) : (
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
)}
|
||||
<ChangeRequestPendingBadge companyId={company.id} />
|
||||
</Group>
|
||||
}
|
||||
@@ -631,6 +648,21 @@ export default function CustomerDetailPage() {
|
||||
{/* OVERVIEW */}
|
||||
<Tabs.Panel value="overview" pt="lg">
|
||||
<Stack gap="lg">
|
||||
{stillOnboarding && (
|
||||
<Alert
|
||||
color="gray"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<Hourglass size={18} />}
|
||||
title="This customer hasn't submitted their application yet"
|
||||
>
|
||||
They're still filling in the onboarding wizard, so the details
|
||||
below are an unfinished draft — the company name and TIN are
|
||||
placeholders until they reach those steps. Role profiles become
|
||||
reviewable once the application is submitted.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ChangeRequestReview company={company} />
|
||||
|
||||
<KpiStrip
|
||||
@@ -642,10 +674,16 @@ export default function CustomerDetailPage() {
|
||||
color: "edr-green",
|
||||
},
|
||||
{
|
||||
label: "Pending approval",
|
||||
value: company.companyProfiles.filter(
|
||||
(p) => p.status === "pending",
|
||||
).length,
|
||||
// A draft's profiles are all `pending` by construction, which
|
||||
// would read as a review backlog that doesn't exist yet.
|
||||
label: stillOnboarding
|
||||
? "Awaiting submission"
|
||||
: "Pending approval",
|
||||
value: stillOnboarding
|
||||
? "—"
|
||||
: company.companyProfiles.filter(
|
||||
(p) => p.status === "pending",
|
||||
).length,
|
||||
icon: IdCard,
|
||||
color: "yellow",
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Hourglass,
|
||||
Mail,
|
||||
Phone,
|
||||
RefreshCw,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import type { Company, CompanyStatus } from "@/types/customer";
|
||||
import { isOnboardingDraft } from "@/types/customer";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
@@ -43,22 +45,39 @@ import {
|
||||
type ColumnDef,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* The list's segmented views. "Pending approval" means submitted-and-awaiting-
|
||||
* review, so it excludes drafts — a company row exists from the onboarding
|
||||
* wizard's first click and would otherwise pad the review queue. Those drafts
|
||||
* get their own view instead of disappearing, so staff can still chase them.
|
||||
*/
|
||||
type CustomerView = "all" | "pending" | "onboarding" | "active";
|
||||
|
||||
const VIEW_FILTERS: Record<
|
||||
CustomerView,
|
||||
{ status?: CompanyStatus; onboardingCompleted?: boolean }
|
||||
> = {
|
||||
all: {},
|
||||
pending: { status: "pending", onboardingCompleted: true },
|
||||
onboarding: { onboardingCompleted: false },
|
||||
active: { status: "active" },
|
||||
};
|
||||
|
||||
export default function CustomersPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [query, setQuery] = useState("");
|
||||
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||
// "" = all; otherwise a CompanyStatus to narrow the list (e.g. pending review).
|
||||
const [statusFilter, setStatusFilter] = useState<"" | CompanyStatus>("");
|
||||
const [view, setView] = useState<CustomerView>("all");
|
||||
|
||||
const filter = useMemo(
|
||||
() => ({
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
search: debouncedQuery,
|
||||
status: statusFilter || undefined,
|
||||
...VIEW_FILTERS[view],
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||
[pagination.pageIndex, pagination.pageSize, debouncedQuery, view],
|
||||
);
|
||||
|
||||
const { data: stats } = useQuery(api.customers.stats.queryOptions({ input: {} }));
|
||||
@@ -114,6 +133,17 @@ export default function CustomersPage() {
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
// A draft's profiles are all `pending` by construction, so the
|
||||
// "N pending" review hint would be a lie until they submit.
|
||||
if (isOnboardingDraft(row.original)) {
|
||||
return (
|
||||
<Tooltip label="Customer is still filling in the onboarding wizard">
|
||||
<Badge color="gray" variant="light" size="sm" radius="sm">
|
||||
Onboarding
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const pending = (row.original.companyProfiles ?? []).filter(
|
||||
(p) => p.status === "pending",
|
||||
).length;
|
||||
@@ -206,6 +236,12 @@ export default function CustomersPage() {
|
||||
{ label: "Companies", value: stats?.total ?? "—", icon: Users, color: "edr-green" },
|
||||
{ label: "Active", value: stats?.active ?? "—", icon: CheckCircle2, color: "edr-green" },
|
||||
{ label: "Pending", value: stats?.pending ?? "—", icon: Clock, color: "yellow" },
|
||||
{
|
||||
label: "Onboarding",
|
||||
value: stats?.onboarding ?? "—",
|
||||
icon: Hourglass,
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
label: "Blacklisted",
|
||||
value: stats?.blacklisted ?? "—",
|
||||
@@ -243,14 +279,15 @@ export default function CustomersPage() {
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={statusFilter || "all"}
|
||||
value={view}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v === "all" ? "" : (v as CompanyStatus));
|
||||
setView(v as CustomerView);
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
data={[
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Pending approval", value: "pending" },
|
||||
{ label: "Onboarding", value: "onboarding" },
|
||||
{ label: "Active", value: "active" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -384,7 +384,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
label: "Max wagon count",
|
||||
type: "number",
|
||||
required: true,
|
||||
description: "Ceiling per type: WAGON 50 · CURRENCY 35 · CUSTOMS 15",
|
||||
description: "No upper limit — must be at least the min wagon count",
|
||||
},
|
||||
{ name: "scorePoints", label: "Score points", type: "number", required: true },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
/**
|
||||
* Client mirror of the backend's contiguous-range rules for priority configs
|
||||
* (see PriorityConfigsService.assertNoRangeCollision): ranges per type — per
|
||||
* currency for CURRENCY — run 1..cap with no gaps and no overlaps, so the next
|
||||
* range always starts at the lowest uncovered wagon count. The backend
|
||||
* re-validates on submit AND on approval; this only drives the form prefill.
|
||||
* currency for CURRENCY — run from 1 with no gaps and no overlaps, so the next
|
||||
* range always starts at the lowest uncovered wagon count. There is no upper
|
||||
* ceiling. The backend re-validates on submit AND on approval; this only
|
||||
* drives the form prefill.
|
||||
*/
|
||||
|
||||
export type PriorityRuleType = "WAGON" | "CURRENCY" | "CUSTOMS";
|
||||
|
||||
/** Hard ceiling of each type's chain — keep in sync with the API's RANGE_CAPS. */
|
||||
export const PRIORITY_RANGE_CAPS: Record<PriorityRuleType, number> = {
|
||||
WAGON: 50,
|
||||
CURRENCY: 35,
|
||||
CUSTOMS: 15,
|
||||
};
|
||||
|
||||
export interface PriorityRangeRule {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
@@ -23,10 +17,13 @@ export interface PriorityRangeRule {
|
||||
maxWagonCount?: unknown;
|
||||
}
|
||||
|
||||
const PRIORITY_RULE_TYPES: PriorityRuleType[] = ["WAGON", "CURRENCY", "CUSTOMS"];
|
||||
|
||||
/**
|
||||
* Where the next range for `type` (+`currency`) must start, excluding
|
||||
* `excludeId` (the rule being edited). Null when the chain already covers
|
||||
* 1..cap — no further rule fits.
|
||||
* `excludeId` (the rule being edited). Null only when `type` is not yet a
|
||||
* known priority rule type — the chain itself is unbounded, so a next start
|
||||
* always exists.
|
||||
*/
|
||||
export function nextPriorityRangeStart(
|
||||
rules: PriorityRangeRule[],
|
||||
@@ -34,8 +31,7 @@ export function nextPriorityRangeStart(
|
||||
currency: string | null | undefined,
|
||||
excludeId?: string,
|
||||
): number | null {
|
||||
const cap = PRIORITY_RANGE_CAPS[type as PriorityRuleType];
|
||||
if (!cap) return null;
|
||||
if (!PRIORITY_RULE_TYPES.includes(type as PriorityRuleType)) return null;
|
||||
|
||||
const scoped = rules
|
||||
.filter(
|
||||
@@ -56,5 +52,5 @@ export function nextPriorityRangeStart(
|
||||
if (r.min > next) break; // gap before this rule — fill it first
|
||||
next = Math.max(next, r.max + 1);
|
||||
}
|
||||
return next > cap ? null : next;
|
||||
return next;
|
||||
}
|
||||
|
||||
@@ -185,6 +185,7 @@ import { trainService, type Train } from "./trains.service";
|
||||
import {
|
||||
trainBuilderService,
|
||||
type AdjustConsistPayload,
|
||||
type AdjustConsistResult,
|
||||
type AvailableTrain,
|
||||
type BuildTrainPayload,
|
||||
type BuiltTrainListFilters,
|
||||
@@ -338,7 +339,7 @@ export const api = {
|
||||
|
||||
adjustConsist: endpoint<
|
||||
{ scheduleId: string; payload: AdjustConsistPayload },
|
||||
ScheduleConsist
|
||||
AdjustConsistResult
|
||||
>(
|
||||
"train-scheduling",
|
||||
"adjust-consist",
|
||||
|
||||
@@ -235,6 +235,18 @@ export interface ScheduleConsist {
|
||||
occurredAt: string;
|
||||
}>;
|
||||
editable: boolean;
|
||||
/**
|
||||
* Wagon-slot picture of the schedule: the consist IS the booking capacity
|
||||
* (weight/length only bind while building the consist), so the dialog can
|
||||
* project FULL / reopen / over-allocation live. Null on legacy schedules.
|
||||
*/
|
||||
scheduleCapacity: {
|
||||
maxWagons: number;
|
||||
allocatedWagons: number;
|
||||
remainingSlots: number;
|
||||
overAllocatedBy: number;
|
||||
bookingWindowStatus: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface AdjustConsistPayload {
|
||||
@@ -242,6 +254,9 @@ export interface AdjustConsistPayload {
|
||||
removeWagonIds?: string[];
|
||||
}
|
||||
|
||||
/** Adjust response: fresh consist + schedule-impact warnings to surface. */
|
||||
export type AdjustConsistResult = ScheduleConsist & { warnings: string[] };
|
||||
|
||||
export const trainBuilderService = {
|
||||
list: (filters: BuiltTrainListFilters = {}) =>
|
||||
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
|
||||
@@ -275,7 +290,7 @@ export const trainBuilderService = {
|
||||
apiClient.get<ScheduleConsist>(`/train-scheduling/schedules/${scheduleId}/consist`),
|
||||
/** Permanently trim/add wagons on the schedule's built train. */
|
||||
adjustConsist: (scheduleId: string, payload: AdjustConsistPayload) =>
|
||||
apiClient.post<ScheduleConsist>(
|
||||
apiClient.post<AdjustConsistResult>(
|
||||
`/train-scheduling/schedules/${scheduleId}/adjust-consist`,
|
||||
payload,
|
||||
),
|
||||
|
||||
@@ -146,10 +146,38 @@ export interface Company {
|
||||
website?: string | null;
|
||||
attributes?: Record<string, unknown> | null;
|
||||
companyProfiles: CompanyProfile[];
|
||||
/**
|
||||
* Whether the customer submitted their onboarding application. A company row
|
||||
* is created on the wizard's first click, so a `pending` company with this
|
||||
* false is a half-filled draft — not reviewable. Staff-created companies are
|
||||
* always true. Undefined on endpoints that don't load external profiles.
|
||||
*/
|
||||
onboardingCompleted?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the customer has submitted their onboarding application. Mirrors the
|
||||
* API's review gate (`setCompanyProfileStatus`): until this is true, a role
|
||||
* awaiting a decision cannot be approved or rejected. Companies loaded without
|
||||
* external profiles (`undefined`) are treated as submitted — absence of the
|
||||
* flag must not lock staff out.
|
||||
*/
|
||||
export function hasSubmittedOnboarding(company: Company): boolean {
|
||||
return company.onboardingCompleted !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A pristine draft: still `pending` and never submitted, so its name/TIN are
|
||||
* placeholders and there is nothing to review. Drives presentation only — the
|
||||
* approval gate is `hasSubmittedOnboarding`, which also covers the (corrupted)
|
||||
* case of a company activated before that gate existed.
|
||||
*/
|
||||
export function isOnboardingDraft(company: Company): boolean {
|
||||
return company.status === "pending" && !hasSubmittedOnboarding(company);
|
||||
}
|
||||
|
||||
/** Query parameters for the company list. */
|
||||
export interface CompanyListFilter {
|
||||
page: number;
|
||||
@@ -158,6 +186,8 @@ export interface CompanyListFilter {
|
||||
type?: CompanyType;
|
||||
kind?: CompanyKind;
|
||||
status?: CompanyStatus;
|
||||
/** `true` = submitted applications only; `false` = drafts only; omit for both. */
|
||||
onboardingCompleted?: boolean;
|
||||
}
|
||||
|
||||
/** Standard paginated list envelope (matches the bookings service shape). */
|
||||
@@ -170,7 +200,10 @@ export interface PaginatedCompanies {
|
||||
export interface CompanyStats {
|
||||
total: number;
|
||||
active: number;
|
||||
/** Submitted applications awaiting review. Excludes drafts. */
|
||||
pending: number;
|
||||
/** Self-registered companies still working through the onboarding wizard. */
|
||||
onboarding: number;
|
||||
suspended: number;
|
||||
blacklisted: number;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,6 @@ export interface ContainerUnitRow {
|
||||
label: string;
|
||||
grossWeightTons: number;
|
||||
sizeFt?: number;
|
||||
wagonsPerUnit?: number;
|
||||
containersPerWagon?: number;
|
||||
teuSlots?: number;
|
||||
containerNumber?: string | null;
|
||||
|
||||
@@ -322,7 +322,7 @@ Payment providers send notifications to:
|
||||
- \`POST /payments/webhooks/card\` (International)
|
||||
|
||||
## Support
|
||||
- **Email:** support@edr-platform.com
|
||||
- **Email:** edr_@edrsc.com
|
||||
- **Documentation:** https://docs.edr-platform.com
|
||||
- **Status Page:** https://status.edr-platform.com
|
||||
`,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsDateString, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class GetDuplicateSeatsQuery {
|
||||
@ApiProperty({ example: '2026-07-17', description: 'Schedule date (YYYY-MM-DD)' })
|
||||
@IsDateString()
|
||||
date: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter to a specific schedule ID' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
scheduleId?: string;
|
||||
}
|
||||
|
||||
export class ResolveDuplicatesDto {
|
||||
@ApiProperty({
|
||||
description: 'BookingSeat IDs of the duplicate bookings to reassign',
|
||||
type: [String],
|
||||
example: ['uuid-booking-seat-1', 'uuid-booking-seat-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
bookingSeatIds: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Coach IDs to source replacement seats from (searched in order; first available seat per coach is used)',
|
||||
type: [String],
|
||||
example: ['uuid-coach-1', 'uuid-coach-2'],
|
||||
})
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
coachIds: string[];
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import {
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiBody,
|
||||
} from "@nestjs/swagger";
|
||||
import { SeatsService } from "./seats.service";
|
||||
import { HoldSeatsDto, ReleaseHoldDto } from "./seats.dto";
|
||||
import { GetDuplicateSeatsQuery, ResolveDuplicatesDto } from "./duplicate-seats.dto";
|
||||
import { JwtGuard } from "../../common/jwt.guard";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
@@ -306,4 +308,90 @@ This makes it clear which segment of the route each seat is held for, enabling s
|
||||
) {
|
||||
return this.service.importSeatsCSV(body.scheduleId, body.csv, body.commit);
|
||||
}
|
||||
|
||||
// ── Duplicate seat management (backoffice) ────────────────────────────────
|
||||
|
||||
@Get("duplicates")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "List duplicate seat assignments by schedule date",
|
||||
description:
|
||||
"Returns all schedules on the given date that have bookings sharing " +
|
||||
"the same seat, grouped by coach. Each coach entry includes the duplicate " +
|
||||
"groups (with full booking info) and the list of currently available seats " +
|
||||
"that can be used for reassignment.",
|
||||
})
|
||||
@ApiQuery({ name: "date", example: "2026-07-17", description: "Schedule date (YYYY-MM-DD)" })
|
||||
@ApiQuery({ name: "scheduleId", required: false, description: "Filter to a specific schedule" })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Duplicate seat report grouped by schedule → coach",
|
||||
schema: {
|
||||
example: {
|
||||
date: "2026-07-17",
|
||||
totalDuplicates: 1,
|
||||
schedules: [{
|
||||
scheduleId: "uuid",
|
||||
departureAt: "2026-07-17T06:00:00.000Z",
|
||||
origin: "Addis Ababa",
|
||||
destination: "Dire Dawa",
|
||||
coaches: [{
|
||||
coachId: "uuid",
|
||||
coachNumber: "C1",
|
||||
coachTypeName: "SBC",
|
||||
duplicates: [{
|
||||
seatId: "uuid",
|
||||
seatNumber: "12A",
|
||||
leg: 1,
|
||||
bookings: [
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "ATPC9F", passengerName: "Abebe", contactPhone: "+251911000000", createdAt: "2026-07-16T10:00:00.000Z" },
|
||||
{ bookingSeatId: "uuid", bookingId: "uuid", bookingRef: "XYZ123", passengerName: "Kebede", contactPhone: "+251922000000", createdAt: "2026-07-16T11:00:00.000Z" },
|
||||
],
|
||||
}],
|
||||
availableSeats: [
|
||||
{ seatId: "uuid", seatNumber: "14B" },
|
||||
{ seatId: "uuid", seatNumber: "15A" },
|
||||
],
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
getDuplicateSeats(@Query() query: GetDuplicateSeatsQuery) {
|
||||
return this.service.getDuplicateSeats(query.date, query.scheduleId);
|
||||
}
|
||||
|
||||
@Post("duplicates/resolve")
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth("JWT-auth")
|
||||
@ApiOperation({
|
||||
summary: "Auto-assign duplicate bookings to seats in selected coaches",
|
||||
description:
|
||||
"Staff selects which duplicate BookingSeat IDs to fix and which coaches to pull replacement seats from. " +
|
||||
"The system automatically picks the first available (non-blocked, non-occupied) seat in the given coaches " +
|
||||
"for each booking, updates BookingSeat + Ticket + JourneySegment atomically so the seatmap reflects the " +
|
||||
"change immediately, then sends an SMS notification to the passenger. " +
|
||||
"Coaches are searched in the order provided; seats within each coach are assigned by row then column.",
|
||||
})
|
||||
@ApiBody({ type: ResolveDuplicatesDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "Resolution summary — resolved count, unresolved count, per-booking results",
|
||||
schema: {
|
||||
example: {
|
||||
resolved: 2,
|
||||
unresolved: 0,
|
||||
results: [
|
||||
{ bookingRef: "XYZ123", oldSeatNumber: "1A", newSeatNumber: "14B", contactPhone: "+251922000000" },
|
||||
{ bookingRef: "ABC456", oldSeatNumber: "1A", newSeatNumber: "15A", contactPhone: "+251933000000" },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Booking not in CONFIRMED/BOARDED status" })
|
||||
@ApiResponse({ status: 404, description: "BookingSeat ID not found" })
|
||||
resolveDuplicateSeats(@Body() dto: ResolveDuplicatesDto) {
|
||||
return this.service.resolveDuplicateSeats(dto.bookingSeatIds, dto.coachIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import { SeatsService } from './seats.service';
|
||||
import { SegmentsModule } from '../segments/segments.module';
|
||||
import { SystemConfigModule } from '../system-config/system-config.module';
|
||||
import { AuditModule } from '../../common/audit.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule],
|
||||
imports: [SegmentsModule, HttpModule, SystemConfigModule, AuditModule, NotificationsModule],
|
||||
controllers: [SeatsController],
|
||||
providers: [SeatsService],
|
||||
exports: [SeatsService],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
import { AuditService } from '../../common/audit.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils';
|
||||
import { checkDirectionConflict } from '../../common/utils/journey-direction.utils';
|
||||
|
||||
@@ -17,6 +18,7 @@ export class SeatsService {
|
||||
private segmentsService: SegmentsService,
|
||||
private systemConfig: SystemConfigService,
|
||||
private auditService: AuditService,
|
||||
private sms: SmsClientService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
||||
@@ -960,4 +962,416 @@ export class SeatsService {
|
||||
skippedSeatIds: Array.from(skippedSeatIds), // kept for logging/API compat; no DB writes needed
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Duplicate-seat management (backoffice)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async getDuplicateSeats(date: string, scheduleId?: string) {
|
||||
const dayStart = new Date(`${date}T00:00:00.000Z`);
|
||||
const dayEnd = new Date(`${date}T23:59:59.999Z`);
|
||||
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
where: {
|
||||
departureAt: { gte: dayStart, lte: dayEnd },
|
||||
...(scheduleId ? { id: scheduleId } : {}),
|
||||
},
|
||||
orderBy: { departureAt: 'asc' },
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
coachAssignments: {
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
coachType: { select: { name: true } },
|
||||
seats: {
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
select: { id: true, seatNumber: true, status: true, coachId: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
// All confirmed BookingSeat rows for this schedule
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ scheduleId: schedule.id },
|
||||
{ scheduleId: null, booking: { scheduleId: schedule.id } },
|
||||
],
|
||||
booking: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
},
|
||||
select: {
|
||||
id: true, seatId: true, scheduleId: true, leg: true, passengerName: true,
|
||||
seat: { select: { coachId: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
createdAt: true, contactPhone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Seats occupied by any confirmed journey on this schedule (source of truth)
|
||||
const journeySegments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: schedule.id,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
const occupiedIds = new Set(journeySegments.map(js => js.seatId!));
|
||||
|
||||
// Group BookingSeat rows by (seatId::leg) to detect duplicates
|
||||
type BS = (typeof bookingSeats)[number];
|
||||
const groups = new Map<string, BS[]>();
|
||||
for (const bs of bookingSeats) {
|
||||
const key = `${bs.seatId}::${bs.leg}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
// All seats held by any confirmed BookingSeat — union of JourneySegment-based
|
||||
// occupancy AND BookingSeat-based occupancy so that seats whose JourneySegments
|
||||
// are missing (e.g. created via enhanced-seats path without bookingId) are still
|
||||
// excluded from the available list.
|
||||
const bookedSeatIds = new Set<string>([
|
||||
...occupiedIds,
|
||||
...bookingSeats.map(bs => bs.seatId).filter((id): id is string => id !== null && id !== undefined),
|
||||
]);
|
||||
|
||||
const coachReports = [];
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
const coach = assignment.coach;
|
||||
|
||||
// Duplicate groups whose seat belongs to this coach
|
||||
const duplicates = [];
|
||||
for (const [key, group] of groups) {
|
||||
if (group.length <= 1) continue;
|
||||
if (group[0].seat.coachId !== coach.id) continue;
|
||||
const [seatId] = key.split('::');
|
||||
const seat = coach.seats.find(s => s.id === seatId);
|
||||
duplicates.push({
|
||||
seatId,
|
||||
seatNumber: seat?.seatNumber ?? seatId,
|
||||
leg: group[0].leg,
|
||||
bookings: group.map(bs => ({
|
||||
bookingSeatId: bs.id,
|
||||
bookingId: bs.booking.id,
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
passengerName: bs.passengerName,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
createdAt: bs.booking.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Free seats in this coach — excludes BLOCKED, all confirmed BookingSeat
|
||||
// assignments, and all confirmed JourneySegment occupancies.
|
||||
const availableSeats = coach.seats
|
||||
.filter(s =>
|
||||
(s.status as string) !== 'BLOCKED' &&
|
||||
!s.seatNumber.startsWith('-') &&
|
||||
!bookedSeatIds.has(s.id),
|
||||
)
|
||||
.map(s => ({ seatId: s.id, seatNumber: s.seatNumber }));
|
||||
|
||||
coachReports.push({
|
||||
coachId: coach.id,
|
||||
coachNumber: coach.number,
|
||||
coachTypeName: coach.coachType.name,
|
||||
duplicates,
|
||||
availableSeats,
|
||||
});
|
||||
}
|
||||
|
||||
if (coachReports.some(c => c.duplicates.length > 0)) {
|
||||
result.push({
|
||||
scheduleId: schedule.id,
|
||||
departureAt: schedule.departureAt,
|
||||
origin: schedule.originStation.name,
|
||||
destination: schedule.destinationStation.name,
|
||||
coaches: coachReports,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalDuplicates = result.reduce(
|
||||
(sum, s) => sum + s.coaches.reduce((cs, c) => cs + c.duplicates.length, 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return { date, schedules: result, totalDuplicates };
|
||||
}
|
||||
|
||||
async resolveDuplicateSeats(bookingSeatIds: string[], coachIds: string[]) {
|
||||
if (bookingSeatIds.length === 0) return { resolved: 0, unresolved: 0, results: [] };
|
||||
|
||||
// Load BookingSeat rows with full booking + schedule context
|
||||
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: { id: { in: bookingSeatIds } },
|
||||
select: {
|
||||
id: true, seatId: true, leg: true, scheduleId: true,
|
||||
seat: { select: { seatNumber: true } },
|
||||
booking: {
|
||||
select: {
|
||||
id: true, bookingRef: true, scheduleId: true,
|
||||
status: true, contactPhone: true, passengerId: true,
|
||||
totalMinor: true, currency: true,
|
||||
originStationId: true, destinationStationId: true,
|
||||
schedule: {
|
||||
select: {
|
||||
originStationId: true,
|
||||
destinationStationId: true,
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
departureAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingSeats.length !== bookingSeatIds.length) {
|
||||
const found = new Set(bookingSeats.map(bs => bs.id));
|
||||
const missing = bookingSeatIds.filter(id => !found.has(id));
|
||||
throw new NotFoundException(`BookingSeat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const invalid = bookingSeats.filter(bs => !['CONFIRMED', 'BOARDED'].includes(bs.booking.status));
|
||||
if (invalid.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Bookings must be CONFIRMED or BOARDED: ${invalid.map(bs => bs.booking.bookingRef).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Load all non-blocked, non-removed seats from the selected coaches (ordered for deterministic pick)
|
||||
const coachSeats = await this.prisma.seat.findMany({
|
||||
where: {
|
||||
coachId: { in: coachIds },
|
||||
status: { not: 'BLOCKED' },
|
||||
NOT: { seatNumber: { startsWith: '-' } },
|
||||
},
|
||||
select: { id: true, seatNumber: true, coachId: true, row: true, col: true },
|
||||
orderBy: [{ coachId: 'asc' }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
|
||||
// Build occupied-seat sets per schedule from confirmed JourneySegments
|
||||
const scheduleIds = [
|
||||
...new Set(
|
||||
bookingSeats
|
||||
.map(bs => bs.scheduleId ?? bs.booking.scheduleId)
|
||||
.filter((id): id is string => id !== null && id !== undefined),
|
||||
),
|
||||
];
|
||||
|
||||
const occupiedBySchedule = new Map<string, Set<string>>();
|
||||
await Promise.all(
|
||||
scheduleIds.map(async scheduleId => {
|
||||
const segments = await this.prisma.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
seatId: { not: null },
|
||||
journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT', 'BOARDED'] } },
|
||||
},
|
||||
select: { seatId: true },
|
||||
});
|
||||
occupiedBySchedule.set(scheduleId, new Set(segments.map(s => s.seatId!)));
|
||||
}),
|
||||
);
|
||||
|
||||
// Track seats assigned within this batch to prevent double-assignment
|
||||
const assignedInBatch = new Set<string>();
|
||||
|
||||
const results: { bookingRef: string; oldSeatNumber: string; newSeatNumber: string; contactPhone: string | null }[] = [];
|
||||
const unresolved: { bookingRef: string; reason: string }[] = [];
|
||||
|
||||
for (const bs of bookingSeats) {
|
||||
const scheduleId = (bs.scheduleId ?? bs.booking.scheduleId)!;
|
||||
const occupied = occupiedBySchedule.get(scheduleId) ?? new Set<string>();
|
||||
|
||||
// Pick the first available seat across the selected coaches
|
||||
const newSeat = coachSeats.find(
|
||||
seat =>
|
||||
!occupied.has(seat.id) &&
|
||||
!assignedInBatch.has(seat.id) &&
|
||||
seat.id !== bs.seatId,
|
||||
);
|
||||
|
||||
if (!newSeat) {
|
||||
unresolved.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
reason: 'No available seat found in selected coaches',
|
||||
});
|
||||
this.logger.warn(
|
||||
`Duplicate resolve: no seat available for ${bs.booking.bookingRef} (schedule ${scheduleId})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
// 1. Change the seat on the booking and ticket.
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: bs.id },
|
||||
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
|
||||
});
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: bs.booking.id, seatId: bs.seatId, leg: bs.leg },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// 2. Point the existing JourneySegments to the new seat.
|
||||
// The Journey is already linked to this booking via bookingId;
|
||||
// just update the seatId in its hop rows for this schedule.
|
||||
const journey = await tx.journey.findFirst({
|
||||
where: { bookingId: bs.booking.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!journey) {
|
||||
// No Journey/JourneySegment for this booking (e.g. duplicate that was never
|
||||
// processed by finalizePaymentSuccess). Create them now using the same logic,
|
||||
// scoped to the booking's origin→destination leg so the seatmap shows BOOKED
|
||||
// only for the correct range of stops.
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
|
||||
const newJourney = await tx.journey.create({
|
||||
data: {
|
||||
passengerId: bs.booking.passengerId,
|
||||
bookingId: bs.booking.id,
|
||||
status: 'CONFIRMED',
|
||||
totalMinor: bs.booking.totalMinor,
|
||||
currency: bs.booking.currency,
|
||||
} as any,
|
||||
});
|
||||
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: newJourney.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`No Journey for ${bs.booking.bookingRef} — created Journey + ${segments.length} segment(s) for seat ${newSeat.seatNumber}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { count } = await tx.journeySegment.updateMany({
|
||||
where: { journeyId: journey.id, scheduleId, seatId: bs.seatId },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
|
||||
// Journey exists but had no segments (e.g. booking confirmed via a path
|
||||
// that skipped JourneySegment creation). Create them now for the new seat
|
||||
// so the seatmap reflects BOOKED.
|
||||
if (count === 0) {
|
||||
const originId = bs.booking.originStationId ?? bs.booking.schedule?.originStationId;
|
||||
const destId = bs.booking.destinationStationId ?? bs.booking.schedule?.destinationStationId;
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId },
|
||||
orderBy: { sequence: 'asc' },
|
||||
select: { stationId: true },
|
||||
});
|
||||
const originIdx = originId ? stopTimes.findIndex(s => s.stationId === originId) : 0;
|
||||
const destIdx = destId ? stopTimes.findIndex(s => s.stationId === destId) : stopTimes.length - 1;
|
||||
const fromIdx = originIdx >= 0 ? originIdx : 0;
|
||||
const toIdx = destIdx >= 0 ? destIdx : stopTimes.length - 1;
|
||||
const segments = [];
|
||||
for (let i = fromIdx; i < toIdx; i++) {
|
||||
segments.push({
|
||||
journeyId: journey.id,
|
||||
scheduleId,
|
||||
segmentOrder: i - fromIdx,
|
||||
seatId: newSeat.id,
|
||||
coachId: newSeat.coachId,
|
||||
departureStationId: stopTimes[i].stationId,
|
||||
arrivalStationId: stopTimes[i + 1].stationId,
|
||||
});
|
||||
}
|
||||
if (segments.length > 0) {
|
||||
await tx.journeySegment.createMany({ data: segments, skipDuplicates: true });
|
||||
}
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(0 existing segments — created ${segments.length} new hop(s))`,
|
||||
);
|
||||
} else {
|
||||
this.logger.log(
|
||||
`Seat reassigned: ${bs.booking.bookingRef} ` +
|
||||
`${bs.seat?.seatNumber ?? bs.seatId} → ${newSeat.seatNumber} ` +
|
||||
`(${count} segment hop(s) updated)`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark as taken so the next booking in this batch doesn't get the same seat
|
||||
assignedInBatch.add(newSeat.id);
|
||||
occupied.add(newSeat.id);
|
||||
|
||||
const oldSeatNumber = bs.seat?.seatNumber ?? '?';
|
||||
const origin = bs.booking.schedule?.originStation?.name ?? '';
|
||||
const dest = bs.booking.schedule?.destinationStation?.name ?? '';
|
||||
|
||||
if (bs.booking.contactPhone) {
|
||||
const message =
|
||||
`EDR: Your booking ${bs.booking.bookingRef} (${origin} → ${dest}): ` +
|
||||
`your seat has been changed from seat ${oldSeatNumber} to seat ${newSeat.seatNumber}. ` +
|
||||
`We apologize for any inconvenience.`;
|
||||
await this.sms.sendSms({ to: bs.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Duplicate resolved: ${bs.booking.bookingRef} seat ${oldSeatNumber} → ${newSeat.seatNumber}`,
|
||||
);
|
||||
|
||||
results.push({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
oldSeatNumber,
|
||||
newSeatNumber: newSeat.seatNumber,
|
||||
contactPhone: bs.booking.contactPhone,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
resolved: results.length,
|
||||
unresolved: unresolved.length,
|
||||
results,
|
||||
...(unresolved.length > 0 ? { unresolvedDetails: unresolved } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { SeatStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SmsClientService } from '../notifications/sms-client.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
@@ -254,438 +253,6 @@ export class TasksService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: detect and resolve duplicate seat assignments.
|
||||
//
|
||||
// Root cause: a stale RabbitMQ message, delivered after system recovery,
|
||||
// re-confirmed a cancelled booking whose seat had already been assigned to
|
||||
// a new booking — leaving two CONFIRMED bookings holding the same seat on
|
||||
// the same schedule.
|
||||
//
|
||||
// Resolution (FCFS):
|
||||
// • Earliest confirmed booking keeps the original seat.
|
||||
// • All later duplicates are reassigned to the next free seat within the
|
||||
// SAME coach type (same coach preferred; any coach of same type as
|
||||
// fallback).
|
||||
// • If no seat is available in that coach type the booking is flagged for
|
||||
// manual intervention and logged as unresolved.
|
||||
//
|
||||
// Idempotent: after reassignment the BookingSeat/JourneySegment rows no
|
||||
// longer share the same (seatId, scheduleId) key, so the next tick finds
|
||||
// nothing to do for the same pair.
|
||||
//
|
||||
// Scope: only schedules departing in the last 24 h or in the future, to
|
||||
// keep the per-tick DB scan bounded.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async resolveDuplicateSeatAssignments() {
|
||||
const BATCH_SIZE = 20;
|
||||
const since = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch all BookingSeat rows for CONFIRMED bookings on upcoming/recent schedules.
|
||||
const confirmedSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
booking: {
|
||||
status: 'CONFIRMED',
|
||||
schedule: { departureAt: { gte: since } },
|
||||
},
|
||||
},
|
||||
include: {
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
bookingRef: true,
|
||||
scheduleId: true,
|
||||
createdAt: true,
|
||||
contactPhone: true,
|
||||
schedule: {
|
||||
include: {
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
seat: {
|
||||
include: {
|
||||
coach: {
|
||||
include: { coachType: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Group by (seatId, scheduleId). BookingSeat.scheduleId is per-leg for
|
||||
// round-trips; fall back to Booking.scheduleId for single-leg bookings.
|
||||
const groups = new Map<string, typeof confirmedSeats>();
|
||||
for (const bs of confirmedSeats) {
|
||||
if (!bs.seatId) continue;
|
||||
const scheduleId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (!scheduleId) continue;
|
||||
const key = `${bs.seatId}:${scheduleId}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
const duplicateGroups = [...groups.values()]
|
||||
.filter(g => g.length > 1)
|
||||
.slice(0, BATCH_SIZE);
|
||||
|
||||
if (duplicateGroups.length === 0) return;
|
||||
|
||||
this.logger.warn(`Seat dedup: ${duplicateGroups.length} duplicate seat group(s) detected`);
|
||||
|
||||
// Track seats newly assigned within this run to prevent double-assignment.
|
||||
const newlyAssigned = new Map<string, Set<string>>(); // scheduleId → Set<seatId>
|
||||
let resolved = 0;
|
||||
let unresolved = 0;
|
||||
|
||||
for (const group of duplicateGroups) {
|
||||
// FCFS: earliest confirmed booking keeps the seat.
|
||||
const sorted = [...group].sort(
|
||||
(a, b) =>
|
||||
new Date(a.booking.createdAt as Date).getTime() -
|
||||
new Date(b.booking.createdAt as Date).getTime(),
|
||||
);
|
||||
const [keeper, ...duplicates] = sorted;
|
||||
|
||||
for (const dup of duplicates) {
|
||||
const scheduleId = (dup.scheduleId ?? dup.booking.scheduleId)!;
|
||||
const coachTypeId = dup.seat?.coach?.coachTypeId;
|
||||
const oldCoachId = dup.seat?.coachId;
|
||||
|
||||
if (!coachTypeId) {
|
||||
this.logger.error(
|
||||
`Seat dedup: missing coachTypeId for BookingSeat ${dup.id}, booking ${dup.booking.bookingRef}`,
|
||||
);
|
||||
unresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!newlyAssigned.has(scheduleId)) newlyAssigned.set(scheduleId, new Set());
|
||||
const takenThisRun = newlyAssigned.get(scheduleId)!;
|
||||
|
||||
// All seats already taken: confirmed bookings + those assigned this tick.
|
||||
const occupiedIds = new Set([
|
||||
...confirmedSeats
|
||||
.filter(bs => (bs.scheduleId ?? bs.booking.scheduleId) === scheduleId && bs.seatId)
|
||||
.map(bs => bs.seatId as string),
|
||||
...takenThisRun,
|
||||
]);
|
||||
|
||||
try {
|
||||
const newSeat = await this.findReplacementSeat(scheduleId, coachTypeId, oldCoachId, occupiedIds);
|
||||
|
||||
if (!newSeat) {
|
||||
this.logger.warn(
|
||||
`Seat dedup: no available seat for booking ${dup.booking.bookingRef} ` +
|
||||
`(schedule ${scheduleId}, coachType ${coachTypeId}) — manual intervention required`,
|
||||
);
|
||||
unresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
// 1. Update BookingSeat to the new seat.
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: dup.id },
|
||||
data: { seatId: newSeat.id, seatLabelSnapshot: newSeat.seatNumber },
|
||||
});
|
||||
|
||||
// 2. Update JourneySegment — look up journeyId first to avoid a
|
||||
// nested-relation filter in updateMany (not supported in all Prisma versions).
|
||||
const journey = await tx.journey.findUnique({
|
||||
where: { bookingId: dup.booking.id } as any,
|
||||
select: { id: true },
|
||||
});
|
||||
if (journey) {
|
||||
await tx.journeySegment.updateMany({
|
||||
where: { journeyId: journey.id, seatId: dup.seatId!, scheduleId },
|
||||
data: { seatId: newSeat.id, coachId: newSeat.coachId },
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Update Ticket seat reference (QR payload regeneration is out of scope
|
||||
// here; the backoffice can trigger that separately if required).
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: dup.booking.id, seatId: dup.seatId! },
|
||||
data: { seatId: newSeat.id },
|
||||
});
|
||||
});
|
||||
|
||||
takenThisRun.add(newSeat.id);
|
||||
|
||||
const oldLabel = dup.seat?.seatNumber ?? dup.seatId ?? '?';
|
||||
const newCoach = (newSeat as any).coach;
|
||||
const coachTypeName = newCoach?.coachType?.name ?? '';
|
||||
const coachNumber = newCoach?.number ?? '';
|
||||
const origin = dup.booking.schedule?.originStation?.name ?? '';
|
||||
const dest = dup.booking.schedule?.destinationStation?.name ?? '';
|
||||
|
||||
if (dup.booking.contactPhone) {
|
||||
const message =
|
||||
`EDR: Your booking ${dup.booking.bookingRef} (${origin} → ${dest}): ` +
|
||||
`your seat has been changed from ${oldLabel} to seat ${newSeat.seatNumber} ` +
|
||||
`in coach ${coachNumber} (${coachTypeName}). ` +
|
||||
`We apologize for the inconvenience.`;
|
||||
await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Seat dedup resolved: booking ${dup.booking.bookingRef} ` +
|
||||
`seat ${oldLabel} → ${newSeat.seatNumber} (coach ${coachNumber}, ${coachTypeName}), ` +
|
||||
`keeper: ${keeper.booking.bookingRef}`,
|
||||
);
|
||||
resolved++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Seat dedup error for booking ${dup.booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
unresolved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Seat dedup run: ${resolved} resolved, ${unresolved} unresolved`);
|
||||
}
|
||||
|
||||
private async findReplacementSeat(
|
||||
scheduleId: string,
|
||||
coachTypeId: string,
|
||||
preferredCoachId: string | undefined,
|
||||
occupiedIds: Set<string>,
|
||||
) {
|
||||
const includeCoach = {
|
||||
coach: { include: { coachType: { select: { id: true, name: true } } } },
|
||||
};
|
||||
const baseWhere = (coachId?: string) => ({
|
||||
...(coachId ? { coachId } : {}),
|
||||
seatNumber: { not: '' },
|
||||
id: { notIn: [...occupiedIds] },
|
||||
coach: { coachTypeId, assignments: { some: { scheduleId } } },
|
||||
NOT: [
|
||||
{ seatNumber: { startsWith: '-' } },
|
||||
{ status: SeatStatus.BLOCKED },
|
||||
],
|
||||
});
|
||||
|
||||
// 1. Prefer the exact same coach.
|
||||
if (preferredCoachId) {
|
||||
const seat = await this.prisma.seat.findFirst({
|
||||
where: baseWhere(preferredCoachId),
|
||||
include: includeCoach,
|
||||
orderBy: [{ row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
if (seat) return seat;
|
||||
}
|
||||
|
||||
// 2. Any coach of the same coach type assigned to this schedule.
|
||||
return this.prisma.seat.findFirst({
|
||||
where: baseWhere(),
|
||||
include: includeCoach,
|
||||
orderBy: [{ coach: { number: 'asc' } }, { row: 'asc' }, { col: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Every 1 min: detect and resolve duplicate seat assignments caused by
|
||||
// RabbitMQ-recovered events re-confirming already-cancelled bookings.
|
||||
//
|
||||
// Detection: group confirmed BookingSeat rows by (scheduleId, seatId, leg).
|
||||
// Any group with >1 row means multiple bookings share the same physical seat.
|
||||
//
|
||||
// Resolution (FCFS): the booking created first keeps the seat; all later
|
||||
// bookings are reassigned to an available seat in:
|
||||
// 1. Same coach + same coach type (preferred)
|
||||
// 2. Same coach type, any coach (fallback)
|
||||
// 3. No seat available → logged, needs manual intervention
|
||||
//
|
||||
// Idempotency: once a duplicate's BookingSeat is updated to a new seatId it
|
||||
// no longer appears in the duplicate group on the next tick — naturally safe
|
||||
// to re-run without any extra flag.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@Cron('*/1 * * * *')
|
||||
async deduplicateSeatAssignments() {
|
||||
this.logger.log('Seat dedup cron started');
|
||||
// Scan at most 500 confirmed seat rows per run to stay lightweight.
|
||||
const confirmedSeats = await this.prisma.bookingSeat.findMany({
|
||||
where: { booking: { status: { in: ['CONFIRMED', 'BOARDED'] } } },
|
||||
select: {
|
||||
id: true,
|
||||
seatId: true,
|
||||
scheduleId: true,
|
||||
leg: true,
|
||||
passengerName: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
bookingRef: true,
|
||||
scheduleId: true,
|
||||
createdAt: true,
|
||||
contactPhone: true,
|
||||
},
|
||||
},
|
||||
seat: {
|
||||
select: {
|
||||
id: true,
|
||||
seatNumber: true,
|
||||
coachId: true,
|
||||
coach: {
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
coachTypeId: true,
|
||||
coachType: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 500,
|
||||
});
|
||||
|
||||
// Group by (effectiveScheduleId :: seatId :: leg)
|
||||
type BsRow = (typeof confirmedSeats)[number];
|
||||
const groups = new Map<string, BsRow[]>();
|
||||
for (const bs of confirmedSeats) {
|
||||
const schedId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (!schedId) continue;
|
||||
const key = `${schedId}::${bs.seatId}::${bs.leg}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(bs);
|
||||
}
|
||||
|
||||
const duplicateGroups = [...groups.values()].filter(g => g.length > 1);
|
||||
if (duplicateGroups.length === 0) return;
|
||||
|
||||
this.logger.warn(`Seat dedup: ${duplicateGroups.length} conflict(s) detected`);
|
||||
|
||||
// Build taken-seat sets keyed by (scheduleId::leg) — used when finding
|
||||
// a replacement seat so we don't assign an already-occupied seat.
|
||||
const takenByScheduleLeg = new Map<string, Set<string>>();
|
||||
for (const bs of confirmedSeats) {
|
||||
const schedId = bs.scheduleId ?? bs.booking.scheduleId;
|
||||
if (!schedId) continue;
|
||||
const key = `${schedId}::${bs.leg}`;
|
||||
if (!takenByScheduleLeg.has(key)) takenByScheduleLeg.set(key, new Set());
|
||||
takenByScheduleLeg.get(key)!.add(bs.seatId);
|
||||
}
|
||||
|
||||
let resolved = 0;
|
||||
let unresolved = 0;
|
||||
|
||||
for (const group of duplicateGroups) {
|
||||
// FCFS: earliest booking keeps the seat
|
||||
group.sort((a, b) =>
|
||||
new Date(a.booking.createdAt).getTime() - new Date(b.booking.createdAt).getTime(),
|
||||
);
|
||||
|
||||
const [winner, ...duplicates] = group;
|
||||
const schedId = winner.scheduleId ?? winner.booking.scheduleId;
|
||||
const coachTypeId = winner.seat.coach.coachTypeId;
|
||||
const origCoachId = winner.seat.coachId;
|
||||
const taken = takenByScheduleLeg.get(`${schedId}::${winner.leg}`) ?? new Set<string>();
|
||||
|
||||
for (const dup of duplicates) {
|
||||
try {
|
||||
// 1st choice: same coach + same coach type
|
||||
const newSeat =
|
||||
(await this.prisma.seat.findFirst({
|
||||
where: {
|
||||
id: { notIn: [...taken] },
|
||||
status: { not: SeatStatus.BLOCKED },
|
||||
coachId: origCoachId,
|
||||
coach: {
|
||||
coachTypeId,
|
||||
assignments: { some: { scheduleId: schedId } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true, seatNumber: true, coachId: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
})) ??
|
||||
// 2nd choice: any coach within same coach type
|
||||
(await this.prisma.seat.findFirst({
|
||||
where: {
|
||||
id: { notIn: [...taken] },
|
||||
status: { not: SeatStatus.BLOCKED },
|
||||
coach: {
|
||||
coachTypeId,
|
||||
assignments: { some: { scheduleId: schedId } },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true, seatNumber: true, coachId: true,
|
||||
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||
},
|
||||
}));
|
||||
|
||||
if (!newSeat) {
|
||||
this.logger.warn(
|
||||
`Seat dedup: no available seat in coach type for ` +
|
||||
`booking ${dup.booking.bookingRef} (${dup.passengerName}) — manual intervention required`,
|
||||
);
|
||||
unresolved++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Atomically update BookingSeat + Ticket + JourneySegment
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.bookingSeat.update({
|
||||
where: { id: dup.id },
|
||||
data: { seatId: newSeat!.id, seatLabelSnapshot: newSeat!.seatNumber },
|
||||
});
|
||||
await tx.ticket.updateMany({
|
||||
where: { bookingId: dup.booking.id, seatId: dup.seatId, leg: dup.leg },
|
||||
data: { seatId: newSeat!.id },
|
||||
});
|
||||
await tx.journeySegment.updateMany({
|
||||
where: {
|
||||
journey: { bookingId: dup.booking.id },
|
||||
seatId: dup.seatId,
|
||||
scheduleId: schedId,
|
||||
},
|
||||
data: { seatId: newSeat!.id, coachId: newSeat!.coachId },
|
||||
});
|
||||
});
|
||||
|
||||
// Claim the new seat so subsequent duplicates in this run don't use it
|
||||
taken.add(newSeat.id);
|
||||
|
||||
const message =
|
||||
`EDR: Your seat for booking ${dup.booking.bookingRef} has been updated ` +
|
||||
`due to a system correction. ` +
|
||||
`New seat: ${newSeat.seatNumber}, Coach: ${newSeat.coach.number} ` +
|
||||
`(${newSeat.coach.coachType.name}). We apologize for the inconvenience.`;
|
||||
|
||||
if (dup.booking.contactPhone) {
|
||||
await this.sms.sendSms({ to: dup.booking.contactPhone, message }).catch(() => null);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Seat dedup: booking ${dup.booking.bookingRef} (${dup.passengerName}) ` +
|
||||
`seat ${dup.seat.seatNumber} → ${newSeat.seatNumber} (coach ${newSeat.coach.number})`,
|
||||
);
|
||||
resolved++;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Seat dedup error for ${dup.booking.bookingRef}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
unresolved++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Seat dedup complete: ${resolved} reassigned, ${unresolved} unresolved ` +
|
||||
`across ${duplicateGroups.length} conflict(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function DiscrepancyLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
519
apps/edr-passenger-web/backoffice/src/app/discrepancy/page.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { Search, Layers, ChevronDown, ChevronUp, CheckSquare, Square, AlertCircle, CheckCircle2, X, Loader2 } from 'lucide-react';
|
||||
import { seatsApi } from '@/lib/api';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface DuplicateBooking {
|
||||
bookingSeatId: string;
|
||||
bookingId: string;
|
||||
bookingRef: string;
|
||||
passengerName: string;
|
||||
contactPhone: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface DuplicateSeatGroup {
|
||||
seatId: string;
|
||||
seatNumber: string;
|
||||
leg: number;
|
||||
bookings: DuplicateBooking[];
|
||||
}
|
||||
|
||||
interface AvailableSeat {
|
||||
seatId: string;
|
||||
seatNumber: string;
|
||||
}
|
||||
|
||||
interface CoachReport {
|
||||
coachId: string;
|
||||
coachNumber: string;
|
||||
coachTypeName: string;
|
||||
duplicates: DuplicateSeatGroup[];
|
||||
availableSeats: AvailableSeat[];
|
||||
}
|
||||
|
||||
interface ScheduleReport {
|
||||
scheduleId: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
departureAt: string;
|
||||
coaches: CoachReport[];
|
||||
}
|
||||
|
||||
interface DuplicatesResponse {
|
||||
date: string;
|
||||
schedules: ScheduleReport[];
|
||||
totalDuplicates: number;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function today() {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function scheduleDuplicateCount(s: ScheduleReport) {
|
||||
return s.coaches.reduce((sum, c) => sum + c.duplicates.length, 0);
|
||||
}
|
||||
|
||||
// ── Resolve modal ─────────────────────────────────────────────────────────
|
||||
|
||||
interface ResolveModalProps {
|
||||
schedule: ScheduleReport;
|
||||
coach: CoachReport;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function ResolveModal({ schedule, coach, onClose, onSuccess }: ResolveModalProps) {
|
||||
// Default: pre-select all-but-first passenger in every duplicate group
|
||||
const defaultSelected = new Set<string>(
|
||||
coach.duplicates.flatMap(g => g.bookings.slice(1).map(b => b.bookingSeatId)),
|
||||
);
|
||||
const [selectedSeats, setSelectedSeats] = useState<Set<string>>(defaultSelected);
|
||||
|
||||
// Coaches that have at least one available seat (pre-select all)
|
||||
const coachesWithSeats = schedule.coaches.filter(c => c.availableSeats.length > 0);
|
||||
const [selectedCoachIds, setSelectedCoachIds] = useState<Set<string>>(
|
||||
new Set(coachesWithSeats.map(c => c.coachId)),
|
||||
);
|
||||
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
|
||||
seatsApi.resolveDuplicates(data),
|
||||
onSuccess: (res) => {
|
||||
setSuccessMsg(
|
||||
`${res.resolved ?? 0} passenger(s) successfully reassigned.` +
|
||||
(res.unresolved > 0 ? ` ${res.unresolved} could not be resolved (no available seat).` : ''),
|
||||
);
|
||||
setErrorMsg(null);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setErrorMsg(err?.response?.data?.message ?? 'Failed to resolve duplicates.');
|
||||
},
|
||||
});
|
||||
|
||||
function toggleBookingSeat(id: string) {
|
||||
setSelectedSeats(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCoach(id: string) {
|
||||
setSelectedCoachIds(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function handleAssign() {
|
||||
setErrorMsg(null);
|
||||
if (selectedSeats.size === 0) {
|
||||
setErrorMsg('Select at least one passenger to reassign.');
|
||||
return;
|
||||
}
|
||||
if (selectedCoachIds.size === 0) {
|
||||
setErrorMsg('Select at least one coach to source the replacement seat from.');
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
bookingSeatIds: [...selectedSeats],
|
||||
coachIds: [...selectedCoachIds],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] overflow-y-auto rounded-xl bg-white dark:bg-gray-900 shadow-2xl flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Resolve Duplicates — {coach.coachNumber}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{schedule.origin} → {schedule.destination} · {formatDateTime(schedule.departureAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<X className="w-5 h-5 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5 space-y-6">
|
||||
|
||||
{/* Duplicate seat groups */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Duplicate seat assignments
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Check the passengers you want to reassign to a new seat. Unchecked passengers keep their current seat.
|
||||
</p>
|
||||
|
||||
{coach.duplicates.map(group => (
|
||||
<div
|
||||
key={`${group.seatId}-${group.leg}`}
|
||||
className="rounded-lg border border-orange-200 dark:border-orange-800 bg-orange-50 dark:bg-orange-950/30 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertCircle className="w-4 h-4 text-orange-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-orange-800 dark:text-orange-300">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers assigned
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => {
|
||||
const checked = selectedSeats.has(b.bookingSeatId);
|
||||
return (
|
||||
<label
|
||||
key={b.bookingSeatId}
|
||||
className="flex items-start gap-3 cursor-pointer rounded-lg px-3 py-2 hover:bg-orange-100 dark:hover:bg-orange-900/30 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleBookingSeat(b.bookingSeatId)}
|
||||
className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{b.passengerName || '—'}
|
||||
</span>
|
||||
<span className="text-xs font-mono bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 px-1.5 py-0.5 rounded">
|
||||
{b.bookingRef}
|
||||
</span>
|
||||
{idx === 0 && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 px-1.5 py-0.5 rounded">
|
||||
earliest
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{b.contactPhone ?? 'No phone'} · Booked {formatDateTime(b.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Coach selection */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Reassign to seats in
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
The system picks the first available seat in the selected coaches.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{coachesWithSeats.length === 0 ? (
|
||||
<p className="text-sm text-red-500">No coaches have available seats on this schedule.</p>
|
||||
) : (
|
||||
coachesWithSeats.map(c => (
|
||||
<label
|
||||
key={c.coachId}
|
||||
className="flex items-center gap-3 cursor-pointer rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCoachIds.has(c.coachId)}
|
||||
onChange={() => toggleCoach(c.coachId)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{c.coachNumber}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">
|
||||
{c.coachTypeName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
{c.availableSeats.length} available
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{errorMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-700 dark:text-red-400">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-4 py-3">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-green-700 dark:text-green-400">{successMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{successMsg ? 'Close' : 'Cancel'}
|
||||
</button>
|
||||
{!successMsg && (
|
||||
<button
|
||||
onClick={handleAssign}
|
||||
disabled={mutation.isPending || selectedSeats.size === 0 || selectedCoachIds.size === 0}
|
||||
className="flex items-center gap-2 px-5 py-2 text-sm font-medium rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{mutation.isPending && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
Assign {selectedSeats.size > 0 ? `${selectedSeats.size} passenger${selectedSeats.size > 1 ? 's' : ''}` : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Coach card ────────────────────────────────────────────────────────────
|
||||
|
||||
interface CoachCardProps {
|
||||
coach: CoachReport;
|
||||
schedule: ScheduleReport;
|
||||
onResolve: () => void;
|
||||
}
|
||||
|
||||
function CoachCard({ coach, schedule, onResolve }: CoachCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const hasDuplicates = coach.duplicates.length > 0;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${hasDuplicates ? 'border-orange-200 dark:border-orange-800' : 'border-gray-200 dark:border-gray-700'} bg-white dark:bg-gray-900 overflow-hidden`}>
|
||||
{/* Card header */}
|
||||
<div className="flex items-center gap-4 px-5 py-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{coach.coachNumber}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">{coach.coachTypeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{coach.availableSeats.length} available seats</span>
|
||||
{hasDuplicates && (
|
||||
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400 font-medium">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{coach.duplicates.length} duplicate{coach.duplicates.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={onResolve}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-500 text-white hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
{hasDuplicates && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-500 transition-colors"
|
||||
title={expanded ? 'Collapse' : 'View passengers'}
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded passenger list */}
|
||||
{expanded && hasDuplicates && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{coach.duplicates.map(group => (
|
||||
<div key={`${group.seatId}-${group.leg}`} className="px-5 py-3">
|
||||
<p className="text-xs font-semibold text-orange-600 dark:text-orange-400 mb-2">
|
||||
Seat {group.seatNumber} — {group.bookings.length} passengers
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{group.bookings.map((b, idx) => (
|
||||
<div key={b.bookingSeatId} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-5 h-5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-xs flex items-center justify-center font-medium shrink-0">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-gray-900 dark:text-white">{b.passengerName || '—'}</span>
|
||||
<span className="ml-2 text-xs font-mono text-gray-500 dark:text-gray-400">{b.bookingRef}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 shrink-0">{b.contactPhone ?? '—'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DiscrepancyPage() {
|
||||
const [date, setDate] = useState(today());
|
||||
const [searchDate, setSearchDate] = useState('');
|
||||
const [resolveTarget, setResolveTarget] = useState<{ schedule: ScheduleReport; coach: CoachReport } | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery<DuplicatesResponse>({
|
||||
queryKey: ['seat-duplicates', searchDate],
|
||||
queryFn: () => seatsApi.getDuplicates(searchDate),
|
||||
enabled: !!searchDate,
|
||||
});
|
||||
|
||||
function handleSearch() {
|
||||
if (date) setSearchDate(date);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 max-w-5xl mx-auto">
|
||||
|
||||
{/* Page header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-950/40">
|
||||
<Layers className="w-6 h-6 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Seat Discrepancy</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Detect and resolve duplicate seat assignments by schedule date
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date picker */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={e => setDate(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!date || isLoading}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{isError && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
Failed to load duplicate seat data. Please try again.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary banner */}
|
||||
{data && (
|
||||
<div className={`rounded-xl border px-5 py-4 flex items-center gap-3 ${
|
||||
data.totalDuplicates > 0
|
||||
? 'bg-orange-50 dark:bg-orange-950/20 border-orange-200 dark:border-orange-800'
|
||||
: 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
|
||||
}`}>
|
||||
{data.totalDuplicates > 0 ? (
|
||||
<AlertCircle className="w-5 h-5 text-orange-500 shrink-0" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 shrink-0" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${
|
||||
data.totalDuplicates > 0
|
||||
? 'text-orange-800 dark:text-orange-300'
|
||||
: 'text-green-800 dark:text-green-300'
|
||||
}`}>
|
||||
{data.totalDuplicates > 0
|
||||
? `${data.totalDuplicates} duplicate seat assignment${data.totalDuplicates > 1 ? 's' : ''} found across ${data.schedules.length} schedule${data.schedules.length > 1 ? 's' : ''} on ${data.date}`
|
||||
: `No duplicate seat assignments found on ${data.date}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results per schedule */}
|
||||
{data?.schedules.map(schedule => (
|
||||
<div key={schedule.scheduleId} className="space-y-3">
|
||||
{/* Schedule header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{schedule.origin} → {schedule.destination}
|
||||
</h2>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatDateTime(schedule.departureAt)} · {scheduleDuplicateCount(schedule)} duplicate{scheduleDuplicateCount(schedule) !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coach cards grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{schedule.coaches.map(coach => (
|
||||
<CoachCard
|
||||
key={coach.coachId}
|
||||
coach={coach}
|
||||
schedule={schedule}
|
||||
onResolve={() => setResolveTarget({ schedule, coach })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Empty state when searched but no results */}
|
||||
{data && data.schedules.length === 0 && data.totalDuplicates === 0 && searchDate && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
|
||||
<p className="text-gray-500 dark:text-gray-400">All seats are correctly assigned for {data.date}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolve modal */}
|
||||
{resolveTarget && (
|
||||
<ResolveModal
|
||||
schedule={resolveTarget.schedule}
|
||||
coach={resolveTarget.coach}
|
||||
onClose={() => setResolveTarget(null)}
|
||||
onSuccess={() => {
|
||||
refetch();
|
||||
// Keep modal open to show success message; user closes manually
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -365,7 +365,7 @@ export default function LoginPage() {
|
||||
Back-office · v1.0
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-600">
|
||||
Need help? <a href="mailto:support@edr.com" className="text-[rgb(20,113,76)] hover:underline">support@edr.com</a>
|
||||
Need help? <a href="mailto:edr_@edrsc.com" className="text-[rgb(20,113,76)] hover:underline">edr_@edrsc.com</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,11 +94,11 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Support Email</label>
|
||||
<input type="email" className="input" defaultValue="support@edr-platform.com" />
|
||||
<input type="email" className="input" defaultValue="edr_@edrsc.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Support Phone</label>
|
||||
<input type="tel" className="input" defaultValue="+251911234567" />
|
||||
<input type="tel" className="input" defaultValue="+2519546" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Default Currency</label>
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Banknote,
|
||||
Activity,
|
||||
Smartphone,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -64,7 +65,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{ name: 'Passengers', href: '/passengers', icon: Users, permission: PERMS.passengers.view },
|
||||
{ name: 'Tickets', href: '/tickets', icon: FileText, permission: PERMS.tickets.view },
|
||||
{ name: 'Boarding', href: '/boarding', icon: LogIn, permission: PERMS.tickets.view },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
{ name: 'Luggage', href: '/excess-baggage', icon: Banknote, permission: PERMS.bookings.view },
|
||||
{ name: 'Discrepancy', href: '/discrepancy', icon: Layers, permission: PERMS.seats.manage },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -159,6 +159,10 @@ export const seatsApi = {
|
||||
undoRemove: (seatId: string) => apiClient.patch<any>(`/seats/${seatId}/undo-remove`, {}),
|
||||
setMaintenance: (seatId: string, reason: string) => apiClient.post<any>(`/seats/${seatId}/maintenance`, { reason }),
|
||||
clearMaintenance: (seatId: string) => apiClient.delete(`/seats/${seatId}/maintenance`),
|
||||
getDuplicates: (date: string, scheduleId?: string) =>
|
||||
apiClient.get<any>(`/seats/duplicates?date=${date}${scheduleId ? `&scheduleId=${scheduleId}` : ''}`),
|
||||
resolveDuplicates: (data: { bookingSeatIds: string[]; coachIds: string[] }) =>
|
||||
apiClient.post<any>('/seats/duplicates/resolve', data),
|
||||
};
|
||||
|
||||
// Payments API
|
||||
|
||||
@@ -853,7 +853,6 @@ export interface BookingReferenceContainerType {
|
||||
name: string;
|
||||
code: string;
|
||||
is_reefer: boolean;
|
||||
wagons_per_unit: number;
|
||||
}
|
||||
|
||||
export interface BookingReferenceContainerSizeGroup {
|
||||
|
||||
Reference in New Issue
Block a user