Files
pn-new-crm/src/lib/services/berth-recommender.service.ts
Matt 60365dc3de
Some checks failed
Build & Push Docker Images / lint (push) Successful in 1m37s
Build & Push Docker Images / build-and-push (push) Failing after 24s
fix(audit): backlog sweep — partial archived indexes, custom-fields per-entity gate, polish
Wave through the 2026-05-07 backlog of small/concrete audit-final-deferred
items (deferring the Documenso Phases 2-7 build and items needing design
decisions or live external instances).

DB schema:
- Migration 0046 converts 5 composite (port_id, archived_at) indexes to
  partial WHERE archived_at IS NULL — clients, interests, yachts, and
  both residential tables. Smaller, faster planner choice for the
  dominant list-query shape.

Multi-tenant isolation:
- document_sends now verifies recipient.interestId belongs to the port
  before landing on the audit row (the surrounding clientId check was
  already port-scoped; interestId pollution was the gap).

Routes / API:
- /api/v1/custom-fields/[entityId] requires entityType query param and
  gates on the matching resource permission (clients/interests/berths/
  yachts/companies). Fixes the cross-resource gap where a user with
  clients.view could read company custom-field values.
- Admin user list trash button wrapped in PermissionGate (edit was
  already gated; remove was not).

Service polish:
- berth-recommender accepts string-shaped JSONB booleans
  ('true'/'false') so admin UIs that wrap values as strings don't
  silently fall through to defaults.
- expense-pdf renderReceiptHeader anchors all text positions to a
  captured baseY rather than reading mutating doc.y after rect+stroke.
  Headers no longer drift on the first receipt page after a soft page
  break.
- berth-pdf apply: collect non-finite numeric coercion drops + warn-log
  them so partial silent drops are observable (was invisible because
  the no-fields-supplied check only fires when ALL drop).
- Storage cache fingerprint comment documenting the encrypted-secret
  invariant + the explicit invalidation hook.

UI polish:
- invoice-detail typed: replaced two `any` casts with a proper
  InvoiceDetailData / LineItem / LinkedExpense interface set.
- YachtForm now accepts initialOwner prop. Wired through:
  - client-yachts-tab passes { type: 'client', id: clientId }
  - interest-form passes { type: 'client', id: selectedClientId }
- Interest-form yacht picker now includes company-owned yachts where
  the selected client is a member (fetches client.companies and feeds
  YachtPicker an array filter). Plus an inline "Add new" button that
  opens YachtForm pre-bound to the client.
- YachtPicker accepts ownerFilter as single OR array for "match any"
  semantics.

BACKLOG.md updated with what landed vs what's still deferred (and why
each deferred item is genuinely larger than this push warrants).

Tests: 1185/1185 vitest, tsc clean.
2026-05-07 21:45:42 +02:00

668 lines
24 KiB
TypeScript

