Files
pn-new-crm/src/lib/services/recommendations.ts

217 lines
6.8 KiB
TypeScript
Raw Normal View History

import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { clients } from '@/lib/db/schema/clients';
import { berths, berthRecommendations } from '@/lib/db/schema/berths';
import { NotFoundError } from '@/lib/errors';
import { createAuditLog } from '@/lib/audit';
interface AuditMeta {
userId: string;
portId: string;
ipAddress: string;
userAgent: string;
}
// ─── Score a single berth ─────────────────────────────────────────────────────
function scoreBerth(
berth: typeof berths.$inferSelect,
yachtLengthFt: number | null,
yachtWidthFt: number | null,
yachtDraftFt: number | null,
): { score: number; reasons: Record<string, number> } {
const reasons: Record<string, number> = {};
const weights: number[] = [];
if (yachtLengthFt && berth.lengthFt) {
const berthLen = parseFloat(berth.lengthFt);
if (berthLen >= yachtLengthFt) {
// Prefer berths that are not too oversized (within 20% extra is ideal)
const score = berthLen <= yachtLengthFt * 1.2 ? 100 : Math.max(50, 100 - (berthLen / yachtLengthFt - 1.2) * 100);
reasons['length_fit'] = Math.round(score);
weights.push(score);
} else {
// Berth too small
reasons['length_fit'] = 0;
weights.push(0);
}
}
if (yachtWidthFt && berth.widthFt) {
const berthWidth = parseFloat(berth.widthFt);
if (berthWidth >= yachtWidthFt) {
const score = berthWidth <= yachtWidthFt * 1.3 ? 100 : Math.max(40, 100 - (berthWidth / yachtWidthFt - 1.3) * 80);
reasons['beam_fit'] = Math.round(score);
weights.push(score);
} else {
reasons['beam_fit'] = 0;
weights.push(0);
}
}
if (yachtDraftFt && berth.draftFt) {
const berthDraft = parseFloat(berth.draftFt);
if (berthDraft >= yachtDraftFt) {
const score = 100;
reasons['draft_fit'] = score;
weights.push(score);
} else {
reasons['draft_fit'] = 0;
weights.push(0);
}
}
if (weights.length === 0) {
return { score: 50, reasons: { no_dimensions: 50 } };
}
const score = Math.round(weights.reduce((a, b) => a + b, 0) / weights.length);
return { score, reasons };
}
// ─── Generate Recommendations ─────────────────────────────────────────────────
export async function generateRecommendations(
interestId: string,
portId: string,
meta: AuditMeta,
) {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new NotFoundError('Interest');
const client = await db.query.clients.findFirst({
where: eq(clients.id, interest.clientId),
});
const yachtLengthFt = client?.yachtLengthFt ? parseFloat(client.yachtLengthFt) : null;
const yachtWidthFt = client?.yachtWidthFt ? parseFloat(client.yachtWidthFt) : null;
const yachtDraftFt = client?.yachtDraftFt ? parseFloat(client.yachtDraftFt) : null;
// Get all available berths for the port
const availableBerths = await db
.select()
.from(berths)
.where(and(eq(berths.portId, portId), eq(berths.status, 'available')));
// Score each berth
const scored = availableBerths.map((berth) => {
const { score, reasons } = scoreBerth(berth, yachtLengthFt, yachtWidthFt, yachtDraftFt);
return { berth, score, reasons };
});
// Sort by score and take top 10
scored.sort((a, b) => b.score - a.score);
const top10 = scored.slice(0, 10);
// Delete existing AI recommendations for this interest
await db
.delete(berthRecommendations)
.where(
and(
eq(berthRecommendations.interestId, interestId),
eq(berthRecommendations.source, 'ai'),
),
);
// Insert new recommendations
if (top10.length > 0) {
await db.insert(berthRecommendations).values(
top10.map(({ berth, score, reasons }) => ({
interestId,
berthId: berth.id,
matchScore: String(score),
matchReasons: reasons,
source: 'ai' as const,
createdBy: meta.userId,
})),
);
}
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth_recommendation',
entityId: interestId,
metadata: { type: 'ai_generated', count: top10.length },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return listRecommendations(interestId, portId);
}
// ─── List Recommendations ─────────────────────────────────────────────────────
export async function listRecommendations(interestId: string, _portId: string) {
const rows = await db
.select({
id: berthRecommendations.id,
interestId: berthRecommendations.interestId,
berthId: berthRecommendations.berthId,
matchScore: berthRecommendations.matchScore,
matchReasons: berthRecommendations.matchReasons,
source: berthRecommendations.source,
createdBy: berthRecommendations.createdBy,
createdAt: berthRecommendations.createdAt,
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
lengthFt: berths.lengthFt,
widthFt: berths.widthFt,
draftFt: berths.draftFt,
})
.from(berthRecommendations)
.innerJoin(berths, eq(berthRecommendations.berthId, berths.id))
.where(eq(berthRecommendations.interestId, interestId))
.orderBy(berthRecommendations.matchScore);
return rows.reverse(); // highest score first
}
// ─── Add Manual Recommendation ────────────────────────────────────────────────
export async function addManualRecommendation(
interestId: string,
portId: string,
berthId: string,
meta: AuditMeta,
) {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) throw new NotFoundError('Interest');
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
const [rec] = await db
.insert(berthRecommendations)
.values({
interestId,
berthId,
source: 'manual',
createdBy: meta.userId,
})
.returning();
void createAuditLog({
userId: meta.userId,
portId,
action: 'create',
entityType: 'berth_recommendation',
entityId: rec!.id,
metadata: { type: 'manual', interestId, berthId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
return rec!;
}