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:
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