implement contract cancellation feature and update contract statuses

- Added functionality to cancel contracts, allowing users to provide a reason for cancellation.
- Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED.
- Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses.
- Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts.
- Removed clearance document management from the contract detail page, as it is now handled per booking.
- Introduced a SQL script to reset bookings and train schedules for development purposes.
This commit is contained in:
Marshal
2026-07-28 05:02:58 +00:00
parent 481878e553
commit 2429f6b629
83 changed files with 3083 additions and 3729 deletions

View File

@@ -73,6 +73,30 @@ function describeCargoScope(scope?: ContractCargoScope[]): string | null {
.join(', ');
}
/**
* Order-independent identity of a cargo scope — two contracts cover the same
* cargo only when they list the same container sizes / commodities. Quantity
* caps are deliberately ignored: they size a GENERAL contract, they don't make
* it a different scope.
*/
function cargoScopeKey(
scope?: Array<
Pick<ContractCargoScope, 'containerSize' | 'cargoTypeId' | 'cargoFreeText'>
> | null,
): string {
if (!scope?.length) return '';
return scope
.map((row) =>
[
row.containerSize?.trim().toLowerCase() ?? '',
row.cargoTypeId ?? '',
row.cargoFreeText?.trim().toLowerCase() ?? '',
].join('|'),
)
.sort()
.join(',');
}
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
@@ -190,25 +214,34 @@ export class ContractsService {
}
/**
* Same customer + same service type + an overlapping route already has a
* non-expired contract → block. A route "overlaps" if any origin/destination
* pair matches — good enough today since ONE_TIME and GENERAL contracts both
* carry a single route in practice, and still correct if that changes.
* A live contract only blocks a new request when EVERY commercial dimension
* of the wizard matches it: service type, operation type (trade direction),
* contract kind, cargo scope and route. Change any one of them — a different
* lane, bulk instead of containers, GENERAL instead of ONE_TIME — and the
* customer may request another contract.
*
* A route "overlaps" if any origin/destination pair matches; cargo scope
* matches only when the two scope sets are identical (same freight type and
* the same container sizes / commodities).
*/
private async assertNoDuplicateContract(
companyId: string,
serviceTypeId: string,
routes: CreateContractDto['routes'],
dto: CreateContractDto,
): Promise<void> {
const candidates = await this.contractsRepository.findDuplicateCandidates(
companyId,
serviceTypeId,
dto.serviceTypeId,
);
const incomingScope = cargoScopeKey(dto.cargoScope);
const duplicate = candidates.find(
(c) =>
!isEffectivelyExpired(c) &&
c.tradeDirection === dto.tradeDirection &&
c.contractKind === dto.contractKind &&
c.freightType === dto.freightType &&
cargoScopeKey(c.cargoScope) === incomingScope &&
(c.routes ?? []).some((existingRoute) =>
routes.some(
dto.routes.some(
(r) =>
r.originYardId === existingRoute.originYardId &&
r.destinationYardId === existingRoute.destinationYardId,
@@ -220,7 +253,7 @@ export class ContractsService {
? duplicate.contractValidUntil.toISOString().slice(0, 10)
: 'its approval completes';
throw new ConflictException(
`An active contract already exists for this service type and route (${duplicate.reference}, valid until ${until}). A new request can't be submitted until it expires or is rejected/cancelled.`,
`An active contract already exists for this service type, operation type, contract kind, cargo scope and route (${duplicate.reference}, valid until ${until}). Change any one of them, or wait until this contract expires or is rejected/cancelled.`,
);
}
}
@@ -257,7 +290,7 @@ export class ContractsService {
this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
if (companyId) {
await this.assertNoDuplicateContract(companyId, dto.serviceTypeId, dto.routes);
await this.assertNoDuplicateContract(companyId, dto);
}
// Stamp the operational profile for portal scoping. A forwarder contract
@@ -813,6 +846,24 @@ export class ContractsService {
}
}
// Why the contract is frozen — shown to staff and customer alike.
if (contract.status === 'SUSPENDED') {
try {
const note = await this.contractsRepository.findLatestReviewNote(
contract.id,
'SUSPENSION',
);
contract.latestSuspensionNote = note?.body ?? null;
} catch {
contract.latestSuspensionNote = null;
}
}
// Lets the portal disable "Cancel contract" instead of letting the customer
// click it and read a 400. The API re-checks on cancel regardless.
contract.activeBookingCount =
await this.contractsRepository.countActiveBookings(contract.id);
return contract;
}