fix(sales): wire missing berth-rule triggers + portal company-billed invoices

- G-C4: deposit_received in invoices.ts
- G-C4 + G-I2: interest_archived + notifyNextInLine in archiveInterest
- G-C4: interest_completed in setInterestOutcome
- G-C4: berth_unlinked in removeInterestBerth
- G-I5: portal invoices include billingEntityType='company' when client is the director

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 13:53:10 +02:00
parent 1b00c8a7a2
commit c0e5af8b92
5 changed files with 149 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import { and, desc, eq, exists, inArray, isNull, sql } from 'drizzle-orm';
import { and, desc, eq, exists, inArray, isNull, ne, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests, interestBerths, interestTags, interestNotes } from '@/lib/db/schema/interests';
@@ -13,6 +13,9 @@ import { getPortReminderConfig } from '@/lib/services/port-config';
import { NotFoundError, ConflictError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
import { setEntityTags } from '@/lib/services/entity-tags.helper';
import { evaluateRule } from '@/lib/services/berth-rules-engine';
import { notifyNextInLine } from '@/lib/services/next-in-line-notify.service';
import { logger } from '@/lib/logger';
import {
getPrimaryBerth,
getPrimaryBerthsForInterests,
@@ -705,7 +708,7 @@ export async function updateInterest(
addedBy: meta.userId,
});
} else if (currentBerthId) {
await removeInterestBerth(id, currentBerthId, portId);
await removeInterestBerth(id, currentBerthId, portId, meta);
}
}
@@ -930,6 +933,12 @@ export async function setInterestOutcome(
oldStage,
});
// G-C4: fire interest_completed berth-rule for any non-null outcome
// (won / lost / cancelled all qualify). Default rule mode is 'auto' →
// berth status flips to 'sold' for won, but admins can scope per outcome
// via system_settings.berth_rules.
void evaluateRule('interest_completed', id, portId, meta);
return { ok: true as const };
}
@@ -998,6 +1007,12 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet
);
}
// Resolve the primary berth BEFORE the soft-delete so the berth-rule and
// next-in-line lookups still see the interest's junction rows. softDelete
// toggles archivedAt; the junction isn't archived alongside it, but the
// rule reads the primary via the junction which is unaffected.
const primaryBerth = await getPrimaryBerth(id);
await softDelete(interests, interests.id, id);
void createAuditLog({
@@ -1011,6 +1026,63 @@ export async function archiveInterest(id: string, portId: string, meta: AuditMet
});
emitToRoom(`port:${portId}`, 'interest:archived', { interestId: id });
// G-C4: fire the berth-rule (default mode 'suggest' for interest_archived).
// G-I2: notify sales of the next-in-line interests on the released berth so
// they can follow up — mirrors the client-archive flow but scoped to a
// single interest's primary berth.
if (primaryBerth) {
void evaluateRule('interest_archived', id, portId, meta);
// Build the next-in-line dossier: any other active interest linked to
// the same berth that doesn't belong to this archived interest.
void (async () => {
try {
const others = await db
.select({
interestId: interests.id,
clientId: interests.clientId,
clientName: clients.fullName,
pipelineStage: interests.pipelineStage,
})
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.leftJoin(clients, eq(interests.clientId, clients.id))
.where(
and(
eq(interestBerths.berthId, primaryBerth.berthId),
eq(interests.portId, portId),
ne(interests.id, id),
isNull(interests.archivedAt),
isNull(interests.outcome),
),
)
.orderBy(desc(interests.updatedAt))
.limit(10);
const archivedClient = existing.clientId
? await db.query.clients.findFirst({ where: eq(clients.id, existing.clientId) })
: null;
await notifyNextInLine({
portId,
berthId: primaryBerth.berthId,
mooringNumber: primaryBerth.mooringNumber ?? '',
archivedClientName: archivedClient?.fullName ?? '(unknown client)',
nextInLineInterests: others.map((o) => ({
interestId: o.interestId,
clientName: o.clientName,
pipelineStage: o.pipelineStage,
})),
});
} catch (err) {
logger.error(
{ err, interestId: id, berthId: primaryBerth.berthId },
'Failed to fire next-in-line notification on interest archive',
);
}
})();
}
}
export async function restoreInterest(id: string, portId: string, meta: AuditMeta) {
@@ -1131,7 +1203,7 @@ export async function unlinkBerth(id: string, portId: string, meta: AuditMeta) {
const oldBerthId = previousPrimary?.berthId ?? null;
if (oldBerthId) {
await removeInterestBerth(id, oldBerthId, portId);
await removeInterestBerth(id, oldBerthId, portId, meta);
}
const [updated] = await db