Merge pull request #1053 from Tria-plc/fix/package

Fix/package
This commit is contained in:
robiman
2026-07-31 21:19:31 +03:00
committed by GitHub
9 changed files with 178 additions and 44 deletions

View File

@@ -0,0 +1,44 @@
import { orderSlotsByWagonSequence } from './train-builder.service';
describe('orderSlotsByWagonSequence', () => {
const slot = (sequenceNo: number, physicalWagonId: string | null) => ({
sequenceNo,
physicalWagonId,
});
it('reorders pinned slots to the wagons new positions, unpinned trail in old order', () => {
// Built train reordered to w3, w1, w2. Slots 1..5: three pinned + two empty.
const newSeq = new Map([
['w3', 1],
['w1', 2],
['w2', 3],
]);
const slots = [
slot(1, 'w1'),
slot(2, 'w2'),
slot(3, 'w3'),
slot(4, null),
slot(5, null),
];
expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([
'w3',
'w1',
'w2',
null,
null,
]);
// Unpinned keep their old relative order (4 before 5).
expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.sequenceNo)).toEqual([
3, 1, 2, 4, 5,
]);
});
it('slots pinned to wagons outside the reorder trail like unpinned ones', () => {
const newSeq = new Map([['w2', 1]]);
const slots = [slot(1, 'w-foreign'), slot(2, 'w2')];
expect(orderSlotsByWagonSequence(slots, newSeq).map((s) => s.physicalWagonId)).toEqual([
'w2',
'w-foreign',
]);
});
});

View File

@@ -16,6 +16,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -601,26 +602,6 @@ export class TrainBuilderService {
return rows.length > 0;
}
/** Batched form of {@link isWagonPinnedToLiveSchedule} for a whole consist. */
private async isAnyWagonPinnedToLiveSchedule(
manager: EntityManager,
wagonIds: string[],
): Promise<boolean> {
if (!wagonIds.length) return false;
const rows: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[wagonIds],
);
return rows.length > 0;
}
/** Persist a drag-reorder: `wagonIds` is the full consist in its new order. */
async reorderWagons(id: string, dto: ReorderTrainWagonsDto) {
await this.dataSource.transaction(async (manager) => {
@@ -633,20 +614,63 @@ export class TrainBuilderService {
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
}
// A live schedule (DRAFT/SCHEDULED/DISPATCHED) reads each wagon's slot at
// its OWN frozen sequenceNo, never the wagon's live sequenceNumber — so
// renumbering here would silently desync that schedule's drawn consist
// from the built train's real order (loaded slots keep the old order,
// empty ones show the new one). Same guard as remove/maintenance.
if (await this.isAnyWagonPinnedToLiveSchedule(manager, [...current])) {
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
// is allowed — the pinned schedules' consists are resequenced below so
// they can never desync from the built train's real order.
const dispatched: { exists: boolean }[] = await manager.query(
`SELECT TRUE AS exists
FROM freight.train_set_wagons tsw
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
AND ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND tsw.deleted_at IS NULL
LIMIT 1`,
[[...current]],
);
if (dispatched.length > 0) {
throw new ConflictException(
'This train has wagons pinned to an active schedule and cannot be reordered — ' +
"it would desync the schedule's consist view from the built train's real order.",
'This train is dispatched — wagons cannot be reordered while it is rolling.',
);
}
for (let i = 0; i < dto.wagonIds.length; i++) {
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
}
// Propagate the new order to every live (DRAFT/SCHEDULED) schedule of
// this train: slots pinned to a reordered wagon adopt the wagon's new
// position, unpinned slots trail in their old relative order. Allocations
// ride the slot row (by id), so cargo stays with its physical wagon.
const newSeq = new Map(dto.wagonIds.map((wid, i) => [wid, i + 1]));
const sets: { train_set_id: string }[] = await manager.query(
`SELECT DISTINCT ts.train_set_id
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
WHERE tset.train_id = $1
AND ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')`,
[id],
);
for (const { train_set_id: trainSetId } of sets) {
const slots = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId },
order: { sequenceNo: 'ASC' },
});
const sorted = orderSlotsByWagonSequence(slots, newSeq);
// (train_set_id, sequence_no) is unique — shift to a temp range first
// so the final renumbering can't collide mid-loop.
await manager.query(
`UPDATE freight.train_set_wagons
SET sequence_no = sequence_no + 100000
WHERE train_set_id = $1 AND deleted_at IS NULL`,
[trainSetId],
);
for (let i = 0; i < sorted.length; i++) {
await manager
.getRepository(TrainSetWagon)
.update(sorted[i].id, { sequenceNo: i + 1 });
}
}
});
return this.getComposition(id);
}
@@ -1067,3 +1091,20 @@ export class TrainBuilderService {
}
}
}
/**
* New consist order for a schedule's slots after a built-train reorder: slots
* pinned to a reordered wagon adopt the wagon's new position; unpinned slots
* trail behind in their previous relative order.
*/
export function orderSlotsByWagonSequence<
T extends Pick<TrainSetWagon, 'sequenceNo' | 'physicalWagonId'>,
>(slots: T[], newSeq: Map<string, number>): T[] {
const key = (s: T): number =>
(s.physicalWagonId ? newSeq.get(s.physicalWagonId) : undefined) ?? Infinity;
return [...slots].sort((a, b) => {
const sa = key(a);
const sb = key(b);
return sa !== sb ? sa - sb : a.sequenceNo - b.sequenceNo;
});
}

