mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
Merge pull request #569 from Tria-plc/freight_feature/usermanagement
Implement client-side validation for container and bul
This commit is contained in:
@@ -39,12 +39,17 @@ export class Route extends BaseEntity {
|
|||||||
milestones?: RouteMilestone[];
|
milestones?: RouteMilestone[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable route label: yard names, not yard codes — "Addis Ababa → Dire Dawa",
|
||||||
|
* not "ADDIS_ABABA → DIRE_DAWA". A yard's display name is its `label`; `code` is the
|
||||||
|
* machine identifier and is only a fallback for a yard missing one.
|
||||||
|
*/
|
||||||
export function formatRouteLabel(route: {
|
export function formatRouteLabel(route: {
|
||||||
originYard?: { code?: string; name?: string } | null;
|
originYard?: { code?: string; label?: string } | null;
|
||||||
destinationYard?: { code?: string; name?: string } | null;
|
destinationYard?: { code?: string; label?: string } | null;
|
||||||
}): string {
|
}): string {
|
||||||
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
|
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||||
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
|
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||||
return `${origin} → ${dest}`;
|
return `${origin} → ${dest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2503,6 +2503,32 @@ export class BookingBatchService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reservation on this schedule still has time left to pay.
|
||||||
|
*
|
||||||
|
* The PAYMENT phase ends a hair BEFORE its own reservations do: `paymentPhaseEndsAt`
|
||||||
|
* is stamped when the phase starts, then `reserve()` gives each booking
|
||||||
|
* `now + paymentWindow` a few hundred milliseconds later, one booking at a time. So
|
||||||
|
* the first settle after the phase deadline finds every reservation still in date,
|
||||||
|
* expires nothing, reports `anySettled = false`, runs no top-up — and the caller
|
||||||
|
* concludes the cycle out from under customers who still had time to pay. The next
|
||||||
|
* tick then expires them with no cycle left to promote the waiting list into.
|
||||||
|
*
|
||||||
|
* Callers must not conclude the cycle while this returns true.
|
||||||
|
*/
|
||||||
|
async hasLiveReservations(scheduleId: string): Promise<boolean> {
|
||||||
|
const reserved =
|
||||||
|
await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||||
|
const now = Date.now();
|
||||||
|
return reserved.some(
|
||||||
|
(b) =>
|
||||||
|
b.paymentStatus !== "PAID" &&
|
||||||
|
b.status !== "PAID" &&
|
||||||
|
b.paymentDeadline != null &&
|
||||||
|
b.paymentDeadline.getTime() > now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** No wagon slots left for allocated + reserved bookings. */
|
/** No wagon slots left for allocated + reserved bookings. */
|
||||||
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
async isScheduleFull(scheduleId: string): Promise<boolean> {
|
||||||
const schedule =
|
const schedule =
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expireUnacceptedForRouteDay: jest.Mock;
|
expireUnacceptedForRouteDay: jest.Mock;
|
||||||
settleDueReservations: jest.Mock;
|
settleDueReservations: jest.Mock;
|
||||||
isScheduleFull: jest.Mock;
|
isScheduleFull: jest.Mock;
|
||||||
|
hasLiveReservations: jest.Mock;
|
||||||
|
refreshWindowStatus: jest.Mock;
|
||||||
};
|
};
|
||||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||||
@@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
|
||||||
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
settleDueReservations: jest.fn().mockResolvedValue(undefined),
|
||||||
isScheduleFull: jest.fn().mockResolvedValue(false),
|
isScheduleFull: jest.fn().mockResolvedValue(false),
|
||||||
|
// No reservation is mid-pay-window by default, so the cycle concludes.
|
||||||
|
hasLiveReservations: jest.fn().mockResolvedValue(false),
|
||||||
|
refreshWindowStatus: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
trainSchedulesRepository = {
|
trainSchedulesRepository = {
|
||||||
findById: jest.fn().mockResolvedValue(null),
|
findById: jest.fn().mockResolvedValue(null),
|
||||||
@@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => {
|
|||||||
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('PAYMENT holds the cycle open while a reservation is still inside its pay window', async () => {
|
||||||
|
// `paymentPhaseEndsAt` is stamped when the phase starts; reserve() then sets each
|
||||||
|
// booking's own deadline milliseconds later. So the phase deadline always passes
|
||||||
|
// first, and concluding here would kill customers who still had time to pay — and
|
||||||
|
// leave no cycle for the waiting-list top-up to run in.
|
||||||
|
batch.hasLiveReservations.mockResolvedValue(true);
|
||||||
|
const s = baseSchedule({
|
||||||
|
windowPhase: 'PAYMENT',
|
||||||
|
paymentPhaseEndsAt: new Date('2026-07-01T02:30:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const advanced = await advanceImport(s, new Date('2026-07-01T02:30:01.000Z'));
|
||||||
|
|
||||||
|
expect(advanced).toBe(true);
|
||||||
|
expect(batch.settleDueReservations).toHaveBeenCalledWith(scheduleId);
|
||||||
|
// Still PAYMENT — the cycle was NOT concluded and the window did not reopen.
|
||||||
|
expect(s.windowPhase).toBe('PAYMENT');
|
||||||
|
expect(batch.isScheduleFull).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
it('conclude: train FULL → window FULL + phase DONE + auto-finalize', async () => {
|
||||||
batch.isScheduleFull.mockResolvedValue(true);
|
batch.isScheduleFull.mockResolvedValue(true);
|
||||||
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
const s = baseSchedule({ windowPhase: 'PAYMENT' });
|
||||||
|
|||||||
@@ -322,6 +322,20 @@ export class BookingWindowService implements OnModuleInit {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `paymentPhaseEndsAt` is stamped when the phase starts; each reservation's own
|
||||||
|
// deadline is set milliseconds later, per booking, so the phase always expires
|
||||||
|
// a fraction before the reservations it opened. Concluding here would end the
|
||||||
|
// cycle while customers still had time to pay, and the settle that finally
|
||||||
|
// expires them (next tick) would have no cycle left to promote the waiting
|
||||||
|
// list into. Hold in PAYMENT until every reservation has actually resolved.
|
||||||
|
if (await this.bookingBatchService.hasLiveReservations(schedule.id)) {
|
||||||
|
this.logger.log(
|
||||||
|
`[WINDOW] ${schedule.id} PAYMENT phase past its deadline but reservations ` +
|
||||||
|
`are still within their pay windows — holding the cycle open`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
await this.concludeCycle(schedule, cfg, now);
|
await this.concludeCycle(schedule, cfg, now);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps {
|
|||||||
queriesLocked?: boolean;
|
queriesLocked?: boolean;
|
||||||
/** Read-only audit view — no approve/query actions. */
|
/** Read-only audit view — no approve/query actions. */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* GENERAL customs bookings use the phased milestone workflow (same as
|
||||||
|
* ONE_TIME contracts): hide the legacy output-documents upload block and the
|
||||||
|
* finalize button — declaration/duty/transit run in the phased action panel.
|
||||||
|
*/
|
||||||
|
phasedCustoms?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_META: Record<
|
const STATUS_META: Record<
|
||||||
@@ -73,6 +79,7 @@ export function ClearanceReviewSection({
|
|||||||
approvalsLocked = false,
|
approvalsLocked = false,
|
||||||
queriesLocked = false,
|
queriesLocked = false,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
phasedCustoms = false,
|
||||||
}: ClearanceReviewSectionProps) {
|
}: ClearanceReviewSectionProps) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||||
@@ -240,7 +247,7 @@ export function ClearanceReviewSection({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
{clearance.outputCode && (
|
{clearance.outputCode && !phasedCustoms && (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
icon={Upload}
|
icon={Upload}
|
||||||
title="Customs output documents"
|
title="Customs output documents"
|
||||||
@@ -341,7 +348,7 @@ export function ClearanceReviewSection({
|
|||||||
</SectionCard>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{finalizeMutation.isError && (
|
{!phasedCustoms && finalizeMutation.isError && (
|
||||||
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
|
||||||
{finalizeMutation.error instanceof Error
|
{finalizeMutation.error instanceof Error
|
||||||
? finalizeMutation.error.message
|
? finalizeMutation.error.message
|
||||||
@@ -349,8 +356,10 @@ export function ClearanceReviewSection({
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md" p="md">
|
{phasedCustoms ? (
|
||||||
<Group justify="space-between" wrap="nowrap">
|
// Phased (GENERAL customs) — no legacy finalize; the milestone steps in
|
||||||
|
// the action panel drive the workflow, same as ONE_TIME contracts.
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -358,26 +367,50 @@ export function ClearanceReviewSection({
|
|||||||
radius="md"
|
radius="md"
|
||||||
size={28}
|
size={28}
|
||||||
>
|
>
|
||||||
<FileCheck2 size={15} />
|
{clearance.allApproved ? (
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
) : (
|
||||||
|
<FileCheck2 size={15} />
|
||||||
|
)}
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fz="12.5px" c="dimmed">
|
<Text fz="12.5px" c="dimmed">
|
||||||
{clearance.allApproved
|
{clearance.allApproved
|
||||||
? "All required documents are approved — you can finalize."
|
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
|
||||||
: "Approve every required document to unlock finalization."}
|
: "Approve every required document to unlock the customs milestone steps."}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Button
|
</Paper>
|
||||||
color="edr-green"
|
) : (
|
||||||
radius="md"
|
<Paper withBorder radius="md" p="md">
|
||||||
leftSection={<CheckCircle2 size={16} />}
|
<Group justify="space-between" wrap="nowrap">
|
||||||
disabled={!clearance.allApproved}
|
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
loading={finalizeMutation.isPending}
|
<ThemeIcon
|
||||||
onClick={() => finalizeMutation.mutate()}
|
variant="light"
|
||||||
>
|
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||||
Finalize clearance
|
radius="md"
|
||||||
</Button>
|
size={28}
|
||||||
</Group>
|
>
|
||||||
</Paper>
|
<FileCheck2 size={15} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text fz="12.5px" c="dimmed">
|
||||||
|
{clearance.allApproved
|
||||||
|
? "All required documents are approved — you can finalize."
|
||||||
|
: "Approve every required document to unlock finalization."}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
disabled={!clearance.allApproved}
|
||||||
|
loading={finalizeMutation.isPending}
|
||||||
|
onClick={() => finalizeMutation.mutate()}
|
||||||
|
>
|
||||||
|
Finalize clearance
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
{viewer}
|
{viewer}
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() {
|
|||||||
hideSummary
|
hideSummary
|
||||||
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||||
queriesLocked={queriesLocked}
|
queriesLocked={queriesLocked}
|
||||||
|
phasedCustoms={isPhasedGeneral}
|
||||||
onChanged={() => void refetch()}
|
onChanged={() => void refetch()}
|
||||||
/>
|
/>
|
||||||
</Grid.Col>
|
</Grid.Col>
|
||||||
|
|||||||
@@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() {
|
|||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||||
{data.kind === "booking" ? (
|
{data.kind === "booking" ? (
|
||||||
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
|
<ClearanceReviewSection
|
||||||
|
bookingId={id!}
|
||||||
|
hideSummary
|
||||||
|
readOnly
|
||||||
|
phasedCustoms
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ContractClearanceReviewSection
|
<ContractClearanceReviewSection
|
||||||
contractId={id!}
|
contractId={id!}
|
||||||
|
|||||||
@@ -39,11 +39,16 @@ export interface SaveRoutePayload {
|
|||||||
status?: RouteStatus;
|
status?: RouteStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable route label: yard names, not yard codes. Staff read
|
||||||
|
* "Addis Ababa → Dire Dawa", not "ADDIS_ABABA → DIRE_DAWA". Falls back to the
|
||||||
|
* code only when a yard has no label.
|
||||||
|
*/
|
||||||
export function formatRouteLabel(route: RouteRecord): string {
|
export function formatRouteLabel(route: RouteRecord): string {
|
||||||
const origin =
|
const origin =
|
||||||
route.originYard?.code ?? route.originYard?.label ?? 'Origin';
|
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
|
||||||
const dest =
|
const dest =
|
||||||
route.destinationYard?.code ?? route.destinationYard?.label ?? 'Destination';
|
route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
|
||||||
return `${origin} → ${dest}`;
|
return `${origin} → ${dest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user