diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index 71c5577af3..68f6adb371 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -19,6 +19,7 @@ import { DescriptionBulletProcess } from './seed/description-bullet.process.js'; import { BOMProcess } from './seed/bom.process.js'; import { TaskProcess } from './seed/tasks.process.js'; import { WorkPackageProcess } from './seed/work-package.process.js'; +import { ChangeRequestProcess } from './seed/change-request.process.js'; const prisma = new PrismaClient(); @@ -37,7 +38,8 @@ await new SeedRunner() new WorkPackageProcess(), new DescriptionBulletProcess(), new BOMProcess(), - new TaskProcess() + new TaskProcess(), + new ChangeRequestProcess() ) .run(); diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts new file mode 100644 index 0000000000..7428254c18 --- /dev/null +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -0,0 +1,339 @@ +import { Faker } from '@faker-js/faker'; +import { Account_Code, CR_Type, Prisma } from '@prisma/client'; +import { DateRange } from '../context.js'; +import { clampDate, daysBetween } from '../dates.js'; +import { addDaysToDate } from 'shared'; + +export type SeedCrParent = { + wbsElementId: string; + timeline: DateRange; + leadId?: string; + managerId?: string; +}; + +export type SeedCrActor = { userId: string }; + +export type SeedCrOverrides = Partial; +export const WORK_PACKAGE_CR_TYPES: CR_Type[] = [CR_Type.ACTIVATION, CR_Type.STAGE_GATE]; +export const PROJECT_CR_TYPES: CR_Type[] = [CR_Type.STANDARD, CR_Type.LEADERSHIP]; + +type ReviewOutcome = 'APPROVED' | 'DENIED' | 'PENDING'; + +const STANDARD_WHY = [ + 'Initial change request', + 'Scope adjustment after design review', + 'Updating the timeline to reflect current progress', + 'Reassigning ownership after a lead change', + 'Correcting budget to match the latest quote', + 'Adding deliverables identified during research', + 'Pulling in schedule after blocker was resolved', + 'Documentation cleanup and status update' +]; + +const BUDGET_WHY = [ + 'Requesting additional budget for consumables', + 'Budget increase to cover competition costs', + 'Reallocating funds after a quote came in higher', + 'Additional budget needed for tools and equipment' +]; + +const APPROVED_NOTES = ['LGTM', 'Approved!', 'Looks good to me!', 'Approved, nice work', 'LGTM!', 'Good to go']; +const DENIED_NOTES = [ + 'Needs more detail before this can be approved', + 'Please revise the scope and resubmit', + 'Budget is too high, tighten this up', + 'Talk to the project lead before resubmitting', + 'Not ready yet' +]; + +const CHANGE_DETAILS = [ + 'New Project Created', + 'New Work Package Created', + 'Changed budget from "0" to "3500"', + 'Added manager', + 'Changed Project Lead', + 'Added new Deliverables', + 'Changed start date', + 'Changed duration', + 'Updated status' +]; + +const BUDGET_AMOUNTS = [500, 1000, 1500, 2000, 2500, 5000]; + +type CrLink = { kind: 'wbs'; wbsElementId: string } | { kind: 'accountCode'; accountCodeId: string }; + +const AUTO_ACCEPTED_TYPES: CR_Type[] = [CR_Type.ACTIVATION, CR_Type.STAGE_GATE, CR_Type.LEADERSHIP]; + +const isAutoAccepted = (type: CR_Type): boolean => AUTO_ACCEPTED_TYPES.includes(type); + +const crTypeForParent = (faker: Faker, isWorkPackage: boolean): CR_Type => { + if (isWorkPackage) { + return faker.helpers.weightedArrayElement([ + { weight: 51, value: CR_Type.ACTIVATION }, + { weight: 49, value: CR_Type.STAGE_GATE } + ]); + } + + return faker.helpers.weightedArrayElement([ + { weight: 97, value: CR_Type.STANDARD }, + { weight: 3, value: CR_Type.LEADERSHIP } + ]); +}; + +const latestOutcome = (faker: Faker): ReviewOutcome => + faker.helpers.weightedArrayElement([ + { weight: 57, value: 'APPROVED' as const }, + { weight: 39, value: 'PENDING' as const }, + { weight: 4, value: 'DENIED' as const } + ]); + +const resolvedOutcome = (faker: Faker): ReviewOutcome => + faker.helpers.weightedArrayElement([ + { weight: 93, value: 'APPROVED' as const }, + { weight: 7, value: 'DENIED' as const } + ]); + +const subtypeCreateInput = ( + faker: Faker, + type: CR_Type, + parent: SeedCrParent | undefined, + submittedDate: Date +): Pick< + Prisma.Change_RequestCreateInput, + 'budgetChangeRequest' | 'stageGateChangeRequest' | 'activationChangeRequest' | 'leadershipChangeRequest' +> => { + switch (type) { + case CR_Type.BUDGET: + return { + budgetChangeRequest: { + create: { proposedBudget: faker.helpers.arrayElement(BUDGET_AMOUNTS) } + } + }; + case CR_Type.STAGE_GATE: + return { + stageGateChangeRequest: { + create: { leftoverBudget: 0, confirmDone: true } + } + }; + case CR_Type.ACTIVATION: { + const leadId = parent?.leadId; + const managerId = parent?.managerId; + if (!leadId || !managerId) { + throw new Error('Activation change request requires a lead and manager on the parent work package.'); + } + return { + activationChangeRequest: { + create: { + startDate: submittedDate, + confirmDetails: true, + lead: { connect: { userId: leadId } }, + manager: { connect: { userId: managerId } } + } + } + }; + } + case CR_Type.LEADERSHIP: + return { + leadershipChangeRequest: { + create: { + ...(parent?.leadId ? { lead: { connect: { userId: parent.leadId } } } : {}), + ...(parent?.managerId ? { manager: { connect: { userId: parent.managerId } } } : {}) + } + } + }; + case CR_Type.STANDARD: + default: + return {}; + } +}; + +const changesCreateInput = ( + faker: Faker, + implementerId: string, + link: CrLink +): Prisma.ChangeCreateWithoutChangeRequestInput[] => { + const count = faker.number.int({ min: 1, max: 4 }); + + return Array.from({ length: count }, () => ({ + detail: faker.helpers.arrayElement(CHANGE_DETAILS), + implementer: { connect: { userId: implementerId } }, + ...(link.kind === 'wbs' + ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } + : { accountCode: { connect: { accountCodeId: link.accountCodeId } } }) + })); +}; + +export const crCountForProject = (faker: Faker): number => + faker.helpers.weightedArrayElement([ + { weight: 15, value: 0 }, + { weight: 45, value: faker.number.int({ min: 1, max: 3 }) }, + { weight: 30, value: faker.number.int({ min: 4, max: 8 }) }, + { weight: 10, value: faker.number.int({ min: 9, max: 15 }) } + ]); + +export const crCountForWorkPackage = (faker: Faker): number => + faker.helpers.weightedArrayElement([ + { weight: 20, value: 0 }, + { weight: 55, value: 1 }, + { weight: 20, value: 2 }, + { weight: 5, value: 3 } + ]); + +export const crCountForAccountCode = (faker: Faker): number => + faker.helpers.weightedArrayElement([ + { weight: 40, value: 0 }, + { weight: 40, value: faker.number.int({ min: 1, max: 3 }) }, + { weight: 20, value: faker.number.int({ min: 4, max: 6 }) } + ]); + +type BuildChangeRequestArgs = { + faker: Faker; + identifier: number; + organizationId: string; + type: CR_Type; + parent?: SeedCrParent; + link: CrLink; + submitterId: string; + reviewerId: string; + dateSubmitted: Date; + outcome: ReviewOutcome; + reviewWindowEnd: Date; + overrides?: SeedCrOverrides; +}; + +const buildChangeRequest = ({ + faker, + identifier, + organizationId, + type, + parent, + link, + submitterId, + reviewerId, + dateSubmitted, + outcome, + reviewWindowEnd, + overrides = {} +}: BuildChangeRequestArgs): Prisma.Change_RequestCreateInput => { + const reviewed = outcome !== 'PENDING'; + const accepted = outcome === 'APPROVED'; + + const dateReviewed = reviewed + ? clampDate(addDaysToDate(dateSubmitted, faker.number.int({ min: 1, max: 10 })), { + start: dateSubmitted, + end: reviewWindowEnd + }) + : undefined; + + const baseLink = + link.kind === 'wbs' + ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } + : { accountCode: { connect: { accountCodeId: link.accountCodeId } } }; + + return { + identifier, + type, + dateSubmitted, + why: type === CR_Type.BUDGET ? faker.helpers.arrayElement(BUDGET_WHY) : faker.helpers.arrayElement(STANDARD_WHY), + organization: { connect: { organizationId } }, + submitter: { connect: { userId: submitterId } }, + ...baseLink, + ...subtypeCreateInput(faker, type, parent, dateSubmitted), + ...(reviewed + ? { + reviewer: { connect: { userId: reviewerId } }, + dateReviewed, + accepted, + reviewNotes: accepted ? faker.helpers.arrayElement(APPROVED_NOTES) : faker.helpers.arrayElement(DENIED_NOTES), + ...(accepted ? { changes: { create: changesCreateInput(faker, reviewerId, link) } } : {}) + } + : {}), + ...overrides + }; +}; + +const outcomesForOrderedCrs = (faker: Faker, types: CR_Type[]): ReviewOutcome[] => { + const lastReviewableIndex = types.reduce((last, type, index) => (isAutoAccepted(type) ? last : index), -1); + + return types.map((type, index) => { + if (isAutoAccepted(type)) return 'APPROVED'; + return index === lastReviewableIndex ? latestOutcome(faker) : resolvedOutcome(faker); + }); +}; + +const cappedWindow = (timeline: DateRange): DateRange => ({ + start: timeline.start, + end: new Date(Math.min(timeline.end.getTime(), Date.now())) +}); + +const orderedSubmissionDates = (faker: Faker, window: DateRange, count: number): Date[] => + Array.from({ length: count }, () => + clampDate(addDaysToDate(new Date(window.start), faker.number.int({ min: 0, max: daysBetween(window) })), window) + ).sort((a, b) => a.getTime() - b.getTime()); + +const pickActor = (faker: Faker, actors: SeedCrActor[]): string => faker.helpers.arrayElement(actors).userId; + +export const buildWbsChangeRequests = ( + faker: Faker, + parent: SeedCrParent, + isWorkPackage: boolean, + identifiers: number[], + organizationId: string, + submitters: SeedCrActor[], + reviewers: SeedCrActor[] +): Prisma.Change_RequestCreateInput[] => { + if (identifiers.length === 0) return []; + + const window = cappedWindow(parent.timeline); + const dates = orderedSubmissionDates(faker, window, identifiers.length); + const types = identifiers.map(() => crTypeForParent(faker, isWorkPackage)); + const outcomes = outcomesForOrderedCrs(faker, types); + + return identifiers.map((identifier, index) => + buildChangeRequest({ + faker, + identifier, + organizationId, + type: types[index], + parent, + link: { kind: 'wbs', wbsElementId: parent.wbsElementId }, + submitterId: pickActor(faker, submitters), + reviewerId: pickActor(faker, reviewers), + dateSubmitted: dates[index], + outcome: outcomes[index], + reviewWindowEnd: window.end + }) + ); +}; + +export const buildAccountCodeChangeRequests = ( + faker: Faker, + accountCode: Account_Code, + timeline: DateRange, + identifiers: number[], + organizationId: string, + submitters: SeedCrActor[], + reviewers: SeedCrActor[] +): Prisma.Change_RequestCreateInput[] => { + if (identifiers.length === 0) return []; + + const window = cappedWindow(timeline); + const dates = orderedSubmissionDates(faker, window, identifiers.length); + const types = identifiers.map(() => CR_Type.BUDGET); + const outcomes = outcomesForOrderedCrs(faker, types); + + return identifiers.map((identifier, index) => + buildChangeRequest({ + faker, + identifier, + organizationId, + type: CR_Type.BUDGET, + link: { kind: 'accountCode', accountCodeId: accountCode.accountCodeId }, + submitterId: pickActor(faker, submitters), + reviewerId: pickActor(faker, reviewers), + dateSubmitted: dates[index], + outcome: outcomes[index], + reviewWindowEnd: window.end + }) + ); +}; diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts new file mode 100644 index 0000000000..05c2e8a45d --- /dev/null +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -0,0 +1,274 @@ +import { Change_Request, CR_Type, Prisma, WBS_Element_Status } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; +import { ProjectOutput, ProjectProcess } from './project.process.js'; +import { UsersOutput, UsersProcess } from './user.process.js'; +import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; +import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; +import { TeamOutput, TeamProcess } from './team.process.js'; +import { DateRange } from '../context.js'; +import { WEEK_MS } from '../dates.js'; +import { + buildAccountCodeChangeRequests, + buildWbsChangeRequests, + crCountForAccountCode, + crCountForProject, + crCountForWorkPackage, + SeedCrActor, + SeedCrParent +} from '../factories/change-request.factory.js'; + +type ChangeRequestInput = OrganizationOutput & + ProjectOutput & + UsersOutput & + WorkPackageOutput & + ConfigDataOutput & + TeamOutput; + +export type ChangeRequestOutput = { + changeRequests: Change_Request[]; + changeRequestsByWbsElementId: Record; +}; + +const changeRequestInclude = { + activationChangeRequest: true, + stageGateChangeRequest: true, + budgetChangeRequest: true, + wbsElement: { include: { workPackage: true } } +} satisfies Prisma.Change_RequestInclude; + +type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ include: typeof changeRequestInclude }>; + +export class ChangeRequestProcess extends SeedProcess { + private identifierCounter = 1; + + dependencies() { + return [OrganizationProcess, ProjectProcess, UsersProcess, WorkPackageProcess, ConfigDataProcess, TeamProcess]; + } + + private allocateIdentifiers(count: number): number[] { + const identifiers = Array.from({ length: count }, (_, offset) => this.identifierCounter + offset); + this.identifierCounter += count; + return identifiers; + } + + private async createChangeRequests(inputs: Prisma.Change_RequestCreateInput[]): Promise { + return Promise.all(inputs.map((data) => this.prisma.change_Request.create({ data, include: changeRequestInclude }))); + } + + private async applyAcceptedWbsChanges(changeRequests: SeededChangeRequest[]): Promise { + const workPackageCrs = new Map(); + + for (const cr of changeRequests) { + if (!cr.accepted || !cr.wbsElement?.workPackage) continue; + if (cr.type !== CR_Type.ACTIVATION && cr.type !== CR_Type.STAGE_GATE) continue; + + const list = workPackageCrs.get(cr.wbsElement.wbsElementId) ?? []; + list.push(cr); + workPackageCrs.set(cr.wbsElement.wbsElementId, list); + } + + await Promise.all(Array.from(workPackageCrs.values()).map((crs) => this.applyWorkPackageLifecycle(crs))); + } + + private async applyWorkPackageLifecycle(crs: SeededChangeRequest[]): Promise { + const workPackage = crs[0].wbsElement?.workPackage; + if (!workPackage) return; + + const ordered = [...crs].sort((a, b) => a.dateSubmitted.getTime() - b.dateSubmitted.getTime()); + + let { startDate, duration } = workPackage; + let status: WBS_Element_Status = WBS_Element_Status.INACTIVE; + + for (const cr of ordered) { + if (cr.type === CR_Type.ACTIVATION && cr.activationChangeRequest) { + ({ startDate } = cr.activationChangeRequest); + status = WBS_Element_Status.ACTIVE; + } else if (cr.type === CR_Type.STAGE_GATE) { + const completedDate = cr.dateReviewed ?? cr.dateSubmitted; + duration = Math.max(1, Math.round((completedDate.getTime() - startDate.getTime()) / WEEK_MS)); + status = WBS_Element_Status.COMPLETE; + } + } + + await this.prisma.work_Package.update({ + where: { workPackageId: workPackage.workPackageId }, + data: { + startDate, + duration, + wbsElement: { update: { status } } + } + }); + } + + private async applyAcceptedAccountCodeBudgets(changeRequests: SeededChangeRequest[]): Promise { + const latestBudgetByAccountCode = new Map(); + + for (const cr of changeRequests) { + if (!cr.accepted || !cr.accountCodeId || !cr.budgetChangeRequest) continue; + + const current = latestBudgetByAccountCode.get(cr.accountCodeId); + if (!current || cr.dateSubmitted >= current.dateSubmitted) { + latestBudgetByAccountCode.set(cr.accountCodeId, { + dateSubmitted: cr.dateSubmitted, + proposedBudget: cr.budgetChangeRequest.proposedBudget + }); + } + } + + await Promise.all( + Array.from(latestBudgetByAccountCode.entries()).map(([accountCodeId, { proposedBudget }]) => + this.prisma.account_Code.update({ where: { accountCodeId }, data: { amount: proposedBudget } }) + ) + ); + } + + private async resolveFinanceActors( + financeTeamId: string, + fallbackSubmitters: SeedCrActor[], + fallbackReviewers: SeedCrActor[] + ): Promise<{ submitters: SeedCrActor[]; reviewers: SeedCrActor[] }> { + const financeTeam = await this.prisma.team.findUnique({ + where: { teamId: financeTeamId }, + include: { members: true, leads: true, head: true } + }); + + if (!financeTeam) { + return { submitters: fallbackSubmitters, reviewers: fallbackReviewers }; + } + + const reviewers = [...financeTeam.leads, financeTeam.head]; + const submitters = [...financeTeam.members, ...reviewers]; + + return { + submitters: submitters.length > 0 ? submitters : fallbackSubmitters, + reviewers: reviewers.length > 0 ? reviewers : fallbackReviewers + }; + } + + async run({ + organization, + projects, + workPackagesByProjectId, + members, + leadership, + heads, + admins, + appAdmins, + accountCodes, + financeTeam + }: ChangeRequestInput): Promise { + const { organizationId } = organization; + + if (projects.length === 0) { + throw new Error('ChangeRequestProcess requires at least one project.'); + } + + this.identifierCounter = 1; + + const submitterPool = [...members, ...leadership, ...heads, ...admins, ...appAdmins]; + const reviewerPool = [...leadership, ...heads, ...admins, ...appAdmins]; + + if (submitterPool.length === 0 || reviewerPool.length === 0) { + throw new Error('ChangeRequestProcess requires submitters and reviewers.'); + } + + const wbsChangeRequestInputs: Prisma.Change_RequestCreateInput[] = []; + + for (const { project, timeline } of projects) { + const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; + + const { leadId: projectLeadId, managerId: projectManagerId } = project.wbsElement; + const projectParent: SeedCrParent = { + wbsElementId: project.wbsElementId, + timeline, + leadId: projectLeadId ?? undefined, + managerId: projectManagerId ?? undefined + }; + + wbsChangeRequestInputs.push( + ...buildWbsChangeRequests( + this.faker, + projectParent, + false, + this.allocateIdentifiers(crCountForProject(this.faker)), + organizationId, + submitterPool, + reviewerPool + ) + ); + + for (const { workPackage, timeline: wpTimeline } of projectWorkPackages) { + const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; + const wpParent: SeedCrParent = { + wbsElementId: workPackage.wbsElement.wbsElementId, + timeline: wpTimeline, + leadId: wpLeadId ?? undefined, + managerId: wpManagerId ?? undefined + }; + + wbsChangeRequestInputs.push( + ...buildWbsChangeRequests( + this.faker, + wpParent, + true, + this.allocateIdentifiers(crCountForWorkPackage(this.faker)), + organizationId, + submitterPool, + reviewerPool + ) + ); + } + } + + const financeWindow = this.financeWindow(projects.map(({ timeline }) => timeline)); + const { submitters: financeSubmitters, reviewers: financeReviewers } = await this.resolveFinanceActors( + financeTeam.teamId, + submitterPool, + reviewerPool + ); + + const budgetChangeRequestInputs = accountCodes.flatMap((accountCode) => + buildAccountCodeChangeRequests( + this.faker, + accountCode, + financeWindow, + this.allocateIdentifiers(crCountForAccountCode(this.faker)), + organizationId, + financeSubmitters, + financeReviewers + ) + ); + + const createdWbsChangeRequests = await this.createChangeRequests(wbsChangeRequestInputs); + const createdBudgetChangeRequests = await this.createChangeRequests(budgetChangeRequestInputs); + + await this.applyAcceptedWbsChanges(createdWbsChangeRequests); + await this.applyAcceptedAccountCodeBudgets(createdBudgetChangeRequests); + + const changeRequests = [...createdWbsChangeRequests, ...createdBudgetChangeRequests]; + + const changeRequestsByWbsElementId = changeRequests.reduce>((acc, cr) => { + if (cr.wbsElementId) { + acc[cr.wbsElementId] ??= []; + acc[cr.wbsElementId].push(cr); + } + return acc; + }, {}); + + return { + changeRequests, + changeRequestsByWbsElementId + }; + } + + private financeWindow(timelines: DateRange[]): DateRange { + const starts = timelines.map(({ start }) => start.getTime()); + const ends = timelines.map(({ end }) => end.getTime()); + + return { + start: new Date(Math.min(...starts)), + end: new Date(Math.max(...ends)) + }; + } +}