diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 379449f4f..310364927 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -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 { // 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}%` }, ); } diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index b9cf58da0..4ffde3d0d 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -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 }) => diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 4a5fc86bb..2d1bbf6f4 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7283c874a..7c41ee43a 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -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 }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index ed099ad72..15cf4f592 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -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(); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 2effe6a37..0fb6e16ab 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -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 { + async emitWindowState(scheduleId: string): Promise { 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 { - 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 { + 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, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 0c6a3553a..8c2a3a912 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -500,7 +500,7 @@ export default function BookingRequestsPage() { diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 029c53dd2..db57819d5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -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(null); const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [ownershipFilter, setOwnershipFilter] = useState(null); + const [customerKindFilter, setCustomerKindFilter] = useState(null); const [createdFrom, setCreatedFrom] = useState(null); const [createdTo, setCreatedTo] = useState(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: () => Customer, 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 (
- + {isShippingLine ? ( + + ) : ( + + )}
-

+

{customer} + {isShippingLine ? ( + + Shipping line + + ) : null}

@@ -267,7 +290,7 @@ export default function ClearanceDocumentsPage() { } value={query} onChange={(e) => { @@ -333,6 +356,19 @@ export default function ClearanceDocumentsPage() { style={{ minWidth: 140 }} aria-label="Filter by freight type" /> +