View File

@@ -72,19 +72,39 @@ function apiErrorMessage(error: unknown, fallback: string): string {
*/
function phaseCountdown(
schedule: TrainScheduleDetail,
): { label: string; deadline: string } | null {
): { label: string; deadline: string; expiredText: string } | null {
switch (schedule.windowPhase) {
case "PRE_WINDOW":
return schedule.windowOpensAt
? {
label: "Booking window opens in",
deadline: schedule.windowOpensAt,
expiredText: "Booking opening now…",
}
: null;
case "OPEN":
return schedule.windowClosesAt
? { label: "Booking window closes in", deadline: schedule.windowClosesAt }
? {
label: "Booking window closes in",
deadline: schedule.windowClosesAt,
expiredText: "Document review starting…",
}
: null;
case "DOC_REVIEW":
return schedule.docReviewEndsAt
? { label: "Document review ends in", deadline: schedule.docReviewEndsAt }
? {
label: "Document review ends in",
deadline: schedule.docReviewEndsAt,
expiredText: "Payment starting…",
}
: null;
case "PAYMENT":
return schedule.paymentPhaseEndsAt
? { label: "Payment window ends in", deadline: schedule.paymentPhaseEndsAt }
? {
label: "Payment window ends in",
deadline: schedule.paymentPhaseEndsAt,
expiredText: "Payment window closing…",
}
: null;
default:
return null;
@@ -523,7 +543,12 @@ export function ScheduleWorkspacePanel({
border: "1px solid var(--mantine-color-blue-2)",
}}
>
<CountdownTimer deadline={cd.deadline} label={cd.label} size="sm" />
<CountdownTimer
deadline={cd.deadline}
label={cd.label}
expiredText={cd.expiredText}
size="sm"
/>
</Group>
) : null;
})()}

View File

@@ -147,6 +147,12 @@ export function useBookingWindowSocket(enabled: boolean = true) {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoardDetail(event.scheduleId),
});
// Same for the schedule detail page (workspace tab countdown reads
// windowPhase/paymentPhaseEndsAt from it) — without this the page shows
// the OLD phase's expired countdown through the whole import cycle.
void qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.scheduleById(event.scheduleId),
});
// Always refresh the batch board (different shape, not patched). When the
// schedule wasn't in any window list either, refresh those too so a newly

View File

