feat(eoi): multi-berth EOI generation + berth-range formatter
Plan §4.6 + §1: a render function that compresses every berth marked
is_in_eoi_bundle=true on an interest into a compact range string
("A1-A3, B5-B7"), wired into both EOI generation paths (the Documenso
template-generate call and the in-app pdf-lib AcroForm fill).
- src/lib/templates/berth-range.ts: pure formatBerthRange() with the
full edge-case set from §4.6 - empty, single, run, gap, multiple
prefixes, sort/dedup, multi-letter prefixes, non-canonical
passthrough, long ranges. Sorts by (prefix, number); dedupes; passes
non-canonical inputs through with a logger warning.
- src/lib/templates/merge-fields.ts: new {{eoi.berthRange}} token
added to VALID_MERGE_TOKENS allow-list under a fresh `eoi` scope so
unknown-token validation at template creation time still rejects
typos.
- src/lib/services/eoi-context.ts: EoiContext gains eoiBerthRange.
Resolved by joining interest_berths (is_in_eoi_bundle=true) →
berths and feeding the mooring numbers through formatBerthRange.
- src/lib/services/documenso-payload.ts: formValues now includes
"Berth Range" alongside the legacy "Berth Number". Multi-berth EOIs
surface here; single-berth EOIs duplicate the primary.
- src/lib/pdf/fill-eoi-form.ts: in-app AcroForm fill mirrors the
Documenso payload by populating "Berth Range". Falls back silently
when older PDFs don't have the field (setText is no-op-on-missing).
15 unit tests on the formatter; existing EoiContext + Documenso
payload tests updated to assert the new field. 1022 -> 1037 passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,10 @@ export async function fillEoiFormFields(
|
|||||||
setText(form, 'Width', context.yacht?.widthFt ?? '');
|
setText(form, 'Width', context.yacht?.widthFt ?? '');
|
||||||
setText(form, 'Draft', context.yacht?.draftFt ?? '');
|
setText(form, 'Draft', context.yacht?.draftFt ?? '');
|
||||||
setText(form, 'Berth Number', context.berth?.mooringNumber ?? '');
|
setText(form, 'Berth Number', context.berth?.mooringNumber ?? '');
|
||||||
|
// Multi-berth EOI: compact range string from the interest's EOI bundle.
|
||||||
|
// Falls back silently when the AcroForm field doesn't exist (older
|
||||||
|
// template revisions without the field still fill cleanly).
|
||||||
|
setText(form, 'Berth Range', context.eoiBerthRange);
|
||||||
|
|
||||||
setCheckbox(form, 'Purchase', true);
|
setCheckbox(form, 'Purchase', true);
|
||||||
setCheckbox(form, 'Lease_10', false);
|
setCheckbox(form, 'Lease_10', false);
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ export interface DocumensoTemplatePayload {
|
|||||||
Width: string;
|
Width: string;
|
||||||
Draft: string;
|
Draft: string;
|
||||||
'Berth Number': string;
|
'Berth Number': string;
|
||||||
|
/**
|
||||||
|
* Compact range string of every berth in the interest's EOI bundle
|
||||||
|
* (e.g. "A1-A3, B5"). Empty when the bundle is empty. Multi-berth
|
||||||
|
* EOIs surface here; single-berth EOIs duplicate the primary mooring.
|
||||||
|
*/
|
||||||
|
'Berth Range': string;
|
||||||
Lease_10: boolean;
|
Lease_10: boolean;
|
||||||
Purchase: boolean;
|
Purchase: boolean;
|
||||||
};
|
};
|
||||||
@@ -135,6 +141,7 @@ export function buildDocumensoPayload(
|
|||||||
Width: context.yacht?.widthFt ?? '',
|
Width: context.yacht?.widthFt ?? '',
|
||||||
Draft: context.yacht?.draftFt ?? '',
|
Draft: context.yacht?.draftFt ?? '',
|
||||||
'Berth Number': context.berth?.mooringNumber ?? '',
|
'Berth Number': context.berth?.mooringNumber ?? '',
|
||||||
|
'Berth Range': context.eoiBerthRange,
|
||||||
Lease_10: false,
|
Lease_10: false,
|
||||||
Purchase: true,
|
Purchase: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,12 +4,13 @@ import { db } from '@/lib/db';
|
|||||||
import { berths } from '@/lib/db/schema/berths';
|
import { berths } from '@/lib/db/schema/berths';
|
||||||
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients';
|
||||||
import { companies, companyAddresses } from '@/lib/db/schema/companies';
|
import { companies, companyAddresses } from '@/lib/db/schema/companies';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { yachts } from '@/lib/db/schema/yachts';
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
import { getCountryName } from '@/lib/i18n/countries';
|
import { getCountryName } from '@/lib/i18n/countries';
|
||||||
import { NotFoundError, ValidationError } from '@/lib/errors';
|
import { NotFoundError, ValidationError } from '@/lib/errors';
|
||||||
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
|
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
|
||||||
|
import { formatBerthRange } from '@/lib/templates/berth-range';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -57,6 +58,13 @@ export type EoiContext = {
|
|||||||
priceCurrency: string;
|
priceCurrency: string;
|
||||||
tenureType: string;
|
tenureType: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
/**
|
||||||
|
* Compact range string for every berth in the interest's EOI bundle
|
||||||
|
* (rows where `interest_berths.is_in_eoi_bundle=true`). Populates the
|
||||||
|
* Documenso `Berth Range` form field for multi-berth EOIs (plan §1
|
||||||
|
* + §4.6). Empty string when the bundle is empty.
|
||||||
|
*/
|
||||||
|
eoiBerthRange: string;
|
||||||
interest: {
|
interest: {
|
||||||
stage: string;
|
stage: string;
|
||||||
leadCategory: string | null;
|
leadCategory: string | null;
|
||||||
@@ -102,6 +110,16 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
|||||||
const primaryBerth = await getPrimaryBerth(interest.id);
|
const primaryBerth = await getPrimaryBerth(interest.id);
|
||||||
const primaryBerthId = primaryBerth?.berthId ?? null;
|
const primaryBerthId = primaryBerth?.berthId ?? null;
|
||||||
|
|
||||||
|
// Resolve every berth in the EOI bundle (is_in_eoi_bundle=true) for the
|
||||||
|
// multi-berth EOI compact-range merge field. Empty bundle → "" so the
|
||||||
|
// Documenso template renders blank rather than "undefined".
|
||||||
|
const bundleRows = await db
|
||||||
|
.select({ mooringNumber: berths.mooringNumber })
|
||||||
|
.from(interestBerths)
|
||||||
|
.innerJoin(berths, eq(berths.id, interestBerths.berthId))
|
||||||
|
.where(and(eq(interestBerths.interestId, interest.id), eq(interestBerths.isInEoiBundle, true)));
|
||||||
|
const eoiBerthRange = formatBerthRange(bundleRows.map((r) => r.mooringNumber));
|
||||||
|
|
||||||
// Parallelise independent reads. Yacht and berth are both nullable -
|
// Parallelise independent reads. Yacht and berth are both nullable -
|
||||||
// the EOI's Section 3 stays blank when they're absent.
|
// the EOI's Section 3 stays blank when they're absent.
|
||||||
const [yacht, berth, client, port] = await Promise.all([
|
const [yacht, berth, client, port] = await Promise.all([
|
||||||
@@ -277,6 +295,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi
|
|||||||
tenureType: berth.tenureType,
|
tenureType: berth.tenureType,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
eoiBerthRange,
|
||||||
interest: {
|
interest: {
|
||||||
stage: interest.pipelineStage,
|
stage: interest.pipelineStage,
|
||||||
leadCategory: interest.leadCategory,
|
leadCategory: interest.leadCategory,
|
||||||
|
|||||||
102
src/lib/templates/berth-range.ts
Normal file
102
src/lib/templates/berth-range.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Compress a list of mooring numbers into a compact range string used
|
||||||
|
* inside the Documenso EOI template (plan §4.6).
|
||||||
|
*
|
||||||
|
* formatBerthRange([]) -> ''
|
||||||
|
* formatBerthRange(['A5']) -> 'A5'
|
||||||
|
* formatBerthRange(['A1', 'A2', 'A3']) -> 'A1-A3'
|
||||||
|
* formatBerthRange(['A1', 'A3']) -> 'A1, A3'
|
||||||
|
* formatBerthRange(['A1', 'A2', 'B5', 'B6', 'B7']) -> 'A1-A2, B5-B7'
|
||||||
|
* formatBerthRange(['B5', 'A1', 'A2']) -> 'A1-A2, B5' (sorted)
|
||||||
|
* formatBerthRange(['A1', 'A1', 'A2']) -> 'A1-A2' (dedup)
|
||||||
|
*
|
||||||
|
* Inputs that don't match the canonical `^[A-Z]+\d+$` pattern (multi-
|
||||||
|
* letter prefixes are fine, but suffixes like 'A1a' or pure numbers
|
||||||
|
* are not) are passed through unchanged and joined with the rest by
|
||||||
|
* comma. A warning is logged.
|
||||||
|
*
|
||||||
|
* The returned string is the **only** place berths are rendered as a
|
||||||
|
* compressed range. CRM UI always shows berths as individual chips
|
||||||
|
* (plan §1).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '@/lib/logger';
|
||||||
|
|
||||||
|
const CANONICAL = /^([A-Z]+)(\d+)$/;
|
||||||
|
|
||||||
|
interface ParsedMooring {
|
||||||
|
prefix: string;
|
||||||
|
number: number;
|
||||||
|
raw: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryParse(raw: string): ParsedMooring | null {
|
||||||
|
const m = CANONICAL.exec(raw.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
return {
|
||||||
|
prefix: m[1]!,
|
||||||
|
number: parseInt(m[2]!, 10),
|
||||||
|
raw: raw.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBerthRange(mooringNumbers: readonly string[]): string {
|
||||||
|
if (mooringNumbers.length === 0) return '';
|
||||||
|
|
||||||
|
// Dedup while preserving the original-input order so the warning log
|
||||||
|
// (if any) reports the actual offending input.
|
||||||
|
const dedupSeen = new Set<string>();
|
||||||
|
const dedup: string[] = [];
|
||||||
|
for (const m of mooringNumbers) {
|
||||||
|
const trimmed = m.trim();
|
||||||
|
if (trimmed && !dedupSeen.has(trimmed)) {
|
||||||
|
dedupSeen.add(trimmed);
|
||||||
|
dedup.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: ParsedMooring[] = [];
|
||||||
|
const passthrough: string[] = [];
|
||||||
|
for (const m of dedup) {
|
||||||
|
const p = tryParse(m);
|
||||||
|
if (p) parsed.push(p);
|
||||||
|
else {
|
||||||
|
logger.warn({ mooring: m }, 'formatBerthRange: non-canonical mooring; passing through');
|
||||||
|
passthrough.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort canonical-form moorings by (prefix, number).
|
||||||
|
parsed.sort((a, b) => {
|
||||||
|
if (a.prefix !== b.prefix) return a.prefix < b.prefix ? -1 : 1;
|
||||||
|
return a.number - b.number;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compress consecutive runs within the same prefix into "A1-A3".
|
||||||
|
const segments: string[] = [];
|
||||||
|
let runStart: ParsedMooring | null = null;
|
||||||
|
let runEnd: ParsedMooring | null = null;
|
||||||
|
for (const p of parsed) {
|
||||||
|
if (runStart && runEnd && p.prefix === runEnd.prefix && p.number === runEnd.number + 1) {
|
||||||
|
runEnd = p;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (runStart && runEnd) {
|
||||||
|
segments.push(formatRun(runStart, runEnd));
|
||||||
|
}
|
||||||
|
runStart = p;
|
||||||
|
runEnd = p;
|
||||||
|
}
|
||||||
|
if (runStart && runEnd) {
|
||||||
|
segments.push(formatRun(runStart, runEnd));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append non-canonical inputs verbatim.
|
||||||
|
segments.push(...passthrough);
|
||||||
|
|
||||||
|
return segments.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRun(start: ParsedMooring, end: ParsedMooring): string {
|
||||||
|
return start.raw === end.raw ? start.raw : `${start.raw}-${end.raw}`;
|
||||||
|
}
|
||||||
@@ -61,6 +61,12 @@ export const MERGE_FIELDS: MergeFieldCatalog = {
|
|||||||
{ token: '{{berth.tenureType}}', label: 'Tenure Type', required: false },
|
{ token: '{{berth.tenureType}}', label: 'Tenure Type', required: false },
|
||||||
{ token: '{{berth.tenureYears}}', label: 'Tenure Years', required: false },
|
{ token: '{{berth.tenureYears}}', label: 'Tenure Years', required: false },
|
||||||
],
|
],
|
||||||
|
eoi: [
|
||||||
|
// Compact range string of every berth in the EOI bundle - used inside
|
||||||
|
// the Documenso PDF where space is constrained. CRM UI always renders
|
||||||
|
// the bundle as individual berth chips. See `formatBerthRange`.
|
||||||
|
{ token: '{{eoi.berthRange}}', label: 'EOI Berth Range', required: false },
|
||||||
|
],
|
||||||
reservation: [
|
reservation: [
|
||||||
{ token: '{{reservation.startDate}}', label: 'Reservation Start Date', required: false },
|
{ token: '{{reservation.startDate}}', label: 'Reservation Start Date', required: false },
|
||||||
{ token: '{{reservation.endDate}}', label: 'Reservation End Date', required: false },
|
{ token: '{{reservation.endDate}}', label: 'Reservation End Date', required: false },
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
|
|||||||
priceCurrency: 'USD',
|
priceCurrency: 'USD',
|
||||||
tenureType: 'permanent',
|
tenureType: 'permanent',
|
||||||
},
|
},
|
||||||
|
eoiBerthRange: '',
|
||||||
interest: { stage: 'open', leadCategory: null, dateFirstContact: null, notes: null },
|
interest: { stage: 'open', leadCategory: null, dateFirstContact: null, notes: null },
|
||||||
port: { name: 'Port Nimara', defaultCurrency: 'USD' },
|
port: { name: 'Port Nimara', defaultCurrency: 'USD' },
|
||||||
date: { today: '2026-04-26', year: '2026' },
|
date: { today: '2026-04-26', year: '2026' },
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function makeContext(overrides?: Partial<EoiContext>): EoiContext {
|
|||||||
priceCurrency: 'USD',
|
priceCurrency: 'USD',
|
||||||
tenureType: 'permanent',
|
tenureType: 'permanent',
|
||||||
},
|
},
|
||||||
|
eoiBerthRange: 'A12',
|
||||||
interest: {
|
interest: {
|
||||||
stage: 'open',
|
stage: 'open',
|
||||||
leadCategory: null,
|
leadCategory: null,
|
||||||
@@ -78,6 +79,7 @@ describe('buildDocumensoPayload', () => {
|
|||||||
Width: '14',
|
Width: '14',
|
||||||
Draft: '6',
|
Draft: '6',
|
||||||
'Berth Number': 'A12',
|
'Berth Number': 'A12',
|
||||||
|
'Berth Range': 'A12',
|
||||||
Lease_10: false,
|
Lease_10: false,
|
||||||
Purchase: true,
|
Purchase: true,
|
||||||
});
|
});
|
||||||
|
|||||||
66
tests/unit/templates/berth-range.test.ts
Normal file
66
tests/unit/templates/berth-range.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { formatBerthRange } from '@/lib/templates/berth-range';
|
||||||
|
|
||||||
|
describe('formatBerthRange', () => {
|
||||||
|
it('returns "" for an empty list', () => {
|
||||||
|
expect(formatBerthRange([])).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a single mooring as-is, never "A5-A5"', () => {
|
||||||
|
expect(formatBerthRange(['A5'])).toBe('A5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compresses a consecutive run', () => {
|
||||||
|
expect(formatBerthRange(['A1', 'A2', 'A3'])).toBe('A1-A3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins non-consecutive single moorings with comma', () => {
|
||||||
|
expect(formatBerthRange(['A1', 'A3'])).toBe('A1, A3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple runs across prefixes', () => {
|
||||||
|
expect(formatBerthRange(['A1', 'A2', 'B5', 'B6', 'B7'])).toBe('A1-A2, B5-B7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts unsorted input by (prefix, number)', () => {
|
||||||
|
expect(formatBerthRange(['B5', 'A1', 'A2'])).toBe('A1-A2, B5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dedupes', () => {
|
||||||
|
expect(formatBerthRange(['A1', 'A1', 'A2'])).toBe('A1-A2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not run across prefixes (B1 after A11 stays separate)', () => {
|
||||||
|
expect(formatBerthRange(['A11', 'B1'])).toBe('A11, B1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed runs + singletons', () => {
|
||||||
|
expect(formatBerthRange(['A1', 'A2', 'A4', 'B5', 'B6'])).toBe('A1-A2, A4, B5-B6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multi-letter prefixes', () => {
|
||||||
|
expect(formatBerthRange(['AA1', 'AA2', 'BB7'])).toBe('AA1-AA2, BB7');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats numerically-sequential moorings as consecutive (A9 + A10)', () => {
|
||||||
|
expect(formatBerthRange(['A9', 'A10', 'A11'])).toBe('A9-A11');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes non-canonical inputs through unchanged', () => {
|
||||||
|
expect(formatBerthRange(['A1', 'A2', 'B-LEG'])).toBe('A1-A2, B-LEG');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trims whitespace', () => {
|
||||||
|
expect(formatBerthRange([' A1 ', 'A2'])).toBe('A1-A2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops empty strings without error', () => {
|
||||||
|
expect(formatBerthRange(['A1', '', 'A2'])).toBe('A1-A2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles long ranges (50 consecutive)', () => {
|
||||||
|
const moorings = Array.from({ length: 50 }, (_, i) => `A${i + 1}`);
|
||||||
|
expect(formatBerthRange(moorings)).toBe('A1-A50');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user