feat(bookings): persist Options & Services (customs/insurance/DG/handling)

Les options du formulaire de recherche etaient collectees mais jamais stockees.
Ajout d'une colonne jsonb options sur csv_bookings (+ migration), stockee a la
creation et a l'edition (editFromRate), renvoyee dans la reponse, et re-pre-
remplie a la reprise (search-advanced <- URL <- booking.options).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
David 2026-07-02 19:12:18 +02:00
parent 795e635df3
commit c5f823f8b7
12 changed files with 141 additions and 8 deletions

View File

@ -6,6 +6,7 @@ import {
Min, Min,
IsOptional, IsOptional,
IsEnum, IsEnum,
IsObject,
MinLength, MinLength,
MaxLength, MaxLength,
} from 'class-validator'; } from 'class-validator';
@ -169,6 +170,16 @@ export class CreateCsvBookingDto {
@MaxLength(1000) @MaxLength(1000)
notes?: string; notes?: string;
@ApiPropertyOptional({
description:
'Selected options & services as a JSON string (multipart), e.g. {"insurance":true}',
example: '{"insurance":true,"customsStop":true}',
})
@IsOptional()
@IsString()
@MaxLength(2000)
options?: string;
// Documents will be handled via file upload interceptor // Documents will be handled via file upload interceptor
// Not included in DTO validation but processed separately // Not included in DTO validation but processed separately
} }
@ -346,6 +357,14 @@ export class UpdateCsvBookingRateDto {
@IsString() @IsString()
@MaxLength(2000) @MaxLength(2000)
notes?: string; notes?: string;
@ApiPropertyOptional({
description: 'Selected options & services (customs, insurance, DG, handling…)',
example: { insurance: true, customsStop: true },
})
@IsOptional()
@IsObject()
options?: Record<string, boolean>;
} }
/** /**
@ -602,6 +621,12 @@ export class CsvBookingResponseDto {
example: 'EUR', example: 'EUR',
}) })
fobCurrency?: string; fobCurrency?: string;
@ApiPropertyOptional({
description: 'Selected options & services (customs, insurance, DG, handling…)',
example: { insurance: true },
})
options?: Record<string, boolean>;
} }
/** /**

View File

@ -172,6 +172,16 @@ export class CsvBookingService {
: CsvBookingStatus.PENDING; : CsvBookingStatus.PENDING;
// Create domain entity (no email sent yet when a payment is required) // Create domain entity (no email sent yet when a payment is required)
let parsedOptions: Record<string, boolean> = {};
if (dto.options) {
try {
const parsed = JSON.parse(dto.options);
if (parsed && typeof parsed === 'object') parsedOptions = parsed;
} catch {
this.logger.warn(`Ignoring invalid options JSON for booking creation`);
}
}
const booking = new CsvBooking( const booking = new CsvBooking(
bookingId, bookingId,
userId, userId,
@ -202,7 +212,8 @@ export class CsvBookingService {
dto.freightTotal, dto.freightTotal,
dto.freightCurrency, dto.freightCurrency,
dto.fobTotal, dto.fobTotal,
dto.fobCurrency dto.fobCurrency,
parsedOptions
); );
// Save to database // Save to database
@ -1118,6 +1129,7 @@ export class CsvBookingService {
fobTotal: dto.fobTotal, fobTotal: dto.fobTotal,
fobCurrency: dto.fobCurrency, fobCurrency: dto.fobCurrency,
notes: dto.notes, notes: dto.notes,
options: dto.options,
}); });
} catch (err) { } catch (err) {
throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate'); throw new BadRequestException(err instanceof Error ? err.message : 'Invalid booking rate');
@ -1571,6 +1583,7 @@ export class CsvBookingService {
freightCurrency: booking.freightCurrency, freightCurrency: booking.freightCurrency,
fobTotal: booking.fobTotal, fobTotal: booking.fobTotal,
fobCurrency: booking.fobCurrency, fobCurrency: booking.fobCurrency,
options: booking.options ?? {},
}; };
} }

View File

@ -95,7 +95,10 @@ export class CsvBooking {
public freightTotal?: number, public freightTotal?: number,
public freightCurrency?: string, public freightCurrency?: string,
public fobTotal?: number, public fobTotal?: number,
public fobCurrency?: string public fobCurrency?: string,
// Options & services selected in the search form (customs, insurance, DG,
// handling…). Stored as a flat map of enabled flags. Editable before payment.
public options: Record<string, boolean> = {}
) { ) {
this.validate(); this.validate();
} }
@ -394,6 +397,7 @@ export class CsvBooking {
fobTotal?: number; fobTotal?: number;
fobCurrency?: string; fobCurrency?: string;
notes?: string; notes?: string;
options?: Record<string, boolean>;
}): void { }): void {
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) { if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
throw new Error( throw new Error(
@ -430,6 +434,7 @@ export class CsvBooking {
this.fobTotal = data.fobTotal; this.fobTotal = data.fobTotal;
this.fobCurrency = data.fobCurrency; this.fobCurrency = data.fobCurrency;
if (data.notes !== undefined) this.notes = data.notes; if (data.notes !== undefined) this.notes = data.notes;
if (data.options !== undefined) this.options = data.options;
} }
/** /**
@ -600,7 +605,8 @@ export class CsvBooking {
freightTotal?: number, freightTotal?: number,
freightCurrency?: string, freightCurrency?: string,
fobTotal?: number, fobTotal?: number,
fobCurrency?: string fobCurrency?: string,
options?: Record<string, boolean>
): CsvBooking { ): CsvBooking {
// Create instance without calling constructor validation // Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype); const booking = Object.create(CsvBooking.prototype);
@ -636,6 +642,7 @@ export class CsvBooking {
booking.freightCurrency = freightCurrency; booking.freightCurrency = freightCurrency;
booking.fobTotal = fobTotal; booking.fobTotal = fobTotal;
booking.fobCurrency = fobCurrency; booking.fobCurrency = fobCurrency;
booking.options = options ?? {};
return booking; return booking;
} }

View File

@ -183,6 +183,9 @@ export class CsvBookingOrmEntity {
@Column({ name: 'fob_currency', type: 'varchar', length: 3, nullable: true }) @Column({ name: 'fob_currency', type: 'varchar', length: 3, nullable: true })
fobCurrency: string | null; fobCurrency: string | null;
@Column({ name: 'options', type: 'jsonb', default: {} })
options: Record<string, boolean>;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt: Date; createdAt: Date;

View File

@ -49,7 +49,8 @@ export class CsvBookingMapper {
ormEntity.freightTotal != null ? Number(ormEntity.freightTotal) : undefined, ormEntity.freightTotal != null ? Number(ormEntity.freightTotal) : undefined,
ormEntity.freightCurrency ?? undefined, ormEntity.freightCurrency ?? undefined,
ormEntity.fobTotal != null ? Number(ormEntity.fobTotal) : undefined, ormEntity.fobTotal != null ? Number(ormEntity.fobTotal) : undefined,
ormEntity.fobCurrency ?? undefined ormEntity.fobCurrency ?? undefined,
ormEntity.options ?? {}
); );
} }
@ -87,6 +88,7 @@ export class CsvBookingMapper {
freightCurrency: domain.freightCurrency ?? null, freightCurrency: domain.freightCurrency ?? null,
fobTotal: domain.fobTotal ?? null, fobTotal: domain.fobTotal ?? null,
fobCurrency: domain.fobCurrency ?? null, fobCurrency: domain.fobCurrency ?? null,
options: domain.options ?? {},
}; };
} }
@ -113,6 +115,7 @@ export class CsvBookingMapper {
freightCurrency: domain.freightCurrency ?? null, freightCurrency: domain.freightCurrency ?? null,
fobTotal: domain.fobTotal ?? null, fobTotal: domain.fobTotal ?? null,
fobCurrency: domain.fobCurrency ?? null, fobCurrency: domain.fobCurrency ?? null,
options: domain.options ?? {},
status: domain.status as CsvBookingOrmEntity['status'], status: domain.status as CsvBookingOrmEntity['status'],
respondedAt: domain.respondedAt, respondedAt: domain.respondedAt,
notes: domain.notes, notes: domain.notes,

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
export class AddOptionsToCsvBookings1749000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
'csv_bookings',
new TableColumn({
name: 'options',
type: 'jsonb',
isNullable: false,
default: "'{}'",
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn('csv_bookings', 'options');
}
}

View File

@ -228,6 +228,10 @@ export default function PayCommissionPage() {
</p> </p>
<button <button
onClick={() => { onClick={() => {
const opts = (booking as any).options || {};
const enabledOptions = Object.keys(opts)
.filter(k => opts[k])
.join(',');
const params = new URLSearchParams({ const params = new URLSearchParams({
editBookingId: bookingId, editBookingId: bookingId,
origin: booking.origin || '', origin: booking.origin || '',
@ -235,8 +239,9 @@ export default function PayCommissionPage() {
volumeCBM: String(booking.volumeCBM ?? ''), volumeCBM: String(booking.volumeCBM ?? ''),
weightKG: String(booking.weightKG ?? ''), weightKG: String(booking.weightKG ?? ''),
palletCount: String(booking.palletCount ?? ''), palletCount: String(booking.palletCount ?? ''),
hasDangerousGoods: 'false', hasDangerousGoods: String(!!opts.dangerousGoods),
}); });
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced?${params.toString()}`); router.push(`/dashboard/search-advanced?${params.toString()}`);
}} }}
className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors" className="mb-8 inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"

View File

@ -193,6 +193,17 @@ function NewBookingPageContent() {
formDataToSend.append('notes', formData.notes); formDataToSend.append('notes', formData.notes);
} }
// Options & services selected in the search form (persisted with the booking)
const optionsParam = searchParams.get('options') || '';
if (optionsParam) {
const optionsObj = optionsParam
.split(',')
.map(s => s.trim())
.filter(Boolean)
.reduce((acc, k) => ({ ...acc, [k]: true }), {} as Record<string, boolean>);
formDataToSend.append('options', JSON.stringify(optionsObj));
}
// Append documents // Append documents
formData.documents.forEach(file => { formData.documents.forEach(file => {
formDataToSend.append('documents', file); formDataToSend.append('documents', file);

View File

@ -33,6 +33,9 @@ export default function BookingsListPage() {
// "Modifier" reopens the existing booking flow (search → carrier choice → // "Modifier" reopens the existing booking flow (search → carrier choice →
// payment) pre-filled with the booking's data, so a change re-quotes the price. // payment) pre-filled with the booking's data, so a change re-quotes the price.
const buildEditUrl = (b: any) => { const buildEditUrl = (b: any) => {
const enabledOptions = Object.keys(b.options || {})
.filter(k => b.options[k])
.join(',');
const params = new URLSearchParams({ const params = new URLSearchParams({
editBookingId: b.id, editBookingId: b.id,
origin: b.origin || b.originCity || '', origin: b.origin || b.originCity || '',
@ -40,8 +43,9 @@ export default function BookingsListPage() {
volumeCBM: String(b.volumeCBM ?? ''), volumeCBM: String(b.volumeCBM ?? ''),
weightKG: String(b.weightKG ?? ''), weightKG: String(b.weightKG ?? ''),
palletCount: String(b.palletCount ?? ''), palletCount: String(b.palletCount ?? ''),
hasDangerousGoods: 'false', hasDangerousGoods: String(!!(b.options && b.options.dangerousGoods)),
}); });
if (enabledOptions) params.set('options', enabledOptions);
return `/dashboard/search-advanced?${params.toString()}`; return `/dashboard/search-advanced?${params.toString()}`;
}; };

View File

@ -51,6 +51,22 @@ interface SearchForm {
t1Document: boolean; t1Document: boolean;
} }
// Options & services persisted with the booking (order irrelevant).
const OPTION_KEYS: (keyof SearchForm)[] = [
'eurDocument',
't1Document',
'customsStop',
'exportAssistance',
'dangerousGoods',
'regulatedProducts',
'specialHandling',
'tailgate',
'straps',
'thermalCover',
'appointment',
'insurance',
];
export default function AdvancedSearchPage() { export default function AdvancedSearchPage() {
const t = useTranslations('dashboard.rateSearch'); const t = useTranslations('dashboard.rateSearch');
const router = useRouter(); const router = useRouter();
@ -155,6 +171,16 @@ export default function AdvancedSearchPage() {
const pallets = parseInt(searchParams.get('palletCount') || '0', 10); const pallets = parseInt(searchParams.get('palletCount') || '0', 10);
const dg = searchParams.get('hasDangerousGoods') === 'true'; const dg = searchParams.get('hasDangerousGoods') === 'true';
// Prefill the options & services from the "options" CSV param.
const enabledOptions = (searchParams.get('options') || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
const optionFlags = OPTION_KEYS.reduce(
(acc, k) => ({ ...acc, [k]: enabledOptions.includes(k) }),
{} as Record<string, boolean>
);
// Reconstruct a single representative package that reproduces the aggregate // Reconstruct a single representative package that reproduces the aggregate
// totals (the booking only stores totals, not individual packages). // totals (the booking only stores totals, not individual packages).
const q = pallets > 0 ? pallets : 1; const q = pallets > 0 ? pallets : 1;
@ -172,9 +198,10 @@ export default function AdvancedSearchPage() {
setSearchForm(prev => ({ setSearchForm(prev => ({
...prev, ...prev,
...optionFlags,
origin: originParam || prev.origin, origin: originParam || prev.origin,
destination: destinationParam || prev.destination, destination: destinationParam || prev.destination,
dangerousGoods: dg, dangerousGoods: dg || !!optionFlags.dangerousGoods,
packages: vol > 0 || wgt > 0 || pallets > 0 ? [pkg] : prev.packages, packages: vol > 0 || wgt > 0 || pallets > 0 ? [pkg] : prev.packages,
})); }));
if (originParam) setOriginSearch(originParam); if (originParam) setOriginSearch(originParam);
@ -249,6 +276,10 @@ export default function AdvancedSearchPage() {
params.set('editBookingId', editBookingId); params.set('editBookingId', editBookingId);
} }
// Carry the selected options & services so they can be persisted.
const enabledOptions = OPTION_KEYS.filter(k => searchForm[k]).join(',');
if (enabledOptions) params.set('options', enabledOptions);
router.push(`/dashboard/search-advanced/results?${params.toString()}`); router.push(`/dashboard/search-advanced/results?${params.toString()}`);
}; };

View File

@ -42,6 +42,14 @@ export default function SearchResultsPage() {
// When present, we are re-selecting a rate for an existing unpaid booking: // When present, we are re-selecting a rate for an existing unpaid booking:
// update it in place and go straight to payment (skip the new-booking form). // update it in place and go straight to payment (skip the new-booking form).
const editBookingId = searchParams.get('editBookingId'); const editBookingId = searchParams.get('editBookingId');
const optionsParam = searchParams.get('options') || '';
const optionsObj: Record<string, boolean> = optionsParam
? optionsParam
.split(',')
.map(s => s.trim())
.filter(Boolean)
.reduce((acc, k) => ({ ...acc, [k]: true }), {})
: {};
const [submittingId, setSubmittingId] = useState<string | null>(null); const [submittingId, setSubmittingId] = useState<string | null>(null);
const [selectError, setSelectError] = useState<string | null>(null); const [selectError, setSelectError] = useState<string | null>(null);
@ -76,6 +84,7 @@ export default function SearchResultsPage() {
freightCurrency, freightCurrency,
fobTotal, fobTotal,
fobCurrency, fobCurrency,
options: optionsObj,
}); });
router.push(`/dashboard/booking/${editBookingId}/pay`); router.push(`/dashboard/booking/${editBookingId}/pay`);
} catch (err) { } catch (err) {
@ -88,8 +97,9 @@ export default function SearchResultsPage() {
} }
const rateData = encodeURIComponent(JSON.stringify(option)); const rateData = encodeURIComponent(JSON.stringify(option));
const optionsQuery = optionsParam ? `&options=${encodeURIComponent(optionsParam)}` : '';
router.push( router.push(
`/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}` `/dashboard/booking/new?rateData=${rateData}&volumeCBM=${volumeCBM}&weightKG=${weightKG}&palletCount=${palletCount}${optionsQuery}`
); );
}; };

View File

@ -66,6 +66,7 @@ export interface CsvBookingResponse {
updatedAt: string; updatedAt: string;
commissionRate?: number; commissionRate?: number;
commissionAmountEur?: number; commissionAmountEur?: number;
options?: Record<string, boolean>;
} }
export interface CommissionPaymentResponse { export interface CommissionPaymentResponse {
@ -311,6 +312,7 @@ export interface UpdateCsvBookingRateRequest {
fobTotal?: number; fobTotal?: number;
fobCurrency?: string; fobCurrency?: string;
notes?: string; notes?: string;
options?: Record<string, boolean>;
} }
export async function updateCsvBookingRate( export async function updateCsvBookingRate(