mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 15:03:39 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
return {
|
||||
service,
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('ContractBookingService — customs booking gate', () => {
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
{} as never, // bookingTransitionService
|
||||
{} as never, // consolidationApprovalService
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 booking’s 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user