mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user