chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,155 @@
import type { Prisma } from "@planarchy/db";
import { EstimateStatus, EstimateVersionStatus } from "@planarchy/shared";
import {
buildProjectSnapshot,
ESTIMATE_DETAIL_INCLUDE,
type EstimateDbClient,
type EstimateWithDetails,
} from "./shared.js";
export interface CloneEstimateInput {
sourceEstimateId: string;
/** Override name for the clone (defaults to "Copy of <original>") */
name?: string | undefined;
/** Link to a different project */
projectId?: string | undefined;
}
export async function cloneEstimate(
db: EstimateDbClient,
input: CloneEstimateInput,
): Promise<EstimateWithDetails> {
const source = await db.estimate.findUnique({
where: { id: input.sourceEstimateId },
include: {
versions: {
orderBy: { versionNumber: "desc" as const },
take: 1,
include: {
assumptions: { orderBy: { sortOrder: "asc" as const } },
scopeItems: { orderBy: { sortOrder: "asc" as const } },
demandLines: { orderBy: { createdAt: "asc" as const } },
resourceSnapshots: { orderBy: { displayName: "asc" as const } },
metrics: { orderBy: { createdAt: "asc" as const } },
},
},
},
});
if (!source) {
throw new Error("Source estimate not found");
}
const sourceVersion = source.versions[0];
if (!sourceVersion) {
throw new Error("Source estimate has no versions");
}
const cloneName = input.name ?? `Copy of ${source.name}`;
const cloneProjectId = input.projectId ?? source.projectId;
const projectSnapshot = await buildProjectSnapshot(db, cloneProjectId);
const estimate = await db.estimate.create({
data: {
...(cloneProjectId ? { projectId: cloneProjectId } : {}),
name: cloneName,
...(source.opportunityId ? { opportunityId: source.opportunityId } : {}),
baseCurrency: source.baseCurrency,
status: EstimateStatus.DRAFT,
latestVersionNumber: 1,
versions: {
create: {
versionNumber: 1,
label: "Cloned from v" + sourceVersion.versionNumber,
status: EstimateVersionStatus.WORKING,
projectSnapshot,
assumptions: {
create: sourceVersion.assumptions.map((a) => ({
category: a.category,
key: a.key,
label: a.label,
valueType: a.valueType,
value: a.value as Prisma.InputJsonValue,
sortOrder: a.sortOrder,
...(a.notes ? { notes: a.notes } : {}),
})),
},
scopeItems: {
create: sourceVersion.scopeItems.map((s) => ({
sequenceNo: s.sequenceNo,
scopeType: s.scopeType,
...(s.packageCode ? { packageCode: s.packageCode } : {}),
name: s.name,
...(s.description ? { description: s.description } : {}),
...(s.scene ? { scene: s.scene } : {}),
...(s.page ? { page: s.page } : {}),
...(s.location ? { location: s.location } : {}),
...(s.assumptionCategory ? { assumptionCategory: s.assumptionCategory } : {}),
technicalSpec: s.technicalSpec as Prisma.InputJsonValue,
...(s.frameCount !== null ? { frameCount: s.frameCount } : {}),
...(s.itemCount !== null ? { itemCount: s.itemCount } : {}),
...(s.unitMode ? { unitMode: s.unitMode } : {}),
...(s.internalComments ? { internalComments: s.internalComments } : {}),
...(s.externalComments ? { externalComments: s.externalComments } : {}),
sortOrder: s.sortOrder,
metadata: s.metadata as Prisma.InputJsonValue,
})),
},
demandLines: {
create: sourceVersion.demandLines.map((l) => ({
...(l.roleId ? { roleId: l.roleId } : {}),
...(l.resourceId ? { resourceId: l.resourceId } : {}),
lineType: l.lineType,
name: l.name,
...(l.chapter ? { chapter: l.chapter } : {}),
hours: l.hours,
...(l.days !== null ? { days: l.days } : {}),
...(l.fte !== null ? { fte: l.fte } : {}),
...(l.rateSource ? { rateSource: l.rateSource } : {}),
costRateCents: l.costRateCents,
billRateCents: l.billRateCents,
currency: l.currency,
costTotalCents: l.costTotalCents,
priceTotalCents: l.priceTotalCents,
monthlySpread: l.monthlySpread as Prisma.InputJsonValue,
staffingAttributes: l.staffingAttributes as Prisma.InputJsonValue,
metadata: l.metadata as Prisma.InputJsonValue,
})),
},
resourceSnapshots: {
create: sourceVersion.resourceSnapshots.map((r) => ({
...(r.resourceId ? { resourceId: r.resourceId } : {}),
...(r.sourceEid ? { sourceEid: r.sourceEid } : {}),
displayName: r.displayName,
...(r.chapter ? { chapter: r.chapter } : {}),
...(r.roleId ? { roleId: r.roleId } : {}),
currency: r.currency,
lcrCents: r.lcrCents,
ucrCents: r.ucrCents,
...(r.fte !== null ? { fte: r.fte } : {}),
...(r.location ? { location: r.location } : {}),
...(r.country ? { country: r.country } : {}),
...(r.level ? { level: r.level } : {}),
...(r.workType ? { workType: r.workType } : {}),
attributes: r.attributes as Prisma.InputJsonValue,
})),
},
metrics: {
create: sourceVersion.metrics.map((m) => ({
key: m.key,
label: m.label,
...(m.metricGroup ? { metricGroup: m.metricGroup } : {}),
valueDecimal: Number(m.valueDecimal),
...(m.valueCents !== null ? { valueCents: m.valueCents } : {}),
...(m.currency ? { currency: m.currency } : {}),
metadata: m.metadata as Prisma.InputJsonValue,
})),
},
},
},
},
include: ESTIMATE_DETAIL_INCLUDE,
});
return estimate;
}
@@ -0,0 +1,159 @@
import type { Prisma } from "@planarchy/db";
import { EstimateVersionStatus, type CreateEstimateInput } from "@planarchy/shared";
import {
buildProjectSnapshot,
ESTIMATE_DETAIL_INCLUDE,
type EstimateDbClient,
type EstimateWithDetails,
} from "./shared.js";
export async function createEstimate(
db: EstimateDbClient,
input: CreateEstimateInput,
): Promise<EstimateWithDetails> {
const projectSnapshot = await buildProjectSnapshot(db, input.projectId);
const estimate = await db.estimate.create({
data: {
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
name: input.name,
...(input.opportunityId !== undefined
? { opportunityId: input.opportunityId }
: {}),
baseCurrency: input.baseCurrency,
status: input.status,
latestVersionNumber: 1,
versions: {
create: {
versionNumber: 1,
...(input.versionLabel !== undefined ? { label: input.versionLabel } : {}),
status: EstimateVersionStatus.WORKING,
...(input.versionNotes !== undefined
? { notes: input.versionNotes }
: {}),
projectSnapshot,
assumptions: {
create: input.assumptions.map((assumption) => ({
category: assumption.category,
key: assumption.key,
label: assumption.label,
valueType: assumption.valueType,
value: assumption.value as Prisma.InputJsonValue,
sortOrder: assumption.sortOrder,
...(assumption.notes !== undefined ? { notes: assumption.notes } : {}),
})),
},
scopeItems: {
create: input.scopeItems.map((scopeItem) => ({
sequenceNo: scopeItem.sequenceNo,
scopeType: scopeItem.scopeType,
...(scopeItem.packageCode !== undefined
? { packageCode: scopeItem.packageCode }
: {}),
name: scopeItem.name,
...(scopeItem.description !== undefined
? { description: scopeItem.description }
: {}),
...(scopeItem.scene !== undefined ? { scene: scopeItem.scene } : {}),
...(scopeItem.page !== undefined ? { page: scopeItem.page } : {}),
...(scopeItem.location !== undefined
? { location: scopeItem.location }
: {}),
...(scopeItem.assumptionCategory !== undefined
? { assumptionCategory: scopeItem.assumptionCategory }
: {}),
technicalSpec: scopeItem.technicalSpec as Prisma.InputJsonValue,
...(scopeItem.frameCount !== undefined
? { frameCount: scopeItem.frameCount }
: {}),
...(scopeItem.itemCount !== undefined
? { itemCount: scopeItem.itemCount }
: {}),
...(scopeItem.unitMode !== undefined
? { unitMode: scopeItem.unitMode }
: {}),
...(scopeItem.internalComments !== undefined
? { internalComments: scopeItem.internalComments }
: {}),
...(scopeItem.externalComments !== undefined
? { externalComments: scopeItem.externalComments }
: {}),
sortOrder: scopeItem.sortOrder,
metadata: scopeItem.metadata as Prisma.InputJsonValue,
})),
},
demandLines: {
create: input.demandLines.map((line) => ({
...(line.scopeItemId !== undefined ? { scopeItemId: line.scopeItemId } : {}),
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
...(line.resourceId !== undefined
? { resourceId: line.resourceId }
: {}),
lineType: line.lineType,
name: line.name,
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
hours: line.hours,
...(line.days !== undefined ? { days: line.days } : {}),
...(line.fte !== undefined ? { fte: line.fte } : {}),
...(line.rateSource !== undefined
? { rateSource: line.rateSource }
: {}),
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,
})),
},
resourceSnapshots: {
create: input.resourceSnapshots.map((snapshot) => ({
...(snapshot.resourceId !== undefined
? { resourceId: snapshot.resourceId }
: {}),
...(snapshot.sourceEid !== undefined
? { sourceEid: snapshot.sourceEid }
: {}),
displayName: snapshot.displayName,
...(snapshot.chapter !== undefined ? { chapter: snapshot.chapter } : {}),
...(snapshot.roleId !== undefined ? { roleId: snapshot.roleId } : {}),
currency: snapshot.currency,
lcrCents: snapshot.lcrCents,
ucrCents: snapshot.ucrCents,
...(snapshot.fte !== undefined ? { fte: snapshot.fte } : {}),
...(snapshot.location !== undefined
? { location: snapshot.location }
: {}),
...(snapshot.country !== undefined ? { country: snapshot.country } : {}),
...(snapshot.level !== undefined ? { level: snapshot.level } : {}),
...(snapshot.workType !== undefined
? { workType: snapshot.workType }
: {}),
attributes: snapshot.attributes as Prisma.InputJsonValue,
})),
},
metrics: {
create: input.metrics.map((metric) => ({
key: metric.key,
label: metric.label,
...(metric.metricGroup !== undefined
? { metricGroup: metric.metricGroup }
: {}),
valueDecimal: metric.valueDecimal,
...(metric.valueCents !== undefined
? { valueCents: metric.valueCents }
: {}),
...(metric.currency !== undefined ? { currency: metric.currency } : {}),
metadata: metric.metadata as Prisma.InputJsonValue,
})),
},
},
},
},
include: ESTIMATE_DETAIL_INCLUDE,
});
return estimate;
}
@@ -0,0 +1,303 @@
import { countWorkingDays } from "@planarchy/engine";
import { countEstimateHandoffPlanningEntries } from "../allocation/count-estimate-handoff-planning-entries.js";
import { createAssignment } from "../allocation/create-assignment.js";
import { createDemandRequirement } from "../allocation/create-demand-requirement.js";
import {
AllocationStatus,
EstimateVersionStatus,
type CreateEstimatePlanningHandoffInput,
type EstimatePlanningHandoffAllocationRef,
type EstimatePlanningHandoffResult,
type WeekdayAvailability,
} from "@planarchy/shared";
import {
ESTIMATE_DETAIL_INCLUDE,
PROJECT_SNAPSHOT_SELECT,
type EstimateDbClient,
type EstimateVersionDetails,
type EstimateWithDetails,
} from "./shared.js";
const STANDARD_BUSINESS_AVAILABILITY: WeekdayAvailability = {
monday: 8,
tuesday: 8,
wednesday: 8,
thursday: 8,
friday: 8,
saturday: 0,
sunday: 0,
};
function roundHoursPerDay(value: number) {
return Math.max(0.25, Math.min(24, Number(value.toFixed(2))));
}
function resolveVersion(
estimate: EstimateWithDetails,
versionId: string | undefined | null,
): EstimateVersionDetails {
const version = versionId
? estimate.versions.find((candidate) => candidate.id === versionId)
: estimate.versions.find((candidate) => candidate.status === EstimateVersionStatus.APPROVED);
if (!version) {
throw new Error(versionId ? "Estimate version not found" : "Estimate has no approved version");
}
if (version.status !== EstimateVersionStatus.APPROVED) {
throw new Error("Only approved versions can be handed off to planning");
}
return version;
}
async function getEstimateOrThrow(
db: EstimateDbClient,
estimateId: string,
): Promise<EstimateWithDetails> {
const estimate = await db.estimate.findUnique({
where: { id: estimateId },
include: ESTIMATE_DETAIL_INCLUDE,
});
if (!estimate) {
throw new Error("Estimate not found");
}
return estimate;
}
function canFallbackToPlaceholder(error: unknown) {
if (!(error instanceof Error)) {
return false;
}
return [
"Resource not found",
"Resource is required for non-placeholder allocations",
"Resource has availability conflicts on",
].some((message) => error.message.startsWith(message));
}
export async function createEstimatePlanningHandoff(
db: EstimateDbClient,
input: CreateEstimatePlanningHandoffInput,
): Promise<EstimatePlanningHandoffResult> {
const estimate = await getEstimateOrThrow(db, input.estimateId);
const targetVersion = resolveVersion(estimate, input.versionId);
if (!estimate.projectId) {
throw new Error("Estimate must be linked to a project before planning handoff");
}
const project = await db.project.findUnique({
where: { id: estimate.projectId },
select: PROJECT_SNAPSHOT_SELECT,
});
if (!project) {
throw new Error("Linked project not found");
}
if (project.endDate < project.startDate) {
throw new Error("Linked project has an invalid date range");
}
const existingHandoffPlanningEntryCount =
await countEstimateHandoffPlanningEntries(db, {
projectId: estimate.projectId,
estimateVersionId: targetVersion.id,
});
if (existingHandoffPlanningEntryCount > 0) {
throw new Error("Planning handoff already exists for this approved version");
}
const resourceIds = [
...new Set(
targetVersion.demandLines
.map((line) => line.resourceId)
.filter((resourceId): resourceId is string => typeof resourceId === "string"),
),
];
const resources = resourceIds.length
? await db.resource.findMany({
where: { id: { in: resourceIds } },
select: {
id: true,
availability: true,
},
})
: [];
const resourceMap = new Map(
resources.map((resource) => [
resource.id,
resource.availability as unknown as WeekdayAvailability,
]),
);
const createdHandoff = await db.$transaction(async (tx) => {
const handoffAllocations: EstimatePlanningHandoffAllocationRef[] = [];
let fallbackPlaceholderCount = 0;
for (const line of targetVersion.demandLines) {
if (line.hours <= 0) {
continue;
}
const headcount = Math.max(1, Math.ceil(line.fte != null && line.fte > 0 ? line.fte : 1));
const resourceAvailability =
line.resourceId != null ? resourceMap.get(line.resourceId) : null;
const workingDays = countWorkingDays(
project.startDate,
project.endDate,
resourceAvailability ?? STANDARD_BUSINESS_AVAILABILITY,
);
if (workingDays <= 0) {
throw new Error(`Project window has no working days for demand line "${line.name}"`);
}
const hoursPerDay = roundHoursPerDay(line.hours / workingDays / headcount);
const percentage = Math.max(1, Math.min(100, Math.round((hoursPerDay / 8) * 100)));
const estimateHandoffMetadata = {
estimateId: estimate.id,
estimateVersionId: targetVersion.id,
estimateVersionNumber: targetVersion.versionNumber,
estimateDemandLineId: line.id,
estimateDemandLineName: line.name,
handedOffAt: new Date().toISOString(),
estimatedHours: line.hours,
estimatedDays: line.days,
estimatedFte: line.fte,
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
currency: line.currency,
costTotalCents: line.costTotalCents,
priceTotalCents: line.priceTotalCents,
sourceMetadata: line.metadata,
sourceStaffingAttributes: line.staffingAttributes,
sourceMonthlySpread: line.monthlySpread,
} satisfies Record<string, unknown>;
const demandMetadata = {
...(line.resourceId ? { suggestedResourceId: line.resourceId } : {}),
estimateHandoff: estimateHandoffMetadata,
} satisfies Record<string, unknown>;
try {
if (line.resourceId) {
const demandRequirement = await createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
{
projectId: project.id,
startDate: project.startDate,
endDate: project.endDate,
hoursPerDay,
percentage,
role: line.name,
roleId: line.roleId ?? undefined,
headcount,
status: AllocationStatus.PROPOSED,
metadata: {
...demandMetadata,
estimateHandoff: {
...estimateHandoffMetadata,
handoffMode: "resource",
linkedResourceId: line.resourceId,
},
},
},
);
const assignment = await createAssignment(
tx as unknown as Parameters<typeof createAssignment>[0],
{
resourceId: line.resourceId,
demandRequirementId: demandRequirement.id,
projectId: project.id,
startDate: project.startDate,
endDate: project.endDate,
hoursPerDay,
percentage,
role: line.name,
roleId: line.roleId ?? undefined,
status: AllocationStatus.PROPOSED,
metadata: {
estimateHandoff: {
...estimateHandoffMetadata,
handoffMode: "resource",
linkedResourceId: line.resourceId,
},
},
},
);
handoffAllocations.push({
id: assignment.id,
projectId: assignment.projectId,
resourceId: assignment.resourceId,
isPlaceholder: false,
});
continue;
}
} catch (error) {
if (!canFallbackToPlaceholder(error)) {
throw error;
}
}
const placeholder = await createDemandRequirement(
tx as unknown as Parameters<typeof createDemandRequirement>[0],
{
projectId: project.id,
startDate: project.startDate,
endDate: project.endDate,
hoursPerDay,
percentage,
role: line.name,
roleId: line.roleId ?? undefined,
headcount,
status: AllocationStatus.PROPOSED,
metadata: {
...(line.resourceId ? { suggestedResourceId: line.resourceId } : {}),
estimateHandoff: {
...estimateHandoffMetadata,
handoffMode: line.resourceId ? "fallback_placeholder" : "placeholder",
...(line.resourceId ? { suggestedResourceId: line.resourceId } : {}),
},
},
},
);
if (line.resourceId) {
fallbackPlaceholderCount += 1;
}
handoffAllocations.push({
id: placeholder.id,
projectId: placeholder.projectId,
resourceId: null,
isPlaceholder: true,
});
}
return { allocations: handoffAllocations, fallbackPlaceholderCount };
});
const placeholderCount = createdHandoff.allocations.filter(
(allocation) => allocation.isPlaceholder,
).length;
const assignedCount = createdHandoff.allocations.length - placeholderCount;
return {
estimateId: estimate.id,
estimateVersionId: targetVersion.id,
estimateVersionNumber: targetVersion.versionNumber,
projectId: project.id,
createdCount: createdHandoff.allocations.length,
placeholderCount,
assignedCount,
fallbackPlaceholderCount: createdHandoff.fallbackPlaceholderCount,
allocations: createdHandoff.allocations,
};
}
@@ -0,0 +1,8 @@
import { ESTIMATE_DETAIL_INCLUDE, type EstimateDbClient } from "./shared.js";
export async function getEstimateById(db: EstimateDbClient, estimateId: string) {
return db.estimate.findUnique({
where: { id: estimateId },
include: ESTIMATE_DETAIL_INCLUDE,
});
}
@@ -0,0 +1,16 @@
export {
createEstimate,
} from "./create-estimate.js";
export { cloneEstimate } from "./clone-estimate.js";
export type { CloneEstimateInput } from "./clone-estimate.js";
export { listEstimates } from "./list-estimates.js";
export { getEstimateById } from "./get-estimate.js";
export { updateEstimateDraft } from "./update-estimate-draft.js";
export {
approveEstimateVersion,
createEstimateExport,
createEstimateRevision,
submitEstimateVersion,
} from "./version-actions.js";
export { createEstimatePlanningHandoff } from "./create-planning-handoff.js";
export type { EstimateWithDetails, EstimateListItem } from "./shared.js";
@@ -0,0 +1,49 @@
import type { EstimateListFilters } from "@planarchy/shared";
import type { EstimateDbClient } from "./shared.js";
export async function listEstimates(
db: EstimateDbClient,
filters: EstimateListFilters = {},
) {
return db.estimate.findMany({
where: {
...(filters.projectId !== undefined ? { projectId: filters.projectId } : {}),
...(filters.status !== undefined ? { status: filters.status } : {}),
...(filters.query
? {
OR: [
{ name: { contains: filters.query, mode: "insensitive" } },
{
opportunityId: {
contains: filters.query,
mode: "insensitive",
},
},
],
}
: {}),
},
include: {
project: {
select: {
id: true,
shortCode: true,
name: true,
status: true,
},
},
versions: {
orderBy: { versionNumber: "desc" },
take: 1,
select: {
id: true,
versionNumber: true,
label: true,
status: true,
updatedAt: true,
},
},
},
orderBy: { updatedAt: "desc" },
});
}
@@ -0,0 +1,113 @@
import type {
Estimate,
EstimateAssumption,
EstimateDemandLine,
EstimateExport,
EstimateMetric,
EstimateVersion,
PrismaClient,
Project,
ResourceCostSnapshot,
ScopeItem,
} from "@planarchy/db";
import type { Prisma } from "@planarchy/db";
export const PROJECT_SNAPSHOT_SELECT = {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
orderType: true,
allocationType: true,
winProbability: true,
budgetCents: true,
responsiblePerson: true,
} as const;
export const ESTIMATE_DETAIL_INCLUDE = {
project: {
select: {
id: true,
shortCode: true,
name: true,
status: true,
startDate: true,
endDate: true,
},
},
versions: {
orderBy: { versionNumber: "desc" },
include: {
assumptions: {
orderBy: { sortOrder: "asc" },
},
scopeItems: {
orderBy: { sortOrder: "asc" },
},
demandLines: {
orderBy: { createdAt: "asc" },
},
resourceSnapshots: {
orderBy: { displayName: "asc" },
},
metrics: {
orderBy: { createdAt: "asc" },
},
exports: {
orderBy: { createdAt: "desc" },
},
},
},
} as const;
export interface EstimateVersionDetails extends EstimateVersion {
assumptions: EstimateAssumption[];
scopeItems: ScopeItem[];
demandLines: EstimateDemandLine[];
resourceSnapshots: ResourceCostSnapshot[];
metrics: EstimateMetric[];
exports: EstimateExport[];
}
export interface EstimateWithDetails extends Estimate {
project?: Pick<Project, "id" | "shortCode" | "name" | "status" | "startDate" | "endDate"> | null;
versions: EstimateVersionDetails[];
}
export interface EstimateListItem extends Estimate {
project?: Pick<Project, "id" | "shortCode" | "name" | "status"> | null;
versions: Array<
Pick<
EstimateVersion,
"id" | "versionNumber" | "label" | "status" | "updatedAt"
>
>;
}
export type EstimateDbClient = PrismaClient;
export async function buildProjectSnapshot(
db: Pick<PrismaClient, "project">,
projectId: string | null | undefined,
): Promise<Prisma.InputJsonValue> {
if (!projectId) {
return {};
}
const project = await db.project.findUnique({
where: { id: projectId },
select: PROJECT_SNAPSHOT_SELECT,
});
if (!project) {
return {};
}
return {
...project,
startDate: project.startDate.toISOString(),
endDate: project.endDate.toISOString(),
} satisfies Prisma.InputJsonValue;
}
@@ -0,0 +1,203 @@
import type { Prisma } from "@planarchy/db";
import { EstimateVersionStatus, type UpdateEstimateDraftInput } from "@planarchy/shared";
import {
buildProjectSnapshot,
ESTIMATE_DETAIL_INCLUDE,
type EstimateDbClient,
type EstimateWithDetails,
} from "./shared.js";
function toAssumptionCreateInput(
assumption: UpdateEstimateDraftInput["assumptions"][number],
) {
return {
...(assumption.id !== undefined ? { id: assumption.id } : {}),
category: assumption.category,
key: assumption.key,
label: assumption.label,
valueType: assumption.valueType,
value: assumption.value as Prisma.InputJsonValue,
sortOrder: assumption.sortOrder,
...(assumption.notes !== undefined ? { notes: assumption.notes } : {}),
};
}
function toScopeItemCreateInput(
scopeItem: UpdateEstimateDraftInput["scopeItems"][number],
) {
return {
...(scopeItem.id !== undefined ? { id: scopeItem.id } : {}),
sequenceNo: scopeItem.sequenceNo,
scopeType: scopeItem.scopeType,
...(scopeItem.packageCode !== undefined
? { packageCode: scopeItem.packageCode }
: {}),
name: scopeItem.name,
...(scopeItem.description !== undefined
? { description: scopeItem.description }
: {}),
...(scopeItem.scene !== undefined ? { scene: scopeItem.scene } : {}),
...(scopeItem.page !== undefined ? { page: scopeItem.page } : {}),
...(scopeItem.location !== undefined ? { location: scopeItem.location } : {}),
...(scopeItem.assumptionCategory !== undefined
? { assumptionCategory: scopeItem.assumptionCategory }
: {}),
technicalSpec: scopeItem.technicalSpec as Prisma.InputJsonValue,
...(scopeItem.frameCount !== undefined
? { frameCount: scopeItem.frameCount }
: {}),
...(scopeItem.itemCount !== undefined ? { itemCount: scopeItem.itemCount } : {}),
...(scopeItem.unitMode !== undefined ? { unitMode: scopeItem.unitMode } : {}),
...(scopeItem.internalComments !== undefined
? { internalComments: scopeItem.internalComments }
: {}),
...(scopeItem.externalComments !== undefined
? { externalComments: scopeItem.externalComments }
: {}),
sortOrder: scopeItem.sortOrder,
metadata: scopeItem.metadata as Prisma.InputJsonValue,
};
}
function toDemandLineCreateInput(
line: UpdateEstimateDraftInput["demandLines"][number],
) {
return {
...(line.id !== undefined ? { id: line.id } : {}),
...(line.scopeItemId !== undefined ? { scopeItemId: line.scopeItemId } : {}),
...(line.roleId !== undefined ? { roleId: line.roleId } : {}),
...(line.resourceId !== undefined ? { resourceId: line.resourceId } : {}),
lineType: line.lineType,
name: line.name,
...(line.chapter !== undefined ? { chapter: line.chapter } : {}),
hours: line.hours,
...(line.days !== undefined ? { days: line.days } : {}),
...(line.fte !== undefined ? { fte: line.fte } : {}),
...(line.rateSource !== undefined ? { rateSource: line.rateSource } : {}),
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,
};
}
function toResourceSnapshotCreateInput(
snapshot: UpdateEstimateDraftInput["resourceSnapshots"][number],
) {
return {
...(snapshot.id !== undefined ? { id: snapshot.id } : {}),
...(snapshot.resourceId !== undefined ? { resourceId: snapshot.resourceId } : {}),
...(snapshot.sourceEid !== undefined ? { sourceEid: snapshot.sourceEid } : {}),
displayName: snapshot.displayName,
...(snapshot.chapter !== undefined ? { chapter: snapshot.chapter } : {}),
...(snapshot.roleId !== undefined ? { roleId: snapshot.roleId } : {}),
currency: snapshot.currency,
lcrCents: snapshot.lcrCents,
ucrCents: snapshot.ucrCents,
...(snapshot.fte !== undefined ? { fte: snapshot.fte } : {}),
...(snapshot.location !== undefined ? { location: snapshot.location } : {}),
...(snapshot.country !== undefined ? { country: snapshot.country } : {}),
...(snapshot.level !== undefined ? { level: snapshot.level } : {}),
...(snapshot.workType !== undefined ? { workType: snapshot.workType } : {}),
attributes: snapshot.attributes as Prisma.InputJsonValue,
};
}
function toMetricCreateInput(metric: UpdateEstimateDraftInput["metrics"][number]) {
return {
...(metric.id !== undefined ? { id: metric.id } : {}),
key: metric.key,
label: metric.label,
...(metric.metricGroup !== undefined ? { metricGroup: metric.metricGroup } : {}),
valueDecimal: metric.valueDecimal,
...(metric.valueCents !== undefined ? { valueCents: metric.valueCents } : {}),
...(metric.currency !== undefined ? { currency: metric.currency } : {}),
metadata: metric.metadata as Prisma.InputJsonValue,
};
}
export async function updateEstimateDraft(
db: EstimateDbClient,
input: UpdateEstimateDraftInput,
): Promise<EstimateWithDetails> {
const existing = await db.estimate.findUnique({
where: { id: input.id },
include: ESTIMATE_DETAIL_INCLUDE,
});
if (!existing) {
throw new Error("Estimate not found");
}
const workingVersion = existing.versions.find(
(version) => version.status === EstimateVersionStatus.WORKING,
);
if (!workingVersion) {
throw new Error("Estimate has no working version");
}
const effectiveProjectId = input.projectId ?? existing.projectId;
const projectSnapshot = await buildProjectSnapshot(db, effectiveProjectId);
await db.$transaction(async (tx) => {
await tx.estimate.update({
where: { id: input.id },
data: {
...(input.projectId !== undefined ? { projectId: input.projectId } : {}),
...(input.name !== undefined ? { name: input.name } : {}),
...(input.opportunityId !== undefined
? { opportunityId: input.opportunityId }
: {}),
...(input.baseCurrency !== undefined
? { baseCurrency: input.baseCurrency }
: {}),
...(input.status !== undefined ? { status: input.status } : {}),
},
});
await tx.estimateVersion.update({
where: { id: workingVersion.id },
data: {
...(input.versionLabel !== undefined ? { label: input.versionLabel } : {}),
...(input.versionNotes !== undefined ? { notes: input.versionNotes } : {}),
projectSnapshot,
assumptions: {
deleteMany: {},
create: input.assumptions.map(toAssumptionCreateInput),
},
scopeItems: {
deleteMany: {},
create: input.scopeItems.map(toScopeItemCreateInput),
},
demandLines: {
deleteMany: {},
create: input.demandLines.map(toDemandLineCreateInput),
},
resourceSnapshots: {
deleteMany: {},
create: input.resourceSnapshots.map(toResourceSnapshotCreateInput),
},
metrics: {
deleteMany: {},
create: input.metrics.map(toMetricCreateInput),
},
},
});
});
const updated = await db.estimate.findUnique({
where: { id: input.id },
include: ESTIMATE_DETAIL_INCLUDE,
});
if (!updated) {
throw new Error("Estimate not found after update");
}
return updated;
}
@@ -0,0 +1,409 @@
import type { Prisma } from "@planarchy/db";
import { serializeEstimateExport } from "@planarchy/engine";
import {
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
type ApproveEstimateVersionInput,
type CreateEstimateExportInput,
type CreateEstimateRevisionInput,
type SubmitEstimateVersionInput,
} from "@planarchy/shared";
import {
ESTIMATE_DETAIL_INCLUDE,
type EstimateDbClient,
type EstimateVersionDetails,
type EstimateWithDetails,
} from "./shared.js";
async function getEstimateOrThrow(
db: EstimateDbClient,
estimateId: string,
): Promise<EstimateWithDetails> {
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<EstimateWithDetails> {
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<EstimateWithDetails> {
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<EstimateWithDetails> {
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<string, string>();
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<EstimateWithDetails> {
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<string, unknown>)
: 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);
}