mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
Merge pull request #1000 from Tria-plc/freight_feature/usermanagement
New Transit Agents admin table (name, valid-from/to, active/suspended…
This commit is contained in:
@@ -317,11 +317,11 @@ export class BookingLifecycleNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/** GL raised the final (post-offload) invoice — customer pays + uploads slip. */
|
||||
/** GL raised the final (post-offload) invoice — customer approves, pays, uploads slip. */
|
||||
finalInvoiceCreated(b: Booking, amount: number, currency: string): void {
|
||||
const msg =
|
||||
`A final invoice of ${amount} ${currency} has been issued for booking ${b.reference}. ` +
|
||||
`Please pay and upload the payment slip from the portal.`;
|
||||
`A final invoice of ${amount} ${currency} has been raised for booking ${b.reference}. ` +
|
||||
`Please review and approve it in the portal, then pay and upload the payment slip.`;
|
||||
void this.notifyContact(b, msg, 'FINAL INVOICE');
|
||||
this.inApp(b, 'Final invoice issued', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
@@ -361,6 +361,15 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer approved the GL Djibouti final invoice — payment slip can follow. */
|
||||
finalInvoiceApprovedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
b,
|
||||
'Final invoice approved',
|
||||
`The customer approved the final invoice for booking ${this.ref(b)} — awaiting payment slip.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Customer signed the booking contract. */
|
||||
customerSignedToStaff(b: Booking): void {
|
||||
this.inAppStaff(
|
||||
@@ -392,16 +401,27 @@ export class BookingLifecycleNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer disputed the advised duty & tax. This goes to STAFF, not the
|
||||
* customer: GL Ethiopia is the one who has to re-advise, and the clearance
|
||||
* page is where they do it.
|
||||
*/
|
||||
dutyDisputed(b: Booking, note: string): void {
|
||||
/** GL Ethiopia sent a draft customs declaration — the customer must accept or request a change. */
|
||||
draftDeclarationReady(b: Booking, price: number, currency: string): void {
|
||||
const msg =
|
||||
`The customer disputed the duty & tax advised on booking ${this.ref(b)}: ` +
|
||||
`"${note}". Review and re-advise the amount on the clearance page.`;
|
||||
this.inAppStaff(b, `Duty disputed on ${this.ref(b)}`, msg, {
|
||||
`A draft customs declaration for booking ${b.reference} is ready for your review — ` +
|
||||
`estimated price ${price} ${currency}. Please accept it or request a change from the portal.`;
|
||||
void this.notifyContact(b, msg, 'DRAFT DECLARATION READY');
|
||||
this.inApp(b, 'Draft declaration ready for review', msg, {
|
||||
type: NotificationType.DOCUMENT_ACTION,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer asked for a change on the draft declaration. This goes to
|
||||
* STAFF, not the customer: GL Ethiopia is the one who has to send a
|
||||
* corrected draft, and the clearance page is where they do it.
|
||||
*/
|
||||
draftDeclarationChangeRequested(b: Booking, note: string): void {
|
||||
const msg =
|
||||
`The customer requested a change to the draft declaration on booking ${this.ref(b)}: ` +
|
||||
`"${note}". Send a corrected draft from the clearance page.`;
|
||||
this.inAppStaff(b, `Draft declaration change requested — ${this.ref(b)}`, msg, {
|
||||
type: NotificationType.CLEARANCE_REVIEW,
|
||||
link: `/dashboard/bookings/${b.id}/clearance`,
|
||||
});
|
||||
|
||||
@@ -841,13 +841,13 @@ export class BookingsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
|
||||
})
|
||||
async assignBookingTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('assignee') assignee: string,
|
||||
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, assignee);
|
||||
const booking = await this.bookingClearanceService.assignTransitAssignee(id, transitAgentId);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@@ -900,17 +900,52 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/duty/dispute')
|
||||
@Post(':id/clearance/draft-declaration')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
|
||||
@UseInterceptors(AnyFilesInterceptor())
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)',
|
||||
'GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review',
|
||||
})
|
||||
async disputeBookingDuty(
|
||||
async uploadBookingDraftDeclaration(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('price') priceRaw: string,
|
||||
@Body('currency') currency: string | undefined,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.uploadDraftDeclaration(
|
||||
id,
|
||||
files ?? [],
|
||||
Number(priceRaw),
|
||||
currency ?? 'ETB',
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/draft-declaration/accept')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia',
|
||||
})
|
||||
async acceptBookingDraftDeclaration(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.bookingClearanceService.acceptDraftDeclaration(id);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/clearance/draft-declaration/change')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)',
|
||||
})
|
||||
async requestBookingDraftDeclarationChange(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('note') note: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingClearanceService.disputeDuty(
|
||||
const booking = await this.bookingClearanceService.requestDraftDeclarationChange(
|
||||
id,
|
||||
note,
|
||||
resolveAuthUserId(user),
|
||||
|
||||
@@ -7,10 +7,10 @@ export const REVIEW_NOTE_TYPES = [
|
||||
'REJECTION',
|
||||
'STAFF_NOTE',
|
||||
/**
|
||||
* The customer disputed the advised duty & tax and asked GL Ethiopia to
|
||||
* correct it. One row per round — the advice/dispute loop can repeat.
|
||||
* The customer asked GL Ethiopia to correct the draft customs declaration
|
||||
* (price/files). One row per round — the draft/change-request loop can repeat.
|
||||
*/
|
||||
'DUTY_DISPUTE',
|
||||
'DRAFT_DECL_CHANGE_REQUEST',
|
||||
] as const;
|
||||
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
|
||||
|
||||
|
||||
@@ -104,6 +104,12 @@ function makeService(overrides?: {
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
} as never, // notifier
|
||||
{ listVisibleToCustomer: jest.fn().mockResolvedValue([]) } as never, // GL exchange
|
||||
{
|
||||
getAssignable: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'ta-1', name: 'Ahmed Bourhan' }),
|
||||
} as never, // transit agents
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
ContractDocPhase,
|
||||
isDraftDeclarationFileCode,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceOffloadState,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
type GlExchangeDocument,
|
||||
} from '@edr/types';
|
||||
|
||||
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
|
||||
@@ -23,8 +26,10 @@ import { assertDoCollectionDates } from './contract-clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
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 { AdviseContractDutyDto } from './dto/phased-clearance.dto';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
|
||||
|
||||
const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days';
|
||||
|
||||
@@ -89,10 +94,22 @@ export interface BookingClearanceView {
|
||||
noticeFile?: { id: string; name: string; url: string } | null;
|
||||
} | null;
|
||||
/**
|
||||
* The customer's open objection to the advised duty. Present only until GL
|
||||
* re-advises; `rounds` counts how many times it has been sent back.
|
||||
* Import only: the draft customs declaration GL Ethiopia sends before filing
|
||||
* the real one. Present once a draft has been uploaded, regardless of
|
||||
* accept state — `accepted` tells the caller which.
|
||||
*/
|
||||
dutyDispute?: {
|
||||
draftDeclaration?: {
|
||||
price: number;
|
||||
currency: string;
|
||||
files: Array<{ id: string; name: string; url: string }>;
|
||||
accepted: boolean;
|
||||
} | null;
|
||||
/**
|
||||
* The customer's open change request on the current draft declaration.
|
||||
* Present only until GL sends a corrected draft; `rounds` counts how many
|
||||
* times it has been sent back.
|
||||
*/
|
||||
draftDeclarationChangeRequest?: {
|
||||
note: string;
|
||||
raisedAt: string;
|
||||
rounds: number;
|
||||
@@ -107,6 +124,8 @@ export interface BookingClearanceView {
|
||||
t1Closed?: boolean;
|
||||
t1ClosedAt?: string | null;
|
||||
offloaded?: boolean;
|
||||
/** Offload stats for this booking (what came off the train, and where). */
|
||||
offload?: ClearanceOffloadState | null;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
@@ -117,6 +136,8 @@ export interface BookingClearanceView {
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
/** GL-shared documents this booking's uploader marked visible to the customer. */
|
||||
exchangeDocuments?: GlExchangeDocument[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -131,6 +152,8 @@ export class BookingClearanceService {
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly glExchangeService: GlExchangeService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
) {}
|
||||
|
||||
private async assertPhasedCustoms(booking: Booking): Promise<void> {
|
||||
@@ -231,7 +254,11 @@ export class BookingClearanceService {
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
);
|
||||
const dutyAdvice = this.buildDutyAdvice(files, milestones);
|
||||
const dutyDispute = await this.buildDutyDispute(bookingId, milestones);
|
||||
const draftDeclaration = this.buildDraftDeclaration(files, milestones);
|
||||
const draftDeclarationChangeRequest = await this.buildDraftDeclarationChangeRequest(
|
||||
bookingId,
|
||||
milestones,
|
||||
);
|
||||
const workflowFiles = buildWorkflowFiles(
|
||||
files,
|
||||
booking.tradeDirection ?? 'IMPORT',
|
||||
@@ -259,6 +286,12 @@ export class BookingClearanceService {
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||
// GL↔GL exchange documents shared with the customer. The two desks may work
|
||||
// the thread on the booking (per-booking customs) or on its contract
|
||||
// (pre-booking clearance), so the customer's view spans both.
|
||||
const exchangeDocuments = await this.glExchangeService.listVisibleToCustomer(
|
||||
[bookingId, booking.contractId ?? ''],
|
||||
);
|
||||
|
||||
return {
|
||||
bookingId,
|
||||
@@ -301,8 +334,10 @@ export class BookingClearanceService {
|
||||
: null,
|
||||
},
|
||||
dutyAdvice,
|
||||
dutyDispute,
|
||||
draftDeclaration,
|
||||
draftDeclarationChangeRequest,
|
||||
workflowFiles,
|
||||
exchangeDocuments,
|
||||
t1,
|
||||
train,
|
||||
gatepassGranted: gatepass.granted,
|
||||
@@ -313,6 +348,7 @@ export class BookingClearanceService {
|
||||
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
offload: await this.glOperationsService.offloadState(bookingId, milestones),
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
@@ -359,13 +395,37 @@ export class BookingClearanceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async buildDutyDispute(
|
||||
private buildDraftDeclaration(
|
||||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||||
milestones: ClearanceMilestone[],
|
||||
): BookingClearanceView['draftDeclaration'] {
|
||||
const uploaded = milestones.find(
|
||||
(m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED' && m.status === 'COMPLETED',
|
||||
);
|
||||
if (!uploaded?.metadata) return null;
|
||||
const price = uploaded.metadata.draftDeclarationPrice;
|
||||
const currency = uploaded.metadata.draftDeclarationCurrency;
|
||||
if (typeof price !== 'number' || typeof currency !== 'string') return null;
|
||||
const draftFiles = files
|
||||
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''))
|
||||
.map((f) => ({ id: f.id, name: f.name, url: f.url }));
|
||||
const accepted =
|
||||
milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_ACCEPTED')?.status ===
|
||||
'COMPLETED';
|
||||
return { price, currency, files: draftFiles, accepted };
|
||||
}
|
||||
|
||||
private async buildDraftDeclarationChangeRequest(
|
||||
bookingId: string,
|
||||
milestones: ClearanceMilestone[],
|
||||
): Promise<BookingClearanceView['dutyDispute']> {
|
||||
const advised = milestones.find((m) => m.milestoneCode === 'DUTY_TAXES_ADVISED');
|
||||
if (!advised || advised.status === 'COMPLETED') return null;
|
||||
const notes = await this.bookingsRepository.findReviewNotes(bookingId, 'DUTY_DISPUTE');
|
||||
): Promise<BookingClearanceView['draftDeclarationChangeRequest']> {
|
||||
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
||||
if (!uploaded || uploaded.status === 'COMPLETED') return null;
|
||||
const notes = await this.bookingsRepository.findReviewNotes(
|
||||
bookingId,
|
||||
'DRAFT_DECL_CHANGE_REQUEST',
|
||||
);
|
||||
const latest = notes[0];
|
||||
if (!latest) return null;
|
||||
return {
|
||||
@@ -426,28 +486,27 @@ export class BookingClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti names the transit officer — free text, because the person is not
|
||||
* a platform user. Answering unblocks the declaration for Ethiopia. A later
|
||||
* call overwrites the name (reassignment) and re-notifies.
|
||||
* GL Djibouti picks the transit officer from the admin-managed roster —
|
||||
* rejected unless the agent is active and inside its validity window.
|
||||
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
||||
* the name (reassignment) and re-notifies.
|
||||
*/
|
||||
async assignTransitAssignee(bookingId: string, assignee: string): Promise<Booking> {
|
||||
async assignTransitAssignee(bookingId: string, transitAgentId: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (!assignee?.trim()) {
|
||||
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||
}
|
||||
if (!booking.transitAssigneeRequestedAt) {
|
||||
throw new BadRequestException(
|
||||
'GL Ethiopia has not requested a transit assignee for this shipment yet.',
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
|
||||
|
||||
const previous = booking.transitAssigneeName ?? null;
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
this.notifier.transitAssigneeAssigned(booking, assignee.trim(), previous);
|
||||
this.notifier.transitAssigneeAssigned(booking, agent.name, previous);
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -499,12 +558,6 @@ export class BookingClearanceService {
|
||||
: ContractDocPhase.CustomerDuty,
|
||||
} as never);
|
||||
|
||||
// Export: the declaration is the last GL ET pre-operation action — release
|
||||
// immediately so the customer can proceed without a separate confirm click.
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||
}
|
||||
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
@@ -562,61 +615,120 @@ export class BookingClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer disagrees with the advised duty & tax on this booking and asks
|
||||
* GL Ethiopia to correct it. Nothing is paid; the advice milestone reopens so
|
||||
* the Duty & tax step becomes actionable again on the GL clearance page, with
|
||||
* the customer's message shown beside it. GL re-advises (same endpoint as the
|
||||
* first time), which closes the dispute — the loop may run as many rounds as
|
||||
* it takes.
|
||||
* GL Ethiopia sends a draft customs declaration (estimated price + files) for
|
||||
* the customer to review before the real declaration is filed. Repeatable —
|
||||
* each call replaces the previous draft's files/price and re-arms the step,
|
||||
* which is what a re-send after a change request needs.
|
||||
*/
|
||||
async disputeDuty(
|
||||
async uploadDraftDeclaration(
|
||||
bookingId: string,
|
||||
files: Express.Multer.File[],
|
||||
price: number,
|
||||
currency: 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.');
|
||||
}
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No draft declaration documents uploaded');
|
||||
}
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
throw new BadRequestException('A valid estimated price is required.');
|
||||
}
|
||||
// Backfills the two new milestone rows for bookings seeded before this step
|
||||
// existed — a blind complete() 404s on a booking with no such row yet.
|
||||
await this.milestoneService.ensureBookingMilestones(bookingId, 'IMPORT');
|
||||
await this.workflowService.assertPriorCompleteForBooking(
|
||||
bookingId,
|
||||
'IMPORT',
|
||||
'DRAFT_DECLARATION_UPLOADED',
|
||||
);
|
||||
|
||||
await persistDraftDeclarationUploads(this.filesService, bookingId, 'bookings', files);
|
||||
await this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
'DRAFT_DECLARATION_UPLOADED',
|
||||
{ draftDeclarationPrice: price, draftDeclarationCurrency: currency },
|
||||
userId,
|
||||
);
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||
} as never);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.draftDeclarationReady(updated, price, currency);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer accepts the draft declaration — GL Ethiopia may now file the
|
||||
* real customs declaration.
|
||||
*/
|
||||
async acceptDraftDeclaration(bookingId: string): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||
}
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const uploaded = milestones.find((m) => m.milestoneCode === 'DRAFT_DECLARATION_UPLOADED');
|
||||
if (uploaded?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException('There is no draft declaration to accept yet.');
|
||||
}
|
||||
|
||||
await this.workflowService.completeMilestoneForBooking(bookingId, 'DRAFT_DECLARATION_ACCEPTED');
|
||||
return this.bookingsService.findById(bookingId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer sends the draft declaration back with a reason. Nothing is
|
||||
* filed; the upload milestone reopens so the step becomes actionable again
|
||||
* for GL Ethiopia, with the customer's message shown beside it. GL re-sends
|
||||
* (same endpoint as the first time), which closes the request — the loop may
|
||||
* run as many rounds as it takes.
|
||||
*/
|
||||
async requestDraftDeclarationChange(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.loadBooking(bookingId);
|
||||
if (booking.tradeDirection !== 'IMPORT') {
|
||||
throw new BadRequestException('Duty applies only to import bookings.');
|
||||
throw new BadRequestException('Draft declaration applies only to import bookings.');
|
||||
}
|
||||
if (!note?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'Say what is wrong with the advised amount so GL can correct it.',
|
||||
'Say what needs to change so GL can correct the draft.',
|
||||
);
|
||||
}
|
||||
if (!booking.dutyRequired) {
|
||||
throw new BadRequestException('Duty/tax is not required for this clearance.');
|
||||
}
|
||||
|
||||
const milestones = await this.workflowService.listMilestonesForBooking(bookingId);
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
if (byCode.get('DUTY_TAXES_ADVISED')?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
'There is no advised duty amount to dispute yet.',
|
||||
);
|
||||
if (byCode.get('DRAFT_DECLARATION_UPLOADED')?.status !== 'COMPLETED') {
|
||||
throw new BadRequestException('There is no draft declaration to request a change on yet.');
|
||||
}
|
||||
// Once the slip is in, the money is paid — a dispute then is a refund
|
||||
// conversation, not a re-advice.
|
||||
if (byCode.get('DUTY_TAX_PAID')?.status === 'COMPLETED') {
|
||||
if (byCode.get('DRAFT_DECLARATION_ACCEPTED')?.status === 'COMPLETED') {
|
||||
throw new BadRequestException(
|
||||
'The duty payment slip has already been submitted — contact GL Ethiopia directly.',
|
||||
'The draft declaration has already been accepted — contact GL Ethiopia directly.',
|
||||
);
|
||||
}
|
||||
|
||||
await this.bookingsRepository.createReviewNote(
|
||||
bookingId,
|
||||
note.trim(),
|
||||
'DUTY_DISPUTE',
|
||||
'DRAFT_DECL_CHANGE_REQUEST',
|
||||
userId,
|
||||
);
|
||||
// Back to GL: reopening the milestone is what re-arms the Duty & tax step
|
||||
// (the stepper picks its active step from milestone completion).
|
||||
await this.milestoneService.reopenForBooking(bookingId, 'DUTY_TAXES_ADVISED');
|
||||
// Back to GL: reopening the milestone is what re-arms the step (the
|
||||
// stepper picks its active step from milestone completion).
|
||||
await this.milestoneService.reopenForBooking(bookingId, 'DRAFT_DECLARATION_UPLOADED');
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
clearanceCurrentPhase: ContractDocPhase.GlEtOutput,
|
||||
} as never);
|
||||
|
||||
const updated = await this.bookingsService.findById(bookingId);
|
||||
this.notifier.dutyDisputed(updated, note.trim());
|
||||
this.notifier.draftDeclarationChangeRequested(updated, note.trim());
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -824,6 +936,10 @@ export class BookingClearanceService {
|
||||
'RELEASE_ORDER_SECURED',
|
||||
userId,
|
||||
);
|
||||
// Release Order is now the last GL DJ pre-operation action (it follows the
|
||||
// declaration) — release immediately so booking creation unlocks without a
|
||||
// separate confirm click.
|
||||
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
|
||||
|
||||
return { booking: await this.bookingsService.findById(bookingId), hold: false };
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { BookingClearanceService } from './booking-clearance.service';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The duty advice → dispute → re-advice loop, at the booking level. GL
|
||||
* Ethiopia advises an amount; the customer either pays it or sends it back
|
||||
* with a reason. Sending it back reopens the advice milestone — that is what
|
||||
* puts the Duty & tax step back in GL's hands — and the round can repeat
|
||||
* until the amount is agreed.
|
||||
*/
|
||||
describe('BookingClearanceService — duty dispute', () => {
|
||||
const booking = (over: Partial<Booking> = {}): Booking =>
|
||||
({
|
||||
id: 'bk-1',
|
||||
reference: 'BKG-2026-00042',
|
||||
tradeDirection: 'IMPORT',
|
||||
customsClearingEnabled: true,
|
||||
contractId: 'ctr-1',
|
||||
dutyRequired: true,
|
||||
...over,
|
||||
}) as Booking;
|
||||
|
||||
const milestone = (code: string, status: string) =>
|
||||
({ milestoneCode: code, status }) as never;
|
||||
|
||||
let repo: {
|
||||
createReviewNote: jest.Mock;
|
||||
findReviewNotes: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
let bookingsService: { findById: jest.Mock };
|
||||
let workflowService: { listMilestonesForBooking: jest.Mock };
|
||||
let milestoneService: { reopenForBooking: jest.Mock };
|
||||
let notifier: { dutyDisputed: jest.Mock };
|
||||
let service: BookingClearanceService;
|
||||
|
||||
const build = (milestones: unknown[]) => {
|
||||
workflowService.listMilestonesForBooking.mockResolvedValue(milestones);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
createReviewNote: jest.fn().mockResolvedValue(undefined),
|
||||
findReviewNotes: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
bookingsService = { findById: jest.fn().mockResolvedValue(booking()) };
|
||||
workflowService = { listMilestonesForBooking: jest.fn().mockResolvedValue([]) };
|
||||
milestoneService = { reopenForBooking: jest.fn().mockResolvedValue(undefined) };
|
||||
notifier = { dutyDisputed: jest.fn() };
|
||||
|
||||
service = new BookingClearanceService(
|
||||
repo as never,
|
||||
bookingsService as never,
|
||||
{} as never, // filesService
|
||||
{} as never, // fileUploadSettingsService
|
||||
workflowService as never,
|
||||
milestoneService as never,
|
||||
{} as never, // dropdownSettingsService
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
);
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
milestone('DUTY_TAX_PAID', 'PENDING'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('records the objection and hands the step back to GL', async () => {
|
||||
await service.disputeDuty('bk-1', ' Declared value is wrong ', 'user-1');
|
||||
|
||||
expect(repo.createReviewNote).toHaveBeenCalledWith(
|
||||
'bk-1',
|
||||
'Declared value is wrong',
|
||||
'DUTY_DISPUTE',
|
||||
'user-1',
|
||||
);
|
||||
// Reopening the advice milestone is what re-arms the Duty & tax step.
|
||||
expect(milestoneService.reopenForBooking).toHaveBeenCalledWith(
|
||||
'bk-1',
|
||||
'DUTY_TAXES_ADVISED',
|
||||
);
|
||||
expect(repo.update).toHaveBeenCalledWith('bk-1', {
|
||||
clearanceCurrentPhase: 'GL_ET_OUTPUT',
|
||||
});
|
||||
});
|
||||
|
||||
it('tells GL Ethiopia, not the customer', async () => {
|
||||
await service.disputeDuty('bk-1', 'Too high', 'user-1');
|
||||
expect(notifier.dutyDisputed).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'bk-1' }),
|
||||
'Too high',
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a reason — GL cannot correct an unexplained objection', async () => {
|
||||
await expect(service.disputeDuty('bk-1', ' ')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
expect(milestoneService.reopenForBooking).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses when nothing has been advised yet', async () => {
|
||||
build([milestone('DUTY_TAXES_ADVISED', 'PENDING')]);
|
||||
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||
/no advised duty amount/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses once the payment slip is in — that is a refund, not a re-advice', async () => {
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
milestone('DUTY_TAX_PAID', 'COMPLETED'),
|
||||
]);
|
||||
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||
/already been submitted/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses when duty was never required for this clearance', async () => {
|
||||
bookingsService.findById.mockResolvedValue(booking({ dutyRequired: false }));
|
||||
await expect(service.disputeDuty('bk-1', 'Too high')).rejects.toThrow(
|
||||
/not required/i,
|
||||
);
|
||||
});
|
||||
|
||||
describe('the view', () => {
|
||||
const buildDispute = (milestones: unknown[]) =>
|
||||
(
|
||||
service as unknown as {
|
||||
buildDutyDispute: (id: string, m: unknown[]) => Promise<unknown>;
|
||||
}
|
||||
).buildDutyDispute('bk-1', milestones);
|
||||
|
||||
it('shows the objection while GL still owes a corrected advice', async () => {
|
||||
repo.findReviewNotes.mockResolvedValue([
|
||||
{ note: 'Second look please', createdAt: new Date('2026-07-20T09:00:00Z') },
|
||||
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||
]);
|
||||
|
||||
const dispute = await buildDispute([
|
||||
milestone('DUTY_TAXES_ADVISED', 'PENDING'),
|
||||
]);
|
||||
|
||||
expect(dispute).toMatchObject({ note: 'Second look please', rounds: 2 });
|
||||
});
|
||||
|
||||
it('clears itself once GL re-advises', async () => {
|
||||
repo.findReviewNotes.mockResolvedValue([
|
||||
{ note: 'First objection', createdAt: new Date('2026-07-18T09:00:00Z') },
|
||||
]);
|
||||
|
||||
const dispute = await buildDispute([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
]);
|
||||
|
||||
expect(dispute).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
|
||||
IMPORT_DOCS_UPLOADED: { label: 'Import Documents Uploaded', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
PENDING_DOCUMENT_REVIEW: { label: 'Pending Document Review', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DOCUMENTS_APPROVED: { label: 'Documents Approved', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DRAFT_DECLARATION_UPLOADED: { label: 'Draft Declaration Sent', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DRAFT_DECLARATION_ACCEPTED: { label: 'Draft Declaration Accepted', ownerRegion: 'CUST', triggeredByDoc: false },
|
||||
UNDER_CUSTOMS_CLEARANCE: { label: 'Under Customs Clearance', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
DECLARED: { label: 'Declared', ownerRegion: 'ET', triggeredByDoc: true },
|
||||
DUTY_TAXES_ADVISED: { label: 'Duty and Taxes Advised', ownerRegion: 'ET', triggeredByDoc: false },
|
||||
|
||||
@@ -353,10 +353,10 @@ export class ClearanceWorkflowService {
|
||||
if (!isDone('DOCUMENTS_APPROVED')) return ContractDocPhase.GlEtReview;
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return ContractDocPhase.GlDjCollection;
|
||||
}
|
||||
if (!isDone('DECLARED')) return ContractDocPhase.GlEtOutput;
|
||||
if (!isDone(EXPORT_BOUNDARY)) return ContractDocPhase.GlEtPostClearance;
|
||||
return ContractDocPhase.GlEtPostClearance;
|
||||
}
|
||||
@@ -450,13 +450,6 @@ export class ClearanceWorkflowService {
|
||||
: 'Proceed to request operation';
|
||||
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Release Order and vessel departure date',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
};
|
||||
}
|
||||
if (!isDone('DECLARED')) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
@@ -464,6 +457,13 @@ export class ClearanceWorkflowService {
|
||||
milestoneCode: 'DECLARED',
|
||||
};
|
||||
}
|
||||
if (!isDone('RELEASE_ORDER_SECURED')) {
|
||||
return {
|
||||
actor: 'GL_DJ',
|
||||
action: 'Upload Release Order and vessel departure date',
|
||||
milestoneCode: 'RELEASE_ORDER_SECURED',
|
||||
};
|
||||
}
|
||||
if (!isDone(EXPORT_BOUNDARY)) {
|
||||
return {
|
||||
actor: 'GL_ET',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
ContractDocPhase,
|
||||
type ClearanceFinalInvoiceSummary,
|
||||
type ClearanceOffloadState,
|
||||
type ClearanceSecondDuty,
|
||||
type ClearanceT1State,
|
||||
type ClearanceTrainState,
|
||||
@@ -26,6 +27,7 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { TransitAgentsService } from '../transit-agents/transit-agents.service';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
@@ -140,6 +142,8 @@ export interface ContractClearanceView {
|
||||
t1Closed?: boolean;
|
||||
t1ClosedAt?: string | null;
|
||||
offloaded?: boolean;
|
||||
/** Offload stats for the linked booking (null until one exists). */
|
||||
offload?: ClearanceOffloadState | null;
|
||||
/** GL Djibouti post-offload final invoice (export). */
|
||||
finalInvoice?: ClearanceFinalInvoiceSummary | null;
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
@@ -165,6 +169,7 @@ export class ContractClearanceService {
|
||||
private readonly dropdownSettingsService: DropdownSettingsService,
|
||||
private readonly glOperationsService: GlOperationsService,
|
||||
private readonly notifier: ContractNotifierService,
|
||||
private readonly transitAgentsService: TransitAgentsService,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(contract: Contract): boolean {
|
||||
@@ -433,6 +438,9 @@ export class ContractClearanceService {
|
||||
? t1ClosedMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
|
||||
offload: cycle?.bookingId
|
||||
? await this.glOperationsService.offloadState(cycle.bookingId, bookingMilestones)
|
||||
: null,
|
||||
finalInvoice,
|
||||
riskLevel:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
@@ -1117,20 +1125,18 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti names the transit officer — free text, because the person is
|
||||
* not a platform user. Answering unblocks the declaration for Ethiopia. A
|
||||
* later call overwrites the name (reassignment) and re-notifies.
|
||||
* GL Djibouti picks the transit officer from the admin-managed roster —
|
||||
* rejected unless the agent is active and inside its validity window.
|
||||
* Answering unblocks the declaration for Ethiopia. A later call overwrites
|
||||
* the name (reassignment) and re-notifies.
|
||||
*/
|
||||
async assignTransitAssignee(
|
||||
contractId: string,
|
||||
assignee: string,
|
||||
transitAgentId: string,
|
||||
userId?: string,
|
||||
): Promise<Contract> {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
if (!assignee?.trim()) {
|
||||
throw new BadRequestException('Name the officer who will handle the transit.');
|
||||
}
|
||||
const cycle = await this.contractsRepository.currentCycle(contractId);
|
||||
if (!cycle) throw new BadRequestException('No clearance cycle found');
|
||||
if (!cycle.transitAssigneeRequestedAt) {
|
||||
@@ -1138,16 +1144,17 @@ export class ContractClearanceService {
|
||||
'GL Ethiopia has not requested a transit assignee for this clearance yet.',
|
||||
);
|
||||
}
|
||||
const agent = await this.transitAgentsService.getAssignable(transitAgentId);
|
||||
|
||||
const previous = cycle.transitAssigneeName ?? null;
|
||||
await this.contractsRepository.updateCycle(cycle.id, {
|
||||
transitAssigneeName: assignee.trim(),
|
||||
transitAssigneeName: agent.name,
|
||||
transitAssigneeAssignedAt: new Date(),
|
||||
transitAssigneeAssignedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
const updated = await this.contractsService.findById(contractId);
|
||||
this.notifier.transitAssigneeAssigned(updated, assignee.trim(), previous);
|
||||
this.notifier.transitAssigneeAssigned(updated, agent.name, previous);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1186,6 +1193,15 @@ export class ContractClearanceService {
|
||||
const contract = await this.contractsService.findById(contractId);
|
||||
this.assertPhasedCustoms(contract);
|
||||
await this.ensureDeclarationPrerequisites(contractId, contract);
|
||||
// The draft-declaration accept/change-request loop only exists on the
|
||||
// booking-scoped clearance page (portal customers never see contract-scoped
|
||||
// clearance) — skip it here so it can never block the ONE_TIME pre-booking
|
||||
// flow, which has no UI to complete it. Contracts seeded before this step
|
||||
// existed have no such row to skip — ignore, `assertPriorComplete` below
|
||||
// already tolerates a missing milestone as "not required".
|
||||
await this.workflowService
|
||||
.skipMilestones(contractId, ['DRAFT_DECLARATION_UPLOADED', 'DRAFT_DECLARATION_ACCEPTED'])
|
||||
.catch(() => undefined);
|
||||
await this.workflowService.assertPriorComplete(
|
||||
contractId,
|
||||
contract.tradeDirection,
|
||||
@@ -1215,12 +1231,6 @@ export class ContractClearanceService {
|
||||
});
|
||||
}
|
||||
|
||||
// Export: the declaration is the last GL ET pre-booking action — release
|
||||
// immediately so booking creation unlocks without a separate confirm click.
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
await this.workflowService.onExportReleased(contractId, userId);
|
||||
}
|
||||
|
||||
return this.contractsService.findById(contractId);
|
||||
}
|
||||
|
||||
@@ -1561,6 +1571,10 @@ export class ContractClearanceService {
|
||||
currentPhase: ContractDocPhase.GlEtOutput,
|
||||
});
|
||||
await this.workflowService.completeMilestone(contractId, 'RELEASE_ORDER_SECURED', userId);
|
||||
// Release Order is now the last GL DJ pre-booking action (it follows the
|
||||
// declaration) — release immediately so booking creation unlocks without a
|
||||
// separate confirm click.
|
||||
await this.workflowService.onExportReleased(contractId, userId);
|
||||
|
||||
return { contract: await this.contractsService.findById(contractId), hold: false };
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('ContractClearanceService — duty dispute', () => {
|
||||
{} as never, // dropdownSettingsService
|
||||
{} as never, // glOperationsService
|
||||
notifier as never,
|
||||
{} as never, // transitAgentsService
|
||||
);
|
||||
build([
|
||||
milestone('DUTY_TAXES_ADVISED', 'COMPLETED'),
|
||||
|
||||
@@ -837,16 +837,16 @@ export class ContractsController {
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'GL Djibouti names the transit officer (free text) — unblocks the customs declaration; calling again reassigns',
|
||||
'GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns',
|
||||
})
|
||||
assignTransitAssignee(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('assignee') assignee: string,
|
||||
@Body('transitAgentId', ParseUUIDPipe) transitAgentId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.clearanceService.assignTransitAssignee(
|
||||
id,
|
||||
assignee,
|
||||
transitAgentId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
@@ -1294,6 +1294,20 @@ export class ContractsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice/approve')
|
||||
@ApiOperation({
|
||||
summary: 'Customer approves the drafted final invoice — unlocks the payment slip',
|
||||
})
|
||||
approveFinalInvoice(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.approveFinalInvoice(
|
||||
bookingId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice-slip')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo
|
||||
import { BookingsModule } from '../bookings/bookings.module';
|
||||
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
|
||||
import { ContractTemplatesModule } from '../contract-templates/contract-templates.module';
|
||||
import { TransitAgentsModule } from '../transit-agents/transit-agents.module';
|
||||
|
||||
import { ContractsController } from './contracts.controller';
|
||||
import { ContractsService } from './contracts.service';
|
||||
@@ -31,6 +32,8 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ContractBookingService } from './contract-booking.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { GlExchangeController } from './gl-exchange.controller';
|
||||
import { GlExchangeService } from './gl-exchange.service';
|
||||
import { BookingRequestService } from './booking-request.service';
|
||||
import { BookingRequestRepository } from './booking-request.repository';
|
||||
|
||||
@@ -89,6 +92,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
// Provides the admin-editable contract document templates consumed by
|
||||
// ContractDocumentViewModelBuilder when rendering contract PDFs.
|
||||
ContractTemplatesModule,
|
||||
TransitAgentsModule,
|
||||
// BookingsModule provides BookingsRepository/BookingPricingService used by the
|
||||
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
|
||||
forwardRef(() => BookingsModule),
|
||||
@@ -102,7 +106,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
config.get<ExchangeOptions>('app.cbeExchange') ?? {},
|
||||
}),
|
||||
],
|
||||
controllers: [ContractsController],
|
||||
controllers: [ContractsController, GlExchangeController],
|
||||
providers: [
|
||||
ContractsService,
|
||||
ContractsRepository,
|
||||
@@ -117,6 +121,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlOperationsService,
|
||||
GlExchangeService,
|
||||
BookingRequestService,
|
||||
BookingRequestRepository,
|
||||
// Contract PDF providers (template resolution + render + PDF) — stateless
|
||||
@@ -136,6 +141,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
|
||||
BookingClearanceService,
|
||||
ContractBookingService,
|
||||
ClearanceMilestoneService,
|
||||
GlExchangeService,
|
||||
],
|
||||
})
|
||||
export class ContractsModule {}
|
||||
|
||||
@@ -46,6 +46,9 @@ export interface MilestoneMetadata {
|
||||
declarationSerial?: string;
|
||||
/** When the gate pass was physically granted (GL DJ captures the time). */
|
||||
gatepassAt?: string;
|
||||
/** DRAFT_DECLARATION_UPLOADED → the estimated price GL sent the customer. */
|
||||
draftDeclarationPrice?: number;
|
||||
draftDeclarationCurrency?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
|
||||
/**
|
||||
* The GL Djibouti final invoice lands as a DRAFT: the customer must approve it
|
||||
* (which issues it) before a payment slip is accepted.
|
||||
*/
|
||||
describe('GlOperationsService — final invoice approval', () => {
|
||||
const invoice = (status: Freight.InvoiceStatus, issuedAt: Date | null = null) => ({
|
||||
id: 'inv-1',
|
||||
invoiceNumber: 'INV-1',
|
||||
status,
|
||||
totalAmount: 1500,
|
||||
currency: 'ETB',
|
||||
issuedAt,
|
||||
paidAt: null,
|
||||
});
|
||||
|
||||
let billingService: { findInvoice: jest.Mock; updateStatus: jest.Mock };
|
||||
let filesService: { findByResource: jest.Mock; upsertByCode: jest.Mock };
|
||||
let notifier: { finalInvoiceApprovedToStaff: jest.Mock; dutySlipUploadedToStaff: jest.Mock };
|
||||
let service: GlOperationsService;
|
||||
|
||||
beforeEach(() => {
|
||||
billingService = {
|
||||
findInvoice: jest.fn(),
|
||||
updateStatus: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
filesService = {
|
||||
findByResource: jest.fn().mockResolvedValue([]),
|
||||
upsertByCode: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
notifier = {
|
||||
finalInvoiceApprovedToStaff: jest.fn(),
|
||||
dutySlipUploadedToStaff: jest.fn(),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: (entity: unknown) =>
|
||||
entity === Booking
|
||||
? { findOne: jest.fn().mockResolvedValue({ id: 'bk-1', reference: 'BKG-1' }) }
|
||||
: { findOne: jest.fn().mockResolvedValue({ description: 'Post-offload charges' }) },
|
||||
};
|
||||
|
||||
service = new GlOperationsService(
|
||||
dataSource as never,
|
||||
filesService as never,
|
||||
{} as never, // milestoneService
|
||||
billingService as never,
|
||||
notifier as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('issues the draft on customer approval and reports approvedAt', async () => {
|
||||
const issued = new Date('2026-07-28T09:00:00.000Z');
|
||||
billingService.findInvoice
|
||||
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Draft))
|
||||
.mockResolvedValueOnce(invoice(Freight.InvoiceStatus.Issued, issued));
|
||||
|
||||
const summary = await service.approveFinalInvoice('bk-1', 'user-1');
|
||||
|
||||
expect(billingService.updateStatus).toHaveBeenCalledWith(
|
||||
'inv-1',
|
||||
Freight.InvoiceStatus.Issued,
|
||||
);
|
||||
expect(notifier.finalInvoiceApprovedToStaff).toHaveBeenCalled();
|
||||
expect(summary.approvedAt).toBe(issued.toISOString());
|
||||
});
|
||||
|
||||
it('is a no-op when the invoice was already approved', async () => {
|
||||
billingService.findInvoice.mockResolvedValue(
|
||||
invoice(Freight.InvoiceStatus.Issued, new Date()),
|
||||
);
|
||||
|
||||
await service.approveFinalInvoice('bk-1');
|
||||
|
||||
expect(billingService.updateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a payment slip while the invoice is still a draft', async () => {
|
||||
billingService.findInvoice.mockResolvedValue(invoice(Freight.InvoiceStatus.Draft));
|
||||
|
||||
await expect(
|
||||
service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(filesService.upsertByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts the payment slip once approved', async () => {
|
||||
billingService.findInvoice.mockResolvedValue(
|
||||
invoice(Freight.InvoiceStatus.Issued, new Date()),
|
||||
);
|
||||
|
||||
await service.uploadFinalInvoiceSlip('bk-1', { originalname: 'slip.pdf' } as never);
|
||||
|
||||
expect(filesService.upsertByCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ code: 'final_invoice_slip' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { hasFreightPermission } from '../../common/freight-permission.util';
|
||||
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
|
||||
|
||||
import {
|
||||
GlExchangeService,
|
||||
type GlExchangeActor,
|
||||
type GlExchangeSide,
|
||||
} from './gl-exchange.service';
|
||||
|
||||
/** Either GL desk may read and post; ownership decides who may edit. */
|
||||
const GL_EXCHANGE_PERMS = [
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
];
|
||||
|
||||
/** Multipart bodies arrive as strings — "true"/"1" mean checked. */
|
||||
const asBool = (raw: string | boolean | undefined): boolean =>
|
||||
raw === true || raw === 'true' || raw === '1';
|
||||
|
||||
@ApiTags('gl-exchange')
|
||||
@ApiBearerAuth()
|
||||
@Controller('gl-exchange')
|
||||
export class GlExchangeController {
|
||||
constructor(private readonly exchangeService: GlExchangeService) {}
|
||||
|
||||
@Get(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@ApiOperation({
|
||||
summary: 'GL ET ↔ GL DJ shared documents for a booking or contract',
|
||||
})
|
||||
list(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.exchangeService.list(entityId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post(':entityId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Share a document with the other GL desk' })
|
||||
upload(
|
||||
@Param('entityId', ParseUUIDPipe) entityId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body('title') title: string,
|
||||
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.exchangeService.upload(
|
||||
entityId,
|
||||
file,
|
||||
{ title, visibleToCustomer: asBool(visibleToCustomer) },
|
||||
this.actor(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('documents/:documentId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({
|
||||
summary: 'Uploader edits a shared document (title, visibility, file)',
|
||||
})
|
||||
update(
|
||||
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body('title') title: string | undefined,
|
||||
@Body('visibleToCustomer') visibleToCustomer: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.exchangeService.update(
|
||||
documentId,
|
||||
{
|
||||
title,
|
||||
visibleToCustomer:
|
||||
visibleToCustomer == null ? undefined : asBool(visibleToCustomer),
|
||||
},
|
||||
file,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('documents/:documentId')
|
||||
@BookingStaff(GL_EXCHANGE_PERMS)
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Uploader removes a shared document' })
|
||||
async remove(
|
||||
@Param('documentId', ParseUUIDPipe) documentId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.exchangeService.remove(documentId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Which desk is posting. A user holding only the Djibouti actions permission
|
||||
* is Djibouti; everyone else (GL Ethiopia, and super admins who hold both)
|
||||
* posts as Ethiopia.
|
||||
*/
|
||||
private actor(user: TCurrentUser): GlExchangeActor {
|
||||
const side: GlExchangeSide =
|
||||
!hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) &&
|
||||
hasFreightPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
? 'DJ'
|
||||
: 'ET';
|
||||
return {
|
||||
userId: resolveAuthUserId(user),
|
||||
name: actorLabel(user) ?? null,
|
||||
side,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { Freight } from '@edr/types';
|
||||
|
||||
import { FilesService } from '../files/files.service';
|
||||
import type { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
/**
|
||||
* `files.resource` of the GL Ethiopia ↔ GL Djibouti document exchange. The
|
||||
* thread is keyed by the entity the two desks are working on — a booking id on
|
||||
* the per-booking clearance pages, a contract id on the pre-booking ones — so
|
||||
* both desks opening the same record see the same documents.
|
||||
*/
|
||||
export const GL_EXCHANGE_RESOURCE = 'gl_exchange';
|
||||
|
||||
export type GlExchangeSide = 'ET' | 'DJ';
|
||||
|
||||
export interface GlExchangeActor {
|
||||
userId: string;
|
||||
name?: string | null;
|
||||
side: GlExchangeSide;
|
||||
}
|
||||
|
||||
export interface GlExchangeUploadInput {
|
||||
title: string;
|
||||
visibleToCustomer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Free-form document exchange between the two Global Logistics desks. Anything
|
||||
* either side needs the other to have (scans, correspondence, corrected forms)
|
||||
* lands here under a title they choose, instead of a fixed clearance slot.
|
||||
*
|
||||
* Rules, all enforced here rather than in the UI:
|
||||
* - both desks read every document in a thread, whoever uploaded it;
|
||||
* - only the uploader may retitle, replace or remove one;
|
||||
* - the customer sees only what its uploader marked visible.
|
||||
*/
|
||||
@Injectable()
|
||||
export class GlExchangeService {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
/** Every document on one thread, newest first, from a GL desk's view. */
|
||||
async list(
|
||||
entityId: string,
|
||||
viewerId: string,
|
||||
): Promise<Freight.GlExchangeDocument[]> {
|
||||
const records = await this.filesService.findByResource(
|
||||
entityId,
|
||||
GL_EXCHANGE_RESOURCE,
|
||||
);
|
||||
return this.sort(records.map((r) => this.toDto(r, viewerId)));
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer-facing slice across several threads (a booking and the
|
||||
* contract it belongs to). Never exposes internal documents, and never marks
|
||||
* anything editable — the customer is not a GL desk.
|
||||
*/
|
||||
async listVisibleToCustomer(
|
||||
entityIds: string[],
|
||||
): Promise<Freight.GlExchangeDocument[]> {
|
||||
const ids = [...new Set(entityIds.filter(Boolean))];
|
||||
if (ids.length === 0) return [];
|
||||
const grouped = await this.filesService.findByResourceIdsGrouped(
|
||||
ids,
|
||||
GL_EXCHANGE_RESOURCE,
|
||||
);
|
||||
const visible = [...grouped.values()]
|
||||
.flat()
|
||||
.filter((r) => r.visibleToCustomer);
|
||||
return this.sort(visible.map((r) => this.toDto(r, null)));
|
||||
}
|
||||
|
||||
async upload(
|
||||
entityId: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
input: GlExchangeUploadInput,
|
||||
actor: GlExchangeActor,
|
||||
): Promise<Freight.GlExchangeDocument> {
|
||||
const title = input.title?.trim();
|
||||
if (!title) throw new BadRequestException('A document title is required.');
|
||||
if (!file) throw new BadRequestException('A file is required.');
|
||||
|
||||
const record = await this.filesService.upload({
|
||||
resourceId: entityId,
|
||||
resource: GL_EXCHANGE_RESOURCE,
|
||||
// No fixed slot exists for these — `code` carries the uploading desk, so
|
||||
// a document's origin survives even if the uploader leaves the org.
|
||||
code: actor.side,
|
||||
file,
|
||||
title,
|
||||
visibleToCustomer: input.visibleToCustomer,
|
||||
uploadedByUserId: actor.userId,
|
||||
uploadedByName: actor.name ?? null,
|
||||
});
|
||||
return this.toDto(record, actor.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retitle, re-share or replace a document. Uploader only — the other desk
|
||||
* reads it but never edits it. A replacement file supersedes the old record
|
||||
* (soft-deleted, bytes kept) and carries its metadata forward.
|
||||
*/
|
||||
async update(
|
||||
documentId: string,
|
||||
patch: { title?: string; visibleToCustomer?: boolean },
|
||||
file: Express.Multer.File | undefined,
|
||||
actorId: string,
|
||||
): Promise<Freight.GlExchangeDocument> {
|
||||
const record = await this.assertUploader(documentId, actorId);
|
||||
const title = patch.title?.trim();
|
||||
if (patch.title != null && !title) {
|
||||
throw new BadRequestException('A document title is required.');
|
||||
}
|
||||
|
||||
if (file) {
|
||||
const replacement = await this.filesService.upload({
|
||||
resourceId: record.resourceId,
|
||||
resource: GL_EXCHANGE_RESOURCE,
|
||||
code: record.code,
|
||||
file,
|
||||
title: title ?? record.title,
|
||||
visibleToCustomer: patch.visibleToCustomer ?? record.visibleToCustomer,
|
||||
uploadedByUserId: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
});
|
||||
await this.filesService.remove(record.id);
|
||||
return this.toDto(replacement, actorId);
|
||||
}
|
||||
|
||||
const updated = await this.filesService.updateMeta(record.id, {
|
||||
...(title ? { title } : {}),
|
||||
...(patch.visibleToCustomer != null
|
||||
? { visibleToCustomer: patch.visibleToCustomer }
|
||||
: {}),
|
||||
});
|
||||
return this.toDto(updated, actorId);
|
||||
}
|
||||
|
||||
/** Uploader-only removal (soft delete — the stored bytes are kept). */
|
||||
async remove(documentId: string, actorId: string): Promise<void> {
|
||||
const record = await this.assertUploader(documentId, actorId);
|
||||
await this.filesService.remove(record.id);
|
||||
}
|
||||
|
||||
private async assertUploader(
|
||||
documentId: string,
|
||||
actorId: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.filesService.findById(documentId);
|
||||
if (record.resource !== GL_EXCHANGE_RESOURCE) {
|
||||
throw new NotFoundException(`Exchange document ${documentId} not found`);
|
||||
}
|
||||
if (record.uploadedByUserId !== actorId) {
|
||||
throw new ForbiddenException(
|
||||
'Only the person who uploaded this document can change it.',
|
||||
);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
private sort(
|
||||
docs: Freight.GlExchangeDocument[],
|
||||
): Freight.GlExchangeDocument[] {
|
||||
return docs.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt));
|
||||
}
|
||||
|
||||
private toDto(
|
||||
record: FileRecord,
|
||||
viewerId: string | null,
|
||||
): Freight.GlExchangeDocument {
|
||||
return {
|
||||
id: record.id,
|
||||
entityId: record.resourceId,
|
||||
// Pre-title rows (none in practice) fall back to the filename so a list
|
||||
// never renders a blank row.
|
||||
title: record.title ?? record.name,
|
||||
side: record.code === 'DJ' ? 'DJ' : 'ET',
|
||||
visibleToCustomer: record.visibleToCustomer,
|
||||
uploadedById: record.uploadedByUserId,
|
||||
uploadedByName: record.uploadedByName,
|
||||
uploadedAt: record.createdAt.toISOString(),
|
||||
file: {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
url: record.url,
|
||||
size: record.size,
|
||||
mimeType: record.mimeType,
|
||||
},
|
||||
canEdit: viewerId != null && record.uploadedByUserId === viewerId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -282,6 +282,85 @@ export class GlOperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Offload facts for a booking, read-only: what came off the train at its
|
||||
* destination (containers, wagons, tonnes) and where the goods went. Sourced
|
||||
* from the booking's warehouse-inventory row — written by the auto-unload
|
||||
* that runs on train arrival for both directions.
|
||||
*/
|
||||
async offloadState(
|
||||
bookingId: string,
|
||||
milestones: Array<{ milestoneCode: string; status: string; triggeredAt?: Date | null }>,
|
||||
): Promise<Freight.ClearanceOffloadState> {
|
||||
const [row]: Array<{
|
||||
destination: string | null;
|
||||
containers: number;
|
||||
wagons: number;
|
||||
bookedWeight: string | null;
|
||||
inventoryStatus: string | null;
|
||||
unloadedAt: Date | null;
|
||||
grnNumber: string | null;
|
||||
offloadedWeight: string | null;
|
||||
warehouse: string | null;
|
||||
warehouseYard: string | null;
|
||||
zone: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT COALESCE(dy.label, dy.code) AS "destination",
|
||||
(SELECT COUNT(*)::int
|
||||
FROM freight.booking_container bc
|
||||
JOIN freight.booking_container_units bcu
|
||||
ON bcu.booking_container_id = bc.id AND bcu.deleted_at IS NULL
|
||||
WHERE bc.booking_id = b.id AND bc.deleted_at IS NULL) AS "containers",
|
||||
(SELECT COUNT(*)::int
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
WHERE wba.booking_id = b.id AND wba.deleted_at IS NULL) AS "wagons",
|
||||
b.cargo_total_weight_vgm AS "bookedWeight",
|
||||
inv.status AS "inventoryStatus",
|
||||
inv.unloaded_at AS "unloadedAt",
|
||||
inv.grn_number AS "grnNumber",
|
||||
inv.weight AS "offloadedWeight",
|
||||
wh.name AS "warehouse",
|
||||
wy.name AS "warehouseYard",
|
||||
wz.name AS "zone"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT i.*
|
||||
FROM freight.warehouse_inventory i
|
||||
WHERE i.booking_id = b.id AND i.deleted_at IS NULL
|
||||
ORDER BY i.unloaded_at DESC NULLS LAST, i.created_at DESC
|
||||
LIMIT 1
|
||||
) inv ON TRUE
|
||||
LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id
|
||||
LEFT JOIN freight.warehouse_yards wy ON wy.id = inv.yard_id
|
||||
LEFT JOIN freight.warehouse_zones wz ON wz.id = inv.zone_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
|
||||
const milestone = milestones.find((m) => m.milestoneCode === 'OFFLOADED');
|
||||
const offloadedAt =
|
||||
milestone?.status === 'COMPLETED' && milestone.triggeredAt
|
||||
? new Date(milestone.triggeredAt).toISOString()
|
||||
: (row?.unloadedAt ? new Date(row.unloadedAt).toISOString() : null);
|
||||
// The warehouse records the real offloaded tonnage; before it does, the
|
||||
// booked VGM is the best number we have.
|
||||
const weight = Number(row?.offloadedWeight ?? 0) || Number(row?.bookedWeight ?? 0);
|
||||
const location = [row?.warehouse, row?.warehouseYard, row?.zone].filter(Boolean).join(' › ');
|
||||
|
||||
return {
|
||||
offloaded: milestone?.status === 'COMPLETED' || Boolean(row?.unloadedAt),
|
||||
offloadedAt,
|
||||
destination: row?.destination ?? null,
|
||||
containers: row?.containers ?? 0,
|
||||
wagons: row?.wagons ?? 0,
|
||||
weightTons: weight || null,
|
||||
grnNumber: row?.grnNumber ?? null,
|
||||
location: location || null,
|
||||
inventoryStatus: row?.inventoryStatus ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||||
* is secured on the train schedule (which itself follows wagon allocation).
|
||||
@@ -381,8 +460,9 @@ export class GlOperationsService {
|
||||
|
||||
/**
|
||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||
* attached invoice document. The customer pays offline and attaches a slip;
|
||||
* GL (ET or DJ) then confirms to settle it.
|
||||
* attached invoice document. It is issued as a DRAFT the customer must approve
|
||||
* first; only then do they pay offline and attach a slip, and GL (ET or DJ)
|
||||
* confirms to settle it.
|
||||
*/
|
||||
async createFinalInvoice(
|
||||
bookingId: string,
|
||||
@@ -445,7 +525,8 @@ export class GlOperationsService {
|
||||
amount: input.amount,
|
||||
},
|
||||
],
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
// DRAFT until the customer approves it — approveFinalInvoice issues it.
|
||||
status: Freight.InvoiceStatus.Draft,
|
||||
});
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
@@ -467,6 +548,40 @@ export class GlOperationsService {
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer approves the drafted final invoice — issues it, which is what
|
||||
* unlocks the payment slip upload. Idempotent: approving twice is a no-op.
|
||||
*/
|
||||
async approveFinalInvoice(
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
const invoice = await this.billingService.findInvoice(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
GL_FINAL_INVOICE_TYPE,
|
||||
);
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been raised for this shipment.');
|
||||
}
|
||||
if (
|
||||
invoice.status === Freight.InvoiceStatus.Cancelled ||
|
||||
invoice.status === Freight.InvoiceStatus.Expired
|
||||
) {
|
||||
throw new BadRequestException('The final invoice is no longer payable.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
await this.billingService.updateStatus(invoice.id, Freight.InvoiceStatus.Issued);
|
||||
this.notifier.finalInvoiceApprovedToStaff(booking);
|
||||
}
|
||||
|
||||
void userId;
|
||||
const summary = await this.finalInvoiceSummary(bookingId);
|
||||
if (!summary) throw new NotFoundException('Final invoice not found.');
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** Customer attaches the payment slip for the final invoice. */
|
||||
async uploadFinalInvoiceSlip(
|
||||
bookingId: string,
|
||||
@@ -483,6 +598,11 @@ export class GlOperationsService {
|
||||
if (!invoice) {
|
||||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Draft) {
|
||||
throw new BadRequestException(
|
||||
'Approve the final invoice before attaching a payment slip.',
|
||||
);
|
||||
}
|
||||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||||
throw new BadRequestException('The final invoice is already paid.');
|
||||
}
|
||||
@@ -688,6 +808,8 @@ export class GlOperationsService {
|
||||
description: line?.description ?? null,
|
||||
invoiceFile: toRef('final_invoice'),
|
||||
slipFile: toRef('final_invoice_slip'),
|
||||
// Issuing IS the customer approval (createFinalInvoice leaves it DRAFT).
|
||||
approvedAt: invoice.issuedAt ? new Date(invoice.issuedAt).toISOString() : null,
|
||||
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
catalogEntriesForTradeDirection,
|
||||
declarationFileLabel,
|
||||
draftDeclarationFileLabel,
|
||||
isDeclarationFileCode,
|
||||
isDraftDeclarationFileCode,
|
||||
isImportTransitPermitFileCode,
|
||||
isExportTransportFileCode,
|
||||
isT1TransportFileCode,
|
||||
@@ -72,6 +74,52 @@ export async function persistDeclarationUploads(
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one draft declaration file in the upload batch. */
|
||||
export function assertDraftDeclarationFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
throw new BadRequestException('No draft declaration documents uploaded');
|
||||
}
|
||||
}
|
||||
|
||||
/** Assign stable `draft_declaration_*` codes so multi-file uploads always pass validation. */
|
||||
export function normalizeDraftDeclarationFieldNames(
|
||||
files: Express.Multer.File[],
|
||||
): Express.Multer.File[] {
|
||||
return files.map((file, index) => ({
|
||||
...file,
|
||||
fieldname: `draft_declaration_${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Replace all draft declaration files on a resource with a new multi-file upload batch. */
|
||||
export async function persistDraftDeclarationUploads(
|
||||
store: DeclarationFileStore,
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<void> {
|
||||
const normalized = normalizeDraftDeclarationFieldNames(files);
|
||||
assertDraftDeclarationFiles(normalized);
|
||||
|
||||
const existing = await store.findByResource(resourceId, resource);
|
||||
await Promise.all(
|
||||
existing
|
||||
.filter((f) => f.code && isDraftDeclarationFileCode(f.code))
|
||||
.map((f) => store.deleteByCode(resourceId, resource, f.code!)),
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
normalized.map((file, index) =>
|
||||
store.upload({
|
||||
resourceId,
|
||||
resource,
|
||||
code: `draft_declaration_${index}`,
|
||||
file,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Require at least one transit permit file in the upload batch. */
|
||||
export function assertTransitPermitFiles(files: Express.Multer.File[]): void {
|
||||
if (files.length === 0) {
|
||||
@@ -341,6 +389,22 @@ export function buildWorkflowFiles(
|
||||
});
|
||||
});
|
||||
|
||||
const extraDraftDeclarations = files
|
||||
.filter((f) => f.code && isDraftDeclarationFileCode(f.code) && !included.has(f.code))
|
||||
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
|
||||
|
||||
extraDraftDeclarations.forEach((file, index) => {
|
||||
if (!file.code) return;
|
||||
included.add(file.code);
|
||||
out.push({
|
||||
code: file.code,
|
||||
label: draftDeclarationFileLabel(index),
|
||||
uploadedBy: 'gl_et',
|
||||
category: 'draft_declaration',
|
||||
file: { id: file.id, name: file.name, url: file.url },
|
||||
});
|
||||
});
|
||||
|
||||
if (tradeDirection === 'IMPORT') {
|
||||
const extraTransit = files
|
||||
.filter((f) => f.code && isImportTransitPermitFileCode(f.code) && !included.has(f.code))
|
||||
|
||||
@@ -26,6 +26,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
transitAssigneeRequested: jest.Mock;
|
||||
transitAssigneeAssigned: jest.Mock;
|
||||
};
|
||||
let transitAgentsService: { getAssignable: jest.Mock };
|
||||
let service: ContractClearanceService;
|
||||
|
||||
const cycle = (over: Record<string, unknown> = {}) => ({
|
||||
@@ -45,6 +46,9 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
transitAssigneeRequested: jest.fn(),
|
||||
transitAssigneeAssigned: jest.fn(),
|
||||
};
|
||||
transitAgentsService = {
|
||||
getAssignable: jest.fn().mockResolvedValue({ id: 'agent-1', name: 'Ahmed Bourhan' }),
|
||||
};
|
||||
service = new ContractClearanceService(
|
||||
repo as never,
|
||||
contractsService as never,
|
||||
@@ -56,6 +60,7 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
notifier as never,
|
||||
transitAgentsService as never,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -79,8 +84,9 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||
);
|
||||
|
||||
await service.assignTransitAssignee('ctr-1', ' Ahmed Bourhan ', 'dj-1');
|
||||
await service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1');
|
||||
|
||||
expect(transitAgentsService.getAssignable).toHaveBeenCalledWith('agent-1');
|
||||
const patch = repo.updateCycle.mock.calls[0][1];
|
||||
expect(patch.transitAssigneeName).toBe('Ahmed Bourhan');
|
||||
expect(patch.transitAssigneeAssignedByUserId).toBe('dj-1');
|
||||
@@ -98,8 +104,12 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
transitAssigneeName: 'Ahmed Bourhan',
|
||||
}),
|
||||
);
|
||||
transitAgentsService.getAssignable.mockResolvedValue({
|
||||
id: 'agent-2',
|
||||
name: 'Fatouma Ali',
|
||||
});
|
||||
|
||||
await service.assignTransitAssignee('ctr-1', 'Fatouma Ali', 'dj-1');
|
||||
await service.assignTransitAssignee('ctr-1', 'agent-2', 'dj-1');
|
||||
|
||||
expect(notifier.transitAssigneeAssigned).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
@@ -108,19 +118,23 @@ describe('ContractClearanceService — transit assignee', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses an empty name', async () => {
|
||||
it('refuses a suspended or out-of-window agent', async () => {
|
||||
repo.currentCycle.mockResolvedValue(
|
||||
cycle({ transitAssigneeRequestedAt: new Date() }),
|
||||
);
|
||||
transitAgentsService.getAssignable.mockRejectedValue(
|
||||
new BadRequestException('suspended'),
|
||||
);
|
||||
await expect(
|
||||
service.assignTransitAssignee('ctr-1', ' ', 'dj-1'),
|
||||
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses before Ethiopia has asked', async () => {
|
||||
await expect(
|
||||
service.assignTransitAssignee('ctr-1', 'Ahmed Bourhan', 'dj-1'),
|
||||
service.assignTransitAssignee('ctr-1', 'agent-1', 'dj-1'),
|
||||
).rejects.toThrow(/not requested/i);
|
||||
expect(transitAgentsService.getAssignable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -65,4 +65,29 @@ export class FileRecord extends BaseEntity {
|
||||
/** Why the file was replaced — shown on the document's version history. */
|
||||
@Column({ name: "replace_reason", type: "text", nullable: true })
|
||||
replaceReason!: string | null;
|
||||
|
||||
/**
|
||||
* Free-text label chosen by the uploader, when the document has no fixed slot
|
||||
* (`code`) to name it — the GL Ethiopia ↔ GL Djibouti exchange. Null for every
|
||||
* catalog-driven upload, whose label comes from its code.
|
||||
*/
|
||||
@Column({ name: "title", type: "varchar", length: 300, nullable: true })
|
||||
title!: string | null;
|
||||
|
||||
/** Uploader's choice to share the document with the customer's portal. */
|
||||
@Column({ name: "visible_to_customer", type: "boolean", default: false })
|
||||
visibleToCustomer!: boolean;
|
||||
|
||||
/** Who uploaded it — the only user allowed to edit or remove it afterwards. */
|
||||
@Column({ name: "uploaded_by_user_id", type: "uuid", nullable: true })
|
||||
uploadedByUserId!: string | null;
|
||||
|
||||
/** Uploader's display name, resolved once so lists need no IAM lookup. */
|
||||
@Column({
|
||||
name: "uploaded_by_name",
|
||||
type: "varchar",
|
||||
length: 200,
|
||||
nullable: true,
|
||||
})
|
||||
uploadedByName!: string | null;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ export interface CreateFileInput {
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
/** Optional metadata for free-form uploads (GL exchange) — see FileRecord. */
|
||||
title?: string | null;
|
||||
visibleToCustomer?: boolean;
|
||||
uploadedByUserId?: string | null;
|
||||
uploadedByName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,9 +106,27 @@ export class FilesService {
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
title: input.title ?? null,
|
||||
visibleToCustomer: input.visibleToCustomer ?? false,
|
||||
uploadedByUserId: input.uploadedByUserId ?? null,
|
||||
uploadedByName: input.uploadedByName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the uploader-authored metadata of a stored file (title, customer
|
||||
* visibility). Bytes are untouched — callers replacing content upload a new
|
||||
* record instead.
|
||||
*/
|
||||
async updateMeta(
|
||||
id: string,
|
||||
patch: { title?: string; visibleToCustomer?: boolean },
|
||||
): Promise<FileRecord> {
|
||||
const updated = await this.filesRepository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`File ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the file stored under a resource + code (e.g. contract PDF). The
|
||||
* previous version is retired, not destroyed — pass `replacedBy` to record who
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDateString, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
return value;
|
||||
};
|
||||
|
||||
export class CreateTransitAgentDto {
|
||||
@ApiProperty({ maxLength: 150, example: 'Ahmed Bourhan' })
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-01-01' })
|
||||
@IsDateString()
|
||||
validFrom!: string;
|
||||
|
||||
@ApiProperty({ example: '2026-12-31' })
|
||||
@IsDateString()
|
||||
validTo!: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
|
||||
import { CreateTransitAgentDto } from './create-transit-agent.dto';
|
||||
|
||||
export class UpdateTransitAgentDto extends PartialType(CreateTransitAgentDto) {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Djibouti transit officer GL Djibouti may assign against a shipment's
|
||||
* transit-assignee handshake. Admin-managed so the roster and each officer's
|
||||
* validity window arrive without a code change; `isActive` is the manual
|
||||
* suspend/reactivate switch, independent of the validity window.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'transit_agents' })
|
||||
@Index(['isActive'])
|
||||
export class TransitAgent extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 150 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'valid_from', type: 'date' })
|
||||
validFrom!: string;
|
||||
|
||||
@Column({ name: 'valid_to', type: 'date' })
|
||||
validTo!: string;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
RuleEngineCreate,
|
||||
RuleEngineDelete,
|
||||
RuleEngineUpdate,
|
||||
RuleEngineView,
|
||||
} from '../../common/rule-engine-guards';
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
|
||||
@ApiTags('transit-agents')
|
||||
@Controller('transit-agents')
|
||||
@ApiBearerAuth()
|
||||
export class TransitAgentsController {
|
||||
constructor(private readonly transitAgentsService: TransitAgentsService) {}
|
||||
|
||||
@Get()
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents' })
|
||||
findAll(@Query() query: Record<string, string | undefined>) {
|
||||
return this.transitAgentsService.findAll({
|
||||
isActive:
|
||||
query.isActive === 'all'
|
||||
? undefined
|
||||
: query.isActive !== undefined
|
||||
? query.isActive === 'true'
|
||||
: undefined,
|
||||
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||
sortBy: query.sortBy,
|
||||
sortOrder: query.sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
/** Active + currently valid officers — the transit-assignee assignment dropdown. */
|
||||
@Get('assignable')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'List transit agents assignable right now (active and in-window)' })
|
||||
findAssignable() {
|
||||
return this.transitAgentsService.findAssignable();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RuleEngineView('transit-agents')
|
||||
@ApiOperation({ summary: 'Get a transit agent by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.findById(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineCreate('transit-agents')
|
||||
@ApiOperation({ summary: 'Create a transit agent' })
|
||||
create(@Body() dto: CreateTransitAgentDto) {
|
||||
return this.transitAgentsService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineUpdate('transit-agents')
|
||||
@ApiOperation({ summary: 'Update a transit agent' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateTransitAgentDto) {
|
||||
return this.transitAgentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineDelete('transit-agents')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Soft-delete a transit agent' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.transitAgentsService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsController } from './transit-agents.controller';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
import { TransitAgentsService } from './transit-agents.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([TransitAgent])],
|
||||
controllers: [TransitAgentsController],
|
||||
providers: [TransitAgentsRepository, TransitAgentsService],
|
||||
exports: [TransitAgentsRepository, TransitAgentsService],
|
||||
})
|
||||
export class TransitAgentsModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsRepository extends BaseRepository<TransitAgent> {
|
||||
constructor(
|
||||
@InjectRepository(TransitAgent)
|
||||
repository: Repository<TransitAgent>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Active AND currently inside its validity window (today's date, server-side). */
|
||||
findAssignable(today: string): Promise<TransitAgent[]> {
|
||||
return this.repository.find({
|
||||
where: {
|
||||
isActive: true,
|
||||
validFrom: LessThanOrEqual(today),
|
||||
validTo: MoreThanOrEqual(today),
|
||||
},
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindOptionsOrder } from 'typeorm';
|
||||
|
||||
import { CreateTransitAgentDto } from './dto/create-transit-agent.dto';
|
||||
import { UpdateTransitAgentDto } from './dto/update-transit-agent.dto';
|
||||
import { TransitAgent } from './entities/transit-agent.entity';
|
||||
import { TransitAgentsRepository } from './transit-agents.repository';
|
||||
|
||||
export type TransitAgentValidityStatus = 'VALID' | 'NOT_STARTED' | 'EXPIRED';
|
||||
|
||||
export type TransitAgentView = TransitAgent & {
|
||||
validityStatus: TransitAgentValidityStatus;
|
||||
};
|
||||
|
||||
type TransitAgentListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
/** Today as `yyyy-MM-dd`, matching the `date`-typed validity columns. */
|
||||
function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function validityStatus(agent: Pick<TransitAgent, 'validFrom' | 'validTo'>): TransitAgentValidityStatus {
|
||||
const today = todayISODate();
|
||||
if (today < agent.validFrom) return 'NOT_STARTED';
|
||||
if (today > agent.validTo) return 'EXPIRED';
|
||||
return 'VALID';
|
||||
}
|
||||
|
||||
function withValidityStatus(agent: TransitAgent): TransitAgentView {
|
||||
return { ...agent, validityStatus: validityStatus(agent) };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TransitAgentsService {
|
||||
constructor(private readonly transitAgentsRepository: TransitAgentsRepository) {}
|
||||
|
||||
async findAll(filter: TransitAgentListFilter = {}): Promise<{
|
||||
data: TransitAgentView[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['name', 'validFrom', 'validTo', 'isActive'].includes(filter.sortBy ?? '')
|
||||
? (filter.sortBy as keyof TransitAgent)
|
||||
: 'name';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
const [data, total] = await this.transitAgentsRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<TransitAgent>,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
});
|
||||
|
||||
return {
|
||||
data: data.map(withValidityStatus),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Active and currently inside its validity window — the DJ assignment dropdown. */
|
||||
async findAssignable(): Promise<TransitAgent[]> {
|
||||
return this.transitAgentsRepository.findAssignable(todayISODate());
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TransitAgentView> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(agent);
|
||||
}
|
||||
|
||||
/** Used by the assignment flow — rejects a suspended or out-of-window officer. */
|
||||
async getAssignable(id: string): Promise<TransitAgent> {
|
||||
const agent = await this.transitAgentsRepository.findById(id);
|
||||
if (!agent) {
|
||||
throw new BadRequestException('Selected transit officer was not found.');
|
||||
}
|
||||
if (!agent.isActive) {
|
||||
throw new BadRequestException(`${agent.name} is suspended — pick another transit officer.`);
|
||||
}
|
||||
if (validityStatus(agent) !== 'VALID') {
|
||||
throw new BadRequestException(
|
||||
`${agent.name}'s validity window has expired — pick another transit officer or extend their dates.`,
|
||||
);
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
async create(dto: CreateTransitAgentDto): Promise<TransitAgentView> {
|
||||
if (dto.validTo < dto.validFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
}
|
||||
const agent = await this.transitAgentsRepository.create({
|
||||
name: dto.name.trim(),
|
||||
validFrom: dto.validFrom,
|
||||
validTo: dto.validTo,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
return withValidityStatus(agent);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateTransitAgentDto): Promise<TransitAgentView> {
|
||||
const current = await this.findById(id);
|
||||
const nextValidFrom = dto.validFrom ?? current.validFrom;
|
||||
const nextValidTo = dto.validTo ?? current.validTo;
|
||||
if (nextValidTo < nextValidFrom) {
|
||||
throw new BadRequestException('Valid-to date must be on or after valid-from date.');
|
||||
}
|
||||
|
||||
const updated = await this.transitAgentsRepository.update(id, {
|
||||
...dto,
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Transit agent ${id} not found`);
|
||||
}
|
||||
return withValidityStatus(updated);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.transitAgentsRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user