import type { Prisma } from "@capakraken/db"; import { serializeEstimateExport } from "@capakraken/engine"; import { EstimateExportFormat, EstimateStatus, EstimateVersionStatus, type ApproveEstimateVersionInput, type CreateEstimateExportInput, type CreateEstimateRevisionInput, type SubmitEstimateVersionInput, } from "@capakraken/shared"; import { ESTIMATE_DETAIL_INCLUDE, type EstimateDbClient, type EstimateVersionDetails, type EstimateWithDetails, } from "./shared.js"; async function getEstimateOrThrow( db: EstimateDbClient, estimateId: string, ): Promise { const estimate = await db.estimate.findUnique({ where: { id: estimateId }, include: ESTIMATE_DETAIL_INCLUDE, }); if (!estimate) { throw new Error("Estimate not found"); } return estimate; } function resolveVersion( estimate: EstimateWithDetails, versionId: string | undefined, matcher: (version: EstimateVersionDetails) => boolean, errorMessage: string, ) { const version = versionId ? estimate.versions.find((candidate) => candidate.id === versionId) : estimate.versions.find(matcher); if (!version) { throw new Error(errorMessage); } return version; } function sanitizeFileNameSegment(value: string) { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return normalized.length > 0 ? normalized : "estimate"; } function buildExportFileName( estimateName: string, versionNumber: number, format: EstimateExportFormat, ) { return `${sanitizeFileNameSegment(estimateName)}-v${versionNumber}.${format.toLowerCase()}`; } export async function submitEstimateVersion( db: EstimateDbClient, input: SubmitEstimateVersionInput, ): Promise { const estimate = await getEstimateOrThrow(db, input.estimateId); const targetVersion = resolveVersion( estimate, input.versionId, (version) => version.status === EstimateVersionStatus.WORKING, input.versionId ? "Estimate version not found" : "Estimate has no working version", ); if (targetVersion.status !== EstimateVersionStatus.WORKING) { throw new Error("Only working versions can be submitted"); } const supersededIds = estimate.versions .filter( (version) => version.id !== targetVersion.id && version.status === EstimateVersionStatus.SUBMITTED, ) .map((version) => version.id); await db.$transaction(async (tx) => { if (supersededIds.length > 0) { await tx.estimateVersion.updateMany({ where: { id: { in: supersededIds } }, data: { status: EstimateVersionStatus.SUPERSEDED }, }); } await tx.estimateVersion.update({ where: { id: targetVersion.id }, data: { status: EstimateVersionStatus.SUBMITTED, lockedAt: targetVersion.lockedAt ?? new Date(), }, }); await tx.estimate.update({ where: { id: estimate.id }, data: { status: EstimateStatus.IN_REVIEW }, }); }); return getEstimateOrThrow(db, estimate.id); } export async function approveEstimateVersion( db: EstimateDbClient, input: ApproveEstimateVersionInput, ): Promise { const estimate = await getEstimateOrThrow(db, input.estimateId); const targetVersion = resolveVersion( estimate, input.versionId, (version) => version.status === EstimateVersionStatus.SUBMITTED, input.versionId ? "Estimate version not found" : "Estimate has no submitted version", ); if (targetVersion.status !== EstimateVersionStatus.SUBMITTED) { throw new Error("Only submitted versions can be approved"); } const supersededIds = estimate.versions .filter( (version) => version.id !== targetVersion.id && (version.status === EstimateVersionStatus.SUBMITTED || version.status === EstimateVersionStatus.APPROVED), ) .map((version) => version.id); await db.$transaction(async (tx) => { if (supersededIds.length > 0) { await tx.estimateVersion.updateMany({ where: { id: { in: supersededIds } }, data: { status: EstimateVersionStatus.SUPERSEDED }, }); } await tx.estimateVersion.update({ where: { id: targetVersion.id }, data: { status: EstimateVersionStatus.APPROVED, lockedAt: targetVersion.lockedAt ?? new Date(), }, }); await tx.estimate.update({ where: { id: estimate.id }, data: { status: EstimateStatus.APPROVED }, }); }); return getEstimateOrThrow(db, estimate.id); } export async function createEstimateRevision( db: EstimateDbClient, input: CreateEstimateRevisionInput, ): Promise { const estimate = await getEstimateOrThrow(db, input.estimateId); const existingWorkingVersion = estimate.versions.find( (version) => version.status === EstimateVersionStatus.WORKING, ); if (existingWorkingVersion) { throw new Error("Estimate already has a working version"); } const sourceVersion = resolveVersion( estimate, input.sourceVersionId, (version) => version.status !== EstimateVersionStatus.WORKING || version.lockedAt != null, input.sourceVersionId ? "Estimate version not found" : "Estimate has no locked version to revise", ); if ( sourceVersion.status === EstimateVersionStatus.WORKING && sourceVersion.lockedAt == null ) { throw new Error("Source version must be locked before creating a revision"); } const nextVersionNumber = estimate.latestVersionNumber + 1; await db.$transaction(async (tx) => { const newVersion = await tx.estimateVersion.create({ data: { estimateId: estimate.id, versionNumber: nextVersionNumber, label: input.label ?? `Revision ${nextVersionNumber}`, status: EstimateVersionStatus.WORKING, notes: input.notes ?? `Revision created from v${sourceVersion.versionNumber}`, projectSnapshot: sourceVersion.projectSnapshot as Prisma.InputJsonValue, }, }); if (sourceVersion.assumptions.length > 0) { await tx.estimateAssumption.createMany({ data: sourceVersion.assumptions.map((assumption) => ({ estimateVersionId: newVersion.id, category: assumption.category, key: assumption.key, label: assumption.label, valueType: assumption.valueType, value: assumption.value as Prisma.InputJsonValue, sortOrder: assumption.sortOrder, ...(assumption.notes != null ? { notes: assumption.notes } : {}), })), }); } const scopeItemIdMap = new Map(); for (const scopeItem of sourceVersion.scopeItems) { const createdScopeItem = await tx.scopeItem.create({ data: { estimateVersionId: newVersion.id, sequenceNo: scopeItem.sequenceNo, scopeType: scopeItem.scopeType, ...(scopeItem.packageCode != null ? { packageCode: scopeItem.packageCode } : {}), name: scopeItem.name, ...(scopeItem.description != null ? { description: scopeItem.description } : {}), ...(scopeItem.scene != null ? { scene: scopeItem.scene } : {}), ...(scopeItem.page != null ? { page: scopeItem.page } : {}), ...(scopeItem.location != null ? { location: scopeItem.location } : {}), ...(scopeItem.assumptionCategory != null ? { assumptionCategory: scopeItem.assumptionCategory } : {}), technicalSpec: scopeItem.technicalSpec as Prisma.InputJsonValue, ...(scopeItem.frameCount != null ? { frameCount: scopeItem.frameCount } : {}), ...(scopeItem.itemCount != null ? { itemCount: scopeItem.itemCount } : {}), ...(scopeItem.unitMode != null ? { unitMode: scopeItem.unitMode } : {}), ...(scopeItem.internalComments != null ? { internalComments: scopeItem.internalComments } : {}), ...(scopeItem.externalComments != null ? { externalComments: scopeItem.externalComments } : {}), sortOrder: scopeItem.sortOrder, metadata: scopeItem.metadata as Prisma.InputJsonValue, }, }); scopeItemIdMap.set(scopeItem.id, createdScopeItem.id); } if (sourceVersion.demandLines.length > 0) { await tx.estimateDemandLine.createMany({ data: sourceVersion.demandLines.map((line) => ({ estimateVersionId: newVersion.id, scopeItemId: line.scopeItemId != null ? (scopeItemIdMap.get(line.scopeItemId) ?? null) : null, roleId: line.roleId ?? null, resourceId: line.resourceId ?? null, lineType: line.lineType, name: line.name, chapter: line.chapter ?? null, hours: line.hours, days: line.days ?? null, fte: line.fte ?? null, rateSource: line.rateSource ?? null, costRateCents: line.costRateCents, billRateCents: line.billRateCents, currency: line.currency, costTotalCents: line.costTotalCents, priceTotalCents: line.priceTotalCents, monthlySpread: line.monthlySpread as Prisma.InputJsonValue, staffingAttributes: line.staffingAttributes as Prisma.InputJsonValue, metadata: line.metadata as Prisma.InputJsonValue, })), }); } if (sourceVersion.resourceSnapshots.length > 0) { await tx.resourceCostSnapshot.createMany({ data: sourceVersion.resourceSnapshots.map((snapshot) => ({ estimateVersionId: newVersion.id, ...(snapshot.resourceId != null ? { resourceId: snapshot.resourceId } : {}), ...(snapshot.sourceEid != null ? { sourceEid: snapshot.sourceEid } : {}), displayName: snapshot.displayName, ...(snapshot.chapter != null ? { chapter: snapshot.chapter } : {}), ...(snapshot.roleId != null ? { roleId: snapshot.roleId } : {}), currency: snapshot.currency, lcrCents: snapshot.lcrCents, ucrCents: snapshot.ucrCents, ...(snapshot.fte != null ? { fte: snapshot.fte } : {}), ...(snapshot.location != null ? { location: snapshot.location } : {}), ...(snapshot.country != null ? { country: snapshot.country } : {}), ...(snapshot.level != null ? { level: snapshot.level } : {}), ...(snapshot.workType != null ? { workType: snapshot.workType } : {}), attributes: snapshot.attributes as Prisma.InputJsonValue, })), }); } if (sourceVersion.metrics.length > 0) { await tx.estimateMetric.createMany({ data: sourceVersion.metrics.map((metric) => ({ estimateVersionId: newVersion.id, key: metric.key, label: metric.label, ...(metric.metricGroup != null ? { metricGroup: metric.metricGroup } : {}), valueDecimal: metric.valueDecimal, ...(metric.valueCents != null ? { valueCents: metric.valueCents } : {}), ...(metric.currency != null ? { currency: metric.currency } : {}), metadata: metric.metadata as Prisma.InputJsonValue, })), }); } await tx.estimate.update({ where: { id: estimate.id }, data: { latestVersionNumber: nextVersionNumber, status: EstimateStatus.DRAFT, }, }); }); return getEstimateOrThrow(db, estimate.id); } export async function createEstimateExport( db: EstimateDbClient, input: CreateEstimateExportInput, ): Promise { const estimate = await getEstimateOrThrow(db, input.estimateId); const targetVersion = resolveVersion( estimate, input.versionId, () => true, input.versionId ? "Estimate version not found" : "Estimate has no version to export", ); const projectSnapshot = typeof targetVersion.projectSnapshot === "object" && targetVersion.projectSnapshot !== null && !Array.isArray(targetVersion.projectSnapshot) ? (targetVersion.projectSnapshot as Record) : null; const payload = serializeEstimateExport( { estimate, version: targetVersion, project: estimate.project ? { ...estimate.project, startDate: typeof projectSnapshot?.startDate === "string" ? projectSnapshot.startDate : null, endDate: typeof projectSnapshot?.endDate === "string" ? projectSnapshot.endDate : null, } : null, }, input.format, ); await db.estimateExport.create({ data: { estimateVersionId: targetVersion.id, format: input.format, fileName: buildExportFileName( estimate.name, targetVersion.versionNumber, input.format, ), payload: payload as unknown as Prisma.InputJsonValue, }, }); return getEstimateOrThrow(db, estimate.id); }