chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user