From 3ed2e6d9cc90067d1390564113afe4e845b0df9a Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 12 Jul 2026 21:25:50 -0400 Subject: [PATCH 1/7] #4243 factory and process --- src/backend/src/prisma/dev-seed.ts | 4 +- .../reimbursement-request.factory.ts | 313 +++++++++++++++ .../seed/reimbursement-request.process.ts | 372 ++++++++++++++++++ 3 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 src/backend/src/prisma/factories/reimbursement-request.factory.ts create mode 100644 src/backend/src/prisma/seed/reimbursement-request.process.ts diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index eaff3d271d..b7c9272e65 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -16,6 +16,7 @@ import { TeamProcess } from './seed/team.process.js'; import { ProjectProcess } from './seed/project.process.js'; import { SchedulingProcess } from './seed/scheduling.process.js'; import { WorkPackageProcess } from './seed/work-package.process.js'; +import { ReimbursementRequestProcess } from './seed/reimbursement-request.process.js'; const prisma = new PrismaClient(); @@ -31,7 +32,8 @@ await new SeedRunner() new TeamProcess(), new ShopProcess(), new ProjectProcess(), - new WorkPackageProcess() + new WorkPackageProcess(), + new ReimbursementRequestProcess() ) .run(); diff --git a/src/backend/src/prisma/factories/reimbursement-request.factory.ts b/src/backend/src/prisma/factories/reimbursement-request.factory.ts new file mode 100644 index 0000000000..794c36a37d --- /dev/null +++ b/src/backend/src/prisma/factories/reimbursement-request.factory.ts @@ -0,0 +1,313 @@ +import { Faker } from '@faker-js/faker'; +import { Account_Code, Index_Code, Prisma, Reimbursement_Status_Type } from '@prisma/client'; +import { addDaysToDate } from 'shared'; + +export const REIMBURSEMENT_REQUESTS_PER_CAR = 150; + +export const WBS_REASON_CHANCE = 0.7; +export const ASSIGNEE_CHANCE = 0.75; +export const EXTRA_COMMENT_CHANCE = 0.15; +export const DELIVERY_CHANCE = 0.65; +export const REIMBURSEMENT_CHANCE_PER_RECIPIENT = 0.7; + +const DENIED_CHANCE = 0.08; +const MIN_DAYS_PER_STAGE = 2; +const MAX_DAYS_PER_STAGE = 14; + +const STAGE_ORDER: Reimbursement_Status_Type[] = [ + Reimbursement_Status_Type.PENDING_LEADERSHIP_APPROVAL, + Reimbursement_Status_Type.LEADERSHIP_APPROVED, + Reimbursement_Status_Type.PENDING_FINANCE, + Reimbursement_Status_Type.PENDING_SABO_SUBMISSION, + Reimbursement_Status_Type.SABO_SUBMITTED, + Reimbursement_Status_Type.REIMBURSED +]; + +export const WBS_PRODUCT_NAMES = [ + 'Carbon Fiber Sheets', + 'Aluminum Stock', + 'Epoxy Resin', + 'High Performance Battery Pack', + 'Sensor Components', + 'Microcontrollers', + 'PCB Manufacturing', + '3D Printing Filament', + 'Machining Services', + 'Fasteners and Hardware', + 'Wiring Harness Materials', + 'Bearings', + 'Brake Pads', + 'Suspension Bushings', + 'Motor Controller Components', + 'Battery Testing Equipment', + 'CAD Software License', + 'Data Acquisition Sensors', + 'Tires', + 'Welding Supplies', + 'Powder Coating Service', + 'Custom Machined Brackets', + 'Heat Shrink Tubing', + 'Connectors and Terminals', + 'Composite Layup Materials', + 'Cooling System Components', + 'Steering Components', + 'Chassis Tubing', + 'Telemetry Hardware', + 'Prototype Enclosures' +]; + +export const GENERAL_SUPPLY_PRODUCT_NAMES = [ + 'Workshop Cleaning Supplies', + 'Safety Glasses', + 'Nitrile Gloves', + 'Zip Ties', + 'Duct Tape', + 'Shop Towels', + 'First Aid Supplies', + 'Whiteboard Markers', + 'Printer Paper', + 'Team Event Supplies', + 'Workshop Snacks', + 'Office Supplies', + 'Presentation Materials', + 'Recruitment Flyers', + 'Competition Travel Snacks', + 'Hand Tools Set', + 'Extension Cords', + 'Storage Bins', + 'Label Maker Tape', + 'Cleaning Solvents' +]; + +const INDEX_CODE_NAMES = ['CASH', 'BUDGET'] as const; + +const ACCOUNT_CODE_NAMES_BY_INDEX_CODE: Record<(typeof INDEX_CODE_NAMES)[number], string[]> = { + CASH: ['Subscriptions', 'Travel-Misc', 'Food', 'General Supplies/Tools'], + BUDGET: ['Subscriptions', 'Travel-Auto/Van Rental', 'Travel-Misc', 'Competition-Registration', 'Food', 'General Supplies/Tools'] +}; + +export const chooseFundingSource = ( + faker: Faker, + indexCodesByName: Record, + accountCodesByName: Record +): { indexCode: Index_Code; accountCode: Account_Code } => { + const indexCodeName = faker.helpers.arrayElement(INDEX_CODE_NAMES); + const accountCodeName = faker.helpers.arrayElement(ACCOUNT_CODE_NAMES_BY_INDEX_CODE[indexCodeName]); + + const indexCode = indexCodesByName[indexCodeName]; + const accountCode = accountCodesByName[accountCodeName]; + + if (!indexCode || !accountCode) { + throw new Error(`Missing funding source for index code ${indexCodeName} / account code ${accountCodeName}`); + } + + return { indexCode, accountCode }; +}; + +export type ReimbursementStatusStep = { type: Reimbursement_Status_Type; date: Date }; + +/** + * Generates a chronologically ordered status history for a reimbursement request, bounded by `now`. + * Requests created more recently naturally stall earlier in the pipeline since fewer days + * have elapsed for them to progress; older requests have had time to reach later stages. + * A request may be denied at a random point instead of continuing to progress. + */ +export const generateReimbursementStatusHistory = (faker: Faker, dateCreated: Date, now: Date): ReimbursementStatusStep[] => { + const history: ReimbursementStatusStep[] = [{ type: STAGE_ORDER[0], date: dateCreated }]; + + const isDenied = faker.datatype.boolean({ probability: DENIED_CHANCE }); + const deniedAfterStageCount = isDenied ? faker.number.int({ min: 0, max: 2 }) : Infinity; + + let currentDate = dateCreated; + + for (let stageIndex = 1; stageIndex < STAGE_ORDER.length; stageIndex++) { + if (stageIndex > deniedAfterStageCount) break; + + const nextDate = addDaysToDate(currentDate, faker.number.int({ min: MIN_DAYS_PER_STAGE, max: MAX_DAYS_PER_STAGE })); + if (nextDate > now) break; + + history.push({ type: STAGE_ORDER[stageIndex], date: nextDate }); + currentDate = nextDate; + } + + if (isDenied && history[history.length - 1].type !== Reimbursement_Status_Type.REIMBURSED) { + const deniedDate = addDaysToDate(currentDate, faker.number.int({ min: 1, max: MAX_DAYS_PER_STAGE })); + if (deniedDate <= now) { + history.push({ type: Reimbursement_Status_Type.DENIED, date: deniedDate }); + } + } + + return history; +}; + +export const hasReachedStage = (history: ReimbursementStatusStep[], stage: Reimbursement_Status_Type): boolean => + history.some((step) => step.type === stage); + +export const generateProductCount = (faker: Faker): number => + faker.helpers.weightedArrayElement([ + { weight: 60, value: 1 }, + { weight: 30, value: 2 }, + { weight: 10, value: 3 } + ]); + +export const generateReimbursementRequestTotalCost = (faker: Faker): number => { + const bucket = faker.number.int({ min: 1, max: 100 }); + + const dollars = + bucket <= 55 + ? faker.number.int({ min: 15, max: 300 }) + : bucket <= 90 + ? faker.number.int({ min: 300, max: 1500 }) + : faker.number.int({ min: 1500, max: 5000 }); + + return dollars * 100; +}; + +/** + * Splits a total cost (in cents) across `partCount` products, rounded to the nearest dollar, + * with any rounding remainder applied to the last part so the parts always sum to the total. + */ +export const splitCost = (faker: Faker, totalCost: number, partCount: number): number[] => { + if (partCount === 1) return [totalCost]; + + const weights = Array.from({ length: partCount }, () => faker.number.float({ min: 0.5, max: 1.5 })); + const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); + + const parts = weights.map((weight) => Math.round((totalCost * weight) / totalWeight / 100) * 100); + const difference = totalCost - parts.reduce((sum, part) => sum + part, 0); + + // apply the rounding remainder to the largest part so a small part can never be pushed to zero/negative + const largestPartIndex = parts.reduce( + (largestIndex, part, index) => (part > parts[largestIndex] ? index : largestIndex), + 0 + ); + parts[largestPartIndex] += difference; + + return parts; +}; + +export const reimbursementRequestCreateInput = ( + organizationId: string, + identifier: number, + recipientId: string, + vendorId: string, + indexCodeId: string, + accountCodeId: string, + totalCost: number, + dateCreated: Date, + dateOfExpense: Date | undefined, + description: string +): Prisma.Reimbursement_RequestCreateInput => ({ + identifier, + totalCost, + dateCreated, + dateOfExpense: dateOfExpense ?? null, + description, + recipient: { connect: { userId: recipientId } }, + vendor: { connect: { vendorId } }, + indexCode: { connect: { indexCodeId } }, + accountCode: { connect: { accountCodeId } }, + organization: { connect: { organizationId } }, + reimbursementStatuses: { + create: { + type: Reimbursement_Status_Type.PENDING_LEADERSHIP_APPROVAL, + userId: recipientId, + dateCreated + } + } +}); + +export const wbsReimbursementProductReasonCreateInput = ( + wbsElementId: string +): Prisma.Reimbursement_Product_ReasonCreateInput => ({ + wbsElement: { connect: { wbsElementId } } +}); + +export const otherReimbursementProductReasonCreateInput = ( + otherReasonId: string +): Prisma.Reimbursement_Product_ReasonCreateInput => ({ + otherReason: { connect: { otherReimbursementProductReasonId: otherReasonId } } +}); + +export const reimbursementProductCreateInput = ( + name: string, + cost: number, + reimbursementRequestId: string, + reimbursementProductReasonId: string, + indexCodeId: string +): Prisma.Reimbursement_ProductCreateInput => ({ + name, + cost, + reimbursementRequest: { connect: { reimbursementRequestId } }, + reimbursementProductReason: { connect: { reimbursementProductReasonId } }, + refundSources: { + create: { + amount: cost, + indexCode: { connect: { indexCodeId } } + } + } +}); + +export const receiptCreateInput = ( + reimbursementRequestId: string, + createdByUserId: string, + identifier: number, + dateCreated: Date +): Prisma.ReceiptCreateInput => ({ + googleFileId: `seed-receipt-${identifier}`, + name: `receipt-${identifier}.pdf`, + dateCreated, + createdBy: { connect: { userId: createdByUserId } }, + reimbursementRequest: { connect: { reimbursementRequestId } } +}); + +/** + * Only a Head+ recipient can set their date of expense right at creation (the "Date of Expense" + * field is hidden from everyone else on the create form). Anyone else has to wait until their + * request is leadership-approved, then adds it themselves via "Add Purchase Details" once they've + * actually bought the item - so their date of expense must fall on or after that approval date. + */ +export const generateDateOfExpense = ( + faker: Faker, + recipientCanSetAtCreation: boolean, + dateCreated: Date, + approvalDate: Date | undefined, + latestPossibleDate: Date +): Date | undefined => { + if (recipientCanSetAtCreation) { + return faker.date.recent({ days: faker.number.int({ min: 0, max: 10 }), refDate: dateCreated }); + } + + if (!approvalDate) return undefined; + + const latest = latestPossibleDate > approvalDate ? latestPossibleDate : addDaysToDate(approvalDate, 1); + return faker.date.between({ from: approvalDate, to: latest }); +}; + +export const systemCommentText = (firstName: string, lastName: string, action: string): string => + `${firstName} ${lastName} ${action}`; + +export const reimbursementRequestCommentCreateInput = ( + reimbursementRequestId: string, + userCreatedId: string, + comment: string, + dateCreated: Date +): Prisma.Reimbursement_Request_CommentCreateInput => ({ + comment, + dateCreated, + reimbursementRequest: { connect: { reimbursementRequestId } }, + userCreated: { connect: { userId: userCreatedId } } +}); + +export const reimbursementCreateInput = ( + organizationId: string, + purchaserId: string, + amount: number, + dateCreated: Date +): Prisma.ReimbursementCreateInput => ({ + amount, + dateCreated, + purchaser: { connect: { userId: purchaserId } }, + userSubmitted: { connect: { userId: purchaserId } }, + organization: { connect: { organizationId } } +}); diff --git a/src/backend/src/prisma/seed/reimbursement-request.process.ts b/src/backend/src/prisma/seed/reimbursement-request.process.ts new file mode 100644 index 0000000000..1762dcd70a --- /dev/null +++ b/src/backend/src/prisma/seed/reimbursement-request.process.ts @@ -0,0 +1,372 @@ +import { Reimbursement_Request, Reimbursement_Status_Type } from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; +import { UsersOutput, UsersProcess } from './user.process.js'; +import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; +import { TeamOutput, TeamProcess } from './team.process.js'; +import { ProjectOutput, ProjectProcess } from './project.process.js'; +import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; +import { CarProcess } from './car.process.js'; +import { CarOutput, FullUser } from '../context.js'; +import { clampDate } from '../dates.js'; +import { + ASSIGNEE_CHANCE, + chooseFundingSource, + DELIVERY_CHANCE, + EXTRA_COMMENT_CHANCE, + generateDateOfExpense, + generateProductCount, + generateReimbursementRequestTotalCost, + generateReimbursementStatusHistory, + GENERAL_SUPPLY_PRODUCT_NAMES, + hasReachedStage, + otherReimbursementProductReasonCreateInput, + receiptCreateInput, + reimbursementCreateInput, + reimbursementProductCreateInput, + reimbursementRequestCommentCreateInput, + reimbursementRequestCreateInput, + REIMBURSEMENT_CHANCE_PER_RECIPIENT, + REIMBURSEMENT_REQUESTS_PER_CAR, + splitCost, + systemCommentText, + wbsReimbursementProductReasonCreateInput, + WBS_PRODUCT_NAMES, + WBS_REASON_CHANCE +} from '../factories/reimbursement-request.factory.js'; + +type ReimbursementRequestInput = OrganizationOutput & + UsersOutput & + ConfigDataOutput & + TeamOutput & + CarOutput & + ProjectOutput & + WorkPackageOutput; + +export type ReimbursementRequestOutput = { + reimbursementRequests: Reimbursement_Request[]; +}; + +const SYSTEM_COMMENT_ACTION_BY_STAGE: Partial> = { + [Reimbursement_Status_Type.LEADERSHIP_APPROVED]: 'Leadership Approved', + [Reimbursement_Status_Type.PENDING_FINANCE]: 'Marked Pending Finance', + [Reimbursement_Status_Type.PENDING_SABO_SUBMISSION]: 'Inputted in SABO', + [Reimbursement_Status_Type.SABO_SUBMITTED]: 'submitted to SABO', + [Reimbursement_Status_Type.REIMBURSED]: 'Marked As Reimbursed', + [Reimbursement_Status_Type.DENIED]: 'Denied This Request' +}; + +const EXTRA_COMMENT_TEMPLATES = [ + 'Please upload receipt when available', + 'Receipt uploaded to Google Drive', + 'Following up with vendor on delivery date', + 'Approved and ready for SABO submission', + 'Items delivered and verified', + 'Let me know if you need anything else for this' +]; + +export class ReimbursementRequestProcess extends SeedProcess { + dependencies() { + return [OrganizationProcess, UsersProcess, ConfigDataProcess, TeamProcess, CarProcess, ProjectProcess, WorkPackageProcess]; + } + + async run({ + organization, + members, + leadership, + heads, + admins, + appAdmins, + cars, + indexCodes, + accountCodes, + vendors, + reimbursementProductOtherReasons, + financeTeam, + projectsByCarId, + workPackagesByProjectId + }: ReimbursementRequestInput): Promise { + const { organizationId } = organization; + const now = new Date(); + + // appAdmins is just the bootstrap user, which has no User_Secure_Settings row - createReimbursementRequest + // hard-requires that for the recipient, so it's excluded here (it can still approve/action requests, which + // has no such requirement) + const recipients = [...members, ...leadership, ...heads, ...admins]; + const headApprovers = [...heads, ...admins, ...appAdmins]; + const headApproverIds = new Set(headApprovers.map((user) => user.userId)); + + if (recipients.length === 0) throw new Error('ReimbursementRequestProcess requires at least one eligible recipient.'); + if (headApprovers.length === 0) throw new Error('ReimbursementRequestProcess requires at least one head-level approver.'); + + const financeTeamFull = await this.prisma.team.findUniqueOrThrow({ + where: { teamId: financeTeam.teamId }, + include: { head: true, leads: true, members: true } + }); + + const financeTeamMemberIds = new Set( + [financeTeamFull.headId, ...financeTeamFull.leads.map((u) => u.userId), ...financeTeamFull.members.map((u) => u.userId)].filter( + (id): id is string => !!id + ) + ); + + const financePersonnelById = new Map(); + [...headApprovers, ...recipients.filter((user) => financeTeamMemberIds.has(user.userId))].forEach((user) => + financePersonnelById.set(user.userId, user) + ); + const financePersonnel = [...financePersonnelById.values()]; + + if (financePersonnel.length === 0) throw new Error('ReimbursementRequestProcess requires at least one finance team member.'); + + const indexCodesByName = indexCodes.reduce>((acc, indexCode) => { + acc[indexCode.name] = indexCode; + return acc; + }, {}); + + const accountCodesByName = accountCodes.reduce>((acc, accountCode) => { + acc[accountCode.name] = accountCode; + return acc; + }, {}); + + let identifier = 0; + const reimbursementRequests: Reimbursement_Request[] = []; + const reimbursedTotalsByRecipientId = new Map(); + + for (const { car, dateRange } of cars) { + // skip cars that haven't started yet (e.g. a next-year car still in early planning) + if (dateRange.start >= now) continue; + + const carProjects = (projectsByCarId[car.carId] ?? []).filter((projectContext) => projectContext.timeline.start < now); + if (carProjects.length === 0) continue; + + const carCreationWindow = { start: dateRange.start, end: clampDate(dateRange.end, { start: dateRange.start, end: now }) }; + + for (let i = 0; i < REIMBURSEMENT_REQUESTS_PER_CAR; i++) { + identifier += 1; + + const recipient = this.faker.helpers.arrayElement(recipients); + const project = this.faker.helpers.arrayElement(carProjects); + + const projectCreationWindow = { + start: project.timeline.start > carCreationWindow.start ? project.timeline.start : carCreationWindow.start, + end: project.timeline.end < carCreationWindow.end ? project.timeline.end : carCreationWindow.end + }; + + const dateCreated = + projectCreationWindow.start < projectCreationWindow.end + ? this.faker.date.between({ from: projectCreationWindow.start, to: projectCreationWindow.end }) + : projectCreationWindow.start; + + const statusHistory = generateReimbursementStatusHistory(this.faker, dateCreated, now); + + // only a Head+ recipient may set their date of expense immediately at creation; everyone + // else has to wait until their request is leadership-approved and add it themselves after + const approvalStep = statusHistory.find((step) => step.type === Reimbursement_Status_Type.LEADERSHIP_APPROVED); + const pendingFinanceStep = statusHistory.find((step) => step.type === Reimbursement_Status_Type.PENDING_FINANCE); + + const dateOfExpense = generateDateOfExpense( + this.faker, + headApproverIds.has(recipient.userId), + dateCreated, + approvalStep?.date, + pendingFinanceStep?.date ?? now + ); + + const { indexCode, accountCode } = chooseFundingSource(this.faker, indexCodesByName, accountCodesByName); + const vendor = this.faker.helpers.arrayElement(vendors); + + const useWbsReason = this.faker.datatype.boolean({ probability: WBS_REASON_CHANCE }); + const productCount = generateProductCount(this.faker); + const totalCost = generateReimbursementRequestTotalCost(this.faker); + const productCosts = splitCost(this.faker, totalCost, productCount); + + const description = useWbsReason + ? `Expenses for ${project.project.wbsElement.name}` + : this.faker.helpers.arrayElement(GENERAL_SUPPLY_PRODUCT_NAMES); + + const createdRequest = await this.prisma.reimbursement_Request.create({ + data: reimbursementRequestCreateInput( + organizationId, + identifier, + recipient.userId, + vendor.vendorId, + indexCode.indexCodeId, + accountCode.accountCodeId, + totalCost, + dateCreated, + dateOfExpense, + description + ) + }); + + const workPackagesForProject = workPackagesByProjectId[project.project.projectId] ?? []; + + for (let productIndex = 0; productIndex < productCount; productIndex++) { + const useWorkPackage = + useWbsReason && workPackagesForProject.length > 0 && this.faker.datatype.boolean({ probability: 0.4 }); + + const reasonCreateInput = useWbsReason + ? wbsReimbursementProductReasonCreateInput( + useWorkPackage + ? this.faker.helpers.arrayElement(workPackagesForProject).workPackage.wbsElement.wbsElementId + : project.project.wbsElement.wbsElementId + ) + : otherReimbursementProductReasonCreateInput( + this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId + ); + + const reason = await this.prisma.reimbursement_Product_Reason.create({ data: reasonCreateInput }); + + const productName = useWbsReason + ? this.faker.helpers.arrayElement(WBS_PRODUCT_NAMES) + : this.faker.helpers.arrayElement(GENERAL_SUPPLY_PRODUCT_NAMES); + + await this.prisma.reimbursement_Product.create({ + data: reimbursementProductCreateInput( + productName, + productCosts[productIndex], + createdRequest.reimbursementRequestId, + reason.reimbursementProductReasonId, + indexCode.indexCodeId + ) + }); + } + + for (const step of statusHistory.slice(1)) { + const actor = this.pickActorForStage(step.type, recipient, headApprovers, admins, financePersonnel); + + if (step.type === Reimbursement_Status_Type.PENDING_FINANCE) { + await this.prisma.receipt.create({ + data: receiptCreateInput(createdRequest.reimbursementRequestId, recipient.userId, identifier, step.date) + }); + } + + const action = SYSTEM_COMMENT_ACTION_BY_STAGE[step.type]; + if (action) { + await this.prisma.reimbursement_Request_Comment.create({ + data: reimbursementRequestCommentCreateInput( + createdRequest.reimbursementRequestId, + actor.userId, + systemCommentText(actor.firstName, actor.lastName, action), + step.date + ) + }); + } + + await this.prisma.reimbursement_Status.create({ + data: { + type: step.type, + userId: actor.userId, + dateCreated: step.date, + reimbursementRequestId: createdRequest.reimbursementRequestId + } + }); + } + + if (hasReachedStage(statusHistory, Reimbursement_Status_Type.PENDING_SABO_SUBMISSION)) { + await this.prisma.reimbursement_Request.update({ + where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, + data: { saboId: `SABO-${identifier}` } + }); + } + + if ( + dateOfExpense && + !hasReachedStage(statusHistory, Reimbursement_Status_Type.DENIED) && + this.faker.datatype.boolean({ probability: DELIVERY_CHANCE }) + ) { + const dateDelivered = clampDate( + this.faker.date.soon({ days: this.faker.number.int({ min: 1, max: 14 }), refDate: dateOfExpense }), + { start: dateOfExpense, end: now } + ); + + await this.prisma.reimbursement_Request.update({ + where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, + data: { dateDelivered } + }); + + await this.prisma.reimbursement_Request_Comment.create({ + data: reimbursementRequestCommentCreateInput( + createdRequest.reimbursementRequestId, + recipient.userId, + systemCommentText(recipient.firstName, recipient.lastName, 'Marked As Delivered'), + dateDelivered + ) + }); + } + + if ( + hasReachedStage(statusHistory, Reimbursement_Status_Type.LEADERSHIP_APPROVED) && + this.faker.datatype.boolean({ probability: ASSIGNEE_CHANCE }) + ) { + await this.prisma.reimbursement_Request.update({ + where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, + data: { assignee: { connect: { userId: this.faker.helpers.arrayElement(financePersonnel).userId } } } + }); + } + + if (this.faker.datatype.boolean({ probability: EXTRA_COMMENT_CHANCE })) { + const commentAuthor = this.faker.helpers.arrayElement([recipient, ...financePersonnel]); + + await this.prisma.reimbursement_Request_Comment.create({ + data: reimbursementRequestCommentCreateInput( + createdRequest.reimbursementRequestId, + commentAuthor.userId, + this.faker.helpers.arrayElement(EXTRA_COMMENT_TEMPLATES), + statusHistory[statusHistory.length - 1].date + ) + }); + } + + if (hasReachedStage(statusHistory, Reimbursement_Status_Type.REIMBURSED)) { + const reimbursedDate = statusHistory.find((step) => step.type === Reimbursement_Status_Type.REIMBURSED)!.date; + const existing = reimbursedTotalsByRecipientId.get(recipient.userId); + + reimbursedTotalsByRecipientId.set(recipient.userId, { + total: (existing?.total ?? 0) + totalCost, + latestDate: existing && existing.latestDate > reimbursedDate ? existing.latestDate : reimbursedDate + }); + } + + reimbursementRequests.push(createdRequest); + } + } + + for (const [recipientId, { total, latestDate }] of reimbursedTotalsByRecipientId) { + if (!this.faker.datatype.boolean({ probability: REIMBURSEMENT_CHANCE_PER_RECIPIENT })) continue; + + const amount = Math.round((total * this.faker.number.float({ min: 0.5, max: 1 })) / 100) * 100; + + await this.prisma.reimbursement.create({ + data: reimbursementCreateInput(organizationId, recipientId, amount, latestDate) + }); + } + + return { reimbursementRequests }; + } + + private pickActorForStage( + stage: Reimbursement_Status_Type, + recipient: FullUser, + headApprovers: FullUser[], + admins: FullUser[], + financePersonnel: FullUser[] + ): FullUser { + switch (stage) { + case Reimbursement_Status_Type.LEADERSHIP_APPROVED: + return this.faker.helpers.arrayElement(headApprovers); + // in practice this is almost always the recipient marking their own request pending finance + // once they've added purchase details; only rarely does an admin do it on their behalf + case Reimbursement_Status_Type.PENDING_FINANCE: + return this.faker.datatype.boolean({ probability: 0.85 }) || admins.length === 0 + ? recipient + : this.faker.helpers.arrayElement(admins); + case Reimbursement_Status_Type.SABO_SUBMITTED: + return recipient; + case Reimbursement_Status_Type.DENIED: + return this.faker.helpers.arrayElement([recipient, ...financePersonnel]); + default: + return this.faker.helpers.arrayElement(financePersonnel); + } + } +} From 654efd34403a751d1723bead6d7a8e8c4373f6b9 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 12 Jul 2026 21:42:31 -0400 Subject: [PATCH 2/7] #4243 prettier; --- .../reimbursement-request.factory.ts | 15 ++++++++-- .../seed/reimbursement-request.process.ts | 29 ++++++++++++++----- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/src/backend/src/prisma/factories/reimbursement-request.factory.ts b/src/backend/src/prisma/factories/reimbursement-request.factory.ts index 794c36a37d..b595d7ef4a 100644 --- a/src/backend/src/prisma/factories/reimbursement-request.factory.ts +++ b/src/backend/src/prisma/factories/reimbursement-request.factory.ts @@ -83,7 +83,14 @@ const INDEX_CODE_NAMES = ['CASH', 'BUDGET'] as const; const ACCOUNT_CODE_NAMES_BY_INDEX_CODE: Record<(typeof INDEX_CODE_NAMES)[number], string[]> = { CASH: ['Subscriptions', 'Travel-Misc', 'Food', 'General Supplies/Tools'], - BUDGET: ['Subscriptions', 'Travel-Auto/Van Rental', 'Travel-Misc', 'Competition-Registration', 'Food', 'General Supplies/Tools'] + BUDGET: [ + 'Subscriptions', + 'Travel-Auto/Van Rental', + 'Travel-Misc', + 'Competition-Registration', + 'Food', + 'General Supplies/Tools' + ] }; export const chooseFundingSource = ( @@ -112,7 +119,11 @@ export type ReimbursementStatusStep = { type: Reimbursement_Status_Type; date: D * have elapsed for them to progress; older requests have had time to reach later stages. * A request may be denied at a random point instead of continuing to progress. */ -export const generateReimbursementStatusHistory = (faker: Faker, dateCreated: Date, now: Date): ReimbursementStatusStep[] => { +export const generateReimbursementStatusHistory = ( + faker: Faker, + dateCreated: Date, + now: Date +): ReimbursementStatusStep[] => { const history: ReimbursementStatusStep[] = [{ type: STAGE_ORDER[0], date: dateCreated }]; const isDenied = faker.datatype.boolean({ probability: DENIED_CHANCE }); diff --git a/src/backend/src/prisma/seed/reimbursement-request.process.ts b/src/backend/src/prisma/seed/reimbursement-request.process.ts index 1762dcd70a..652f762fcc 100644 --- a/src/backend/src/prisma/seed/reimbursement-request.process.ts +++ b/src/backend/src/prisma/seed/reimbursement-request.process.ts @@ -67,7 +67,15 @@ const EXTRA_COMMENT_TEMPLATES = [ export class ReimbursementRequestProcess extends SeedProcess { dependencies() { - return [OrganizationProcess, UsersProcess, ConfigDataProcess, TeamProcess, CarProcess, ProjectProcess, WorkPackageProcess]; + return [ + OrganizationProcess, + UsersProcess, + ConfigDataProcess, + TeamProcess, + CarProcess, + ProjectProcess, + WorkPackageProcess + ]; } async run({ @@ -97,7 +105,8 @@ export class ReimbursementRequestProcess extends SeedProcess user.userId)); if (recipients.length === 0) throw new Error('ReimbursementRequestProcess requires at least one eligible recipient.'); - if (headApprovers.length === 0) throw new Error('ReimbursementRequestProcess requires at least one head-level approver.'); + if (headApprovers.length === 0) + throw new Error('ReimbursementRequestProcess requires at least one head-level approver.'); const financeTeamFull = await this.prisma.team.findUniqueOrThrow({ where: { teamId: financeTeam.teamId }, @@ -105,9 +114,11 @@ export class ReimbursementRequestProcess extends SeedProcess u.userId), ...financeTeamFull.members.map((u) => u.userId)].filter( - (id): id is string => !!id - ) + [ + financeTeamFull.headId, + ...financeTeamFull.leads.map((u) => u.userId), + ...financeTeamFull.members.map((u) => u.userId) + ].filter((id): id is string => !!id) ); const financePersonnelById = new Map(); @@ -116,7 +127,8 @@ export class ReimbursementRequestProcess extends SeedProcess>((acc, indexCode) => { acc[indexCode.name] = indexCode; @@ -139,7 +151,10 @@ export class ReimbursementRequestProcess extends SeedProcess projectContext.timeline.start < now); if (carProjects.length === 0) continue; - const carCreationWindow = { start: dateRange.start, end: clampDate(dateRange.end, { start: dateRange.start, end: now }) }; + const carCreationWindow = { + start: dateRange.start, + end: clampDate(dateRange.end, { start: dateRange.start, end: now }) + }; for (let i = 0; i < REIMBURSEMENT_REQUESTS_PER_CAR; i++) { identifier += 1; From f506b4fe5de29e06f09b41d2ff45088c46b50988 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Sun, 12 Jul 2026 22:02:32 -0400 Subject: [PATCH 3/7] #4243 bug fix on vendors and rr --- src/backend/src/prisma/factories/config-data.factory.ts | 3 ++- .../src/prisma/factories/reimbursement-request.factory.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/backend/src/prisma/factories/config-data.factory.ts b/src/backend/src/prisma/factories/config-data.factory.ts index 5f7410a09d..4a3e1e3ff8 100644 --- a/src/backend/src/prisma/factories/config-data.factory.ts +++ b/src/backend/src/prisma/factories/config-data.factory.ts @@ -1,5 +1,6 @@ import { Prisma, Work_Package_Stage } from '@prisma/client'; import { connectOrganization, connectUser } from '../utils/common.factory.js'; +import { encrypt } from '../../utils/encryption.utils.js'; type WorkPackageTemplateConfig = { templateName: string; @@ -278,7 +279,7 @@ export const vendorCreateInputs = (addedByUserId: string, organizationId: string taxExempt, notes, username, - password, + password: password ? encrypt(password) : undefined, discountCode, addedBy: connectUser(addedByUserId), organization: connectOrganization(organizationId), diff --git a/src/backend/src/prisma/factories/reimbursement-request.factory.ts b/src/backend/src/prisma/factories/reimbursement-request.factory.ts index b595d7ef4a..2b39ea9b38 100644 --- a/src/backend/src/prisma/factories/reimbursement-request.factory.ts +++ b/src/backend/src/prisma/factories/reimbursement-request.factory.ts @@ -286,7 +286,7 @@ export const generateDateOfExpense = ( latestPossibleDate: Date ): Date | undefined => { if (recipientCanSetAtCreation) { - return faker.date.recent({ days: faker.number.int({ min: 0, max: 10 }), refDate: dateCreated }); + return faker.date.recent({ days: faker.number.int({ min: 1, max: 10 }), refDate: dateCreated }); } if (!approvalDate) return undefined; From 921d18ba7a708757ee8396783345cd2c07f7881e Mon Sep 17 00:00:00 2001 From: wavehassman Date: Mon, 13 Jul 2026 21:47:28 -0400 Subject: [PATCH 4/7] #4243 requested changes --- .../prisma/factories/reimbursement-request.factory.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/backend/src/prisma/factories/reimbursement-request.factory.ts b/src/backend/src/prisma/factories/reimbursement-request.factory.ts index 2b39ea9b38..5b33485452 100644 --- a/src/backend/src/prisma/factories/reimbursement-request.factory.ts +++ b/src/backend/src/prisma/factories/reimbursement-request.factory.ts @@ -79,6 +79,8 @@ export const GENERAL_SUPPLY_PRODUCT_NAMES = [ 'Cleaning Solvents' ]; +export type ReimbursementStatusStep = { type: Reimbursement_Status_Type; date: Date }; + const INDEX_CODE_NAMES = ['CASH', 'BUDGET'] as const; const ACCOUNT_CODE_NAMES_BY_INDEX_CODE: Record<(typeof INDEX_CODE_NAMES)[number], string[]> = { @@ -111,8 +113,6 @@ export const chooseFundingSource = ( return { indexCode, accountCode }; }; -export type ReimbursementStatusStep = { type: Reimbursement_Status_Type; date: Date }; - /** * Generates a chronologically ordered status history for a reimbursement request, bounded by `now`. * Requests created more recently naturally stall earlier in the pipeline since fewer days @@ -127,12 +127,12 @@ export const generateReimbursementStatusHistory = ( const history: ReimbursementStatusStep[] = [{ type: STAGE_ORDER[0], date: dateCreated }]; const isDenied = faker.datatype.boolean({ probability: DENIED_CHANCE }); - const deniedAfterStageCount = isDenied ? faker.number.int({ min: 0, max: 2 }) : Infinity; + const deniedAfterStageIndex = isDenied ? faker.number.int({ min: 0, max: 2 }) : STAGE_ORDER.length; let currentDate = dateCreated; for (let stageIndex = 1; stageIndex < STAGE_ORDER.length; stageIndex++) { - if (stageIndex > deniedAfterStageCount) break; + if (stageIndex > deniedAfterStageIndex) break; const nextDate = addDaysToDate(currentDate, faker.number.int({ min: MIN_DAYS_PER_STAGE, max: MAX_DAYS_PER_STAGE })); if (nextDate > now) break; @@ -296,7 +296,7 @@ export const generateDateOfExpense = ( }; export const systemCommentText = (firstName: string, lastName: string, action: string): string => - `${firstName} ${lastName} ${action}`; + `${firstName} ${lastName} ${action}`; export const reimbursementRequestCommentCreateInput = ( reimbursementRequestId: string, From 335cbdc1ff9be65cb7f3bcef760da90699d4589d Mon Sep 17 00:00:00 2001 From: wavehassman Date: Thu, 16 Jul 2026 22:53:10 -0400 Subject: [PATCH 5/7] #4243 tag material to rrs and fix project timeline issue --- .../src/prisma/factories/bom.factory.ts | 63 ++-- .../reimbursement-request.factory.ts | 98 ++--- src/backend/src/prisma/seed/bom.process.ts | 137 ++++--- .../prisma/seed/description-bullet.process.ts | 5 +- .../seed/reimbursement-request.process.ts | 348 +++++++++--------- src/backend/src/prisma/seed/tasks.process.ts | 5 +- .../src/prisma/seed/work-package.process.ts | 53 ++- 7 files changed, 388 insertions(+), 321 deletions(-) diff --git a/src/backend/src/prisma/factories/bom.factory.ts b/src/backend/src/prisma/factories/bom.factory.ts index 083b5677f4..bc37f3f587 100644 --- a/src/backend/src/prisma/factories/bom.factory.ts +++ b/src/backend/src/prisma/factories/bom.factory.ts @@ -1,6 +1,7 @@ import { Faker } from '@faker-js/faker'; import { Material_Status, Prisma } from '@prisma/client'; import { randomElementWithBlacklist } from '../utils/common.factory.js'; +import { DateRange } from '../context.js'; const MATERIAL_KEYWORDS_BY_TYPE: Record< string, @@ -407,30 +408,6 @@ export const generateProjectBOMCount = (faker: Faker): number => { return faker.number.int({ min: 81, max: 200 }); }; -/** - * Splits a project's total BOM count across its WBS elements, always summing back to `total`. - * Uses floor (never rounds up) so no share can overshoot, and the remainder left by flooring - * is handed out one unit at a time instead of dumped onto a single index - which previously - * could push that index below zero and get silently clamped away, undercounting the total. - */ -export const splitBOMCount = (faker: Faker, total: number, wbsElementCount: number): number[] => { - if (total === 0) return Array(wbsElementCount).fill(0); - if (wbsElementCount === 1) return [total]; - - const weights = Array.from({ length: wbsElementCount }, () => faker.number.float({ min: 0.5, max: 1.5 })); - const totalWeight = weights.reduce((sum, w) => sum + w, 0); - - const counts = weights.map((w) => Math.floor((total * w) / totalWeight)); - - let remainder = total - counts.reduce((sum, c) => sum + c, 0); - while (remainder > 0) { - counts[faker.number.int({ min: 0, max: wbsElementCount - 1 })] += 1; - remainder -= 1; - } - - return counts; -}; - export const generateAssemblyName = (faker: Faker): string => { while (true) { const parts: string[] = []; @@ -485,10 +462,11 @@ export const generateMaterialName = (faker: Faker, materialTypeName: string): st export const assemblyCreateInput = ( name: string, wbsElementId: string, - userCreatedId: string + userCreatedId: string, + dateCreated: Date ): Prisma.AssemblyCreateInput => ({ name, - dateCreated: new Date(), + dateCreated, userCreated: { connect: { userId: userCreatedId } }, wbsElement: { connect: { wbsElementId } } }); @@ -499,6 +477,7 @@ export const materialCreateInput = ( userCreatedId: string, materialTypeId: string, status: Material_Status, + dateCreated: Date, assemblyId?: string, manufacturerId?: string, unitId?: string, @@ -506,7 +485,7 @@ export const materialCreateInput = ( price?: number ): Prisma.MaterialCreateInput => ({ name, - dateCreated: new Date(), + dateCreated, linkUrl: '', status, userCreated: { connect: { userId: userCreatedId } }, @@ -516,5 +495,33 @@ export const materialCreateInput = ( ...(manufacturerId ? { manufacturer: { connect: { id: manufacturerId } } } : {}), ...(unitId ? { unit: { connect: { id: unitId } } } : {}), ...(quantity !== undefined ? { quantity } : {}), - ...(price !== undefined ? { price, subtotal: price * (quantity ?? 1) } : {}) + // matches BillOfMaterialsService.createMaterial: subtotal is only ever computed when both price and quantity are set + ...(price !== undefined ? { price } : {}), + ...(price !== undefined && quantity !== undefined ? { subtotal: Math.round(price * quantity) } : {}) }); + +/** Clamps a random date to `timeline`, and never later than `now` (a material can't have been logged in the future). */ +export const generateMaterialDateCreated = (faker: Faker, timeline: DateRange, now: Date): Date => { + const end = timeline.end < now ? timeline.end : now; + if (end <= timeline.start) return new Date(timeline.start); + return faker.date.between({ from: timeline.start, to: end }); +}; + +// past-year cars have had their entire year to actually purchase things, so most BOM items are received by now +export const PAST_YEAR_RECEIVED_CHANCE = 0.85; +// the current-year car is only halfway through its year, so only about half of its BOM items have been purchased so far +export const CURRENT_YEAR_RECEIVED_CHANCE = 0.5; + +const NOT_YET_PURCHASED_STATUSES: Material_Status[] = [ + Material_Status.NOT_READY_TO_ORDER, + Material_Status.READY_TO_ORDER, + Material_Status.ORDERED, + Material_Status.SHIPPED +]; + +export const generateMaterialStatus = (faker: Faker, isCurrentYearCar: boolean): Material_Status => { + const receivedChance = isCurrentYearCar ? CURRENT_YEAR_RECEIVED_CHANCE : PAST_YEAR_RECEIVED_CHANCE; + + if (faker.datatype.boolean({ probability: receivedChance })) return Material_Status.RECEIVED; + return faker.helpers.arrayElement(NOT_YET_PURCHASED_STATUSES); +}; diff --git a/src/backend/src/prisma/factories/reimbursement-request.factory.ts b/src/backend/src/prisma/factories/reimbursement-request.factory.ts index 5b33485452..7e05ac9140 100644 --- a/src/backend/src/prisma/factories/reimbursement-request.factory.ts +++ b/src/backend/src/prisma/factories/reimbursement-request.factory.ts @@ -2,9 +2,15 @@ import { Faker } from '@faker-js/faker'; import { Account_Code, Index_Code, Prisma, Reimbursement_Status_Type } from '@prisma/client'; import { addDaysToDate } from 'shared'; -export const REIMBURSEMENT_REQUESTS_PER_CAR = 150; +// fraction of a past-year car's BOM items that get tied to a reimbursement request +export const PAST_YEAR_BOM_TIE_CHANCE = 0.6; +// fraction of the current-year car's BOM items that get tied to a reimbursement request +// (it's only halfway through its year, so fewer of its BOM items have been purchased yet) +export const CURRENT_YEAR_BOM_TIE_CHANCE = 0.3; +// of all reimbursement products generated, the fraction that should reference a BOM item +// vs. a general supply - the remainder (1 - this) are general supplies +export const BOM_PRODUCT_RATIO = 0.7; -export const WBS_REASON_CHANCE = 0.7; export const ASSIGNEE_CHANCE = 0.75; export const EXTRA_COMMENT_CHANCE = 0.15; export const DELIVERY_CHANCE = 0.65; @@ -23,39 +29,6 @@ const STAGE_ORDER: Reimbursement_Status_Type[] = [ Reimbursement_Status_Type.REIMBURSED ]; -export const WBS_PRODUCT_NAMES = [ - 'Carbon Fiber Sheets', - 'Aluminum Stock', - 'Epoxy Resin', - 'High Performance Battery Pack', - 'Sensor Components', - 'Microcontrollers', - 'PCB Manufacturing', - '3D Printing Filament', - 'Machining Services', - 'Fasteners and Hardware', - 'Wiring Harness Materials', - 'Bearings', - 'Brake Pads', - 'Suspension Bushings', - 'Motor Controller Components', - 'Battery Testing Equipment', - 'CAD Software License', - 'Data Acquisition Sensors', - 'Tires', - 'Welding Supplies', - 'Powder Coating Service', - 'Custom Machined Brackets', - 'Heat Shrink Tubing', - 'Connectors and Terminals', - 'Composite Layup Materials', - 'Cooling System Components', - 'Steering Components', - 'Chassis Tubing', - 'Telemetry Hardware', - 'Prototype Enclosures' -]; - export const GENERAL_SUPPLY_PRODUCT_NAMES = [ 'Workshop Cleaning Supplies', 'Safety Glasses', @@ -161,40 +134,37 @@ export const generateProductCount = (faker: Faker): number => { weight: 10, value: 3 } ]); -export const generateReimbursementRequestTotalCost = (faker: Faker): number => { - const bucket = faker.number.int({ min: 1, max: 100 }); +/** Independently rolls each material against `tieChance` to decide which ones get a reimbursement product. */ +export const selectMaterialsToTie = (faker: Faker, materials: T[], tieChance: number): T[] => + materials.filter(() => faker.datatype.boolean({ probability: tieChance })); - const dollars = - bucket <= 55 - ? faker.number.int({ min: 15, max: 300 }) - : bucket <= 90 - ? faker.number.int({ min: 300, max: 1500 }) - : faker.number.int({ min: 1500, max: 5000 }); +/** How many general-supply products to generate so the overall BOM-vs-general-supply split lands on `BOM_PRODUCT_RATIO`. */ +export const generalSupplyCountForTiedMaterials = (tiedMaterialCount: number): number => + Math.round(tiedMaterialCount * ((1 - BOM_PRODUCT_RATIO) / BOM_PRODUCT_RATIO)); - return dollars * 100; -}; +/** Fallback cost (in cents) for a BOM-tied product whose material has no price set. */ +export const generateFallbackMaterialCost = (faker: Faker): number => faker.number.int({ min: 500, max: 20000 }); -/** - * Splits a total cost (in cents) across `partCount` products, rounded to the nearest dollar, - * with any rounding remainder applied to the last part so the parts always sum to the total. - */ -export const splitCost = (faker: Faker, totalCost: number, partCount: number): number[] => { - if (partCount === 1) return [totalCost]; +export type ReimbursementProductSpec = { material: T } | { generalSupply: true }; - const weights = Array.from({ length: partCount }, () => faker.number.float({ min: 0.5, max: 1.5 })); - const totalWeight = weights.reduce((sum, weight) => sum + weight, 0); +export const buildProductSpecs = (tiedMaterials: T[], generalSupplyCount: number): ReimbursementProductSpec[] => [ + ...tiedMaterials.map((material) => ({ material })), + ...Array.from({ length: generalSupplyCount }, () => ({ generalSupply: true as const })) +]; - const parts = weights.map((weight) => Math.round((totalCost * weight) / totalWeight / 100) * 100); - const difference = totalCost - parts.reduce((sum, part) => sum + part, 0); +/** Shuffles then chunks items into groups sized by `generateGroupSize` (e.g. `generateProductCount`), used to split a batch of products across individual reimbursement requests. */ +export const chunkIntoGroups = (faker: Faker, items: T[], generateGroupSize: (faker: Faker) => number): T[][] => { + const shuffled = faker.helpers.shuffle(items); + const groups: T[][] = []; - // apply the rounding remainder to the largest part so a small part can never be pushed to zero/negative - const largestPartIndex = parts.reduce( - (largestIndex, part, index) => (part > parts[largestIndex] ? index : largestIndex), - 0 - ); - parts[largestPartIndex] += difference; + let index = 0; + while (index < shuffled.length) { + const groupSize = Math.min(generateGroupSize(faker), shuffled.length - index); + groups.push(shuffled.slice(index, index + groupSize)); + index += groupSize; + } - return parts; + return groups; }; export const reimbursementRequestCreateInput = ( @@ -245,12 +215,14 @@ export const reimbursementProductCreateInput = ( cost: number, reimbursementRequestId: string, reimbursementProductReasonId: string, - indexCodeId: string + indexCodeId: string, + materialId?: string ): Prisma.Reimbursement_ProductCreateInput => ({ name, cost, reimbursementRequest: { connect: { reimbursementRequestId } }, reimbursementProductReason: { connect: { reimbursementProductReasonId } }, + ...(materialId ? { material: { connect: { materialId } } } : {}), refundSources: { create: { amount: cost, diff --git a/src/backend/src/prisma/seed/bom.process.ts b/src/backend/src/prisma/seed/bom.process.ts index e30435db0a..9861c876d5 100644 --- a/src/backend/src/prisma/seed/bom.process.ts +++ b/src/backend/src/prisma/seed/bom.process.ts @@ -1,73 +1,91 @@ +import { Material } from '@prisma/client'; import { SeedProcess } from '../processes/seed-process.js'; import { OrganizationProcess, OrganizationOutput } from './organization.process.js'; import { UsersOutput, UsersProcess } from './user.process.js'; import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; -import { ProjectOutput, ProjectProcess } from './project.process.js'; import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; -import { Material_Status } from '@prisma/client'; +import { CarOutput, DateRange } from '../context.js'; +import { CarProcess } from './car.process.js'; import { generateProjectBOMCount, - splitBOMCount, generateAssemblyName, generateMaterialName, + generateMaterialDateCreated, + generateMaterialStatus, assemblyCreateInput, materialCreateInput } from '../factories/bom.factory.js'; -type BOMInput = OrganizationOutput & UsersOutput & ConfigDataOutput & ProjectOutput & WorkPackageOutput; +type BOMInput = OrganizationOutput & UsersOutput & ConfigDataOutput & WorkPackageOutput & CarOutput; + +export type BOMOutput = { + materialsByProjectId: Record; +}; const ASSEMBLY_PROBABILITY = 0.3; const BATCH_SIZE = 20; +const MATERIAL_BATCH_SIZE = 50; -export class BOMProcess extends SeedProcess> { +export class BOMProcess extends SeedProcess { dependencies() { - return [OrganizationProcess, UsersProcess, ConfigDataProcess, ProjectProcess, WorkPackageProcess]; + return [OrganizationProcess, UsersProcess, ConfigDataProcess, WorkPackageProcess, CarProcess]; } async run({ projects, - workPackagesByProjectId, materialTypes, manufacturers, units, leadership, heads, admins, - appAdmins - }: BOMInput): Promise> { + appAdmins, + currentYearCar + }: BOMInput): Promise { const creators = [...leadership, ...heads, ...admins, ...appAdmins]; + const materialsByProjectId: Record = {}; + const now = new Date(); for (let i = 0; i < projects.length; i += BATCH_SIZE) { const batch = projects.slice(i, i + BATCH_SIZE); await Promise.all( - batch.map(({ project }) => { - const projectWPs = workPackagesByProjectId[project.projectId] ?? []; - const allWbsElements = [project.wbsElement, ...projectWPs.map(({ workPackage }) => workPackage.wbsElement)]; - - const totalCount = generateProjectBOMCount(this.faker); - const counts = splitBOMCount(this.faker, totalCount, allWbsElements.length); + batch.map(async ({ project, timeline }) => { + // materials can only ever be created on a project's own WBS element (never a work package's) - + // BillOfMaterialsService.createMaterial/createAssembly/editMaterial all resolve the target via + // ProjectsService.getSingleProjectWithQueryArgs, which requires isProjectWbs (workPackageNumber === 0) + const isCurrentYearCar = project.carId === currentYearCar.car.carId; + const count = generateProjectBOMCount(this.faker); - return Promise.all( - allWbsElements.map((wbsElement, index) => - this.generateBOMForWbsElement(wbsElement, counts[index], creators, materialTypes, manufacturers, units) - ) + materialsByProjectId[project.projectId] = await this.generateBOMForWbsElement( + project.wbsElement, + timeline, + isCurrentYearCar, + now, + count, + creators, + materialTypes, + manufacturers, + units ); }) ); } - return {}; + return { materialsByProjectId }; } private async generateBOMForWbsElement( wbsElement: { wbsElementId: string; name: string }, + timeline: DateRange, + isCurrentYearCar: boolean, + now: Date, count: number, creators: UsersOutput['leadership'], materialTypes: BOMInput['materialTypes'], manufacturers: BOMInput['manufacturers'], units: BOMInput['units'] - ) { - if (count === 0) return; + ): Promise { + if (count === 0) return []; const creator = this.faker.helpers.arrayElement(creators); const hasAssembly = this.faker.datatype.boolean({ probability: ASSEMBLY_PROBABILITY }); @@ -76,38 +94,55 @@ export class BOMProcess extends SeedProcess> { if (hasAssembly) { const { assemblyId: newAssemblyId } = await this.prisma.assembly.create({ - data: assemblyCreateInput(generateAssemblyName(this.faker), wbsElement.wbsElementId, creator.userId) + data: assemblyCreateInput( + generateAssemblyName(this.faker), + wbsElement.wbsElementId, + creator.userId, + generateMaterialDateCreated(this.faker, timeline, now) + ) }); assemblyId = newAssemblyId; } - await Promise.all( - Array.from({ length: count }, () => { - const materialType = this.faker.helpers.arrayElement(materialTypes); - const name = generateMaterialName(this.faker, materialType.name); - const status = this.faker.helpers.arrayElement(Object.values(Material_Status)); - const manufacturer = this.faker.helpers.maybe(() => this.faker.helpers.arrayElement(manufacturers), { - probability: 0.6 - }); - const unit = this.faker.helpers.maybe(() => this.faker.helpers.arrayElement(units), { probability: 0.5 }); - const quantity = this.faker.helpers.maybe(() => this.faker.number.int({ min: 1, max: 20 }), { probability: 0.7 }); - const price = this.faker.helpers.maybe(() => this.faker.number.int({ min: 10, max: 50000 }), { probability: 0.7 }); - - return this.prisma.material.create({ - data: materialCreateInput( - name, - wbsElement.wbsElementId, - creator.userId, - materialType.id, - status, - assemblyId, - manufacturer?.id, - unit?.id, - quantity, - price - ) - }); - }) - ); + const materials: Material[] = []; + + for (let i = 0; i < count; i += MATERIAL_BATCH_SIZE) { + const batchCount = Math.min(MATERIAL_BATCH_SIZE, count - i); + + const batchMaterials = await Promise.all( + Array.from({ length: batchCount }, () => { + const materialType = this.faker.helpers.arrayElement(materialTypes); + const name = generateMaterialName(this.faker, materialType.name); + const dateCreated = generateMaterialDateCreated(this.faker, timeline, now); + const status = generateMaterialStatus(this.faker, isCurrentYearCar); + const manufacturer = this.faker.helpers.maybe(() => this.faker.helpers.arrayElement(manufacturers), { + probability: 0.6 + }); + const unit = this.faker.helpers.maybe(() => this.faker.helpers.arrayElement(units), { probability: 0.5 }); + const quantity = this.faker.helpers.maybe(() => this.faker.number.int({ min: 1, max: 20 }), { probability: 0.7 }); + const price = this.faker.helpers.maybe(() => this.faker.number.int({ min: 10, max: 50000 }), { probability: 0.7 }); + + return this.prisma.material.create({ + data: materialCreateInput( + name, + wbsElement.wbsElementId, + creator.userId, + materialType.id, + status, + dateCreated, + assemblyId, + manufacturer?.id, + unit?.id, + quantity, + price + ) + }); + }) + ); + + materials.push(...batchMaterials); + } + + return materials; } } diff --git a/src/backend/src/prisma/seed/description-bullet.process.ts b/src/backend/src/prisma/seed/description-bullet.process.ts index d9399c75cc..17e30720ef 100644 --- a/src/backend/src/prisma/seed/description-bullet.process.ts +++ b/src/backend/src/prisma/seed/description-bullet.process.ts @@ -1,7 +1,6 @@ import { SeedProcess } from '../processes/seed-process.js'; import { OrganizationProcess, OrganizationOutput } from './organization.process.js'; import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; -import { ProjectOutput, ProjectProcess } from './project.process.js'; import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; import { generateDescriptionBulletCount, @@ -9,11 +8,11 @@ import { descriptionBulletCreateInput } from '../factories/description-bullet.factory.js'; -type DescriptionBulletInput = OrganizationOutput & ConfigDataOutput & ProjectOutput & WorkPackageOutput; +type DescriptionBulletInput = OrganizationOutput & ConfigDataOutput & WorkPackageOutput; export class DescriptionBulletProcess extends SeedProcess> { dependencies() { - return [OrganizationProcess, ConfigDataProcess, ProjectProcess, WorkPackageProcess]; + return [OrganizationProcess, ConfigDataProcess, WorkPackageProcess]; } async run({ projects, workPackages, descriptionBulletTypes }: DescriptionBulletInput): Promise> { diff --git a/src/backend/src/prisma/seed/reimbursement-request.process.ts b/src/backend/src/prisma/seed/reimbursement-request.process.ts index 652f762fcc..79f64643cd 100644 --- a/src/backend/src/prisma/seed/reimbursement-request.process.ts +++ b/src/backend/src/prisma/seed/reimbursement-request.process.ts @@ -1,38 +1,41 @@ -import { Reimbursement_Request, Reimbursement_Status_Type } from '@prisma/client'; +import { Material, Reimbursement_Request, Reimbursement_Status_Type } from '@prisma/client'; import { SeedProcess } from '../processes/seed-process.js'; import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; import { UsersOutput, UsersProcess } from './user.process.js'; import { ConfigDataOutput, ConfigDataProcess } from './config-data.process.js'; import { TeamOutput, TeamProcess } from './team.process.js'; -import { ProjectOutput, ProjectProcess } from './project.process.js'; import { WorkPackageOutput, WorkPackageProcess } from './work-package.process.js'; +import { BOMOutput, BOMProcess } from './bom.process.js'; import { CarProcess } from './car.process.js'; import { CarOutput, FullUser } from '../context.js'; import { clampDate } from '../dates.js'; import { ASSIGNEE_CHANCE, + buildProductSpecs, chooseFundingSource, + chunkIntoGroups, + CURRENT_YEAR_BOM_TIE_CHANCE, DELIVERY_CHANCE, EXTRA_COMMENT_CHANCE, + generalSupplyCountForTiedMaterials, generateDateOfExpense, + generateFallbackMaterialCost, generateProductCount, - generateReimbursementRequestTotalCost, generateReimbursementStatusHistory, GENERAL_SUPPLY_PRODUCT_NAMES, hasReachedStage, otherReimbursementProductReasonCreateInput, + PAST_YEAR_BOM_TIE_CHANCE, receiptCreateInput, + ReimbursementProductSpec, reimbursementCreateInput, reimbursementProductCreateInput, reimbursementRequestCommentCreateInput, reimbursementRequestCreateInput, REIMBURSEMENT_CHANCE_PER_RECIPIENT, - REIMBURSEMENT_REQUESTS_PER_CAR, - splitCost, + selectMaterialsToTie, systemCommentText, - wbsReimbursementProductReasonCreateInput, - WBS_PRODUCT_NAMES, - WBS_REASON_CHANCE + wbsReimbursementProductReasonCreateInput } from '../factories/reimbursement-request.factory.js'; type ReimbursementRequestInput = OrganizationOutput & @@ -40,8 +43,8 @@ type ReimbursementRequestInput = OrganizationOutput & ConfigDataOutput & TeamOutput & CarOutput & - ProjectOutput & - WorkPackageOutput; + WorkPackageOutput & + BOMOutput; export type ReimbursementRequestOutput = { reimbursementRequests: Reimbursement_Request[]; @@ -73,8 +76,8 @@ export class ReimbursementRequestProcess extends SeedProcess { const { organizationId } = organization; const now = new Date(); @@ -156,194 +160,202 @@ export class ReimbursementRequestProcess extends SeedProcess carCreationWindow.start ? project.timeline.start : carCreationWindow.start, end: project.timeline.end < carCreationWindow.end ? project.timeline.end : carCreationWindow.end }; - const dateCreated = - projectCreationWindow.start < projectCreationWindow.end - ? this.faker.date.between({ from: projectCreationWindow.start, to: projectCreationWindow.end }) - : projectCreationWindow.start; - - const statusHistory = generateReimbursementStatusHistory(this.faker, dateCreated, now); - - // only a Head+ recipient may set their date of expense immediately at creation; everyone - // else has to wait until their request is leadership-approved and add it themselves after - const approvalStep = statusHistory.find((step) => step.type === Reimbursement_Status_Type.LEADERSHIP_APPROVED); - const pendingFinanceStep = statusHistory.find((step) => step.type === Reimbursement_Status_Type.PENDING_FINANCE); - - const dateOfExpense = generateDateOfExpense( - this.faker, - headApproverIds.has(recipient.userId), - dateCreated, - approvalStep?.date, - pendingFinanceStep?.date ?? now - ); - - const { indexCode, accountCode } = chooseFundingSource(this.faker, indexCodesByName, accountCodesByName); - const vendor = this.faker.helpers.arrayElement(vendors); - - const useWbsReason = this.faker.datatype.boolean({ probability: WBS_REASON_CHANCE }); - const productCount = generateProductCount(this.faker); - const totalCost = generateReimbursementRequestTotalCost(this.faker); - const productCosts = splitCost(this.faker, totalCost, productCount); - - const description = useWbsReason - ? `Expenses for ${project.project.wbsElement.name}` - : this.faker.helpers.arrayElement(GENERAL_SUPPLY_PRODUCT_NAMES); - - const createdRequest = await this.prisma.reimbursement_Request.create({ - data: reimbursementRequestCreateInput( - organizationId, - identifier, - recipient.userId, - vendor.vendorId, - indexCode.indexCodeId, - accountCode.accountCodeId, - totalCost, + for (const group of productGroups) { + identifier += 1; + + const recipient = this.faker.helpers.arrayElement(recipients); + + const dateCreated = + projectCreationWindow.start < projectCreationWindow.end + ? this.faker.date.between({ from: projectCreationWindow.start, to: projectCreationWindow.end }) + : projectCreationWindow.start; + + const statusHistory = generateReimbursementStatusHistory(this.faker, dateCreated, now); + + // only a Head+ recipient may set their date of expense immediately at creation; everyone + // else has to wait until their request is leadership-approved and add it themselves after + const approvalStep = statusHistory.find((step) => step.type === Reimbursement_Status_Type.LEADERSHIP_APPROVED); + const pendingFinanceStep = statusHistory.find((step) => step.type === Reimbursement_Status_Type.PENDING_FINANCE); + + const dateOfExpense = generateDateOfExpense( + this.faker, + headApproverIds.has(recipient.userId), dateCreated, - dateOfExpense, - description - ) - }); - - const workPackagesForProject = workPackagesByProjectId[project.project.projectId] ?? []; - - for (let productIndex = 0; productIndex < productCount; productIndex++) { - const useWorkPackage = - useWbsReason && workPackagesForProject.length > 0 && this.faker.datatype.boolean({ probability: 0.4 }); - - const reasonCreateInput = useWbsReason - ? wbsReimbursementProductReasonCreateInput( - useWorkPackage - ? this.faker.helpers.arrayElement(workPackagesForProject).workPackage.wbsElement.wbsElementId - : project.project.wbsElement.wbsElementId - ) - : otherReimbursementProductReasonCreateInput( - this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId - ); + approvalStep?.date, + pendingFinanceStep?.date ?? now + ); - const reason = await this.prisma.reimbursement_Product_Reason.create({ data: reasonCreateInput }); + const { indexCode, accountCode } = chooseFundingSource(this.faker, indexCodesByName, accountCodesByName); + const vendor = this.faker.helpers.arrayElement(vendors); - const productName = useWbsReason - ? this.faker.helpers.arrayElement(WBS_PRODUCT_NAMES) + const hasMaterialProduct = group.some((spec) => 'material' in spec); + const description = hasMaterialProduct + ? `Expenses for ${project.project.wbsElement.name}` : this.faker.helpers.arrayElement(GENERAL_SUPPLY_PRODUCT_NAMES); - await this.prisma.reimbursement_Product.create({ - data: reimbursementProductCreateInput( - productName, - productCosts[productIndex], - createdRequest.reimbursementRequestId, - reason.reimbursementProductReasonId, - indexCode.indexCodeId + const productCosts = group.map((spec) => + 'material' in spec ? (spec.material.price ?? generateFallbackMaterialCost(this.faker)) : generateFallbackMaterialCost(this.faker) + ); + const totalCost = productCosts.reduce((sum, cost) => sum + cost, 0); + + const createdRequest = await this.prisma.reimbursement_Request.create({ + data: reimbursementRequestCreateInput( + organizationId, + identifier, + recipient.userId, + vendor.vendorId, + indexCode.indexCodeId, + accountCode.accountCodeId, + totalCost, + dateCreated, + dateOfExpense, + description ) }); - } - for (const step of statusHistory.slice(1)) { - const actor = this.pickActorForStage(step.type, recipient, headApprovers, admins, financePersonnel); + for (let productIndex = 0; productIndex < group.length; productIndex++) { + const spec: ReimbursementProductSpec = group[productIndex]; - if (step.type === Reimbursement_Status_Type.PENDING_FINANCE) { - await this.prisma.receipt.create({ - data: receiptCreateInput(createdRequest.reimbursementRequestId, recipient.userId, identifier, step.date) - }); - } + const reasonCreateInput = 'material' in spec + ? wbsReimbursementProductReasonCreateInput(spec.material.wbsElementId) + : otherReimbursementProductReasonCreateInput( + this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId + ); - const action = SYSTEM_COMMENT_ACTION_BY_STAGE[step.type]; - if (action) { - await this.prisma.reimbursement_Request_Comment.create({ - data: reimbursementRequestCommentCreateInput( + const reason = await this.prisma.reimbursement_Product_Reason.create({ data: reasonCreateInput }); + + const productName = 'material' in spec + ? spec.material.name + : this.faker.helpers.arrayElement(GENERAL_SUPPLY_PRODUCT_NAMES); + + await this.prisma.reimbursement_Product.create({ + data: reimbursementProductCreateInput( + productName, + productCosts[productIndex], createdRequest.reimbursementRequestId, - actor.userId, - systemCommentText(actor.firstName, actor.lastName, action), - step.date + reason.reimbursementProductReasonId, + indexCode.indexCodeId, + 'material' in spec ? spec.material.materialId : undefined ) }); } - await this.prisma.reimbursement_Status.create({ - data: { - type: step.type, - userId: actor.userId, - dateCreated: step.date, - reimbursementRequestId: createdRequest.reimbursementRequestId + for (const step of statusHistory.slice(1)) { + const actor = this.pickActorForStage(step.type, recipient, headApprovers, admins, financePersonnel); + + if (step.type === Reimbursement_Status_Type.PENDING_FINANCE) { + await this.prisma.receipt.create({ + data: receiptCreateInput(createdRequest.reimbursementRequestId, recipient.userId, identifier, step.date) + }); } - }); - } - if (hasReachedStage(statusHistory, Reimbursement_Status_Type.PENDING_SABO_SUBMISSION)) { - await this.prisma.reimbursement_Request.update({ - where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, - data: { saboId: `SABO-${identifier}` } - }); - } + const action = SYSTEM_COMMENT_ACTION_BY_STAGE[step.type]; + if (action) { + await this.prisma.reimbursement_Request_Comment.create({ + data: reimbursementRequestCommentCreateInput( + createdRequest.reimbursementRequestId, + actor.userId, + systemCommentText(actor.firstName, actor.lastName, action), + step.date + ) + }); + } - if ( - dateOfExpense && - !hasReachedStage(statusHistory, Reimbursement_Status_Type.DENIED) && - this.faker.datatype.boolean({ probability: DELIVERY_CHANCE }) - ) { - const dateDelivered = clampDate( - this.faker.date.soon({ days: this.faker.number.int({ min: 1, max: 14 }), refDate: dateOfExpense }), - { start: dateOfExpense, end: now } - ); + await this.prisma.reimbursement_Status.create({ + data: { + type: step.type, + userId: actor.userId, + dateCreated: step.date, + reimbursementRequestId: createdRequest.reimbursementRequestId + } + }); + } - await this.prisma.reimbursement_Request.update({ - where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, - data: { dateDelivered } - }); + if (hasReachedStage(statusHistory, Reimbursement_Status_Type.PENDING_SABO_SUBMISSION)) { + await this.prisma.reimbursement_Request.update({ + where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, + data: { saboId: `SABO-${identifier}` } + }); + } - await this.prisma.reimbursement_Request_Comment.create({ - data: reimbursementRequestCommentCreateInput( - createdRequest.reimbursementRequestId, - recipient.userId, - systemCommentText(recipient.firstName, recipient.lastName, 'Marked As Delivered'), - dateDelivered - ) - }); - } + if ( + dateOfExpense && + !hasReachedStage(statusHistory, Reimbursement_Status_Type.DENIED) && + this.faker.datatype.boolean({ probability: DELIVERY_CHANCE }) + ) { + const dateDelivered = clampDate( + this.faker.date.soon({ days: this.faker.number.int({ min: 1, max: 14 }), refDate: dateOfExpense }), + { start: dateOfExpense, end: now } + ); + + await this.prisma.reimbursement_Request.update({ + where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, + data: { dateDelivered } + }); - if ( - hasReachedStage(statusHistory, Reimbursement_Status_Type.LEADERSHIP_APPROVED) && - this.faker.datatype.boolean({ probability: ASSIGNEE_CHANCE }) - ) { - await this.prisma.reimbursement_Request.update({ - where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, - data: { assignee: { connect: { userId: this.faker.helpers.arrayElement(financePersonnel).userId } } } - }); - } + await this.prisma.reimbursement_Request_Comment.create({ + data: reimbursementRequestCommentCreateInput( + createdRequest.reimbursementRequestId, + recipient.userId, + systemCommentText(recipient.firstName, recipient.lastName, 'Marked As Delivered'), + dateDelivered + ) + }); + } + + if ( + hasReachedStage(statusHistory, Reimbursement_Status_Type.LEADERSHIP_APPROVED) && + this.faker.datatype.boolean({ probability: ASSIGNEE_CHANCE }) + ) { + await this.prisma.reimbursement_Request.update({ + where: { reimbursementRequestId: createdRequest.reimbursementRequestId }, + data: { assignee: { connect: { userId: this.faker.helpers.arrayElement(financePersonnel).userId } } } + }); + } - if (this.faker.datatype.boolean({ probability: EXTRA_COMMENT_CHANCE })) { - const commentAuthor = this.faker.helpers.arrayElement([recipient, ...financePersonnel]); + if (this.faker.datatype.boolean({ probability: EXTRA_COMMENT_CHANCE })) { + const commentAuthor = this.faker.helpers.arrayElement([recipient, ...financePersonnel]); - await this.prisma.reimbursement_Request_Comment.create({ - data: reimbursementRequestCommentCreateInput( - createdRequest.reimbursementRequestId, - commentAuthor.userId, - this.faker.helpers.arrayElement(EXTRA_COMMENT_TEMPLATES), - statusHistory[statusHistory.length - 1].date - ) - }); - } + await this.prisma.reimbursement_Request_Comment.create({ + data: reimbursementRequestCommentCreateInput( + createdRequest.reimbursementRequestId, + commentAuthor.userId, + this.faker.helpers.arrayElement(EXTRA_COMMENT_TEMPLATES), + statusHistory[statusHistory.length - 1].date + ) + }); + } - if (hasReachedStage(statusHistory, Reimbursement_Status_Type.REIMBURSED)) { - const reimbursedDate = statusHistory.find((step) => step.type === Reimbursement_Status_Type.REIMBURSED)!.date; - const existing = reimbursedTotalsByRecipientId.get(recipient.userId); + if (hasReachedStage(statusHistory, Reimbursement_Status_Type.REIMBURSED)) { + const reimbursedDate = statusHistory.find((step) => step.type === Reimbursement_Status_Type.REIMBURSED)!.date; + const existing = reimbursedTotalsByRecipientId.get(recipient.userId); - reimbursedTotalsByRecipientId.set(recipient.userId, { - total: (existing?.total ?? 0) + totalCost, - latestDate: existing && existing.latestDate > reimbursedDate ? existing.latestDate : reimbursedDate - }); - } + reimbursedTotalsByRecipientId.set(recipient.userId, { + total: (existing?.total ?? 0) + totalCost, + latestDate: existing && existing.latestDate > reimbursedDate ? existing.latestDate : reimbursedDate + }); + } - reimbursementRequests.push(createdRequest); + reimbursementRequests.push(createdRequest); + } } } diff --git a/src/backend/src/prisma/seed/tasks.process.ts b/src/backend/src/prisma/seed/tasks.process.ts index 653926a4b7..9c444df198 100644 --- a/src/backend/src/prisma/seed/tasks.process.ts +++ b/src/backend/src/prisma/seed/tasks.process.ts @@ -1,11 +1,10 @@ import { Task } from '@prisma/client'; import { SeedProcess } from '../processes/seed-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 { SeedTaskParent, assigneeCountForTask, createSeedTask, taskCountForProject } from '../factories/tasks.factory.js'; -type TaskInput = ProjectOutput & UsersOutput & WorkPackageOutput; +type TaskInput = UsersOutput & WorkPackageOutput; export type TaskOutput = { tasks: Task[]; @@ -16,7 +15,7 @@ const WP_ATTACH_PROBABILITY = 0.4; export class TaskProcess extends SeedProcess { dependencies() { - return [ProjectProcess, UsersProcess, WorkPackageProcess]; + return [UsersProcess, WorkPackageProcess]; } async run({ diff --git a/src/backend/src/prisma/seed/work-package.process.ts b/src/backend/src/prisma/seed/work-package.process.ts index 60818540af..1b753eda71 100644 --- a/src/backend/src/prisma/seed/work-package.process.ts +++ b/src/backend/src/prisma/seed/work-package.process.ts @@ -2,7 +2,7 @@ import { SeedProcess } from '../processes/seed-process.js'; import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; import { UsersOutput, UsersProcess } from './user.process.js'; import { ProjectOutput, ProjectProcess } from './project.process.js'; -import { WorkPackageContext, ProjectContext, FullUser } from '../context.js'; +import { WorkPackageContext, ProjectContext, FullUser, DateRange } from '../context.js'; import { generateWorkPackageCount, generateWorkPackageName, @@ -19,6 +19,12 @@ type WorkPackageInput = OrganizationOutput & UsersOutput & ProjectOutput; export type WorkPackageOutput = { workPackages: WorkPackageContext[]; workPackagesByProjectId: Record; + // projects re-exported with `timeline` recomputed as the actual span of their work packages + // (falling back to the original car-bounded generation window for projects with none), since + // a project has no dates of its own - it's the summation of its work packages + projects: ProjectContext[]; + projectsByCarId: Record; + projectsById: Record; }; const BLOCKED_PERCENTAGE = 0.3; @@ -32,11 +38,20 @@ export class WorkPackageProcess extends SeedProcess this.generateWorkPackagesForProject(organizationId, projectContext, projectOwners)) + const results = await Promise.all( + projects.map(async (projectContext) => { + const workPackageContexts = await this.generateWorkPackagesForProject(organizationId, projectContext, projectOwners); + const timeline = this.deriveProjectTimeline(projectContext.timeline, workPackageContexts); + + return { + workPackageContexts, + updatedProjectContext: { ...projectContext, timeline } + }; + }) ); - const workPackages = allWorkPackageContexts.flat(); + const workPackages = results.flatMap((result) => result.workPackageContexts); + const updatedProjects = results.map((result) => result.updatedProjectContext); const workPackagesByProjectId = workPackages.reduce>((acc, wpContext) => { const { projectId } = wpContext.workPackage; @@ -45,7 +60,35 @@ export class WorkPackageProcess extends SeedProcess>((acc, projectContext) => { + const { carId } = projectContext.project; + acc[carId] ??= []; + acc[carId].push(projectContext); + return acc; + }, {}); + + const projectsById = updatedProjects.reduce>((acc, projectContext) => { + acc[projectContext.project.projectId] = projectContext; + return acc; + }, {}); + + return { workPackages, workPackagesByProjectId, projects: updatedProjects, projectsByCarId, projectsById }; + } + + /** + * A project has no dates of its own - its effective timeline is the span of its actual work + * packages. Falls back to the original (car-bounded) generation window for projects with none. + */ + private deriveProjectTimeline(generationWindow: DateRange, workPackageContexts: WorkPackageContext[]): DateRange { + if (workPackageContexts.length === 0) return generationWindow; + + return workPackageContexts.reduce( + (range, { timeline }) => ({ + start: timeline.start < range.start ? timeline.start : range.start, + end: timeline.end > range.end ? timeline.end : range.end + }), + workPackageContexts[0].timeline + ); } private async generateWorkPackagesForProject( From 9e0e0107f3e3c954fa04bb5a39cf38f0c51e196f Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 17 Jul 2026 18:17:41 -0400 Subject: [PATCH 6/7] fix material status bug and project timeline bug --- .../reimbursement-request.factory.ts | 15 ++++++++++- src/backend/src/prisma/seed/bom.process.ts | 6 ++--- .../prisma/seed/description-bullet.process.ts | 8 ++++-- .../seed/reimbursement-request.process.ts | 17 ++++++++++-- src/backend/src/prisma/seed/tasks.process.ts | 6 ++--- .../src/prisma/seed/work-package.process.ts | 26 ++++++++++++------- 6 files changed, 58 insertions(+), 20 deletions(-) diff --git a/src/backend/src/prisma/factories/reimbursement-request.factory.ts b/src/backend/src/prisma/factories/reimbursement-request.factory.ts index 7e05ac9140..17336d05cc 100644 --- a/src/backend/src/prisma/factories/reimbursement-request.factory.ts +++ b/src/backend/src/prisma/factories/reimbursement-request.factory.ts @@ -1,5 +1,5 @@ import { Faker } from '@faker-js/faker'; -import { Account_Code, Index_Code, Prisma, Reimbursement_Status_Type } from '@prisma/client'; +import { Account_Code, Index_Code, Material_Status, Prisma, Reimbursement_Status_Type } from '@prisma/client'; import { addDaysToDate } from 'shared'; // fraction of a past-year car's BOM items that get tied to a reimbursement request @@ -127,6 +127,19 @@ export const generateReimbursementStatusHistory = ( export const hasReachedStage = (history: ReimbursementStatusStep[], stage: Reimbursement_Status_Type): boolean => history.some((step) => step.type === stage); +/** + * createReimbursementProducts (reimbursement-requests.utils.ts) unconditionally forces a tied + * material's status to READY_TO_ORDER, and updateMaterialStatusesOnPayment bumps it to ORDERED + * the moment the request reaches PENDING_FINANCE (even if later DENIED) - so NOT_READY_TO_ORDER + * is never reachable for a tied material, and ORDERED is guaranteed once PENDING_FINANCE happens. + * RECEIVED once REIMBURSED isn't automatic in the app, but is the realistic real-world follow-through. + */ +export const deriveMaterialStatusAfterTie = (history: ReimbursementStatusStep[]): Material_Status => { + if (hasReachedStage(history, Reimbursement_Status_Type.REIMBURSED)) return Material_Status.RECEIVED; + if (hasReachedStage(history, Reimbursement_Status_Type.PENDING_FINANCE)) return Material_Status.ORDERED; + return Material_Status.READY_TO_ORDER; +}; + export const generateProductCount = (faker: Faker): number => faker.helpers.weightedArrayElement([ { weight: 60, value: 1 }, diff --git a/src/backend/src/prisma/seed/bom.process.ts b/src/backend/src/prisma/seed/bom.process.ts index 9861c876d5..506517a81a 100644 --- a/src/backend/src/prisma/seed/bom.process.ts +++ b/src/backend/src/prisma/seed/bom.process.ts @@ -32,7 +32,7 @@ export class BOMProcess extends SeedProcess { } async run({ - projects, + projectsWithTimeline, materialTypes, manufacturers, units, @@ -46,8 +46,8 @@ export class BOMProcess extends SeedProcess { const materialsByProjectId: Record = {}; const now = new Date(); - for (let i = 0; i < projects.length; i += BATCH_SIZE) { - const batch = projects.slice(i, i + BATCH_SIZE); + for (let i = 0; i < projectsWithTimeline.length; i += BATCH_SIZE) { + const batch = projectsWithTimeline.slice(i, i + BATCH_SIZE); await Promise.all( batch.map(async ({ project, timeline }) => { // materials can only ever be created on a project's own WBS element (never a work package's) - diff --git a/src/backend/src/prisma/seed/description-bullet.process.ts b/src/backend/src/prisma/seed/description-bullet.process.ts index 17e30720ef..7aa07d26f8 100644 --- a/src/backend/src/prisma/seed/description-bullet.process.ts +++ b/src/backend/src/prisma/seed/description-bullet.process.ts @@ -15,11 +15,15 @@ export class DescriptionBulletProcess extends SeedProcess> { + async run({ + projectsWithTimeline, + workPackages, + descriptionBulletTypes + }: DescriptionBulletInput): Promise> { const [bulletType] = descriptionBulletTypes; await Promise.all([ - ...projects.map(({ project }) => + ...projectsWithTimeline.map(({ project }) => this.createBulletsForWbsElement(project.wbsElement.wbsElementId, project.wbsElement.name, bulletType.id) ), ...workPackages.map(({ workPackage }) => diff --git a/src/backend/src/prisma/seed/reimbursement-request.process.ts b/src/backend/src/prisma/seed/reimbursement-request.process.ts index 79f64643cd..6573d1f75c 100644 --- a/src/backend/src/prisma/seed/reimbursement-request.process.ts +++ b/src/backend/src/prisma/seed/reimbursement-request.process.ts @@ -16,6 +16,7 @@ import { chunkIntoGroups, CURRENT_YEAR_BOM_TIE_CHANCE, DELIVERY_CHANCE, + deriveMaterialStatusAfterTie, EXTRA_COMMENT_CHANCE, generalSupplyCountForTiedMaterials, generateDateOfExpense, @@ -95,7 +96,7 @@ export class ReimbursementRequestProcess extends SeedProcess { const { organizationId } = organization; @@ -152,7 +153,9 @@ export class ReimbursementRequestProcess extends SeedProcess= now) continue; - const carProjects = (projectsByCarId[car.carId] ?? []).filter((projectContext) => projectContext.timeline.start < now); + const carProjects = (projectsByCarIdWithTimeline[car.carId] ?? []).filter( + (projectContext) => projectContext.timeline.start < now + ); if (carProjects.length === 0) continue; const carCreationWindow = { @@ -256,6 +259,16 @@ export class ReimbursementRequestProcess extends SeedProcess { } async run({ - projects, + projectsWithTimeline, members, leadership, heads, @@ -27,7 +27,7 @@ export class TaskProcess extends SeedProcess { appAdmins, workPackagesByProjectId }: TaskInput): Promise { - if (projects.length === 0) { + if (projectsWithTimeline.length === 0) { throw new Error('TaskProcess requires at least one project.'); } @@ -39,7 +39,7 @@ export class TaskProcess extends SeedProcess { const tasks: Task[] = []; - for (const { project, timeline } of projects) { + for (const { project, timeline } of projectsWithTimeline) { const projectWorkPackages = workPackagesByProjectId[project.projectId] ?? []; const taskCount = taskCountForProject(this.faker); diff --git a/src/backend/src/prisma/seed/work-package.process.ts b/src/backend/src/prisma/seed/work-package.process.ts index 1b753eda71..99a4cc32b0 100644 --- a/src/backend/src/prisma/seed/work-package.process.ts +++ b/src/backend/src/prisma/seed/work-package.process.ts @@ -19,12 +19,14 @@ type WorkPackageInput = OrganizationOutput & UsersOutput & ProjectOutput; export type WorkPackageOutput = { workPackages: WorkPackageContext[]; workPackagesByProjectId: Record; - // projects re-exported with `timeline` recomputed as the actual span of their work packages - // (falling back to the original car-bounded generation window for projects with none), since - // a project has no dates of its own - it's the summation of its work packages - projects: ProjectContext[]; - projectsByCarId: Record; - projectsById: Record; + // projects re-exported (under different names than ProjectOutput's, since SeedRunner merges every + // process's output into one flat global context and would collide on a shared key) with `timeline` + // recomputed as the actual span of their work packages (falling back to the original car-bounded + // generation window for projects with none), since a project has no dates of its own - it's the + // summation of its work packages + projectsWithTimeline: ProjectContext[]; + projectsByCarIdWithTimeline: Record; + projectsByIdWithTimeline: Record; }; const BLOCKED_PERCENTAGE = 0.3; @@ -60,19 +62,25 @@ export class WorkPackageProcess extends SeedProcess>((acc, projectContext) => { + const projectsByCarIdWithTimeline = updatedProjects.reduce>((acc, projectContext) => { const { carId } = projectContext.project; acc[carId] ??= []; acc[carId].push(projectContext); return acc; }, {}); - const projectsById = updatedProjects.reduce>((acc, projectContext) => { + const projectsByIdWithTimeline = updatedProjects.reduce>((acc, projectContext) => { acc[projectContext.project.projectId] = projectContext; return acc; }, {}); - return { workPackages, workPackagesByProjectId, projects: updatedProjects, projectsByCarId, projectsById }; + return { + workPackages, + workPackagesByProjectId, + projectsWithTimeline: updatedProjects, + projectsByCarIdWithTimeline, + projectsByIdWithTimeline + }; } /** From aadba41ebe6f14a8e6d804d18e835059b083f404 Mon Sep 17 00:00:00 2001 From: wavehassman Date: Fri, 17 Jul 2026 18:18:31 -0400 Subject: [PATCH 7/7] prettier --- .../seed/reimbursement-request.process.ts | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/backend/src/prisma/seed/reimbursement-request.process.ts b/src/backend/src/prisma/seed/reimbursement-request.process.ts index 6573d1f75c..f6d6abe656 100644 --- a/src/backend/src/prisma/seed/reimbursement-request.process.ts +++ b/src/backend/src/prisma/seed/reimbursement-request.process.ts @@ -71,15 +71,7 @@ const EXTRA_COMMENT_TEMPLATES = [ export class ReimbursementRequestProcess extends SeedProcess { dependencies() { - return [ - OrganizationProcess, - UsersProcess, - ConfigDataProcess, - TeamProcess, - CarProcess, - WorkPackageProcess, - BOMProcess - ]; + return [OrganizationProcess, UsersProcess, ConfigDataProcess, TeamProcess, CarProcess, WorkPackageProcess, BOMProcess]; } async run({ @@ -215,7 +207,9 @@ export class ReimbursementRequestProcess extends SeedProcess - 'material' in spec ? (spec.material.price ?? generateFallbackMaterialCost(this.faker)) : generateFallbackMaterialCost(this.faker) + 'material' in spec + ? (spec.material.price ?? generateFallbackMaterialCost(this.faker)) + : generateFallbackMaterialCost(this.faker) ); const totalCost = productCosts.reduce((sum, cost) => sum + cost, 0); @@ -237,17 +231,17 @@ export class ReimbursementRequestProcess extends SeedProcess = group[productIndex]; - const reasonCreateInput = 'material' in spec - ? wbsReimbursementProductReasonCreateInput(spec.material.wbsElementId) - : otherReimbursementProductReasonCreateInput( - this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId - ); + const reasonCreateInput = + 'material' in spec + ? wbsReimbursementProductReasonCreateInput(spec.material.wbsElementId) + : otherReimbursementProductReasonCreateInput( + this.faker.helpers.arrayElement(reimbursementProductOtherReasons).otherReimbursementProductReasonId + ); const reason = await this.prisma.reimbursement_Product_Reason.create({ data: reasonCreateInput }); - const productName = 'material' in spec - ? spec.material.name - : this.faker.helpers.arrayElement(GENERAL_SUPPLY_PRODUCT_NAMES); + const productName = + 'material' in spec ? spec.material.name : this.faker.helpers.arrayElement(GENERAL_SUPPLY_PRODUCT_NAMES); await this.prisma.reimbursement_Product.create({ data: reimbursementProductCreateInput(