chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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