enhance contract and booking services with server-side search and validation improvements

- Added  parameter to  and  for server-side free-text search on contract reference, company name, and booking details.
- Introduced new validation errors in  for container clashes and space issues when creating bookings.
- Implemented paginated dropdown settings retrieval in .
- Updated  to fetch active yards using a new method that handles pagination.
- Enhanced  with a  method to fetch all records by walking through pages.
- Refactored  to support filtering and pagination in schedule listings.
- Improved  to return a paginated list of facilities.
- Updated UI components in  and  to utilize debounced search inputs for better performance.
- Added alerts in  to inform users about booking constraints related to splits and capacity.
- Enhanced  to display notifications for split bookings and capacity usage.
This commit is contained in:
Marshal
2026-07-12 10:51:31 +00:00
parent 6695c5448e
commit 4b7f6d2548
108 changed files with 3187 additions and 1368 deletions

View File

@@ -199,6 +199,8 @@ export class BookingRequestService {
reviewedByStaffId: staffId ?? null,
reviewedAt: new Date(),
} as never);
const contract = await this.contractsService.findById(request.contractId);
this.notifier.shipmentRequestRejected(contract, request.reference, note);
return (await this.repo.findById(requestId)) ?? request;
}

View File

@@ -28,6 +28,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
{} as never, // invoiceService
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, contractsRepository };

View File

