diff --git a/src/lib/pdf/fill-eoi-form.ts b/src/lib/pdf/fill-eoi-form.ts index 59f045e..213c21c 100644 --- a/src/lib/pdf/fill-eoi-form.ts +++ b/src/lib/pdf/fill-eoi-form.ts @@ -87,6 +87,10 @@ export async function fillEoiFormFields( setText(form, 'Width', context.yacht?.widthFt ?? ''); setText(form, 'Draft', context.yacht?.draftFt ?? ''); 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, 'Lease_10', false); diff --git a/src/lib/services/documenso-payload.ts b/src/lib/services/documenso-payload.ts index 82e62d1..ef20c43 100644 --- a/src/lib/services/documenso-payload.ts +++ b/src/lib/services/documenso-payload.ts @@ -22,6 +22,12 @@ export interface DocumensoTemplatePayload { Width: string; Draft: 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; Purchase: boolean; }; @@ -135,6 +141,7 @@ export function buildDocumensoPayload( Width: context.yacht?.widthFt ?? '', Draft: context.yacht?.draftFt ?? '', 'Berth Number': context.berth?.mooringNumber ?? '', + 'Berth Range': context.eoiBerthRange, Lease_10: false, Purchase: true, }, diff --git a/src/lib/services/eoi-context.ts b/src/lib/services/eoi-context.ts index 2b4184a..f2399bf 100644 --- a/src/lib/services/eoi-context.ts +++ b/src/lib/services/eoi-context.ts @@ -4,12 +4,13 @@ import { db } from '@/lib/db'; import { berths } from '@/lib/db/schema/berths'; import { clients, clientAddresses, clientContacts } from '@/lib/db/schema/clients'; 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 { yachts } from '@/lib/db/schema/yachts'; import { getCountryName } from '@/lib/i18n/countries'; import { NotFoundError, ValidationError } from '@/lib/errors'; import { getPrimaryBerth } from '@/lib/services/interest-berths.service'; +import { formatBerthRange } from '@/lib/templates/berth-range'; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -57,6 +58,13 @@ export type EoiContext = { priceCurrency: string; tenureType: string; } | 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: { stage: string; leadCategory: string | null; @@ -102,6 +110,16 @@ export async function buildEoiContext(interestId: string, portId: string): Promi const primaryBerth = await getPrimaryBerth(interest.id); 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 - // the EOI's Section 3 stays blank when they're absent. const [yacht, berth, client, port] = await Promise.all([ @@ -277,6 +295,7 @@ export async function buildEoiContext(interestId: string, portId: string): Promi tenureType: berth.tenureType, } : null, + eoiBerthRange, interest: { stage: interest.pipelineStage, leadCategory: interest.leadCategory, diff --git a/src/lib/templates/berth-range.ts b/src/lib/templates/berth-range.ts new file mode 100644 index 0000000..eca34cf --- /dev/null +++ b/src/lib/templates/berth-range.ts @@ -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(); + 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}`; +} diff --git a/src/lib/templates/merge-fields.ts b/src/lib/templates/merge-fields.ts index 320373a..875c734 100644 --- a/src/lib/templates/merge-fields.ts +++ b/src/lib/templates/merge-fields.ts @@ -61,6 +61,12 @@ export const MERGE_FIELDS: MergeFieldCatalog = { { token: '{{berth.tenureType}}', label: 'Tenure Type', 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: [ { token: '{{reservation.startDate}}', label: 'Reservation Start Date', required: false }, { token: '{{reservation.endDate}}', label: 'Reservation End Date', required: false }, diff --git a/tests/unit/pdf/fill-eoi-form.test.ts b/tests/unit/pdf/fill-eoi-form.test.ts index 82ef7ca..6f38b1f 100644 --- a/tests/unit/pdf/fill-eoi-form.test.ts +++ b/tests/unit/pdf/fill-eoi-form.test.ts @@ -69,6 +69,7 @@ function makeContext(overrides: Partial = {}): EoiContext { priceCurrency: 'USD', tenureType: 'permanent', }, + eoiBerthRange: '', interest: { stage: 'open', leadCategory: null, dateFirstContact: null, notes: null }, port: { name: 'Port Nimara', defaultCurrency: 'USD' }, date: { today: '2026-04-26', year: '2026' }, diff --git a/tests/unit/services/documenso-payload.test.ts b/tests/unit/services/documenso-payload.test.ts index f9362b1..0365b06 100644 --- a/tests/unit/services/documenso-payload.test.ts +++ b/tests/unit/services/documenso-payload.test.ts @@ -34,6 +34,7 @@ function makeContext(overrides?: Partial): EoiContext { priceCurrency: 'USD', tenureType: 'permanent', }, + eoiBerthRange: 'A12', interest: { stage: 'open', leadCategory: null, @@ -78,6 +79,7 @@ describe('buildDocumensoPayload', () => { Width: '14', Draft: '6', 'Berth Number': 'A12', + 'Berth Range': 'A12', Lease_10: false, Purchase: true, }); diff --git a/tests/unit/templates/berth-range.test.ts b/tests/unit/templates/berth-range.test.ts new file mode 100644 index 0000000..bfa000a --- /dev/null +++ b/tests/unit/templates/berth-range.test.ts @@ -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'); + }); +});