208 lines
7.4 KiB
TypeScript
208 lines
7.4 KiB
TypeScript
import {
|
|
evaluateCustomerLifecycle,
|
|
type CustomerLifecycleProjection,
|
|
type LifecycleAllocationSnapshot,
|
|
type LifecycleContractSnapshot,
|
|
type LifecycleDealSnapshot,
|
|
type LifecycleRequestSnapshot,
|
|
} from '@pig/core';
|
|
import {
|
|
accounts,
|
|
activities,
|
|
allocations,
|
|
capacityRequests,
|
|
contractObligations,
|
|
contracts,
|
|
demandDeals,
|
|
type Database,
|
|
} from '@pig/db';
|
|
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm';
|
|
import { CapacityService } from './capacity';
|
|
|
|
export interface GrowthCustomer {
|
|
account: {
|
|
id: string;
|
|
name: string;
|
|
domain: string | null;
|
|
customerSegment: string | null;
|
|
ownerUserId: string | null;
|
|
};
|
|
lifecycle: CustomerLifecycleProjection;
|
|
openDealCount: number;
|
|
}
|
|
|
|
export interface GrowthIdleSupply {
|
|
commitmentId: string;
|
|
name: string;
|
|
gpuType: string;
|
|
gpuCount: number;
|
|
startsAt: Date;
|
|
endsAt: Date;
|
|
soldGpuHours: number;
|
|
heldGpuHours: number;
|
|
availableGpuHours: number;
|
|
idleGpuHours: number;
|
|
idleCostCents: number;
|
|
breakEvenPriceCents: number | null;
|
|
}
|
|
|
|
export interface GrowthReport {
|
|
rulesetVersion: string;
|
|
computedAt: string;
|
|
customers: GrowthCustomer[];
|
|
customersTruncated: boolean;
|
|
idleSupply: GrowthIdleSupply[];
|
|
}
|
|
|
|
export class CustomerLifecycleService {
|
|
private readonly capacity: CapacityService;
|
|
|
|
constructor(
|
|
private readonly db: Database,
|
|
private readonly clock: () => Date = () => new Date(),
|
|
) {
|
|
this.capacity = new CapacityService(db);
|
|
}
|
|
|
|
async report(accountId?: string): Promise<GrowthReport> {
|
|
const now = this.clock();
|
|
const accountRows = await this.db
|
|
.select({
|
|
id: accounts.id,
|
|
name: accounts.name,
|
|
domain: accounts.domain,
|
|
customerSegment: accounts.customerSegment,
|
|
ownerUserId: accounts.ownerUserId,
|
|
lastActivityAt: accounts.lastActivityAt,
|
|
})
|
|
.from(accounts)
|
|
.where(and(
|
|
or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')),
|
|
isNull(accounts.archivedAt),
|
|
accountId ? eq(accounts.id, accountId) : undefined,
|
|
))
|
|
.orderBy(desc(accounts.updatedAt))
|
|
.limit(accountId ? 1 : 201);
|
|
const customersTruncated = accountRows.length > 200;
|
|
if (customersTruncated) accountRows.length = 200;
|
|
const accountIds = accountRows.map((account) => account.id);
|
|
if (!accountIds.length) {
|
|
const idleSupply = await this.readIdleSupply();
|
|
return {
|
|
rulesetVersion: 'growth-r1-2026-08-13',
|
|
computedAt: now.toISOString(),
|
|
customers: [],
|
|
customersTruncated: false,
|
|
idleSupply,
|
|
};
|
|
}
|
|
|
|
const [dealRows, contractRows, activityRows] = await Promise.all([
|
|
this.db.select().from(demandDeals).where(inArray(demandDeals.accountId, accountIds)),
|
|
this.db.select().from(contracts).where(and(inArray(contracts.accountId, accountIds), eq(contracts.side, 'demand'))),
|
|
this.db.select().from(activities).where(inArray(activities.accountId, accountIds)).orderBy(desc(activities.occurredAt)).limit(2_000),
|
|
]);
|
|
const dealIds = dealRows.map((deal) => deal.id);
|
|
const contractIds = contractRows.map((contract) => contract.id);
|
|
const [requestRows, allocationRows, obligationRows, idleSupply] = await Promise.all([
|
|
dealIds.length ? this.db.select().from(capacityRequests).where(inArray(capacityRequests.demandDealId, dealIds)) : [],
|
|
dealIds.length ? this.db.select().from(allocations).where(inArray(allocations.demandDealId, dealIds)) : [],
|
|
contractIds.length ? this.db.select().from(contractObligations).where(inArray(contractObligations.contractId, contractIds)) : [],
|
|
this.readIdleSupply(),
|
|
]);
|
|
|
|
const customers = accountRows.map((account): GrowthCustomer => {
|
|
const deals = dealRows.filter((deal) => deal.accountId === account.id);
|
|
const ownDealIds = new Set(deals.map((deal) => deal.id));
|
|
const ownContracts = contractRows.filter((contract) => contract.accountId === account.id);
|
|
const ownContractIds = new Set(ownContracts.map((contract) => contract.id));
|
|
const recentActivity = activityRows.find((activity) => activity.accountId === account.id);
|
|
const lifecycle = evaluateCustomerLifecycle({
|
|
accountId: account.id,
|
|
deals: deals.map((deal): LifecycleDealSnapshot => ({
|
|
id: deal.id,
|
|
stage: deal.stage,
|
|
productLine: deal.productLine,
|
|
parentDealId: deal.parentDealId,
|
|
msaExecuted: deal.msaExecuted,
|
|
lastActivityAt: deal.lastActivityAt,
|
|
})),
|
|
requests: requestRows
|
|
.filter((request) => ownDealIds.has(request.demandDealId))
|
|
.map((request): LifecycleRequestSnapshot => ({
|
|
id: request.id,
|
|
demandDealId: request.demandDealId,
|
|
startsAt: request.startsAt,
|
|
endsAt: request.endsAt,
|
|
totalGpuHours: request.totalGpuHours == null ? null : Number(request.totalGpuHours),
|
|
})),
|
|
allocations: allocationRows
|
|
.filter((allocation) => allocation.demandDealId && ownDealIds.has(allocation.demandDealId))
|
|
.map((allocation): LifecycleAllocationSnapshot => ({
|
|
id: allocation.id,
|
|
demandDealId: allocation.demandDealId,
|
|
status: allocation.status,
|
|
gpuHours: Number(allocation.gpuHours),
|
|
startsAt: allocation.startsAt,
|
|
endsAt: allocation.endsAt,
|
|
holdExpiresAt: allocation.holdExpiresAt,
|
|
})),
|
|
contracts: ownContracts.map((contract): LifecycleContractSnapshot => ({
|
|
id: contract.id,
|
|
status: contract.status,
|
|
expiresAt: contract.expiresAt,
|
|
isAutoRenew: contract.isAutoRenew,
|
|
noticeDays: contract.noticeDays,
|
|
})),
|
|
obligations: obligationRows.filter((obligation) => ownContractIds.has(obligation.contractId)),
|
|
lastActivityAt: recentActivity?.occurredAt ?? account.lastActivityAt,
|
|
lastActivityId: recentActivity?.id,
|
|
}, now);
|
|
return {
|
|
account: {
|
|
id: account.id,
|
|
name: account.name,
|
|
domain: account.domain,
|
|
customerSegment: account.customerSegment,
|
|
ownerUserId: account.ownerUserId,
|
|
},
|
|
lifecycle,
|
|
openDealCount: deals.filter((deal) => !['closed_won', 'closed_lost'].includes(deal.stage)).length,
|
|
};
|
|
}).sort((left, right) =>
|
|
right.lifecycle.score - left.lifecycle.score || left.account.name.localeCompare(right.account.name),
|
|
);
|
|
|
|
return {
|
|
rulesetVersion: customers[0]?.lifecycle.rulesetVersion ?? 'growth-r1-2026-08-13',
|
|
computedAt: now.toISOString(),
|
|
customers,
|
|
customersTruncated,
|
|
idleSupply,
|
|
};
|
|
}
|
|
|
|
async account(accountId: string): Promise<GrowthCustomer | null> {
|
|
const report = await this.report(accountId);
|
|
return report.customers.find((customer) => customer.account.id === accountId) ?? null;
|
|
}
|
|
|
|
private async readIdleSupply(): Promise<GrowthIdleSupply[]> {
|
|
const rows = await this.capacity.idleCapacity({ thresholdPct: 0.25, withinDays: 30 });
|
|
return rows.map((row) => ({
|
|
commitmentId: row.commitmentId,
|
|
name: row.name,
|
|
gpuType: row.gpuType,
|
|
gpuCount: row.gpuCount,
|
|
startsAt: row.startsAt,
|
|
endsAt: row.endsAt,
|
|
soldGpuHours: row.soldGpuHours,
|
|
heldGpuHours: row.heldGpuHours,
|
|
availableGpuHours: row.availableGpuHours,
|
|
idleGpuHours: row.idleGpuHours,
|
|
idleCostCents: row.idleCostCents,
|
|
breakEvenPriceCents: row.breakEvenPriceCents,
|
|
}));
|
|
}
|
|
}
|