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:
Matt Ciaccio
2026-05-05 03:03:29 +02:00
parent b1e787e55c
commit e00e812199
8 changed files with 208 additions and 1 deletions

View 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}`;
}

View File

@@ -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 },