mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile
This commit is contained in:
@@ -41,12 +41,54 @@ export class BookingOrdersService {
|
||||
) {}
|
||||
|
||||
/** Orders placed against a contract, with their lines and child booking. */
|
||||
listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
return this.ordersRepository.findByContract(contractBookingId);
|
||||
async listByContract(contractBookingId: string): Promise<BookingOrder[]> {
|
||||
const orders = await this.ordersRepository.findByContract(contractBookingId);
|
||||
await Promise.all(orders.map((o) => this.syncOrderFromChild(o)));
|
||||
return orders;
|
||||
}
|
||||
|
||||
findById(id: string): Promise<BookingOrder | null> {
|
||||
return this.ordersRepository.findById(id);
|
||||
async findById(id: string): Promise<BookingOrder | null> {
|
||||
const order = await this.ordersRepository.findById(id);
|
||||
if (order) await this.syncOrderFromChild(order);
|
||||
return order;
|
||||
}
|
||||
|
||||
/**
|
||||
* The order is a ledger row; the spawned child ONE_TIME booking is what
|
||||
* actually moves through the workflow (clearance → marketing/ops accept →
|
||||
* pay → allocate), exactly like a one-time booking. Nothing writes the order
|
||||
* row after creation, so its stored status would stay 'PENDING' forever.
|
||||
*
|
||||
* Mirror the child onto the order whenever it is read: copy the child's
|
||||
* status, schedulingStatus and trainScheduleId onto the order (mutating the
|
||||
* in-memory instance the caller gets back), and persist that snapshot when it
|
||||
* has drifted so list/detail views and any stored reporting stay in sync.
|
||||
*/
|
||||
private async syncOrderFromChild(order: BookingOrder): Promise<void> {
|
||||
const child = order.booking;
|
||||
if (!child) return;
|
||||
|
||||
const nextStatus = child.status;
|
||||
const nextScheduling = child.schedulingStatus;
|
||||
const nextTrainScheduleId = child.trainScheduleId ?? null;
|
||||
|
||||
const drifted =
|
||||
order.status !== nextStatus ||
|
||||
order.schedulingStatus !== nextScheduling ||
|
||||
(order.trainScheduleId ?? null) !== nextTrainScheduleId;
|
||||
|
||||
// Reflect the child onto the instance returned to the caller.
|
||||
order.status = nextStatus;
|
||||
order.schedulingStatus = nextScheduling;
|
||||
order.trainScheduleId = nextTrainScheduleId;
|
||||
|
||||
if (drifted) {
|
||||
await this.ordersRepository.update(order.id, {
|
||||
status: nextStatus,
|
||||
schedulingStatus: nextScheduling,
|
||||
trainScheduleId: nextTrainScheduleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,7 +167,6 @@ export class BookingOrdersService {
|
||||
}
|
||||
|
||||
const isContainer = contract.freightType === 'CONTAINER';
|
||||
const orderTotal = dto.lines.reduce((sum, l) => sum + l.quantity, 0);
|
||||
|
||||
// Hazardous/reefer counts the customer entered cannot exceed the line they
|
||||
// belong to. Validated for every order regardless of routing.
|
||||
@@ -142,43 +183,31 @@ export class BookingOrdersService {
|
||||
}
|
||||
}
|
||||
|
||||
if (routeLineId) {
|
||||
// Multi-route: validate against the chosen route line's remaining pool.
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
// The contract has a single shared drawdown pool (per container type for
|
||||
// CONTAINER, or one bulk bucket). Routes are pure lanes — the chosen route
|
||||
// only fixed origin/destination/km above — so every order, routed or not,
|
||||
// validates each line against the same shared pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
const chosen = routeLines.find((r) => r.routeLineId === routeLineId)!;
|
||||
if (orderTotal > chosen.remainingQuantity) {
|
||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||
if (!poolLine) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${orderTotal} exceeds remaining ${chosen.remainingQuantity} for this route`,
|
||||
isContainer
|
||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||
: 'This contract has no matching quantity pool',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Single-route: validate each line against the per-container-type pool.
|
||||
const poolLines = await this.generalContractService.getQuantityLines(
|
||||
contract.id,
|
||||
);
|
||||
for (const line of dto.lines) {
|
||||
if (line.quantity <= 0) {
|
||||
throw new BadRequestException('Order quantities must be greater than zero');
|
||||
}
|
||||
const key = isContainer ? (line.containerTypeId ?? '') : '';
|
||||
const poolLine = poolLines.find((p) => (p.containerTypeId ?? '') === key);
|
||||
if (!poolLine) {
|
||||
throw new BadRequestException(
|
||||
isContainer
|
||||
? `Container type ${line.containerTypeId} is not part of this contract`
|
||||
: 'This contract has no matching quantity pool',
|
||||
);
|
||||
}
|
||||
if (line.quantity > poolLine.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||
);
|
||||
}
|
||||
if (line.quantity > poolLine.remainingQuantity) {
|
||||
throw new BadRequestException(
|
||||
`Requested ${line.quantity} exceeds remaining ${poolLine.remainingQuantity}` +
|
||||
(poolLine.containerTypeName ? ` for ${poolLine.containerTypeName}` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,13 @@ export class ContractQuantityLineView {
|
||||
remainingQuantity!: number;
|
||||
}
|
||||
|
||||
/** A contracted/ordered/remaining pool line for one route of a general contract. */
|
||||
/**
|
||||
* A contracted route (lane) of a general contract. Routes are pure
|
||||
* origin→destination lanes the contract covers; they carry NO quantity. The
|
||||
* contract has a single shared drawdown pool (see {@link ContractQuantityLineView}),
|
||||
* and an order picks one lane (for scheduling/billing) while drawing from that
|
||||
* shared pool.
|
||||
*/
|
||||
export class ContractRouteLineView {
|
||||
@ApiProperty({ description: 'Contract route line id' })
|
||||
routeLineId!: string;
|
||||
@@ -39,21 +45,6 @@ export class ContractRouteLineView {
|
||||
@ApiProperty({ nullable: true })
|
||||
destinationYardName!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Container type id (null for bulk/break-bulk)' })
|
||||
containerTypeId!: string | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
containerTypeName!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
contractedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
orderedQuantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
remainingQuantity!: number;
|
||||
|
||||
@ApiProperty({ nullable: true, description: 'Road distance (km); used to bill road orders' })
|
||||
km!: number | null;
|
||||
}
|
||||
|
||||
@@ -125,10 +125,12 @@ export class GeneralContractService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-route drawdown pool for a multi-route general contract: contracted vs.
|
||||
* ordered vs. remaining, one entry per contracted route line. Returns [] for
|
||||
* single-route contracts (no route lines) — callers fall back to
|
||||
* {@link getQuantityLines}.
|
||||
* The contracted routes (lanes) of a multi-route general contract — pure
|
||||
* origin→destination pairs the contract covers. Routes carry NO quantity; the
|
||||
* contract draws from a single shared pool ({@link getQuantityLines}). An order
|
||||
* picks one lane (for scheduling + road billing) and draws from that pool.
|
||||
* Returns [] for single-route contracts (no route lines) — callers then use the
|
||||
* contract's own origin/destination.
|
||||
*/
|
||||
async getRouteLines(
|
||||
contractBookingId: string,
|
||||
@@ -140,52 +142,18 @@ export class GeneralContractService {
|
||||
relations: {
|
||||
originYard: true,
|
||||
destinationYard: true,
|
||||
containerType: true,
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (routeLines.length === 0) return [];
|
||||
|
||||
const ordered = await this.orderedByRouteLine(contractBookingId);
|
||||
|
||||
return routeLines.map((rl) => {
|
||||
const orderedQty = ordered.get(rl.id) ?? 0;
|
||||
const contracted = Number(rl.quantity);
|
||||
return {
|
||||
routeLineId: rl.id,
|
||||
originYardId: rl.originYardId,
|
||||
originYardName: rl.originYard?.label ?? null,
|
||||
destinationYardId: rl.destinationYardId,
|
||||
destinationYardName: rl.destinationYard?.label ?? null,
|
||||
containerTypeId: rl.containerTypeId ?? null,
|
||||
containerTypeName: rl.containerType?.label ?? null,
|
||||
contractedQuantity: contracted,
|
||||
orderedQuantity: orderedQty,
|
||||
remainingQuantity: Math.max(0, contracted - orderedQty),
|
||||
km: rl.km != null ? Number(rl.km) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order quantities, keyed by route_line_id. */
|
||||
private async orderedByRouteLine(
|
||||
contractBookingId: string,
|
||||
): Promise<Map<string, number>> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingOrder)
|
||||
.createQueryBuilder('o')
|
||||
.innerJoin('o.lines', 'line')
|
||||
.select('o.route_line_id', 'key')
|
||||
.addSelect('SUM(line.quantity)', 'total')
|
||||
.where('o.contract_booking_id = :contractBookingId', { contractBookingId })
|
||||
.andWhere('o.route_line_id IS NOT NULL')
|
||||
.andWhere(`o.status NOT IN ('CANCELLED', 'REJECTED')`)
|
||||
.groupBy('o.route_line_id')
|
||||
.getRawMany<{ key: string; total: string }>();
|
||||
|
||||
const map = new Map<string, number>();
|
||||
for (const row of rows) if (row.key) map.set(row.key, Number(row.total));
|
||||
return map;
|
||||
return routeLines.map((rl) => ({
|
||||
routeLineId: rl.id,
|
||||
originYardId: rl.originYardId,
|
||||
originYardName: rl.originYard?.label ?? null,
|
||||
destinationYardId: rl.destinationYardId,
|
||||
destinationYardName: rl.destinationYard?.label ?? null,
|
||||
km: rl.km != null ? Number(rl.km) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sum of non-cancelled order line quantities, keyed by container type id ('' = bulk). */
|
||||
@@ -221,14 +189,12 @@ export class GeneralContractService {
|
||||
return line?.remainingQuantity ?? 0;
|
||||
}
|
||||
|
||||
/** True once every contracted line is fully drawn down. */
|
||||
/**
|
||||
* True once the contract's shared pool is fully drawn down. Routes are pure
|
||||
* lanes with no quantity, so exhaustion is purely a function of the shared
|
||||
* per-container-type (or bulk) pool, regardless of how many routes exist.
|
||||
*/
|
||||
async isExhausted(contractBookingId: string): Promise<boolean> {
|
||||
// Multi-route contracts are exhausted when every route line is drawn down;
|
||||
// single-route contracts fall back to the per-container-type pool.
|
||||
const routeLines = await this.getRouteLines(contractBookingId);
|
||||
if (routeLines.length > 0) {
|
||||
return routeLines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
const lines = await this.getQuantityLines(contractBookingId);
|
||||
return lines.every((l) => l.remainingQuantity <= 0);
|
||||
}
|
||||
|
||||
@@ -485,8 +485,11 @@ export class BookingsService {
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
}
|
||||
|
||||
// Multi-route general contracts: persist the contracted routes + quantities.
|
||||
// Each drawdown order later draws from one of these route lines.
|
||||
// Multi-route general contracts: persist the contracted routes (lanes). Routes
|
||||
// carry NO quantity — the contract has a single shared pool (the cargo-step
|
||||
// total / container quantities). Each drawdown order picks one lane for
|
||||
// scheduling + road billing and draws from that shared pool. `quantity` on the
|
||||
// route line is retained for legacy rows but is no longer meaningful (0).
|
||||
if (isGeneralContract && dto.routes?.length) {
|
||||
const routeRepo = this.dataSource.getRepository(ContractRouteLine);
|
||||
await routeRepo.save(
|
||||
@@ -495,9 +498,8 @@ export class BookingsService {
|
||||
contractBookingId: booking.id,
|
||||
originYardId: r.originYardId,
|
||||
destinationYardId: r.destinationYardId,
|
||||
containerTypeId:
|
||||
dto.freightType === 'CONTAINER' ? (r.containerTypeId ?? null) : null,
|
||||
quantity: r.quantity,
|
||||
containerTypeId: null,
|
||||
quantity: 0,
|
||||
km: r.km ?? null,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -55,6 +55,12 @@ export class CreateBookingContainerDto {
|
||||
vgmPerUnitTons!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A contracted route (lane) of a general contract — a pure origin→destination
|
||||
* pair the contract covers. Routes carry NO quantity; the contract draws from a
|
||||
* single shared pool (the container quantities / bulk total on the booking). An
|
||||
* order picks one lane (for scheduling + road billing) and draws from that pool.
|
||||
*/
|
||||
export class CreateContractRouteDto {
|
||||
@ApiProperty({ format: 'uuid', description: 'FK to yards.id (origin)' })
|
||||
@IsUUID()
|
||||
@@ -64,20 +70,6 @@ export class CreateContractRouteDto {
|
||||
@IsUUID()
|
||||
destinationYardId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Container type for CONTAINER contracts; omit for BULK',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerTypeId?: string;
|
||||
|
||||
@ApiProperty({ description: 'Contracted quantity for this route', minimum: 1 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
quantity!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Road distance (km) for this route; used to bill road orders.',
|
||||
minimum: 0,
|
||||
|
||||
Reference in New Issue
Block a user