mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 04:15:43 +00:00
add quantity cap for GENERAL contracts and implement capacity tracking
- Updated ContractClearanceService and ContractsController to remove region parameter from queue method. - Enhanced ContractsRepository to attach contract files for download and added attachContractFiles method. - Modified ContractsService to persist cargo scope with quantity cap based on contract kind. - Introduced quantityCap field in CreateContractCargoScopeDto and ContractCargoScope entity. - Implemented capacity tracking in the frontend with ContractCapacityNotice component to display remaining bookable quantities. - Updated various components and services to support new capacity features, including hooks and API calls. - Added migration to include quantity_cap column in contract_cargo_scope table.
This commit is contained in:
@@ -77,7 +77,9 @@ export class ContractBookingService {
|
||||
throw new BadRequestException('Contract validity has expired — no new bookings.');
|
||||
}
|
||||
|
||||
// ONE_TIME: only one active booking at a time (also enforced by partial unique index).
|
||||
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
|
||||
// booking reached a terminal state (e.g. payment expired without shipping),
|
||||
// letting the customer re-book within contract validity (doc §10.4).
|
||||
if (contract.contractKind === 'ONE_TIME') {
|
||||
const active = await this.countActiveBookings(contractId);
|
||||
if (active > 0) {
|
||||
@@ -85,6 +87,9 @@ export class ContractBookingService {
|
||||
'This one-time contract already has an active booking.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// GENERAL: draw down against the cargo quantity cap until it is full.
|
||||
await this.assertWithinQuantityCap(contract, dto);
|
||||
}
|
||||
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
@@ -219,6 +224,115 @@ export class ContractBookingService {
|
||||
.getCount();
|
||||
}
|
||||
|
||||
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Reject a GENERAL booking whose cargo would exceed the contract's quantity
|
||||
* cap. Container caps are per size; bulk is a single tons/items cap. Bookings
|
||||
* that never shipped (CANCELLED / REJECTED / EXPIRED) release their hold.
|
||||
*/
|
||||
private async assertWithinQuantityCap(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<void> {
|
||||
const capacity = await this.computeCapacity(contract);
|
||||
if (capacity.length === 0) return; // uncapped contract
|
||||
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
for (const line of dto.containers ?? []) {
|
||||
const cap = capacity.find((c) => c.containerSize === line.containerSize);
|
||||
if (!cap || cap.remaining == null) continue; // size uncapped
|
||||
if (line.quantity > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
`Only ${cap.remaining} of ${cap.cap} ${line.containerSize} containers remain on this contract.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const requested =
|
||||
(dto.bulkLines ?? []).reduce(
|
||||
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
|
||||
0,
|
||||
) || this.resolveBulkTons(dto) || 0;
|
||||
const cap = capacity.find((c) => c.cap != null);
|
||||
if (cap && cap.remaining != null && requested > cap.remaining) {
|
||||
throw new BadRequestException(
|
||||
`Only ${cap.remaining} of ${cap.cap} remain on this contract.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaining bookable quantity per cargo-scope line: cap minus what prior
|
||||
* bookings already consumed. Returns [] when the contract has no caps.
|
||||
*/
|
||||
async computeCapacity(
|
||||
contract: Contract,
|
||||
): Promise<
|
||||
Array<{
|
||||
containerSize?: string | null;
|
||||
cargoTypeId?: string | null;
|
||||
cap: number | null;
|
||||
booked: number;
|
||||
remaining: number | null;
|
||||
}>
|
||||
> {
|
||||
const scope = contract.cargoScope ?? [];
|
||||
const capped = scope.filter((s) => s.quantityCap != null);
|
||||
if (capped.length === 0) return [];
|
||||
|
||||
const booked = await this.bookedQuantities(contract);
|
||||
return capped.map((s) => {
|
||||
const cap = Number(s.quantityCap);
|
||||
const used =
|
||||
contract.freightType === 'CONTAINER'
|
||||
? (booked.bySize.get(s.containerSize ?? '') ?? 0)
|
||||
: booked.bulk;
|
||||
return {
|
||||
containerSize: s.containerSize,
|
||||
cargoTypeId: s.cargoTypeId,
|
||||
cap,
|
||||
booked: used,
|
||||
remaining: Math.max(0, cap - used),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantities already booked under a contract that still hold capacity. Excludes
|
||||
* bookings that never shipped (CANCELLED / REJECTED / EXPIRED).
|
||||
*/
|
||||
private async bookedQuantities(
|
||||
contract: Contract,
|
||||
): Promise<{ bySize: Map<string, number>; bulk: number }> {
|
||||
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(BookingContainer)
|
||||
.createQueryBuilder('bc')
|
||||
.innerJoin(Booking, 'b', 'b.id = bc.booking_id')
|
||||
.select('bc.container_size', 'size')
|
||||
.addSelect('COALESCE(SUM(bc.quantity), 0)', 'qty')
|
||||
.where('b.contract_id = :contractId', { contractId: contract.id })
|
||||
.andWhere('b.status NOT IN (:...releasing)', { releasing })
|
||||
.groupBy('bc.container_size')
|
||||
.getRawMany<{ size: string | null; qty: string }>();
|
||||
const bySize = new Map<string, number>();
|
||||
for (const r of rows) bySize.set(r.size ?? '', Number(r.qty));
|
||||
return { bySize, bulk: 0 };
|
||||
}
|
||||
|
||||
const row = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
.select('COALESCE(SUM(b.cargo_total_weight_vgm), 0)', 'tons')
|
||||
.where('b.contract_id = :contractId', { contractId: contract.id })
|
||||
.andWhere('b.status NOT IN (:...releasing)', { releasing })
|
||||
.getRawOne<{ tons: string }>();
|
||||
return { bySize: new Map(), bulk: Number(row?.tons ?? 0) };
|
||||
}
|
||||
|
||||
private async resolveRoute(
|
||||
contract: Contract,
|
||||
contractRouteId?: string,
|
||||
|
||||
Reference in New Issue
Block a user