mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 10:58:14 +00:00
@@ -0,0 +1,40 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3170 backfilled the per-side facility flags onto EXISTING yard_facilities
|
||||||
|
* rows, but the Djibouti port yards (Negad, the Doraleh terminals) are flagged
|
||||||
|
* `has_facility` without ever getting a facility record — the seeder only
|
||||||
|
* covers the inland intercity facilities. With the contract route picker now
|
||||||
|
* gating on the per-side flags, those yards report false on every side and
|
||||||
|
* vanish: import contracts lose all origin options, exports all destinations.
|
||||||
|
*
|
||||||
|
* Give every facility-flagged yard that has no live record one with both
|
||||||
|
* freight types open on both sides — exactly the offerability these yards had
|
||||||
|
* before the gate existed. Ops can narrow a port from the backoffice yards
|
||||||
|
* page, which now edits these flags.
|
||||||
|
*/
|
||||||
|
export class PortYardFacilityRecords3180000000000 implements MigrationInterface {
|
||||||
|
name = 'PortYardFacilityRecords3180000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
INSERT INTO freight.yard_facilities
|
||||||
|
(yard_id, has_warehouse, handles_container, handles_bulk,
|
||||||
|
has_container_facility_origin, has_bulk_facility_origin,
|
||||||
|
has_container_facility_destination, has_bulk_facility_destination)
|
||||||
|
SELECT y.id, false, true, true, true, true, true, true
|
||||||
|
FROM freight.yards y
|
||||||
|
WHERE y.deleted_at IS NULL
|
||||||
|
AND y.has_facility = true
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.yard_facilities f
|
||||||
|
WHERE f.yard_id = y.id AND f.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(): Promise<void> {
|
||||||
|
// Data seed — the inserted rows are indistinguishable from operator edits
|
||||||
|
// afterwards, so reversing would risk deleting real configuration.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,38 @@ export class CreateYardDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
hasFacility?: boolean;
|
hasFacility?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description: 'Facility can load containers onto a train (contract origin side)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasContainerFacilityOrigin?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description: 'Facility can load bulk onto a train (contract origin side)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasBulkFacilityOrigin?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description: 'Facility can receive containers off a train (contract destination side)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasContainerFacilityDestination?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
default: false,
|
||||||
|
description: 'Facility can receive bulk off a train (contract destination side)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
hasBulkFacilityDestination?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
@ApiPropertyOptional({ default: 1, description: 'UI display sort order' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export interface YardFacilityInfo {
|
|||||||
/** Which side of the trip a yard is being considered for. */
|
/** Which side of the trip a yard is being considered for. */
|
||||||
export type YardSide = 'ORIGIN' | 'DESTINATION';
|
export type YardSide = 'ORIGIN' | 'DESTINATION';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four per-side capability flags as stored — NOT gated on the coarse
|
||||||
|
* `handles*` switches. The yards config page edits the stored values; gating
|
||||||
|
* is applied only when the flows resolve capability (see `toInfo`).
|
||||||
|
*/
|
||||||
|
export interface YardSideFlags {
|
||||||
|
hasContainerFacilityOrigin: boolean;
|
||||||
|
hasBulkFacilityOrigin: boolean;
|
||||||
|
hasContainerFacilityDestination: boolean;
|
||||||
|
hasBulkFacilityDestination: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which yards can handle cargo, and what kind.
|
* Which yards can handle cargo, and what kind.
|
||||||
*
|
*
|
||||||
@@ -116,6 +128,61 @@ export class YardFacilitiesService {
|
|||||||
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stored per-side flags for a set of yards, keyed by yard id. Yards with no facility record are absent. */
|
||||||
|
async sideFlagsForYards(yardIds: string[]): Promise<Map<string, YardSideFlags>> {
|
||||||
|
if (yardIds.length === 0) return new Map();
|
||||||
|
const rows: Array<YardSideFlags & { yardId: string }> = await this.dataSource.query(
|
||||||
|
`SELECT yard_id AS "yardId",
|
||||||
|
has_container_facility_origin AS "hasContainerFacilityOrigin",
|
||||||
|
has_bulk_facility_origin AS "hasBulkFacilityOrigin",
|
||||||
|
has_container_facility_destination AS "hasContainerFacilityDestination",
|
||||||
|
has_bulk_facility_destination AS "hasBulkFacilityDestination"
|
||||||
|
FROM freight.yard_facilities
|
||||||
|
WHERE deleted_at IS NULL AND yard_id = ANY($1)`,
|
||||||
|
[yardIds],
|
||||||
|
);
|
||||||
|
return new Map(
|
||||||
|
rows.map((r) => [
|
||||||
|
r.yardId,
|
||||||
|
{
|
||||||
|
hasContainerFacilityOrigin: r.hasContainerFacilityOrigin,
|
||||||
|
hasBulkFacilityOrigin: r.hasBulkFacilityOrigin,
|
||||||
|
hasContainerFacilityDestination: r.hasContainerFacilityDestination,
|
||||||
|
hasBulkFacilityDestination: r.hasBulkFacilityDestination,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write per-side flags from the yards config form, creating the facility
|
||||||
|
* record if the yard doesn't have one yet (backoffice-created yards don't).
|
||||||
|
* Flags left undefined keep their stored value; on first insert they default
|
||||||
|
* false — an unconfigured facility offers nothing.
|
||||||
|
*/
|
||||||
|
async upsertSideFlags(yardId: string, flags: Partial<YardSideFlags>): Promise<void> {
|
||||||
|
await this.dataSource.query(
|
||||||
|
`INSERT INTO freight.yard_facilities
|
||||||
|
(yard_id, has_container_facility_origin, has_bulk_facility_origin,
|
||||||
|
has_container_facility_destination, has_bulk_facility_destination)
|
||||||
|
VALUES ($1, COALESCE($2, false), COALESCE($3, false), COALESCE($4, false), COALESCE($5, false))
|
||||||
|
ON CONFLICT (yard_id) WHERE deleted_at IS NULL
|
||||||
|
DO UPDATE SET
|
||||||
|
has_container_facility_origin = COALESCE($2, yard_facilities.has_container_facility_origin),
|
||||||
|
has_bulk_facility_origin = COALESCE($3, yard_facilities.has_bulk_facility_origin),
|
||||||
|
has_container_facility_destination = COALESCE($4, yard_facilities.has_container_facility_destination),
|
||||||
|
has_bulk_facility_destination = COALESCE($5, yard_facilities.has_bulk_facility_destination),
|
||||||
|
updated_at = now()`,
|
||||||
|
[
|
||||||
|
yardId,
|
||||||
|
flags.hasContainerFacilityOrigin ?? null,
|
||||||
|
flags.hasBulkFacilityOrigin ?? null,
|
||||||
|
flags.hasContainerFacilityDestination ?? null,
|
||||||
|
flags.hasBulkFacilityDestination ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||||
* so callers can't get it subtly wrong.
|
* so callers can't get it subtly wrong.
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ const service = (): YardsService =>
|
|||||||
update: async (_id: string, d: Partial<Yard>) => d as Yard,
|
update: async (_id: string, d: Partial<Yard>) => d as Yard,
|
||||||
} as never,
|
} as never,
|
||||||
{ resolveCreateOrder: async () => 1 } as never,
|
{ resolveCreateOrder: async () => 1 } as never,
|
||||||
|
{
|
||||||
|
sideFlagsForYards: async () => new Map(),
|
||||||
|
upsertSideFlags: async () => undefined,
|
||||||
|
} as never,
|
||||||
);
|
);
|
||||||
|
|
||||||
describe('duplicate yard labels are rejected', () => {
|
describe('duplicate yard labels are rejected', () => {
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ import { UpdateYardDto } from '../dto/update-yard.dto';
|
|||||||
import { Yard } from '../entities/yard.entity';
|
import { Yard } from '../entities/yard.entity';
|
||||||
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.repository.interface';
|
||||||
import { DisplayOrderService } from './display-order.service';
|
import { DisplayOrderService } from './display-order.service';
|
||||||
|
import { YardFacilitiesService, YardSideFlags } from './yard-facilities.service';
|
||||||
|
|
||||||
|
/** Yard rows the config page lists/edits carry the stored per-side facility flags. */
|
||||||
|
export type YardWithSideFlags = Yard & YardSideFlags;
|
||||||
|
|
||||||
|
const SIDE_FLAG_KEYS = [
|
||||||
|
'hasContainerFacilityOrigin',
|
||||||
|
'hasBulkFacilityOrigin',
|
||||||
|
'hasContainerFacilityDestination',
|
||||||
|
'hasBulkFacilityDestination',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const NO_FLAGS: YardSideFlags = {
|
||||||
|
hasContainerFacilityOrigin: false,
|
||||||
|
hasBulkFacilityOrigin: false,
|
||||||
|
hasContainerFacilityDestination: false,
|
||||||
|
hasBulkFacilityDestination: false,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YardsService {
|
export class YardsService {
|
||||||
@@ -15,18 +33,34 @@ export class YardsService {
|
|||||||
@Inject(YARDS_REPOSITORY)
|
@Inject(YARDS_REPOSITORY)
|
||||||
private readonly repository: IYardsRepository,
|
private readonly repository: IYardsRepository,
|
||||||
private readonly displayOrder: DisplayOrderService,
|
private readonly displayOrder: DisplayOrderService,
|
||||||
|
private readonly facilities: YardFacilitiesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List yards — standard paginated envelope with server-side search. */
|
/** List yards — standard paginated envelope with server-side search. */
|
||||||
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<Yard>> {
|
async findAll(query: ListYardsQueryDto): Promise<PaginatedResponse<YardWithSideFlags>> {
|
||||||
return this.repository.findPaged(query);
|
const page = await this.repository.findPaged(query);
|
||||||
|
const flags = await this.facilities.sideFlagsForYards(page.items.map((y) => y.id));
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: page.items.map((y) => ({ ...y, ...NO_FLAGS, ...flags.get(y.id) })),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get a yard by ID. */
|
/** Get a yard by ID. */
|
||||||
async findById(id: string): Promise<Yard> {
|
async findById(id: string): Promise<YardWithSideFlags> {
|
||||||
const entity = await this.repository.findById(id);
|
const entity = await this.repository.findById(id);
|
||||||
if (!entity) throw new NotFoundException(`Yard ${id} not found`);
|
if (!entity) throw new NotFoundException(`Yard ${id} not found`);
|
||||||
return entity;
|
const flags = await this.facilities.sideFlagsForYards([id]);
|
||||||
|
return { ...entity, ...NO_FLAGS, ...flags.get(id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The per-side facility flags present in the dto, or null when none were sent. */
|
||||||
|
private pickSideFlags(dto: Partial<CreateYardDto>): Partial<YardSideFlags> | null {
|
||||||
|
const flags: Partial<YardSideFlags> = {};
|
||||||
|
for (const key of SIDE_FLAG_KEYS) {
|
||||||
|
if (dto[key] !== undefined) flags[key] = dto[key];
|
||||||
|
}
|
||||||
|
return Object.keys(flags).length > 0 ? flags : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a yard. */
|
/** Create a yard. */
|
||||||
@@ -43,7 +77,7 @@ export class YardsService {
|
|||||||
insertAfterId: dto.insertAfterId,
|
insertAfterId: dto.insertAfterId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repository.create({
|
const yard = await this.repository.create({
|
||||||
code,
|
code,
|
||||||
label: dto.label,
|
label: dto.label,
|
||||||
country: dto.country,
|
country: dto.country,
|
||||||
@@ -51,15 +85,28 @@ export class YardsService {
|
|||||||
hasFacility: dto.hasFacility ?? false,
|
hasFacility: dto.hasFacility ?? false,
|
||||||
displayOrder,
|
displayOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const flags = this.pickSideFlags(dto);
|
||||||
|
if (flags) await this.facilities.upsertSideFlags(yard.id, flags);
|
||||||
|
return this.findById(yard.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update a yard. */
|
/** Update a yard. */
|
||||||
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
async update(id: string, dto: UpdateYardDto): Promise<Yard> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
|
if (dto.label !== undefined) await this.assertLabelAvailable(dto.label, id);
|
||||||
const updated = await this.repository.update(id, dto);
|
|
||||||
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
// Per-side flags live on yard_facilities, not the yards row — split them out.
|
||||||
return updated;
|
const flags = this.pickSideFlags(dto);
|
||||||
|
const yardDto = { ...dto };
|
||||||
|
for (const key of SIDE_FLAG_KEYS) delete yardDto[key];
|
||||||
|
|
||||||
|
if (Object.keys(yardDto).length > 0) {
|
||||||
|
const updated = await this.repository.update(id, yardDto);
|
||||||
|
if (!updated) throw new NotFoundException(`Yard ${id} not found`);
|
||||||
|
}
|
||||||
|
if (flags) await this.facilities.upsertSideFlags(id, flags);
|
||||||
|
return this.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** No two active yards may share a label (case/whitespace-insensitive). */
|
/** No two active yards may share a label (case/whitespace-insensitive). */
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { BookingJourneyService } from './booking-journey.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* autoPlaceOnFreedWagons: intercity cargo boards the wagons freed by earlier
|
||||||
|
* unloads. Exercised directly with a stubbed EntityManager — the surrounding
|
||||||
|
* loadBooking flow is integration-tested through the running app.
|
||||||
|
*/
|
||||||
|
describe('BookingJourneyService.autoPlaceOnFreedWagons', () => {
|
||||||
|
const service = new BookingJourneyService(
|
||||||
|
{} as never, // dataSource
|
||||||
|
{} as never, // yardFacilities
|
||||||
|
{} as never, // facilityHandling
|
||||||
|
{ emit: jest.fn() } as never, // events
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule = { id: 'sched-1', trainSetId: 'ts-1' };
|
||||||
|
const booking = {
|
||||||
|
id: 'booking-1',
|
||||||
|
reference: 'BK-1',
|
||||||
|
cargoTotalWeightVgm: 50,
|
||||||
|
freightType: 'CONTAINER',
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeManager = (slots: unknown[], existingAllocs: unknown[] = []) => {
|
||||||
|
const savedAllocs: Array<Record<string, unknown>> = [];
|
||||||
|
const savedItems: Array<Record<string, unknown>> = [];
|
||||||
|
const allocQb = {
|
||||||
|
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
innerJoin: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue(existingAllocs),
|
||||||
|
};
|
||||||
|
const slotQb = {
|
||||||
|
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||||
|
innerJoin: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue(slots),
|
||||||
|
};
|
||||||
|
let allocId = 0;
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity: { name?: string }) => {
|
||||||
|
const name = entity?.name;
|
||||||
|
if (name === 'WagonBookingAllocation') {
|
||||||
|
return {
|
||||||
|
createQueryBuilder: jest.fn(() => allocQb),
|
||||||
|
create: jest.fn((v: Record<string, unknown>) => v),
|
||||||
|
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||||
|
const row = { ...v, id: `alloc-${++allocId}` };
|
||||||
|
savedAllocs.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (name === 'TrainSetWagon') {
|
||||||
|
return { createQueryBuilder: jest.fn(() => slotQb) };
|
||||||
|
}
|
||||||
|
if (name === 'BookingContainer') {
|
||||||
|
return {
|
||||||
|
find: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'line-1',
|
||||||
|
containerNumber: 'LINE-001',
|
||||||
|
containerTypeId: 'ct-20',
|
||||||
|
units: [{ containerNumber: 'UNIT-001' }, { containerNumber: 'UNIT-002' }],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (name === 'WagonAllocationContainerItem') {
|
||||||
|
return {
|
||||||
|
create: jest.fn((v: Record<string, unknown>) => v),
|
||||||
|
save: jest.fn(async (v: Record<string, unknown>) => {
|
||||||
|
savedItems.push(v);
|
||||||
|
return v;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${name}`);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { manager, savedAllocs, savedItems };
|
||||||
|
};
|
||||||
|
|
||||||
|
const call = (manager: unknown) =>
|
||||||
|
(service as never as {
|
||||||
|
autoPlaceOnFreedWagons: (m: unknown, s: unknown, b: unknown) => Promise<void>;
|
||||||
|
}).autoPlaceOnFreedWagons(manager, schedule, booking);
|
||||||
|
|
||||||
|
it('places the booking on freed slots in consist order, with container items', async () => {
|
||||||
|
const slots = [
|
||||||
|
// Active cargo still riding — NOT freed.
|
||||||
|
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
|
||||||
|
// Freed by an earlier unload.
|
||||||
|
{ id: 'slot-2', sequenceNo: 2, capacityTons: 60, allocations: [{ status: 'DEPARTED' }] },
|
||||||
|
{ id: 'slot-3', sequenceNo: 3, capacityTons: 60, allocations: [] },
|
||||||
|
];
|
||||||
|
const { manager, savedAllocs, savedItems } = makeManager(slots);
|
||||||
|
|
||||||
|
await call(manager);
|
||||||
|
|
||||||
|
// 50 t fits on the first freed slot alone.
|
||||||
|
expect(savedAllocs).toHaveLength(1);
|
||||||
|
expect(savedAllocs[0]).toMatchObject({
|
||||||
|
trainSetWagonId: 'slot-2',
|
||||||
|
bookingId: 'booking-1',
|
||||||
|
allocatedWeightTons: 50,
|
||||||
|
status: 'LOADED',
|
||||||
|
});
|
||||||
|
// One item per physical unit, on the first allocation.
|
||||||
|
expect(savedItems.map((i) => i.containerNumber)).toEqual(['UNIT-001', 'UNIT-002']);
|
||||||
|
expect(savedItems.every((i) => i.wagonBookingAllocationId === 'alloc-1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spills over onto the next freed slot when one is not enough', async () => {
|
||||||
|
const slots = [
|
||||||
|
{ id: 'slot-2', sequenceNo: 2, capacityTons: 30, allocations: [{ status: 'DEPARTED' }] },
|
||||||
|
{ id: 'slot-3', sequenceNo: 3, capacityTons: 30, allocations: [] },
|
||||||
|
];
|
||||||
|
const { manager, savedAllocs } = makeManager(slots);
|
||||||
|
|
||||||
|
await call(manager);
|
||||||
|
|
||||||
|
expect(savedAllocs.map((a) => [a.trainSetWagonId, a.allocatedWeightTons])).toEqual([
|
||||||
|
['slot-2', 30],
|
||||||
|
['slot-3', 20],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the booking already has allocations', async () => {
|
||||||
|
const { manager, savedAllocs } = makeManager([], [{ id: 'existing' }]);
|
||||||
|
await call(manager);
|
||||||
|
expect(savedAllocs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads without allocation when no wagon is free', async () => {
|
||||||
|
const slots = [
|
||||||
|
{ id: 'slot-1', sequenceNo: 1, capacityTons: 60, allocations: [{ status: 'LOADED' }] },
|
||||||
|
];
|
||||||
|
const { manager, savedAllocs } = makeManager(slots);
|
||||||
|
await expect(call(manager)).resolves.toBeUndefined();
|
||||||
|
expect(savedAllocs).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,8 @@ import { Freight } from '@edr/types';
|
|||||||
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
import { YardFacilitiesService } from '../rule-engine/services/yard-facilities.service';
|
||||||
import { FacilityHandlingService } from './facility-handling.service';
|
import { FacilityHandlingService } from './facility-handling.service';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
|
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||||
|
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
@@ -82,6 +84,11 @@ export class BookingJourneyService {
|
|||||||
loadedAt: now,
|
loadedAt: now,
|
||||||
loadedByUserId: userId ?? null,
|
loadedByUserId: userId ?? null,
|
||||||
} as never);
|
} as never);
|
||||||
|
// Intercity cargo rides the wagons freed by earlier unloads along the
|
||||||
|
// corridor — place it before the status flip so it boards with a wagon.
|
||||||
|
if (booking.tradeDirection === 'DOMESTIC') {
|
||||||
|
await this.autoPlaceOnFreedWagons(manager, schedule, booking);
|
||||||
|
}
|
||||||
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
await this.setAllocationStatuses(manager, scheduleId, bookingId, 'LOADED');
|
||||||
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
|
// Keep the schedule↔booking link's tracking flag in sync — the dispatch
|
||||||
// readiness warnings and workspace badges read loading_status, not loadedAt.
|
// readiness warnings and workspace badges read loading_status, not loadedAt.
|
||||||
@@ -327,19 +334,48 @@ export class BookingJourneyService {
|
|||||||
RETURNING b.id, b.trade_direction`,
|
RETURNING b.id, b.trade_direction`,
|
||||||
[schedule.id, schedule.destinationStationId, now],
|
[schedule.id, schedule.destinationStationId, now],
|
||||||
);
|
);
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
// The facility took the cargo off the train at the final yard — raise its
|
||||||
|
// GRN, same as the per-booking unloadBooking() path does. Only when that
|
||||||
|
// yard also has a warehouse (or has no facility at all, e.g. Kality) does
|
||||||
|
// WarehouseInventoryService additionally get to allocate a warehouse/yard/
|
||||||
|
// zone row: a pure facility yard (Dire Dawa, Modjo, Sebeta, Adama) is
|
||||||
|
// fully represented by the facility event alone — there is nothing there
|
||||||
|
// for warehouse_inventory's NOT NULL warehouse/yard/zone to point at.
|
||||||
|
const facility = await this.yardFacilities.facilityForYard(schedule.destinationStationId);
|
||||||
|
const bookings = await manager
|
||||||
|
.getRepository(Booking)
|
||||||
|
.find({ where: { id: In(rows.map((r) => r.id)) }, relations: ['company'] });
|
||||||
|
const bookingById = new Map(bookings.map((b) => [b.id, b]));
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
// Intercity rows just completed — let a ONE_TIME contract close on delivery.
|
||||||
if (row.trade_direction === 'DOMESTIC') {
|
if (row.trade_direction === 'DOMESTIC') {
|
||||||
this.events.emit('booking.completed', { bookingId: row.id });
|
this.events.emit('booking.completed', { bookingId: row.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const booking = bookingById.get(row.id);
|
||||||
|
if (booking) {
|
||||||
|
await this.facilityHandling.recordHandling(manager, {
|
||||||
|
booking,
|
||||||
|
yardId: schedule.destinationStationId,
|
||||||
|
trainScheduleId: schedule.id,
|
||||||
|
eventType: 'UNLOAD',
|
||||||
|
occurredAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
|
// Same event the per-booking unloadBooking() path emits — WarehouseInventoryService
|
||||||
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
|
// listens for this to auto-create the warehouse_inventory row (import/intercity only,
|
||||||
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
|
// it filters EXPORT itself). The bulk SQL update above skipped this entirely, so
|
||||||
// bookings caught by this fallback never left "awaiting unload".
|
// bookings caught by this fallback never left "awaiting unload".
|
||||||
this.events.emit('booking.unloadedAtYard', {
|
if (row.trade_direction !== 'EXPORT' && (!facility?.hasFacility || facility.hasWarehouse)) {
|
||||||
bookingId: row.id,
|
this.events.emit('booking.unloadedAtYard', {
|
||||||
tradeDirection: row.trade_direction,
|
bookingId: row.id,
|
||||||
});
|
tradeDirection: row.trade_direction,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return rows.map((r) => r.id);
|
return rows.map((r) => r.id);
|
||||||
}
|
}
|
||||||
@@ -435,6 +471,100 @@ export class BookingJourneyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* INTERCITY ONLY. Intercity cargo does not get its own wagons — it rides the
|
||||||
|
* slots freed by cargo already unloaded along the corridor (e.g. import
|
||||||
|
* containers uncoupled at Dire Dawa). Staff pinning is a pre-dispatch tool,
|
||||||
|
* so a DOMESTIC booking loaded mid-corridor is auto-placed here: greedy over
|
||||||
|
* on-train slots (not DEPARTED) with no active cargo (every allocation
|
||||||
|
* DEPARTED, or none), in consist order, by capacity. Container numbers are
|
||||||
|
* copied onto the first allocation so the marshalling document and its
|
||||||
|
* 40ft/20ft tally stay truthful. When nothing is free the load proceeds
|
||||||
|
* unallocated — the marshalling document then lists the booking as on board
|
||||||
|
* without a recorded wagon.
|
||||||
|
* ponytail: remainder over free capacity is dumped on the last used slot
|
||||||
|
* (paper overload beats missing cargo); upgrade path is a capacity guard in
|
||||||
|
* the intercity accept step.
|
||||||
|
*/
|
||||||
|
private async autoPlaceOnFreedWagons(
|
||||||
|
manager: EntityManager,
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
booking: Booking,
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.allocationsForBooking(manager, schedule.id, booking.id);
|
||||||
|
if (existing.length) return;
|
||||||
|
|
||||||
|
const slots = await manager
|
||||||
|
.getRepository(TrainSetWagon)
|
||||||
|
.createQueryBuilder('slot')
|
||||||
|
.leftJoinAndSelect('slot.allocations', 'alloc')
|
||||||
|
.innerJoin(
|
||||||
|
TrainSchedule,
|
||||||
|
'schedule',
|
||||||
|
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||||
|
{ scheduleId: schedule.id },
|
||||||
|
)
|
||||||
|
.where(`slot.status != 'DEPARTED'`)
|
||||||
|
.orderBy('slot.sequence_no', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
const freed = slots.filter((slot) =>
|
||||||
|
(slot.allocations ?? []).every((a) => a.status === 'DEPARTED'),
|
||||||
|
);
|
||||||
|
if (!freed.length) {
|
||||||
|
this.logger.warn(
|
||||||
|
`No freed wagon for intercity booking ${booking.reference} on schedule ${schedule.id} — loading without wagon allocation`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = Number(booking.cargoTotalWeightVgm) || 0;
|
||||||
|
const allocRepo = manager.getRepository(WagonBookingAllocation);
|
||||||
|
const created: WagonBookingAllocation[] = [];
|
||||||
|
for (const slot of freed) {
|
||||||
|
const capacity = Number(slot.capacityTons) || remaining || 1;
|
||||||
|
const take = Math.min(remaining || capacity, capacity);
|
||||||
|
created.push(
|
||||||
|
await allocRepo.save(
|
||||||
|
allocRepo.create({
|
||||||
|
trainSetWagonId: slot.id,
|
||||||
|
bookingId: booking.id,
|
||||||
|
allocatedWeightTons: take,
|
||||||
|
loadType: booking.freightType ?? null,
|
||||||
|
status: 'LOADED',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
remaining = Math.max(0, remaining - take);
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
}
|
||||||
|
if (remaining > 0 && created.length) {
|
||||||
|
await allocRepo.update(created[created.length - 1].id, {
|
||||||
|
allocatedWeightTons: () => `allocated_weight_tons + ${remaining}`,
|
||||||
|
} as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container numbers onto the first allocation, from the booking's container
|
||||||
|
// lines (per physical unit when recorded, else per line).
|
||||||
|
const lines = await manager
|
||||||
|
.getRepository(BookingContainer)
|
||||||
|
.find({ where: { bookingId: booking.id }, relations: { units: true } });
|
||||||
|
const itemRepo = manager.getRepository(WagonAllocationContainerItem);
|
||||||
|
const first = created[0];
|
||||||
|
for (const line of lines) {
|
||||||
|
const units = line.units?.length ? line.units : [null];
|
||||||
|
for (const unit of units) {
|
||||||
|
await itemRepo.save(
|
||||||
|
itemRepo.create({
|
||||||
|
wagonBookingAllocationId: first.id,
|
||||||
|
bookingContainerId: line.id,
|
||||||
|
containerNumber: unit?.containerNumber ?? line.containerNumber ?? null,
|
||||||
|
containerTypeId: line.containerTypeId ?? null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async setAllocationStatuses(
|
private async setAllocationStatuses(
|
||||||
manager: EntityManager,
|
manager: EntityManager,
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
|
|||||||
@@ -711,6 +711,20 @@ export class TrainSchedulingController {
|
|||||||
return res.send(buffer);
|
return res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("schedules/:id/intercity/marshalling/document")
|
||||||
|
@TrainSchedulingView()
|
||||||
|
@ApiOperation({ summary: "Download current on-board intercity marshalling (Marshalling 2) PDF" })
|
||||||
|
async intercityMarshallingDocument(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { filename, buffer } = await this.trainSchedulingService.intercityMarshallingDocument(id);
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||||
|
res.setHeader("Content-Length", buffer.length);
|
||||||
|
return res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- batch / booking-window staff actions ----
|
// ---- batch / booking-window staff actions ----
|
||||||
|
|
||||||
@Post("schedules/:id/run-batch")
|
@Post("schedules/:id/run-batch")
|
||||||
|
|||||||
@@ -1121,6 +1121,132 @@ describe('TrainSchedulingService', () => {
|
|||||||
expect(html).not.toContain('empty)');
|
expect(html).not.toContain('empty)');
|
||||||
expect(html).not.toContain('EMPTY');
|
expect(html).not.toContain('EMPTY');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- intercity marshalling (Marshalling 2): the current on-board view ----
|
||||||
|
|
||||||
|
const onBoardView = (schedule: unknown) =>
|
||||||
|
(service as never as {
|
||||||
|
intercityOnBoardView: (s: unknown) => { wagons: unknown[]; unassignedBookings: unknown[] };
|
||||||
|
}).intercityOnBoardView(schedule);
|
||||||
|
|
||||||
|
const buildWithOpts = (schedule: unknown, opts: unknown) =>
|
||||||
|
(service as never as {
|
||||||
|
buildExportLoadListHtml: (s: unknown, o?: unknown) => string;
|
||||||
|
}).buildExportLoadListHtml(schedule, opts);
|
||||||
|
|
||||||
|
const allocWith = (over: Record<string, unknown>) => ({ ...loadedAllocation, ...over });
|
||||||
|
|
||||||
|
it('drops DEPARTED wagon slots and DEPARTED allocations from the on-board view', () => {
|
||||||
|
const schedule = {
|
||||||
|
trainSet: {
|
||||||
|
wagons: [
|
||||||
|
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||||||
|
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'LOADED' })]), status: 'DEPARTED' },
|
||||||
|
{
|
||||||
|
...makeWagon(3, 'W-003', [
|
||||||
|
allocWith({ status: 'LOADED', bookingId: 'booking-3' }),
|
||||||
|
allocWith({ status: 'DEPARTED', bookingId: 'booking-4' }),
|
||||||
|
]),
|
||||||
|
status: 'RESERVED',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { wagons } = onBoardView(schedule);
|
||||||
|
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||||
|
(w) => w.physicalWagon.wagonNumber,
|
||||||
|
);
|
||||||
|
expect(numbers).toEqual(['W-001', 'W-003']);
|
||||||
|
const w3 = (wagons as Array<{ physicalWagon: { wagonNumber: string }; allocations: Array<{ bookingId: string }> }>).find(
|
||||||
|
(w) => w.physicalWagon.wagonNumber === 'W-003',
|
||||||
|
);
|
||||||
|
expect(w3?.allocations.map((a) => a.bookingId)).toEqual(['booking-3']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an attached wagon whose cargo all departed, as an EMPTY row', () => {
|
||||||
|
const schedule = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
trainNumber: '8302',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
trainSet: {
|
||||||
|
wagons: [
|
||||||
|
{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' },
|
||||||
|
{ ...makeWagon(2, 'W-002', [allocWith({ status: 'DEPARTED' })]), status: 'RESERVED' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||||||
|
const html = buildWithOpts(schedule, { wagons, unassignedBookings });
|
||||||
|
expect(html).toContain('W-002');
|
||||||
|
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||||
|
expect(html).toContain('2 (1 empty)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides a leg slot (boardYardId set) until it has confirmed LOADED cargo', () => {
|
||||||
|
const legWagonEmpty = { ...makeWagon(2, 'W-LEG', [allocWith({ status: 'RESERVED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||||
|
const legWagonLoaded = { ...makeWagon(3, 'W-LEG2', [allocWith({ status: 'LOADED' })]), status: 'RESERVED', boardYardId: 'yard-mid' };
|
||||||
|
const schedule = {
|
||||||
|
trainSet: { wagons: [legWagonEmpty, legWagonLoaded] },
|
||||||
|
scheduleBookings: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { wagons } = onBoardView(schedule);
|
||||||
|
const numbers = (wagons as Array<{ physicalWagon: { wagonNumber: string } }>).map(
|
||||||
|
(w) => w.physicalWagon.wagonNumber,
|
||||||
|
);
|
||||||
|
expect(numbers).toEqual(['W-LEG2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => {
|
||||||
|
const rider = {
|
||||||
|
id: 'booking-9',
|
||||||
|
reference: 'BK-2026-000009',
|
||||||
|
status: 'IN_TRANSIT',
|
||||||
|
company: { name: 'Rider Co' },
|
||||||
|
cargoType: { cargoTypeName: 'Cement', code: 'CEM' },
|
||||||
|
originYard: { label: 'Adama' },
|
||||||
|
destinationYard: { label: 'Dire Dawa' },
|
||||||
|
bookingContainers: [{ containerNumber: 'RIDE-001' }],
|
||||||
|
};
|
||||||
|
const done = { id: 'booking-8', reference: 'BK-2026-000008', status: 'COMPLETED' };
|
||||||
|
const schedule = {
|
||||||
|
id: 'schedule-1',
|
||||||
|
trainNumber: '8302',
|
||||||
|
direction: 'EXPORT',
|
||||||
|
trainSet: { wagons: [{ ...makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]), status: 'RESERVED' }] },
|
||||||
|
scheduleBookings: [{ bookingId: rider.id, booking: rider }, { bookingId: done.id, booking: done }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { wagons, unassignedBookings } = onBoardView(schedule);
|
||||||
|
expect((unassignedBookings as Array<{ id: string }>).map((b) => b.id)).toEqual(['booking-9']);
|
||||||
|
|
||||||
|
const html = buildWithOpts(schedule, {
|
||||||
|
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||||
|
positionLabel: 'After Dire Dawa',
|
||||||
|
wagons,
|
||||||
|
unassignedBookings,
|
||||||
|
});
|
||||||
|
expect(html).toContain('ON BOARD — WAGON NOT RECORDED');
|
||||||
|
expect(html).toContain('BK-2026-000009');
|
||||||
|
expect(html).toContain('RIDE-001');
|
||||||
|
expect(html).not.toContain('BK-2026-000008');
|
||||||
|
expect(html).toContain('Intercity Marshalling Document / Load List (Marshalling 2)');
|
||||||
|
expect(html).toContain('After Dire Dawa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the intercity marshalling document for a train that has not been dispatched', async () => {
|
||||||
|
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue({
|
||||||
|
id: 'schedule-1',
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.intercityMarshallingDocument('schedule-1'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('moveWagonLoad — staff rearrange', () => {
|
describe('moveWagonLoad — staff rearrange', () => {
|
||||||
|
|||||||
@@ -2928,6 +2928,78 @@ export class TrainSchedulingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The train's composition as it stands right now — the source for the
|
||||||
|
* intercity marshalling ("Marshalling 2") document printed after mid-corridor
|
||||||
|
* station work. A wagon slot is on the train iff it has not DEPARTED and
|
||||||
|
* either rides the whole corridor (no boardYardId) or has confirmed LOADED
|
||||||
|
* cargo. Kept wagons carry only their LOADED allocations (DEPARTED =
|
||||||
|
* unloaded, PLANNED/RESERVED = not on board yet).
|
||||||
|
* ponytail: boardYardId presence is the "boarded yet?" heuristic; upgrade
|
||||||
|
* path is comparing the board yard against the latest checkpoint sequence.
|
||||||
|
*/
|
||||||
|
private intercityOnBoardView(schedule: TrainSchedule): {
|
||||||
|
wagons: TrainSetWagon[];
|
||||||
|
unassignedBookings: Booking[];
|
||||||
|
} {
|
||||||
|
const wagons = (schedule.trainSet?.wagons ?? [])
|
||||||
|
.filter((wagon) => {
|
||||||
|
if (wagon.status === 'DEPARTED') return false;
|
||||||
|
const hasLoaded = (wagon.allocations ?? []).some((a) => a.status === 'LOADED');
|
||||||
|
return wagon.boardYardId == null || hasLoaded;
|
||||||
|
})
|
||||||
|
.map((wagon) => ({
|
||||||
|
...wagon,
|
||||||
|
allocations: (wagon.allocations ?? []).filter((a) => a.status === 'LOADED'),
|
||||||
|
})) as TrainSetWagon[];
|
||||||
|
|
||||||
|
const onBoardBookingIds = new Set(
|
||||||
|
wagons.flatMap((wagon) => (wagon.allocations ?? []).map((a) => a.bookingId)),
|
||||||
|
);
|
||||||
|
// IN_TRANSIT bookings with no kept allocation: intercity riders accepted
|
||||||
|
// after dispatch (never wagon-pinned) and loads whose allocation was never
|
||||||
|
// confirmed LOADED. They are physically on the train, so they get a row.
|
||||||
|
const unassignedBookings = (schedule.scheduleBookings ?? [])
|
||||||
|
.map((link) => link.booking)
|
||||||
|
.filter((booking): booking is Booking => Boolean(booking))
|
||||||
|
.filter((booking) => booking.status === 'IN_TRANSIT' && !onBoardBookingIds.has(booking.id));
|
||||||
|
|
||||||
|
return { wagons, unassignedBookings };
|
||||||
|
}
|
||||||
|
|
||||||
|
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
|
if (!schedule) {
|
||||||
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
|
}
|
||||||
|
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Intercity marshalling document applies only to dispatched or arrived trains',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||||
|
const last = checkpoints[checkpoints.length - 1];
|
||||||
|
const positionLabel = last
|
||||||
|
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
|
||||||
|
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
|
||||||
|
|
||||||
|
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
|
||||||
|
const html = this.buildExportLoadListHtml(schedule, {
|
||||||
|
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
|
||||||
|
positionLabel,
|
||||||
|
wagons,
|
||||||
|
unassignedBookings,
|
||||||
|
});
|
||||||
|
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
|
||||||
|
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
|
||||||
|
const reference = schedule.trainNumber ?? schedule.id;
|
||||||
|
return {
|
||||||
|
filename: `intercity-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||||
|
buffer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A container item's size in feet, for the marshalling document's 40ft/20ft
|
* A container item's size in feet, for the marshalling document's 40ft/20ft
|
||||||
* tally. Two independent sources, since only one is populated depending on
|
* tally. Two independent sources, since only one is populated depending on
|
||||||
@@ -2954,7 +3026,15 @@ export class TrainSchedulingService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
private buildExportLoadListHtml(
|
||||||
|
schedule: TrainSchedule,
|
||||||
|
opts?: {
|
||||||
|
title?: string;
|
||||||
|
positionLabel?: string;
|
||||||
|
wagons?: TrainSetWagon[];
|
||||||
|
unassignedBookings?: Booking[];
|
||||||
|
},
|
||||||
|
): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? '-')
|
String(value ?? '-')
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
@@ -2967,7 +3047,7 @@ export class TrainSchedulingService {
|
|||||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||||
// The document is checked against the physical train, so it has to run in
|
// The document is checked against the physical train, so it has to run in
|
||||||
// consist order — the relation comes back unordered.
|
// consist order — the relation comes back unordered.
|
||||||
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
|
const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort(
|
||||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||||
);
|
);
|
||||||
const rows = wagons
|
const rows = wagons
|
||||||
@@ -3011,6 +3091,29 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.join('');
|
.join('');
|
||||||
|
// Intercity riders accepted after dispatch have no wagon slot recorded —
|
||||||
|
// they are still physically on the train, so they get rows of their own.
|
||||||
|
const unassigned = opts?.unassignedBookings ?? [];
|
||||||
|
const unassignedRows = unassigned.length
|
||||||
|
? `<tr class="empty"><td colspan="11">ON BOARD — WAGON NOT RECORDED</td></tr>` +
|
||||||
|
unassigned
|
||||||
|
.map((booking) => {
|
||||||
|
const containerNumbers = (booking.bookingContainers ?? [])
|
||||||
|
.map((container) => container.containerNumber)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ');
|
||||||
|
const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`;
|
||||||
|
return `<tr>
|
||||||
|
<td colspan="6">${esc(booking.reference)} — ${esc(leg)}</td>
|
||||||
|
<td>${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)}</td>
|
||||||
|
<td>${esc(booking.company?.name)}</td>
|
||||||
|
<td>${esc(containerNumbers)}</td>
|
||||||
|
<td>-</td>
|
||||||
|
<td>-</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
: '';
|
||||||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||||
const totalWeight = wagons.reduce(
|
const totalWeight = wagons.reduce(
|
||||||
(sum, wagon) =>
|
(sum, wagon) =>
|
||||||
@@ -3034,7 +3137,7 @@ export class TrainSchedulingService {
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Export Marshalling Document</title>
|
<title>${esc(opts?.title ?? 'Export Marshalling Document')}</title>
|
||||||
<style>
|
<style>
|
||||||
@page { size: A4 landscape; margin: 10mm; }
|
@page { size: A4 landscape; margin: 10mm; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -3063,7 +3166,7 @@ export class TrainSchedulingService {
|
|||||||
<div class="top">
|
<div class="top">
|
||||||
<div>
|
<div>
|
||||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||||
<h1>Export Marshalling Document / Load List</h1>
|
<h1>${esc(opts?.title ?? 'Export Marshalling Document / Load List')}</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
Train / Schedule
|
Train / Schedule
|
||||||
@@ -3088,6 +3191,7 @@ export class TrainSchedulingService {
|
|||||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||||
|
${opts?.positionLabel ? `<div class="tile"><span>Current position</span><strong>${esc(opts.positionLabel)}</strong></div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
@@ -3108,6 +3212,7 @@ export class TrainSchedulingService {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||||
|
${unassignedRows}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -27,12 +27,20 @@ async function main() {
|
|||||||
const dataSource = app.get(DataSource);
|
const dataSource = app.get(DataSource);
|
||||||
const inventory = app.get(WarehouseInventoryService);
|
const inventory = app.get(WarehouseInventoryService);
|
||||||
|
|
||||||
|
// Skip bookings destined for a pure facility yard (has a facility but no
|
||||||
|
// warehouse, e.g. Dire Dawa) — those are fully represented by their
|
||||||
|
// facility_handling_events UNLOAD record, not a warehouse_inventory row.
|
||||||
|
// Same gate as BookingJourneyService.autoArriveAtFinalYard.
|
||||||
const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
|
const bookings: { id: string; tradeDirection: string }[] = await dataSource.query(
|
||||||
`SELECT b.id, b.trade_direction AS "tradeDirection"
|
`SELECT b.id, b.trade_direction AS "tradeDirection"
|
||||||
FROM freight.bookings b
|
FROM freight.bookings b
|
||||||
|
JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||||
|
LEFT JOIN freight.yard_facilities yf
|
||||||
|
ON yf.yard_id = dy.id AND yf.deleted_at IS NULL AND yf.is_active = true
|
||||||
WHERE b.deleted_at IS NULL
|
WHERE b.deleted_at IS NULL
|
||||||
AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
|
AND b.trade_direction IN ('IMPORT', 'DOMESTIC')
|
||||||
AND b.status IN ('ARRIVED', 'COMPLETED')
|
AND b.status IN ('ARRIVED', 'COMPLETED')
|
||||||
|
AND NOT (dy.has_facility = true AND COALESCE(yf.has_warehouse, false) = false)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM freight.warehouse_inventory wi
|
SELECT 1 FROM freight.warehouse_inventory wi
|
||||||
WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
|
WHERE wi.booking_id = b.id AND wi.deleted_at IS NULL
|
||||||
|
|||||||
@@ -416,6 +416,8 @@ export const URL_CONSTANTS = {
|
|||||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||||
|
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||||
|
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||||
CHECKPOINTS: (id: string) =>
|
CHECKPOINTS: (id: string) =>
|
||||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useMemo, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import {
|
||||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
useAllExternalUsers,
|
||||||
|
userTypeEnum,
|
||||||
|
} from "@/super-admin/hooks/useExternalUsers";
|
||||||
import {
|
import {
|
||||||
ALL_TRADE_DIRECTIONS,
|
ALL_TRADE_DIRECTIONS,
|
||||||
TRADE_DIRECTION_LABELS,
|
TRADE_DIRECTION_LABELS,
|
||||||
@@ -34,17 +36,12 @@ type EmployeeRow = {
|
|||||||
* and overview to the checked directions. Admins always bypass the scope.
|
* and overview to the checked directions. Admins always bypass the scope.
|
||||||
*/
|
*/
|
||||||
export default function TradeAccessPage() {
|
export default function TradeAccessPage() {
|
||||||
const { user } = useAuth();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const organizationId =
|
const { data: usersResponse, isLoading: usersLoading } = useAllExternalUsers({
|
||||||
user?.employee && user.employee.length > 0
|
userType: userTypeEnum.employee,
|
||||||
? user.employee[0].organizationId
|
take: 3000,
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({
|
|
||||||
organizationId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: configs, isLoading: configsLoading } = useQuery({
|
const { data: configs, isLoading: configsLoading } = useQuery({
|
||||||
@@ -76,12 +73,12 @@ export default function TradeAccessPage() {
|
|||||||
}, [configs]);
|
}, [configs]);
|
||||||
|
|
||||||
const rows: EmployeeRow[] = useMemo(() => {
|
const rows: EmployeeRow[] = useMemo(() => {
|
||||||
const items = employeesResponseByOrg?.items ?? [];
|
const items = usersResponse?.items ?? [];
|
||||||
const mapped = items
|
const mapped = items
|
||||||
.map((item: { user?: { id?: string; name?: { en?: string }; email?: string; username?: string } }) => ({
|
.map((u) => ({
|
||||||
userId: item.user?.id ?? "",
|
userId: u.id ?? "",
|
||||||
name: item.user?.name?.en ?? item.user?.username ?? "—",
|
name: u.name?.en ?? u.username ?? "—",
|
||||||
email: item.user?.email ?? "",
|
email: u.email ?? "",
|
||||||
}))
|
}))
|
||||||
.filter((r: EmployeeRow) => r.userId);
|
.filter((r: EmployeeRow) => r.userId);
|
||||||
const term = search.trim().toLowerCase();
|
const term = search.trim().toLowerCase();
|
||||||
@@ -91,7 +88,7 @@ export default function TradeAccessPage() {
|
|||||||
r.name.toLowerCase().includes(term) ||
|
r.name.toLowerCase().includes(term) ||
|
||||||
r.email.toLowerCase().includes(term),
|
r.email.toLowerCase().includes(term),
|
||||||
);
|
);
|
||||||
}, [employeesResponseByOrg, search]);
|
}, [usersResponse, search]);
|
||||||
|
|
||||||
// No row yet = unrestricted, so render as all three checked.
|
// No row yet = unrestricted, so render as all three checked.
|
||||||
const directionsFor = (userId: string): TradeDirection[] =>
|
const directionsFor = (userId: string): TradeDirection[] =>
|
||||||
@@ -105,7 +102,7 @@ export default function TradeAccessPage() {
|
|||||||
saveMutation.mutate({ userId, directions: next });
|
saveMutation.mutate({ userId, directions: next });
|
||||||
};
|
};
|
||||||
|
|
||||||
const loading = isLoadingEmployeesByOrg || configsLoading;
|
const loading = usersLoading || configsLoading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
|
|||||||
@@ -691,6 +691,30 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
accessorKey: "hasFacility",
|
accessorKey: "hasFacility",
|
||||||
format: "boolean",
|
format: "boolean",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "hasContainerFacilityOrigin",
|
||||||
|
header: "Container origin",
|
||||||
|
accessorKey: "hasContainerFacilityOrigin",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hasContainerFacilityDestination",
|
||||||
|
header: "Container dest.",
|
||||||
|
accessorKey: "hasContainerFacilityDestination",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hasBulkFacilityOrigin",
|
||||||
|
header: "Bulk origin",
|
||||||
|
accessorKey: "hasBulkFacilityOrigin",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hasBulkFacilityDestination",
|
||||||
|
header: "Bulk dest.",
|
||||||
|
accessorKey: "hasBulkFacilityDestination",
|
||||||
|
format: "boolean",
|
||||||
|
},
|
||||||
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
|
||||||
activeColumn,
|
activeColumn,
|
||||||
],
|
],
|
||||||
@@ -710,6 +734,34 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
description:
|
description:
|
||||||
"This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.",
|
"This yard can load and unload cargo. Intercity bookings can only be loaded at their origin and unloaded at their destination when it is a facility.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "hasContainerFacilityOrigin",
|
||||||
|
label: "Container facility — origin",
|
||||||
|
type: "boolean",
|
||||||
|
description: "Can load containers onto a train. Offered as a contract origin for container freight.",
|
||||||
|
showIf: (values) => Boolean(values.hasFacility),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hasContainerFacilityDestination",
|
||||||
|
label: "Container facility — destination",
|
||||||
|
type: "boolean",
|
||||||
|
description: "Can receive containers off a train. Offered as a contract destination for container freight.",
|
||||||
|
showIf: (values) => Boolean(values.hasFacility),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hasBulkFacilityOrigin",
|
||||||
|
label: "Bulk facility — origin",
|
||||||
|
type: "boolean",
|
||||||
|
description: "Can load bulk cargo onto a train. Offered as a contract origin for bulk freight.",
|
||||||
|
showIf: (values) => Boolean(values.hasFacility),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "hasBulkFacilityDestination",
|
||||||
|
label: "Bulk facility — destination",
|
||||||
|
type: "boolean",
|
||||||
|
description: "Can receive bulk cargo off a train. Offered as a contract destination for bulk freight.",
|
||||||
|
showIf: (values) => Boolean(values.hasFacility),
|
||||||
|
},
|
||||||
{ name: "isActive", label: "Active", type: "boolean" },
|
{ name: "isActive", label: "Active", type: "boolean" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
Flag,
|
Flag,
|
||||||
MapPin,
|
MapPin,
|
||||||
Navigation,
|
Navigation,
|
||||||
@@ -40,6 +41,8 @@ import { freightBrand } from "@/theme/freight-brand";
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||||
|
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||||
|
|
||||||
const parseError = (error: unknown, fallback: string) => {
|
const parseError = (error: unknown, fallback: string) => {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
@@ -160,6 +163,31 @@ export default function TrainScheduleTrackPage() {
|
|||||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// Marshalling 2: the current on-board list, reprinted after station work.
|
||||||
|
const intercityMarshalling = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||||
|
});
|
||||||
|
const openIntercityMarshalling = async () => {
|
||||||
|
const pdfWindow = window.open("", "_blank");
|
||||||
|
try {
|
||||||
|
const blob = await intercityMarshalling.mutateAsync();
|
||||||
|
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
|
||||||
|
toast({
|
||||||
|
title: "Intercity marshalling ready",
|
||||||
|
description: opened
|
||||||
|
? "The PDF opened in a browser tab for printing or saving."
|
||||||
|
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
pdfWindow?.close();
|
||||||
|
toast({
|
||||||
|
title: "Could not open intercity marshalling document",
|
||||||
|
description: parseError(error, "Please try again"),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
const [yardModal, setYardModal] = useState<{
|
const [yardModal, setYardModal] = useState<{
|
||||||
station: TrackStation;
|
station: TrackStation;
|
||||||
isFinal: boolean;
|
isFinal: boolean;
|
||||||
@@ -249,17 +277,32 @@ export default function TrainScheduleTrackPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<Button
|
<Group justify="space-between" w="100%">
|
||||||
component={Link}
|
<Button
|
||||||
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
component={Link}
|
||||||
variant="subtle"
|
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="compact-sm"
|
color="gray"
|
||||||
leftSection={<ArrowLeft size={16} />}
|
size="compact-sm"
|
||||||
w="fit-content"
|
leftSection={<ArrowLeft size={16} />}
|
||||||
>
|
w="fit-content"
|
||||||
Back to schedule
|
>
|
||||||
</Button>
|
Back to schedule
|
||||||
|
</Button>
|
||||||
|
{inTransit || arrived ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="lg"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={<FileText size={16} />}
|
||||||
|
loading={intercityMarshalling.isPending}
|
||||||
|
onClick={() => void openIntercityMarshalling()}
|
||||||
|
>
|
||||||
|
Intercity Marshalling
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
|
||||||
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
|
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
|
||||||
<Paper
|
<Paper
|
||||||
|
|||||||
@@ -208,10 +208,12 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||||
const downloadMarshalling = useMutation({
|
const downloadMarshalling = useMutation({
|
||||||
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
|
||||||
direction === "EXPORT"
|
variant === "INTERCITY"
|
||||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
: direction === "EXPORT"
|
||||||
|
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||||
|
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -426,14 +428,21 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
title?: string;
|
title?: string;
|
||||||
successDescription?: string;
|
successDescription?: string;
|
||||||
errorTitle?: string;
|
errorTitle?: string;
|
||||||
|
variant?: "INTERCITY";
|
||||||
}) => {
|
}) => {
|
||||||
const pdfWindow = window.open("", "_blank");
|
const pdfWindow = window.open("", "_blank");
|
||||||
try {
|
try {
|
||||||
const blob = await downloadMarshalling.mutateAsync({
|
const blob = await downloadMarshalling.mutateAsync({
|
||||||
id: scheduleId,
|
id: scheduleId,
|
||||||
direction: schedule.direction,
|
direction: schedule.direction,
|
||||||
|
variant: options?.variant,
|
||||||
});
|
});
|
||||||
const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling";
|
const prefix =
|
||||||
|
options?.variant === "INTERCITY"
|
||||||
|
? "intercity-marshalling"
|
||||||
|
: schedule.direction === "EXPORT"
|
||||||
|
? "export-marshalling"
|
||||||
|
: "import-marshalling";
|
||||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||||
toast({
|
toast({
|
||||||
@@ -973,6 +982,24 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
Marshalling PDF
|
Marshalling PDF
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
radius="lg"
|
||||||
|
size="sm"
|
||||||
|
leftSection={<FileText size={16} />}
|
||||||
|
loading={downloadMarshalling.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
void openMarshallingDocument({
|
||||||
|
title: "Intercity marshalling ready",
|
||||||
|
variant: "INTERCITY",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Intercity Marshalling
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|||||||
@@ -624,6 +624,16 @@ export const trainSchedulingService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
downloadIntercityMarshallingDocument: async (
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<Blob> => {
|
||||||
|
const response = await client.get(
|
||||||
|
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_MARSHALLING_DOCUMENT(scheduleId),
|
||||||
|
{ responseType: "blob" },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||||
const response = await client.get<TrainTrackResponse>(
|
const response = await client.get<TrainTrackResponse>(
|
||||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export enum userTypeEnum {
|
|||||||
external = "external_organization",
|
external = "external_organization",
|
||||||
individual = "individual",
|
individual = "individual",
|
||||||
externalUsers = "external_organization,individual",
|
externalUsers = "external_organization,individual",
|
||||||
|
employee = "employee",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExternalQueryParams {
|
export interface ExternalQueryParams {
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ import { BlockedSeatRevenueLossStat } from '@edr/types';
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { ReportsService } from '../reports/reports.service';
|
import { ReportsService } from '../reports/reports.service';
|
||||||
|
|
||||||
/** Window the dashboard's blocked-seat loss roll-up covers. Matches the report's default. */
|
|
||||||
const BLOCKED_SEAT_LOSS_PERIOD_DAYS = 30;
|
|
||||||
|
|
||||||
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
|
/** Shown when nothing is blocked, or when the loss roll-up could not be computed. */
|
||||||
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
|
const EMPTY_BLOCKED_SEAT_LOSS: BlockedSeatRevenueLossStat = {
|
||||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
periodDays: null,
|
||||||
lossByCurrency: [],
|
lossByCurrency: [],
|
||||||
schedulesAffected: 0,
|
schedulesAffected: 0,
|
||||||
blockedSeatCount: 0,
|
blockedSeatCount: 0,
|
||||||
@@ -85,7 +82,7 @@ export class DashboardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact roll-up of the Blocked Seat Revenue Loss report over the last 30 days.
|
* Compact roll-up of the Blocked Seat Revenue Loss report over its full history.
|
||||||
*
|
*
|
||||||
* Reuses the report service rather than re-deriving the rule — there is exactly one
|
* Reuses the report service rather than re-deriving the rule — there is exactly one
|
||||||
* definition of what a blocked seat costs. A failure here degrades to zeroes instead of
|
* definition of what a blocked seat costs. A failure here degrades to zeroes instead of
|
||||||
@@ -98,7 +95,7 @@ export class DashboardService {
|
|||||||
const { summary } = report;
|
const { summary } = report;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
periodDays: BLOCKED_SEAT_LOSS_PERIOD_DAYS,
|
periodDays: null,
|
||||||
lossByCurrency: summary.lossByCurrency,
|
lossByCurrency: summary.lossByCurrency,
|
||||||
schedulesAffected: summary.schedulesAffected,
|
schedulesAffected: summary.schedulesAffected,
|
||||||
blockedSeatCount: summary.blockedSeatCount,
|
blockedSeatCount: summary.blockedSeatCount,
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ export function assembleReport(
|
|||||||
// so a plain sum here never crosses currencies.
|
// so a plain sum here never crosses currencies.
|
||||||
const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0);
|
const estimatedLossMinor = blocks.reduce((sum, b) => sum + b.estimatedLossMinor, 0);
|
||||||
const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB';
|
const currency = blocks.find((b) => b.currency)?.currency ?? 'ETB';
|
||||||
|
const blockedByNames = [...new Set(blocks.map(blockerDisplayName))];
|
||||||
|
|
||||||
scheduleRows.push({
|
scheduleRows.push({
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
@@ -359,6 +360,7 @@ export function assembleReport(
|
|||||||
soldSeats,
|
soldSeats,
|
||||||
loadFactorPercent: +(loadFactor * 100).toFixed(1),
|
loadFactorPercent: +(loadFactor * 100).toFixed(1),
|
||||||
blockedSeatCount: blocks.length,
|
blockedSeatCount: blocks.length,
|
||||||
|
blockedByNames,
|
||||||
estimatedLossMinor,
|
estimatedLossMinor,
|
||||||
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
|
adjustedLossMinor: Math.round(estimatedLossMinor * loadFactor),
|
||||||
currency,
|
currency,
|
||||||
@@ -525,6 +527,11 @@ function groupByReasonCategory(
|
|||||||
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
return [...groups.values()].sort((a, b) => b.estimatedLossMinor - a.estimatedLossMinor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Legacy rows carry no name; 'SYSTEM' blocks are not a person. */
|
||||||
|
function blockerDisplayName(block: Pick<BlockedSeatLossDetail, 'blockedBy' | 'blockedByName'>): string {
|
||||||
|
return block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown');
|
||||||
|
}
|
||||||
|
|
||||||
function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] {
|
function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlocker[] {
|
||||||
const groups = new Map<string, BlockedSeatLossByBlocker>();
|
const groups = new Map<string, BlockedSeatLossByBlocker>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -532,9 +539,7 @@ function groupByBlocker(rows: BlockedSeatLossSchedule[]): BlockedSeatLossByBlock
|
|||||||
const key = `${block.blockedBy}|${block.currency}`;
|
const key = `${block.blockedBy}|${block.currency}`;
|
||||||
const entry = groups.get(key) ?? {
|
const entry = groups.get(key) ?? {
|
||||||
blockedBy: block.blockedBy,
|
blockedBy: block.blockedBy,
|
||||||
// Legacy rows carry no name; 'SYSTEM' blocks are not a person.
|
blockedByName: blockerDisplayName(block),
|
||||||
blockedByName:
|
|
||||||
block.blockedByName ?? (block.blockedBy === 'SYSTEM' ? 'System' : 'Unknown'),
|
|
||||||
count: 0,
|
count: 0,
|
||||||
estimatedLossMinor: 0,
|
estimatedLossMinor: 0,
|
||||||
currency: block.currency,
|
currency: block.currency,
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ export enum BlockedSeatsLossSortBy {
|
|||||||
export class BlockedSeatsRevenueLossQueryDto {
|
export class BlockedSeatsRevenueLossQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '2026-07-01',
|
example: '2026-07-01',
|
||||||
description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to 30 days ago.',
|
description: 'Start of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the earliest scheduled departure on record.',
|
||||||
})
|
})
|
||||||
@IsOptional() @IsDateString() dateFrom?: string;
|
@IsOptional() @IsDateString() dateFrom?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: '2026-07-31',
|
example: '2026-07-31',
|
||||||
description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to today.',
|
description: 'End of the window, inclusive, matched on TrainSchedule.departureAt. Defaults to the latest scheduled departure on record.',
|
||||||
})
|
})
|
||||||
@IsOptional() @IsDateString() dateTo?: string;
|
@IsOptional() @IsDateString() dateTo?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,6 @@ import {
|
|||||||
|
|
||||||
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
/** Fares are quoted at the local tariff unless the caller asks otherwise. */
|
||||||
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
|
const DEFAULT_LOSS_NATIONALITY = "Ethiopian";
|
||||||
/** Window used when the caller supplies neither `dateFrom` nor `dateTo`. */
|
|
||||||
const DEFAULT_LOSS_WINDOW_DAYS = 30;
|
|
||||||
const DEFAULT_LOSS_PAGE_SIZE = 25;
|
const DEFAULT_LOSS_PAGE_SIZE = 25;
|
||||||
/** How many schedules are priced in parallel. Keeps the DB from being flooded. */
|
/** How many schedules are priced in parallel. Keeps the DB from being flooded. */
|
||||||
const FARE_QUOTE_CONCURRENCY = 4;
|
const FARE_QUOTE_CONCURRENCY = 4;
|
||||||
@@ -42,25 +40,6 @@ const EMPTY_LOSS_INPUT: LossCalculatorInput = {
|
|||||||
blocks: [],
|
blocks: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
|
|
||||||
* matching `generateReport`. Defaults to the last 30 days of departures.
|
|
||||||
*/
|
|
||||||
function resolveWindow(
|
|
||||||
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
|
||||||
now: Date,
|
|
||||||
): { dateFrom: Date; dateTo: Date } {
|
|
||||||
const dateTo = query.dateTo ? new Date(query.dateTo) : new Date(now);
|
|
||||||
dateTo.setHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
const dateFrom = query.dateFrom
|
|
||||||
? new Date(query.dateFrom)
|
|
||||||
: new Date(dateTo.getTime() - DEFAULT_LOSS_WINDOW_DAYS * 24 * 60 * 60 * 1000);
|
|
||||||
dateFrom.setHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
return { dateFrom, dateTo };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */
|
/** Mirrors the fare engine's own LOCAL/INTERNATIONAL split. */
|
||||||
function resolveNationalityType(nationality: string): string {
|
function resolveNationalityType(nationality: string): string {
|
||||||
const upper = nationality.toUpperCase();
|
const upper = nationality.toUpperCase();
|
||||||
@@ -1235,6 +1214,37 @@ export class ReportsService {
|
|||||||
|
|
||||||
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
// ── Blocked Seat Revenue Loss ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the reporting window. Both bounds are inclusive and snap to whole local days,
|
||||||
|
* matching `generateReport`. When the caller supplies neither bound, defaults to the full
|
||||||
|
* history of scheduled departures on record — the earliest `TrainSchedule.departureAt` to
|
||||||
|
* the latest — not a rolling window, so nothing ages out of the report on its own.
|
||||||
|
*/
|
||||||
|
private async resolveWindow(
|
||||||
|
query: Pick<BlockedSeatsRevenueLossQueryDto, "dateFrom" | "dateTo">,
|
||||||
|
now: Date,
|
||||||
|
): Promise<{ dateFrom: Date; dateTo: Date }> {
|
||||||
|
let dateFrom: Date;
|
||||||
|
let dateTo: Date;
|
||||||
|
|
||||||
|
if (query.dateFrom && query.dateTo) {
|
||||||
|
dateFrom = new Date(query.dateFrom);
|
||||||
|
dateTo = new Date(query.dateTo);
|
||||||
|
} else {
|
||||||
|
const bounds = await this.prisma.trainSchedule.aggregate({
|
||||||
|
_min: { departureAt: true },
|
||||||
|
_max: { departureAt: true },
|
||||||
|
});
|
||||||
|
dateFrom = query.dateFrom ? new Date(query.dateFrom) : (bounds._min.departureAt ?? new Date(now));
|
||||||
|
dateTo = query.dateTo ? new Date(query.dateTo) : (bounds._max.departureAt ?? new Date(now));
|
||||||
|
}
|
||||||
|
|
||||||
|
dateTo.setHours(23, 59, 59, 999);
|
||||||
|
dateFrom.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return { dateFrom, dateTo };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Potential revenue lost to seats that were blocked and therefore never sellable.
|
* Potential revenue lost to seats that were blocked and therefore never sellable.
|
||||||
*
|
*
|
||||||
@@ -1247,7 +1257,7 @@ export class ReportsService {
|
|||||||
query: BlockedSeatsRevenueLossQueryDto,
|
query: BlockedSeatsRevenueLossQueryDto,
|
||||||
): Promise<BlockedSeatRevenueLossReport> {
|
): Promise<BlockedSeatRevenueLossReport> {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { dateFrom, dateTo } = resolveWindow(query, now);
|
const { dateFrom, dateTo } = await this.resolveWindow(query, now);
|
||||||
const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY;
|
const nationalityAssumption = query.nationality?.trim() || DEFAULT_LOSS_NATIONALITY;
|
||||||
const nationalityType = resolveNationalityType(nationalityAssumption);
|
const nationalityType = resolveNationalityType(nationalityAssumption);
|
||||||
|
|
||||||
|
|||||||
@@ -329,16 +329,16 @@ function DashboardPageContent() {
|
|||||||
|
|
||||||
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
|
{/* Blocked-seat revenue loss — rides the same backoffice-stats payload, so the
|
||||||
dashboard makes no extra request for it. */}
|
dashboard makes no extra request for it. */}
|
||||||
<div className="card flex flex-col gap-3">
|
<div className="card flex flex-col gap-3 border-rose-200 bg-rose-50/60 dark:border-rose-900/50 dark:bg-rose-950/20">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="rounded-lg bg-slate-100 dark:bg-slate-800 p-1.5">
|
<div className="rounded-lg bg-rose-100 dark:bg-rose-900/40 p-1.5">
|
||||||
<Ban className="h-4 w-4 text-slate-600 dark:text-slate-400" />
|
<Ban className="h-4 w-4 text-rose-600 dark:text-rose-400" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
<span className="text-xs font-semibold uppercase tracking-wider text-rose-700 dark:text-rose-400">
|
||||||
Blocked Seats
|
Blocked Seats / Revenue Not Collected
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto text-[11px] text-muted-foreground">
|
<span className="ml-auto text-[11px] text-muted-foreground">
|
||||||
Last {blockedLoss?.periodDays ?? 30}d
|
{blockedLoss?.periodDays ? `Last ${blockedLoss.periodDays}d` : "All time"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
@@ -355,8 +355,8 @@ function DashboardPageContent() {
|
|||||||
key={row.currency}
|
key={row.currency}
|
||||||
className={
|
className={
|
||||||
i === 0
|
i === 0
|
||||||
? "text-3xl font-bold text-foreground tabular-nums"
|
? "text-3xl font-bold text-rose-600 dark:text-rose-400 tabular-nums"
|
||||||
: "text-lg font-semibold text-foreground tabular-nums"
|
: "text-lg font-semibold text-rose-600/80 dark:text-rose-400/80 tabular-nums"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formatCurrency(row.estimatedLossMinor, row.currency)}
|
{formatCurrency(row.estimatedLossMinor, row.currency)}
|
||||||
@@ -386,9 +386,9 @@ function DashboardPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/reports/blocked-seats"
|
href="/reports/blocked-seats"
|
||||||
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
|
className="flex items-center justify-center gap-1.5 rounded-md bg-rose-600 hover:bg-rose-700 dark:bg-rose-600 dark:hover:bg-rose-500 px-3 py-2 text-sm font-semibold text-white shadow-sm transition-colors mt-auto"
|
||||||
>
|
>
|
||||||
View full report <ArrowRight className="h-3 w-3" />
|
View full report <ArrowRight className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,12 +69,6 @@ function reasonLabel(category: string | null): string {
|
|||||||
return SEAT_BLOCK_REASON_CATEGORY_LABELS[key] ?? key;
|
return SEAT_BLOCK_REASON_CATEGORY_LABELS[key] ?? key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isoDaysAgo(days: number): string {
|
|
||||||
const d = new Date();
|
|
||||||
d.setDate(d.getDate() - days);
|
|
||||||
return d.toISOString().split("T")[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const TABLE_PAGE_SIZE = 25;
|
const TABLE_PAGE_SIZE = 25;
|
||||||
|
|
||||||
export default function BlockedSeatRevenueLossPage() {
|
export default function BlockedSeatRevenueLossPage() {
|
||||||
@@ -82,8 +76,11 @@ export default function BlockedSeatRevenueLossPage() {
|
|||||||
const palette = getChartPalette(isDark);
|
const palette = getChartPalette(isDark);
|
||||||
|
|
||||||
// ── Filters ───────────────────────────────────────────────────────────────
|
// ── Filters ───────────────────────────────────────────────────────────────
|
||||||
const [dateFrom, setDateFrom] = useState(isoDaysAgo(30));
|
// Blank dateFrom/dateTo are dropped before the request (see toQueryString), so the report
|
||||||
const [dateTo, setDateTo] = useState(() => new Date().toISOString().split("T")[0]);
|
// defaults to its full history — the earliest schedule on record to the latest — rather
|
||||||
|
// than a rolling window.
|
||||||
|
const [dateFrom, setDateFrom] = useState("");
|
||||||
|
const [dateTo, setDateTo] = useState("");
|
||||||
const [scheduleId, setScheduleId] = useState("");
|
const [scheduleId, setScheduleId] = useState("");
|
||||||
const [routeId, setRouteId] = useState("");
|
const [routeId, setRouteId] = useState("");
|
||||||
const [trainId, setTrainId] = useState("");
|
const [trainId, setTrainId] = useState("");
|
||||||
@@ -143,8 +140,8 @@ export default function BlockedSeatRevenueLossPage() {
|
|||||||
const totalPages = Math.max(1, Math.ceil((data?.meta.total ?? 0) / TABLE_PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil((data?.meta.total ?? 0) / TABLE_PAGE_SIZE));
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setDateFrom(isoDaysAgo(30));
|
setDateFrom("");
|
||||||
setDateTo(new Date().toISOString().split("T")[0]);
|
setDateTo("");
|
||||||
setScheduleId("");
|
setScheduleId("");
|
||||||
setRouteId("");
|
setRouteId("");
|
||||||
setTrainId("");
|
setTrainId("");
|
||||||
@@ -674,6 +671,7 @@ export default function BlockedSeatRevenueLossPage() {
|
|||||||
"Route",
|
"Route",
|
||||||
"Departure",
|
"Departure",
|
||||||
"Blocked",
|
"Blocked",
|
||||||
|
"Blocked by",
|
||||||
"Load factor",
|
"Load factor",
|
||||||
"Estimated loss",
|
"Estimated loss",
|
||||||
"Adjusted loss",
|
"Adjusted loss",
|
||||||
@@ -699,7 +697,7 @@ export default function BlockedSeatRevenueLossPage() {
|
|||||||
{scheduleRows.length === 0 && (
|
{scheduleRows.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={7}
|
colSpan={8}
|
||||||
className="py-8 text-center text-sm text-muted-foreground"
|
className="py-8 text-center text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
No schedules on this page
|
No schedules on this page
|
||||||
@@ -826,6 +824,11 @@ function ScheduleRow({
|
|||||||
{formatDateTime(row.departureAt)}
|
{formatDateTime(row.departureAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{row.blockedSeatCount}</td>
|
<td className="px-4 py-3 tabular-nums whitespace-nowrap">{row.blockedSeatCount}</td>
|
||||||
|
<td className="px-4 py-3 text-xs text-muted-foreground max-w-[12rem] truncate" title={row.blockedByNames.join(", ")}>
|
||||||
|
{row.blockedByNames.length > 1
|
||||||
|
? `${row.blockedByNames[0]} +${row.blockedByNames.length - 1}`
|
||||||
|
: (row.blockedByNames[0] ?? "—")}
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground tabular-nums">
|
<td className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground tabular-nums">
|
||||||
{row.loadFactorPercent}%{" "}
|
{row.loadFactorPercent}%{" "}
|
||||||
<span className="opacity-70">
|
<span className="opacity-70">
|
||||||
@@ -841,7 +844,7 @@ function ScheduleRow({
|
|||||||
</tr>
|
</tr>
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="bg-gray-50/60 dark:bg-gray-800/40 px-4 py-4">
|
<td colSpan={8} className="bg-gray-50/60 dark:bg-gray-800/40 px-4 py-4">
|
||||||
<BlockDetailTable blocks={row.blocks} />
|
<BlockDetailTable blocks={row.blocks} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -866,8 +869,6 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
|
|||||||
"Blocked by",
|
"Blocked by",
|
||||||
"Approved by",
|
"Approved by",
|
||||||
"Blocked at",
|
"Blocked at",
|
||||||
"Until",
|
|
||||||
"Days",
|
|
||||||
"Estimated loss",
|
"Estimated loss",
|
||||||
].map((h) => (
|
].map((h) => (
|
||||||
<th key={h} className="px-3 py-2 text-left font-medium whitespace-nowrap">
|
<th key={h} className="px-3 py-2 text-left font-medium whitespace-nowrap">
|
||||||
@@ -903,16 +904,6 @@ function BlockDetailTable({ blocks }: { blocks: BlockedSeatLossDetail[] }) {
|
|||||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||||
{formatDateTime(b.blockedAt)}
|
{formatDateTime(b.blockedAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
|
||||||
{b.stillBlocked ? (
|
|
||||||
<span className="text-amber-600 dark:text-amber-400">Still blocked</span>
|
|
||||||
) : (
|
|
||||||
formatDateTime(b.unblockAt)
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums text-muted-foreground">
|
|
||||||
{b.daysBlocked}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 whitespace-nowrap tabular-nums font-medium text-foreground">
|
<td className="px-3 py-2 whitespace-nowrap tabular-nums font-medium text-foreground">
|
||||||
{formatCurrency(b.estimatedLossMinor, b.currency)}
|
{formatCurrency(b.estimatedLossMinor, b.currency)}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export interface BlockedSeatLossSchedule {
|
|||||||
/** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */
|
/** `soldSeats / sellableSeats`, as a percentage rounded to one decimal. */
|
||||||
loadFactorPercent: number;
|
loadFactorPercent: number;
|
||||||
blockedSeatCount: number;
|
blockedSeatCount: number;
|
||||||
|
/** Distinct blockers behind this schedule's blocked seats, in no particular order. */
|
||||||
|
blockedByNames: string[];
|
||||||
/** Loss at full occupancy — the sum of the fares these seats would have sold for. */
|
/** Loss at full occupancy — the sum of the fares these seats would have sold for. */
|
||||||
estimatedLossMinor: number;
|
estimatedLossMinor: number;
|
||||||
/** `estimatedLossMinor × loadFactor` — what the train's actual demand supports. */
|
/** `estimatedLossMinor × loadFactor` — what the train's actual demand supports. */
|
||||||
@@ -160,7 +162,8 @@ export interface BlockedSeatRevenueLossReport {
|
|||||||
|
|
||||||
/** Compact roll-up embedded in `GET /dashboard/backoffice-stats`. */
|
/** Compact roll-up embedded in `GET /dashboard/backoffice-stats`. */
|
||||||
export interface BlockedSeatRevenueLossStat {
|
export interface BlockedSeatRevenueLossStat {
|
||||||
periodDays: number;
|
/** `null` means the roll-up covers full history — the earliest schedule to the latest. */
|
||||||
|
periodDays: number | null;
|
||||||
lossByCurrency: BlockedSeatLossByCurrency[];
|
lossByCurrency: BlockedSeatLossByCurrency[];
|
||||||
schedulesAffected: number;
|
schedulesAffected: number;
|
||||||
blockedSeatCount: number;
|
blockedSeatCount: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user