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, '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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
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.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 },
|
||||
|
||||
Reference in New Issue
Block a user