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:
@@ -69,6 +69,7 @@ function makeContext(overrides: Partial<EoiContext> = {}): 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' },
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeContext(overrides?: Partial<EoiContext>): 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,
|
||||
});
|
||||
|
||||
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