mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 02:30:55 +00:00
add company stamp upload functionality for contract signing
- Introduced StampUpload component for uploading company stamp images. - Integrated stamp upload in contract signing modal, supporting PNG and JPG formats. - Implemented validation for file type and size (max 5 MB). - Added visual feedback for drag-and-drop functionality. - Updated contract-related pages to handle duplicate contract alerts and pricing notices. - Enhanced contract expiry management with a nightly sweep service. - Added unit tests for new features and updated existing tests for contract handling.
This commit is contained in:
@@ -3000,6 +3000,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many bookings on this route-day would be expired if document review
|
||||
* ended right now — i.e. requests staff have neither accepted nor rejected.
|
||||
* Same query the doc-review-end sweep runs, so the number staff see is
|
||||
* exactly what is at risk.
|
||||
*/
|
||||
async countUnacceptedForRouteDay(group: RouteDayGroup): Promise<number> {
|
||||
const corridorYards = await this.corridorYardsForRouteDay(group);
|
||||
if (corridorYards.length === 0) return 0;
|
||||
const unaccepted = await this.bookingsRepository.findUnacceptedForRouteDay(
|
||||
corridorYards,
|
||||
group.day,
|
||||
);
|
||||
return unaccepted.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free capacity for a government booking by displacing the lowest-priority commercial
|
||||
* bookings (reserved first, then allocated — including PAID). Displaced → EXPIRED + notified.
|
||||
|
||||
@@ -22,6 +22,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireLeftoverDayPool: jest.Mock;
|
||||
expireLeftoverExportDay: jest.Mock;
|
||||
fillFromWaitingList: jest.Mock;
|
||||
countUnacceptedForRouteDay: jest.Mock;
|
||||
};
|
||||
let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock };
|
||||
let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock };
|
||||
@@ -79,6 +80,7 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expireLeftoverExportDay: jest.fn().mockResolvedValue(undefined),
|
||||
// No waiting booking fits by default, so conclude proceeds to reopen/DONE.
|
||||
fillFromWaitingList: jest.fn().mockResolvedValue(0),
|
||||
countUnacceptedForRouteDay: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainSchedulesRepository = {
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
@@ -267,4 +269,87 @@ describe('BookingWindowService — window state machine', () => {
|
||||
expect(s.windowPhase).toBe('OPEN');
|
||||
expect(batch.setWindow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---- header alarm ---------------------------------------------------------
|
||||
|
||||
describe('getDocReviewAlert', () => {
|
||||
const reviewing = (over: Partial<TrainSchedule>): TrainSchedule =>
|
||||
baseSchedule({
|
||||
windowPhase: 'DOC_REVIEW',
|
||||
docReviewEndsAt: new Date('2026-07-01T01:30:00.000Z'),
|
||||
...over,
|
||||
});
|
||||
|
||||
it('returns null when nothing is under document review', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
baseSchedule({ windowPhase: 'OPEN' }),
|
||||
]);
|
||||
expect(await service.getDocReviewAlert()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when every request on the route-day is decided', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(0);
|
||||
expect(await service.getDocReviewAlert()).toBeNull();
|
||||
});
|
||||
|
||||
it('reports the deadline, its own pending count and the phase length', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([reviewing({})]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(3);
|
||||
|
||||
const alert = await service.getDocReviewAlert();
|
||||
|
||||
expect(alert).toMatchObject({
|
||||
scheduleId,
|
||||
originYardId: 'yard-o',
|
||||
destinationYardId: 'yard-d',
|
||||
tradeDirection: 'IMPORT',
|
||||
pendingCount: 3,
|
||||
docReviewMinutes: 30,
|
||||
docReviewEndsAt: '2026-07-01T01:30:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips the nearest deadline when it has nothing pending', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
reviewing({
|
||||
id: 'sched-later',
|
||||
destinationStationId: 'yard-far',
|
||||
docReviewEndsAt: new Date('2026-07-01T02:00:00.000Z'),
|
||||
}),
|
||||
reviewing({ id: 'sched-soon' }),
|
||||
]);
|
||||
// Nearest (sched-soon, yard-d) is clear; the later route-day still isn't.
|
||||
batch.countUnacceptedForRouteDay.mockImplementation(
|
||||
async (g: { destinationYardId: string }) =>
|
||||
g.destinationYardId === 'yard-far' ? 2 : 0,
|
||||
);
|
||||
|
||||
const alert = await service.getDocReviewAlert();
|
||||
|
||||
expect(alert?.scheduleId).toBe('sched-later');
|
||||
expect(alert?.pendingCount).toBe(2);
|
||||
});
|
||||
|
||||
it('counts a route-day once when sibling trains share the review phase', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
reviewing({ id: 'sched-a' }),
|
||||
reviewing({ id: 'sched-b' }),
|
||||
]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(4);
|
||||
|
||||
const alert = await service.getDocReviewAlert();
|
||||
|
||||
expect(alert?.pendingCount).toBe(4);
|
||||
expect(batch.countUnacceptedForRouteDay).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores a phase staff already completed early', async () => {
|
||||
trainSchedulesRepository.findAll.mockResolvedValue([
|
||||
reviewing({ docReviewCompletedAt: new Date('2026-07-01T01:10:00.000Z') }),
|
||||
]);
|
||||
batch.countUnacceptedForRouteDay.mockResolvedValue(5);
|
||||
expect(await service.getDocReviewAlert()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,28 @@ import {
|
||||
} from './batch-window.util';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
|
||||
/**
|
||||
* The most urgent document-review deadline that still has un-accepted booking
|
||||
* requests behind it. Backoffice counts down to it and warns staff, because
|
||||
* everything still pending when the phase ends is expired automatically.
|
||||
*/
|
||||
export interface DocReviewAlert {
|
||||
/** A schedule of the route-day group under review (deep-link target). */
|
||||
scheduleId: string;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
/** EAT booking day of the group, YYYY-MM-DD. */
|
||||
day: string;
|
||||
/** IMPORT (the usual) or DOMESTIC — both run a review phase; export does not. */
|
||||
tradeDirection: string;
|
||||
/** ISO deadline the review phase ends at. */
|
||||
docReviewEndsAt: string;
|
||||
/** Full length of the review phase — the client warns past its halfway mark. */
|
||||
docReviewMinutes: number;
|
||||
/** Requests neither accepted nor rejected — they expire at the deadline. */
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
|
||||
* booking window for EXPORT schedules. All state lives in DB timestamps on the
|
||||
@@ -130,6 +152,65 @@ export class BookingWindowService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The route-day currently in document review whose deadline is nearest and
|
||||
* which still has un-accepted requests. Null when nothing is under review or
|
||||
* every request has been decided — the backoffice header shows nothing then.
|
||||
*
|
||||
* One card, one deadline, one count: route-days are checked in deadline order
|
||||
* and the first with pending work wins, so the number always belongs to the
|
||||
* clock beside it.
|
||||
*/
|
||||
async getDocReviewAlert(): Promise<DocReviewAlert | null> {
|
||||
const reviewing = (
|
||||
await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
})
|
||||
)
|
||||
.filter(
|
||||
(s) =>
|
||||
s.windowPhase === 'DOC_REVIEW' &&
|
||||
s.docReviewCompletedAt == null &&
|
||||
s.docReviewEndsAt != null &&
|
||||
s.scheduledDepartureDate != null,
|
||||
)
|
||||
.sort((a, b) => a.docReviewEndsAt!.getTime() - b.docReviewEndsAt!.getTime());
|
||||
if (reviewing.length === 0) return null;
|
||||
|
||||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const seen = new Set<string>();
|
||||
for (const schedule of reviewing) {
|
||||
const group = {
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
};
|
||||
// Sibling trains share one review phase for the route-day pool — count it once.
|
||||
const key = `${group.originYardId}|${group.destinationYardId}|${group.day}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const pendingCount =
|
||||
await this.bookingBatchService.countUnacceptedForRouteDay(group);
|
||||
if (pendingCount === 0) continue;
|
||||
|
||||
return {
|
||||
scheduleId: schedule.id,
|
||||
...group,
|
||||
// Carried so the backoffice list opens on the same direction the
|
||||
// at-risk requests belong to (import corridor, or a domestic day).
|
||||
tradeDirection: schedule.direction ?? 'IMPORT',
|
||||
docReviewEndsAt: schedule.docReviewEndsAt!.toISOString(),
|
||||
docReviewMinutes: effectiveWindowConfig(schedule, liveCfg).docReviewMinutes,
|
||||
pendingCount,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Staff finished document review early — start the batch/payment phase now. */
|
||||
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
BookingDocReviewAlert,
|
||||
TrainSchedulingCancel,
|
||||
TrainSchedulingCreate,
|
||||
TrainSchedulingReschedule,
|
||||
@@ -747,6 +748,18 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Get("doc-review-alert")
|
||||
// Dedicated permission, not scheduling or bookings:view — the alarm is meant
|
||||
// for the position types that actually decide operation requests.
|
||||
@BookingDocReviewAlert()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Nearest document-review deadline that still has un-accepted booking requests behind it (null when there is none) — drives the backoffice header countdown",
|
||||
})
|
||||
async getDocReviewAlert() {
|
||||
return this.bookingWindowService.getDocReviewAlert();
|
||||
}
|
||||
|
||||
@Post("schedules/:id/doc-review-complete")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -6659,6 +6659,8 @@ export class TrainSchedulingService {
|
||||
: slot.physicalWagonId ?? null;
|
||||
if (physicalId) coveredPhysicalIds.add(physicalId);
|
||||
}
|
||||
// Fallback only — real empty rows below carry the wagon's OWN physical
|
||||
// sequenceNumber, not an invented tail position (see emptyConsistWagons).
|
||||
const maxSlotSequenceNo = Math.max(
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
@@ -6669,7 +6671,11 @@ export class TrainSchedulingService {
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
// The wagon's REAL coupling position, so an empty wagon in the middle
|
||||
// of the train draws in the middle — not appended after every loaded
|
||||
// slot. Falls back to a tail position only if the wagon somehow has
|
||||
// no sequence number of its own.
|
||||
sequenceNo: wagon.sequenceNumber ?? maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
@@ -6779,8 +6785,7 @@ export class TrainSchedulingService {
|
||||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||||
})),
|
||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
wagons: (schedule.trainSet.wagons ?? [])
|
||||
.map((wagon) => {
|
||||
// Frozen schedules read the wagon number + allocations from the
|
||||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||
@@ -6788,9 +6793,20 @@ export class TrainSchedulingService {
|
||||
const frozenSlot = isWagonAllocationFrozen
|
||||
? snapshotSlotByTrainSetWagonId.get(wagon.id)
|
||||
: undefined;
|
||||
// Draw the slot at its physical wagon's REAL coupling position,
|
||||
// not the planning-time slot index — the two diverge once a
|
||||
// load has been dragged onto a different wagon (moveWagonLoad
|
||||
// repoints physicalWagonId but a slot keeps its own sequenceNo),
|
||||
// or once wagon types were interleaved at pinning time. Frozen
|
||||
// and not-yet-pinned slots have no live physical wagon to trust,
|
||||
// so they keep their own slot sequence.
|
||||
const sequenceNo =
|
||||
frozenSlot || !wagon.physicalWagon
|
||||
? wagon.sequenceNo
|
||||
: (wagon.physicalWagon.sequenceNumber ?? wagon.sequenceNo);
|
||||
return {
|
||||
id: wagon.id,
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
sequenceNo,
|
||||
capacityTons: roundTons(Number(wagon.capacityTons)),
|
||||
lengthMeters: roundTons(Number(wagon.lengthMeters)),
|
||||
assignedWeightTons: roundTons(Number(wagon.assignedWeightTons)),
|
||||
@@ -6859,7 +6875,12 @@ export class TrainSchedulingService {
|
||||
})) ?? [],
|
||||
};
|
||||
})
|
||||
.concat(emptyConsistWagons),
|
||||
.concat(emptyConsistWagons)
|
||||
.sort((a, b) =>
|
||||
schedule.reverseWagonOrder
|
||||
? b.sequenceNo - a.sequenceNo
|
||||
: a.sequenceNo - b.sequenceNo,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
|
||||
Reference in New Issue
Block a user