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:
Marshal
2026-06-28 17:32:18 +00:00
parent e1d54746c2
commit 11c7f1bb74
27 changed files with 512 additions and 74 deletions

View File

@@ -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,

View File

@@ -481,11 +481,7 @@ export class ContractClearanceService {
/**
* GL ET queue: customs (Path B) contracts awaiting pre-booking document review.
*/
async queue(
filter: FilterContractDto,
region?: string,
): Promise<PaginatedContracts> {
void region; // single ET pre-booking queue today; region reserved for split
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
return this.contractsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 100,

View File

@@ -166,11 +166,8 @@ export class ContractsController {
@Get('clearance/queue')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
@ApiOperation({ summary: 'GL ET queue: contracts awaiting pre-booking document review' })
clearanceQueue(
@Query() filter: FilterContractDto,
@Query('region') region?: string,
) {
return this.clearanceService.queue(filter, region);
clearanceQueue(@Query() filter: FilterContractDto) {
return this.clearanceService.queue(filter);
}
@Get(':id')
@@ -485,6 +482,15 @@ export class ContractsController {
);
}
@Get(':id/capacity')
@ApiOperation({
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
})
async capacity(@Param('id', ParseUUIDPipe) id: string) {
const contract = await this.contractsService.findById(id);
return this.contractBookingService.computeCapacity(contract);
}
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────
@Get(':id/milestones')

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { DataSource, In, IsNull, Repository, SelectQueryBuilder } from 'typeorm';
import { FileRecord } from '../files/entities/file.entity';
import { Contract } from './entities/contract.entity';
@@ -132,6 +132,10 @@ export class ContractsRepository extends BaseRepository<Contract> {
.take(pageSize)
.getManyAndCount();
// Attach the generated contract PDF to each row so list/home can offer a
// direct download. Loaded separately to keep pagination counts correct.
await this.attachContractFiles(items);
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 0;
return {
items,
@@ -147,6 +151,28 @@ export class ContractsRepository extends BaseRepository<Contract> {
};
}
/**
* Load contract-resource files for the given contracts and attach them to
* `contract.files`. Kept separate from the paginated query so the one-to-many
* join doesn't inflate the page count.
*/
private async attachContractFiles(contracts: Contract[]): Promise<void> {
if (contracts.length === 0) return;
const ids = contracts.map((c) => c.id);
const files = await this.dataSource.getRepository(FileRecord).find({
where: { resource: 'contracts', resourceId: In(ids), deletedAt: IsNull() },
});
const byContract = new Map<string, FileRecord[]>();
for (const file of files) {
const list = byContract.get(file.resourceId) ?? [];
list.push(file);
byContract.set(file.resourceId, list);
}
for (const contract of contracts) {
contract.files = byContract.get(contract.id) ?? [];
}
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('contract')

View File

@@ -210,7 +210,7 @@ export class ContractsService {
} as never);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
@@ -244,8 +244,12 @@ export class ContractsService {
private async persistCargoScope(
contractId: string,
cargoScope: CreateContractDto['cargoScope'],
contractKind: string,
): Promise<void> {
const repo = this.dataSource.getRepository(ContractCargoScope);
// A quantity cap only governs GENERAL contracts (multi-shipment draw-down).
// ONE_TIME allows a single booking, so any cap on it is meaningless → null.
const isGeneral = contractKind === 'GENERAL';
await repo.save(
cargoScope.map((c) =>
repo.create({
@@ -253,6 +257,7 @@ export class ContractsService {
containerSize: c.containerSize ?? null,
cargoTypeId: c.cargoTypeId ?? null,
cargoFreeText: c.cargoFreeText ?? null,
quantityCap: isGeneral ? (c.quantityCap ?? null) : null,
}),
),
);
@@ -318,7 +323,7 @@ export class ContractsService {
}
if (dto.cargoScope) {
await this.dataSource.getRepository(ContractCargoScope).delete({ contractId: id });
await this.persistCargoScope(id, dto.cargoScope);
await this.persistCargoScope(id, dto.cargoScope, existing.contractKind);
}
if (files.length > 0) {

View File

@@ -51,6 +51,17 @@ export class CreateContractCargoScopeDto {
@IsString()
@MaxLength(200)
cargoFreeText?: string | null;
@ApiPropertyOptional({
description:
'GENERAL only: total bookable quantity for this line (containers per size, or tons/items for bulk). Omit for uncapped.',
minimum: 1,
})
@IsOptional()
@IsNumber()
@Min(1)
@Transform(({ value }) => (value == null || value === '' ? null : Number(value)))
quantityCap?: number | null;
}
/** A contracted lane (origin → destination). Routes carry NO quantity. */

View File

@@ -31,4 +31,14 @@ export class ContractCargoScope extends BaseEntity {
@Column({ name: 'cargo_free_text', type: 'varchar', length: 200, nullable: true })
cargoFreeText?: string | null;
/**
* GENERAL contracts: total cargo quantity allowed across ALL shipments on this
* scope line over the validity window. Container → number of containers of
* this size; bulk → tons (or items for per-item commodities). Bookings draw
* down against it until the cap is reached. NULL = uncapped (always NULL for
* ONE_TIME, which allows a single booking).
*/
@Column({ name: 'quantity_cap', type: 'numeric', precision: 12, scale: 2, nullable: true })
quantityCap?: number | null;
}