/**
* Berth recommender (plan §4.4 + §13).
*
* Pure SQL ranking - no AI. The single CTE chain:
*
* 1. interest_input - the desired dimensions + port for this interest
* 2. feasible - berths in the port that can fit the yacht
* (length/width/draft >= desired) AND don't exceed
* the configured max-oversize percentage
* 3. tier_inputs - per-berth aggregates over interest_berths +
* interests: active count, lost count, max stage
* among active interests, latest fall-through
* timestamp, total historical interest count,
* EOI-signed count
* 4. classified - assign tier A/B/C/D
*
* The feasible berths are returned to JavaScript for fit-score + heat
* calculation - keeping that out of SQL lets per-port admins tune the
* heat weights without a code deploy.
*
* Heat scoring (only relevant for tier B; berths surface back into the
* recommender pool after a fall-through):
* - recency : how recently did the last interest fall through
* - furthest stage : how close did the prior interest get to closing
* - historical interest : how often does this berth attract interest
* - historical EOI count : how often does interest convert to EOI
*
* Per-port settings (defaults below) drive the cap, fall-through policy,
* heat weights, and tier-D visibility.
*/
import { and, eq, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { systemSettings } from '@/lib/db/schema/system';
import { interests } from '@/lib/db/schema/interests';
import { CodedError } from '@/lib/errors';
// ─── Settings ──────────────────────────────────────────────────────────────
export interface RecommenderSettings {
maxOversizePct: number;
topNDefault: number;
fallthroughPolicy: 'immediate_with_heat' | 'cooldown' | 'never_auto_recommend';
fallthroughCooldownDays: number;
heatWeightRecency: number;
heatWeightFurthestStage: number;
heatWeightInterestCount: number;
heatWeightEoiCount: number;
tierLadderHideLateStage: boolean;
}
export const DEFAULT_RECOMMENDER_SETTINGS: RecommenderSettings = {
maxOversizePct: 30,
topNDefault: 8,
fallthroughPolicy: 'immediate_with_heat',
fallthroughCooldownDays: 30,
heatWeightRecency: 30,
heatWeightFurthestStage: 40,
heatWeightInterestCount: 15,
heatWeightEoiCount: 15,
tierLadderHideLateStage: true,
};
const SETTINGS_KEYS = {
maxOversizePct: 'recommender_max_oversize_pct',
topNDefault: 'recommender_top_n_default',
fallthroughPolicy: 'fallthrough_policy',
fallthroughCooldownDays: 'fallthrough_cooldown_days',
heatWeightRecency: 'heat_weight_recency',
heatWeightFurthestStage: 'heat_weight_furthest_stage',
heatWeightInterestCount: 'heat_weight_interest_count',
heatWeightEoiCount: 'heat_weight_eoi_count',
tierLadderHideLateStage: 'tier_ladder_hide_late_stage',
} as const;
/**
* Reads recommender settings for the port, layered over the defaults.
* Per-port row wins; null portId row is the global fallback; defaults
* cover anything still missing.
*/
export async function loadRecommenderSettings(portId: string): Promise<RecommenderSettings> {
const rows = await db
.select({ key: systemSettings.key, value: systemSettings.value, portId: systemSettings.portId })
.from(systemSettings)
.where(
and(
inArray(systemSettings.key, Object.values(SETTINGS_KEYS)),
sql`(${systemSettings.portId} = ${portId} OR ${systemSettings.portId} IS NULL)`,
),
);
const portRows = new Map<string, unknown>();
const globalRows = new Map<string, unknown>();
for (const r of rows) {
if (r.portId === portId) portRows.set(r.key, r.value);
else if (r.portId === null) globalRows.set(r.key, r.value);
}
const pick = <T>(key: string, parse: (v: unknown) => T | null, fallback: T): T => {
const portVal = portRows.has(key) ? parse(portRows.get(key)) : null;
if (portVal !== null) return portVal;
const globalVal = globalRows.has(key) ? parse(globalRows.get(key)) : null;
if (globalVal !== null) return globalVal;
return fallback;
};
const asNumber = (v: unknown): number | null => {
if (v === null || v === undefined) return null;
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
if (typeof v === 'string') {
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
}
return null;
};
const asBool = (v: unknown): boolean | null => {
if (typeof v === 'boolean') return v;
// Some admin UIs (or older settings rows) persist booleans as the
// strings "true" / "false" inside the JSONB blob. Without this
// tolerant parse, a per-port override quietly falls through to the
// default and the admin's tuning has no effect.
if (typeof v === 'string') {
if (v === 'true') return true;
if (v === 'false') return false;
}
return null;
};
const asPolicy = (v: unknown): RecommenderSettings['fallthroughPolicy'] | null => {
if (v === 'immediate_with_heat' || v === 'cooldown' || v === 'never_auto_recommend') {
return v;
}
return null;
};
return {
maxOversizePct: pick(
SETTINGS_KEYS.maxOversizePct,
asNumber,
DEFAULT_RECOMMENDER_SETTINGS.maxOversizePct,
),
topNDefault: pick(
SETTINGS_KEYS.topNDefault,
asNumber,
DEFAULT_RECOMMENDER_SETTINGS.topNDefault,
),
fallthroughPolicy: pick(
SETTINGS_KEYS.fallthroughPolicy,
asPolicy,
DEFAULT_RECOMMENDER_SETTINGS.fallthroughPolicy,
),
fallthroughCooldownDays: pick(
SETTINGS_KEYS.fallthroughCooldownDays,
asNumber,
DEFAULT_RECOMMENDER_SETTINGS.fallthroughCooldownDays,
),
heatWeightRecency: pick(
SETTINGS_KEYS.heatWeightRecency,
asNumber,
DEFAULT_RECOMMENDER_SETTINGS.heatWeightRecency,
),
heatWeightFurthestStage: pick(
SETTINGS_KEYS.heatWeightFurthestStage,
asNumber,
DEFAULT_RECOMMENDER_SETTINGS.heatWeightFurthestStage,
),
heatWeightInterestCount: pick(
SETTINGS_KEYS.heatWeightInterestCount,
asNumber,
DEFAULT_RECOMMENDER_SETTINGS.heatWeightInterestCount,
),
heatWeightEoiCount: pick(
SETTINGS_KEYS.heatWeightEoiCount,
asNumber,
DEFAULT_RECOMMENDER_SETTINGS.heatWeightEoiCount,
),
tierLadderHideLateStage: pick(
SETTINGS_KEYS.tierLadderHideLateStage,
asBool,
DEFAULT_RECOMMENDER_SETTINGS.tierLadderHideLateStage,
),
};
}
// ─── Tier mapping ──────────────────────────────────────────────────────────
const STAGE_ORDER: Record<string, number> = {
open: 1,
details_sent: 2,
in_communication: 3,
eoi_sent: 4,
eoi_signed: 5,
deposit_10pct: 6,
contract_sent: 7,
contract_signed: 8,
completed: 9,
};
/** Stage at which a berth is "in late stage" (Tier D when active). */
const LATE_STAGE_THRESHOLD = STAGE_ORDER.deposit_10pct!; // 6
export type Tier = 'A' | 'B' | 'C' | 'D';
interface TierInputs {
activeInterestCount: number;
lostCount: number;
maxActiveStage: number;
}
export function classifyTier(t: TierInputs): Tier {
if (t.activeInterestCount > 0 && t.maxActiveStage >= LATE_STAGE_THRESHOLD) return 'D';
if (t.activeInterestCount > 0) return 'C';
if (t.lostCount > 0) return 'B';
return 'A';
}
// ─── Heat scoring ──────────────────────────────────────────────────────────
interface HeatInputs {
latestFallthroughAt: Date | null;
totalInterestCount: number;
eoiSignedCount: number;
/** Stage code at the time of the most recent fall-through. */
fallthroughMaxStage: number;
}
export interface HeatBreakdown {
recency: number;
furthestStage: number;
interestCount: number;
eoiCount: number;
total: number;
}
/**
* 0..100 heat score with per-port-tunable weights. Each component is
* normalized 0..1 before being multiplied by its weight; the totals sum
* to 100 by construction (the four weights are expected to sum to 100,
* but the function clamps regardless so admin-tuning errors don't
* produce out-of-range scores).
*/
export function computeHeat(
inputs: HeatInputs,
weights: Pick<
RecommenderSettings,
| 'heatWeightRecency'
| 'heatWeightFurthestStage'
| 'heatWeightInterestCount'
| 'heatWeightEoiCount'
>,
now: Date = new Date(),
): HeatBreakdown {
const weightSum =
weights.heatWeightRecency +
weights.heatWeightFurthestStage +
weights.heatWeightInterestCount +
weights.heatWeightEoiCount;
const norm = weightSum > 0 ? 100 / weightSum : 0;
// 1.0 if fallthrough <= 30 days, decays linearly to 0 at 365 days.
let recency = 0;
if (inputs.latestFallthroughAt) {
const ageDays = (now.getTime() - inputs.latestFallthroughAt.getTime()) / 86_400_000;
if (ageDays <= 30) recency = 1;
else if (ageDays >= 365) recency = 0;
else recency = 1 - (ageDays - 30) / (365 - 30);
}
// 0 if max stage is open; 1 at signed/deposit/contract.
const furthestStage =
inputs.fallthroughMaxStage <= 1
? 0
: Math.min(1, (inputs.fallthroughMaxStage - 1) / (LATE_STAGE_THRESHOLD - 1));
// 0 at 0 interests, 1 at 5+ interests.
const interestCount = Math.min(1, inputs.totalInterestCount / 5);
// 0 at 0 EOIs, 1 at 3+ EOIs.
const eoiCount = Math.min(1, inputs.eoiSignedCount / 3);
const r = recency * weights.heatWeightRecency * norm;
const f = furthestStage * weights.heatWeightFurthestStage * norm;
const ic = interestCount * weights.heatWeightInterestCount * norm;
const ec = eoiCount * weights.heatWeightEoiCount * norm;
const total = Math.max(0, Math.min(100, r + f + ic + ec));
return {
recency: Math.round(r * 100) / 100,
furthestStage: Math.round(f * 100) / 100,
interestCount: Math.round(ic * 100) / 100,
eoiCount: Math.round(ec * 100) / 100,
total: Math.round(total * 100) / 100,
};
}
// ─── Recommend ────────────────────────────────────────────────────────────
export interface RecommendBerthsArgs {
interestId: string;
portId: string;
/** Override the per-port topNDefault. */
topN?: number;
/** Override the per-port maxOversizePct. */
maxOversizePct?: number;
/** Show late-stage (Tier D) berths even when the per-port setting hides them. */
showLateStage?: boolean;
/** Optional rep-supplied amenity filters. */
amenityFilters?: {
minPowerCapacityKw?: number;
requiredVoltage?: number;
requiredAccess?: string;
requiredMooringType?: string;
requiredCleatCapacity?: string;
};
}
export interface Recommendation {
berthId: string;
mooringNumber: string;
area: string | null;
tier: Tier;
fitScore: number;
sizeBufferPct: number | null;
heat: HeatBreakdown | null;
reasons: {
dimensional: string;
pipeline: string;
amenities?: string;
heat?: string;
};
lengthFt: number | null;
widthFt: number | null;
draftFt: number | null;
status: string;
amenities: {
powerCapacity: number | null;
voltage: number | null;
access: string | null;
mooringType: string | null;
cleatCapacity: string | null;
};
}
interface RawRow extends Record<string, unknown> {
berthId: string;
mooringNumber: string;
area: string | null;
status: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
powerCapacity: string | null;
voltage: string | null;
access: string | null;
mooringType: string | null;
cleatCapacity: string | null;
activeInterestCount: number;
lostCount: number;
maxActiveStage: number;
latestFallthroughAt: Date | null;
fallthroughMaxStage: number;
totalInterestCount: number;
eoiSignedCount: number;
}
interface InterestInput {
desiredLengthFt: number | null;
desiredWidthFt: number | null;
desiredDraftFt: number | null;
portId: string;
}
async function loadInterestInput(interestId: string): Promise<InterestInput | null> {
const [row] = await db
.select({
desiredLengthFt: interests.desiredLengthFt,
desiredWidthFt: interests.desiredWidthFt,
desiredDraftFt: interests.desiredDraftFt,
portId: interests.portId,
})
.from(interests)
.where(eq(interests.id, interestId))
.limit(1);
if (!row) return null;
const toNum = (v: string | null): number | null => {
if (v === null) return null;
const n = parseFloat(v);
return Number.isFinite(n) ? n : null;
};
return {
desiredLengthFt: toNum(row.desiredLengthFt),
desiredWidthFt: toNum(row.desiredWidthFt),
desiredDraftFt: toNum(row.desiredDraftFt),
portId: row.portId,
};
}
/**
* Run the recommender. Returns ranked recommendations top-N. Multi-port
* isolation is enforced both in the CTE (`b.port_id = $portId`) and via
* the interest's own port_id - cross-port queries fail explicitly
* rather than leak (§14.10 critical).
*/
export async function recommendBerths(args: RecommendBerthsArgs): Promise<Recommendation[]> {
const settings = await loadRecommenderSettings(args.portId);
const interestInput = await loadInterestInput(args.interestId);
if (!interestInput) return [];
if (interestInput.portId !== args.portId) {
// Defensive: caller passed a port that doesn't own this interest.
throw new CodedError('RECOMMENDER_INTEREST_PORT_MISMATCH', {
internalMessage: `interest ${args.interestId} belongs to port ${interestInput.portId}, not ${args.portId}`,
});
}
const oversizePct = args.maxOversizePct ?? settings.maxOversizePct;
const topN = args.topN ?? settings.topNDefault;
const showLateStage = args.showLateStage ?? !settings.tierLadderHideLateStage;
const oversizeMultiplier = 1 + oversizePct / 100;
const predicates: ReturnType<typeof sql>[] = [
sql`b.port_id = ${args.portId}`,
sql`b.status <> 'sold'`,
];
if (interestInput.desiredLengthFt !== null) {
predicates.push(sql`b.length_ft::numeric >= ${interestInput.desiredLengthFt}`);
predicates.push(
sql`b.length_ft::numeric <= ${interestInput.desiredLengthFt}::numeric * ${oversizeMultiplier}::numeric`,
);
} else if (interestInput.desiredWidthFt !== null) {
// Width-only feasibility: cap the length using a generous L/W ratio
// so the recommender doesn't surface a 200 ft berth for a 30 ft beam
// request. Plan §4.4 promised an upper bound; without this branch the
// null-length path skipped the cap entirely.
predicates.push(
sql`b.length_ft::numeric <= ${interestInput.desiredWidthFt}::numeric * 8::numeric * ${oversizeMultiplier}::numeric`,
);
}
if (interestInput.desiredWidthFt !== null) {
predicates.push(sql`b.width_ft::numeric >= ${interestInput.desiredWidthFt}`);
}
if (interestInput.desiredDraftFt !== null) {
predicates.push(sql`b.draft_ft::numeric >= ${interestInput.desiredDraftFt}`);
}
if (args.amenityFilters?.minPowerCapacityKw != null) {
predicates.push(sql`b.power_capacity::numeric >= ${args.amenityFilters.minPowerCapacityKw}`);
}
if (args.amenityFilters?.requiredVoltage != null) {
predicates.push(sql`b.voltage::numeric = ${args.amenityFilters.requiredVoltage}`);
}
if (args.amenityFilters?.requiredAccess) {
predicates.push(sql`b.access = ${args.amenityFilters.requiredAccess}`);
}
if (args.amenityFilters?.requiredMooringType) {
predicates.push(sql`b.mooring_type = ${args.amenityFilters.requiredMooringType}`);
}
if (args.amenityFilters?.requiredCleatCapacity) {
predicates.push(sql`b.cleat_capacity = ${args.amenityFilters.requiredCleatCapacity}`);
}
const whereClause = sql.join(predicates, sql` AND `);
const rawRows = await db.execute<RawRow>(sql`
WITH feasible AS (
SELECT b.*
FROM berths b
WHERE ${whereClause}
),
aggregates AS (
SELECT
f.id AS berth_id,
-- Active = is_specific_interest=true junction rows only (matches
-- the public-map "Under Offer" filter). An EOI-bundle-only link
-- (is_specific_interest=false, is_in_eoi_bundle=true) is legal
-- coverage, not a pitch, and shouldn't demote the berth.
COUNT(*) FILTER (
WHERE i.archived_at IS NULL
AND i.outcome IS NULL
AND ib.is_specific_interest = true
) AS active_interest_count,
COUNT(*) FILTER (
WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
) AS lost_count,
COALESCE(
MAX(CASE i.pipeline_stage
WHEN 'open' THEN 1
WHEN 'details_sent' THEN 2
WHEN 'in_communication' THEN 3
WHEN 'eoi_sent' THEN 4
WHEN 'eoi_signed' THEN 5
WHEN 'deposit_10pct' THEN 6
WHEN 'contract_sent' THEN 7
WHEN 'contract_signed' THEN 8
ELSE 0 END
) FILTER (WHERE i.archived_at IS NULL AND i.outcome IS NULL),
0
) AS max_active_stage,
MAX(i.outcome_at) FILTER (
WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')
) AS latest_fallthrough_at,
COALESCE(
MAX(CASE i.pipeline_stage
WHEN 'open' THEN 1
WHEN 'details_sent' THEN 2
WHEN 'in_communication' THEN 3
WHEN 'eoi_sent' THEN 4
WHEN 'eoi_signed' THEN 5
WHEN 'deposit_10pct' THEN 6
WHEN 'contract_sent' THEN 7
WHEN 'contract_signed' THEN 8
ELSE 0 END
) FILTER (WHERE i.outcome IS NOT NULL AND (i.outcome::text LIKE 'lost%' OR i.outcome = 'cancelled')),
0
) AS fallthrough_max_stage,
-- COUNT(ib.berth_id) (not COUNT(*)) so a berth with no junction
-- rows reports 0 — the LEFT JOIN otherwise produces a single
-- NULL-right-side row that COUNT(*) would tally as 1 and inflate
-- the heat interest-count component for berths with no history.
-- The FILTER also enforces port isolation defense-in-depth: an
-- orphan junction row whose interest belongs to a different port
-- (which the new cross-port guard now prevents but pre-existing
-- data may carry) shouldn't inflate this count.
COUNT(ib.berth_id) FILTER (WHERE i.id IS NOT NULL) AS total_interest_count,
COUNT(*) FILTER (WHERE i.eoi_status = 'signed') AS eoi_signed_count
FROM feasible f
LEFT JOIN interest_berths ib ON ib.berth_id = f.id
LEFT JOIN interests i ON i.id = ib.interest_id AND i.port_id = ${args.portId}
GROUP BY f.id
)
SELECT
f.id AS "berthId",
f.mooring_number AS "mooringNumber",
f.area,
f.status,
f.length_ft AS "lengthFt",
f.width_ft AS "widthFt",
f.draft_ft AS "draftFt",
f.power_capacity AS "powerCapacity",
f.voltage,
f.access,
f.mooring_type AS "mooringType",
f.cleat_capacity AS "cleatCapacity",
a.active_interest_count::int AS "activeInterestCount",
a.lost_count::int AS "lostCount",
a.max_active_stage::int AS "maxActiveStage",
a.latest_fallthrough_at AS "latestFallthroughAt",
a.fallthrough_max_stage::int AS "fallthroughMaxStage",
a.total_interest_count::int AS "totalInterestCount",
a.eoi_signed_count::int AS "eoiSignedCount"
FROM feasible f
JOIN aggregates a ON a.berth_id = f.id
`);
const rows = (rawRows as { rows?: RawRow[] }).rows ?? (rawRows as unknown as RawRow[]);
// Apply tier classification + fall-through cooldown filter + heat scoring + fit ranking in JS.
const cooldownCutoff =
settings.fallthroughPolicy === 'cooldown'
? new Date(Date.now() - settings.fallthroughCooldownDays * 86_400_000)
: null;
const recommendations: Recommendation[] = [];
for (const r of rows) {
const tier = classifyTier(r);
if (tier === 'D' && !showLateStage) continue;
if (tier === 'B' && settings.fallthroughPolicy === 'never_auto_recommend') continue;
if (
tier === 'B' &&
cooldownCutoff &&
r.latestFallthroughAt &&
new Date(r.latestFallthroughAt) > cooldownCutoff
) {
continue;
}
const lengthFt = r.lengthFt === null ? null : parseFloat(r.lengthFt);
const widthFt = r.widthFt === null ? null : parseFloat(r.widthFt);
const draftFt = r.draftFt === null ? null : parseFloat(r.draftFt);
const sizeBufferPct =
interestInput.desiredLengthFt && lengthFt
? Math.round(
((lengthFt - interestInput.desiredLengthFt) / interestInput.desiredLengthFt) * 1000,
) / 10
: null;
const heat =
tier === 'B'
? computeHeat(
{
latestFallthroughAt: r.latestFallthroughAt ? new Date(r.latestFallthroughAt) : null,
totalInterestCount: r.totalInterestCount,
eoiSignedCount: r.eoiSignedCount,
fallthroughMaxStage: r.fallthroughMaxStage,
},
settings,
)
: null;
// Fit score: tier weight + 1/(1+buffer) closeness + heat boost.
// Tier base scores: A=80, B=60+heat, C=40, D=20. Smaller buffer adds up to 20.
const tierBase = { A: 80, B: 60, C: 40, D: 20 }[tier];
const closeness =
sizeBufferPct === null
? 10
: Math.max(0, 20 - Math.abs(sizeBufferPct) / Math.max(1, oversizePct / 20));
const heatBoost = heat ? heat.total / 5 : 0;
const fitScore = Math.max(0, Math.min(100, tierBase + closeness + heatBoost));
recommendations.push({
berthId: r.berthId,
mooringNumber: r.mooringNumber,
area: r.area,
tier,
fitScore: Math.round(fitScore * 10) / 10,
sizeBufferPct,
heat,
reasons: {
dimensional:
sizeBufferPct === null
? 'No length specified - all feasible berths shown'
: `Within ${Math.abs(sizeBufferPct)}% of desired length`,
pipeline: pipelineReason(tier, r),
...(heat ? { heat: heatSummary(heat) } : {}),
...(args.amenityFilters && Object.keys(args.amenityFilters).length > 0
? { amenities: 'All required amenities matched' }
: {}),
},
lengthFt,
widthFt,
draftFt,
status: r.status,
amenities: {
powerCapacity: r.powerCapacity === null ? null : parseFloat(r.powerCapacity),
voltage: r.voltage === null ? null : parseFloat(r.voltage),
access: r.access,
mooringType: r.mooringType,
cleatCapacity: r.cleatCapacity,
},
});
}
recommendations.sort((a, b) => {
const tierRank = (t: Tier): number => ({ A: 1, B: 2, C: 3, D: 4 })[t];
if (tierRank(a.tier) !== tierRank(b.tier)) return tierRank(a.tier) - tierRank(b.tier);
return b.fitScore - a.fitScore;
});
return recommendations.slice(0, topN);
}
function pipelineReason(tier: Tier, r: RawRow): string {
switch (tier) {
case 'A':
return 'No prior interest history';
case 'B':
return `Previously fell through (${r.lostCount} time${r.lostCount === 1 ? '' : 's'})`;
case 'C':
return `${r.activeInterestCount} active interest${r.activeInterestCount === 1 ? '' : 's'} in early stage`;
case 'D':
return 'Active interest in late stage';
}
}
function heatSummary(h: HeatBreakdown): string {
return `Heat ${h.total.toFixed(0)}/100 (recency ${h.recency.toFixed(0)}, stage ${h.furthestStage.toFixed(0)}, count ${h.interestCount.toFixed(0)}, EOI ${h.eoiCount.toFixed(0)})`;
}