mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 13:28:11 +00:00
Implement intercity document handling and rejection notes for contracts
This commit is contained in:
@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
@@ -85,6 +88,8 @@ export interface BookingClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -282,6 +287,12 @@ export class BookingClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
|
||||
// the current one; this is the trail behind it.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
||||
});
|
||||
|
||||
/**
|
||||
* The level is customer-visible and stays correctable until duty is advised,
|
||||
* so a changed level must leave a trail rather than overwrite the last one.
|
||||
*/
|
||||
describe('risk history', () => {
|
||||
it('records the first assignment with no previous level', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedByUserId: 'user-1',
|
||||
assignedBy: 'Abebe K.',
|
||||
note: 'initial rating',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
|
||||
});
|
||||
|
||||
it('keeps the earlier decision when the level is reassigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
|
||||
|
||||
expect(saved.metadata?.riskLevel).toBe('GREEN');
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(2);
|
||||
// The original RED decision survives, with who made it.
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedBy: 'Abebe K.',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
|
||||
level: 'GREEN',
|
||||
previousLevel: 'RED',
|
||||
assignedByUserId: 'user-2',
|
||||
assignedBy: 'Sara M.',
|
||||
note: 'downgraded',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the whole chain across several reassignments, oldest first', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
await service.assignRisk('b-1', 'YELLOW');
|
||||
const saved = await service.assignRisk('b-1', 'RED');
|
||||
|
||||
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
|
||||
'GREEN',
|
||||
'YELLOW',
|
||||
'RED',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not record a repeat of the level already assigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('always leaves riskLevel equal to the last history entry', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED');
|
||||
const saved = await service.assignRisk('b-1', 'YELLOW');
|
||||
|
||||
const history = saved.metadata?.riskHistory ?? [];
|
||||
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,15 +210,50 @@ export class ClearanceMilestoneService {
|
||||
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
||||
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
||||
*
|
||||
* The level stays correctable until duty is advised off it, so each assignment
|
||||
* is appended to `riskHistory` instead of silently replacing the last one — a
|
||||
* customer-visible level that changes needs a trail of who changed it and when.
|
||||
*/
|
||||
async assignRisk(
|
||||
bookingId: string,
|
||||
riskLevel: CustomsRiskLevel,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
actor?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
await this.assertT1Closed(bookingId);
|
||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||||
|
||||
const existing = await this.repo.findOne({
|
||||
where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
|
||||
});
|
||||
const previousLevel = existing?.metadata?.riskLevel;
|
||||
const history = existing?.metadata?.riskHistory ?? [];
|
||||
|
||||
// A repeat of the level already assigned is not a decision — recording it
|
||||
// would pad the trail with entries that changed nothing.
|
||||
const entries =
|
||||
previousLevel === riskLevel
|
||||
? history
|
||||
: [
|
||||
...history,
|
||||
{
|
||||
level: riskLevel,
|
||||
...(previousLevel ? { previousLevel } : {}),
|
||||
assignedAt: new Date().toISOString(),
|
||||
assignedByUserId: userId ?? null,
|
||||
assignedBy: actor ?? null,
|
||||
note: note ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
return this.completeWithMetadata(
|
||||
bookingId,
|
||||
'RISK_ASSIGNED',
|
||||
{ riskLevel, riskHistory: entries },
|
||||
userId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
|
||||
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
||||
|
||||
@@ -27,6 +27,7 @@ describe('ContractBookingService — quantity-cap completion', () => {
|
||||
{} as never, // workflowService
|
||||
{} as never, // invoiceService
|
||||
{} as never, // clearanceFeeService
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => {
|
||||
{} as never, // workflowService
|
||||
invoiceService as never,
|
||||
{} as never, // clearanceFeeService
|
||||
{ createdToStaff: jest.fn() } as never, // bookingNotifier
|
||||
{} as never, // dataSource
|
||||
{} as never, // trainSchedulingService
|
||||
{} as never, // bookingBatchService
|
||||
|
||||
@@ -18,6 +18,7 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { BookingTransitionService } from '../bookings/booking-transition.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ConsolidationService } from '../bookings/consolidation.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
@@ -97,6 +98,7 @@ export class ContractBookingService {
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly clearanceFeeService: ClearanceFeeService,
|
||||
private readonly bookingNotifier: BookingLifecycleNotifierService,
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
@@ -353,6 +355,12 @@ export class ContractBookingService {
|
||||
const withContainers = await this.bookingsRepository.findByIdWithFiles(
|
||||
booking.id,
|
||||
);
|
||||
|
||||
// Tell staff the booking exists. Placed after the zero-price rollback (which
|
||||
// hard-deletes the row) and before the consolidation gate, so it fires
|
||||
// exactly once whether the booking parks for a partner or finalizes inline.
|
||||
this.bookingNotifier.createdToStaff(withContainers ?? booking);
|
||||
|
||||
const intendedStatus =
|
||||
generalCustoms || generalSelfClear
|
||||
? 'AWAITING_DOCUMENTS'
|
||||
@@ -482,6 +490,7 @@ export class ContractBookingService {
|
||||
);
|
||||
|
||||
const result = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
this.bookingNotifier.createdToStaff(result ?? booking);
|
||||
return { booking: result ?? booking, warnings: [] };
|
||||
}
|
||||
|
||||
@@ -569,7 +578,10 @@ export class ContractBookingService {
|
||||
await this.clearanceFeeService.issueForBooking(booking, contract);
|
||||
}
|
||||
|
||||
return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||
const created =
|
||||
(await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking;
|
||||
this.bookingNotifier.createdToStaff(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,7 +18,10 @@ 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 { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
@@ -102,6 +105,8 @@ export interface ContractClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -365,6 +370,11 @@ export class ContractClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first — see booking-clearance.service.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -102,6 +102,27 @@ function maskPhone(phone: string): string {
|
||||
return `${'•'.repeat(trimmed.length - 4)}${trimmed.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** Email counterpart of {@link maskPhone} (`jane@x.com` → `j•••@x.com`). */
|
||||
function maskEmail(email: string): string {
|
||||
const [local, domain] = email.trim().split('@');
|
||||
if (!domain) return email.trim();
|
||||
return `${local.slice(0, 1)}${'•'.repeat(Math.max(local.length - 1, 1))}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the signing code went, for the "we sent a code to …" line in the UI.
|
||||
* Both contacts are listed when both were used — a signer who only watches their
|
||||
* handset otherwise has no idea the email carries the same code.
|
||||
*/
|
||||
function maskSignerContacts(contacts: { phone?: string; email?: string }): string {
|
||||
return [
|
||||
contacts.email ? maskEmail(contacts.email) : null,
|
||||
contacts.phone ? maskPhone(contacts.phone) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
}
|
||||
|
||||
/** Status-machine guard mirroring booking-status.util. */
|
||||
function assertContractStatus(contract: Contract, allowed: string[]): void {
|
||||
if (!allowed.includes(contract.status)) {
|
||||
@@ -139,34 +160,39 @@ export class ContractTransitionService {
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The phone the signing OTP is sent to and verified against: the signer's own
|
||||
* IAM account number.
|
||||
* The contacts the signing OTP is sent to and verified against: the signer's
|
||||
* own IAM account phone AND email. One code goes to both and either delivery
|
||||
* verifies it, so a signer whose SMS is delayed can still complete from their
|
||||
* inbox instead of abandoning a ready contract.
|
||||
*
|
||||
* H12(b): resolved server-side from the authenticated user id, never from the
|
||||
* request body — a caller-supplied number would let an attacker point the code
|
||||
* at their own phone. Ownership is already gated separately by
|
||||
* request body — caller-supplied contacts would let an attacker point the code
|
||||
* at their own phone or mailbox. Ownership is already gated separately by
|
||||
* {@link ContractsService.assertCustomerCanAccessContract}, so this binds the
|
||||
* signature to the *person* signing rather than to a company landline that may
|
||||
* be shared, stale, or imported from eTrade.
|
||||
*/
|
||||
private async resolveSignerPhone(signerUserId?: string): Promise<string> {
|
||||
private async resolveSignerContacts(
|
||||
signerUserId?: string,
|
||||
): Promise<{ phone?: string; email?: string }> {
|
||||
if (!signerUserId) {
|
||||
// Unreachable in practice (the ownership gate rejects a missing user
|
||||
// first), but never fall back to another number if it ever changes.
|
||||
// first), but never fall back to another account if it ever changes.
|
||||
throw new BadRequestException('Authentication required to sign');
|
||||
}
|
||||
const rows: Array<{ phone_number: string | null }> =
|
||||
const rows: Array<{ phone_number: string | null; email: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT phone_number FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
`SELECT phone_number, email FROM iam.users WHERE id = $1 AND is_active = true`,
|
||||
[signerUserId],
|
||||
);
|
||||
const phone = rows[0]?.phone_number?.trim();
|
||||
if (!phone) {
|
||||
const email = rows[0]?.email?.trim();
|
||||
if (!phone && !email) {
|
||||
throw new BadRequestException(
|
||||
'Your account has no registered phone number. Add one in Settings → Account before signing.',
|
||||
'Your account has no registered phone number or email. Add one in Settings → Account before signing.',
|
||||
);
|
||||
}
|
||||
return phone;
|
||||
return { ...(phone ? { phone } : {}), ...(email ? { email } : {}) };
|
||||
}
|
||||
|
||||
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
|
||||
@@ -992,11 +1018,11 @@ export class ContractTransitionService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone — the
|
||||
* same number {@link sign} verifies against. The client never picks the number
|
||||
* (that is the H12(b) trust property): it only asks us to send, and we resolve
|
||||
* the phone from the authenticated user id. Returns a masked hint so the UI can
|
||||
* say where the code went without exposing the full number.
|
||||
* Send the sudo-mode signing OTP to the SIGNER's own registered phone and
|
||||
* email — the same contacts {@link sign} verifies against. The client never
|
||||
* picks them (that is the H12(b) trust property): it only asks us to send, and
|
||||
* we resolve them from the authenticated user id. Returns a masked hint so the
|
||||
* UI can say where the code went without exposing the full values.
|
||||
*/
|
||||
async sendSigningOtp(
|
||||
contractId: string,
|
||||
@@ -1011,9 +1037,9 @@ export class ContractTransitionService {
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
await this.otpService.sendOtp({ phone: signerPhone });
|
||||
return { sentTo: maskPhone(signerPhone) };
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
await this.otpService.sendOtp(signerContacts);
|
||||
return { sentTo: maskSignerContacts(signerContacts) };
|
||||
}
|
||||
|
||||
/** Customer signs the ready contract → SIGNED_CUSTOMER. */
|
||||
@@ -1040,17 +1066,17 @@ export class ContractTransitionService {
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
// signature is applied. H12(b): verify against the SIGNER's own registered
|
||||
// phone, resolved server-side from the authenticated user id — never a
|
||||
// caller-supplied number, which an attacker could point at their own
|
||||
// phone. Ownership is already asserted above, so this proves the specific
|
||||
// person holding the account is present, not merely that someone reached a
|
||||
// shared company line. Must resolve identically to sendSigningOtp, or send
|
||||
// and verify would target different numbers.
|
||||
const signerPhone = await this.resolveSignerPhone(options.signerUserId);
|
||||
// contacts, resolved server-side from the authenticated user id — never
|
||||
// caller-supplied ones, which an attacker could point at their own phone
|
||||
// or mailbox. Ownership is already asserted above, so this proves the
|
||||
// specific person holding the account is present, not merely that someone
|
||||
// reached a shared company line. Must resolve identically to
|
||||
// sendSigningOtp, or send and verify would target different contacts.
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
if (!dto.otp) {
|
||||
throw new BadRequestException('OTP verification is required to sign the contract');
|
||||
}
|
||||
await this.otpService.verifyOtpForAction({ phone: signerPhone }, dto.otp);
|
||||
await this.otpService.verifyOtpForAction(signerContacts, dto.otp);
|
||||
await this.applySignature(contract, dto, options);
|
||||
await this.contractsRepository.update(contractId, {
|
||||
status: 'SIGNED_CUSTOMER',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { ContractDocumentHistoryService } from './contract-document-history.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
@@ -990,13 +991,16 @@ export class ContractsController {
|
||||
assignRisk(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: AssignRiskDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.milestoneService.assignRisk(
|
||||
bookingId,
|
||||
dto.riskLevel,
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
// Risk history is read by people, so resolve the name now — the id alone
|
||||
// would render as a UUID in the trail.
|
||||
actorLabel(user),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
||||
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
||||
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* One customs risk decision. Risk stays correctable until duty is advised off
|
||||
* it, and the level is customer-visible, so every assignment is kept rather than
|
||||
* overwritten — a disputed level needs to show what was set, by whom, and when.
|
||||
*/
|
||||
export interface RiskAssignmentRecord {
|
||||
level: CustomsRiskLevel;
|
||||
/** The level this replaced; absent on the first assignment. */
|
||||
previousLevel?: CustomsRiskLevel;
|
||||
assignedAt: string;
|
||||
assignedByUserId?: string | null;
|
||||
/** Display name resolved at assignment time, so the trail never shows a UUID. */
|
||||
assignedBy?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured payload some milestones carry beyond a plain note (doc §11.3):
|
||||
* - RISK_ASSIGNED → `riskLevel`
|
||||
* - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment)
|
||||
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
|
||||
* Stored on the milestone so the timeline can render the value inline.
|
||||
*/
|
||||
export interface MilestoneMetadata {
|
||||
riskLevel?: CustomsRiskLevel;
|
||||
/**
|
||||
* Append-only, oldest first. `riskLevel` is the current value and always
|
||||
* equals the last entry's `level`.
|
||||
*/
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
dutyAmount?: number;
|
||||
dutyCurrency?: string;
|
||||
declarationSerial?: string;
|
||||
|
||||
Reference in New Issue
Block a user