Merge branch 'dev' into freight/nati-2

# Conflicts:
#	apps/edr-freight-api/src/app.module.ts
#	apps/edr-freight-api/src/seed/freight-permissions.registry.ts
#	apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx
#	apps/edr-freight-web/backoffice/src/constants/URLS.ts
#	apps/edr-freight-web/backoffice/src/lib/permissions.ts
This commit is contained in:
Nathnael
2026-08-20 11:29:21 +00:00
287 changed files with 25453 additions and 2339 deletions

View File

@@ -32,11 +32,17 @@ function makeService(overrides?: {
workflowThrows?: boolean;
/** Resolve the input doc set with no required fields → every doc counts approved. */
docsApproved?: boolean;
/** Yard ids the caller is scoped to; `null` (default) = unrestricted. */
yardScope?: string[] | null;
}) {
const booking = overrides?.booking ?? generalImportBooking;
const bookingsRepository = {
findDocumentReviews: jest.fn().mockResolvedValue([]),
update: jest.fn().mockResolvedValue(booking),
findByStatuses: jest.fn().mockResolvedValue([]),
findBookingsWithUnreviewedDocuments: jest
.fn()
.mockResolvedValue(new Set<string>()),
};
const bookingsService = {
findById: jest.fn().mockResolvedValue(booking),
@@ -111,6 +117,8 @@ function makeService(overrides?: {
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
} as never, // transit agents
{ findAll: jest.fn().mockResolvedValue([]) } as never, // contracts repository
{ getScopedYardIds: jest.fn().mockResolvedValue(overrides?.yardScope ?? null) } as never, // yard scope
{ record: jest.fn() } as never, // clearanceEvents
);
return {
@@ -124,6 +132,30 @@ function makeService(overrides?: {
}
describe('BookingClearanceService', () => {
describe('etQueue yard scope', () => {
const queueBookings = [
{ ...generalImportBooking, id: 'b-mojo-out', originYardId: 'mojo', destinationYardId: 'dire' },
{ ...generalImportBooking, id: 'b-mojo-in', originYardId: 'addis', destinationYardId: 'mojo' },
{ ...generalImportBooking, id: 'b-elsewhere', originYardId: 'addis', destinationYardId: 'dire' },
] as unknown as Booking[];
it('keeps only bookings whose origin or destination is in scope', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: ['mojo'] });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows.map((b) => b.id)).toEqual(['b-mojo-out', 'b-mojo-in']);
});
it('shows everything when the position has no yard mapping', async () => {
const { service, bookingsRepository, workflowService } = makeService({ yardScope: null });
bookingsRepository.findByStatuses.mockResolvedValue(queueBookings);
workflowService.listMilestonesForBooking.mockResolvedValue([{ status: 'PENDING' }]);
const rows = await service.etQueue({});
expect(rows).toHaveLength(3);
});
});
describe('adviseDuty', () => {
it('skips duty milestones when duty is not required', async () => {
const { service, workflowService, bookingsRepository } = makeService();

View File

@@ -30,10 +30,18 @@ import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { GlExchangeService } from './gl-exchange.service';
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { ContractsRepository } from './contracts.repository';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
import {
buildClearanceDocHistory,
type ClearanceDocEvent,
} from '../bookings/clearance-doc-history.util';
import { ClearanceEventService } from '../bookings/clearance-event.service';
import { clearanceDocumentsOpen } from '../bookings/clearance.util';
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
export interface BookingClearanceView {
@@ -51,8 +59,13 @@ export interface BookingClearanceView {
file: { id: string; name: string; url: string } | null;
reviewStatus: 'PENDING' | 'APPROVED' | 'QUERIED' | null;
note: string | null;
uploadedAt: string | null;
reviewedAt: string | null;
reviewedByName: string | null;
history: ClearanceDocEvent[];
}>;
allApproved: boolean;
documentsOpen: boolean;
phase?: string | null;
milestones?: Array<{
id: string;
@@ -158,6 +171,8 @@ export class BookingClearanceService {
private readonly glExchangeService: GlExchangeService,
private readonly transitAgentsService: TransitAgentsService,
private readonly contractsRepository: ContractsRepository,
private readonly yardScope: YardScopeService,
private readonly clearanceEvents: ClearanceEventService,
) {}
private async assertPhasedCustoms(booking: Booking): Promise<void> {
@@ -183,6 +198,18 @@ export class BookingClearanceService {
const fileByCode = new Map(files.map((f) => [f.code, f]));
const reviews = await this.bookingsRepository.findDocumentReviews(bookingId);
const reviewByKey = new Map(reviews.map((r) => [`${r.settingCode}:${r.fileKey}`, r]));
const allVersions = await this.filesService.findAllVersionsByResource(
bookingId,
'bookings',
);
const queryNotes = await this.bookingsRepository.findReviewNotes(
bookingId,
'CHANGES_REQUESTED',
);
const reviewerNames = await this.bookingsRepository.resolveStaffNames([
...reviews.map((r) => r.reviewedByStaffId),
...queryNotes.map((n) => n.authorId),
]);
const documents: BookingClearanceView['documents'] = [];
@@ -206,6 +233,18 @@ export class BookingClearanceService {
file: file ? { id: file.id, name: file.name, url: file.url } : null,
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: file?.createdAt ? file.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: field.fileKey,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
};
@@ -225,6 +264,18 @@ export class BookingClearanceService {
file: { id: f.id, name: f.name, url: f.url },
reviewStatus: review?.status ?? null,
note: review?.note ?? null,
uploadedAt: f.createdAt ? f.createdAt.toISOString() : null,
reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null,
reviewedByName: review?.reviewedByStaffId
? (reviewerNames.get(review.reviewedByStaffId) ?? null)
: null,
history: buildClearanceDocHistory({
fileKey: f.code,
allVersions,
queryNotes,
review,
names: reviewerNames,
}),
});
}
@@ -305,6 +356,7 @@ export class BookingClearanceService {
outputCode,
documents,
allApproved,
documentsOpen: clearanceDocumentsOpen(booking),
phase,
milestones: milestones.map((m) => ({
id: m.id,
@@ -477,6 +529,7 @@ export class BookingClearanceService {
async requestTransitAssignee(
bookingId: string,
note: string | undefined,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
@@ -484,6 +537,13 @@ export class BookingClearanceService {
transitAssigneeRequestedAt: new Date(),
transitAssigneeRequestNote: note?.trim() || null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_REQUESTED',
label: 'Requested a transit assignee from GL Djibouti',
actorId: userId ?? null,
metadata: { note: note?.trim() || null },
});
this.notifier.transitAssigneeRequested(booking, note?.trim() ?? null);
return this.bookingsService.findById(bookingId);
@@ -495,7 +555,11 @@ export class BookingClearanceService {
* Answering unblocks the declaration for Ethiopia. A later call overwrites
* the name (reassignment) and re-notifies.
*/
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
async assignTransitAssignee(
bookingId: string,
transitAgentId: string,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (!booking.transitAssigneeRequestedAt) {
throw new BadRequestException(
@@ -509,6 +573,13 @@ export class BookingClearanceService {
transitAssigneeName: agent.name,
transitAssigneeAssignedAt: new Date(),
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_ASSIGNEE_ASSIGNED',
label: `Assigned transit officer "${agent.name}"`,
actorId: userId ?? null,
metadata: { transitAgentId, agentName: agent.name, previous },
});
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
return this.bookingsService.findById(bookingId);
@@ -561,6 +632,13 @@ export class BookingClearanceService {
? ContractDocPhase.GlEtPostClearance
: ContractDocPhase.CustomerDuty,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DECLARATION_UPLOADED',
label: `Uploaded customs declaration (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
@@ -614,6 +692,19 @@ export class BookingClearanceService {
);
this.notifier.dutyAdvised(booking, dto.amount, dto.currency ?? 'ETB');
}
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_ADVISED',
label: dto.dutyRequired
? `Advised duty/tax of ${dto.amount} ${dto.currency ?? 'ETB'}`
: 'Advised that no duty/tax applies',
actorId: userId ?? null,
metadata: {
dutyRequired: dto.dutyRequired,
amount: dto.amount ?? null,
currency: dto.currency ?? null,
},
});
return this.bookingsService.findById(bookingId);
}
@@ -661,6 +752,14 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_SENT',
label: `Sent draft customs declaration (estimated ${price} ${currency})`,
actorId: userId ?? null,
metadata: { price, currency, fileNames: files.map((f) => f.originalname) },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationReady(updated, price, currency);
return updated;
@@ -670,7 +769,7 @@ export class BookingClearanceService {
* The customer accepts the draft declaration — GL Ethiopia may now file the
* real customs declaration.
*/
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
async acceptDraftDeclaration(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Draft declaration applies only to import bookings.');
@@ -682,6 +781,13 @@ export class BookingClearanceService {
}
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_ACCEPTED',
label: 'Customer accepted the draft customs declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
@@ -731,12 +837,25 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DRAFT_DECLARATION_CHANGE_REQUESTED',
label: 'Customer requested a change to the draft declaration',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { note: note.trim() },
});
const updated = await this.bookingsService.findById(bookingId);
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
return updated;
}
async uploadDutySlip(bookingId: string, file: Express.Multer.File): Promise<Booking> {
async uploadDutySlip(
bookingId: string,
file: Express.Multer.File,
userId?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Duty slip upload applies only to import bookings.');
@@ -758,6 +877,15 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DUTY_SLIP_UPLOADED',
label: 'Customer uploaded the duty/tax payment slip',
actorType: 'CUSTOMER',
actorId: userId ?? null,
metadata: { fileName: file.originalname },
});
this.notifier.dutySlipUploadedToStaff(booking, 'first');
return this.bookingsService.findById(bookingId);
}
@@ -790,11 +918,18 @@ export class BookingClearanceService {
await this.bookingsRepository.update(bookingId, {
clearanceCurrentPhase: ContractDocPhase.GlEtPostClearance,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'TRANSIT_PERMIT_UPLOADED',
label: `Uploaded transit permit (${files.length} file(s))`,
actorId: userId ?? null,
metadata: { fileNames: files.map((f) => f.originalname) },
});
return this.bookingsService.findById(bookingId);
}
async finalizePreClearance(bookingId: string): Promise<Booking> {
async finalizePreClearance(bookingId: string, userId?: string): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Pre-clearance finalize applies only to import bookings.');
@@ -814,6 +949,12 @@ export class BookingClearanceService {
preClearanceFinalizedAt: new Date(),
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'PRE_CLEARANCE_FINALIZED',
label: 'Finalized pre-clearance — handed over to GL Djibouti collection',
actorId: userId ?? null,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
@@ -847,6 +988,17 @@ export class BookingClearanceService {
vesselArrivalDate,
doCollectedDate,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'DELIVERY_ORDER_UPLOADED',
label: 'Uploaded Delivery Order',
actorId: userId ?? null,
metadata: {
vesselArrivalDate: vesselArrivalDate ?? null,
doCollectedDate: doCollectedDate ?? null,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
@@ -904,6 +1056,16 @@ export class BookingClearanceService {
vesselDepartureDate,
roAmendmentRequestedAt: null,
} as never);
await this.clearanceEvents.record({
bookingId,
action: 'RELEASE_ORDER_UPLOADED',
label: `Uploaded Release Order (vessel departs ${vesselDepartureDate})`,
actorId: userId ?? null,
metadata: {
vesselDepartureDate,
fileNames: (files ?? []).map((f) => f.originalname),
},
});
if (leadDays < minDays) {
const reason = `Vessel departs in ${leadDays} day(s) — minimum lead time is ${minDays} day(s). Request a port amendment or upload a new RO with a later date.`;
@@ -963,6 +1125,13 @@ export class BookingClearanceService {
userId,
);
}
await this.clearanceEvents.record({
bookingId,
action: 'RO_AMENDMENT_REQUESTED',
label: 'Requested a port amendment on the Release Order',
actorId: userId ?? null,
metadata: { note: reason },
});
return this.bookingsService.findById(bookingId);
}
@@ -978,10 +1147,16 @@ export class BookingClearanceService {
'EXPORT_RELEASED',
);
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
await this.clearanceEvents.record({
bookingId,
action: 'EXPORT_RELEASE_CONFIRMED',
label: 'Confirmed export release',
actorId: userId ?? null,
});
return this.bookingsService.findById(bookingId);
}
async etQueue(): Promise<Booking[]> {
async etQueue(user?: unknown): Promise<Booking[]> {
const candidates = await this.bookingsRepository.findByStatuses([
...PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES,
]);
@@ -989,9 +1164,50 @@ export class BookingClearanceService {
for (const b of candidates) {
if (!this.isPhasedCustomsBooking(b)) continue;
const milestones = await this.workflowService.listMilestonesForBooking(b.id);
if (belongsOnEtClearanceQueue(milestones)) filtered.push(b);
if (!belongsOnEtClearanceQueue(milestones)) continue;
// Surfaced on the queue row: every required document is approved even
// though the booking status stays DOCUMENTS_UNDER_REVIEW until finalize.
(b as Booking & { allDocsApproved?: boolean }).allDocsApproved =
milestones.some(
(m) =>
m.milestoneCode === 'DOCUMENTS_APPROVED' &&
(m.status === 'COMPLETED' || m.status === 'SKIPPED'),
);
filtered.push(b);
}
return this.attachContractSummary(filtered);
// A document added after clearance was finalized lands as PENDING without
// moving the booking's status — the row would otherwise still read
// "Clearance ready" while GL has something waiting. Ad-hoc documents are
// tracked by no milestone, so this reads the review rows directly.
const pending = await this.bookingsRepository.findBookingsWithUnreviewedDocuments(
filtered.map((b) => b.id),
);
for (const b of filtered) {
(b as Booking & { hasDocumentsAwaitingReview?: boolean })
.hasDocumentsAwaitingReview = pending.has(b.id);
}
const rows = await this.attachContractSummary(filtered);
return this.narrowToYardScope(rows, user);
}
/**
* Keep only bookings whose ORIGIN or DESTINATION yard is one of the caller's
* assigned yards (`freight.yard_positions` via the active position). Yards in
* the middle of a route do not count. An unmapped position, super admin or
* `yards:view_all` holder sees everything (scope resolves to `null`).
* Runs after {@link attachContractSummary} so route-fallback yards count too.
*/
private async narrowToYardScope(bookings: Booking[], user: unknown): Promise<Booking[]> {
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return bookings;
const inScope = (id: string | null | undefined) => !!id && scope.includes(id);
return bookings.filter(
(b) =>
inScope(b.originYardId ?? b.originYard?.id) ||
inScope(b.destinationYardId ?? b.destinationYard?.id),
);
}
/**

View File

@@ -30,7 +30,8 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
async findQueue(): Promise<BookingRequest[]> {
return this.repository.find({
order: { createdAt: 'DESC' },
relations: { contract: { company: true } },
// `routes` rides along so the queue can be narrowed to the caller's yards.
relations: { contract: { company: true, routes: true } },
});
}

View File

@@ -7,6 +7,7 @@ import {
} from '@nestjs/common';
import type { Freight } from '@edr/types';
import { YardScopeService } from '../rule-engine/services/yard-scope.service';
import { BookingRequestRepository } from './booking-request.repository';
import { ContractsService } from './contracts.service';
import { ContractBookingService } from './contract-booking.service';
@@ -28,6 +29,7 @@ export class BookingRequestService {
private readonly contractsService: ContractsService,
private readonly contractBookingService: ContractBookingService,
private readonly notifier: ContractNotifierService,
private readonly yardScope: YardScopeService,
) {}
/**
@@ -168,8 +170,25 @@ export class BookingRequestService {
return request;
}
queue(): Promise<BookingRequest[]> {
return this.repo.findQueue();
/**
* GL queue narrowed to the caller's yards: a request stays when its route's
* ORIGIN or DESTINATION yard is one the caller's active position is mapped to
* (unmapped position / super admin → everything). A request with no
* resolvable route (no `contractRouteId` on a multi-route contract) has no
* yards to judge by and is kept visible.
*/
async queue(user?: unknown): Promise<BookingRequest[]> {
const rows = await this.repo.findQueue();
const scope = await this.yardScope.getScopedYardIds(user as never);
if (scope === null) return rows;
return rows.filter((r) => {
const routes = r.contract?.routes ?? [];
const route =
routes.find((x) => x.id === r.contractRouteId) ??
(routes.length === 1 ? routes[0] : undefined);
if (!route) return true;
return scope.includes(route.originYardId) || scope.includes(route.destinationYardId);
});
}
private async findPending(requestId: string): Promise<BookingRequest> {

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
}

View File

@@ -0,0 +1,216 @@
import { ContractBookingService } from './contract-booking.service';
import { Booking } from '../bookings/entities/booking.entity';
/**
* Manual (GL-driven) odd-20ft consolidation. On a customs contract GL completes
* the booking, so GL also picks who shares its wagon: two bookings each carrying
* an odd 20ft count are completed together onto one wagon.
*
* The two invariants that matter are that the pair is all-or-nothing (a failure
* on either half must leave NEITHER booking completed and no link written) and
* that the two bookings stay financially separate — one completion each, so one
* price and one invoice each.
*/
describe('ContractBookingService — manual odd-20ft consolidation', () => {
function makeService(overrides: {
bookingsRepository?: Partial<Record<string, jest.Mock>>;
dataSource?: unknown;
}) {
const bookingsRepository = {
findByIdWithFiles: jest.fn(),
findManualConsolidationCandidates: jest.fn().mockResolvedValue([]),
linkConsolidationPartners: jest.fn().mockResolvedValue(undefined),
...overrides.bookingsRepository,
};
// A transaction that simply runs the callback — enough to assert the
// all-or-nothing contract: whatever throws inside propagates out, and the
// caller observes no link written.
const dataSource = overrides.dataSource ?? {
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) => cb({})),
};
const service = new ContractBookingService(
{ findByIdWithRelations: jest.fn() } as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
{} as never, // invoiceService
{} as never, // bookingNotifier
dataSource as never,
{} as never, // trainSchedulingService
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
// The pairing is parked for approval rather than going straight to
// Operations; the gate itself is covered by its own spec.
{ requestApproval: jest.fn().mockResolvedValue({ id: 'ap-1' }) } as never,
);
return { service, bookingsRepository, dataSource };
}
const partnerBooking = {
id: 'b-2',
reference: 'BK-2',
contractId: 'c-2',
consolidationPartnerId: null,
} as unknown as Booking;
const pairDto = {
partnerBookingId: 'b-2',
booking: { scheduledDate: '2026-09-01' },
partner: { scheduledDate: '2026-09-01' },
};
it('completes both halves and links them', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
// partner lookup before the transaction
.mockResolvedValueOnce(partnerBooking)
// the two reloads after it
.mockResolvedValueOnce({ id: 'b-1', reference: 'BK-1' } as Booking)
.mockResolvedValueOnce({ id: 'b-2', reference: 'BK-2' } as Booking),
},
});
// Each half runs the ordinary completion machine — one call per booking, so
// each is priced and invoiced on its own.
const complete = jest
.spyOn(service, 'completeUnderContract')
.mockImplementation(
async (_contractId, bookingId) =>
({
booking: { id: bookingId } as Booking,
warnings: [],
}) as never,
);
const result = await service.completeConsolidatedPair(
'c-1',
'b-1',
pairDto as never,
);
expect(complete).toHaveBeenCalledTimes(2);
// The partner is completed against ITS OWN contract, not this one.
expect(complete.mock.calls[0][0]).toBe('c-1');
expect(complete.mock.calls[1][0]).toBe('c-2');
// Neither half may re-enter the automatic matcher — GL links them here.
expect(complete.mock.calls[0][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(complete.mock.calls[1][2]).toMatchObject({
skipAutoConsolidation: true,
});
expect(bookingsRepository.linkConsolidationPartners).toHaveBeenCalledWith(
'b-1',
'b-2',
);
expect(result.booking.id).toBe('b-1');
expect(result.partner.id).toBe('b-2');
});
it('links nothing when the partner half fails (all-or-nothing)', async () => {
const { service, bookingsRepository } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue(partnerBooking),
},
});
jest
.spyOn(service, 'completeUnderContract')
.mockImplementationOnce(
async () => ({ booking: { id: 'b-1' } as Booking, warnings: [] }) as never,
)
.mockImplementationOnce(async () => {
throw new Error('no train space for the partner');
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow('no train space for the partner');
// The link is the last write in the transaction — it must never happen when
// a half failed, so the rollback leaves no dangling pairing.
expect(bookingsRepository.linkConsolidationPartners).not.toHaveBeenCalled();
});
it('refuses a partner that already shares a wagon', async () => {
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest.fn().mockResolvedValue({
...partnerBooking,
consolidationPartnerId: 'b-9',
}),
},
});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', pairDto as never),
).rejects.toThrow(/already shares a wagon/i);
});
it('refuses to consolidate a booking with itself', async () => {
const { service } = makeService({});
await expect(
service.completeConsolidatedPair('c-1', 'b-1', {
...pairDto,
partnerBookingId: 'b-1',
} as never),
).rejects.toThrow(/cannot be consolidated with itself/i);
});
it('offers only bookings whose own 20ft count is odd', async () => {
// Two odd counts always sum to even, so an odd partner is exactly what fills
// the wagon; an even one would leave the pair partial again.
const rows = [
{
id: 'odd',
reference: 'BK-ODD',
bookingContainers: [
{ quantity: 3, containerType: { sizeFt: 20 } },
],
},
{
id: 'even',
reference: 'BK-EVEN',
bookingContainers: [
{ quantity: 4, containerType: { sizeFt: 20 } },
],
},
// A bare instance has no cargo yet — GL enters it on the split form, so it
// stays a candidate.
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
];
const { service } = makeService({
bookingsRepository: {
findByIdWithFiles: jest
.fn()
.mockResolvedValue({ id: 'b-1', contractId: 'c-1' } as Booking),
findManualConsolidationCandidates: jest.fn(async (booking: Booking) =>
// Mirror the repository's in-memory odd filter.
rows.filter((row) => {
void booking;
const lines = row.bookingContainers ?? [];
if (lines.length === 0) return true;
const ft20 = lines
.filter((l) => Number(l.containerType?.sizeFt) === 20)
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
return ft20 % 2 === 1;
}),
),
},
});
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
expect(candidates[0].ft20Quantity).toBe(3);
expect(candidates[1].hasCargo).toBe(false);
});
});

View File

@@ -59,6 +59,7 @@ describe('ContractBookingService — changes-requested resubmit restating cargo'
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
{} as never, // consolidationApprovalService
);
return { service, bookingsRepository, invoiceService };
}

View File

@@ -20,6 +20,7 @@ import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingTransitionService } from '../bookings/booking-transition.service';
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
import { ConsolidationService } from '../bookings/consolidation.service';
import { ConsolidationApprovalService } from '../bookings/consolidation-approval.service';
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
@@ -44,6 +45,7 @@ import {
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { isEffectivelyExpired } from './utils/contract-expiry.util';
import {
CompleteConsolidatedPairDto,
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
@@ -62,6 +64,25 @@ export interface CreateBookingUnderContractResult {
warnings: string[];
}
/**
* A booking GL may pick as the shared-wagon partner of an odd-20ft customs
* booking. `hasCargo` is false for a bare instance whose containers GL still has
* to enter on the split completion form.
*/
export interface ConsolidationCandidate {
id: string;
reference: string;
contractId: string | null;
companyName: string | null;
status: string;
tradeDirection: string | null;
originYardId: string | null;
destinationYardId: string | null;
scheduledDate: string | null;
ft20Quantity: number;
hasCargo: boolean;
}
/**
* Outstanding split remainder of a contract: what was booked in the first split
* booking's pre-split snapshot MINUS everything currently booked. Container
@@ -110,6 +131,8 @@ export class ContractBookingService {
private readonly bookingBatchService: BookingBatchService,
@Inject(forwardRef(() => BookingTransitionService))
private readonly bookingTransitionService: BookingTransitionService,
@Inject(forwardRef(() => ConsolidationApprovalService))
private readonly consolidationApprovalService: ConsolidationApprovalService,
) {}
async createUnderContract(
@@ -598,6 +621,146 @@ export class ContractBookingService {
return created;
}
/**
* Candidate partners a GL operator may link to an odd-20ft customs booking.
* Manual counterpart to the automatic pairing in {@link consolidateDrawdown} —
* a customs instance is completed by GL, so GL also chooses who shares its
* wagon rather than waiting for the auto-matcher to find an exact complement.
*/
async listConsolidationCandidates(
contractId: string,
bookingId: string,
): Promise<ConsolidationCandidate[]> {
const booking = await this.bookingsRepository.findByIdWithFiles(bookingId);
if (!booking || booking.contractId !== contractId) {
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
}
const rows = await this.bookingsRepository.findManualConsolidationCandidates(
booking,
);
return rows.map((row) => {
const lines = row.bookingContainers ?? [];
return {
id: row.id,
reference: row.reference,
contractId: row.contractId ?? null,
companyName: row.company?.name ?? null,
status: row.status,
tradeDirection: row.tradeDirection ?? null,
originYardId: row.originYardId ?? null,
destinationYardId: row.destinationYardId ?? null,
scheduledDate: row.scheduledDate ? row.scheduledDate.toISOString() : null,
ft20Quantity: lines
.filter((line) => Number(line.containerType?.sizeFt) === 20)
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
hasCargo: lines.length > 0,
};
});
}
/**
* Complete an odd-20ft customs booking together with the partner booking GL
* picked for its shared wagon. Both halves run the ordinary
* {@link completeUnderContract} machine — same gates, same pricing, same
* per-booking invoice, so each customer still pays only its own shipment — and
* are linked as consolidation partners at the end.
*
* All-or-nothing: the two completions plus the pairing run inside one
* transaction, so a failure on either half leaves neither booking completed
* and no half-linked wagon behind. `runInTransaction` is used rather than a
* manual QueryRunner so the nested services join the same transactional
* context through the shared DataSource.
*/
async completeConsolidatedPair(
contractId: string,
bookingId: string,
dto: CompleteConsolidatedPairDto,
actorPermissions?: unknown,
/** IAM id of the GL user creating the pairing — recorded on the approval. */
actorUserId?: string | null,
): Promise<{
booking: Booking;
partner: Booking;
warnings: string[];
}> {
if (dto.partnerBookingId === bookingId) {
throw new BadRequestException(
'A booking cannot be consolidated with itself.',
);
}
const partner = await this.bookingsRepository.findByIdWithFiles(
dto.partnerBookingId,
);
if (!partner) {
throw new NotFoundException(
`Partner booking ${dto.partnerBookingId} not found`,
);
}
if (partner.consolidationPartnerId) {
throw new ConflictException(
`Booking ${partner.reference} already shares a wagon with another booking.`,
);
}
if (!partner.contractId) {
throw new BadRequestException(
`Booking ${partner.reference} is not a contract booking and cannot be completed here.`,
);
}
const warnings: string[] = [];
const { ownId, partnerId } = await this.dataSource.transaction(async () => {
const own = await this.completeUnderContract(
contractId,
bookingId,
{ ...dto.booking, skipAutoConsolidation: true },
// Both halves are completed by the same GL actor that reached this
// endpoint — the customs gate in completeUnderContract re-checks it.
actorPermissions,
);
warnings.push(...own.warnings);
const other = await this.completeUnderContract(
partner.contractId as string,
partner.id,
{ ...dto.partner, skipAutoConsolidation: true },
actorPermissions,
);
warnings.push(...other.warnings);
// Link the two halves. Written directly (not via pairConsolidation) because
// both bookings have just been completed into their live status here —
// pairConsolidation exists to RESUME bookings parked in
// PENDING_CONSOLIDATION and would overwrite that status.
await this.bookingsRepository.linkConsolidationPartners(
own.booking.id,
other.booking.id,
);
return { ownId: own.booking.id, partnerId: other.booking.id };
});
// Both halves have just been completed into the operations queue by the
// ordinary completion machine. A shared wagon does not go there unreviewed:
// pull the pair back into the approval gate, which releases them to
// Operations only once a person signs off on the pairing.
await this.consolidationApprovalService.requestApproval(
ownId,
partnerId,
actorUserId ?? null,
);
// Sequential reads: one connection per transaction context.
const finalBooking = await this.bookingsRepository.findByIdWithFiles(ownId);
const finalPartner = await this.bookingsRepository.findByIdWithFiles(partnerId);
return {
booking: finalBooking!,
partner: finalPartner ?? partner,
warnings,
};
}
/**
* Complete a bare initiated booking after its per-booking clearance is
* finalized (CLEARANCE_READY) or operations returned it for changes
@@ -826,10 +989,18 @@ export class ContractBookingService {
// exactly like a drawdown created with cargo does. The shipment day is
// stored first so the pairing event can resume straight into the
// operations queue.
// Customs (Path B) instances are exempt from the AUTO-matcher: GL links
// their shared wagon by hand through completeConsolidatedPair, so nothing
// may claim a partner for them behind GL's back. A customs half completed
// as part of a manual pair carries `skipAutoConsolidation`; one completed
// alone still falls through to the automatic gate below, so an odd 20ft
// booking can never proceed on a partial wagon. Non-customs drawdowns are
// unaffected.
const withContainers = await this.bookingsRepository.findByIdWithFiles(booking.id);
if (
withContainers &&
freightType === 'CONTAINER' &&
!dto.skipAutoConsolidation &&
(await this.consolidationService.needsConsolidationFromBooking(withContainers))
) {
await this.bookingsRepository.update(booking.id, {
@@ -2287,6 +2458,22 @@ export class ContractBookingService {
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
// Parity gate. 20ft containers ride two per wagon, so an odd total leaves
// one container that cannot be placed. Consolidation (pairing it with
// another customer's odd booking) is built end to end but switched off for
// now, so an odd total is rejected outright — server-side, because the
// frontend block alone is not a guarantee.
const ft20Quantity = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
if (ft20Quantity % 2 === 1) {
throw new BadRequestException(
`20ft containers travel two per wagon, so they must be booked in even ` +
`numbers. This booking has ${ft20Quantity} — add one more or remove ` +
`one (book ${ft20Quantity + 1} or ${ft20Quantity - 1}).`,
);
}
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>

View File

@@ -358,32 +358,39 @@ export class ContractClearanceService {
// Once GL creates the shipment booking, surface its reference + status so the
// customer sees the concrete booking instead of a stale "will be created
// shortly" message. Reuse the export booking load; fetch for import too.
let linkedBookingId: string | null = null;
let linkedBookingReference: string | null = null;
let linkedBookingStatus: string | null = null;
let linkedBookingReviewNote: string | null = null;
let linkedBookingScheduledDate: string | null = null;
if (cycle?.bookingId) {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
linkedBookingScheduledDate = booking.scheduledDate
? new Date(booking.scheduledDate).toISOString()
: null;
// Newest changes-requested note (reviewNotes ride along on findById).
linkedBookingReviewNote =
[...(booking.reviewNotes ?? [])]
.filter((n) => n.type === 'CHANGES_REQUESTED')
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
// The cycle is the historical link, but it is not written on every path (an
// FCFS export booking and a GL drawdown both reach the operations queue
// without a cycle row), so fall back to the contract's own live booking —
// otherwise the clearance page sees no linked booking at all and cannot show
// its status or the actions that depend on it.
const booking = cycle?.bookingId
? await this.bookingsService.findById(cycle.bookingId)
: await this.contractsRepository.findLatestBookingForContract(contractId);
if (booking) {
linkedBookingId = booking.id ?? null;
linkedBookingReference = booking.reference ?? null;
linkedBookingStatus = booking.status ?? null;
linkedBookingScheduledDate = booking.scheduledDate
? new Date(booking.scheduledDate).toISOString()
: null;
// Newest changes-requested note (reviewNotes ride along on findById).
linkedBookingReviewNote =
[...(booking.reviewNotes ?? [])]
.filter((n) => n.type === 'CHANGES_REQUESTED')
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)[0]?.note ?? null;
if (contract.tradeDirection === 'EXPORT') {
nextAction = this.workflowService.computeNextActionForBooking(
booking,
bookingMilestones,
);
}
}
@@ -420,7 +427,7 @@ export class ContractClearanceService {
bookingReady: boundary,
preClearanceFinalized: Boolean(cycle?.preClearanceFinalizedAt),
exportClearanceFinalized: Boolean(cycle?.completedAt),
linkedBookingId: cycle?.bookingId ?? null,
linkedBookingId,
linkedBookingReference,
linkedBookingStatus,
linkedBookingReviewNote,

View File

@@ -3,17 +3,20 @@ import { BadRequestException } from '@nestjs/common';
import { ContractTransitionService } from './contract-transition.service';
/**
* Where the booking-contract view reads the global stamp live, the contracts
* path SNAPSHOTS it onto the signature row at signing time, so replacing the
* company stamp can never restamp an already-executed contract. These specs
* pin the sourcing split: EDR always seals with the global stamp and staff
* never supply one, while the customer must upload their own.
* The staff signature seals with the ONE global stamp by REFERENCE: the
* signature row stores the current global stampFileId instead of re-uploading
* a copy per contract. That id stays valid after the stamp is replaced
* (StampSettingsService never deletes retired stamp files), so each contract
* keeps the exact seal it was signed with. These specs pin the sourcing
* split: EDR always seals with the global stamp and staff never supply one,
* while the customer must upload their own.
*/
describe('applySignature stamp sourcing', () => {
const contract = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
const GLOBAL_STAMP = 'data:image/png;base64,RURS';
const GLOBAL_STAMP_FILE_ID = 'file-global-stamp';
const build = (globalStamp: string | null = GLOBAL_STAMP) => {
const build = (globalStampFileId: string | null = GLOBAL_STAMP_FILE_ID) => {
const uploads: Array<{ code: string; image: string }> = [];
const saved: unknown[] = [];
const service = Object.create(
@@ -22,7 +25,10 @@ describe('applySignature stamp sourcing', () => {
Object.assign(service, {
logger: { warn: jest.fn(), log: jest.fn() },
stampSettings: {
getStampImageUrl: jest.fn().mockResolvedValue(globalStamp),
get: jest.fn().mockResolvedValue({
id: 's-1',
stampFileId: globalStampFileId,
}),
},
contractsRepository: {
saveSignature: jest.fn((row: unknown) => {
@@ -62,35 +68,36 @@ describe('applySignature stamp sourcing', () => {
signatureImageBase64: 'data:image/png;base64,U0lH',
};
it('seals the EDR side with the global stamp', async () => {
it('seals the EDR side by referencing the global stamp file, without re-uploading it', async () => {
const { service, uploads, saved } = build();
await apply(service, staffDto);
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.code)).toEqual(['signature_staff']);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: 'file-stamp_staff' }),
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
it('ignores a stamp a staff client tries to supply', async () => {
const { service, uploads } = build();
const { service, uploads, saved } = build();
await apply(service, {
...staffDto,
stampImageBase64: 'data:image/png;base64,SEFDSw==',
});
expect(uploads).toContainEqual({ code: 'stamp_staff', image: GLOBAL_STAMP });
expect(uploads.map((u) => u.image)).not.toContain(
'data:image/png;base64,SEFDSw==',
);
expect(saved[0]).toEqual(
expect.objectContaining({ stampFileId: GLOBAL_STAMP_FILE_ID }),
);
});
/**
* Failing loudly matters here: getStampImageUrl degrades to null when the
* stamp cannot be inlined, and silently executing an unsealed contract would
* be worse than refusing to counter-sign.
* Failing loudly matters here: silently executing an unsealed contract
* would be worse than refusing to counter-sign.
*/
it('refuses to counter-sign when no global stamp is configured', async () => {
const { service, saved } = build(null);

View File

@@ -1128,17 +1128,27 @@ export class ContractTransitionService {
);
}
// Snapshot whichever stamp applies onto the signature row rather than
// referencing the global one, so replacing the company stamp later can
// never restamp an already-executed contract.
let stampImageBase64 = dto.stampImageBase64 ?? null;
// STAFF seals by REFERENCE to the one global stamp file — no per-contract
// copy of the image. Safe because StampSettingsService.setStamp/clearStamp
// never delete a replaced stamp file: the referenced id keeps rendering
// the exact seal that was current at signing, even after the global stamp
// is later replaced. The customer's stamp is their own upload and is still
// stored per contract.
let stampFileId: string | null = null;
if (role === 'STAFF') {
stampImageBase64 = await this.stampSettings.getStampImageUrl();
if (!stampImageBase64) {
stampFileId = (await this.stampSettings.get()).stampFileId ?? null;
if (!stampFileId) {
throw new BadRequestException(
'No company stamp is configured. Upload the company stamp under Settings before counter-signing contracts.',
);
}
} else if (dto.stampImageBase64) {
const stampRecord = await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
dto.stampImageBase64,
);
stampFileId = stampRecord.id;
}
const fileRecord = await this.uploadSignatureAsset(
@@ -1146,13 +1156,6 @@ export class ContractTransitionService {
`signature_${role.toLowerCase()}`,
imageBase64,
);
const stampRecord = stampImageBase64
? await this.uploadSignatureAsset(
contract,
`stamp_${role.toLowerCase()}`,
stampImageBase64,
)
: null;
await this.contractsRepository.saveSignature({
contractId: contract.id,
@@ -1160,7 +1163,7 @@ export class ContractTransitionService {
signerDisplayName,
signedAt: new Date(),
signatureFileId: fileRecord.id,
stampFileId: stampRecord?.id ?? null,
stampFileId,
consentText: dto.consentText ?? null,
});

View File

@@ -76,7 +76,10 @@ import {
import { SignContractDto } from './dto/sign-contract.dto';
import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto';
import { RenewContractDto } from './dto/renew-contract.dto';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
CompleteConsolidatedPairDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
import {
CreateBookingRequestDto,
ReviewBookingRequestDto,
@@ -120,8 +123,8 @@ export class ContractsController {
@Get('booking-requests/queue')
@BookingStaff(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({ summary: 'GL queue: shipment requests across contracts (all statuses, newest first)' })
bookingRequestQueue() {
return this.bookingRequestService.queue();
bookingRequestQueue(@CurrentUser() user: AuthUserPayload) {
return this.bookingRequestService.queue(user);
}
@Get('booking-requests/:reqId')
@@ -1152,10 +1155,48 @@ export class ContractsController {
// Customs (Path B) instances may only be completed by GL Ethiopia — the
// service checks the actor's contracts:create_booking permission.
return this.contractBookingService.completeUnderContract(
id,
bookingId,
// skipAutoConsolidation is internal to the manual pair-completion path; a
// client must never suppress the wagon gate on a lone booking.
{ ...dto, skipAutoConsolidation: false },
user,
);
}
@Get(':id/bookings/:bookingId/consolidation-candidates')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Bookings GL may link to this odd-20ft customs booking as its shared-wagon partner (same route and direction, customs, odd 20ft, unpaired).',
})
listConsolidationCandidates(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
) {
return this.contractBookingService.listConsolidationCandidates(id, bookingId);
}
@Post(':id/bookings/:bookingId/complete-consolidated')
@MixedAudience(FREIGHT_PERMS.contracts.createBooking)
@ApiOperation({
summary:
'Complete this booking and its chosen shared-wagon partner together (all-or-nothing). Each booking is priced and invoiced separately — only the wagon is shared.',
})
completeConsolidatedPair(
@Param('id', ParseUUIDPipe) id: string,
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: CompleteConsolidatedPairDto,
@CurrentUser() user: TCurrentUser & { sub?: string },
) {
return this.contractBookingService.completeConsolidatedPair(
id,
bookingId,
dto,
user,
// Recorded as the requester on the approval: the person who created the
// pairing may not be the one who approves it.
user?.id ?? user?.sub ?? null,
);
}

View File

@@ -663,6 +663,31 @@ export class ContractsRepository extends BaseRepository<Contract> {
.getCount();
}
/**
* The live shipment booking on a contract, newest first.
*
* The clearance view historically reached the booking through
* `currentCycle().bookingId`, but a cycle row is not created on every path —
* an FCFS export booking and a GL drawdown both reach
* OPERATION_REQUEST_PENDING without one — so that lookup returns null and the
* clearance page loses the booking's status entirely. This resolves it from
* the bookings themselves, which is the authoritative link (bookings carry
* contract_id), and is used as the fallback when the cycle has no booking.
*/
async findLatestBookingForContract(
contractId: string,
): Promise<Booking | null> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.where('b.contract_id = :contractId', { contractId })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.orderBy('b.created_at', 'DESC')
.getOne();
}
async createReviewNote(
contractId: string,
body: string,

View File

@@ -1,4 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiHideProperty, ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
IsArray,
@@ -219,4 +219,46 @@ export class CreateBookingUnderContractDto {
@IsOptional()
@IsString()
notes?: string;
/**
* Internal: set by the manual GL pair-completion path, never by a client.
* Suppresses the automatic wagon-consolidation gate for this completion
* because the caller links the shared wagon itself. Excluded from the public
* schema so a client cannot set it to bypass the gate on a lone booking.
*/
@ApiHideProperty()
@IsOptional()
@IsBoolean()
skipAutoConsolidation?: boolean;
}
/**
* Complete an odd-20ft customs booking together with the partner booking GL
* picked to share its wagon. Each half carries its own full completion payload —
* the two bookings stay separately priced and separately invoiced, they only
* share the wagon.
*/
export class CompleteConsolidatedPairDto {
@ApiProperty({
format: 'uuid',
description: 'The booking chosen to share this bookings wagon.',
})
@IsUUID()
partnerBookingId!: string;
@ApiProperty({
type: CreateBookingUnderContractDto,
description: 'Completion payload for the booking in the URL.',
})
@ValidateNested()
@Type(() => CreateBookingUnderContractDto)
booking!: CreateBookingUnderContractDto;
@ApiProperty({
type: CreateBookingUnderContractDto,
description: 'Completion payload for the partner booking.',
})
@ValidateNested()
@Type(() => CreateBookingUnderContractDto)
partner!: CreateBookingUnderContractDto;
}