@@ -59,6 +59,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
invoiceService as never,
{} as never, // dataSource
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return {

View File

@@ -24,9 +24,11 @@ import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { hasFreightPermission } from '../../common/freight-permission.util';
@@ -40,6 +42,11 @@ import { CreateBookingUnderContractDto } from './dto/create-booking-under-contra
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
/** Bookings that never shipped release their quantity hold on the contract. */
const RELEASING_BOOKING_STATUSES = ['CANCELLED', 'REJECTED', 'EXPIRED'];
const round3 = (n: number): number => Math.round(n * 1000) / 1000;
export interface CreateBookingUnderContractResult {
booking: Booking;
warnings: string[];
@@ -74,6 +81,8 @@ export class ContractBookingService {
private readonly dataSource: DataSource,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
) {}
@@ -90,10 +99,22 @@ export class ContractBookingService {
// A contract whose quantity cap was fully booked is completed — no further
// bookings, even while contract validity and a booking window are still
// open. Capacity released after closure (a cancelled/expired booking)
// reopens the contract on the next booking attempt.
// reopens the contract on the next booking attempt. A ONE_TIME contract
// only closes via a finished split chain, so its room is the outstanding
// split remainder rather than a cap line.
if (contract.status === 'CONTRACT_CLOSED') {
const capacity = await this.computeCapacity(contract);
const hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
let hasRoom: boolean;
if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
hasRoom = outstanding
? contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].some((s) => s.outstanding > 0)
: (outstanding.bulk?.outstanding ?? 0) > 0.001
: false;
} else {
const capacity = await this.computeCapacity(contract);
hasRoom = capacity.some((c) => c.remaining == null || c.remaining > 0);
}
if (!hasRoom) {
throw new BadRequestException(
'This contract is completed — the full contracted quantity has been booked.',
@@ -121,12 +142,21 @@ export class ContractBookingService {
// 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).
// EXCEPTION — split chain: a paid partial split (booking.isSplit) releases
// the slot for the leftover, but the next booking must take the WHOLE
// remainder; the customer cannot start any other booking on the contract.
// If the remainder splits again the same rule repeats until the cap is
// exhausted and the contract completes.
if (contract.contractKind === 'ONE_TIME') {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
if (await this.hasSplitBooking(contractId)) {
await this.assertExactRemainder(contract, dto);
} else {
const active = await this.countActiveBookings(contractId);
if (active > 0) {
throw new BadRequestException(
'This one-time contract already has an active booking.',
);
}
}
} else {
// GENERAL: draw down against the cargo quantity cap until it is full.
@@ -181,6 +211,10 @@ export class ContractBookingService {
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
// EXPORT rides whole or not at all (no split concept): reject the booking
// up front when no single open train on the day can carry it, telling the
// customer how much space is still bookable.
await this.assertExportTrainSpace(contract, route, dto);
}
// Hard capacity gate: a container line whose total weight exceeds the
@@ -573,6 +607,31 @@ export class ContractBookingService {
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
// persisted, only the day re-picked) sizes from the booking itself.
if (contract.tradeDirection === 'EXPORT') {
if (hasCargo) {
const probe = Object.assign(
Object.create(Object.getPrototypeOf(booking)),
booking,
{ scheduledDate: new Date(dto.scheduledDate) },
) as Booking;
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
} else {
await this.assertExportTrainSpace(contract, null, dto, {
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
});
}
}
// First completion persists cargo and draws contract capacity; a resubmit
// after OPERATION_CHANGES_REQUESTED already has its cargo and only re-picks
// the shipment day.
@@ -869,6 +928,196 @@ export class ContractBookingService {
.getCount();
}
/**
* Whether the contract is in split-remainder mode: some booking on it was
* reduced by a paid partial batch offer and still holds capacity. A split
* booking that never shipped (CANCELLED / REJECTED / EXPIRED) releases its
* hold and the contract falls back to the plain single-slot rule — the
* customer can rebook the whole quantity again.
*/
private async hasSplitBooking(contractId: string): Promise<boolean> {
const count = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.getCount();
return count > 0;
}
/**
* Outstanding split remainder of a ONE_TIME contract: what the FIRST split
* booking carried before its reduction (its pre_split_quantities snapshot —
* one-time contracts have no quantity cap to derive this from) minus
* everything currently booked on the contract. Bookings that never shipped
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
* contract has no live split booking.
*/
private async splitOutstanding(
contract: Contract,
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
const first = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId: contract.id })
.andWhere('b.is_split = true')
.andWhere('b.status NOT IN (:...releasing)', { releasing: RELEASING_BOOKING_STATUSES })
.orderBy('b.created_at', 'ASC')
.getOne();
if (!first?.preSplitQuantities) return null;
const booked = await this.bookedQuantities(contract);
if (contract.freightType === 'CONTAINER') {
const bySize = new Map<string, { total: number; outstanding: number }>();
for (const [size, total] of Object.entries(first.preSplitQuantities.bySize ?? {})) {
bySize.set(size, {
total: Number(total),
outstanding: Math.max(0, Number(total) - (booked.bySize.get(size) ?? 0)),
});
}
return { bySize, bulk: null };
}
const total = Number(first.preSplitQuantities.bulkTons ?? 0);
return {
bySize: new Map(),
bulk: { total, outstanding: Math.max(0, round3(total - booked.bulk)) },
};
}
/**
* EXPORT whole-booking single-train gate. Export bookings never split — the
* entire booking must ride ONE open train on the chosen day. When no train
* fits it whole (trying every fillable train on the corridor, earliest
* first), reject BEFORE anything is written, with the largest still-bookable
* space (tons for bulk via the cargo type's wagon type; wagons/containers
* for container freight) so the customer knows what he CAN book.
*/
private async assertExportTrainSpace(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<void> {
if (contract.tradeDirection !== 'EXPORT' || !dto.scheduledDate) return;
const probe = await this.buildExportProbe(contract, route, dto, yards);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (report.scheduleId) return;
throw new BadRequestException(
report.fullMessage ?? 'Not enough train space for this day.',
);
}
/**
* Unsaved booking twin carrying exactly what the batch engine's capacity
* math reads: yards + day for the leg, container lines WITH their container
* type (wagon-type FK) for TEU/wagon sizing, or bulk tons + cargo type
* (wagon-type FK) for tons→wagons conversion.
*/
private async buildExportProbe(
contract: Contract,
route: ContractRoute | null,
dto: CreateBookingUnderContractDto,
yards?: { originYardId: string | null; destinationYardId: string | null },
): Promise<Booking> {
const probe = new Booking();
probe.freightType = contract.freightType;
probe.tradeDirection = contract.tradeDirection;
probe.scheduledDate = dto.scheduledDate ? new Date(dto.scheduledDate) : null;
// Entity types are non-nullable; a missing yard just makes legOf() match no
// train, which surfaces as "no export train for this day" — the right failure.
probe.originYardId = (yards?.originYardId ?? route?.originYardId) as string;
probe.destinationYardId = (yards?.destinationYardId ??
route?.destinationYardId) as string;
if (contract.freightType === 'CONTAINER') {
const lines = await Promise.all(
(dto.containers ?? []).map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const bc = new BookingContainer();
bc.containerSize = line.containerSize;
bc.quantity = line.quantity;
bc.containerTypeId = ct.id;
bc.containerType = ct;
bc.wagonsRequired = Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1));
bc.totalVgmTons = (line.units ?? []).reduce(
(sum, u) => sum + Number(u.vgmTons ?? 0),
0,
);
return bc;
}),
);
probe.bookingContainers = lines;
probe.cargoTotalWeightVgm = lines.reduce(
(sum, l) => sum + Number(l.totalVgmTons ?? 0),
0,
);
return probe;
}
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
probe.cargoTypeId = cargoTypeId;
if (cargoTypeId) {
probe.cargoType =
(await this.dataSource
.getRepository(CargoType)
.findOne({ where: { id: cargoTypeId } })) ?? undefined;
}
return probe;
}
/**
* ONE_TIME split chain: the next booking must take the WHOLE outstanding
* remainder — a one-time contract is a single shipment, so the only way it
* fragments is the system splitting it on train capacity, never the customer
* choosing a partial amount.
*/
private async assertExactRemainder(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return; // no live split booking — nothing to pin the remainder to
if (contract.freightType === 'CONTAINER') {
const sizes = new Set<string>([
...outstanding.bySize.keys(),
...(dto.containers ?? []).map((l) => l.containerSize ?? ''),
]);
for (const size of sizes) {
const remaining = outstanding.bySize.get(size)?.outstanding ?? 0;
const requested = (dto.containers ?? [])
.filter((l) => (l.containerSize ?? '') === size)
.reduce((sum, l) => sum + Number(l.quantity ?? 0), 0);
if (requested !== remaining) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole remainder: ` +
`${remaining} × ${size || 'container'} container(s), got ${requested}.`,
);
}
}
return;
}
const requested =
(dto.bulkLines ?? []).reduce(
(sum, b) => sum + (b.cargoWeightTons ?? b.itemCount ?? 0),
0,
) || this.resolveBulkTons(dto) || 0;
const remaining = outstanding.bulk?.outstanding ?? 0;
// 0.001 t tolerance absorbs the 3-decimal rounding applied to split weights.
if (Math.abs(requested - remaining) > 0.001) {
throw new BadRequestException(
`This one-time contract was split — the next booking must take the whole ` +
`remaining ${remaining} tons, got ${requested}.`,
);
}
}
// ── GENERAL contract quantity cap (draw-down) ──────────────────────────────
/**
@@ -980,34 +1229,96 @@ export class ContractBookingService {
});
}
/**
* Capacity as shown to bookers (the /:id/capacity endpoint): GENERAL cap
* lines as-is, or — for a ONE_TIME contract in split-remainder mode —
* synthesized lines whose cap is the first split booking's pre-split
* snapshot and whose remaining is the outstanding remainder, i.e. the exact
* quantity the next booking must take.
*/
async capacityView(
contract: Contract,
): Promise<
Array<{
containerSize?: string | null;
cargoTypeId?: string | null;
cap: number | null;
booked: number;
remaining: number | null;
}>
> {
const capacity = await this.computeCapacity(contract);
if (capacity.length > 0 || contract.contractKind === 'GENERAL') {
return capacity;
}
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return capacity;
if (contract.freightType === 'CONTAINER') {
return [...outstanding.bySize.entries()].map(([size, s]) => ({
containerSize: size,
cargoTypeId: null,
cap: s.total,
booked: s.total - s.outstanding,
remaining: s.outstanding,
}));
}
const bulk = outstanding.bulk;
if (!bulk) return [];
return [
{
containerSize: null,
cargoTypeId: null,
cap: bulk.total,
booked: round3(bulk.total - bulk.outstanding),
remaining: bulk.outstanding,
},
];
}
/**
* Complete the contract once its quantity cap is fully consumed. Runs after
* every booking created under a GENERAL contract (including a split remainder
* being rebooked): when no capped scope line has capacity left, the contract
* moves to CONTRACT_CLOSED even though its validity window is still open —
* blocking further bookings and shipment requests, including inside an open
* booking window. Never throws: a status hiccup must not undo the booking
* that was just created.
* every booking created under a GENERAL contract, and under a ONE_TIME
* contract in split-remainder mode (a split remainder being rebooked): when
* no capped scope line has capacity left, the contract moves to
* CONTRACT_CLOSED even though its validity window is still open — blocking
* further bookings and shipment requests, including inside an open booking
* window. Never throws: a status hiccup must not undo the booking that was
* just created.
*/
private async maybeCompleteContract(contract: Contract): Promise<void> {
try {
// ONE_TIME contracts are governed by the single-active-booking slot (and
// are promoted to GENERAL on split), so only GENERAL completes by cap.
if (contract.contractKind !== 'GENERAL') return;
if (!['CONTRACT_ACTIVE', 'FULLY_EXECUTED'].includes(contract.status)) return;
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round to
// 3 decimals); container caps are integers and unaffected.
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
// ONE_TIME contracts are governed by the single-active-booking slot, so
// they normally complete by expiry — EXCEPT once a booking was split: the
// remainder chain draws down the split booking's pre-split snapshot, and
// the contract completes when the outstanding remainder hits zero.
// (An unsplit ONE_TIME never completes here, so re-booking after an
// expired unpaid booking keeps working.)
if (contract.contractKind !== 'GENERAL') {
const outstanding = await this.splitOutstanding(contract);
if (!outstanding) return;
// 0.001 tolerance absorbs bulk-ton float rounding (split weights round
// to 3 decimals); container quantities are integers and unaffected.
const exhausted =
contract.freightType === 'CONTAINER'
? [...outstanding.bySize.values()].every((s) => s.outstanding <= 0)
: (outstanding.bulk?.outstanding ?? 0) <= 0.001;
if (!exhausted) return;
} else {
const capacity = await this.computeCapacity(contract);
if (capacity.length === 0) return; // uncapped — completes only by expiry
const exhausted = capacity.every(
(c) => c.remaining != null && c.remaining <= 0.001,
);
if (!exhausted) return;
}
await this.contractsRepository.update(contract.id, {
status: 'CONTRACT_CLOSED',
} as never);
this.logger.log(
`Contract ${contract.reference} quantity cap fully booked — completed; no further bookings within validity.`,
`Contract ${contract.reference} quantity fully booked — completed; no further bookings within validity.`,
);
} catch (err) {
this.logger.error(
@@ -1025,7 +1336,7 @@ export class ContractBookingService {
private async bookedQuantities(
contract: Contract,
): Promise<{ bySize: Map<string, number>; bulk: number }> {
const releasing = ['CANCELLED', 'REJECTED', 'EXPIRED'];
const releasing = RELEASING_BOOKING_STATUSES;
if (contract.freightType === 'CONTAINER') {
const rows = await this.dataSource
.getRepository(BookingContainer)
@@ -1213,6 +1524,8 @@ export class ContractBookingService {
currency: string | null;
pairingErrors: string[];
capacityErrors: string[];
containerClashErrors: string[];
spaceErrors: string[];
lineItems: PriceLineItemDto[];
totalAmount: number;
}> {
@@ -1227,6 +1540,8 @@ export class ContractBookingService {
currency: null,
pairingErrors: [],
capacityErrors: [],
containerClashErrors: [],
spaceErrors: [],
lineItems: [],
totalAmount: 0,
};
@@ -1312,12 +1627,50 @@ export class ContractBookingService {
contract.tradeDirection,
);
// A physical container rides one train only — surface a clash with another
// active booking on the same day + route in the preview, so the form can
// hard-block before the create call rejects with the same rule.
let containerClashErrors: string[] = [];
if (dto.scheduledDate) {
const numbers = lines.flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
const clashes = await this.findContainerClashesOnTrain(
[...new Set(numbers)],
dto.scheduledDate,
{
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
);
containerClashErrors = clashes.map(
(c) =>
`${c.containerNumber} is already booked on ${c.reference} for this shipment day.`,
);
}
// EXPORT rides whole or not at all — surface the single-train space check
// in the preview so the form hard-blocks BEFORE the create call rejects
// with the same message (including how much space is still bookable).
let spaceErrors: string[] = [];
if (contract.tradeDirection === 'EXPORT' && dto.scheduledDate) {
const probe = await this.buildExportProbe(contract, route, dto);
const report = await this.bookingBatchService.exportSpaceReport(probe);
if (!report.scheduleId) {
spaceErrors = [report.fullMessage ?? 'Not enough train space for this day.'];
}
}
return {
overweightLines: computed.overweightLines,
overweightSurchargeAmount,
currency: computed.currency,
pairingErrors,
capacityErrors,
containerClashErrors,
spaceErrors,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
};
@@ -1445,6 +1798,37 @@ export class ContractBookingService {
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const clashes = await this.findContainerClashesOnTrain(
numbers,
scheduledDate,
route,
excludeBookingId,
);
if (clashes.length) {
const detail = clashes
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
/**
* Container numbers among `numbers` that already sit on another active
* booking of the same train — same day and same route. One row per clashing
* number. Bookings without route yards (legacy rows) match on the day alone
* rather than let through.
*/
private async findContainerClashesOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<Array<{ containerNumber: string; reference: string }>> {
if (!numbers.length) return [];
const qb = this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
@@ -1474,18 +1858,7 @@ export class ContractBookingService {
}
const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany();
if (clashes.length) {
const detail = [
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
]
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
return [...new Map(clashes.map((c) => [c.containerNumber, c])).values()];
}
private async assert20ftPairableAtCreate(
@@ -1528,8 +1901,8 @@ export class ContractBookingService {
preferReefer: boolean,
): Promise<ContainerType> {
const sizeFt = parseInt(size, 10);
const { data } = await this.containerTypesService.findAll({ pageSize: 200 });
const types = data.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
const { items } = await this.containerTypesService.findAll({ pageSize: 100 });
const types = items.filter((t) => Number(t.sizeFt) === sizeFt && t.isActive !== false);
if (!types.length) {
throw new BadRequestException(`No container type configured for size ${size}.`);
}

View File

@@ -145,6 +145,19 @@ export class ContractNotifierService {
this.inApp(c, 'Contract changes requested', msg);
}
/** GL rejected a shipment request filed under the contract. */
shipmentRequestRejected(c: Contract, requestRef: string, note?: string): void {
const msg =
`Your shipment request ${requestRef} under contract ${c.reference} was rejected.` +
(note ? ` Reason: ${note}.` : '') +
` Please contact us for details.`;
void this.notifyContact(c, msg, 'SHIPMENT REQUEST REJECTED');
this.inApp(c, 'Shipment request rejected', msg, {
type: NotificationType.BOOKING_STATUS,
data: { contractId: c.id, reference: requestRef },
});
}
// ── Clearance milestones needing customer action ──────────────────────────
/** GL advised duty & tax on the contract cycle — customer pays + uploads slip. */

View File

@@ -80,9 +80,9 @@ export class ContractPricingService {
const sizes = (contract.cargoScope ?? [])
.map((c) => c.containerSize)
.filter((s): s is string => !!s);
const { data: containerTypes } = await this.containerTypesService.findAll({
const { items: containerTypes } = await this.containerTypesService.findAll({
isActive: true,
pageSize: 500,
pageSize: 100,
});
for (const size of sizes) {
const sizeFt = size === '40ft' ? 40 : 20;

View File

@@ -852,11 +852,12 @@ export class ContractsController {
@Get(':id/capacity')
@ApiOperation({
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',
summary:
'Remaining bookable quantity per cargo line (GENERAL draw-down cap, or the outstanding remainder of a split ONE_TIME contract)',
})
async capacity(@Param('id', ParseUUIDPipe) id: string) {
const contract = await this.contractsService.findById(id);
return this.contractBookingService.computeCapacity(contract);
return this.contractBookingService.capacityView(contract);
}
// ── Clearance milestones (doc §11.3, §12.2) ────────────────────────────────

View File

@@ -98,6 +98,7 @@ export class ContractsRepository extends BaseRepository<Contract> {
options: ContractListFilterOptions & {
page: number;
pageSize: number;
search?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
},
@@ -128,6 +129,16 @@ export class ContractsRepository extends BaseRepository<Contract> {
this.applyListFilters(qb, options);
// Free-text search across contract reference and customer (company) name.
// Applied here (not in applyListFilters) because only this query joins the
// `company` alias — the summary-metrics query builder does not.
if (options.search) {
qb.andWhere(
'(contract.reference ILIKE :search OR company.name ILIKE :search)',
{ search: `%${options.search}%` },
);
}
const sortField =
options.sortBy === 'contractValidUntil'
? 'contract.contractValidUntil'

View File

@@ -579,6 +579,7 @@ export class ContractsService {
paymentCurrency: filter.paymentCurrency,
createdFrom: filter.createdFrom,
createdTo: filter.createdTo,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});

View File

@@ -71,6 +71,15 @@ export class FilterContractDto {
@IsDateString()
createdTo?: string;
@ApiPropertyOptional({
description: 'Free-text search across contract reference and company name.',
})
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim() : undefined,
)
search?: string;
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }) => (value ? parseInt(value, 10) : 1))