Implement client-side validation for container and bul

This commit is contained in:
Marshal
2026-07-09 08:13:20 +00:00
parent c6198a4c62
commit f736523afd
8 changed files with 140 additions and 26 deletions

View File

@@ -39,12 +39,17 @@ export class Route extends BaseEntity {
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: {
originYard?: { code?: string; name?: string } | null;
destinationYard?: { code?: string; name?: string } | null;
originYard?: { code?: string; label?: string } | null;
destinationYard?: { code?: string; label?: string } | null;
}): string {
const origin = route.originYard?.code ?? route.originYard?.name ?? 'Origin';
const dest = route.destinationYard?.code ?? route.destinationYard?.name ?? 'Destination';
const origin = route.originYard?.label ?? route.originYard?.code ?? 'Origin';
const dest = route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
return `${origin}${dest}`;
}

View File

@@ -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. */
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =

View File

@@ -17,6 +17,8 @@ describe('BookingWindowService — window state machine', () => {
expireUnacceptedForRouteDay: jest.Mock;
settleDueReservations: jest.Mock;
isScheduleFull: jest.Mock;
hasLiveReservations: jest.Mock;
refreshWindowStatus: jest.Mock;
};
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
@@ -68,6 +70,9 @@ describe('BookingWindowService — window state machine', () => {
expireUnacceptedForRouteDay: jest.fn().mockResolvedValue(undefined),
settleDueReservations: jest.fn().mockResolvedValue(undefined),
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 = {
findById: jest.fn().mockResolvedValue(null),
@@ -154,6 +159,26 @@ describe('BookingWindowService — window state machine', () => {
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 () => {
batch.isScheduleFull.mockResolvedValue(true);
const s = baseSchedule({ windowPhase: 'PAYMENT' });

View File

@@ -322,6 +322,20 @@ export class BookingWindowService implements OnModuleInit {
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);
return true;
}

View File

@@ -47,6 +47,12 @@ export interface ClearanceReviewSectionProps {
queriesLocked?: boolean;
/** Read-only audit view — no approve/query actions. */
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<
@@ -73,6 +79,7 @@ export function ClearanceReviewSection({
approvalsLocked = false,
queriesLocked = false,
readOnly = false,
phasedCustoms = false,
}: ClearanceReviewSectionProps) {
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
@@ -240,7 +247,7 @@ export function ClearanceReviewSection({
</Stack>
</SectionCard>
{clearance.outputCode && (
{clearance.outputCode && !phasedCustoms && (
<SectionCard
icon={Upload}
title="Customs output documents"
@@ -341,7 +348,7 @@ export function ClearanceReviewSection({
</SectionCard>
)}
{finalizeMutation.isError && (
{!phasedCustoms && finalizeMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeMutation.error instanceof Error
? finalizeMutation.error.message
@@ -349,8 +356,10 @@ export function ClearanceReviewSection({
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
{phasedCustoms ? (
// 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 }}>
<ThemeIcon
variant="light"
@@ -358,26 +367,50 @@ export function ClearanceReviewSection({
radius="md"
size={28}
>
<FileCheck2 size={15} />
{clearance.allApproved ? (
<CheckCircle2 size={15} />
) : (
<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."}
? "All required documents are approved. Continue declaration, duty, and transit in the action panel."
: "Approve every required document to unlock the customs milestone steps."}
</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>
</Paper>
) : (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<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}
</Stack>
);

View File

@@ -176,6 +176,7 @@ export default function DocumentClearanceDetailPage() {
hideSummary
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
queriesLocked={queriesLocked}
phasedCustoms={isPhasedGeneral}
onChanged={() => void refetch()}
/>
</Grid.Col>

View File

@@ -188,7 +188,12 @@ export default function GlClearanceDetailPage() {
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
<ClearanceReviewSection
bookingId={id!}
hideSummary
readOnly
phasedCustoms
/>
) : (
<ContractClearanceReviewSection
contractId={id!}

View File

@@ -39,11 +39,16 @@ export interface SaveRoutePayload {
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 {
const origin =
route.originYard?.code ?? route.originYard?.label ?? 'Origin';
route.originYard?.label ?? route.originYard?.code ?? 'Origin';
const dest =
route.destinationYard?.code ?? route.destinationYard?.label ?? 'Destination';
route.destinationYard?.label ?? route.destinationYard?.code ?? 'Destination';
return `${origin}${dest}`;
}