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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user