From 061f2e6021ed22748174334647fe1e5a50252c1e Mon Sep 17 00:00:00 2001 From: Steph375 Date: Wed, 15 Jul 2026 15:31:51 -0400 Subject: [PATCH 1/4] 4245 change request process and factory --- src/backend/src/prisma/dev-seed.ts | 4 +- .../factories/change-request.factory.ts | 223 ++++++++++++++++++ .../src/prisma/seed/change-request.process.ts | 145 ++++++++++++ 3 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 src/backend/src/prisma/factories/change-request.factory.ts create mode 100644 src/backend/src/prisma/seed/change-request.process.ts diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index 8a65b86dd1..114e5960f8 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -17,6 +17,7 @@ import { ProjectProcess } from './seed/project.process.js'; import { SchedulingProcess } from './seed/scheduling.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(); @@ -33,7 +34,8 @@ await new SeedRunner() new ShopProcess(), new ProjectProcess(), new WorkPackageProcess(), - 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..f8c77f2d87 --- /dev/null +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -0,0 +1,223 @@ +import { Faker } from '@faker-js/faker'; +import { 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 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.BUDGET, 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 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: 94, value: CR_Type.STANDARD }, + { weight: 3, value: CR_Type.BUDGET }, + { weight: 3, value: CR_Type.LEADERSHIP } + ]); +}; + +const reviewOutcome = (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 subtypeCreateInput = ( + faker: Faker, + type: CR_Type, + parent: SeedCrParent, + submittedDate: Date +): Pick< + Prisma.Change_RequestCreateInput, + 'budgetChangeRequest' | 'stageGateChangeRequest' | 'activationChangeRequest' | 'leadershipChangeRequest' +> => { + const { leadId, managerId } = parent; + + switch (type) { + case CR_Type.BUDGET: + return { + budgetChangeRequest: { + create: { proposedBudget: faker.helpers.arrayElement([500, 1000, 1500, 2000, 2500, 5000]) } + } + }; + case CR_Type.STAGE_GATE: + return { + stageGateChangeRequest: { + create: { leftoverBudget: 0, confirmDone: true } + } + }; + case CR_Type.ACTIVATION: + 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: { + ...(leadId ? { lead: { connect: { userId: leadId } } } : {}), + ...(managerId ? { manager: { connect: { userId: managerId } } } : {}) + } + } + }; + case CR_Type.STANDARD: + default: + return {}; + } +}; + +const changesCreateInput = ( + faker: Faker, + implementerId: string, + link: { wbsElementId: string } | { categoryId: string } +): 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 } }, + ...('wbsElementId' in link + ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } + : { category: { connect: { otherReimbursementProductReasonId: link.categoryId } } }) + })); +}; + +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 createSeedChangeRequest = ( + faker: Faker, + parent: SeedCrParent, + isWorkPackage: boolean, + identifier: number, + organizationId: string, + submitterId: string, + reviewerId: string, + budgetReasonId: string | undefined, + overrides: SeedCrOverrides = {} +): Prisma.Change_RequestCreateInput => { + const { timeline, wbsElementId } = parent; + const type = crTypeForParent(faker, isWorkPackage); + + const useCategory = type === CR_Type.BUDGET && budgetReasonId !== undefined; + const baseLink = useCategory + ? { category: { connect: { otherReimbursementProductReasonId: budgetReasonId as string } } } + : { wbsElement: { connect: { wbsElementId } } }; + const changeLink = useCategory ? { categoryId: budgetReasonId as string } : { wbsElementId }; + + const dateSubmitted = clampDate( + addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), + { start: timeline.start, end: timeline.end } + ); + + const outcome = reviewOutcome(faker); + const reviewed = outcome !== 'PENDING'; + const accepted = outcome === 'APPROVED'; + + const dateReviewed = reviewed + ? clampDate(addDaysToDate(dateSubmitted, faker.number.int({ min: 1, max: 10 })), { + start: dateSubmitted, + end: timeline.end + }) + : undefined; + + 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, changeLink) } } : {}) + } + : {}), + ...overrides + }; +}; 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..37b3ae54d7 --- /dev/null +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -0,0 +1,145 @@ +import { Change_Request } 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 { + crCountForProject, + crCountForWorkPackage, + createSeedChangeRequest, + SeedCrParent +} from '../factories/change-request.factory.js'; + +type ChangeRequestInput = OrganizationOutput & ProjectOutput & UsersOutput & WorkPackageOutput & ConfigDataOutput; + +export type ChangeRequestOutput = { + changeRequests: Change_Request[]; + changeRequestsByWbsElementId: Record; +}; + +export class ChangeRequestProcess extends SeedProcess { + private identifierCounter = 1; + + dependencies() { + return [OrganizationProcess, ProjectProcess, UsersProcess, WorkPackageProcess, ConfigDataProcess]; + } + + private allocateIdentifiers(count: number): number[] { + const identifiers = Array.from({ length: count }, (_, offset) => this.identifierCounter + offset); + this.identifierCounter += count; + return identifiers; + } + + async run({ + organization, + projects, + workPackagesByProjectId, + members, + leadership, + heads, + admins, + appAdmins, + reimbursementProductOtherReasons + }: 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 budgetReasonId = (): string | undefined => + reimbursementProductOtherReasons.length > 0 + ? this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId + : undefined; + + const changeRequests: Change_Request[] = []; + + for (const { project, timeline } of projects) { + const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; + + // project-scoped CRs (standard / budget / leadership) + const { leadId: projectLeadId, managerId: projectManagerId } = project.wbsElement; + const projectParent: SeedCrParent = { + wbsElementId: project.wbsElementId, + timeline, + leadId: projectLeadId ?? undefined, + managerId: projectManagerId ?? undefined + }; + + const projectCrIdentifiers = this.allocateIdentifiers(crCountForProject(this.faker)); + + const projectCrs = await Promise.all( + projectCrIdentifiers.map((identifier) => + this.prisma.change_Request.create({ + data: createSeedChangeRequest( + this.faker, + projectParent, + false, + identifier, + organizationId, + this.faker.helpers.arrayElement(submitterPool).userId, + this.faker.helpers.arrayElement(reviewerPool).userId, + budgetReasonId() + ) + }) + ) + ); + changeRequests.push(...projectCrs); + + const wpCrs = await Promise.all( + projectWorkPackages.flatMap(({ workPackage, timeline: wpTimeline }) => { + const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; + const wpParent: SeedCrParent = { + wbsElementId: workPackage.wbsElement.wbsElementId, + timeline: wpTimeline, + leadId: wpLeadId ?? undefined, + managerId: wpManagerId ?? undefined + }; + + const wpCrIdentifiers = this.allocateIdentifiers(crCountForWorkPackage(this.faker)); + + return wpCrIdentifiers.map((identifier) => + this.prisma.change_Request.create({ + data: createSeedChangeRequest( + this.faker, + wpParent, + true, + identifier, + organizationId, + this.faker.helpers.arrayElement(submitterPool).userId, + this.faker.helpers.arrayElement(reviewerPool).userId, + undefined + ) + }) + ); + }) + ); + changeRequests.push(...wpCrs); + } + + const changeRequestsByWbsElementId = changeRequests.reduce>((acc, cr) => { + // budget CRs are category-scoped and legitimately have no wbsElement + if (cr.wbsElementId) { + acc[cr.wbsElementId] ??= []; + acc[cr.wbsElementId].push(cr); + } + return acc; + }, {}); + + return { + changeRequests, + changeRequestsByWbsElementId + }; + } +} From e1dba52730061352c41e270c3e973d5c875b4553 Mon Sep 17 00:00:00 2001 From: Steph375 Date: Thu, 16 Jul 2026 16:34:27 -0400 Subject: [PATCH 2/4] 4245 comment fix --- src/backend/src/prisma/seed/change-request.process.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index 37b3ae54d7..a2bc078063 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -58,7 +58,7 @@ export class ChangeRequestProcess extends SeedProcess + const generateBudgetReasonId = (): string | undefined => reimbursementProductOtherReasons.length > 0 ? this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId : undefined; @@ -90,7 +90,7 @@ export class ChangeRequestProcess extends SeedProcess>((acc, cr) => { - // budget CRs are category-scoped and legitimately have no wbsElement if (cr.wbsElementId) { acc[cr.wbsElementId] ??= []; acc[cr.wbsElementId].push(cr); From ca39b01aceabf920af7456d1fbabe30f857644c9 Mon Sep 17 00:00:00 2001 From: Steph375 Date: Mon, 20 Jul 2026 14:16:07 -0400 Subject: [PATCH 3/4] #4245 comment fixes --- .../factories/change-request.factory.ts | 186 ++++++++++---- .../src/prisma/seed/change-request.process.ts | 229 +++++++++++++----- 2 files changed, 313 insertions(+), 102 deletions(-) diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts index f8c77f2d87..5c06620982 100644 --- a/src/backend/src/prisma/factories/change-request.factory.ts +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -1,5 +1,5 @@ import { Faker } from '@faker-js/faker'; -import { CR_Type, Prisma } from '@prisma/client'; +import { Account_Code, CR_Type, Prisma } from '@prisma/client'; import { DateRange } from '../context.js'; import { clampDate, daysBetween } from '../dates.js'; import { addDaysToDate } from 'shared'; @@ -11,9 +11,11 @@ export type SeedCrParent = { 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.BUDGET, CR_Type.LEADERSHIP]; +export const PROJECT_CR_TYPES: CR_Type[] = [CR_Type.STANDARD, CR_Type.LEADERSHIP]; type ReviewOutcome = 'APPROVED' | 'DENIED' | 'PENDING'; @@ -56,6 +58,10 @@ const CHANGE_DETAILS = [ 'Updated status' ]; +const BUDGET_AMOUNTS = [500, 1000, 1500, 2000, 2500, 5000]; + +type CrLink = { kind: 'wbs'; wbsElementId: string } | { kind: 'accountCode'; accountCodeId: string }; + const crTypeForParent = (faker: Faker, isWorkPackage: boolean): CR_Type => { if (isWorkPackage) { return faker.helpers.weightedArrayElement([ @@ -65,35 +71,38 @@ const crTypeForParent = (faker: Faker, isWorkPackage: boolean): CR_Type => { } return faker.helpers.weightedArrayElement([ - { weight: 94, value: CR_Type.STANDARD }, - { weight: 3, value: CR_Type.BUDGET }, + { weight: 97, value: CR_Type.STANDARD }, { weight: 3, value: CR_Type.LEADERSHIP } ]); }; -const reviewOutcome = (faker: Faker): ReviewOutcome => +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, + parent: SeedCrParent | undefined, submittedDate: Date ): Pick< Prisma.Change_RequestCreateInput, 'budgetChangeRequest' | 'stageGateChangeRequest' | 'activationChangeRequest' | 'leadershipChangeRequest' > => { - const { leadId, managerId } = parent; - switch (type) { case CR_Type.BUDGET: return { budgetChangeRequest: { - create: { proposedBudget: faker.helpers.arrayElement([500, 1000, 1500, 2000, 2500, 5000]) } + create: { proposedBudget: faker.helpers.arrayElement(BUDGET_AMOUNTS) } } }; case CR_Type.STAGE_GATE: @@ -102,7 +111,9 @@ const subtypeCreateInput = ( create: { leftoverBudget: 0, confirmDone: true } } }; - case CR_Type.ACTIVATION: + 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.'); } @@ -116,12 +127,13 @@ const subtypeCreateInput = ( } } }; + } case CR_Type.LEADERSHIP: return { leadershipChangeRequest: { create: { - ...(leadId ? { lead: { connect: { userId: leadId } } } : {}), - ...(managerId ? { manager: { connect: { userId: managerId } } } : {}) + ...(parent?.leadId ? { lead: { connect: { userId: parent.leadId } } } : {}), + ...(parent?.managerId ? { manager: { connect: { userId: parent.managerId } } } : {}) } } }; @@ -134,16 +146,16 @@ const subtypeCreateInput = ( const changesCreateInput = ( faker: Faker, implementerId: string, - link: { wbsElementId: string } | { categoryId: 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 } }, - ...('wbsElementId' in link + ...(link.kind === 'wbs' ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } - : { category: { connect: { otherReimbursementProductReasonId: link.categoryId } } }) + : { accountCode: { connect: { accountCodeId: link.accountCodeId } } }) })); }; @@ -163,42 +175,57 @@ export const crCountForWorkPackage = (faker: Faker): number => { weight: 5, value: 3 } ]); -export const createSeedChangeRequest = ( - faker: Faker, - parent: SeedCrParent, - isWorkPackage: boolean, - identifier: number, - organizationId: string, - submitterId: string, - reviewerId: string, - budgetReasonId: string | undefined, - overrides: SeedCrOverrides = {} -): Prisma.Change_RequestCreateInput => { - const { timeline, wbsElementId } = parent; - const type = crTypeForParent(faker, isWorkPackage); - - const useCategory = type === CR_Type.BUDGET && budgetReasonId !== undefined; - const baseLink = useCategory - ? { category: { connect: { otherReimbursementProductReasonId: budgetReasonId as string } } } - : { wbsElement: { connect: { wbsElementId } } }; - const changeLink = useCategory ? { categoryId: budgetReasonId as string } : { wbsElementId }; - - const dateSubmitted = clampDate( - addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), - { start: timeline.start, end: timeline.end } - ); +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 }) } + ]); - const outcome = reviewOutcome(faker); +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: timeline.end + end: reviewWindowEnd }) : undefined; + const baseLink = + link.kind === 'wbs' + ? { wbsElement: { connect: { wbsElementId: link.wbsElementId } } } + : { accountCode: { connect: { accountCodeId: link.accountCodeId } } }; + return { identifier, type, @@ -214,10 +241,83 @@ export const createSeedChangeRequest = ( dateReviewed, accepted, reviewNotes: accepted ? faker.helpers.arrayElement(APPROVED_NOTES) : faker.helpers.arrayElement(DENIED_NOTES), - - ...(accepted ? { changes: { create: changesCreateInput(faker, reviewerId, changeLink) } } : {}) + ...(accepted ? { changes: { create: changesCreateInput(faker, reviewerId, link) } } : {}) } : {}), ...overrides }; }; + +const outcomesForOrderedCrs = (faker: Faker, count: number): ReviewOutcome[] => + Array.from({ length: count }, (_, index) => (index === count - 1 ? latestOutcome(faker) : resolvedOutcome(faker))); + +const orderedSubmissionDates = (faker: Faker, timeline: DateRange, count: number): Date[] => + Array.from({ length: count }, () => + clampDate(addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), { + start: timeline.start, + end: timeline.end + }) + ).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 dates = orderedSubmissionDates(faker, parent.timeline, identifiers.length); + const outcomes = outcomesForOrderedCrs(faker, identifiers.length); + + return identifiers.map((identifier, index) => + buildChangeRequest({ + faker, + identifier, + organizationId, + type: crTypeForParent(faker, isWorkPackage), + parent, + link: { kind: 'wbs', wbsElementId: parent.wbsElementId }, + submitterId: pickActor(faker, submitters), + reviewerId: pickActor(faker, reviewers), + dateSubmitted: dates[index], + outcome: outcomes[index], + reviewWindowEnd: parent.timeline.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 dates = orderedSubmissionDates(faker, timeline, identifiers.length); + const outcomes = outcomesForOrderedCrs(faker, identifiers.length); + + 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: timeline.end + }) + ); +}; diff --git a/src/backend/src/prisma/seed/change-request.process.ts b/src/backend/src/prisma/seed/change-request.process.ts index a2bc078063..9df7f75063 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -1,29 +1,47 @@ -import { Change_Request } from '@prisma/client'; +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 { + buildAccountCodeChangeRequests, + buildWbsChangeRequests, + crCountForAccountCode, crCountForProject, crCountForWorkPackage, - createSeedChangeRequest, + SeedCrActor, SeedCrParent } from '../factories/change-request.factory.js'; -type ChangeRequestInput = OrganizationOutput & ProjectOutput & UsersOutput & WorkPackageOutput & ConfigDataOutput; +type ChangeRequestInput = OrganizationOutput & + ProjectOutput & + UsersOutput & + WorkPackageOutput & + ConfigDataOutput & + TeamOutput; export type ChangeRequestOutput = { changeRequests: Change_Request[]; changeRequestsByWbsElementId: Record; }; +const changeRequestInclude = { + activationChangeRequest: true, + stageGateChangeRequest: true, + budgetChangeRequest: 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]; + return [OrganizationProcess, ProjectProcess, UsersProcess, WorkPackageProcess, ConfigDataProcess, TeamProcess]; } private allocateIdentifiers(count: number): number[] { @@ -32,6 +50,83 @@ export class ChangeRequestProcess extends SeedProcess { + return Promise.all(inputs.map((data) => this.prisma.change_Request.create({ data, include: changeRequestInclude }))); + } + + private async applyAcceptedWbsStatuses(changeRequests: SeededChangeRequest[]): Promise { + const latestStatusByWbs = new Map(); + + for (const cr of changeRequests) { + if (!cr.accepted || !cr.wbsElementId) continue; + + const status = + cr.type === CR_Type.ACTIVATION + ? WBS_Element_Status.ACTIVE + : cr.type === CR_Type.STAGE_GATE + ? WBS_Element_Status.COMPLETE + : undefined; + + if (!status) continue; + + const current = latestStatusByWbs.get(cr.wbsElementId); + if (!current || cr.dateSubmitted >= current.dateSubmitted) { + latestStatusByWbs.set(cr.wbsElementId, { dateSubmitted: cr.dateSubmitted, status }); + } + } + + await Promise.all( + Array.from(latestStatusByWbs.entries()).map(([wbsElementId, { status }]) => + this.prisma.wBS_Element.update({ where: { wbsElementId }, data: { 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, @@ -41,7 +136,8 @@ export class ChangeRequestProcess extends SeedProcess { const { organizationId } = organization; @@ -58,17 +154,11 @@ export class ChangeRequestProcess extends SeedProcess - reimbursementProductOtherReasons.length > 0 - ? this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId - : undefined; - - const changeRequests: Change_Request[] = []; + const wbsChangeRequestInputs: Prisma.Change_RequestCreateInput[] = []; for (const { project, timeline } of projects) { const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; - // project-scoped CRs (standard / budget / leadership) const { leadId: projectLeadId, managerId: projectManagerId } = project.wbsElement; const projectParent: SeedCrParent = { wbsElementId: project.wbsElementId, @@ -77,57 +167,68 @@ export class ChangeRequestProcess extends SeedProcess - this.prisma.change_Request.create({ - data: createSeedChangeRequest( - this.faker, - projectParent, - false, - identifier, - organizationId, - this.faker.helpers.arrayElement(submitterPool).userId, - this.faker.helpers.arrayElement(reviewerPool).userId, - generateBudgetReasonId() - ) - }) + wbsChangeRequestInputs.push( + ...buildWbsChangeRequests( + this.faker, + projectParent, + false, + this.allocateIdentifiers(crCountForProject(this.faker)), + organizationId, + submitterPool, + reviewerPool ) ); - changeRequests.push(...projectCrs); - - const wpCrs = await Promise.all( - projectWorkPackages.flatMap(({ workPackage, timeline: wpTimeline }) => { - const { leadId: wpLeadId, managerId: wpManagerId } = workPackage.wbsElement; - const wpParent: SeedCrParent = { - wbsElementId: workPackage.wbsElement.wbsElementId, - timeline: wpTimeline, - leadId: wpLeadId ?? undefined, - managerId: wpManagerId ?? undefined - }; - - const wpCrIdentifiers = this.allocateIdentifiers(crCountForWorkPackage(this.faker)); - - return wpCrIdentifiers.map((identifier) => - this.prisma.change_Request.create({ - data: createSeedChangeRequest( - this.faker, - wpParent, - true, - identifier, - organizationId, - this.faker.helpers.arrayElement(submitterPool).userId, - this.faker.helpers.arrayElement(reviewerPool).userId, - undefined - ) - }) - ); - }) - ); - changeRequests.push(...wpCrs); + + 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.applyAcceptedWbsStatuses(createdWbsChangeRequests); + await this.applyAcceptedAccountCodeBudgets(createdBudgetChangeRequests); + + const changeRequests = [...createdWbsChangeRequests, ...createdBudgetChangeRequests]; + const changeRequestsByWbsElementId = changeRequests.reduce>((acc, cr) => { if (cr.wbsElementId) { acc[cr.wbsElementId] ??= []; @@ -141,4 +242,14 @@ export class ChangeRequestProcess extends SeedProcess start.getTime()); + const ends = timelines.map(({ end }) => end.getTime()); + + return { + start: new Date(Math.min(...starts)), + end: new Date(Math.max(...ends)) + }; + } } From 184787c1d15ddcf3c8276c51ad1ba72d52eab941 Mon Sep 17 00:00:00 2001 From: Steph375 Date: Tue, 21 Jul 2026 11:25:33 -0400 Subject: [PATCH 4/4] #4245 fixes --- .../factories/change-request.factory.ts | 44 +++++++++----- .../src/prisma/seed/change-request.process.ts | 59 ++++++++++++------- 2 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/backend/src/prisma/factories/change-request.factory.ts b/src/backend/src/prisma/factories/change-request.factory.ts index 5c06620982..7428254c18 100644 --- a/src/backend/src/prisma/factories/change-request.factory.ts +++ b/src/backend/src/prisma/factories/change-request.factory.ts @@ -62,6 +62,10 @@ 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([ @@ -248,15 +252,23 @@ const buildChangeRequest = ({ }; }; -const outcomesForOrderedCrs = (faker: Faker, count: number): ReviewOutcome[] => - Array.from({ length: count }, (_, index) => (index === count - 1 ? latestOutcome(faker) : resolvedOutcome(faker))); +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, timeline: DateRange, count: number): Date[] => +const orderedSubmissionDates = (faker: Faker, window: DateRange, count: number): Date[] => Array.from({ length: count }, () => - clampDate(addDaysToDate(new Date(timeline.start), faker.number.int({ min: 0, max: daysBetween(timeline) })), { - start: timeline.start, - end: timeline.end - }) + 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; @@ -272,22 +284,24 @@ export const buildWbsChangeRequests = ( ): Prisma.Change_RequestCreateInput[] => { if (identifiers.length === 0) return []; - const dates = orderedSubmissionDates(faker, parent.timeline, identifiers.length); - const outcomes = outcomesForOrderedCrs(faker, identifiers.length); + 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: crTypeForParent(faker, isWorkPackage), + 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: parent.timeline.end + reviewWindowEnd: window.end }) ); }; @@ -303,8 +317,10 @@ export const buildAccountCodeChangeRequests = ( ): Prisma.Change_RequestCreateInput[] => { if (identifiers.length === 0) return []; - const dates = orderedSubmissionDates(faker, timeline, identifiers.length); - const outcomes = outcomesForOrderedCrs(faker, identifiers.length); + 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({ @@ -317,7 +333,7 @@ export const buildAccountCodeChangeRequests = ( reviewerId: pickActor(faker, reviewers), dateSubmitted: dates[index], outcome: outcomes[index], - reviewWindowEnd: timeline.end + 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 index 9df7f75063..05c2e8a45d 100644 --- a/src/backend/src/prisma/seed/change-request.process.ts +++ b/src/backend/src/prisma/seed/change-request.process.ts @@ -7,6 +7,7 @@ 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, @@ -32,7 +33,8 @@ export type ChangeRequestOutput = { const changeRequestInclude = { activationChangeRequest: true, stageGateChangeRequest: true, - budgetChangeRequest: true + budgetChangeRequest: true, + wbsElement: { include: { workPackage: true } } } satisfies Prisma.Change_RequestInclude; type SeededChangeRequest = Prisma.Change_RequestGetPayload<{ include: typeof changeRequestInclude }>; @@ -54,32 +56,49 @@ export class ChangeRequestProcess extends SeedProcess this.prisma.change_Request.create({ data, include: changeRequestInclude }))); } - private async applyAcceptedWbsStatuses(changeRequests: SeededChangeRequest[]): Promise { - const latestStatusByWbs = new Map(); + private async applyAcceptedWbsChanges(changeRequests: SeededChangeRequest[]): Promise { + const workPackageCrs = new Map(); for (const cr of changeRequests) { - if (!cr.accepted || !cr.wbsElementId) continue; + if (!cr.accepted || !cr.wbsElement?.workPackage) continue; + if (cr.type !== CR_Type.ACTIVATION && cr.type !== CR_Type.STAGE_GATE) continue; - const status = - cr.type === CR_Type.ACTIVATION - ? WBS_Element_Status.ACTIVE - : cr.type === CR_Type.STAGE_GATE - ? WBS_Element_Status.COMPLETE - : undefined; + const list = workPackageCrs.get(cr.wbsElement.wbsElementId) ?? []; + list.push(cr); + workPackageCrs.set(cr.wbsElement.wbsElementId, list); + } - if (!status) continue; + await Promise.all(Array.from(workPackageCrs.values()).map((crs) => this.applyWorkPackageLifecycle(crs))); + } - const current = latestStatusByWbs.get(cr.wbsElementId); - if (!current || cr.dateSubmitted >= current.dateSubmitted) { - latestStatusByWbs.set(cr.wbsElementId, { dateSubmitted: cr.dateSubmitted, status }); + 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 Promise.all( - Array.from(latestStatusByWbs.entries()).map(([wbsElementId, { status }]) => - this.prisma.wBS_Element.update({ where: { wbsElementId }, data: { status } }) - ) - ); + await this.prisma.work_Package.update({ + where: { workPackageId: workPackage.workPackageId }, + data: { + startDate, + duration, + wbsElement: { update: { status } } + } + }); } private async applyAcceptedAccountCodeBudgets(changeRequests: SeededChangeRequest[]): Promise { @@ -224,7 +243,7 @@ export class ChangeRequestProcess extends SeedProcess