Merge branch 'contrat-backup2' of github.com:Tria-plc/edr-platform into contrat-backup2

This commit is contained in:
marshal
2026-07-02 21:15:45 +03:00
17 changed files with 1084 additions and 11 deletions

View File

@@ -617,12 +617,14 @@ export class BookingsController {
async uploadBookingDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
} from '@edr/types';
@@ -80,6 +81,12 @@ export interface BookingClearanceView {
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
}
@Injectable()
@@ -200,6 +207,8 @@ export class BookingClearanceService {
milestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
return {
bookingId,
@@ -248,6 +257,17 @@ export class BookingClearanceService {
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
: null,
riskAssignedAt:
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
};
}
@@ -497,6 +517,7 @@ export class BookingClearanceService {
bookingId: string,
file: Express.Multer.File,
userId?: string,
vesselDepartureDate?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
@@ -515,6 +536,12 @@ export class BookingClearanceService {
file,
});
if (vesselDepartureDate?.trim()) {
await this.bookingsRepository.update(bookingId, {
vesselDepartureDate: vesselDepartureDate.trim(),
} as never);
}
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForOperation(bookingId);

View File

@@ -39,6 +39,16 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
SECOND_DUTY_ADVISED: {
label: 'Additional Duty and Taxes Advised',
ownerRegion: 'ET',
triggeredByDoc: false,
},
SECOND_DUTY_PAID: {
label: 'Additional Duty and Tax Paid',
ownerRegion: 'CUST',
triggeredByDoc: true,
},
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },

View File

@@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/comm
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
} from '@edr/types';
@@ -94,6 +95,12 @@ export interface ContractClearanceView {
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
}
@Injectable()
@@ -226,8 +233,9 @@ export class ContractClearanceService {
files,
contract.tradeDirection ?? 'IMPORT',
);
let bookingFiles: Awaited<ReturnType<FilesService['findByResource']>> = [];
if (cycle?.bookingId) {
const bookingFiles = await this.filesService.findByResource(
bookingFiles = await this.filesService.findByResource(
cycle.bookingId,
'bookings',
);
@@ -269,6 +277,11 @@ export class ContractClearanceService {
bookingMilestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(
bookingMilestones,
bookingFiles,
);
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
@@ -332,6 +345,17 @@ export class ContractClearanceService {
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
: null,
riskAssignedAt:
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
};
}
@@ -1091,6 +1115,7 @@ export class ContractClearanceService {
contractId: string,
file: Express.Multer.File,
userId?: string,
vesselDepartureDate?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
@@ -1111,6 +1136,11 @@ export class ContractClearanceService {
});
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle && vesselDepartureDate?.trim()) {
await this.contractsRepository.updateCycle(cycle.id, {
vesselDepartureDate: vesselDepartureDate.trim(),
});
}
if (cycle?.preClearanceFinalizedAt) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForBooking(contractId);

View File

@@ -611,9 +611,15 @@ export class ContractsController {
uploadDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
return this.clearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
);
}
@Post(':id/clearance/release-order')
@@ -998,6 +1004,47 @@ export class ContractsController {
);
}
@Post('bookings/:bookingId/second-duty')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor('attachment'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)',
})
adviseSecondDuty(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body('dutyRequired') dutyRequiredRaw: string,
@Body('amount') amountRaw: string | undefined,
@Body('currency') currency: string | undefined,
@Body('declarationSerial') declarationSerial: string | undefined,
@UploadedFile() attachment: Express.Multer.File | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.adviseSecondDuty(
bookingId,
{
dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1',
amount:
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
currency: currency ?? 'ETB',
declarationSerial,
},
attachment,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/second-duty-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
uploadSecondDutySlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.glOperationsService.uploadSecondDutySlip(bookingId, file);
}
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -645,6 +645,129 @@ export class GlOperationsService {
return summary;
}
/**
* GL ET advises (or skips) the post-arrival additional duty/tax round (import).
* Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload.
*/
async adviseSecondDuty(
bookingId: string,
input: {
dutyRequired: boolean;
amount?: number;
currency?: string;
declarationSerial?: string;
},
attachment?: Express.Multer.File,
userId?: string,
): Promise<{ advised: boolean; skipped: boolean }> {
const booking = await this.getBooking(bookingId);
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Additional duty applies to customs bookings only.');
}
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
if (tradeDirection !== 'IMPORT') {
throw new BadRequestException('Additional duty applies to import shipments only.');
}
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection);
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection);
if (!input.dutyRequired) {
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED');
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID');
return { advised: false, skipped: true };
}
if (!input.amount || input.amount <= 0) {
throw new BadRequestException('Duty amount must be greater than zero.');
}
const files = await this.filesService.findByResource(bookingId, 'bookings');
const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2');
if (!attachment && !hasNotice) {
throw new BadRequestException('Attach the additional duty/tax notice.');
}
if (attachment) {
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_notice_2',
file: attachment,
});
}
await this.milestoneService.completeWithMetadataForBooking(
bookingId,
'SECOND_DUTY_ADVISED',
{
dutyAmount: input.amount,
dutyCurrency: input.currency ?? 'ETB',
declarationSerial: input.declarationSerial,
},
userId,
);
return { advised: true, skipped: false };
}
/** Customer attaches the payment slip for the additional duty round. */
async uploadSecondDutySlip(
bookingId: string,
file: Express.Multer.File,
): Promise<{ milestoneCompleted: boolean }> {
const booking = await this.getBooking(bookingId);
if (!file) throw new BadRequestException('No payment slip uploaded');
const milestones = await this.milestoneService.listForBooking(bookingId);
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
if (advised?.status !== 'COMPLETED') {
throw new BadRequestException('No additional duty has been advised for this shipment.');
}
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'duty_tax_receipt_2',
file,
});
await this.milestoneService.ensureForBooking(
bookingId,
'SECOND_DUTY_PAID',
booking.tradeDirection ?? 'IMPORT',
);
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
return { milestoneCompleted: true };
}
/** Second duty round state for clearance views. */
secondDutyState(
milestones: Array<{
milestoneCode: string;
status: string;
metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null;
}>,
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
): Freight.ClearanceSecondDuty | null {
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID');
if (!advised && !paid) return null;
const toRef = (code: string) => {
const f = files.find((x) => x.code === code);
return f ? { id: f.id, name: f.name, url: f.url } : null;
};
return {
advised: advised?.status === 'COMPLETED',
skipped: advised?.status === 'SKIPPED',
amount: advised?.metadata?.dutyAmount ?? null,
currency: advised?.metadata?.dutyCurrency ?? null,
declarationSerial: advised?.metadata?.declarationSerial ?? null,
noticeFile: toRef('duty_tax_notice_2'),
slipFile: toRef('duty_tax_receipt_2'),
paid: paid?.status === 'COMPLETED',
};
}
/** Final-invoice state joined with its document + slip files, for clearance views. */
async finalInvoiceSummary(
bookingId: string,