mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
feat: add hazardous goods declaration feature
- Introduced HazardDeclarationPanel component to display dangerous goods declaration details. - Updated URL constants to include CLEARANCE_PROCEED endpoint for re-requesting operations. - Enhanced permissions to include hazardous approval roles for contract approvals. - Integrated HazardDeclarationPanel into ContractRequestDetailPage and ContractClearanceDetailPage. - Added proceedToOperation method in bookings service for handling operation re-requests. - Updated contract forms and schemas to include hazard class and UN number fields. - Implemented validation for hazardous contracts in the contract creation flow. - Added expiry notice functionality for contracts nearing validity end. - Created tests for expiry notice calculations and labels. - Updated UI components to reflect hazardous cargo information and validation errors.
This commit is contained in:
@@ -84,6 +84,14 @@ export interface ContractClearanceView {
|
||||
/** Reference + status of the GL-created shipment booking, once it exists. */
|
||||
linkedBookingReference?: string | null;
|
||||
linkedBookingStatus?: string | null;
|
||||
/**
|
||||
* Operations' latest "needs changes" note on that booking. GL created the
|
||||
* booking, so GL is the one who has to act on it — surfaced here because the
|
||||
* clearance page is where GL works, not the portal.
|
||||
*/
|
||||
linkedBookingReviewNote?: string | null;
|
||||
/** Shipment day the booking currently holds — the default when GL resubmits. */
|
||||
linkedBookingScheduledDate?: string | null;
|
||||
dutyAdvice?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
@@ -301,11 +309,24 @@ export class ContractClearanceService {
|
||||
// shortly" message. Reuse the export booking load; fetch for import too.
|
||||
let linkedBookingReference: string | null = null;
|
||||
let linkedBookingStatus: string | null = null;
|
||||
let linkedBookingReviewNote: string | null = null;
|
||||
let linkedBookingScheduledDate: string | null = null;
|
||||
if (cycle?.bookingId) {
|
||||
const booking = await this.bookingsService.findById(cycle.bookingId);
|
||||
if (booking) {
|
||||
linkedBookingReference = booking.reference ?? null;
|
||||
linkedBookingStatus = booking.status ?? null;
|
||||
linkedBookingScheduledDate = booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString()
|
||||
: null;
|
||||
// Newest changes-requested note (reviewNotes ride along on findById).
|
||||
linkedBookingReviewNote =
|
||||
[...(booking.reviewNotes ?? [])]
|
||||
.filter((n) => n.type === 'CHANGES_REQUESTED')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
)[0]?.note ?? null;
|
||||
if (contract.tradeDirection === 'EXPORT') {
|
||||
nextAction = this.workflowService.computeNextActionForBooking(
|
||||
booking,
|
||||
@@ -349,6 +370,8 @@ export class ContractClearanceService {
|
||||
linkedBookingId: cycle?.bookingId ?? null,
|
||||
linkedBookingReference,
|
||||
linkedBookingStatus,
|
||||
linkedBookingReviewNote,
|
||||
linkedBookingScheduledDate,
|
||||
dutyAdvice,
|
||||
workflowFiles,
|
||||
t1,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ContractExpiryService } from './contract-expiry.service';
|
||||
import type { Contract } from './entities/contract.entity';
|
||||
|
||||
/**
|
||||
* The reminder must warn each customer once, ten days out, and must never let a
|
||||
* notification failure escape into the scheduler (that would also take out the
|
||||
* expiry sweep sharing this service).
|
||||
*/
|
||||
describe('ContractExpiryService — expiry reminder', () => {
|
||||
const contract = (over: Partial<Contract> = {}): Contract =>
|
||||
({
|
||||
id: 'c-1',
|
||||
reference: 'CTR-2026-00042',
|
||||
companyId: 'co-1',
|
||||
contractValidUntil: new Date('2026-08-10T00:00:00.000Z'),
|
||||
status: 'CONTRACT_ACTIVE',
|
||||
...over,
|
||||
}) as Contract;
|
||||
|
||||
let repo: { expireLapsedContracts: jest.Mock; findExpiringInDays: jest.Mock };
|
||||
let inbox: { notify: jest.Mock };
|
||||
let service: ContractExpiryService;
|
||||
|
||||
beforeEach(() => {
|
||||
repo = {
|
||||
expireLapsedContracts: jest.fn().mockResolvedValue(0),
|
||||
findExpiringInDays: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
inbox = { notify: jest.fn().mockResolvedValue(undefined) };
|
||||
service = new ContractExpiryService(repo as never, inbox as never);
|
||||
});
|
||||
|
||||
it('asks for the contracts lapsing ten days out', async () => {
|
||||
await service.remindExpiringContracts();
|
||||
expect(repo.findExpiringInDays).toHaveBeenCalledWith(10);
|
||||
});
|
||||
|
||||
it('notifies the owning company once, deep-linking the contract list', async () => {
|
||||
repo.findExpiringInDays.mockResolvedValue([contract()]);
|
||||
|
||||
await service.remindExpiringContracts();
|
||||
|
||||
expect(inbox.notify).toHaveBeenCalledTimes(1);
|
||||
const sent = inbox.notify.mock.calls[0][0];
|
||||
expect(sent.recipients).toEqual({ companyId: 'co-1' });
|
||||
expect(sent.title).toContain('CTR-2026-00042');
|
||||
expect(sent.title).toContain('10 days');
|
||||
expect(sent.link).toBe('/contracts');
|
||||
expect(sent.data).toMatchObject({ contractId: 'c-1', action: 'CONTRACT_EXPIRING' });
|
||||
});
|
||||
|
||||
it('skips a contract with no owning company (nobody to notify)', async () => {
|
||||
repo.findExpiringInDays.mockResolvedValue([contract({ companyId: null })]);
|
||||
await service.remindExpiringContracts();
|
||||
expect(inbox.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a notification failure instead of throwing into the scheduler', async () => {
|
||||
repo.findExpiringInDays.mockResolvedValue([contract()]);
|
||||
inbox.notify.mockRejectedValue(new Error('inbox down'));
|
||||
await expect(service.remindExpiringContracts()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,13 @@ import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { ContractsRepository } from './contracts.repository';
|
||||
|
||||
/**
|
||||
* How many days before a contract lapses the customer is reminded. Mirrored by
|
||||
* the portal contract list (EXPIRY_NOTICE_DAYS in contract-ui.tsx), which shows
|
||||
* the same countdown on the row.
|
||||
*/
|
||||
const EXPIRY_NOTICE_DAYS = 10;
|
||||
|
||||
/** Nightly sweep that flips contracts past contractValidUntil to EXPIRED. */
|
||||
@Injectable()
|
||||
export class ContractExpiryService {
|
||||
@@ -15,6 +22,51 @@ export class ContractExpiryService {
|
||||
private readonly inbox: NotificationInboxService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Warn every customer whose contract lapses in ~10 days, once. The repository
|
||||
* window is a rolling 24h slice, so a contract is picked up by exactly one
|
||||
* daily run — no reminded-flag column needed.
|
||||
*
|
||||
* ponytail: a missed run (API down over the slice) skips that contract's
|
||||
* reminder; the portal list still shows its countdown for the whole window.
|
||||
*/
|
||||
@Cron(CronExpression.EVERY_DAY_AT_2AM, { name: 'contract-expiry-reminder' })
|
||||
async remindExpiringContracts(): Promise<void> {
|
||||
try {
|
||||
const expiring =
|
||||
await this.contractsRepository.findExpiringInDays(EXPIRY_NOTICE_DAYS);
|
||||
let notified = 0;
|
||||
for (const contract of expiring) {
|
||||
if (!contract.companyId || !contract.contractValidUntil) continue;
|
||||
const endsOn = contract.contractValidUntil.toLocaleDateString('en-GB');
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: contract.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.CONTRACT_STATUS,
|
||||
title: `Contract ${contract.reference} expires in ${EXPIRY_NOTICE_DAYS} days`,
|
||||
body:
|
||||
`Your contract ${contract.reference} is valid until ${endsOn}. ` +
|
||||
'After that date it stops accepting new bookings — contact EDR if ' +
|
||||
'you need it renewed.',
|
||||
link: '/contracts',
|
||||
data: { contractId: contract.id, action: 'CONTRACT_EXPIRING' },
|
||||
});
|
||||
notified += 1;
|
||||
}
|
||||
this.logger.log(
|
||||
`Contract expiry reminder: ${notified} customer(s) warned of a contract ` +
|
||||
`lapsing in ${EXPIRY_NOTICE_DAYS} days`,
|
||||
);
|
||||
} catch (err) {
|
||||
// Never throws into the scheduler — a failed reminder must not stop the
|
||||
// expiry sweep from running.
|
||||
this.logger.error(
|
||||
`Contract expiry reminder failed: ${(err as Error).message}`,
|
||||
(err as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_1AM, { name: 'contract-expiry-sweep' })
|
||||
async expireLapsedContracts(): Promise<void> {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
|
||||
import { ContractTransitionService } from './contract-transition.service';
|
||||
|
||||
/**
|
||||
* Signing is one-shot. The single exception: a contract signed before company
|
||||
* stamps were required must be re-signable so the customer can attach one —
|
||||
* otherwise counterSign's both-stamps gate strands it forever. These specs pin
|
||||
* that exception open and pin everything else shut.
|
||||
*/
|
||||
describe('customer re-sign to attach a missing stamp', () => {
|
||||
const contractReady = { id: 'c-1', reference: 'CTR-1', status: 'CONTRACT_READY' };
|
||||
const signedNoStamp = { id: 'c-1', reference: 'CTR-1', status: 'SIGNED_CUSTOMER' };
|
||||
|
||||
const build = (contract: unknown, existingSignature: unknown) => {
|
||||
const applied: unknown[] = [];
|
||||
const service = Object.create(
|
||||
ContractTransitionService.prototype,
|
||||
) as ContractTransitionService;
|
||||
Object.assign(service, {
|
||||
contractsService: {
|
||||
findById: jest.fn().mockResolvedValue(contract),
|
||||
assertCustomerCanAccessContract: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
contractsRepository: {
|
||||
findSignature: jest.fn().mockResolvedValue(existingSignature),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
otpService: {
|
||||
verifyOtpForAction: jest.fn().mockResolvedValue(undefined),
|
||||
sendOtp: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
notifier: { customerSignedToStaff: jest.fn() },
|
||||
resolveSignerContacts: jest.fn().mockResolvedValue({ phone: '+251900000000' }),
|
||||
applySignature: jest.fn((...args: unknown[]) => {
|
||||
applied.push(args);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
regenerateContractPdf: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
return { service, applied };
|
||||
};
|
||||
|
||||
const dto = {
|
||||
role: 'CUSTOMER' as const,
|
||||
signerDisplayName: 'C. Customer',
|
||||
signatureImageBase64: 'data:image/png;base64,AAAA',
|
||||
stampImageBase64: 'data:image/png;base64,BBBB',
|
||||
otp: '123456',
|
||||
};
|
||||
|
||||
it('lets a customer sign again when their signature has no stamp', async () => {
|
||||
const { service, applied } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: null,
|
||||
});
|
||||
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('still refuses a second signature once a stamp is on file', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: 'file-1',
|
||||
});
|
||||
|
||||
// Stamped already → not the re-sign case, so the status guard rejects
|
||||
// SIGNED_CUSTOMER before the already-signed check is reached.
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses a second signature on a still-ready contract', async () => {
|
||||
const { service } = build(contractReady, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: 'file-1',
|
||||
});
|
||||
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).rejects.toThrow(
|
||||
/already signed/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('signs normally when nothing is on file yet', async () => {
|
||||
const { service, applied } = build(contractReady, null);
|
||||
|
||||
await expect(service.sign('c-1', dto, { signerUserId: 'u-1' })).resolves.toBeDefined();
|
||||
expect(applied).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sends a signing OTP for the stamp re-sign', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
|
||||
).resolves.toEqual(expect.objectContaining({ sentTo: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('refuses a signing OTP once the contract is signed and stamped', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: 'file-1',
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sendSigningOtp('c-1', { signerUserId: 'u-1' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('requires the OTP on the re-sign path too', async () => {
|
||||
const { service } = build(signedNoStamp, {
|
||||
id: 's-1',
|
||||
role: 'CUSTOMER',
|
||||
stampFileId: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.sign('c-1', { ...dto, otp: undefined }, { signerUserId: 'u-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
assertCanApproveContractStep,
|
||||
assertFreightPermission,
|
||||
canEditContractStep,
|
||||
HAZARDOUS_APPROVAL_ROLES,
|
||||
} from '../../common/freight-permission.util';
|
||||
import {
|
||||
FREIGHT_PERMS,
|
||||
@@ -530,12 +531,26 @@ export class ContractTransitionService {
|
||||
);
|
||||
}
|
||||
|
||||
for (const rule of chain) {
|
||||
await this.contractsRepository.createApprovalStep({
|
||||
contractId: contract.id,
|
||||
stepOrder: rule.stepOrder,
|
||||
// Dangerous goods clear two dedicated hazardous desks BEFORE the commercial
|
||||
// chain — if either refuses, the contract never reaches the approvers who
|
||||
// would price and sign it. Steps are renumbered sequentially so the prefix
|
||||
// and the configured chain form one ordered list.
|
||||
const roles: Array<{ requiredRole: string; blocksRole: string | null }> = [
|
||||
...(contract.isHazardous ? [...HAZARDOUS_APPROVAL_ROLES] : []).map(
|
||||
(requiredRole) => ({ requiredRole, blocksRole: null }),
|
||||
),
|
||||
...chain.map((rule) => ({
|
||||
requiredRole: rule.requiredRole,
|
||||
blocksRole: rule.blocksRole ?? null,
|
||||
})),
|
||||
];
|
||||
|
||||
for (const [index, role] of roles.entries()) {
|
||||
await this.contractsRepository.createApprovalStep({
|
||||
contractId: contract.id,
|
||||
stepOrder: index + 1,
|
||||
requiredRole: role.requiredRole,
|
||||
blocksRole: role.blocksRole,
|
||||
status: 'PENDING',
|
||||
});
|
||||
}
|
||||
@@ -1111,7 +1126,17 @@ export class ContractTransitionService {
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
// SIGNED_CUSTOMER is allowed only for the re-sign-to-add-a-stamp case that
|
||||
// {@link sign} permits — otherwise the code would be useless on arrival.
|
||||
const existing = await this.contractsRepository.findSignature(
|
||||
contractId,
|
||||
'CUSTOMER',
|
||||
);
|
||||
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
|
||||
assertContractStatus(
|
||||
contract,
|
||||
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
|
||||
);
|
||||
|
||||
const signerContacts = await this.resolveSignerContacts(options.signerUserId);
|
||||
await this.otpService.sendOtp(signerContacts);
|
||||
@@ -1135,9 +1160,16 @@ export class ContractTransitionService {
|
||||
options.signerUserId,
|
||||
contract,
|
||||
);
|
||||
assertContractStatus(contract, ['CONTRACT_READY']);
|
||||
const existing = await this.contractsRepository.findSignature(contractId, 'CUSTOMER');
|
||||
if (existing) {
|
||||
// Signing is one-shot, with one exception: a contract signed before the
|
||||
// company stamp was required has to be sealed before EDR can counter-sign
|
||||
// it, so the customer may sign again purely to attach the missing stamp.
|
||||
const addingMissingStamp = Boolean(existing) && !existing?.stampFileId;
|
||||
assertContractStatus(
|
||||
contract,
|
||||
addingMissingStamp ? ['CONTRACT_READY', 'SIGNED_CUSTOMER'] : ['CONTRACT_READY'],
|
||||
);
|
||||
if (existing && !addingMissingStamp) {
|
||||
throw new BadRequestException('Customer has already signed this contract');
|
||||
}
|
||||
// Sudo-mode gate: a fresh, single-use OTP must be verified before the
|
||||
|
||||
@@ -107,6 +107,30 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
return result.affected ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live contracts whose validity ends between `days` and `days + 1` days from
|
||||
* now — the slice the daily expiry-reminder cron warns about. The window is
|
||||
* rolling and exactly 24h wide, so consecutive daily runs tile it without
|
||||
* gaps or overlaps: each contract is picked up by exactly one run and the
|
||||
* customer is notified once, with no "already reminded" flag to store.
|
||||
*/
|
||||
async findExpiringInDays(days: number): Promise<Contract[]> {
|
||||
const now = Date.now();
|
||||
return this.repository
|
||||
.createQueryBuilder('contract')
|
||||
.where('contract.deleted_at IS NULL')
|
||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||
})
|
||||
.andWhere('contract.contract_valid_until >= :from', {
|
||||
from: new Date(now + days * 86_400_000),
|
||||
})
|
||||
.andWhere('contract.contract_valid_until < :to', {
|
||||
to: new Date(now + (days + 1) * 86_400_000),
|
||||
})
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Find a contract by ID with all child collections, service type, company and files. */
|
||||
async findByIdWithRelations(id: string): Promise<Contract | null> {
|
||||
if (!id) return null;
|
||||
|
||||
@@ -343,6 +343,10 @@ export class ContractsService {
|
||||
lastMileDeliveryLat: dto.lastMileDeliveryLat ?? null,
|
||||
lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null,
|
||||
isHazardous: dto.isHazardous ?? false,
|
||||
// Hazard class / UN number only exist on a hazardous contract — a stale
|
||||
// pair from an earlier draft must never survive the flag being turned off.
|
||||
hazardClass: dto.isHazardous ? (dto.hazardClass ?? null) : null,
|
||||
unNumber: dto.isHazardous ? (dto.unNumber ?? null) : null,
|
||||
isReefer: dto.isReefer ?? false,
|
||||
contractType: dto.contractType ?? null,
|
||||
status: 'DRAFT',
|
||||
@@ -515,6 +519,13 @@ export class ContractsService {
|
||||
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
|
||||
isHazardous: dto.isHazardous ?? existing.isHazardous,
|
||||
isReefer: dto.isReefer ?? existing.isReefer,
|
||||
// Same rule as create: clearing the flag clears the declaration with it.
|
||||
hazardClass: (dto.isHazardous ?? existing.isHazardous)
|
||||
? (dto.hazardClass ?? existing.hazardClass ?? null)
|
||||
: null,
|
||||
unNumber: (dto.isHazardous ?? existing.isHazardous)
|
||||
? (dto.unNumber ?? existing.unNumber ?? null)
|
||||
: null,
|
||||
equipmentReturn: dto.equipmentReturn ?? existing.equipmentReturn,
|
||||
firstMilePickupAddress: dto.firstMilePickupAddress ?? existing.firstMilePickupAddress,
|
||||
firstMilePickupLat: dto.firstMilePickupLat ?? existing.firstMilePickupLat,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { HAZARD_CLASS_VALUES } from '@edr/types';
|
||||
|
||||
import { CONTRACT_KINDS } from '../entities/contract.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'DOMESTIC'] as const;
|
||||
@@ -228,6 +230,28 @@ export class CreateContractDto {
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isHazardous?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: HAZARD_CLASS_VALUES,
|
||||
description: 'UN/ADR dangerous-goods class. Required when isHazardous.',
|
||||
})
|
||||
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
|
||||
@IsIn(HAZARD_CLASS_VALUES, {
|
||||
message: `hazardClass must be one of: ${HAZARD_CLASS_VALUES.join(', ')}`,
|
||||
})
|
||||
hazardClass?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'UN number of the dangerous good. Required when isHazardous.',
|
||||
})
|
||||
@ValidateIf((o: CreateContractDto) => o.isHazardous === true)
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(16)
|
||||
@Transform(({ value }) =>
|
||||
typeof value === 'string' ? value.trim().toUpperCase() : value,
|
||||
)
|
||||
unNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: false, description: 'Sets contracts.is_reefer' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -188,6 +188,14 @@ export class Contract extends BaseEntity {
|
||||
@Column({ name: 'is_hazardous', type: 'boolean', default: false })
|
||||
isHazardous!: boolean;
|
||||
|
||||
/** UN/ADR dangerous-goods class (CLASS_1..CLASS_9); null unless hazardous. */
|
||||
@Column({ name: 'hazard_class', type: 'varchar', length: 16, nullable: true })
|
||||
hazardClass?: string | null;
|
||||
|
||||
/** UN number of the dangerous good; null unless hazardous. */
|
||||
@Column({ name: 'un_number', type: 'varchar', length: 16, nullable: true })
|
||||
unNumber?: string | null;
|
||||
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user