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