mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 17:10:56 +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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user