mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
Merge pull request #963 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -200,7 +200,7 @@ export class BookingPricingService {
|
||||
// route's container freight, never a frozen OVERWEIGHT_PER_TON value.
|
||||
const frozen = isDerived
|
||||
? null
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency);
|
||||
: this.frozenRateByCode(frozenRates, mod.surchargeCode, paymentCurrency, usdToEtb);
|
||||
const unitAmount = frozen
|
||||
? Number(frozen.unitPrice)
|
||||
: isEtbBooking
|
||||
@@ -547,14 +547,15 @@ export class BookingPricingService {
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
// H15: frozen contract rate for this container size, when present — its
|
||||
// unitPrice is already in the booking currency (no USD→currency convert).
|
||||
// It also stands on its own: a contract line prices off the agreed rate
|
||||
// even when nobody configured a live rate for this leg + type yet.
|
||||
// H15: frozen contract rate for this container size, when present —
|
||||
// converted into the booking currency by frozenRateForContainer. It also
|
||||
// stands on its own: a contract line prices off the agreed rate even when
|
||||
// nobody configured a live rate for this leg + type yet.
|
||||
const frozen = await this.frozenRateForContainer(
|
||||
frozenRates,
|
||||
container.containerTypeId,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
);
|
||||
const label = await this.containerTypeLabel(container.containerTypeId);
|
||||
if (!rate && !frozen) {
|
||||
@@ -632,7 +633,7 @@ export class BookingPricingService {
|
||||
const unitUsd = Number(fallback.rateValue);
|
||||
// H15: bulk freight uses the frozen BULK_FREIGHT snapshot when present.
|
||||
const frozen = isBulk
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency)
|
||||
? this.frozenRateByCode(frozenRates, 'BULK_FREIGHT', paymentCurrency, usdToEtb)
|
||||
: null;
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
@@ -744,12 +745,13 @@ export class BookingPricingService {
|
||||
break;
|
||||
}
|
||||
|
||||
// H15: frozen mile rate (already in booking currency) when the contract
|
||||
// has one; else the live USD rate converted as before.
|
||||
// H15: frozen mile rate (converted into the booking currency) when the
|
||||
// contract has one; else the live USD rate converted as before.
|
||||
const frozen = this.frozenRateByCode(
|
||||
frozenRates,
|
||||
leg.rateType,
|
||||
paymentCurrency,
|
||||
usdToEtb,
|
||||
);
|
||||
let amount: number;
|
||||
let unitAmount: number;
|
||||
@@ -908,20 +910,45 @@ export class BookingPricingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen snapshot for a rate code, or null when there is none, its price
|
||||
* is negative, or it is in a different currency than the booking (in which
|
||||
* case the live-rate path is safer than a mis-converted frozen price).
|
||||
* The frozen snapshot for a rate code, expressed in the BOOKING's currency.
|
||||
*
|
||||
* A contract quotes in USD and freezes USD unit prices; the customer chooses
|
||||
* the billing currency per booking. So a currency mismatch is the normal case
|
||||
* now, not an error — the snapshot is converted rather than discarded. (It
|
||||
* previously returned null on mismatch, which silently dropped the agreed
|
||||
* contract price and re-priced the booking at whatever the live rate had
|
||||
* drifted to.) Grandfathered ETB contracts convert the other way for the same
|
||||
* reason.
|
||||
*
|
||||
* Returns null only when there is no snapshot or its price is unusable.
|
||||
*/
|
||||
private frozenRateByCode(
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
): ContractRateSnapshot | null {
|
||||
const snap = frozenRates?.get(code);
|
||||
if (!snap) return null;
|
||||
if (snap.currency !== bookingCurrency) return null;
|
||||
if (!(Number(snap.unitPrice) >= 0)) return null;
|
||||
return snap;
|
||||
const unitPrice = Number(snap.unitPrice);
|
||||
if (!(unitPrice >= 0)) return null;
|
||||
if (snap.currency === bookingCurrency) return snap;
|
||||
|
||||
// Only USD <-> ETB exist; a rate of 0/NaN would silently zero the price.
|
||||
if (!(usdToEtb > 0)) return null;
|
||||
const converted =
|
||||
snap.currency === 'USD' && bookingCurrency === 'ETB'
|
||||
? Math.round(unitPrice * usdToEtb)
|
||||
: snap.currency === 'ETB' && bookingCurrency === 'USD'
|
||||
? unitPrice / usdToEtb
|
||||
: null;
|
||||
if (converted == null) return null;
|
||||
|
||||
// A copy — the snapshot rows are shared across the pricing pass.
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(snap)), snap, {
|
||||
unitPrice: converted,
|
||||
currency: bookingCurrency,
|
||||
}) as ContractRateSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -933,6 +960,7 @@ export class BookingPricingService {
|
||||
frozenRates: Map<string, ContractRateSnapshot> | null,
|
||||
containerTypeId: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
): Promise<ContractRateSnapshot | null> {
|
||||
if (!frozenRates) return null;
|
||||
let sizeFt: number | null = null;
|
||||
@@ -942,7 +970,7 @@ export class BookingPricingService {
|
||||
return null;
|
||||
}
|
||||
if (!sizeFt) return null;
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency);
|
||||
return this.frozenRateByCode(frozenRates, `CONTAINER_${sizeFt}FT`, bookingCurrency, usdToEtb);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -985,7 +1013,7 @@ export class BookingPricingService {
|
||||
const hasPerSizeSnapshot =
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_20FT') ||
|
||||
frozenRates?.has('CUSTOMS_CLEARANCE_40FT');
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||
const legacyFlat = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
if (legacyFlat && !hasPerSizeSnapshot) {
|
||||
const amount = Number(legacyFlat.unitPrice);
|
||||
if (amount > 0) {
|
||||
@@ -1014,7 +1042,7 @@ export class BookingPricingService {
|
||||
// unknown type — falls through to the live per-type lookup below
|
||||
}
|
||||
const frozen = sizeFt
|
||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency)
|
||||
? this.frozenRateByCode(frozenRates, `CUSTOMS_CLEARANCE_${sizeFt}FT`, currency, usdToEtb)
|
||||
: null;
|
||||
const live = onLeg.find((r) => r.containerTypeId === bc.containerTypeId);
|
||||
if (!frozen && !live) {
|
||||
@@ -1049,7 +1077,7 @@ export class BookingPricingService {
|
||||
// flat snapshot share the CUSTOMS_CLEARANCE code; both are the agreed fee.
|
||||
// Live lookup: the rate scoped to the booking's commodity wins; a
|
||||
// commodity-less rate (legacy) is the catch-all fallback.
|
||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency);
|
||||
const frozen = this.frozenRateByCode(frozenRates, 'CUSTOMS_CLEARANCE', currency, usdToEtb);
|
||||
const live =
|
||||
(booking.cargoTypeId
|
||||
? onLeg.find(
|
||||
|
||||
@@ -916,14 +916,15 @@ export class BookingsController {
|
||||
async uploadBookingDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
{ vesselArrivalDate, doCollectedDate },
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -123,7 +123,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
'booking.files',
|
||||
FileRecord,
|
||||
'file',
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings'",
|
||||
// Superseded versions are soft-deleted, not dropped — keep them out of
|
||||
// the live file list (a manual join condition is not filtered for us).
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings' AND file.deleted_at IS NULL",
|
||||
)
|
||||
.getOne();
|
||||
|
||||
|
||||
@@ -540,6 +540,14 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
|
||||
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
|
||||
vesselArrivalDate?: string | null;
|
||||
|
||||
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
|
||||
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
|
||||
doCollectedDate?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
import { DataSource, EntityManager } from "typeorm";
|
||||
import { CompaniesRepository } from "./companies.repository";
|
||||
import { CompanyProfileRepository } from "./company-profile.repository";
|
||||
import { CompanyChangeRequestRepository } from "./company-change-request.repository";
|
||||
@@ -1111,9 +1111,11 @@ export class CompaniesService {
|
||||
}
|
||||
|
||||
// Anything other than approval has no document gate and no concurrency
|
||||
// hazard — apply it directly.
|
||||
// hazard — no row lock, just the write.
|
||||
if (status !== ProfileStatus.Active) {
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
return this.dataSource.transaction((manager) =>
|
||||
this.applyProfileStatus(manager, existing, status, note, reviewerId),
|
||||
);
|
||||
}
|
||||
|
||||
// Approving over an outstanding document correction would silently accept the
|
||||
@@ -1149,7 +1151,7 @@ export class CompaniesService {
|
||||
);
|
||||
}
|
||||
|
||||
return this.applyProfileStatus(existing, status, note, reviewerId);
|
||||
return this.applyProfileStatus(manager, existing, status, note, reviewerId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1160,11 +1162,21 @@ export class CompaniesService {
|
||||
* transaction while every other status skips that overhead.
|
||||
*/
|
||||
private async applyProfileStatus(
|
||||
manager: EntityManager,
|
||||
existing: CompanyProfile,
|
||||
status: ProfileStatus,
|
||||
note?: string,
|
||||
reviewerId?: string,
|
||||
): Promise<CompanyProfile> {
|
||||
// Every write below goes through `manager`. The approval path holds a
|
||||
// pessimistic_write lock on the company row, and the injected repositories
|
||||
// are bound to the DataSource's default pool — writing the same row through
|
||||
// one of them would block on a lock this very transaction holds, hanging the
|
||||
// request until the statement timed out. That deadlocked the first approval
|
||||
// of any customer: the profile went Active on its own connection while the
|
||||
// company stayed Pending and the caller never got a response.
|
||||
const profileRepo = manager.getRepository(CompanyProfile);
|
||||
const companyRepo = manager.getRepository(Company);
|
||||
// A reference number is only minted the first time a profile is approved
|
||||
// (status → Active). Pending/unapproved profiles carry no reference.
|
||||
const patch: Partial<CompanyProfile> = { status };
|
||||
@@ -1190,7 +1202,8 @@ export class CompaniesService {
|
||||
patch.reviewedAt = new Date();
|
||||
}
|
||||
|
||||
const updated = await this.companyProfilesRepo.update(existing.id, patch);
|
||||
await profileRepo.update(existing.id, patch);
|
||||
const updated = await profileRepo.findOne({ where: { id: existing.id } });
|
||||
if (!updated)
|
||||
throw new NotFoundException(`Company profile ${existing.id} not found`);
|
||||
|
||||
@@ -1208,7 +1221,9 @@ export class CompaniesService {
|
||||
: "approved"
|
||||
: null;
|
||||
if (change) {
|
||||
const company = await this.companiesRepo.findById(updated.companyId);
|
||||
const company = await companyRepo.findOne({
|
||||
where: { id: updated.companyId },
|
||||
});
|
||||
if (company) {
|
||||
this.companyNotifier.profileStatusChanged(
|
||||
company,
|
||||
@@ -1222,7 +1237,7 @@ export class CompaniesService {
|
||||
status === ProfileStatus.Active &&
|
||||
company.status === CompanyStatus.Pending
|
||||
) {
|
||||
await this.companiesRepo.update(updated.companyId, {
|
||||
await companyRepo.update(updated.companyId, {
|
||||
status: CompanyStatus.Active,
|
||||
});
|
||||
this.companyNotifier.companyApproved(company);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
@@ -64,6 +65,9 @@ export interface BookingClearanceView {
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||
vesselArrivalDate?: string | null;
|
||||
doCollectedDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
operationReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
@@ -261,6 +265,8 @@ export class BookingClearanceService {
|
||||
roHold: Boolean(booking.roHoldReason),
|
||||
roHoldReason: booking.roHoldReason ?? null,
|
||||
vesselDepartureDate: booking.vesselDepartureDate ?? null,
|
||||
vesselArrivalDate: booking.vesselArrivalDate ?? null,
|
||||
doCollectedDate: booking.doCollectedDate ?? null,
|
||||
roAmendmentRequestedAt: booking.roAmendmentRequestedAt
|
||||
? booking.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
@@ -547,7 +553,7 @@ export class BookingClearanceService {
|
||||
bookingId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
@@ -556,6 +562,8 @@ export class BookingClearanceService {
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and operation readiness) still
|
||||
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
@@ -566,11 +574,10 @@ export class BookingClearanceService {
|
||||
file,
|
||||
});
|
||||
|
||||
if (vesselDepartureDate?.trim()) {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
} as never);
|
||||
}
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
} as never);
|
||||
|
||||
if (booking.preClearanceFinalizedAt) {
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
|
||||
|
||||
@@ -30,18 +30,35 @@ export class BookingRequestService {
|
||||
private readonly notifier: ContractNotifierService,
|
||||
) {}
|
||||
|
||||
/** Only GENERAL contracts that bundle customs use the request → GL → clearance flow. */
|
||||
private assertGeneralCustoms(contract: Contract): void {
|
||||
if (
|
||||
contract.contractKind !== 'GENERAL' ||
|
||||
!contract.customsClearingEnabled
|
||||
) {
|
||||
/**
|
||||
* Shipment requests exist because on a CUSTOMS contract the customer never
|
||||
* books directly — GL Ethiopia does it for them. The request is how the
|
||||
* customer states what to ship and, now, which currency to be invoiced in.
|
||||
*
|
||||
* GENERAL: each request opens its own per-booking clearance instance.
|
||||
* ONE_TIME: clearance already ran at the contract level, so the request only
|
||||
* records the customer's intent; GL creates the single booking from it.
|
||||
*/
|
||||
private assertCustomsContract(contract: Contract): void {
|
||||
if (!contract.customsClearingEnabled) {
|
||||
throw new BadRequestException(
|
||||
'Shipment requests apply only to general customs-clearance contracts.',
|
||||
'Shipment requests apply only to customs-clearance contracts.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses in which a ONE_TIME customs contract may take a shipment request:
|
||||
* both signatures are in and the contract is at (or past) its clearance
|
||||
* phase, but GL has not booked yet.
|
||||
*/
|
||||
private static readonly ONE_TIME_REQUESTABLE_STATUSES = [
|
||||
'FULLY_EXECUTED',
|
||||
'AWAITING_CLEARANCE_DOCUMENTS',
|
||||
'CLEARANCE_UNDER_REVIEW',
|
||||
'CLEARANCE_READY_FOR_BOOKING',
|
||||
];
|
||||
|
||||
/** Customer submits a shipment request. */
|
||||
async submit(
|
||||
contractId: string,
|
||||
@@ -50,13 +67,35 @@ export class BookingRequestService {
|
||||
): Promise<BookingRequest> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
await this.contractsService.assertCustomerCanAccessContract(userId, contract);
|
||||
this.assertGeneralCustoms(contract);
|
||||
this.assertCustomsContract(contract);
|
||||
const isOneTime = contract.contractKind === 'ONE_TIME';
|
||||
|
||||
if (contract.status === 'CONTRACT_CLOSED') {
|
||||
throw new ConflictException(
|
||||
'This contract is completed — the full contracted quantity has been booked.',
|
||||
);
|
||||
}
|
||||
if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
if (isOneTime) {
|
||||
if (
|
||||
!BookingRequestService.ONE_TIME_REQUESTABLE_STATUSES.includes(
|
||||
contract.status,
|
||||
)
|
||||
) {
|
||||
throw new ConflictException(
|
||||
'The contract must be fully executed before requesting its shipment.',
|
||||
);
|
||||
}
|
||||
// A one-time contract carries exactly one shipment, so it carries at most
|
||||
// one open request — otherwise GL sees two conflicting currencies.
|
||||
const open = (await this.repo.findForContract(contractId)).find(
|
||||
(r) => r.status === 'PENDING',
|
||||
);
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Shipment request ${open.reference} is already open on this contract.`,
|
||||
);
|
||||
}
|
||||
} else if (contract.status !== 'CONTRACT_ACTIVE') {
|
||||
throw new ConflictException(
|
||||
'The contract must be active before requesting a shipment.',
|
||||
);
|
||||
@@ -90,10 +129,14 @@ export class BookingRequestService {
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||
containers: dto.containers,
|
||||
bulk: dto.bulk,
|
||||
});
|
||||
// Draw-down capacity is a GENERAL concept — a ONE_TIME contract's single
|
||||
// shipment is bounded by the contract scope itself, checked when GL books.
|
||||
if (!isOneTime) {
|
||||
await this.contractBookingService.assertRequestWithinCapacity(contract, {
|
||||
containers: dto.containers,
|
||||
bulk: dto.bulk,
|
||||
});
|
||||
}
|
||||
|
||||
const requestedLines: Freight.RequestedShipmentLines = isContainer
|
||||
? {
|
||||
@@ -119,10 +162,17 @@ export class BookingRequestService {
|
||||
// reviews the documents in the clearance queue and completes the booking
|
||||
// (container numbers, VGM, shipment day) once clearance is ready. The
|
||||
// instance is created first so a failure leaves no half-linked request.
|
||||
const booking = await this.contractBookingService.initiateForShipmentRequest(
|
||||
contract,
|
||||
{ contractRouteId: dto.contractRouteId, userId },
|
||||
);
|
||||
// GENERAL: the request immediately opens a BARE booking instance that runs
|
||||
// per-booking phased customs clearance. ONE_TIME: clearance already ran on
|
||||
// the contract, so there is nothing to open — the request stays PENDING
|
||||
// until GL creates the contract's single booking from it.
|
||||
const booking = isOneTime
|
||||
? null
|
||||
: await this.contractBookingService.initiateForShipmentRequest(contract, {
|
||||
contractRouteId: dto.contractRouteId,
|
||||
userId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
});
|
||||
|
||||
const reference = await this.generateReference();
|
||||
const request = await this.repo.create({
|
||||
@@ -131,9 +181,14 @@ export class BookingRequestService {
|
||||
requestedByUserId: userId ?? null,
|
||||
contractRouteId: dto.contractRouteId ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
status: 'ACCEPTED',
|
||||
createdBookingId: booking.id,
|
||||
status: booking ? 'ACCEPTED' : 'PENDING',
|
||||
createdBookingId: booking?.id ?? null,
|
||||
requestedLines,
|
||||
// Intercity is invoiced in birr whatever the customer picked.
|
||||
paymentCurrency:
|
||||
contract.tradeDirection === 'DOMESTIC'
|
||||
? 'ETB'
|
||||
: (dto.paymentCurrency ?? contract.paymentCurrency ?? 'USD'),
|
||||
notes: dto.notes ?? null,
|
||||
} as never);
|
||||
this.notifier.shipmentRequestedToStaff(contract, request.id, request.reference);
|
||||
|
||||
@@ -277,7 +277,7 @@ export class ContractBookingService {
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
@@ -481,7 +481,7 @@ export class ContractBookingService {
|
||||
createdByUserId: user?.id ?? null,
|
||||
scheduledDate: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, null),
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
@@ -520,7 +520,12 @@ export class ContractBookingService {
|
||||
*/
|
||||
async initiateForShipmentRequest(
|
||||
contract: Contract,
|
||||
opts: { contractRouteId?: string; userId?: string | null },
|
||||
opts: {
|
||||
contractRouteId?: string;
|
||||
userId?: string | null;
|
||||
/** Billing currency the customer chose on the shipment request. */
|
||||
paymentCurrency?: string | null;
|
||||
},
|
||||
): Promise<Booking> {
|
||||
const generalCustoms =
|
||||
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
|
||||
@@ -555,7 +560,7 @@ export class ContractBookingService {
|
||||
createdByUserId: opts.userId ?? null,
|
||||
scheduledDate: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, opts?.paymentCurrency),
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
@@ -733,6 +738,13 @@ export class ContractBookingService {
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||
// it is also where the billing currency is chosen. A bare instance was
|
||||
// created before the customer had any figure to look at.
|
||||
paymentCurrency: this.resolveShipmentCurrency(
|
||||
contract,
|
||||
dto.paymentCurrency ?? booking.paymentCurrency,
|
||||
),
|
||||
} as never);
|
||||
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
@@ -1565,6 +1577,23 @@ export class ContractBookingService {
|
||||
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
||||
* Bulk freight keeps the legacy behaviour untouched.
|
||||
*/
|
||||
/**
|
||||
* The billing currency for a shipment under this contract.
|
||||
*
|
||||
* A contract quotes in USD only — the currency is a per-shipment choice now.
|
||||
* Precedence: intercity is always ETB (domestic transport is invoiced in
|
||||
* birr), then the customer's explicit choice, then the contract's own
|
||||
* currency, which is USD for contracts created under the current rule and the
|
||||
* grandfathered value for older ones.
|
||||
*/
|
||||
private resolveShipmentCurrency(
|
||||
contract: Contract,
|
||||
requested?: string | null,
|
||||
): string {
|
||||
if (contract.tradeDirection === 'DOMESTIC') return 'ETB';
|
||||
return requested?.trim() || contract.paymentCurrency || 'USD';
|
||||
}
|
||||
|
||||
private resolveShipmentEquipmentReturn(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
@@ -1795,7 +1824,7 @@ export class ContractBookingService {
|
||||
contractId: contract.id,
|
||||
freightType: contract.freightType,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
paymentCurrency: this.resolveShipmentCurrency(contract, dto.paymentCurrency),
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
@@ -13,7 +18,10 @@ import { FilesService } from '../files/files.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
import { ContractsService, PaginatedContracts } from './contracts.service';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { contractClearanceCodes } from './contract-clearance.util';
|
||||
import {
|
||||
assertDoCollectionDates,
|
||||
contractClearanceCodes,
|
||||
} from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
@@ -72,9 +80,23 @@ export interface ContractClearanceView {
|
||||
blockedReason?: string | null;
|
||||
} | null;
|
||||
dutyRequired?: boolean | null;
|
||||
/**
|
||||
* Pre-declaration handshake with GL Djibouti: who will handle the shipment in
|
||||
* transit. `name` is null until Djibouti answers, and the declaration step is
|
||||
* shut until it is set.
|
||||
*/
|
||||
transitAssignee?: {
|
||||
requestedAt: string | null;
|
||||
requestNote: string | null;
|
||||
name: string | null;
|
||||
assignedAt: string | null;
|
||||
} | null;
|
||||
roHold?: boolean;
|
||||
roHoldReason?: string | null;
|
||||
vesselDepartureDate?: string | null;
|
||||
/** Import DO dates recorded by GL Djibouti on upload. */
|
||||
vesselArrivalDate?: string | null;
|
||||
doCollectedDate?: string | null;
|
||||
roAmendmentRequestedAt?: string | null;
|
||||
bookingReady?: boolean;
|
||||
preClearanceFinalized?: boolean;
|
||||
@@ -98,6 +120,16 @@ export interface ContractClearanceView {
|
||||
declarationSerial?: string | null;
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
/**
|
||||
* The customer's open objection to the advised duty — present only while GL
|
||||
* has not re-advised (the advice milestone is back to PENDING). `rounds` is
|
||||
* how many times it has been sent back, so both sides can see the loop.
|
||||
*/
|
||||
dutyDispute?: {
|
||||
note: string;
|
||||
raisedAt: string;
|
||||
rounds: number;
|
||||
} | null;
|
||||
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
|
||||
/** Import post-allocation T1 transit document state (null until a booking is linked). */
|
||||
t1?: ClearanceT1State | null;
|
||||
@@ -247,6 +279,19 @@ export class ContractClearanceService {
|
||||
contract = await this.reconcilePrematureBookingReady(contractId, contract, boundary);
|
||||
const phase = this.workflowService.resolvePhase(contract, cycle, milestones);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
const dutyDispute = await this.buildDutyDispute(contractId, milestones);
|
||||
const transitAssignee = cycle
|
||||
? {
|
||||
requestedAt: cycle.transitAssigneeRequestedAt
|
||||
? cycle.transitAssigneeRequestedAt.toISOString()
|
||||
: null,
|
||||
requestNote: cycle.transitAssigneeRequestNote ?? null,
|
||||
name: cycle.transitAssigneeName ?? null,
|
||||
assignedAt: cycle.transitAssigneeAssignedAt
|
||||
? cycle.transitAssigneeAssignedAt.toISOString()
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
let workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
contract.tradeDirection ?? 'IMPORT',
|
||||
@@ -361,6 +406,8 @@ export class ContractClearanceService {
|
||||
roHold: Boolean(cycle?.roHoldReason),
|
||||
roHoldReason: cycle?.roHoldReason ?? null,
|
||||
vesselDepartureDate: cycle?.vesselDepartureDate ?? null,
|
||||
vesselArrivalDate: cycle?.vesselArrivalDate ?? null,
|
||||
doCollectedDate: cycle?.doCollectedDate ?? null,
|
||||
roAmendmentRequestedAt: cycle?.roAmendmentRequestedAt
|
||||
? cycle.roAmendmentRequestedAt.toISOString()
|
||||
: null,
|
||||
@@ -373,6 +420,8 @@ export class ContractClearanceService {
|
||||
linkedBookingReviewNote,
|
||||
linkedBookingScheduledDate,
|
||||
dutyAdvice,
|
||||
dutyDispute,
|
||||
transitAssignee,
|
||||
workflowFiles,
|
||||
t1,
|
||||
train,
|
||||
@@ -429,6 +478,32 @@ export class ContractClearanceService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's duty objection, but only while it is still OPEN — i.e. the
|
||||
* advice milestone sits back at PENDING because nobody has re-advised yet.
|
||||
* Re-advising completes that milestone again, which closes the dispute here
|
||||
* without any extra state to keep in sync; the notes stay as the audit trail
|
||||
* and their count is the round number.
|
||||
*/
|
||||
private async buildDutyDispute(
|
||||
contractId: string,
|
||||
milestones: ClearanceMilestone[],
|
||||
): Promise<ContractClearanceView['dutyDispute']> {
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
|
||||
if (!advised || advised.status === 'COMPLETED') return null;
|
||||
const notes = await this.contractsRepository.findReviewNotes(
|
||||
contractId,
|
||||
'DUTY_DISPUTE',
|
||||
);
|
||||
const latest = notes[0];
|
||||
if (!latest) return null;
|
||||
return {
|
||||
note: latest.body,
|
||||
raisedAt: latest.createdAt.toISOString(),
|
||||
rounds: notes.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when every REQUIRED customer-input field has an APPROVED review row in
|
||||
* the current cycle. The 100% gate before clearance can be finalized.
|
||||
@@ -654,6 +729,92 @@ export class ContractClearanceService {
|
||||
return this.applyReview(contractId, fileKey, status, staffId, 'OPERATIONS', note);
|
||||
}
|
||||
|
||||
/**
|
||||
* GL corrects a clearance document in place instead of bouncing it back to
|
||||
* the customer. The customer's upload is NOT lost — it is retired into the
|
||||
* document's version history, stamped with who replaced it and why — and the
|
||||
* new version starts unreviewed, so GL still has to approve it (or query it)
|
||||
* before clearance can be finalized.
|
||||
*
|
||||
* Use this for the small fixes staff can make faster than the customer can
|
||||
* (a wrong page order, a missing stamp scan); a query is still the right tool
|
||||
* when only the customer can produce the correct document.
|
||||
*/
|
||||
async replaceDocument(
|
||||
contractId: string,
|
||||
fileKey: string,
|
||||
file: Express.Multer.File,
|
||||
staffId: string,
|
||||
reason?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertClearanceReviewableStatus(contract);
|
||||
if (!file) throw new BadRequestException('No replacement file uploaded');
|
||||
if (!reason?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'Say why the document is being replaced — it is kept on the file history.',
|
||||
);
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (this.isPhasedCustoms(contract) && cycle?.preClearanceFinalizedAt) {
|
||||
throw new BadRequestException(
|
||||
'Documents cannot be changed after pre-clearance is finalized.',
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.filesService.findByCode(
|
||||
contractId,
|
||||
'contracts',
|
||||
fileKey,
|
||||
);
|
||||
if (!existing) {
|
||||
throw new NotFoundException(
|
||||
`No document is stored under "${fileKey}" on this contract.`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.filesService.upsertByCode(
|
||||
{ resourceId: contractId, resource: 'contracts', code: fileKey, file },
|
||||
{ userId: staffId, reason: reason.trim() },
|
||||
);
|
||||
|
||||
// A fresh version is unreviewed by definition: clear any earlier verdict so
|
||||
// the corrected file is signed off explicitly rather than inheriting a tick.
|
||||
const { inputCode, outputCode } = contractClearanceCodes(contract);
|
||||
const reviews = await this.contractsRepository.findDocumentReviews(
|
||||
contractId,
|
||||
cycle?.id ?? null,
|
||||
);
|
||||
const settingCode =
|
||||
reviews.find((r) => r.fileKey === fileKey)?.settingCode ??
|
||||
(fileKey.startsWith('custom_') ? 'custom' : (inputCode ?? outputCode ?? 'custom'));
|
||||
await this.contractsRepository.setDocumentReviewStatus({
|
||||
contractId,
|
||||
clearanceCycleId: cycle?.id ?? null,
|
||||
settingCode,
|
||||
fileKey,
|
||||
status: 'PENDING',
|
||||
staffId,
|
||||
note: `Replaced by staff: ${reason.trim()}`,
|
||||
});
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
`Document "${fileKey}" replaced by staff: ${reason.trim()}`,
|
||||
'STAFF_NOTE',
|
||||
staffId,
|
||||
'GL_ET',
|
||||
);
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/** Every stored version of one clearance document, newest first. */
|
||||
async documentVersions(contractId: string, fileKey: string) {
|
||||
return this.filesService.versionHistory(contractId, 'contracts', fileKey);
|
||||
}
|
||||
|
||||
private async applyReview(
|
||||
contractId: string,
|
||||
fileKey: string,
|
||||
@@ -968,6 +1129,68 @@ export class ContractClearanceService {
|
||||
// ── Phased clearance actions (ONE_TIME customs, Phase 1) ───────────────────
|
||||
|
||||
/** Sync DOCUMENTS_APPROVED when reviews are done but the milestone row lags. */
|
||||
/**
|
||||
* GL Ethiopia asks Djibouti to name the officer who will handle the shipment
|
||||
* in transit. Nothing else moves until Djibouti answers — the declaration is
|
||||
* gated on it — so this is the first thing ET does once the documents are
|
||||
* approved. Re-requesting is allowed (a nudge) and simply restamps the ask.
|
||||
*/
|
||||
async requestTransitAssignee(
|
||||
contractId: string,
|
||||
note: string | undefined,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
transitAssigneeRequestedAt: new Date(),
|
||||
transitAssigneeRequestedByUserId: userId ?? null,
|
||||
transitAssigneeRequestNote: note?.trim() || null,
|
||||
});
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.transitAssigneeRequested(updated, note?.trim() ?? null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti names the transit officer — free text, because the person is
|
||||
* not a platform user. Answering unblocks the declaration for Ethiopia. A
|
||||
* later call overwrites the name (reassignment) and re-notifies.
|
||||
*/
|
||||
async assignTransitAssignee(
|
||||
contractId: string,
|
||||
assignee: string,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (!assignee?.trim()) {
|
||||
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||
}
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
if (!cycle.transitAssigneeRequestedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
|
||||
);
|
||||
}
|
||||
|
||||
const previous = cycle.transitAssigneeName ?? null;
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
transitAssigneeAssignedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async ensureDeclarationPrerequisites(
|
||||
contractId: string,
|
||||
contract: Contract,
|
||||
@@ -978,6 +1201,16 @@ export class ContractClearanceService {
|
||||
'All required customer documents must be approved before uploading a declaration.',
|
||||
);
|
||||
}
|
||||
// The transit officer must be named by Djibouti first — the declaration is
|
||||
// filed against whoever will physically handle the shipment there.
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.transitAssigneeName) {
|
||||
throw new BadRequestException(
|
||||
cycle?.transitAssigneeRequestedAt
|
||||
? 'GL Djibouti has not assigned the transit officer yet — the declaration cannot be filed until they do.'
|
||||
: 'Request a transit assignee from GL Djibouti before filing the customs declaration.',
|
||||
);
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
const docsApproved = milestones.find((m) => m.milestoneCode === 'DOCUMENTS_APPROVED');
|
||||
if (docsApproved?.status !== 'COMPLETED' && docsApproved?.status !== 'SKIPPED') {
|
||||
@@ -1084,6 +1317,67 @@ export class ContractClearanceService {
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer disagrees with the advised duty & tax and asks GL Ethiopia to
|
||||
* correct it. Nothing is paid; the advice milestone reopens so the Duty & tax
|
||||
* step becomes actionable again on the GL clearance page, with the customer's
|
||||
* message shown beside it. GL re-advises (same endpoint as the first time),
|
||||
* which closes the dispute — the loop may run as many rounds as it takes.
|
||||
*/
|
||||
async disputeDuty(
|
||||
contractId: string,
|
||||
note: string,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (contract.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty applies only to import contracts.');
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'Say what is wrong with the advised amount so GL can correct it.',
|
||||
);
|
||||
}
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle?.dutyRequired) {
|
||||
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestones(contractId);
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
'There is no advised duty amount to dispute yet.',
|
||||
);
|
||||
}
|
||||
// Once the slip is in, the money is paid — a dispute then is a refund
|
||||
// conversation, not a re-advice.
|
||||
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.contractsRepository.createReviewNote(
|
||||
contractId,
|
||||
note.trim(),
|
||||
'DUTY_DISPUTE',
|
||||
userId,
|
||||
'CUSTOMER',
|
||||
);
|
||||
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
|
||||
// (the stepper picks its active step from milestone completion).
|
||||
await this.milestoneService.reopenForContract(contractId, 'DUTY_TAXES_ADVISED');
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
currentPhase: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.dutyDisputed(updated, note.trim());
|
||||
return updated;
|
||||
}
|
||||
|
||||
async uploadDutySlip(
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
@@ -1198,7 +1492,7 @@ export class ContractClearanceService {
|
||||
contractId: string,
|
||||
file: Express.Multer.File,
|
||||
userId?: string,
|
||||
vesselDepartureDate?: string,
|
||||
dates?: { vesselArrivalDate?: string; doCollectedDate?: string },
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
@@ -1208,6 +1502,8 @@ export class ContractClearanceService {
|
||||
|
||||
if (!file) throw new BadRequestException('No Delivery Order uploaded');
|
||||
|
||||
const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates);
|
||||
|
||||
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
|
||||
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
|
||||
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
|
||||
@@ -1219,9 +1515,10 @@ export class ContractClearanceService {
|
||||
});
|
||||
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (cycle && vesselDepartureDate?.trim()) {
|
||||
if (cycle) {
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
vesselDepartureDate: vesselDepartureDate.trim(),
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
}
|
||||
if (cycle?.preClearanceFinalizedAt) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { INTERCITY_DOCUMENTS_SETTING_CODE } from '../bookings/clearance.util';
|
||||
|
||||
@@ -84,3 +86,47 @@ export function contractClearanceCodes(contract: Contract): {
|
||||
includesCustoms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Djibouti GL cannot record a Delivery Order without saying WHEN the vessel
|
||||
* arrived and WHEN the DO was collected — the file alone leaves the import
|
||||
* timeline unauditable. Shared by the contract and per-booking DO uploads so
|
||||
* one endpoint can never be laxer than the other.
|
||||
*
|
||||
* Returns the normalized `YYYY-MM-DD` pair; throws if either is missing,
|
||||
* unparseable, or the DO predates the vessel's arrival.
|
||||
*/
|
||||
export function assertDoCollectionDates(dates?: {
|
||||
vesselArrivalDate?: string;
|
||||
doCollectedDate?: string;
|
||||
}): { vesselArrivalDate: string; doCollectedDate: string } {
|
||||
const vesselArrivalDate = normalizeDoDate(
|
||||
dates?.vesselArrivalDate,
|
||||
'Vessel arrival date',
|
||||
);
|
||||
const doCollectedDate = normalizeDoDate(
|
||||
dates?.doCollectedDate,
|
||||
'DO collected date',
|
||||
);
|
||||
|
||||
if (doCollectedDate < vesselArrivalDate) {
|
||||
throw new BadRequestException(
|
||||
'DO collected date cannot be earlier than the vessel arrival date.',
|
||||
);
|
||||
}
|
||||
|
||||
return { vesselArrivalDate, doCollectedDate };
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` or throw — the column is a DATE, so time zones never enter. */
|
||||
function normalizeDoDate(value: string | undefined, label: string): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(`${label} is required to upload a Delivery Order.`);
|
||||
}
|
||||
const date = trimmed.slice(0, 10);
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
|
||||
throw new BadRequestException(`${label} is not a valid date.`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,18 @@ export type ContractDocumentChange =
|
||||
toOrder: number;
|
||||
}
|
||||
| { kind: 'DOCUMENT_TITLE_CHANGED'; title: string; fromTitle: string | null }
|
||||
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number };
|
||||
| { kind: 'WHEREAS_CHANGED'; added: number; removed: number }
|
||||
/**
|
||||
* A contract field (not a document article) changed — the customer editing a
|
||||
* DRAFT/CHANGES_REQUESTED contract, e.g. its route, cargo or service type.
|
||||
*/
|
||||
| {
|
||||
kind: 'FIELD_CHANGED';
|
||||
field: string;
|
||||
label: string;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
};
|
||||
|
||||
type SnapshotLike = Pick<
|
||||
ContractDocumentSnapshot,
|
||||
@@ -134,6 +145,60 @@ export function diffSnapshots(
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Human label per audited contract field, in the order they read on the form. */
|
||||
export const CONTRACT_FIELD_LABELS: Record<string, string> = {
|
||||
contractKind: 'Contract kind',
|
||||
tradeDirection: 'Trade direction',
|
||||
freightType: 'Freight type',
|
||||
serviceType: 'Service type',
|
||||
paymentCurrency: 'Payment currency',
|
||||
contractType: 'Contract type',
|
||||
isHazardous: 'Hazardous',
|
||||
hazardClass: 'Hazard class',
|
||||
unNumber: 'UN number',
|
||||
isReefer: 'Reefer',
|
||||
equipmentReturn: 'Equipment return',
|
||||
customsClearingAgent: 'Customs clearing agent',
|
||||
firstMilePickupAddress: 'First-mile pickup address',
|
||||
lastMileDeliveryAddress: 'Last-mile delivery address',
|
||||
routes: 'Routes',
|
||||
cargoScope: 'Cargo scope',
|
||||
};
|
||||
|
||||
/** Render a field value for the audit trail — never "[object Object]". */
|
||||
function displayValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two flat maps of contract fields and report what changed. Only keys
|
||||
* present in `after` are considered, so a partial update never reports the
|
||||
* fields it did not touch.
|
||||
*/
|
||||
export function diffContractFields(
|
||||
before: Record<string, unknown>,
|
||||
after: Record<string, unknown>,
|
||||
): ContractDocumentChange[] {
|
||||
const changes: ContractDocumentChange[] = [];
|
||||
|
||||
for (const [field, nextRaw] of Object.entries(after)) {
|
||||
const next = displayValue(nextRaw);
|
||||
const previous = displayValue(before[field]);
|
||||
if (next === previous) continue;
|
||||
changes.push({
|
||||
kind: 'FIELD_CHANGED',
|
||||
field,
|
||||
label: CONTRACT_FIELD_LABELS[field] ?? field,
|
||||
from: previous,
|
||||
to: next,
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Short human summary of a change set, e.g. "2 articles edited, 1 article added". */
|
||||
export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
if (changes.length === 0) return 'No changes';
|
||||
@@ -148,6 +213,7 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const parts: string[] = [];
|
||||
const fields: string[] = [];
|
||||
|
||||
for (const change of changes) {
|
||||
const verb = articleVerbs[change.kind];
|
||||
@@ -157,9 +223,19 @@ export function summarizeChanges(changes: ContractDocumentChange[]): string {
|
||||
parts.push('document title changed');
|
||||
} else if (change.kind === 'WHEREAS_CHANGED') {
|
||||
parts.push('recitals changed');
|
||||
} else if (change.kind === 'FIELD_CHANGED') {
|
||||
fields.push(change.label.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length > 0) {
|
||||
parts.push(
|
||||
fields.length <= 3
|
||||
? `${fields.join(', ')} changed`
|
||||
: `${fields.length} contract fields changed`,
|
||||
);
|
||||
}
|
||||
|
||||
const articleParts = [...counts.entries()].map(
|
||||
([verb, count]) => `${count} article${count === 1 ? '' : 's'} ${verb}`,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { diffSnapshots, summarizeChanges } from './contract-document-diff.util';
|
||||
import {
|
||||
ContractDocumentChange,
|
||||
diffSnapshots,
|
||||
summarizeChanges,
|
||||
} from './contract-document-diff.util';
|
||||
import { ContractDocumentRevision } from './entities/contract-document-revision.entity';
|
||||
import type { ContractDocumentSnapshot } from './entities/contract.entity';
|
||||
|
||||
@@ -12,6 +16,39 @@ export interface RecordRevisionInput {
|
||||
after: ContractDocumentSnapshot | null;
|
||||
actorId?: string | null;
|
||||
actorRole?: string | null;
|
||||
actorName?: string | null;
|
||||
stepId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `iam.users.name` is a localized object ({ en, am, … }), not a string — a
|
||||
* plain `String(name)` there yields "[object Object]" in the audit trail.
|
||||
*/
|
||||
interface IamUserRow {
|
||||
name?: Record<string, string> | string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
|
||||
/** Best display name for a user row: English label → any locale → login → email. */
|
||||
function pickUserName(user: IamUserRow): string | null {
|
||||
const { name } = user;
|
||||
if (typeof name === 'string' && name.trim()) return name.trim();
|
||||
if (name && typeof name === 'object') {
|
||||
const localized =
|
||||
name.en ?? Object.values(name).find((v) => typeof v === 'string' && v.trim());
|
||||
if (localized?.trim()) return localized.trim();
|
||||
}
|
||||
return user.username?.trim() || user.email?.trim() || null;
|
||||
}
|
||||
|
||||
/** Pre-computed changes (contract fields), rather than a document diff. */
|
||||
export interface RecordChangesInput {
|
||||
contractId: string;
|
||||
changes: ContractDocumentChange[];
|
||||
actorId?: string | null;
|
||||
actorRole?: string | null;
|
||||
actorName?: string | null;
|
||||
stepId?: string | null;
|
||||
}
|
||||
|
||||
@@ -22,6 +59,7 @@ export class ContractDocumentHistoryService {
|
||||
constructor(
|
||||
@InjectRepository(ContractDocumentRevision)
|
||||
private readonly revisionRepo: Repository<ContractDocumentRevision>,
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -30,18 +68,32 @@ export class ContractDocumentHistoryService {
|
||||
* and swallowed. A no-op edit records nothing.
|
||||
*/
|
||||
async record(input: RecordRevisionInput): Promise<void> {
|
||||
return this.recordChanges({
|
||||
...input,
|
||||
changes: diffSnapshots(input.before, input.after),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a revision from an already-computed change set — the contract-field
|
||||
* path, where there is no document snapshot to diff. Same best-effort
|
||||
* contract as {@link record}: a no-op change set records nothing, and a
|
||||
* failure here never breaks the edit that triggered it.
|
||||
*/
|
||||
async recordChanges(input: RecordChangesInput): Promise<void> {
|
||||
try {
|
||||
const changes = diffSnapshots(input.before, input.after);
|
||||
if (changes.length === 0) return;
|
||||
if (input.changes.length === 0) return;
|
||||
|
||||
await this.revisionRepo.save(
|
||||
this.revisionRepo.create({
|
||||
contractId: input.contractId,
|
||||
actorId: input.actorId ?? null,
|
||||
actorRole: input.actorRole ?? null,
|
||||
actorName:
|
||||
input.actorName ?? (await this.resolveActorName(input.actorId)),
|
||||
stepId: input.stepId ?? null,
|
||||
summary: summarizeChanges(changes),
|
||||
changes,
|
||||
summary: summarizeChanges(input.changes),
|
||||
changes: input.changes,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -51,11 +103,62 @@ export class ContractDocumentHistoryService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Name for the acting user. `iam.users` is owned by the auth system and has
|
||||
* no entity here, so it is read directly; a miss is not an error — the trail
|
||||
* still carries the id, role and timestamp.
|
||||
*/
|
||||
private async resolveActorName(
|
||||
actorId?: string | null,
|
||||
): Promise<string | null> {
|
||||
if (!actorId) return null;
|
||||
const names = await this.resolveActorNames([actorId]);
|
||||
return names.get(actorId) ?? null;
|
||||
}
|
||||
|
||||
/** Batched {@link resolveActorName} — one query for a whole revision list. */
|
||||
private async resolveActorNames(
|
||||
actorIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
const resolved = new Map<string, string>();
|
||||
const ids = [...new Set(actorIds.filter(Boolean))];
|
||||
if (ids.length === 0) return resolved;
|
||||
|
||||
try {
|
||||
const rows = (await this.dataSource.query(
|
||||
`SELECT id, name, username, email FROM iam.users WHERE id = ANY($1::uuid[])`,
|
||||
[ids],
|
||||
)) as Array<IamUserRow & { id: string }>;
|
||||
for (const row of rows) {
|
||||
const name = pickUserName(row);
|
||||
if (name) resolved.set(row.id, name);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Could not resolve actor names: ${String(err)}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Revision history for a contract, newest first. */
|
||||
list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||
return this.revisionRepo.find({
|
||||
async list(contractId: string): Promise<ContractDocumentRevision[]> {
|
||||
const revisions = await this.revisionRepo.find({
|
||||
where: { contractId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
// Rows written before actor_name existed still carry an actor_id — resolve
|
||||
// those for display (one query for the whole list) rather than backfilling.
|
||||
const missing = revisions
|
||||
.filter((r) => !r.actorName && r.actorId)
|
||||
.map((r) => r.actorId as string);
|
||||
if (missing.length === 0) return revisions;
|
||||
|
||||
const names = await this.resolveActorNames(missing);
|
||||
for (const revision of revisions) {
|
||||
if (!revision.actorName && revision.actorId) {
|
||||
revision.actorName = names.get(revision.actorId) ?? null;
|
||||
}
|
||||
}
|
||||
return revisions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* The duty advice → dispute → re-advice loop. GL Ethiopia advises an amount;
|
||||
* the customer either pays it or sends it back with a reason. Sending it back
|
||||
* reopens the advice milestone — that is what puts the Duty & tax step back in
|
||||
* GL's hands — and the round can repeat until the amount is agreed.
|
||||
*/
|
||||
describe('ContractClearanceService — duty dispute', () => {
|
||||
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||
({
|
||||
id: 'ctr-1',
|
||||
reference: 'CTR-2026-00042',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
...over,
|
||||
}) as Contract;
|
||||
|
||||
const milestone = (code: string, status: string) =>
|
||||
({ milestoneCode: code, status }) as never;
|
||||
|
||||
let repo: {
|
||||
currentCycle: jest.Mock;
|
||||
createReviewNote: jest.Mock;
|
||||
updateCycle: jest.Mock;
|
||||
findReviewNotes: jest.Mock;
|
||||
};
|
||||
let contractsService: { findById: jest.Mock };
|
||||
let workflowService: { listMilestones: jest.Mock };
|
||||
let milestoneService: { reopenForContract: jest.Mock };
|
||||
let notifier: { dutyDisputed: jest.Mock };
|
||||
let service: ContractClearanceService;
|
||||
|
||||
const build = (milestones: unknown[]) => {
|
||||
workflowService.listMilestones.mockResolvedValue(milestones);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
currentCycle: jest.fn().mockResolvedValue({ id: 'cyc-1', dutyRequired: true }),
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
updateCycle: jest.fn().mockResolvedValue(undefined),
|
||||
findReviewNotes: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
|
||||
workflowService = { listMilestones: jest.fn().mockResolvedValue([]) };
|
||||
milestoneService = { reopenForContract: jest.fn().mockResolvedValue(undefined) };
|
||||
notifier = { dutyDisputed: jest.fn() };
|
||||
|
||||
service = new ContractClearanceService(
|
||||
repo as never,
|
||||
contractsService as never,
|
||||
{} as never, // bookingsService
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
workflowService as never,
|
||||
milestoneService as never,
|
||||
{} as never, // dropdownSettingsService
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
);
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
milestone('DUTY_TAX_PAID', 'PENDING'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('records the objection and hands the step back to GL', async () => {
|
||||
await service.disputeDuty('ctr-1', ' Declared value is wrong ', 'user-1');
|
||||
|
||||
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||
'ctr-1',
|
||||
'Declared value is wrong',
|
||||
'DUTY_DISPUTE',
|
||||
'user-1',
|
||||
'CUSTOMER',
|
||||
);
|
||||
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
||||
expect(milestoneService.reopenForContract).toHaveBeenCalledWith(
|
||||
'ctr-1',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
expect(repo.updateCycle).toHaveBeenCalledWith('cyc-1', {
|
||||
currentPhase: 'GL_ET_OUTPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('tells GL Ethiopia, not the customer', async () => {
|
||||
await service.disputeDuty('ctr-1', 'Too high', 'user-1');
|
||||
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'ctr-1' }),
|
||||
'Too high',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
||||
await expect(service.disputeDuty('ctr-1', ' ')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(milestoneService.reopenForContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when nothing has been advised yet', async () => {
|
||||
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
||||
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||
/no advised duty amount/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
milestone('DUTY_TAX_PAID', 'COMPLETED'),
|
||||
]);
|
||||
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||
/already been submitted/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses when duty was never required for this clearance', async () => {
|
||||
repo.currentCycle.mockResolvedValue({ id: 'cyc-1', dutyRequired: false });
|
||||
await expect(service.disputeDuty('ctr-1', 'Too high')).rejects.toThrow(
|
||||
/not required/i,
|
||||
);
|
||||
});
|
||||
|
||||
describe('the view', () => {
|
||||
const buildDispute = (milestones: unknown[]) =>
|
||||
(
|
||||
service as unknown as {
|
||||
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
).buildDutyDispute('ctr-1', milestones);
|
||||
|
||||
it('shows the objection while GL still owes a corrected advice', async () => {
|
||||
repo.findReviewNotes.mockResolvedValue([
|
||||
{ body: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
||||
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||
]);
|
||||
|
||||
const dispute = await buildDispute([
|
||||
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
|
||||
]);
|
||||
|
||||
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
|
||||
});
|
||||
|
||||
it('clears itself once GL re-advises', async () => {
|
||||
repo.findReviewNotes.mockResolvedValue([
|
||||
{ body: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||
]);
|
||||
|
||||
const dispute = await buildDispute([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
]);
|
||||
|
||||
expect(dispute).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
diffContractFields,
|
||||
summarizeChanges,
|
||||
} from './contract-document-diff.util';
|
||||
|
||||
/**
|
||||
* The contract-field audit runs on the customer's own edits, so it has to be
|
||||
* exact: never report a field the edit did not touch, and never render a value
|
||||
* as "[object Object]" or "true" in the trail a reviewer reads.
|
||||
*/
|
||||
describe('diffContractFields', () => {
|
||||
it('reports only the fields that actually changed', () => {
|
||||
const changes = diffContractFields(
|
||||
{ freightType: 'BULK', paymentCurrency: 'USD', isReefer: false },
|
||||
{ freightType: 'CONTAINER', paymentCurrency: 'USD', isReefer: false },
|
||||
);
|
||||
|
||||
expect(changes).toEqual([
|
||||
{
|
||||
kind: 'FIELD_CHANGED',
|
||||
field: 'freightType',
|
||||
label: 'Freight type',
|
||||
from: 'BULK',
|
||||
to: 'CONTAINER',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders booleans as Yes/No, not true/false', () => {
|
||||
const [change] = diffContractFields({ isHazardous: false }, { isHazardous: true });
|
||||
|
||||
expect(change).toMatchObject({ label: 'Hazardous', from: 'No', to: 'Yes' });
|
||||
});
|
||||
|
||||
it('treats null, undefined and empty string as "not set"', () => {
|
||||
expect(diffContractFields({ unNumber: null }, { unNumber: '' })).toEqual([]);
|
||||
expect(diffContractFields({ unNumber: undefined }, { unNumber: null })).toEqual([]);
|
||||
|
||||
const [set] = diffContractFields({ unNumber: null }, { unNumber: 'UN1234' });
|
||||
expect(set).toMatchObject({ from: null, to: 'UN1234' });
|
||||
});
|
||||
|
||||
it('ignores fields absent from the update', () => {
|
||||
// A partial edit must not report the fields it never sent.
|
||||
expect(diffContractFields({ freightType: 'BULK', isReefer: true }, {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('records a route swap that keeps the same lane count', () => {
|
||||
const [change] = diffContractFields(
|
||||
{ routes: 'Nagad → Mojo' },
|
||||
{ routes: 'Nagad → Adama' },
|
||||
);
|
||||
|
||||
expect(change).toMatchObject({
|
||||
label: 'Routes',
|
||||
from: 'Nagad → Mojo',
|
||||
to: 'Nagad → Adama',
|
||||
});
|
||||
});
|
||||
|
||||
it('summarises field changes by name, and by count once there are many', () => {
|
||||
const few = diffContractFields(
|
||||
{ freightType: 'BULK', paymentCurrency: 'USD' },
|
||||
{ freightType: 'CONTAINER', paymentCurrency: 'ETB' },
|
||||
);
|
||||
expect(summarizeChanges(few)).toBe('freight type, payment currency changed');
|
||||
|
||||
const many = diffContractFields(
|
||||
{ a: '1', b: '1', c: '1', d: '1' },
|
||||
{ a: '2', b: '2', c: '2', d: '2' },
|
||||
);
|
||||
expect(summarizeChanges(many)).toBe('4 contract fields changed');
|
||||
});
|
||||
|
||||
it('summarises document and field changes together', () => {
|
||||
const summary = summarizeChanges([
|
||||
{ kind: 'ARTICLE_BODY_CHANGED', articleId: 'a-1', title: 'Article 1' },
|
||||
{
|
||||
kind: 'FIELD_CHANGED',
|
||||
field: 'routes',
|
||||
label: 'Routes',
|
||||
from: 'A → B',
|
||||
to: 'A → C',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary).toBe('1 article edited, routes changed');
|
||||
});
|
||||
});
|
||||
@@ -192,6 +192,56 @@ export class ContractNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Ethiopia asked Djibouti to name the transit officer. Staff-only, and
|
||||
* deep-linked to the Djibouti clearance page where the name is entered — the
|
||||
* customs declaration is blocked until they answer.
|
||||
*/
|
||||
transitAssigneeRequested(c: Contract, note: string | null): void {
|
||||
const msg =
|
||||
`GL Ethiopia needs a transit assignee for contract ${c.reference} before ` +
|
||||
`the customs declaration can be filed.${note ? ` Note: "${note}"` : ''}`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE REQUESTED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Transit assignee needed — ${c.reference}`, msg, {
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/gl-djibouti/clearance/${c.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** Djibouti named (or changed) the transit officer — Ethiopia can proceed. */
|
||||
transitAssigneeAssigned(
|
||||
c: Contract,
|
||||
assignee: string,
|
||||
previous: string | null,
|
||||
): void {
|
||||
const msg = previous
|
||||
? `GL Djibouti changed the transit assignee for contract ${c.reference} from ` +
|
||||
`"${previous}" to "${assignee}".`
|
||||
: `GL Djibouti assigned ${assignee} to handle contract ${c.reference} in transit. ` +
|
||||
`The customs declaration can now be filed.`;
|
||||
this.logger.log(`TRANSIT ASSIGNEE ASSIGNED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Transit assignee set — ${c.reference}`, msg, {
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer disputed the advised duty & tax. This goes to STAFF, not the
|
||||
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
|
||||
* page is where they do it.
|
||||
*/
|
||||
dutyDisputed(c: Contract, note: string): void {
|
||||
const msg =
|
||||
`The customer disputed the duty & tax advised on contract ${c.reference}: ` +
|
||||
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||
this.logger.log(`DUTY DISPUTED — ${c.reference}`);
|
||||
this.inAppStaff(c, `Duty disputed on ${c.reference}`, msg, {
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/contracts/clearance/${c.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
/** A clearance document was queried — customer must re-upload it. */
|
||||
clearanceDocumentQueried(c: Contract, fileKey: string, note: string): void {
|
||||
const msg =
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
|
||||
/**
|
||||
* `iam.users.name` is a localized jsonb object, not a string. Reading it
|
||||
* naively puts "[object Object]" in the audit trail — or, worse, throws and
|
||||
* leaves every revision anonymous. These specs pin the resolution rules.
|
||||
*/
|
||||
describe('ContractDocumentHistoryService actor names', () => {
|
||||
const build = (rows: unknown[]) => {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
const service = Object.create(
|
||||
ContractDocumentHistoryService.prototype,
|
||||
) as ContractDocumentHistoryService;
|
||||
Object.assign(service, {
|
||||
logger: { warn: jest.fn(), error: jest.fn() },
|
||||
dataSource: { query: jest.fn().mockResolvedValue(rows) },
|
||||
revisionRepo: {
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: jest.fn((row: Record<string, unknown>) => {
|
||||
saved.push(row);
|
||||
return Promise.resolve(row);
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
return { service, saved };
|
||||
};
|
||||
|
||||
const change = {
|
||||
kind: 'FIELD_CHANGED' as const,
|
||||
field: 'routes',
|
||||
label: 'Routes',
|
||||
from: 'A → B',
|
||||
to: 'A → C',
|
||||
};
|
||||
|
||||
it('prefers the English label from the localized name object', async () => {
|
||||
const { service, saved } = build([
|
||||
{ id: 'u-1', name: { am: 'ሱፐር አድሚን', en: 'Super Admin' }, username: 'superadmin' },
|
||||
]);
|
||||
|
||||
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||
|
||||
expect(saved[0].actorName).toBe('Super Admin');
|
||||
});
|
||||
|
||||
it('falls back to another locale, then username, then email', async () => {
|
||||
const onlyAmharic = build([{ id: 'u-1', name: { am: 'ሱፐር' }, username: 'x' }]);
|
||||
await onlyAmharic.service.recordChanges({
|
||||
contractId: 'c-1',
|
||||
changes: [change],
|
||||
actorId: 'u-1',
|
||||
});
|
||||
expect(onlyAmharic.saved[0].actorName).toBe('ሱፐር');
|
||||
|
||||
const noName = build([{ id: 'u-1', name: null, username: 'operator', email: 'o@edr' }]);
|
||||
await noName.service.recordChanges({
|
||||
contractId: 'c-1',
|
||||
changes: [change],
|
||||
actorId: 'u-1',
|
||||
});
|
||||
expect(noName.saved[0].actorName).toBe('operator');
|
||||
|
||||
const emailOnly = build([{ id: 'u-1', name: {}, username: null, email: 'o@edr.local' }]);
|
||||
await emailOnly.service.recordChanges({
|
||||
contractId: 'c-1',
|
||||
changes: [change],
|
||||
actorId: 'u-1',
|
||||
});
|
||||
expect(emailOnly.saved[0].actorName).toBe('o@edr.local');
|
||||
});
|
||||
|
||||
it('never writes "[object Object]" as the actor name', async () => {
|
||||
const { service, saved } = build([{ id: 'u-1', name: { en: 'Real Name' } }]);
|
||||
|
||||
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||
|
||||
expect(String(saved[0].actorName)).not.toContain('object Object');
|
||||
});
|
||||
|
||||
it('records nothing when the change set is empty', async () => {
|
||||
const { service, saved } = build([]);
|
||||
|
||||
await service.recordChanges({ contractId: 'c-1', changes: [], actorId: 'u-1' });
|
||||
|
||||
expect(saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still records the revision when the user lookup fails', async () => {
|
||||
const { service, saved } = build([]);
|
||||
Object.assign(service, {
|
||||
dataSource: { query: jest.fn().mockRejectedValue(new Error('iam down')) },
|
||||
});
|
||||
|
||||
await service.recordChanges({ contractId: 'c-1', changes: [change], actorId: 'u-1' });
|
||||
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0].actorName).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves names for legacy rows that predate the actor_name column', async () => {
|
||||
const { service } = build([{ id: 'u-1', name: { en: 'Abenezer Haile' } }]);
|
||||
Object.assign(service, {
|
||||
revisionRepo: {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'r-1', actorId: 'u-1', actorName: null }]),
|
||||
},
|
||||
});
|
||||
|
||||
const [revision] = await service.list('c-1');
|
||||
|
||||
expect(revision.actorName).toBe('Abenezer Haile');
|
||||
});
|
||||
});
|
||||
@@ -274,6 +274,20 @@ export class ContractTransitionService {
|
||||
// shared six templates are never written here.
|
||||
const snapshot = await this.resolveDocumentSnapshot(contract, documentSnapshot);
|
||||
|
||||
// Audit whatever staff changed in the accept dialog. The baseline is the
|
||||
// template this contract would otherwise have frozen as-is, so an untouched
|
||||
// accept diffs to nothing and records no revision.
|
||||
if (documentSnapshot) {
|
||||
const baseline = await this.resolveDocumentSnapshot(contract);
|
||||
await this.documentHistory.record({
|
||||
contractId,
|
||||
before: baseline,
|
||||
after: snapshot,
|
||||
actorId,
|
||||
actorRole: 'Reviewing staff',
|
||||
});
|
||||
}
|
||||
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'PENDING_APPROVAL',
|
||||
approvedByStaffId: actorId,
|
||||
|
||||
@@ -303,8 +303,10 @@ export class ContractsController {
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateContractDto,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
// Recorded on the edit's audit revision — who changed the contract.
|
||||
@CurrentUser() user?: TCurrentUser,
|
||||
) {
|
||||
return this.contractsService.update(id, dto, files ?? []);
|
||||
return this.contractsService.update(id, dto, files ?? [], user?.id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@@ -738,6 +740,92 @@ export class ContractsController {
|
||||
return this.clearanceService.finalizePreClearance(id);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-assignee/request')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL ET asks GL Djibouti to name the transit officer — required before the customs declaration',
|
||||
})
|
||||
requestTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('note') note: string | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.requestTransitAssignee(
|
||||
id,
|
||||
note,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/transit-assignee/assign')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||
})
|
||||
assignTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('assignee') assignee: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.assignTransitAssignee(
|
||||
id,
|
||||
assignee,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/clearance/documents/:fileKey/versions')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Version history of one clearance document — the customer original plus every staff replacement',
|
||||
})
|
||||
documentVersions(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('fileKey') fileKey: string,
|
||||
) {
|
||||
return this.clearanceService.documentVersions(id, fileKey);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/documents/:fileKey/replace')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceReview)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving',
|
||||
})
|
||||
replaceClearanceDocument(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('fileKey') fileKey: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('reason') reason: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.replaceDocument(
|
||||
id,
|
||||
fileKey,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty/dispute')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||
})
|
||||
disputeContractDuty(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('note') note: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.disputeDuty(id, note, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@@ -766,19 +854,21 @@ export class ContractsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'GL DJ uploads Delivery Order (import)' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates',
|
||||
})
|
||||
uploadDeliveryOrder(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
|
||||
@Body('vesselArrivalDate') vesselArrivalDate: string | undefined,
|
||||
@Body('doCollectedDate') doCollectedDate: string | undefined,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.uploadDeliveryOrder(
|
||||
id,
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
vesselDepartureDate,
|
||||
);
|
||||
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), {
|
||||
vesselArrivalDate,
|
||||
doCollectedDate,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/clearance/release-order')
|
||||
|
||||
@@ -86,6 +86,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||
})
|
||||
// A ONE_TIME contract allows a single booking, so once that booking
|
||||
// exists the contract is spent and can never carry another shipment.
|
||||
// Without this it kept blocking new requests on the same service type +
|
||||
// route until its validity lapsed — locking a customer out of a lane for
|
||||
// the rest of the term after one completed shipment.
|
||||
.andWhere(
|
||||
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||
))`,
|
||||
)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
@@ -153,7 +164,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
'contract.files',
|
||||
FileRecord,
|
||||
'file',
|
||||
"file.resource_id = contract.id AND file.resource = 'contracts'",
|
||||
// Superseded versions are soft-deleted, not dropped — keep them out of
|
||||
// the live file list (a manual join condition is not filtered for us).
|
||||
"file.resource_id = contract.id AND file.resource = 'contracts' AND file.deleted_at IS NULL",
|
||||
)
|
||||
.getOne();
|
||||
|
||||
@@ -547,6 +560,17 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
);
|
||||
}
|
||||
|
||||
/** Review notes of one type, newest first — the duty advice/dispute rounds. */
|
||||
async findReviewNotes(
|
||||
contractId: string,
|
||||
noteType: ContractReviewNoteType,
|
||||
): Promise<ContractReviewNote[]> {
|
||||
return this.dataSource.getRepository(ContractReviewNote).find({
|
||||
where: { contractId, noteType },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findLatestReviewNote(
|
||||
contractId: string,
|
||||
noteType?: ContractReviewNoteType,
|
||||
@@ -714,12 +738,20 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
ContractClearanceCycle,
|
||||
| 'dutyRequired'
|
||||
| 'vesselDepartureDate'
|
||||
| 'vesselArrivalDate'
|
||||
| 'doCollectedDate'
|
||||
| 'roAmendmentRequestedAt'
|
||||
| 'roHoldReason'
|
||||
| 'currentPhase'
|
||||
| 'status'
|
||||
| 'preClearanceFinalizedAt'
|
||||
| 'completedAt'
|
||||
| 'transitAssigneeRequestedAt'
|
||||
| 'transitAssigneeRequestedByUserId'
|
||||
| 'transitAssigneeRequestNote'
|
||||
| 'transitAssigneeName'
|
||||
| 'transitAssigneeAssignedAt'
|
||||
| 'transitAssigneeAssignedByUserId'
|
||||
>
|
||||
>,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -26,6 +26,8 @@ import { Contract, CONTRACT_STATUSES, CONTRACT_CUSTOMER_EDITABLE_STATUSES } from
|
||||
import { ContractRoute } from './entities/contract-route.entity';
|
||||
import { ContractCargoScope } from './entities/contract-cargo-scope.entity';
|
||||
import { isEffectivelyExpired } from './utils/contract-expiry.util';
|
||||
import { diffContractFields } from './contract-document-diff.util';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/** Paginated contract list: flat `total` (backoffice) + `meta` block (portal). */
|
||||
@@ -42,6 +44,35 @@ export interface PaginatedContracts {
|
||||
};
|
||||
}
|
||||
|
||||
/** Route list as a readable lane string, e.g. "Nagad → Mojo, Mojo → Adama". */
|
||||
function describeRoutes(routes?: ContractRoute[]): string | null {
|
||||
if (!routes?.length) return null;
|
||||
return [...routes]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map(
|
||||
(r) =>
|
||||
`${r.originYard?.label ?? r.originYardId} → ${r.destinationYard?.label ?? r.destinationYardId}`,
|
||||
)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** Cargo scope as a readable string, e.g. "20ft ×2, 40ft ×1" or "Wheat ×500". */
|
||||
function describeCargoScope(scope?: ContractCargoScope[]): string | null {
|
||||
if (!scope?.length) return null;
|
||||
return scope
|
||||
.map((row) => {
|
||||
const label =
|
||||
row.containerSize ??
|
||||
row.cargoType?.cargoTypeName ??
|
||||
row.cargoFreeText ??
|
||||
row.cargoTypeId ??
|
||||
'cargo';
|
||||
return row.quantityCap != null ? `${label} ×${row.quantityCap}` : String(label);
|
||||
})
|
||||
.sort()
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
const NEEDS_ACTION_STATUSES = [
|
||||
'SUBMITTED',
|
||||
'PENDING_APPROVAL',
|
||||
@@ -57,6 +88,7 @@ export class ContractsService {
|
||||
private readonly companiesService: CompaniesService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly documentHistory: ContractDocumentHistoryService,
|
||||
) {}
|
||||
|
||||
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
|
||||
@@ -332,7 +364,11 @@ export class ContractsService {
|
||||
tradeDirection: dto.tradeDirection,
|
||||
freightType: dto.freightType,
|
||||
serviceTypeId: dto.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency,
|
||||
// A contract is always QUOTED in USD — the billing currency is chosen per
|
||||
// booking (or on the shipment request when GL books for the customer), so
|
||||
// any client-supplied currency here is ignored. Contracts created before
|
||||
// this rule keep whatever they stored; update() never rewrites it.
|
||||
paymentCurrency: 'USD',
|
||||
customsClearingEnabled: includesCustoms,
|
||||
customsClearingAgent: includesCustoms ? null : (dto.customsClearingAgent ?? null),
|
||||
equipmentReturn: dto.equipmentReturn ?? null,
|
||||
@@ -490,6 +526,7 @@ export class ContractsService {
|
||||
id: string,
|
||||
dto: UpdateContractDto,
|
||||
files: Express.Multer.File[],
|
||||
actorId?: string,
|
||||
): Promise<{ contract: Contract; warnings: string[] }> {
|
||||
const existing = await this.findById(id);
|
||||
if (!CONTRACT_CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
|
||||
@@ -516,7 +553,9 @@ export class ContractsService {
|
||||
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
|
||||
freightType,
|
||||
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
// Never rewritten: grandfathered contracts keep the currency (and frozen
|
||||
// snapshots) they were signed with.
|
||||
paymentCurrency: existing.paymentCurrency,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
// Same rule as create: clearing the flag clears the declaration with it.
|
||||
@@ -576,7 +615,52 @@ export class ContractsService {
|
||||
existing.companyProfileId ?? null,
|
||||
);
|
||||
|
||||
return { contract: await this.findById(id), warnings };
|
||||
const updated = await this.findById(id);
|
||||
// Audit what this edit actually changed. Runs after the writes so the
|
||||
// "after" side is read back from the contract rather than from the DTO.
|
||||
await this.recordFieldRevision(existing, updated, actorId);
|
||||
|
||||
return { contract: updated, warnings };
|
||||
}
|
||||
|
||||
/** Fields worth auditing on a customer edit, read off a loaded contract. */
|
||||
private auditableFields(contract: Contract): Record<string, unknown> {
|
||||
return {
|
||||
contractKind: contract.contractKind,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType: contract.freightType,
|
||||
serviceType: contract.serviceType?.serviceName ?? contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
contractType: contract.contractType,
|
||||
isHazardous: contract.isHazardous,
|
||||
hazardClass: contract.hazardClass,
|
||||
unNumber: contract.unNumber,
|
||||
isReefer: contract.isReefer,
|
||||
equipmentReturn: contract.equipmentReturn,
|
||||
customsClearingAgent: contract.customsClearingAgent,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress,
|
||||
routes: describeRoutes(contract.routes),
|
||||
cargoScope: describeCargoScope(contract.cargoScope),
|
||||
};
|
||||
}
|
||||
|
||||
/** Append a revision describing a customer's edit to the contract itself. */
|
||||
private async recordFieldRevision(
|
||||
before: Contract,
|
||||
after: Contract,
|
||||
actorId?: string,
|
||||
): Promise<void> {
|
||||
const changes = diffContractFields(
|
||||
this.auditableFields(before),
|
||||
this.auditableFields(after),
|
||||
);
|
||||
await this.documentHistory.recordChanges({
|
||||
contractId: after.id,
|
||||
changes,
|
||||
actorId: actorId ?? null,
|
||||
actorRole: 'Customer',
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse comma-separated or repeated status query values. */
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { assertDoCollectionDates } from './contract-clearance.util';
|
||||
|
||||
describe('assertDoCollectionDates', () => {
|
||||
it('requires both dates', () => {
|
||||
expect(() => assertDoCollectionDates(undefined)).toThrow(BadRequestException);
|
||||
expect(() =>
|
||||
assertDoCollectionDates({ vesselArrivalDate: '2026-07-01' }),
|
||||
).toThrow(/DO collected date is required/);
|
||||
expect(() =>
|
||||
assertDoCollectionDates({ doCollectedDate: '2026-07-01' }),
|
||||
).toThrow(/Vessel arrival date is required/);
|
||||
// Whitespace is not a date.
|
||||
expect(() =>
|
||||
assertDoCollectionDates({ vesselArrivalDate: ' ', doCollectedDate: ' ' }),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a DO collected before the vessel arrived', () => {
|
||||
expect(() =>
|
||||
assertDoCollectionDates({
|
||||
vesselArrivalDate: '2026-07-10',
|
||||
doCollectedDate: '2026-07-09',
|
||||
}),
|
||||
).toThrow(/cannot be earlier than the vessel arrival date/);
|
||||
});
|
||||
|
||||
it('normalizes an ISO datetime down to its date part', () => {
|
||||
expect(
|
||||
assertDoCollectionDates({
|
||||
vesselArrivalDate: '2026-07-10T21:00:00.000Z',
|
||||
doCollectedDate: '2026-07-10T05:00:00.000Z',
|
||||
}),
|
||||
).toEqual({ vesselArrivalDate: '2026-07-10', doCollectedDate: '2026-07-10' });
|
||||
});
|
||||
|
||||
it('rejects a malformed date', () => {
|
||||
expect(() =>
|
||||
assertDoCollectionDates({
|
||||
vesselArrivalDate: '10/07/2026',
|
||||
doCollectedDate: '2026-07-10',
|
||||
}),
|
||||
).toThrow(/not a valid date/);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@@ -91,6 +92,15 @@ export class CreateBookingRequestDto {
|
||||
@Type(() => RequestBulkLineDto)
|
||||
bulk?: RequestBulkLineDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['ETB', 'USD'],
|
||||
description:
|
||||
'Billing currency for the shipment GL will book. Intercity is always ETB.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['ETB', 'USD'])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { PAYMENT_CURRENCIES } from './create-contract.dto';
|
||||
|
||||
/** Per-shipment equipment return — "NA" stays contract-level only. */
|
||||
const SHIPMENT_EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN'] as const;
|
||||
|
||||
@@ -150,6 +152,20 @@ export class CreateBookingUnderContractDto {
|
||||
@IsUUID()
|
||||
contractRouteId?: string;
|
||||
|
||||
/**
|
||||
* The contract quotes in USD; the customer picks the billing currency here.
|
||||
* Omitted → the contract's own currency (USD for contracts created under the
|
||||
* current rule, the grandfathered currency for older ones). Intercity is
|
||||
* forced to ETB by the service regardless of what is sent.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
enum: PAYMENT_CURRENCIES,
|
||||
description: 'Billing currency for this shipment. Intercity is always ETB.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
|
||||
|
||||
@@ -158,9 +158,20 @@ export class CreateContractDto {
|
||||
@IsUUID()
|
||||
serviceTypeId!: string;
|
||||
|
||||
@ApiProperty({ enum: PAYMENT_CURRENCIES })
|
||||
/**
|
||||
* Deprecated at the contract level. A contract now always quotes in USD; the
|
||||
* customer picks the billing currency per booking (or on the shipment request
|
||||
* when GL books on their behalf). Accepted but ignored on create so older
|
||||
* clients don't break — the service forces USD.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
enum: PAYMENT_CURRENCIES,
|
||||
deprecated: true,
|
||||
description: 'Ignored — contracts always quote in USD. Choose currency at booking.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...PAYMENT_CURRENCIES])
|
||||
paymentCurrency!: string;
|
||||
paymentCurrency?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether EDR/GL handles customs clearance' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -43,6 +43,14 @@ export class BookingRequest extends BaseEntity {
|
||||
@Column({ name: 'requested_lines', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
requestedLines!: Freight.RequestedShipmentLines;
|
||||
|
||||
/**
|
||||
* Billing currency the customer chose for this shipment. The contract quotes
|
||||
* in USD; on a customs contract GL creates the booking, so this is where the
|
||||
* customer states which currency to be invoiced in.
|
||||
*/
|
||||
@Column({ name: 'payment_currency', type: 'varchar', length: 5, nullable: true })
|
||||
paymentCurrency?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
|
||||
|
||||
@@ -43,6 +43,41 @@ export class ContractClearanceCycle extends BaseEntity {
|
||||
@Column({ name: 'vessel_departure_date', type: 'date', nullable: true })
|
||||
vesselDepartureDate?: string | null;
|
||||
|
||||
/** Import DO: when the vessel arrived in Djibouti. Required on DO upload. */
|
||||
@Column({ name: 'vessel_arrival_date', type: 'date', nullable: true })
|
||||
vesselArrivalDate?: string | null;
|
||||
|
||||
/** Import DO: when GL Djibouti collected the DO. Required on DO upload. */
|
||||
@Column({ name: 'do_collected_date', type: 'date', nullable: true })
|
||||
doCollectedDate?: string | null;
|
||||
|
||||
/**
|
||||
* Transit-assignee handshake that runs BEFORE the customs declaration: GL
|
||||
* Ethiopia asks Djibouti for the officer who will handle the shipment in
|
||||
* transit, and Djibouti answers with a name. The declaration step stays shut
|
||||
* until `transitAssigneeName` is set; Djibouti may overwrite it later
|
||||
* (reassignment) and the newer name simply wins.
|
||||
*/
|
||||
@Column({ name: 'transit_assignee_requested_at', type: 'timestamptz', nullable: true })
|
||||
transitAssigneeRequestedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'transit_assignee_requested_by_user_id', type: 'uuid', nullable: true })
|
||||
transitAssigneeRequestedByUserId?: string | null;
|
||||
|
||||
/** What GL Ethiopia asked for — shown on the Djibouti queue. */
|
||||
@Column({ name: 'transit_assignee_request_note', type: 'text', nullable: true })
|
||||
transitAssigneeRequestNote?: string | null;
|
||||
|
||||
/** The officer Djibouti named — free text, no user directory to bind to. */
|
||||
@Column({ name: 'transit_assignee_name', type: 'text', nullable: true })
|
||||
transitAssigneeName?: string | null;
|
||||
|
||||
@Column({ name: 'transit_assignee_assigned_at', type: 'timestamptz', nullable: true })
|
||||
transitAssigneeAssignedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'transit_assignee_assigned_by_user_id', type: 'uuid', nullable: true })
|
||||
transitAssigneeAssignedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'ro_amendment_requested_at', type: 'timestamptz', nullable: true })
|
||||
roAmendmentRequestedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ export class ContractDocumentRevision extends BaseEntity {
|
||||
@Column({ name: 'actor_id', type: 'uuid', nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
/**
|
||||
* Who made the edit, captured at the time. Denormalised so the trail still
|
||||
* names them after a rename or a deactivated account.
|
||||
*/
|
||||
@Column({ name: 'actor_name', type: 'varchar', length: 200, nullable: true })
|
||||
actorName?: string | null;
|
||||
|
||||
/** The approval step's required role at the time of the edit. */
|
||||
@Column({ name: 'actor_role', type: 'varchar', length: 64, nullable: true })
|
||||
actorRole?: string | null;
|
||||
|
||||
@@ -8,6 +8,11 @@ export const CONTRACT_REVIEW_NOTE_TYPES = [
|
||||
'STAFF_NOTE',
|
||||
'CUSTOMER_NOTE',
|
||||
'AMENDMENT',
|
||||
/**
|
||||
* The customer disputed the advised duty & tax and asked GL Ethiopia to
|
||||
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||
*/
|
||||
'DUTY_DISPUTE',
|
||||
] as const;
|
||||
export type ContractReviewNoteType =
|
||||
(typeof CONTRACT_REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
import type { ContractRateSnapshot } from './entities/contract-rate-snapshot.entity';
|
||||
|
||||
const contract = (over: Partial<Contract>): Contract =>
|
||||
({ tradeDirection: 'IMPORT', paymentCurrency: 'USD', ...over }) as Contract;
|
||||
|
||||
/** The private resolver, reached without standing up the whole Nest graph. */
|
||||
const resolveCurrency = (c: Contract, requested?: string | null): string =>
|
||||
(
|
||||
ContractBookingService.prototype as unknown as {
|
||||
resolveShipmentCurrency: (c: Contract, r?: string | null) => string;
|
||||
}
|
||||
).resolveShipmentCurrency(c, requested);
|
||||
|
||||
const snapshot = (currency: string, unitPrice: number): ContractRateSnapshot =>
|
||||
({ rateCode: 'CONTAINER_20FT', currency, unitPrice }) as ContractRateSnapshot;
|
||||
|
||||
const frozenByCode = (
|
||||
snap: ContractRateSnapshot | null,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
): ContractRateSnapshot | null =>
|
||||
(
|
||||
BookingPricingService.prototype as unknown as {
|
||||
frozenRateByCode: (
|
||||
m: Map<string, ContractRateSnapshot> | null,
|
||||
code: string,
|
||||
bookingCurrency: string,
|
||||
usdToEtb: number,
|
||||
) => ContractRateSnapshot | null;
|
||||
}
|
||||
).frozenRateByCode(
|
||||
snap ? new Map([['CONTAINER_20FT', snap]]) : null,
|
||||
'CONTAINER_20FT',
|
||||
bookingCurrency,
|
||||
usdToEtb,
|
||||
);
|
||||
|
||||
describe('per-shipment billing currency', () => {
|
||||
it('takes the customer choice over the contract', () => {
|
||||
expect(resolveCurrency(contract({}), 'ETB')).toBe('ETB');
|
||||
expect(resolveCurrency(contract({}), 'USD')).toBe('USD');
|
||||
});
|
||||
|
||||
it('falls back to the contract currency when none is chosen', () => {
|
||||
// Grandfathered ETB contract with no explicit choice.
|
||||
expect(resolveCurrency(contract({ paymentCurrency: 'ETB' }))).toBe('ETB');
|
||||
expect(resolveCurrency(contract({}), ' ')).toBe('USD');
|
||||
});
|
||||
|
||||
it('forces ETB on intercity whatever was requested', () => {
|
||||
const domestic = contract({ tradeDirection: 'DOMESTIC' });
|
||||
expect(resolveCurrency(domestic, 'USD')).toBe('ETB');
|
||||
expect(resolveCurrency(domestic)).toBe('ETB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('frozen contract rate in the booking currency', () => {
|
||||
it('converts a USD snapshot for an ETB booking instead of dropping it', () => {
|
||||
// The old behaviour returned null here, which silently re-priced the
|
||||
// booking at live rates and lost the agreed contract price.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 150)?.unitPrice).toBe(60_000);
|
||||
});
|
||||
|
||||
it('converts a grandfathered ETB snapshot back for a USD booking', () => {
|
||||
expect(frozenByCode(snapshot('ETB', 60_000), 'USD', 150)?.unitPrice).toBe(400);
|
||||
});
|
||||
|
||||
it('passes a matching-currency snapshot through untouched', () => {
|
||||
const snap = snapshot('USD', 400);
|
||||
expect(frozenByCode(snap, 'USD', 1)).toBe(snap);
|
||||
});
|
||||
|
||||
it('refuses to price off an unusable exchange rate', () => {
|
||||
// Converting with 0 would zero the whole line.
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', 0)).toBeNull();
|
||||
expect(frozenByCode(snapshot('USD', 400), 'ETB', Number.NaN)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is no snapshot', () => {
|
||||
expect(frozenByCode(null, 'ETB', 150)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ContractClearanceService } from './contract-clearance.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* Pre-declaration transit-assignee handshake. GL Ethiopia asks Djibouti who will
|
||||
* handle the shipment in transit; Djibouti answers with a name. The customs
|
||||
* declaration stays shut until that name exists, and Djibouti may send a
|
||||
* different one later.
|
||||
*/
|
||||
describe('ContractClearanceService — transit assignee', () => {
|
||||
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||
({
|
||||
id: 'ctr-1',
|
||||
reference: 'CTR-2026-00042',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
...over,
|
||||
}) as Contract;
|
||||
|
||||
let repo: { currentCycle: jest.Mock; updateCycle: jest.Mock };
|
||||
let contractsService: { findById: jest.Mock };
|
||||
let notifier: {
|
||||
transitAssigneeRequested: jest.Mock;
|
||||
transitAssigneeAssigned: jest.Mock;
|
||||
};
|
||||
let service: ContractClearanceService;
|
||||
|
||||
const cycle = (over: Record<string, unknown> = {}) => ({
|
||||
id: 'cyc-1',
|
||||
transitAssigneeRequestedAt: null,
|
||||
transitAssigneeName: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
currentCycle: jest.fn().mockResolvedValue(cycle()),
|
||||
updateCycle: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
contractsService = { findById: jest.fn().mockResolvedValue(contract()) };
|
||||
notifier = {
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
};
|
||||
service = new ContractClearanceService(
|
||||
repo as never,
|
||||
contractsService as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
notifier as never,
|
||||
);
|
||||
});
|
||||
|
||||
describe('request (GL Ethiopia)', () => {
|
||||
it('stamps the ask and pings Djibouti', async () => {
|
||||
await service.requestTransitAssignee('ctr-1', ' Reefer, needs a cold-chain officer ', 'et-1');
|
||||
|
||||
const patch = repo.updateCycle.mock.calls[0][1];
|
||||
expect(patch.transitAssigneeRequestedAt).toBeInstanceOf(Date);
|
||||
expect(patch.transitAssigneeRequestedByUserId).toBe('et-1');
|
||||
expect(patch.transitAssigneeRequestNote).toBe(
|
||||
'Reefer, needs a cold-chain officer',
|
||||
);
|
||||
expect(notifier.transitAssigneeRequested).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assign (GL Djibouti)', () => {
|
||||
it('records the officer and tells Ethiopia they can proceed', async () => {
|
||||
repo.currentCycle.mockResolvedValue(
|
||||
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||
);
|
||||
|
||||
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
|
||||
|
||||
const patch = repo.updateCycle.mock.calls[0][1];
|
||||
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
|
||||
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
|
||||
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'ctr-1' }),
|
||||
'Ahmed Bourhan',
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('reassigns, carrying the previous name into the notice', async () => {
|
||||
repo.currentCycle.mockResolvedValue(
|
||||
cycle({
|
||||
transitAssigneeRequestedAt: new Date(),
|
||||
transitAssigneeName: 'Ahmed Bourhan',
|
||||
}),
|
||||
);
|
||||
|
||||
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
|
||||
|
||||
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'Fatouma Ali',
|
||||
'Ahmed Bourhan',
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an empty name', async () => {
|
||||
repo.currentCycle.mockResolvedValue(
|
||||
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||
);
|
||||
await expect(
|
||||
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses before Ethiopia has asked', async () => {
|
||||
await expect(
|
||||
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
|
||||
).rejects.toThrow(/not requested/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('declaration gate', () => {
|
||||
const ensure = (c: Contract) =>
|
||||
(
|
||||
service as unknown as {
|
||||
ensureDeclarationPrerequisites: (id: string, c: Contract) => Promise<void>;
|
||||
}
|
||||
).ensureDeclarationPrerequisites('ctr-1', c);
|
||||
|
||||
beforeEach(() => {
|
||||
// Documents are approved; only the assignee decides the outcome here.
|
||||
(
|
||||
service as unknown as { isClearanceFullyApproved: unknown }
|
||||
).isClearanceFullyApproved = jest.fn().mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it('tells GL to raise the request when none exists', async () => {
|
||||
await expect(ensure(contract())).rejects.toThrow(
|
||||
/Request a transit assignee/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('tells GL to wait when Djibouti has not answered', async () => {
|
||||
repo.currentCycle.mockResolvedValue(
|
||||
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||
);
|
||||
await expect(ensure(contract())).rejects.toThrow(/has not assigned/i);
|
||||
});
|
||||
|
||||
it('lets the declaration through once the officer is named', async () => {
|
||||
repo.currentCycle.mockResolvedValue(
|
||||
cycle({
|
||||
transitAssigneeRequestedAt: new Date(),
|
||||
transitAssigneeName: 'Ahmed Bourhan',
|
||||
}),
|
||||
);
|
||||
(
|
||||
service as unknown as { workflowService: unknown }
|
||||
).workflowService = {
|
||||
listMilestones: jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ milestoneCode: 'DOCUMENTS_APPROVED', status: 'COMPLETED' },
|
||||
]),
|
||||
};
|
||||
|
||||
await expect(ensure(contract())).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -53,4 +53,16 @@ export class FileRecord extends BaseEntity {
|
||||
|
||||
@Column({ name: "reviewed_at", type: "timestamptz", nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
|
||||
/**
|
||||
* Who replaced this version, when a newer file took its place. Superseded
|
||||
* versions are soft-deleted rather than dropped, so the original a customer
|
||||
* uploaded survives a staff correction and the two can be compared.
|
||||
*/
|
||||
@Column({ name: "replaced_by_user_id", type: "uuid", nullable: true })
|
||||
replacedByUserId!: string | null;
|
||||
|
||||
/** Why the file was replaced — shown on the document's version history. */
|
||||
@Column({ name: "replace_reason", type: "text", nullable: true })
|
||||
replaceReason!: string | null;
|
||||
}
|
||||
|
||||
@@ -41,12 +41,47 @@ export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the live version(s) of a document code. SOFT delete on purpose: the
|
||||
* bytes and the row stay so the original upload can still be read back from
|
||||
* the version history after staff replace it. Every normal read already
|
||||
* filters soft-deleted rows, so callers see only the current version.
|
||||
*
|
||||
* `replacedBy` / `reason` are stamped on the retired row when a newer file is
|
||||
* taking its place (as opposed to a plain removal).
|
||||
*/
|
||||
async deleteByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||
): Promise<void> {
|
||||
await this.repository.delete({ resourceId, resource, code });
|
||||
if (replacedBy) {
|
||||
await this.repository.update(
|
||||
{ resourceId, resource, code },
|
||||
{
|
||||
replacedByUserId: replacedBy.userId ?? null,
|
||||
replaceReason: replacedBy.reason ?? null,
|
||||
},
|
||||
);
|
||||
}
|
||||
await this.repository.softDelete({ resourceId, resource, code });
|
||||
}
|
||||
|
||||
/**
|
||||
* Every version of one document code, newest first — superseded versions
|
||||
* included. The only read that deliberately looks past the soft-delete filter.
|
||||
*/
|
||||
findVersionHistory(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord[]> {
|
||||
return this.repository.find({
|
||||
where: { resourceId, resource, code },
|
||||
withDeleted: true,
|
||||
order: { createdAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
112
apps/edr-freight-api/src/modules/files/files.service.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
/**
|
||||
* Replacing a stored document must never destroy the previous one: the customer
|
||||
* uploaded it, and a staff correction has to stay auditable against it. The old
|
||||
* row is soft-deleted (so every normal read still returns exactly the current
|
||||
* version) and stamped with who replaced it and why.
|
||||
*/
|
||||
describe('FilesService — document versions', () => {
|
||||
const file = {
|
||||
originalname: 'bill-of-lading.pdf',
|
||||
size: 1234,
|
||||
mimetype: 'application/pdf',
|
||||
buffer: Buffer.from('x'),
|
||||
} as Express.Multer.File;
|
||||
|
||||
let filesRepository: {
|
||||
deleteByCode: jest.Mock;
|
||||
create: jest.Mock;
|
||||
findVersionHistory: jest.Mock;
|
||||
};
|
||||
let service: FilesService;
|
||||
|
||||
beforeEach(() => {
|
||||
filesRepository = {
|
||||
deleteByCode: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn(async (row) => ({ id: 'file-new', ...row })),
|
||||
findVersionHistory: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
service = new FilesService(
|
||||
filesRepository as never,
|
||||
{
|
||||
uploadFile: jest.fn().mockResolvedValue('https://minio/bucket/new.pdf'),
|
||||
getObjectNameFromUrl: (u: string) => u,
|
||||
getSignedUrl: jest.fn(),
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('stamps the retired version with who replaced it and why', async () => {
|
||||
await service.upsertByCode(
|
||||
{ resourceId: 'ctr-1', resource: 'contracts', code: 'bill_of_lading', file },
|
||||
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||
);
|
||||
|
||||
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||
'ctr-1',
|
||||
'contracts',
|
||||
'bill_of_lading',
|
||||
{ userId: 'gl-user-1', reason: 'Customer sent page 2 only' },
|
||||
);
|
||||
});
|
||||
|
||||
it('still replaces silently when no replacer is given (system overwrites)', async () => {
|
||||
await service.upsertByCode({
|
||||
resourceId: 'ctr-1',
|
||||
resource: 'contracts',
|
||||
code: 'contract_pdf',
|
||||
file,
|
||||
});
|
||||
|
||||
expect(filesRepository.deleteByCode).toHaveBeenCalledWith(
|
||||
'ctr-1',
|
||||
'contracts',
|
||||
'contract_pdf',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('marks the live row current and the soft-deleted ones superseded', async () => {
|
||||
filesRepository.findVersionHistory.mockResolvedValue([
|
||||
{
|
||||
id: 'v2',
|
||||
name: 'corrected.pdf',
|
||||
url: 'u2',
|
||||
size: 2,
|
||||
mimeType: 'application/pdf',
|
||||
createdAt: new Date('2026-07-20T10:00:00Z'),
|
||||
deletedAt: null,
|
||||
replacedByUserId: null,
|
||||
replaceReason: null,
|
||||
},
|
||||
{
|
||||
id: 'v1',
|
||||
name: 'original.pdf',
|
||||
url: 'u1',
|
||||
size: 1,
|
||||
mimeType: 'application/pdf',
|
||||
createdAt: new Date('2026-07-18T10:00:00Z'),
|
||||
deletedAt: new Date('2026-07-20T10:00:00Z'),
|
||||
replacedByUserId: 'gl-user-1',
|
||||
replaceReason: 'Wrong page order',
|
||||
},
|
||||
]);
|
||||
|
||||
const versions = await service.versionHistory(
|
||||
'ctr-1',
|
||||
'contracts',
|
||||
'bill_of_lading',
|
||||
);
|
||||
|
||||
expect(versions[0]).toMatchObject({ id: 'v2', isCurrent: true, replacedAt: null });
|
||||
expect(versions[1]).toMatchObject({
|
||||
id: 'v1',
|
||||
isCurrent: false,
|
||||
replacedByUserId: 'gl-user-1',
|
||||
replaceReason: 'Wrong page order',
|
||||
});
|
||||
// The customer's original is still readable — that is the whole point.
|
||||
expect(versions[1].url).toBe('u1');
|
||||
});
|
||||
});
|
||||
@@ -104,13 +104,66 @@ export class FilesService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
|
||||
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
|
||||
/**
|
||||
* Replace the file stored under a resource + code (e.g. contract PDF). The
|
||||
* previous version is retired, not destroyed — pass `replacedBy` to record who
|
||||
* swapped it and why, which is what the version history shows.
|
||||
*/
|
||||
async upsertByCode(
|
||||
input: CreateFileInput,
|
||||
replacedBy?: { userId?: string | null; reason?: string | null },
|
||||
): Promise<FileRecord> {
|
||||
const { resourceId, resource, code } = input;
|
||||
await this.filesRepository.deleteByCode(resourceId, resource, code);
|
||||
await this.filesRepository.deleteByCode(
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
replacedBy,
|
||||
);
|
||||
return this.upload(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored version of one document, newest first. `isCurrent` marks the
|
||||
* live row; the rest are superseded uploads kept for audit.
|
||||
*/
|
||||
async versionHistory(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
mimeType: string;
|
||||
uploadedAt: string;
|
||||
isCurrent: boolean;
|
||||
replacedAt: string | null;
|
||||
replacedByUserId: string | null;
|
||||
replaceReason: string | null;
|
||||
}>
|
||||
> {
|
||||
const rows = await this.filesRepository.findVersionHistory(
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
url: row.url,
|
||||
size: row.size,
|
||||
mimeType: row.mimeType,
|
||||
uploadedAt: row.createdAt.toISOString(),
|
||||
isCurrent: row.deletedAt == null,
|
||||
replacedAt: row.deletedAt ? row.deletedAt.toISOString() : null,
|
||||
replacedByUserId: row.replacedByUserId,
|
||||
replaceReason: row.replaceReason,
|
||||
}));
|
||||
}
|
||||
|
||||
async deleteByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { orderConsistWagons } from './consist-order.util';
|
||||
|
||||
// Built train: A-B-C-D coupled in that order. Slots are created by the wagon
|
||||
// PLAN, so their sequenceNo says nothing about where the wagon actually sits.
|
||||
const TRAIN = ['A', 'B', 'C', 'D'];
|
||||
|
||||
const slot = (sequenceNo: number, physicalWagonId: string | null) => ({
|
||||
sequenceNo,
|
||||
physicalWagonId,
|
||||
});
|
||||
|
||||
describe('orderConsistWagons', () => {
|
||||
it('draws slots in the train coupling order, not slot order', () => {
|
||||
// Plan order says D then B; the train says B sits ahead of D.
|
||||
const drawn = orderConsistWagons([slot(1, 'D'), slot(2, 'B')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'D']);
|
||||
expect(drawn.map((w) => w.position)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('interleaves empty consist wagons in their real place', () => {
|
||||
// Loaded slots on A and C; B and D ride along empty. The empties used to be
|
||||
// appended after every loaded slot, so the drawing was never the train.
|
||||
const drawn = orderConsistWagons(
|
||||
[slot(1, 'A'), slot(2, 'C'), slot(98, 'B'), slot(99, 'D')],
|
||||
{ physicalWagonIdsInOrder: TRAIN },
|
||||
);
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['A', 'B', 'C', 'D']);
|
||||
});
|
||||
|
||||
it('keeps every wagon in place when a load moves between wagons', () => {
|
||||
// Load sat on A (slot 1); staff drag it onto empty D. The move repins the
|
||||
// slot, so the SAME slot now reads as wagon D and A falls back to empty.
|
||||
const before = orderConsistWagons([slot(1, 'A'), slot(98, 'D')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
const after = orderConsistWagons([slot(1, 'D'), slot(98, 'A')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
|
||||
// A is drawn first and D last, before and after — the train did not shuffle.
|
||||
expect(before.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
|
||||
expect(after.map((w) => w.physicalWagonId)).toEqual(['A', 'D']);
|
||||
});
|
||||
|
||||
it('follows a train-builder reorder without touching any slot row', () => {
|
||||
const slots = [slot(1, 'A'), slot(2, 'B')];
|
||||
|
||||
// Builder swaps the coupling order; the slots are untouched.
|
||||
const drawn = orderConsistWagons(slots, {
|
||||
physicalWagonIdsInOrder: ['B', 'A', 'C', 'D'],
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('draws back-to-front when the caller reverses the train', () => {
|
||||
const drawn = orderConsistWagons([slot(1, 'A'), slot(2, 'C')], {
|
||||
physicalWagonIdsInOrder: [...TRAIN].reverse(),
|
||||
reverseWagonOrder: true,
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => w.physicalWagonId)).toEqual(['C', 'A']);
|
||||
});
|
||||
|
||||
it('parks unpinned slots last, in slot order', () => {
|
||||
const drawn = orderConsistWagons([slot(9, null), slot(4, null), slot(1, 'C')], {
|
||||
physicalWagonIdsInOrder: TRAIN,
|
||||
});
|
||||
|
||||
expect(drawn.map((w) => [w.physicalWagonId, w.sequenceNo])).toEqual([
|
||||
['C', 1],
|
||||
[null, 4],
|
||||
[null, 9],
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to slot order when there is no built train', () => {
|
||||
// Frozen schedules and loose-wagon schedules pass no physical order.
|
||||
const drawn = orderConsistWagons([slot(2, 'X'), slot(1, 'Y')], {
|
||||
physicalWagonIdsInOrder: [],
|
||||
});
|
||||
expect(drawn.map((w) => w.sequenceNo)).toEqual([1, 2]);
|
||||
|
||||
const reversed = orderConsistWagons([slot(1, 'X'), slot(2, 'Y')], {
|
||||
physicalWagonIdsInOrder: [],
|
||||
reverseWagonOrder: true,
|
||||
});
|
||||
expect(reversed.map((w) => w.sequenceNo)).toEqual([2, 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Draw order for a schedule's consist.
|
||||
*
|
||||
* A slot's stored `sequenceNo` is its place in the wagon PLAN, not its place in
|
||||
* the train. The train's real coupling order lives on the physical wagons
|
||||
* (`wagons.sequence_number`), which the caller passes in already ordered — ASC
|
||||
* normally, DESC for a `reverseWagonOrder` schedule.
|
||||
*
|
||||
* Ordering by the physical wagon is what keeps the drawing honest:
|
||||
* - moving a load between wagons repaints WHICH wagon is loaded and never
|
||||
* shuffles the train, because each slot is drawn wherever its wagon sits;
|
||||
* - a train-builder reorder lands on the next read, allocations included,
|
||||
* since the order is derived on every read instead of copied at pin time.
|
||||
*
|
||||
* Slots with no physical wagon (not pinned yet, or a schedule that isn't tied
|
||||
* to a built train) have no place in the consist — they keep slot order, last.
|
||||
*/
|
||||
export interface ConsistOrderable {
|
||||
sequenceNo: number;
|
||||
physicalWagonId?: string | null;
|
||||
}
|
||||
|
||||
export interface ConsistOrderOptions {
|
||||
/**
|
||||
* Every wagon coupled to the built train, in real coupling order (already
|
||||
* reversed by the caller for a `reverseWagonOrder` schedule). Empty for a
|
||||
* frozen schedule or one with no built train — the consist then keeps slot
|
||||
* order.
|
||||
*/
|
||||
physicalWagonIdsInOrder: string[];
|
||||
reverseWagonOrder?: boolean;
|
||||
}
|
||||
|
||||
export const orderConsistWagons = <T extends ConsistOrderable>(
|
||||
wagons: T[],
|
||||
{ physicalWagonIdsInOrder, reverseWagonOrder }: ConsistOrderOptions,
|
||||
): (T & { position: number })[] => {
|
||||
const physicalOrder = new Map(physicalWagonIdsInOrder.map((id, index) => [id, index]));
|
||||
const bySlotSequence = (a: T, b: T) =>
|
||||
reverseWagonOrder ? b.sequenceNo - a.sequenceNo : a.sequenceNo - b.sequenceNo;
|
||||
|
||||
const ordered = physicalOrder.size
|
||||
? [...wagons].sort((a, b) => {
|
||||
const ai = a.physicalWagonId ? physicalOrder.get(a.physicalWagonId) : undefined;
|
||||
const bi = b.physicalWagonId ? physicalOrder.get(b.physicalWagonId) : undefined;
|
||||
if (ai == null && bi == null) return bySlotSequence(a, b);
|
||||
if (ai == null) return 1;
|
||||
if (bi == null) return -1;
|
||||
return ai - bi;
|
||||
})
|
||||
: [...wagons].sort(bySlotSequence);
|
||||
|
||||
return ordered.map((wagon, index) => ({ ...wagon, position: index + 1 }));
|
||||
};
|
||||
@@ -147,6 +147,7 @@ import {
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
} from './booking-batch.constants';
|
||||
import { orderConsistWagons } from './consist-order.util';
|
||||
import {
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
@@ -6696,6 +6697,18 @@ export class TrainSchedulingService {
|
||||
consistOnly: true,
|
||||
}));
|
||||
|
||||
// The consist is DRAWN in the built train's real coupling order (rawConsistWagons
|
||||
// is already ASC/DESC per reverseWagonOrder), not in slot order — see
|
||||
// consist-order.util. `position` is the drawn place, 1..n; `sequenceNo` stays
|
||||
// the slot's own stored value.
|
||||
const drawConsist = <T extends { sequenceNo: number; physicalWagonId: string | null }>(
|
||||
list: T[],
|
||||
) =>
|
||||
orderConsistWagons(list, {
|
||||
physicalWagonIdsInOrder: rawConsistWagons.map((wagon) => wagon.id),
|
||||
reverseWagonOrder: schedule.reverseWagonOrder,
|
||||
});
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -6785,7 +6798,8 @@ export class TrainSchedulingService {
|
||||
maxPullWeightTons: roundTons(Number(loco.maxPullWeightTons)),
|
||||
maxTrainLengthMeters: roundTons(Number(loco.maxTrainLengthMeters)),
|
||||
})),
|
||||
wagons: (schedule.trainSet.wagons ?? [])
|
||||
wagons: drawConsist(
|
||||
(schedule.trainSet.wagons ?? [])
|
||||
.map((wagon) => {
|
||||
// Frozen schedules read the wagon number + allocations from the
|
||||
// snapshot slot; the immutable slot geometry (capacity/type) still
|
||||
@@ -6875,12 +6889,8 @@ export class TrainSchedulingService {
|
||||
})) ?? [],
|
||||
};
|
||||
})
|
||||
.concat(emptyConsistWagons)
|
||||
.sort((a, b) =>
|
||||
schedule.reverseWagonOrder
|
||||
? b.sequenceNo - a.sequenceNo
|
||||
: a.sequenceNo - b.sequenceNo,
|
||||
),
|
||||
.concat(emptyConsistWagons),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
@@ -7458,8 +7468,12 @@ export class TrainSchedulingService {
|
||||
];
|
||||
const cargoOf = (allocs: WagonBookingAllocation[]) =>
|
||||
allocs.reduce((sum, a) => sum + Number(a.allocatedWeightTons || 0), 0);
|
||||
const wagonLabel = (slot: { sequenceNo: number } | null, wagon: Wagon | null) =>
|
||||
slot ? `#${slot.sequenceNo}` : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
// Name wagons by their physical number — the consist is drawn in the train's
|
||||
// coupling order, so a slot's sequenceNo is not the position staff can see.
|
||||
const slotLabel = (slot: TrainSetWagon) =>
|
||||
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
||||
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
||||
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
const checkReceives = (
|
||||
allocs: WagonBookingAllocation[],
|
||||
label: string,
|
||||
@@ -7501,7 +7515,7 @@ export class TrainSchedulingService {
|
||||
if (targetAllocs.length) {
|
||||
checkReceives(
|
||||
targetAllocs,
|
||||
`#${source.sequenceNo}`,
|
||||
slotLabel(source),
|
||||
source.wagonType,
|
||||
Number(source.capacityTons),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* OCC ends a transfer request with fewer wagons than asked for. The note is
|
||||
* carried into the requester's notification — it is what tells them WHY the
|
||||
* yard could not give the rest.
|
||||
*/
|
||||
export class CloseShortTransferRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Why the source yard cannot supply the remainder',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
|
||||
const SORT_FIELDS = ['createdAt', 'quantity', 'status'] as const;
|
||||
|
||||
/**
|
||||
* Transfer-desk list query. `status` accepts a comma-separated list so the
|
||||
* "Open" tab can ask for PENDING + PARTIALLY_FULFILLED in one call.
|
||||
*/
|
||||
export class ListTransferRequestsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'One status or a comma-separated list',
|
||||
enum: WagonTransferRequestStatus,
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' && value.trim() ? value.trim() : undefined,
|
||||
)
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Source yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
fromYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Destination yard' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
toYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Wagon type' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
wagonTypeId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SORT_FIELDS, default: 'createdAt' })
|
||||
@IsOptional()
|
||||
@IsIn([...SORT_FIELDS])
|
||||
sortBy?: (typeof SORT_FIELDS)[number];
|
||||
}
|
||||
@@ -40,6 +40,14 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'quantity', type: 'int' })
|
||||
quantity!: number;
|
||||
|
||||
/**
|
||||
* How many have actually moved so far. OCC sends what the yard can spare,
|
||||
* whenever it can — the request stays open until this reaches `quantity` or
|
||||
* OCC closes it short.
|
||||
*/
|
||||
@Column({ name: 'fulfilled_quantity', type: 'int', default: 0 })
|
||||
fulfilledQuantity!: number;
|
||||
|
||||
@Column({
|
||||
name: 'status',
|
||||
type: 'varchar',
|
||||
@@ -54,9 +62,17 @@ export class WagonTransferRequest extends BaseEntity {
|
||||
@Column({ name: 'fulfilled_by_user_id', type: 'uuid', nullable: true })
|
||||
fulfilledByUserId?: string | null;
|
||||
|
||||
/** When the LAST transfer against this request ran (not necessarily the full count). */
|
||||
@Column({ name: 'fulfilled_at', type: 'timestamptz', nullable: true })
|
||||
fulfilledAt?: Date | null;
|
||||
|
||||
/** Set when OCC ended the request with fewer wagons than asked for. */
|
||||
@Column({ name: 'closed_short_at', type: 'timestamptz', nullable: true })
|
||||
closedShortAt?: Date | null;
|
||||
|
||||
@Column({ name: 'closed_short_by_user_id', type: 'uuid', nullable: true })
|
||||
closedShortByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'note', type: 'text', nullable: true })
|
||||
note?: string | null;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@@ -13,26 +12,36 @@ import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
|
||||
import {
|
||||
FleetManage,
|
||||
FleetView,
|
||||
WagonTransferCancel,
|
||||
WagonTransferCloseShort,
|
||||
WagonTransferFulfill,
|
||||
WagonTransferHistoryAll,
|
||||
WagonTransferRequest,
|
||||
WagonTransferView,
|
||||
} from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BulkFulfillTransferRequestsDto } from './dto/bulk-fulfill-transfer-requests.dto';
|
||||
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
|
||||
/** Query-string number, or undefined when absent/garbage (service defaults it). */
|
||||
const toInt = (value?: string): number | undefined => {
|
||||
const n = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Two-person wagon-transfer queue. Requester (transfer_request perm) files a
|
||||
* count-only request; OCC (transfer_fulfill perm) picks the wagons and executes
|
||||
* the move. Separate top-level path so it never collides with `wagons/:id`.
|
||||
* The wagon-transfer desk. A requester (transfer_request) files a count-only
|
||||
* request; OCC (transfer_fulfill) moves wagons against it in as many
|
||||
* instalments as the source yard allows, and closes it short
|
||||
* (transfer_close_short) when the yard has no more to give. Separate top-level
|
||||
* path so it never collides with `wagons/:id`.
|
||||
*/
|
||||
@ApiTags('wagon-transfer-requests')
|
||||
@Controller('wagon-transfer-requests')
|
||||
@FleetView(FREIGHT_PERMS.wagons.view)
|
||||
@WagonTransferView()
|
||||
export class WagonTransferRequestsController {
|
||||
constructor(private readonly service: WagonTransferRequestsService) {}
|
||||
|
||||
@@ -47,10 +56,12 @@ export class WagonTransferRequestsController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiQuery({ name: 'status', required: false, enum: WagonTransferRequestStatus })
|
||||
@ApiOperation({ summary: 'List transfer requests (OCC queue: status=PENDING)' })
|
||||
list(@Query('status') status?: WagonTransferRequestStatus) {
|
||||
return this.service.listRequests(status);
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Transfer desk list — paginated, filterable by status (comma-separated), yards and wagon type',
|
||||
})
|
||||
list(@Query() query: ListTransferRequestsQueryDto) {
|
||||
return this.service.listRequests(query);
|
||||
}
|
||||
|
||||
// NOTE: static routes (`history`, `bulk-fulfill`) MUST stay above `@Get(':id')`
|
||||
@@ -73,24 +84,48 @@ export class WagonTransferRequestsController {
|
||||
// matches in declaration order, so `/history` would otherwise be captured by
|
||||
// the `:id` param route (and rejected by ParseUUIDPipe).
|
||||
@Get('history')
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Caller's own transfer history (requests filed/fulfilled + wagons moved)",
|
||||
})
|
||||
myHistory(@CurrentUser() user: TCurrentUser) {
|
||||
myHistory(
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
// Never fall through to the all-staff view: getHistory(undefined) means
|
||||
// "everyone", so a missing caller id must return empty, not leak scope.
|
||||
if (!user?.id) return { requests: [], movements: [] };
|
||||
return this.service.getHistory(user.id);
|
||||
if (!user?.id) {
|
||||
return {
|
||||
requests: [],
|
||||
movements: [],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
requestsTotal: 0,
|
||||
movementsTotal: 0,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.service.getHistory(user.id, toInt(page), toInt(pageSize));
|
||||
}
|
||||
|
||||
@Get('history/all')
|
||||
@WagonTransferHistoryAll()
|
||||
@ApiQuery({ name: 'userId', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
@ApiOperation({
|
||||
summary: "Admin: any/all staff's transfer history (optional ?userId filter)",
|
||||
})
|
||||
allHistory(@Query('userId') userId?: string) {
|
||||
return this.service.getHistory(userId);
|
||||
allHistory(
|
||||
@Query('userId') userId?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.service.getHistory(userId, toInt(page), toInt(pageSize));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -110,9 +145,26 @@ export class WagonTransferRequestsController {
|
||||
return this.service.fulfillRequest(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/close-short')
|
||||
@WagonTransferCloseShort()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall',
|
||||
})
|
||||
closeShort(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: CloseShortTransferRequestDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.closeShort(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@FleetManage(FREIGHT_PERMS.wagons.transferRequest)
|
||||
@ApiOperation({ summary: 'Withdraw a pending transfer request' })
|
||||
@WagonTransferCancel()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)',
|
||||
})
|
||||
cancel(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.service.cancelRequest(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { WagonTransferRequestStatus } from '@edr/types';
|
||||
import { ConflictException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { WagonTransferRequestsService } from './wagon-transfer-requests.service';
|
||||
import type { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
|
||||
/**
|
||||
* Instalment fulfilment: a request for 50 wagons is met with whatever the source
|
||||
* yard can spare, whenever it can spare it. It stays open until the full count
|
||||
* lands or OCC closes it short — which is what tells the requester to go ask
|
||||
* another yard.
|
||||
*/
|
||||
describe('WagonTransferRequestsService — partial fulfilment', () => {
|
||||
const request = (over: Partial<WagonTransferRequest> = {}): WagonTransferRequest =>
|
||||
({
|
||||
id: 'req-1',
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
fulfilledQuantity: 0,
|
||||
status: WagonTransferRequestStatus.Pending,
|
||||
requestedByUserId: 'user-1',
|
||||
...over,
|
||||
}) as WagonTransferRequest;
|
||||
|
||||
let requestRepo: {
|
||||
findOne: jest.Mock;
|
||||
find: jest.Mock;
|
||||
save: jest.Mock;
|
||||
create: jest.Mock;
|
||||
createQueryBuilder: jest.Mock;
|
||||
};
|
||||
let wagonRepo: { find: jest.Mock; count: jest.Mock };
|
||||
let wagonsService: { bulkTransfer: jest.Mock };
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: WagonTransferRequestsService;
|
||||
let stored: WagonTransferRequest;
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const build = (row: WagonTransferRequest) => {
|
||||
stored = row;
|
||||
requestRepo.findOne.mockImplementation(async () => stored);
|
||||
requestRepo.save.mockImplementation(async (r: WagonTransferRequest) => {
|
||||
stored = r;
|
||||
return r;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
requestRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
save: jest.fn(),
|
||||
create: jest.fn((r) => r),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
wagonRepo = { find: jest.fn().mockResolvedValue([]), count: jest.fn() };
|
||||
wagonsService = { bulkTransfer: jest.fn().mockResolvedValue(undefined) };
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new WagonTransferRequestsService(
|
||||
requestRepo as never,
|
||||
wagonRepo as never,
|
||||
{ find: jest.fn(), findAndCount: jest.fn() } as never,
|
||||
wagonsService as never,
|
||||
inbox as never,
|
||||
);
|
||||
build(request());
|
||||
});
|
||||
|
||||
const availableWagons = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `w-${i}`,
|
||||
wagonNumber: `100${i}`,
|
||||
currentYardId: 'yard-a',
|
||||
wagonTypeId: 'type-1',
|
||||
status: 'AVAILABLE',
|
||||
}));
|
||||
|
||||
describe('fulfillRequest', () => {
|
||||
it('books an instalment and keeps the request open', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(20);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||
expect(wagonsService.bulkTransfer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('completes the request when the last instalment lands', async () => {
|
||||
build(request({ fulfilledQuantity: 30, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(50);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.Fulfilled);
|
||||
});
|
||||
|
||||
it('refuses to move more than is still owed', async () => {
|
||||
build(request({ fulfilledQuantity: 45, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(10));
|
||||
|
||||
await expect(
|
||||
service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(10).map((w) => w.id),
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to touch a request that is already closed', async () => {
|
||||
build(request({ status: WagonTransferRequestStatus.ClosedShort, fulfilledQuantity: 20 }));
|
||||
|
||||
await expect(
|
||||
service.fulfillRequest('req-1', { wagonIds: ['w-0'] }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('tells the requester what landed and what is still owed', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
await service.fulfillRequest('req-1', {
|
||||
wagonIds: availableWagons(20).map((w) => w.id),
|
||||
});
|
||||
await flush();
|
||||
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ userIds: ['user-1'] });
|
||||
expect(sent.body).toContain('20 wagon(s) have arrived');
|
||||
expect(sent.body).toContain('30 of 50 still to come');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkFulfill', () => {
|
||||
it('sends what the yard has instead of skipping a short request', async () => {
|
||||
wagonRepo.find.mockResolvedValue(availableWagons(20));
|
||||
|
||||
const result = await service.bulkFulfill(['req-1']);
|
||||
|
||||
expect(stored.fulfilledQuantity).toBe(20);
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.PartiallyFulfilled);
|
||||
expect(result.skipped).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('skips only when the yard has nothing to give', async () => {
|
||||
wagonRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.bulkFulfill(['req-1']);
|
||||
|
||||
expect(wagonsService.bulkTransfer).not.toHaveBeenCalled();
|
||||
expect(result.skipped[0].reason).toContain('No available wagons');
|
||||
});
|
||||
});
|
||||
|
||||
describe('closeShort', () => {
|
||||
it('ends the request and tells the requester to ask another yard', async () => {
|
||||
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await service.closeShort('req-1', { note: 'Yard is empty until Friday' });
|
||||
await flush();
|
||||
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.ClosedShort);
|
||||
expect(stored.closedShortAt).toBeInstanceOf(Date);
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.body).toContain('Only 20 of the 50');
|
||||
expect(sent.body).toContain('Yard is empty until Friday');
|
||||
expect(sent.body).toContain('Request the remaining 30');
|
||||
});
|
||||
|
||||
it('refuses when the request is already fully supplied', async () => {
|
||||
build(request({ fulfilledQuantity: 50, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await expect(service.closeShort('req-1', {})).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelRequest', () => {
|
||||
it('withdraws a request that never moved a wagon', async () => {
|
||||
await service.cancelRequest('req-1');
|
||||
expect(stored.status).toBe(WagonTransferRequestStatus.Cancelled);
|
||||
});
|
||||
|
||||
it('refuses once wagons have moved — close it short instead', async () => {
|
||||
build(request({ fulfilledQuantity: 20, status: WagonTransferRequestStatus.PartiallyFulfilled }));
|
||||
|
||||
await expect(service.cancelRequest('req-1')).rejects.toThrow(
|
||||
/close it short/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRequest', () => {
|
||||
it('accepts a count larger than what the yard holds today', async () => {
|
||||
wagonRepo.count.mockResolvedValue(20);
|
||||
|
||||
await service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-b',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 50,
|
||||
reason: 'Grain campaign',
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
|
||||
expect(requestRepo.save).toHaveBeenCalled();
|
||||
expect(stored.quantity).toBe(50);
|
||||
});
|
||||
|
||||
it('still refuses a same-yard move', async () => {
|
||||
await expect(
|
||||
service.createRequest(
|
||||
{
|
||||
fromYardId: 'yard-a',
|
||||
toYardId: 'yard-a',
|
||||
wagonTypeId: 'type-1',
|
||||
quantity: 5,
|
||||
reason: 'x',
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,27 @@
|
||||
import { WagonStatus, WagonTransferRequestStatus } from '@edr/types';
|
||||
import {
|
||||
NotificationAudience,
|
||||
NotificationType,
|
||||
OPEN_WAGON_TRANSFER_STATUSES,
|
||||
PaginatedResponse,
|
||||
WagonStatus,
|
||||
WagonTransferRequestStatus,
|
||||
} from '@edr/types';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { paginateQuery } from '../../common/utils/pagination.util';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { CloseShortTransferRequestDto } from './dto/close-short-transfer-request.dto';
|
||||
import { CreateTransferRequestDto } from './dto/create-transfer-request.dto';
|
||||
import { FulfillTransferRequestDto } from './dto/fulfill-transfer-request.dto';
|
||||
import { ListTransferRequestsQueryDto } from './dto/list-transfer-requests-query.dto';
|
||||
import { Wagon } from './entities/wagon.entity';
|
||||
import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
@@ -19,10 +31,21 @@ import { WagonsService } from './wagons.service';
|
||||
export interface TransferHistory {
|
||||
requests: WagonTransferRequest[];
|
||||
movements: WagonMovement[];
|
||||
/**
|
||||
* One pager drives both lists (they are shown side by side), so it carries a
|
||||
* total per list and the page count of the longer one.
|
||||
*/
|
||||
meta: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
requestsTotal: number;
|
||||
movementsTotal: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** How many ledger rows the history returns at most (newest first). */
|
||||
const HISTORY_LIMIT = 500;
|
||||
/** Hard ceiling on a single history page, whatever the client asks for. */
|
||||
const HISTORY_LIMIT = 100;
|
||||
|
||||
const REQUEST_RELATIONS = {
|
||||
fromYard: true,
|
||||
@@ -38,6 +61,8 @@ const REQUEST_RELATIONS = {
|
||||
*/
|
||||
@Injectable()
|
||||
export class WagonTransferRequestsService {
|
||||
private readonly logger = new Logger(WagonTransferRequestsService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(WagonTransferRequest)
|
||||
private readonly requestRepo: Repository<WagonTransferRequest>,
|
||||
@@ -46,13 +71,14 @@ export class WagonTransferRequestsService {
|
||||
@InjectRepository(WagonMovement)
|
||||
private readonly movementRepo: Repository<WagonMovement>,
|
||||
private readonly wagonsService: WagonsService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, but the
|
||||
* count is capped at the AVAILABLE wagons of that type currently sitting in
|
||||
* the source yard: staff may only ask for wagons that are actually there to
|
||||
* give. A reason is mandatory and is shown on the OCC queue.
|
||||
* Record a PENDING request. Count-only — no wagons are picked here, and the
|
||||
* count is NOT capped by what the source yard holds today: OCC fulfils in
|
||||
* instalments, so asking for 50 while only 20 sit there is a normal, useful
|
||||
* request. A reason is mandatory and is shown on the OCC queue.
|
||||
*/
|
||||
async createRequest(
|
||||
dto: CreateTransferRequestDto,
|
||||
@@ -63,14 +89,6 @@ export class WagonTransferRequestsService {
|
||||
'Source and destination yard must be different',
|
||||
);
|
||||
}
|
||||
const available = await this.countAvailable(dto.fromYardId, dto.wagonTypeId);
|
||||
if (available < dto.quantity) {
|
||||
throw new BadRequestException(
|
||||
available === 0
|
||||
? 'No available wagons of this type in the source yard'
|
||||
: `Only ${available} available wagon(s) of this type in the source yard — request at most ${available}`,
|
||||
);
|
||||
}
|
||||
const request = this.requestRepo.create({
|
||||
fromYardId: dto.fromYardId,
|
||||
toYardId: dto.toYardId,
|
||||
@@ -85,8 +103,12 @@ export class WagonTransferRequestsService {
|
||||
return this.findById(saved.id);
|
||||
}
|
||||
|
||||
/** AVAILABLE wagons of `wagonTypeId` currently in `yardId`. */
|
||||
private countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||
/**
|
||||
* AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move
|
||||
* right now. Shown on the desk beside the outstanding count so staff see at a
|
||||
* glance how much of a request the yard can cover today.
|
||||
*/
|
||||
countAvailable(yardId: string, wagonTypeId: string): Promise<number> {
|
||||
return this.wagonRepo.count({
|
||||
where: {
|
||||
currentYardId: yardId,
|
||||
@@ -96,15 +118,51 @@ export class WagonTransferRequestsService {
|
||||
});
|
||||
}
|
||||
|
||||
/** Requests, newest first, optionally filtered by status (OCC queue = PENDING). */
|
||||
/**
|
||||
* The transfer desk list: paginated, newest first, filterable by status (one
|
||||
* or a comma-separated set — the "Open" tab asks for PENDING +
|
||||
* PARTIALLY_FULFILLED), yards and wagon type. Search matches the reason text.
|
||||
*/
|
||||
async listRequests(
|
||||
status?: WagonTransferRequestStatus,
|
||||
): Promise<WagonTransferRequest[]> {
|
||||
return this.requestRepo.find({
|
||||
where: status ? { status } : {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
query: ListTransferRequestsQueryDto,
|
||||
): Promise<PaginatedResponse<WagonTransferRequest>> {
|
||||
const qb = this.requestRepo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.fromYard', 'fromYard')
|
||||
.leftJoinAndSelect('r.toYard', 'toYard')
|
||||
.leftJoinAndSelect('r.wagonType', 'wagonType');
|
||||
|
||||
const statuses = (query.status ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (statuses.length) {
|
||||
qb.andWhere('r.status IN (:...statuses)', { statuses });
|
||||
}
|
||||
if (query.fromYardId) {
|
||||
qb.andWhere('r.from_yard_id = :fromYardId', { fromYardId: query.fromYardId });
|
||||
}
|
||||
if (query.toYardId) {
|
||||
qb.andWhere('r.to_yard_id = :toYardId', { toYardId: query.toYardId });
|
||||
}
|
||||
if (query.wagonTypeId) {
|
||||
qb.andWhere('r.wagon_type_id = :wagonTypeId', {
|
||||
wagonTypeId: query.wagonTypeId,
|
||||
});
|
||||
}
|
||||
if (query.search) {
|
||||
qb.andWhere('r.reason ILIKE :search', { search: `%${query.search}%` });
|
||||
}
|
||||
|
||||
const sortColumn =
|
||||
query.sortBy === 'quantity'
|
||||
? 'r.quantity'
|
||||
: query.sortBy === 'status'
|
||||
? 'r.status'
|
||||
: 'r.created_at';
|
||||
qb.orderBy(sortColumn, query.sortOrder ?? 'DESC');
|
||||
|
||||
return paginateQuery(qb, { page: query.page, pageSize: query.pageSize });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WagonTransferRequest> {
|
||||
@@ -116,11 +174,23 @@ export class WagonTransferRequestsService {
|
||||
return request;
|
||||
}
|
||||
|
||||
/** Wagons still owed on an open request. */
|
||||
private remainingOn(request: WagonTransferRequest): number {
|
||||
return Math.max(0, request.quantity - (request.fulfilledQuantity ?? 0));
|
||||
}
|
||||
|
||||
/** True while OCC can still move wagons against this request. */
|
||||
private isOpen(request: WagonTransferRequest): boolean {
|
||||
return OPEN_WAGON_TRANSFER_STATUSES.includes(request.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC fulfils a PENDING request with hand-picked wagons. Every wagon must sit
|
||||
* in the request's source yard, match its wagon type, and the count must equal
|
||||
* the requested quantity — then the transfer runs and the request is marked
|
||||
* FULFILLED.
|
||||
* OCC moves hand-picked wagons against an open request. Any number from 1 up
|
||||
* to whatever is still owed — the yard rarely has the whole ask at once, so a
|
||||
* request for 50 can be met 20 now, 30 later. Every wagon must sit in the
|
||||
* source yard, match the type and be available. The request completes on its
|
||||
* own once the full count has moved; short of that it stays open as
|
||||
* PARTIALLY_FULFILLED and the requester is told what landed.
|
||||
*/
|
||||
async fulfillRequest(
|
||||
id: string,
|
||||
@@ -128,16 +198,17 @@ export class WagonTransferRequestsService {
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
if (!this.isOpen(request)) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase()}`,
|
||||
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const wagonIds = [...new Set(dto.wagonIds)];
|
||||
if (wagonIds.length !== request.quantity) {
|
||||
const remaining = this.remainingOn(request);
|
||||
if (wagonIds.length > remaining) {
|
||||
throw new BadRequestException(
|
||||
`Select exactly ${request.quantity} wagon(s); you selected ${wagonIds.length}`,
|
||||
`Only ${remaining} wagon(s) still owed on this request; you selected ${wagonIds.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,21 +249,124 @@ export class WagonTransferRequestsService {
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
await this.recordDelivery(request, wagonIds.length, userId);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC accepts AND executes a subset of pending requests in one action. For
|
||||
* each selected request the system auto-picks the required number of
|
||||
* AVAILABLE wagons of the requested type from the source yard (lowest wagon
|
||||
* number first) and runs the audited transfer. A request that cannot be
|
||||
* executed — already decided, or not enough available wagons left after the
|
||||
* ones processed before it — is SKIPPED and simply stays PENDING, visible to
|
||||
* both teams; nothing is rolled back for the others.
|
||||
* Book an instalment against a request: bump the delivered count, complete it
|
||||
* when the full ask has landed, and tell the requester what moved. Shared by
|
||||
* the hand-picked and auto-picked (bulk) fulfilment paths.
|
||||
*/
|
||||
private async recordDelivery(
|
||||
request: WagonTransferRequest,
|
||||
moved: number,
|
||||
userId?: string | null,
|
||||
): Promise<void> {
|
||||
request.fulfilledQuantity = (request.fulfilledQuantity ?? 0) + moved;
|
||||
request.status =
|
||||
request.fulfilledQuantity >= request.quantity
|
||||
? WagonTransferRequestStatus.Fulfilled
|
||||
: WagonTransferRequestStatus.PartiallyFulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
this.notifyRequester(request, moved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the requester what landed. Fire-and-forget: a notification failure must
|
||||
* never undo a transfer that already moved wagons.
|
||||
*/
|
||||
private notifyRequester(
|
||||
request: WagonTransferRequest,
|
||||
moved: number,
|
||||
closedShortNote?: string | null,
|
||||
): void {
|
||||
if (!request.requestedByUserId) return;
|
||||
const outstanding = this.remainingOn(request);
|
||||
const complete = request.status === WagonTransferRequestStatus.Fulfilled;
|
||||
const closedShort =
|
||||
request.status === WagonTransferRequestStatus.ClosedShort;
|
||||
|
||||
const title = complete
|
||||
? `All ${request.quantity} wagon(s) transferred`
|
||||
: closedShort
|
||||
? `Transfer closed short — ${request.fulfilledQuantity} of ${request.quantity} wagon(s)`
|
||||
: `${moved} of ${request.quantity} wagon(s) transferred`;
|
||||
|
||||
const body = complete
|
||||
? `Your wagon transfer request is complete — all ${request.quantity} wagon(s) have arrived.`
|
||||
: closedShort
|
||||
? `Only ${request.fulfilledQuantity} of the ${request.quantity} wagon(s) you asked for could be supplied` +
|
||||
`${closedShortNote ? `: ${closedShortNote}` : '.'} ` +
|
||||
`Request the remaining ${outstanding} from another yard.`
|
||||
: `${moved} wagon(s) have arrived against your request. ` +
|
||||
`${outstanding} of ${request.quantity} still to come.`;
|
||||
|
||||
void this.inbox
|
||||
.notify({
|
||||
recipients: { userIds: [request.requestedByUserId] },
|
||||
audience: NotificationAudience.BACKOFFICE,
|
||||
type: NotificationType.GENERIC,
|
||||
title,
|
||||
body,
|
||||
link: `/dashboard/wagon-transfers/${request.id}`,
|
||||
data: {
|
||||
transferRequestId: request.id,
|
||||
delivered: request.fulfilledQuantity,
|
||||
requested: request.quantity,
|
||||
outstanding,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(
|
||||
`Transfer notification failed for ${request.id}: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC ends a request with fewer wagons than asked for — the source yard has
|
||||
* nothing more to give. What already moved stays moved; the requester is told
|
||||
* the shortfall so they can raise it against another yard. Cancelling is for
|
||||
* requests that never moved anything; this is the close for ones that did.
|
||||
*/
|
||||
async closeShort(
|
||||
id: string,
|
||||
dto: CloseShortTransferRequestDto,
|
||||
userId?: string | null,
|
||||
): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (!this.isOpen(request)) {
|
||||
throw new ConflictException(
|
||||
`Request is already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
);
|
||||
}
|
||||
if (this.remainingOn(request) === 0) {
|
||||
throw new ConflictException(
|
||||
'Nothing outstanding — this request is already fully supplied',
|
||||
);
|
||||
}
|
||||
|
||||
request.status = WagonTransferRequestStatus.ClosedShort;
|
||||
request.closedShortAt = new Date();
|
||||
request.closedShortByUserId = userId ?? null;
|
||||
if (dto.note?.trim()) {
|
||||
request.note = dto.note.trim();
|
||||
}
|
||||
await this.requestRepo.save(request);
|
||||
this.notifyRequester(request, 0, dto.note ?? null);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* OCC executes a set of open requests in one action, auto-picking AVAILABLE
|
||||
* wagons of the requested type from each source yard (lowest wagon number
|
||||
* first). A yard that cannot cover the whole ask still sends what it has —
|
||||
* the request stays open for the rest rather than being skipped, which is the
|
||||
* whole point of instalments. Only a request with NOTHING available is
|
||||
* skipped, and nothing is rolled back for the others.
|
||||
*/
|
||||
async bulkFulfill(
|
||||
requestIds: string[],
|
||||
@@ -212,13 +386,14 @@ export class WagonTransferRequestsService {
|
||||
skipped.push({ id, reason: 'Request not found' });
|
||||
continue;
|
||||
}
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
if (!this.isOpen(request)) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Already ${request.status.toLowerCase()}`,
|
||||
reason: `Already ${request.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const remaining = this.remainingOn(request);
|
||||
const wagons = await this.wagonRepo.find({
|
||||
where: {
|
||||
currentYardId: request.fromYardId,
|
||||
@@ -226,12 +401,12 @@ export class WagonTransferRequestsService {
|
||||
status: WagonStatus.Available,
|
||||
},
|
||||
order: { wagonNumber: 'ASC' },
|
||||
take: request.quantity,
|
||||
take: remaining,
|
||||
});
|
||||
if (wagons.length < request.quantity) {
|
||||
if (wagons.length === 0) {
|
||||
skipped.push({
|
||||
id,
|
||||
reason: `Only ${wagons.length} of ${request.quantity} wagon(s) available in the source yard — left pending`,
|
||||
reason: 'No available wagons of this type in the source yard — left open',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -240,10 +415,7 @@ export class WagonTransferRequestsService {
|
||||
userId,
|
||||
{ transferRequestId: request.id },
|
||||
);
|
||||
request.status = WagonTransferRequestStatus.Fulfilled;
|
||||
request.fulfilledByUserId = userId ?? null;
|
||||
request.fulfilledAt = new Date();
|
||||
await this.requestRepo.save(request);
|
||||
await this.recordDelivery(request, wagons.length, userId);
|
||||
fulfilled.push(await this.findById(id));
|
||||
}
|
||||
|
||||
@@ -258,17 +430,25 @@ export class WagonTransferRequestsService {
|
||||
* (the controller passes the caller's id unless they hold the history-all
|
||||
* permission) — this method trusts its argument.
|
||||
*/
|
||||
async getHistory(userId?: string | null): Promise<TransferHistory> {
|
||||
const requests = await this.requestRepo.find({
|
||||
async getHistory(
|
||||
userId?: string | null,
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
): Promise<TransferHistory> {
|
||||
const take = Math.min(pageSize ?? 20, HISTORY_LIMIT);
|
||||
const skip = ((page ?? 1) - 1) * take;
|
||||
|
||||
const [requests, requestsTotal] = await this.requestRepo.findAndCount({
|
||||
where: userId
|
||||
? [{ requestedByUserId: userId }, { fulfilledByUserId: userId }]
|
||||
: {},
|
||||
relations: REQUEST_RELATIONS,
|
||||
order: { createdAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
const movements = await this.movementRepo.find({
|
||||
const [movements, movementsTotal] = await this.movementRepo.findAndCount({
|
||||
// Own view: moves I made. All view: every user-attributed move (skip the
|
||||
// system-written loaded/reposition legs that carry no mover).
|
||||
where: userId
|
||||
@@ -276,18 +456,39 @@ export class WagonTransferRequestsService {
|
||||
: { movedByUserId: Not(IsNull()) },
|
||||
relations: { wagon: true, fromYard: true, toYard: true, transferRequest: true },
|
||||
order: { occurredAt: 'DESC' },
|
||||
take: HISTORY_LIMIT,
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
|
||||
return { requests, movements };
|
||||
return {
|
||||
requests,
|
||||
movements,
|
||||
meta: {
|
||||
page: page ?? 1,
|
||||
pageSize: take,
|
||||
requestsTotal,
|
||||
movementsTotal,
|
||||
// Whichever list is longer decides how far the pager can go.
|
||||
totalPages: Math.max(
|
||||
1,
|
||||
Math.ceil(Math.max(requestsTotal, movementsTotal) / take),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Withdraw a still-PENDING request. */
|
||||
/**
|
||||
* Withdraw a request before anything moved. Once wagons have been delivered
|
||||
* the request can only be completed or closed short — cancelling would erase
|
||||
* the fact that a transfer happened.
|
||||
*/
|
||||
async cancelRequest(id: string): Promise<WagonTransferRequest> {
|
||||
const request = await this.findById(id);
|
||||
if (request.status !== WagonTransferRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`Only pending requests can be cancelled (this one is ${request.status.toLowerCase()})`,
|
||||
request.status === WagonTransferRequestStatus.PartiallyFulfilled
|
||||
? 'Wagons have already moved against this request — close it short instead of cancelling'
|
||||
: `Only pending requests can be cancelled (this one is ${request.status.toLowerCase().replace(/_/g, ' ')})`,
|
||||
);
|
||||
}
|
||||
request.status = WagonTransferRequestStatus.Cancelled;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WagonMovement } from './entities/wagon-movement.entity';
|
||||
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
import { WagonsController } from './wagons.controller';
|
||||
import { WagonTransferRequestsController } from './wagon-transfer-requests.controller';
|
||||
import { WagonsService } from './wagons.service';
|
||||
@@ -19,6 +20,8 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
|
||||
Train,
|
||||
Yard,
|
||||
]),
|
||||
// The transfer desk notifies the requester as instalments land.
|
||||
NotificationInboxModule,
|
||||
],
|
||||
controllers: [
|
||||
WagonsController,
|
||||
|
||||
Reference in New Issue
Block a user