Merge pull request #1304 from Tria-plc/dev

freight
This commit is contained in:
marshal
2026-08-17 08:45:17 +03:00
committed by GitHub
8 changed files with 225 additions and 75 deletions

View File

@@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util';
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 { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity';
import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { applyDirectionScope } from '../user-trade-access/trade-scope.util';
@@ -783,16 +784,20 @@ export class BookingsRepository extends BaseRepository<Booking> {
// by TypeORM and crashes).
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference')
// Shipping-line owner name for search only (no relation, see entity) —
// the list rows get `shippingLineCompany` hydrated by the service.
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
// Free-text search spans joined columns (company, contract) that only this
// list query joins — so it lives here, not in applyListFilters (shared
// with getListSummaryMetrics, whose query builder has no joins).
// Free-text search spans joined columns (company, shipping line, contract)
// that only this list query joins — so it lives here, not in
// applyListFilters (shared with getListSummaryMetrics, whose query builder
// has no joins).
if (options.search) {
qb.andWhere(
'(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)',
'(booking.reference ILIKE :search OR company.name ILIKE :search OR slc.name ILIKE :search OR contract.reference ILIKE :search)',
{ search: `%${options.search}%` },
);
}

View File

@@ -148,7 +148,7 @@ export class FilterBookingDto {
@ApiPropertyOptional({
description:
'Free-text search across booking reference, company name, and contract reference.',
'Free-text search across booking reference, customer / shipping-line company name, and contract reference.',
})
@IsOptional()
@Transform(({ value }) =>

View File

@@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => {
previewTrainSchedule: jest.fn(),
unassignBooking: jest.fn(),
assignBookingsToSchedule: jest.fn(),
windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}),
emitWindowState: jest.fn().mockResolvedValue(undefined),
};
schedulingRescheduleRepository = {
createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }),
@@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => {
});
trainSchedulesRepository.updateStatus.mockResolvedValue(undefined);
trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' });
// An OPEN window's close must follow the new departure (this is the
// portal's "closes in" countdown) — the derived fields ride along with the
// date write.
const newCloses = new Date('2099-06-22T08:00:00.000Z');
trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({
windowClosesAt: newCloses,
});
const result = await service.maintenanceReschedule(
'sched-1',
@@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => {
expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith(
'sched-1',
'DRAFT',
{ scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') },
{
scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'),
windowClosesAt: newCloses,
},
txManager,
);
expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith(
schedule,
new Date('2099-06-22T10:00:00.000Z'),
);
expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith(
expect.objectContaining({
trigger: 'TRAIN_MAINTENANCE',

View File

@@ -227,17 +227,25 @@ export class SchedulingRescheduleService {
// through this manager without editing TrainSchedulingService. A failure
// between those steps and this block can still leave partial state; a human
// must finish the full cross-service transaction threading.
// The booking window must follow the new departure (an OPEN window's
// "closes in" countdown is capped at departure close offset; PRE_WINDOW /
// DONE re-derive their open/close). Same math as maintenanceReschedule.
const windowFields = newDeparture
? await this.trainSchedulingService.windowFieldsForNewDeparture(
schedule,
newDeparture,
)
: {};
await this.dataSource.transaction(async (manager) => {
if (newDeparture) {
// M7: raw write of scheduledDepartureDate. We deliberately do NOT
// delegate to TrainSchedulingService.updateScheduleDate, which only
// permits a date change while windowPhase === 'PRE_WINDOW' and would
// reject reschedules of already-open (SCHEDULED) trains. Consequence:
// the booking-window fields are NOT re-derived for the new date here.
// Raw write of scheduledDepartureDate: updateScheduleDate only permits a
// date change while windowPhase === 'PRE_WINDOW' and would reject
// reschedules of already-open (SCHEDULED) trains.
await this.trainSchedulesRepository.updateStatus(
scheduleId,
schedule.status as TrainScheduleStatus,
{ scheduledDepartureDate: newDeparture },
{ scheduledDepartureDate: newDeparture, ...windowFields },
manager,
);
}
@@ -263,6 +271,7 @@ export class SchedulingRescheduleService {
// `newDeparture` is null when the date was unchanged, so retained customers
// are not falsely told the train was rescheduled.
await this.notifyRescheduleOutcome(dto, newDeparture);
if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId);
return { plan, schedule: assignResult };
}

View File

@@ -1821,5 +1821,31 @@ describe('TrainSchedulingService', () => {
expect(written.windowPhase).toBeUndefined();
expect(written.windowOpensAt).toBeUndefined();
});
it('moves an OPEN export close to the new departure but keeps the open', async () => {
const opensAt = new Date('2027-06-19T03:00:00.000Z');
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(
doneExportSchedule({
windowPhase: 'OPEN',
bookingWindowStatus: 'OPEN',
windowOpensAt: opensAt,
windowClosesAt: new Date('2027-06-20T03:00:00.000Z'),
}),
);
// Departure pushed 3 days later → close = new departure 120min; the
// open customers already booked against stays untouched.
await service.maintenanceReschedule('sch-done', {
newDepartureDate: '2027-06-23T05:00:00.000Z',
} as never);
const written = scheduleUpdate.mock.calls[0][1];
expect(written.scheduledDepartureDate).toEqual(
new Date('2027-06-23T05:00:00.000Z'),
);
expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z'));
expect(written.windowOpensAt).toBeUndefined();
expect(written.windowPhase).toBeUndefined();
});
});
});

View File

@@ -164,6 +164,8 @@ import {
} from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util';
import {
bookingCloseCutoff,
clampCloseToOfficeHours,
computeExportWindowTimes,
computeImportWindowTimes,
earliestSchedulableDeparture,
@@ -473,7 +475,7 @@ export class TrainSchedulingService {
* used for lifecycle changes outside the window tick (create, cancel,
* finalize, restamp). A push failure must never break the mutation.
*/
private async emitWindowState(scheduleId: string): Promise<void> {
async emitWindowState(scheduleId: string): Promise<void> {
try {
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
// Dedicated shipping-line departures are never announced to the portal —
@@ -1159,66 +1161,73 @@ export class TrainSchedulingService {
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
* Booking-window fields that must follow a train's departure moving to
* `departure` (any window phase). Shared by every reschedule path so the
* "closes in" countdown always tracks the real departure.
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are only
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
* schedule mid- or post-window keeps its timeline untouched.
* PRE_WINDOW: the stamped open/close were derived from the old departure
* and the window hasn't opened yet, so re-derive them from the schedule's
* own rule snapshot against the new date (joining the target day's route
* group timeline when one exists, exactly like updateScheduleDate).
*
* Customers of every moved booking are notified (maintenanceMoved).
* DONE: the window already finished (e.g. the close offset hit and then the
* train was moved to a later departure). The window must follow the new
* departure, so it REOPENS: re-derive open/close the same way, reset the
* phase to PRE_WINDOW and clamp a past open into the present so the tick
* opens it immediately. A FULL train stays closed — there is nothing left
* to sell — and so does one whose re-derived window would already be over.
*
* OPEN: customers are already booking against the open they were shown, so
* the open stays put — but the close was capped at the OLD departure's
* cutoff, so it must follow the new one (import: open + duration under
* office hours, capped at the cutoff; export: the cutoff itself). Moving
* the train later extends the "closes in" countdown, moving it earlier
* shortens it (a close now in the past is picked up by the next tick).
*
* DOC_REVIEW/PAYMENT keep their running timeline.
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
// PRE_WINDOW: the stamped open/close were derived from the old departure
// and the window hasn't opened yet, so re-derive them from the schedule's
// own rule snapshot against the new date (joining the target day's route
// group timeline when one exists, exactly like updateScheduleDate).
//
// DONE: the window already finished (e.g. the close offset hit and then the
// train was moved to a later departure). The window must follow the new
// departure, so it REOPENS: re-derive open/close the same way, reset the
// phase to PRE_WINDOW and clamp a past open into the present so the tick
// opens it immediately. A FULL train stays closed — there is nothing left
// to sell — and so does one whose re-derived window would already be over.
//
// Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline.
async windowFieldsForNewDeparture(
schedule: TrainSchedule,
departure: Date,
): Promise<
Partial<
Pick<
TrainSchedule,
| 'windowOpensAt'
| 'windowClosesAt'
| 'windowPhase'
| 'bookingWindowStatus'
| 'docReviewCompletedAt'
| 'docReviewEndsAt'
| 'paymentPhaseEndsAt'
>
>
> {
const reopenFromDone =
schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL';
const shiftOpenClose =
schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null;
const windowFields =
schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
shiftOpenClose
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
await this.getWindowConfig(),
);
const opensAt = schedule.windowOpensAt!;
const cutoff = bookingCloseCutoff(departure, schedule.direction, merged);
let closesAt = cutoff;
if (schedule.direction !== 'EXPORT') {
closesAt = clampCloseToOfficeHours(
opensAt,
new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000),
merged,
);
if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff;
}
return { windowClosesAt: closesAt };
})()
: schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone
? await (async () => {
const merged = effectiveWindowConfig(
schedule,
@@ -1266,6 +1275,55 @@ export class TrainSchedulingService {
};
})()
: {};
return windowFields;
}
/**
* Maintenance reschedule: the admin moves a train (with everything aboard) to
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
* phase and inside the booking lead window — a maintenance move is an
* operational fact, not a planning choice. What moves and what stays:
*
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
* so a booking left on the old day would fall out of its own train's pool).
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
* maxWagons, and the window RULE snapshot. Stamped window times are
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); an
* OPEN schedule keeps its open but its close follows the new departure;
* DOC_REVIEW/PAYMENT keep their timeline untouched.
*
* Customers of every moved booking are notified (maintenanceMoved).
*/
async maintenanceReschedule(
id: string,
dto: MaintenanceRescheduleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
);
}
const departure = new Date(dto.newDepartureDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
if (departure.getTime() <= Date.now()) {
throw new BadRequestException('New departure must be in the future.');
}
const deltaMs =
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
const scheduledArrivalDate = schedule.scheduledArrivalDate
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
: undefined;
const windowFields = await this.windowFieldsForNewDeparture(schedule, departure);
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,

View File

@@ -500,7 +500,7 @@ export default function BookingRequestsPage() {
<FilterBar
defs={bookingFilterDefs}
controls={controls}
searchPlaceholder="Search booking, contract or customer…"
searchPlaceholder="Search booking, contract, customer or shipping line…"
viewId="booking-requests"
/>
</Box>

View File

@@ -14,7 +14,7 @@ import { DatePickerInput } from "@mantine/dates";
import { getDateRangePresets } from "@/components/common/dateRangePresets";
import { useDebouncedValue } from "@mantine/hooks";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react";
import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
@@ -75,6 +75,12 @@ const OWNERSHIP_OPTIONS = [
{ value: "false", label: "Private" },
];
/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */
const CUSTOMER_KIND_OPTIONS = [
{ value: "SHIPPING_LINE", label: "Shipping line" },
{ value: "CUSTOMER", label: "Customer" },
];
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
@@ -98,6 +104,7 @@ export default function ClearanceDocumentsPage() {
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
const [customerKindFilter, setCustomerKindFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE });
@@ -118,6 +125,7 @@ export default function ClearanceDocumentsPage() {
directionFilter,
freightTypeFilter,
ownershipFilter,
customerKindFilter,
createdFrom,
createdTo,
page,
@@ -138,6 +146,9 @@ export default function ClearanceDocumentsPage() {
...(ownershipFilter
? { isGovernment: ownershipFilter as "true" | "false" }
: {}),
...(customerKindFilter
? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" }
: {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
}),
@@ -153,17 +164,29 @@ export default function ClearanceDocumentsPage() {
header: () => <span className={bookingTable.headerCell}>Customer</span>,
cell: ({ row }) => {
const b = row.original;
const customer = b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "");
const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId);
const customer = isShippingLine
? (b.shippingLineCompany?.name ?? "Shipping line")
: b.isGovernment
? (b.governmentInstitution ?? "Government")
: (b.company?.name ?? "—");
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<User className="size-4" strokeWidth={1.75} />
{isShippingLine ? (
<Ship className="size-4" strokeWidth={1.75} />
) : (
<User className="size-4" strokeWidth={1.75} />
)}
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">
<p className="flex items-center gap-1.5 font-medium text-foreground">
{customer}
{isShippingLine ? (
<Badge variant="secondary" className="h-4 shrink-0 px-1 text-[9px] font-medium">
Shipping line
</Badge>
) : null}
</p>
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<FileText className="size-3 shrink-0 opacity-70" />
@@ -267,7 +290,7 @@ export default function ClearanceDocumentsPage() {
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search booking, contract or customer…"
placeholder="Search booking, contract, customer or shipping line…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
@@ -333,6 +356,19 @@ export default function ClearanceDocumentsPage() {
style={{ minWidth: 140 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="Booked by"
data={CUSTOMER_KIND_OPTIONS}
value={customerKindFilter}
onChange={(v) => {
setCustomerKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by booked by"
/>
<Select
placeholder="Gov / Private"
data={OWNERSHIP_OPTIONS}