diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index 71c5577af3..40f483235c 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -19,6 +19,7 @@ import { DescriptionBulletProcess } from './seed/description-bullet.process.js'; import { BOMProcess } from './seed/bom.process.js'; import { TaskProcess } from './seed/tasks.process.js'; import { WorkPackageProcess } from './seed/work-package.process.js'; +import { ReimbursementRequestProcess } from './seed/reimbursement-request.process.js'; const prisma = new PrismaClient(); @@ -37,7 +38,8 @@ await new SeedRunner() new WorkPackageProcess(), new DescriptionBulletProcess(), new BOMProcess(), - new TaskProcess() + new TaskProcess(), + new ReimbursementRequestProcess() ) .run(); 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/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 new file mode 100644 index 0000000000..17336d05cc --- /dev/null +++ b/src/backend/src/prisma/factories/reimbursement-request.factory.ts @@ -0,0 +1,309 @@ +import { Faker } from '@faker-js/faker'; +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 +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 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 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' +]; + +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[]> = { + 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 }; +}; + +/** + * 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 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 > deniedAfterStageIndex) 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); + +/** + * 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 }, + { weight: 30, value: 2 }, + { weight: 10, value: 3 } + ]); + +/** 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 })); + +/** 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)); + +/** 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 }); + +export type ReimbursementProductSpec = { material: T } | { generalSupply: true }; + +export const buildProductSpecs = (tiedMaterials: T[], generalSupplyCount: number): ReimbursementProductSpec[] => [ + ...tiedMaterials.map((material) => ({ material })), + ...Array.from({ length: generalSupplyCount }, () => ({ generalSupply: true as const })) +]; + +/** 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[][] = []; + + 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 groups; +}; + +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, + materialId?: string +): Prisma.Reimbursement_ProductCreateInput => ({ + name, + cost, + reimbursementRequest: { connect: { reimbursementRequestId } }, + reimbursementProductReason: { connect: { reimbursementProductReasonId } }, + ...(materialId ? { material: { connect: { materialId } } } : {}), + 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: 1, 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/bom.process.ts b/src/backend/src/prisma/seed/bom.process.ts index e30435db0a..506517a81a 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, + projectsWithTimeline, 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); + for (let i = 0; i < projectsWithTimeline.length; i += BATCH_SIZE) { + const batch = projectsWithTimeline.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..7aa07d26f8 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,18 +8,22 @@ 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> { + 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 new file mode 100644 index 0000000000..f6d6abe656 --- /dev/null +++ b/src/backend/src/prisma/seed/reimbursement-request.process.ts @@ -0,0 +1,406 @@ +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 { 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, + deriveMaterialStatusAfterTie, + EXTRA_COMMENT_CHANCE, + generalSupplyCountForTiedMaterials, + generateDateOfExpense, + generateFallbackMaterialCost, + generateProductCount, + generateReimbursementStatusHistory, + GENERAL_SUPPLY_PRODUCT_NAMES, + hasReachedStage, + otherReimbursementProductReasonCreateInput, + PAST_YEAR_BOM_TIE_CHANCE, + receiptCreateInput, + ReimbursementProductSpec, + reimbursementCreateInput, + reimbursementProductCreateInput, + reimbursementRequestCommentCreateInput, + reimbursementRequestCreateInput, + REIMBURSEMENT_CHANCE_PER_RECIPIENT, + selectMaterialsToTie, + systemCommentText, + wbsReimbursementProductReasonCreateInput +} from '../factories/reimbursement-request.factory.js'; + +type ReimbursementRequestInput = OrganizationOutput & + UsersOutput & + ConfigDataOutput & + TeamOutput & + CarOutput & + WorkPackageOutput & + BOMOutput; + +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, WorkPackageProcess, BOMProcess]; + } + + async run({ + organization, + members, + leadership, + heads, + admins, + appAdmins, + cars, + currentYearCar, + indexCodes, + accountCodes, + vendors, + reimbursementProductOtherReasons, + financeTeam, + projectsByCarIdWithTimeline, + materialsByProjectId + }: 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 = (projectsByCarIdWithTimeline[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 }) + }; + + // the current-year car is only halfway through its year, so fewer of its BOM items have + // actually been purchased/reimbursed yet compared to a past-year car's full history + const bomTieChance = car.carId === currentYearCar.car.carId ? CURRENT_YEAR_BOM_TIE_CHANCE : PAST_YEAR_BOM_TIE_CHANCE; + + for (const project of carProjects) { + const projectMaterials = materialsByProjectId[project.project.projectId] ?? []; + const tiedMaterials = selectMaterialsToTie(this.faker, projectMaterials, bomTieChance); + if (tiedMaterials.length === 0) continue; + + const generalSupplyCount = generalSupplyCountForTiedMaterials(tiedMaterials.length); + const productSpecs = buildProductSpecs(tiedMaterials, generalSupplyCount); + const productGroups = chunkIntoGroups(this.faker, productSpecs, generateProductCount); + + const projectCreationWindow = { + start: project.timeline.start > carCreationWindow.start ? project.timeline.start : carCreationWindow.start, + end: project.timeline.end < carCreationWindow.end ? project.timeline.end : carCreationWindow.end + }; + + 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, + approvalStep?.date, + pendingFinanceStep?.date ?? now + ); + + const { indexCode, accountCode } = chooseFundingSource(this.faker, indexCodesByName, accountCodesByName); + const vendor = this.faker.helpers.arrayElement(vendors); + + 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); + + 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 (let productIndex = 0; productIndex < group.length; productIndex++) { + const spec: ReimbursementProductSpec = group[productIndex]; + + 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); + + await this.prisma.reimbursement_Product.create({ + data: reimbursementProductCreateInput( + productName, + productCosts[productIndex], + createdRequest.reimbursementRequestId, + reason.reimbursementProductReasonId, + indexCode.indexCodeId, + 'material' in spec ? spec.material.materialId : undefined + ) + }); + + // createReimbursementProducts forces a tied material to READY_TO_ORDER (then ORDERED once + // PENDING_FINANCE is reached) as a side effect - keep the material's own status consistent + // with that, rather than the independent status BOMProcess originally rolled for it + if ('material' in spec) { + await this.prisma.material.update({ + where: { materialId: spec.material.materialId }, + data: { status: deriveMaterialStatusAfterTie(statusHistory) } + }); + } + } + + 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); + } + } +} diff --git a/src/backend/src/prisma/seed/tasks.process.ts b/src/backend/src/prisma/seed/tasks.process.ts index 653926a4b7..c6c44212c7 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,11 +15,11 @@ const WP_ATTACH_PROBABILITY = 0.4; export class TaskProcess extends SeedProcess { dependencies() { - return [ProjectProcess, UsersProcess, WorkPackageProcess]; + return [UsersProcess, WorkPackageProcess]; } async run({ - projects, + projectsWithTimeline, members, leadership, heads, @@ -28,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.'); } @@ -40,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 60818540af..99a4cc32b0 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,14 @@ type WorkPackageInput = OrganizationOutput & UsersOutput & ProjectOutput; export type WorkPackageOutput = { workPackages: WorkPackageContext[]; workPackagesByProjectId: 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; @@ -32,11 +40,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 +62,41 @@ export class WorkPackageProcess extends SeedProcess>((acc, projectContext) => { + const { carId } = projectContext.project; + acc[carId] ??= []; + acc[carId].push(projectContext); + return acc; + }, {}); + + const projectsByIdWithTimeline = updatedProjects.reduce>((acc, projectContext) => { + acc[projectContext.project.projectId] = projectContext; + return acc; + }, {}); + + return { + workPackages, + workPackagesByProjectId, + projectsWithTimeline: updatedProjects, + projectsByCarIdWithTimeline, + projectsByIdWithTimeline + }; + } + + /** + * 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(