add booking request functionality for GENERAL customs contracts

- Create migration for booking_requests table with necessary fields and indexes.
- Implement BookingRequestRepository for database operations related to booking requests.
- Develop BookingRequestService to handle business logic for submitting, accepting, rejecting, and canceling booking requests.
- Create DTOs for creating booking requests and reviewing them.
- Define BookingRequest entity to map to the booking_requests table.
- Add UI components for managing shipment requests, including detail and list pages.
- Implement OperationDatePicker component for selecting available shipment days.
This commit is contained in:
Marshal
2026-06-29 09:30:44 +00:00
parent aeb5e0046e
commit 0f7cac2b68
46 changed files with 2665 additions and 992 deletions

View File

@@ -2074,7 +2074,18 @@ export class TrainSchedulingService {
* Supports sub-route matching: if originYardId and/or destinationYardId are provided,
* returns schedules whose route passes through both yards in the correct order.
*/
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
/**
* Raw OPEN same-route schedule entities a new booking may target (with the
* relations needed for capacity/fleet checks). Shared by getBookableSchedules
* (which maps to list items) and getAvailableDaysForCargo (which needs the raw
* originStationId / scheduledDepartureDate / trainSet).
*/
private async getBookableScheduleEntities(
originYardId?: string,
destinationYardId?: string,
): Promise<
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
> {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
bookingWindowStatus: 'OPEN',
@@ -2089,7 +2100,7 @@ export class TrainSchedulingService {
order: { scheduledDepartureDate: 'ASC' },
});
const filteredSchedules = schedules
return schedules
.filter((s) => ['DRAFT', 'SCHEDULED'].includes(s.status))
.filter((s) => {
// Build the full stop list: origin -> milestones (ordered) -> destination
@@ -2128,10 +2139,15 @@ export class TrainSchedulingService {
}
return true;
})
.map((s) => this.mapScheduleListItem(s));
});
}
return filteredSchedules;
async getBookableSchedules(originYardId?: string, destinationYardId?: string) {
const schedules = await this.getBookableScheduleEntities(
originYardId,
destinationYardId,
);
return schedules.map((s) => this.mapScheduleListItem(s));
}
/**
@@ -2151,6 +2167,93 @@ export class TrainSchedulingService {
return { days: [...days].sort() };
}
/**
* Cargo-aware day pool: the EAT days that are actually FEASIBLE for the given
* cargo. A day is selectable only when ≥1 OPEN schedule on the route that day
* has BOTH (a) enough AVAILABLE wagons of the cargo's matching type at that
* schedule's origin yard, and (b) remaining train capacity (not fully
* allocated). Days with trains but not enough matching wagons are excluded.
* Same `{ days: string[] }` shape as getAvailableDays — the customer still
* picks a DAY, not a train.
*/
async getAvailableDaysForCargo(input: {
originYardId?: string;
destinationYardId?: string;
freightType: 'CONTAINER' | 'BULK';
cargoTypeCode?: string | null;
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
}): Promise<{ days: string[] }> {
const schedules = await this.getBookableScheduleEntities(
input.originYardId,
input.destinationYardId,
);
if (schedules.length === 0) return { days: [] };
const wagonTypes = await this.dataSource.getRepository(WagonType).find();
// Resolve the wagon type this cargo needs.
const requiredType =
input.freightType === 'BULK'
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
: wagonTypes.find(
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
);
if (!requiredType) return { days: [] };
// How many wagons of that type the cargo needs.
const slotsNeeded = this.wagonsNeededForCargo(input, requiredType);
// AVAILABLE wagons of the required type, counted once per origin yard.
const availableByYard = new Map<string, number>();
const availableAt = async (yardId: string): Promise<number> => {
const cached = availableByYard.get(yardId);
if (cached !== undefined) return cached;
const counts = await this.countFleetAvailability(yardId);
const n =
counts.find((c) => c.wagonTypeId === requiredType.id)?.available ?? 0;
availableByYard.set(yardId, n);
return n;
};
const days = new Set<string>();
for (const s of schedules) {
const hasCapacity =
Math.max(0, (s.maxWagons ?? 0) - (s.trainSet?.wagonCount ?? 0)) > 0;
if (!hasCapacity) continue;
const enoughWagons = (await availableAt(s.originStationId)) >= slotsNeeded;
if (!enoughWagons) continue;
if (s.scheduledDepartureDate)
days.add(eatDay(new Date(s.scheduledDepartureDate)));
}
return { days: [...days].sort() };
}
/**
* Wagons needed for a cargo (pre-booking estimate). BULK: ceil(weight /
* capacity). CONTAINER: TEU packing — 40ft = 2 TEU, 20ft = 1 TEU, 2 TEU per
* wagon. Mirrors wagon-plan.util without fabricating Booking entities.
*/
private wagonsNeededForCargo(
input: {
freightType: 'CONTAINER' | 'BULK';
totalWeightTons?: number;
containers?: Array<{ containerSize: string; quantity: number }>;
},
wagonType: WagonType,
): number {
if (input.freightType === 'BULK') {
const capacity = Number(wagonType.capacityTons) || 1;
const weight = Number(input.totalWeightTons ?? 0);
return Math.max(1, Math.ceil(weight / capacity));
}
const teu = (input.containers ?? []).reduce((sum, c) => {
const per = c.containerSize === '40ft' ? 2 : 1;
return sum + per * Math.max(0, Number(c.quantity ?? 0));
}, 0);
return Math.max(1, Math.ceil(teu / 2));
}
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
async existsOpenScheduleOnRouteDay(
originYardId: string,