Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/backend/src/controllers/projects.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,22 @@ export default class ProjectsController {
}
}

static async copyMaterialsToProject(req: Request, res: Response, next: NextFunction) {
try {
const { materialIds, destinationWbsNum } = req.body;

const newMaterialIds = await BillOfMaterialsService.copyMaterialsToProject(
req.currentUser,
materialIds,
destinationWbsNum,
req.organization
);
res.status(200).json(newMaterialIds);
} catch (error: unknown) {
next(error);
}
}

static async createManufacturer(req: Request, res: Response, next: NextFunction) {
try {
const { name } = req.body;
Expand Down
11 changes: 11 additions & 0 deletions src/backend/src/routes/projects.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
validateInputs,
materialValidators
} from '../utils/validation.utils.js';
import { validateWBS } from 'shared';
import ProjectsController from '../controllers/projects.controllers.js';

const projectRouter = express.Router();
Expand Down Expand Up @@ -93,6 +94,16 @@ projectRouter.post(
);
projectRouter.post('/bom/material/:wbsNum/create', ...materialValidators, validateInputs, ProjectsController.createMaterial);
projectRouter.post('/bom/material/:materialId/edit', ...materialValidators, validateInputs, ProjectsController.editMaterial);
projectRouter.post(
'/bom/material/copy',
body('materialIds').isArray({ min: 1 }),
nonEmptyString(body('materialIds.*')),
body('destinationWbsNum').customSanitizer((value) => {
return validateWBS(value);
}),
validateInputs,
ProjectsController.copyMaterialsToProject
);

projectRouter.post(
'/bom/assembly/:assemblyId/edit',
Expand Down
72 changes: 72 additions & 0 deletions src/backend/src/services/boms.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,78 @@ export default class BillOfMaterialsService {
return materialTransformer(createdMaterial);
}

/**
* Copy materials to project
* @param user The user making the copy
* @param materialIds The ids of the materials to be copied
* @param destinationProjectId The id of the project to copy the materials to
* @param organization The organization the user is currently in
* @returns an array of the newly created material ids
* @throws errors that will be added here later
*/
static async copyMaterialsToProject(
user: User,
materialIds: string[],
destinationProjectId: WbsNumber,
organization: Organization
): Promise<string[]> {
// Fetch materials to be copied
const materials = await prisma.material.findMany({
where: {
materialId: { in: materialIds },
dateDeleted: null
},
...getMaterialQueryArgs(organization.organizationId)
});

if (materials.length !== materialIds.length) throw new NotFoundException('Material', 'Not all materials found');

const invalidMaterials = materials.filter(
(material) => material.materialType.organizationId !== organization.organizationId
);
if (invalidMaterials.length > 0) throw new HttpException(400, 'All materials must be from the current organization');

const destinationProject = await ProjectsService.getSingleProjectWithQueryArgs(destinationProjectId, organization);

const perms =
(await userHasPermission(user.userId, organization.organizationId, isLeadership)) ||
isUserPartOfTeams(destinationProject.teams, user);

if (!perms) throw new AccessDeniedException('Permission to copy materials denied');

return await prisma.$transaction(async (tx) => {
const newMaterialIds: string[] = [];

for (const material of materials) {
const newMaterial = await tx.material.create({
data: {
name: material.name,
status: Material_Status.NOT_READY_TO_ORDER,
materialTypeId: material.materialTypeId,
manufacturerId: material.manufacturerId,
manufacturerPartNumber: material.manufacturerPartNumber,
pdmFileName: material.pdmFileName,
quantity: material.quantity,
unitId: material.unitId,
price: material.price,
subtotal: material.subtotal,
linkUrl: material.linkUrl,
notes: material.notes,
dateCreated: new Date(),
userCreatedId: user.userId,
wbsElementId: destinationProject.wbsElementId,
assemblyId: null
},
...getMaterialQueryArgs(organization.organizationId)
});

newMaterialIds.push(newMaterial.materialId);
}

return newMaterialIds;
});
}

/**
* Create an assembly
* @param name The name of the assembly to be created
Expand Down
114 changes: 114 additions & 0 deletions src/backend/tests/unmocked/project.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import prisma from '../../src/prisma/prisma.js';
import { createTestReimbursementRequest, resetUsers } from '../test-utils.js';
import { Organization, User } from '@prisma/client';
import BillOfMaterials from '../../src/services/boms.services.js';
Expand Down Expand Up @@ -93,6 +94,119 @@ describe('Material Tests', () => {
});
});

describe('Copy materials to project', () => {
test('Successfully copies materials and resets key fields', async () => {
const materialType = await BillOfMaterials.createMaterialType('Capacitor', createdUser, org);
const manufacturer = await BillOfMaterials.createManufacturer(createdUser, 'Mouser', org);

const car = await prisma.car.findFirst({
where: {
wbsElement: {
organizationId: org.organizationId
}
}
});

await prisma.project.create({
data: {
summary: 'Test destination project',
car: {
connect: { carId: car!.carId }
},
wbsElement: {
create: {
carNumber: 0,
projectNumber: 2,
workPackageNumber: 0,
name: 'Destination Project',
organizationId: org.organizationId
}
}
}
});

const material1 = await BillOfMaterials.createMaterial(
createdUser,
'100uF Capacitor',
MaterialStatus.Ordered,
materialType.name,
'https://example.com/mat1',
{ carNumber: 0, projectNumber: 1, workPackageNumber: 0 },
org,
manufacturer.name,
'CAP-100UF',
new Decimal(10),
50,
500,
'Test notes',
undefined,
undefined,
undefined,
reimbursementRequest.reimbursementRequestId
);

const material2 = await BillOfMaterials.createMaterial(
createdUser,
'220uF Capacitor',
MaterialStatus.ReadyToOrder,
materialType.name,
'https://example.com/mat2',
{ carNumber: 0, projectNumber: 1, workPackageNumber: 0 },
org,
manufacturer.name,
'CAP-220UF',
new Decimal(5),
75,
375
);

const newMaterialIds = await BillOfMaterials.copyMaterialsToProject(
createdUser,
[material1.materialId, material2.materialId],
{ carNumber: 0, projectNumber: 2, workPackageNumber: 0 },
org
);

expect(newMaterialIds.length).toBe(2);

const copiedMaterials = await prisma.material.findMany({
where: { materialId: { in: newMaterialIds } },
include: { wbsElement: true }
});

const copiedMat1 = copiedMaterials.find((m) => m.name === '100uF Capacitor')!;
const copiedMat2 = copiedMaterials.find((m) => m.name === '220uF Capacitor')!;

expect(copiedMat1.wbsElement.projectNumber).toBe(2);
expect(copiedMat2.wbsElement.projectNumber).toBe(2);

expect(copiedMat1.status).toBe('NOT_READY_TO_ORDER');
expect(copiedMat1.userCreatedId).toBe(createdUser.userId);
expect(copiedMat1.reimbursementRequestId).toBeNull();
expect(copiedMat1.assemblyId).toBeNull();

expect(copiedMat1.name).toBe('100uF Capacitor');
expect(copiedMat1.price).toBe(50);
expect(copiedMat1.quantity?.toString()).toBe('10');
expect(copiedMat1.manufacturerPartNumber).toBe('CAP-100UF');
expect(copiedMat1.notes).toBe('Test notes');

expect(copiedMat2.status).toBe('NOT_READY_TO_ORDER');
expect(copiedMat2.reimbursementRequestId).toBeNull();
});

test('Fails when material does not exist', async () => {
await expect(
BillOfMaterials.copyMaterialsToProject(
createdUser,
['non-existent-id'],
{ carNumber: 0, projectNumber: 1, workPackageNumber: 0 },
org
)
).rejects.toThrow(NotFoundException);
});
});

describe('Edit a material', () => {
test('Updates the reimbursement request when originally undefined', async () => {
const materialType = await BillOfMaterials.createMaterialType('Resistor', createdUser, org);
Expand Down
11 changes: 11 additions & 0 deletions src/frontend/src/apis/bom.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,17 @@ export const editMaterial = async (materialId: string, material: MaterialDataSub
return data;
};

/**
* Requests to copy materials to a project.
* @param materialIds The IDs of materials to copy
* @param destinationWbsNum The destination project WBS number
* @returns Array of newly created material IDs
*/
export const copyMaterialsToProject = async (materialIds: string[], destinationWbsNum: string) => {
const { data } = await axios.post(apiUrls.bomCopyMaterials(), { materialIds, destinationWbsNum });
return data;
};

/**
* Soft deletes a material.
* @param materialId
Expand Down
28 changes: 28 additions & 0 deletions src/frontend/src/hooks/bom.hooks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useMutation, useQuery, useQueryClient } from 'react-query';
import { Assembly, Manufacturer, Material, MaterialType, Unit, WbsNumber, wbsPipe } from 'shared';
import { useToast } from '../hooks/toasts.hooks';
import {
assignMaterialToAssembly,
createAssembly,
createManufacturer,
deleteManufacturer,
createMaterial,
copyMaterialsToProject,
createMaterialType,
createUnit,
deleteSingleAssembly,
Expand Down Expand Up @@ -139,6 +141,32 @@ export const useDeleteMaterial = (wbsNum: WbsNumber) => {
);
};

/**
* Custom React hook to copy materials to a project.
* @returns the mutation function to copy materials
*/
export const useCopyMaterialsToProject = () => {
const queryClient = useQueryClient();
const toast = useToast();

return useMutation<string[], Error, { materialIds: string[]; destinationWbsNum: string }>(
['materials', 'copy'],
async ({ materialIds, destinationWbsNum }) => {
const data = await copyMaterialsToProject(materialIds, destinationWbsNum);
return data;
},
{
onSuccess: (newMaterialIds, variables) => {
queryClient.invalidateQueries(['materials', variables.destinationWbsNum]);
toast.success(`Successfully copied ${newMaterialIds.length} material${newMaterialIds.length !== 1 ? 's' : ''}!`);
},
onError: () => {
toast.error('Failed to copy materials');
}
}
);
};

/**
* Custom React hook to delete a assembly.
* @param wbsNum The wbs element you are deleting the assembly from
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/src/utils/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ const bomGetAssembliesByWbsNum = (wbsNum: WbsNumber) => `${bomEndpoints()}/${wbs
const bomCreateMaterial = (wbsNum: WbsNumber) => `${materialEndpoints()}/${wbsPipe(wbsNum)}/create`;
const bomEditMaterial = (materialId: string) => `${materialEndpoints()}/${materialId}/edit`;
const bomDeleteMaterial = (materialId: string) => `${materialEndpoints()}/${materialId}/delete`;
const bomCopyMaterials = () => `${materialEndpoints()}/copy`;
const bomCreateAssembly = (wbsNum: WbsNumber) => `${assemblyEndpoints()}/${wbsPipe(wbsNum)}/create`;
const bomDeleteAssembly = (assemblyId: string) => `${assemblyEndpoints()}/${assemblyId}/delete`;
const bomAssignAssembly = (materialId: string) => `${materialEndpoints()}/${materialId}/assign-assembly`;
Expand Down Expand Up @@ -650,6 +651,7 @@ export const apiUrls = {
bomCreateMaterial,
bomEditMaterial,
bomDeleteMaterial,
bomCopyMaterials,
bomCreateAssembly,
bomDeleteAssembly,
bomAssignAssembly,
Expand Down