mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'contrat-backup2' of github.com:Tria-plc/edr-platform into contrat-backup2
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user