@@ -147,8 +147,12 @@ export default function ContractClearanceDetailPage() {
!isDjiboutiGl(user);
const canResubmitBooking = bookingNeedsChanges && isGlBookingOwner;
const canRebook = bookingExpired && isGlBookingOwner;
// Rebook completes the SAME expired booking (it already carries the price and
// cargo from its first completion) via the /complete endpoint's EXPIRED
// branch — routing it through create-booking instead would create a
// duplicate booking on the contract rather than resubmitting the existing one.
const rebookHref = linkedBookingId
? `${bookingHref}?copyFrom=${linkedBookingId}`
? `/dashboard/contracts/${id}/bookings/${linkedBookingId}/complete?copyFrom=${linkedBookingId}`
: bookingHref;
const { data: bookingMilestones, refetch: refetchBookingMilestones } =

View File

@@ -77,6 +77,7 @@ import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api";
import { trainSchedulingService } from "@/services/trainScheduling.service";
import { useToast } from "@/hooks/use-toast";
@@ -121,8 +122,13 @@ export default function TrainScheduleV2DetailPage() {
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "" },
enabled: Boolean(scheduleId),
// Live phase updates come from the booking-window socket (PHASE pushes
// invalidate this query); 60s is the self-heal net for a missed emit so
// the workspace countdown never freezes on an expired phase.
refetchInterval: 60_000,
}),
);
useBookingWindowSocket(Boolean(scheduleId));
const schedule = detailQuery.data;
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
const isDjiboutiPort = (value?: string | null) =>

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -531,9 +531,14 @@ function PassengerCountModal({
// uses) — package pricing/currency is fixed per tier and is never affected by
// this. No default: nationality is a deliberate choice, same as the normal
// flow's search-step picker.
const [nationality, setNationality] = useState<"" | "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER">("");
const [nationality, setNationality] = useState<
"" | "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"
>("");
const [showNationalityError, setShowNationalityError] = useState(false);
const natOptions: Array<{ value: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER"; label: string }> = [
const natOptions: Array<{
value: "ETHIOPIAN" | "DJIBOUTIAN" | "OTHER";
label: string;
}> = [
{ value: "ETHIOPIAN", label: "🇪🇹 Ethiopian" },
{ value: "DJIBOUTIAN", label: "🇩🇯 Djiboutian" },
{ value: "OTHER", label: "🌍 Other" },
@@ -1037,7 +1042,7 @@ export default function PackageDetailPage() {
{/* Hero */}
<div className="relative h-56 md:h-80 bg-gradient-to-br from-[rgb(14,80,54)] to-[rgb(20,140,90)] overflow-hidden">
<Image
src="/packages/kulubi.jpeg"
src="/packages/package.jpeg"
alt={pkg.name}
fill
className="object-cover"

View File

@@ -51,7 +51,7 @@ interface HolidayPackage {
includedServices?: string[];
busTransferIncluded?: boolean;
busTransferRoute?: string | null;
journeyType?: 'ONE_WAY' | 'ROUND_TRIP';
journeyType?: "ONE_WAY" | "ROUND_TRIP";
returnSchedule?: Schedule | null;
outboundSchedule?: Schedule;
priceTiers: PriceTier[];
@@ -65,7 +65,8 @@ interface HolidayPackage {
function fmtDate(iso: string, opts?: Intl.DateTimeFormatOptions): string {
try {
return new Date(iso).toLocaleDateString("en-US", {
month: "short", day: "numeric",
month: "short",
day: "numeric",
...opts,
timeZone: "Africa/Addis_Ababa",
});
@@ -91,7 +92,10 @@ function minPrice(
): { amount: number; currency: string } | null {
if (!tiers?.length) return null;
const min = tiers.reduce((a, b) => (a.priceMinor < b.priceMinor ? a : b));
return { amount: (min.priceMinor * multiplier) / 100, currency: min.currency };
return {
amount: (min.priceMinor * multiplier) / 100,
currency: min.currency,
};
}
function fmtPrice(minor: number, currency: string): string {
@@ -148,7 +152,7 @@ function AvailBar({ booked, total }: { booked: number; total: number }) {
// ─── Featured Card (first package — full-width, image left) ───────────────────
function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1;
const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1;
const price = minPrice(pkg.priceTiers, multiplier);
const origin = pkg.outboundSchedule?.originStation;
const dest = pkg.outboundSchedule?.destinationStation;
@@ -161,7 +165,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
{/* ── Left: Image ── */}
<div className="relative md:w-[46%] h-64 md:h-auto flex-shrink-0 overflow-hidden">
<Image
src="/packages/kulubi.jpeg"
src="/packages/package.jpeg"
alt={pkg.name}
fill
className="object-cover group-hover:scale-105 transition-transform duration-700 ease-out"
@@ -189,7 +193,6 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
</span>
</div>
)}
</div>
{/* ── Right: Content ── */}
@@ -319,7 +322,7 @@ function FeaturedCard({ pkg }: { pkg: HolidayPackage }) {
// ─── Regular Package Card ─────────────────────────────────────────────────────
function PackageCard({ pkg }: { pkg: HolidayPackage }) {
const multiplier = pkg.journeyType === 'ROUND_TRIP' ? 2 : 1;
const multiplier = pkg.journeyType === "ROUND_TRIP" ? 2 : 1;
const price = minPrice(pkg.priceTiers, multiplier);
const origin = pkg.outboundSchedule?.originStation;
const dest = pkg.outboundSchedule?.destinationStation;