Skip to content

Commit b3ce3cb

Browse files
authored
Merge pull request #3946 from Northeastern-Electric-Racing/#3891-update-material-status
#3890 + #3891: Link Materials to RR on Submission + Update Material Status When Payment Details Added
2 parents 1179d95 + af64459 commit b3ce3cb

10 files changed

Lines changed: 488 additions & 34 deletions

File tree

src/backend/src/prisma/migrations/20260116225351_bom_improvements/migration.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ ALTER COLUMN "quantity" DROP NOT NULL,
88
ALTER COLUMN "price" DROP NOT NULL,
99
ALTER COLUMN "subtotal" DROP NOT NULL;
1010

11+
-- AlterTable
12+
ALTER TABLE "Reimbursement_Product" ALTER COLUMN "name" DROP NOT NULL;
13+
1114
-- AlterTable
1215
ALTER TABLE "Reimbursement_Product" ADD COLUMN "materialId" TEXT;
1316

src/backend/src/prisma/schema.prisma

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -733,11 +733,11 @@ model Reimbursement_Product_Reason {
733733

734734
model Reimbursement_Product {
735735
reimbursementProductId String @id @default(uuid())
736-
name String
736+
name String?
737737
dateDeleted DateTime?
738738
cost Int
739739
material Material? @relation(fields: [materialId], references: [materialId])
740-
materialId String?
740+
materialId String?
741741
reimbursementProductReasonId String @unique
742742
reimbursementProductReason Reimbursement_Product_Reason @relation(fields: [reimbursementProductReasonId], references: [reimbursementProductReasonId])
743743
reimbursementRequestId String
@@ -896,33 +896,33 @@ model Assembly {
896896
}
897897

898898
model Material {
899-
materialId String @id @default(uuid())
900-
assembly Assembly? @relation(fields: [assemblyId], references: [assemblyId])
899+
materialId String @id @default(uuid())
900+
assembly Assembly? @relation(fields: [assemblyId], references: [assemblyId])
901901
assemblyId String?
902902
name String
903-
wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId])
903+
wbsElement WBS_Element @relation(fields: [wbsElementId], references: [wbsElementId])
904904
wbsElementId String
905905
dateDeleted DateTime?
906-
userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "materialDeleter")
906+
userDeleted User? @relation(fields: [userDeletedId], references: [userId], name: "materialDeleter")
907907
userDeletedId String?
908908
dateCreated DateTime
909-
userCreated User @relation(fields: [userCreatedId], references: [userId], name: "materialCreator")
909+
userCreated User @relation(fields: [userCreatedId], references: [userId], name: "materialCreator")
910910
userCreatedId String
911911
status Material_Status
912-
materialType Material_Type @relation(fields: [materialTypeId], references: [id])
912+
materialType Material_Type @relation(fields: [materialTypeId], references: [id])
913913
materialTypeId String
914-
manufacturer Manufacturer? @relation(fields: [manufacturerId], references: [id])
914+
manufacturer Manufacturer? @relation(fields: [manufacturerId], references: [id])
915915
manufacturerId String?
916916
manufacturerPartNumber String?
917917
pdmFileName String?
918918
quantity Decimal?
919-
unit Unit? @relation(fields: [unitId], references: [id])
919+
unit Unit? @relation(fields: [unitId], references: [id])
920920
unitId String?
921921
price Int?
922922
subtotal Int?
923923
linkUrl String
924924
notes String?
925-
reimbursementRequest Reimbursement_Request? @relation(fields: [reimbursementRequestId], references: [reimbursementRequestId])
925+
reimbursementRequest Reimbursement_Request? @relation(fields: [reimbursementRequestId], references: [reimbursementRequestId])
926926
reimbursementRequestId String?
927927
reimbursementProducts Reimbursement_Product[]
928928

src/backend/src/routes/reimbursement-requests.routes.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
isOptionalDate,
1212
nonEmptyString,
1313
validateInputs,
14-
validateReimbursementProducts
14+
validateReimbursementProducts,
15+
validateReimbursementProductsForEdit
1516
} from '../utils/validation.utils.js';
1617
import ReimbursementRequestController from '../controllers/reimbursement-requests.controllers.js';
1718
import multer, { memoryStorage } from 'multer';
@@ -139,7 +140,7 @@ reimbursementRequestsRouter.post(
139140
nonEmptyString(body('receiptPictures.*.googleFileId')),
140141
nonEmptyString(body('accountCodeId')),
141142
intMinZero(body('totalCost')),
142-
validateReimbursementProducts(),
143+
validateReimbursementProductsForEdit(),
143144
validateInputs,
144145
ReimbursementRequestController.editReimbursementRequest
145146
);

src/backend/src/services/reimbursement-requests.services.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ import {
3434
validateUserEditRRPermissions,
3535
validateRefund,
3636
validateUserIsPartOfFinanceTeamOrHead,
37-
isUserOnFinanceTeam
37+
isUserOnFinanceTeam,
38+
updateMaterialStatusesOnPayment
3839
} from '../utils/reimbursement-requests.utils.js';
3940
import {
4041
AccessDeniedAdminOnlyException,
@@ -1734,6 +1735,8 @@ export default class ReimbursementRequestService {
17341735
...getReimbursementStatusQueryArgs(organization.organizationId)
17351736
});
17361737

1738+
await updateMaterialStatusesOnPayment(reimbursementRequestId);
1739+
17371740
try {
17381741
await sendReimbursementRequestPendingFinanceNotification(
17391742
reimbursementRequest.notificationSlackThreads,

src/backend/src/transformers/reimbursement-requests.transformer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,8 @@ export const reimbursementProductTransformer = (
8181
): ReimbursementProduct => {
8282
return {
8383
reimbursementProductId: reimbursementProduct.reimbursementProductId,
84-
name: reimbursementProduct.name,
84+
name: reimbursementProduct.name ?? undefined,
85+
materialId: reimbursementProduct.materialId ?? undefined,
8586
cost: reimbursementProduct.cost,
8687
reimbursementProductReason: reimbursementProductReasonTransformer(reimbursementProduct.reimbursementProductReason),
8788
refundSources: reimbursementProduct.refundSources.map(refundSourceTransformer)

src/backend/src/utils/reimbursement-requests.utils.ts

Lines changed: 140 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,29 @@ export const validateReimbursementProducts = async (
8787
if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum));
8888
if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(wbsNum));
8989

90+
// Validate material if materialId is provided
91+
if (product.materialId) {
92+
const material = await prisma.material.findUnique({
93+
where: { materialId: product.materialId }
94+
});
95+
96+
if (!material) {
97+
throw new NotFoundException('Material', product.materialId);
98+
}
99+
100+
if (material.dateDeleted) {
101+
throw new DeletedException('Material', product.materialId);
102+
}
103+
104+
if (material.wbsElementId !== wbsElement.wbsElementId) {
105+
throw new HttpException(400, `Material does not belong to project ${wbsPipe(wbsNum)}`);
106+
}
107+
108+
if (!product.id && material.reimbursementRequestId) {
109+
throw new HttpException(400, `Material is already linked to another reimbursement request`);
110+
}
111+
}
112+
90113
return {
91114
...product,
92115
wbsElementId: wbsElement.wbsElementId,
@@ -147,32 +170,105 @@ export const updateReimbursementProducts = async (
147170
(product) => !updatedExistingProductIds.includes(product.reimbursementProductId)
148171
);
149172

173+
// Unlink any materials from deleted products
174+
await unlinkMaterialsFromDeletedProducts(deletedProducts);
175+
150176
await updateDeletedProducts(deletedProducts);
151177

152178
await createNewProducts(newOtherProducts, newWbsProducts, reimbursementRequestId, organizationId);
153179

154-
await updateExistingProducts(updatedExistingProducts);
180+
await updateExistingProducts(updatedExistingProducts, currentReimbursementProducts);
155181
};
156182

157183
/**
158-
* updates the existing products in the database
159-
*
160-
* @param products the products to update
184+
* Unlinks materials from products that are being deleted
185+
* @param products the products being deleted
161186
*/
162-
const updateExistingProducts = async (products: ReimbursementProductCreateArgs[]) => {
163-
//updates the cost and name of the remaining products, which should be products that existed before that were not deleted
164-
// Does not update wbs element id because we are requiring the user on the frontend to delete it from the wbs number and then adding it to another one
165-
for (const product of products) {
166-
await prisma.reimbursement_Product.update({
167-
where: { reimbursementProductId: product.id },
187+
const unlinkMaterialsFromDeletedProducts = async (products: Reimbursement_Product[]) => {
188+
const materialIds = products.filter((p) => p.materialId).map((p) => p.materialId!);
189+
190+
if (materialIds.length > 0) {
191+
await prisma.material.updateMany({
192+
where: {
193+
materialId: { in: materialIds }
194+
},
168195
data: {
169-
name: product.name,
170-
cost: product.cost
196+
reimbursementRequestId: null,
197+
status: 'NOT_READY_TO_ORDER'
171198
}
172199
});
173200
}
174201
};
175202

203+
/**
204+
* Updates the existing products in the database
205+
* Now handles both material-based and string-based products
206+
* @param products the products to update
207+
* @param currentProducts the current products in the database
208+
*/
209+
const updateExistingProducts = async (
210+
products: ReimbursementProductCreateArgs[],
211+
currentProducts: Reimbursement_Product[]
212+
) => {
213+
for (const product of products) {
214+
const currentProduct = currentProducts.find((p) => p.reimbursementProductId === product.id);
215+
216+
// For material-based products
217+
if (product.materialId) {
218+
await prisma.reimbursement_Product.update({
219+
where: { reimbursementProductId: product.id },
220+
data: {
221+
name: null,
222+
cost: product.cost,
223+
materialId: product.materialId
224+
}
225+
});
226+
227+
// If the material changed, unlink old material and link new one
228+
if (currentProduct?.materialId && currentProduct.materialId !== product.materialId) {
229+
await prisma.material.update({
230+
where: { materialId: currentProduct.materialId },
231+
data: {
232+
reimbursementRequestId: null,
233+
status: 'NOT_READY_TO_ORDER'
234+
}
235+
});
236+
}
237+
238+
// Link new material
239+
await prisma.material.update({
240+
where: { materialId: product.materialId },
241+
data: {
242+
status: 'READY_TO_ORDER',
243+
reimbursementRequestId: currentProduct?.reimbursementRequestId
244+
}
245+
});
246+
}
247+
// For string-based products
248+
else if (product.name) {
249+
await prisma.reimbursement_Product.update({
250+
where: { reimbursementProductId: product.id },
251+
data: {
252+
name: product.name,
253+
cost: product.cost,
254+
materialId: null
255+
}
256+
});
257+
258+
// If this product previously had a material linked, unlink it
259+
if (currentProduct?.materialId) {
260+
await prisma.material.update({
261+
where: { materialId: currentProduct.materialId },
262+
data: {
263+
reimbursementRequestId: null,
264+
status: 'NOT_READY_TO_ORDER'
265+
}
266+
});
267+
}
268+
}
269+
}
270+
};
271+
176272
/**
177273
* validates that the products that should be updated in the database exist
178274
* @param currentReimbursementProducts The products that do exist in the database
@@ -286,17 +382,29 @@ export const createReimbursementProducts = async (
286382
amount: rs.amount
287383
}));
288384

289-
return await prisma.reimbursement_Product.create({
385+
const reimbursementProduct = await prisma.reimbursement_Product.create({
290386
data: {
291-
name: product.name,
387+
name: product.name ?? null,
292388
cost: product.cost,
293389
reimbursementRequestId,
390+
materialId: product.materialId ?? null,
294391
refundSources: {
295392
create: refundSources
296393
},
297394
reimbursementProductReasonId: reimbursementProductReason.reimbursementProductReasonId
298395
}
299396
});
397+
398+
if (product.materialId) {
399+
await prisma.material.update({
400+
where: { materialId: product.materialId },
401+
data: {
402+
status: 'READY_TO_ORDER',
403+
reimbursementRequestId
404+
}
405+
});
406+
}
407+
return reimbursementProduct;
300408
});
301409

302410
await Promise.all([...otherReimbursementProductPromises, ...wbsReimbursementProductPromises]);
@@ -443,3 +551,21 @@ export const validateRefund = async (user: User, refundAmount: number, organizat
443551
throw new HttpException(400, 'Reimbursement is greater than the total amount owed');
444552
}
445553
};
554+
555+
/**
556+
* Updates material statuses to ORDERED when payment details are added to an RR
557+
* Should be called when reimbursement status changes to PENDING_FINANCE
558+
* Only updates materials currently in READY_TO_ORDER status
559+
* @param reimbursementRequestId the id of the reimbursement request
560+
*/
561+
export const updateMaterialStatusesOnPayment = async (reimbursementRequestId: string): Promise<void> => {
562+
await prisma.material.updateMany({
563+
where: {
564+
reimbursementRequestId,
565+
dateDeleted: null
566+
},
567+
data: {
568+
status: 'ORDERED'
569+
}
570+
});
571+
};

src/backend/src/utils/validation.utils.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,47 @@ export const isOptionalDate = (validationObject: ValidationChain): ValidationCha
8080

8181
export const validateReimbursementProducts = () => {
8282
return [
83+
// Other products (non-project) - keep as strings
8384
body('otherReimbursementProducts').isArray(),
8485
nonEmptyString(body('otherReimbursementProducts.*.name')),
8586
nonEmptyString(body('otherReimbursementProducts.*.reason.otherProductReasonId')),
8687
nonEmptyString(body('otherReimbursementProducts.*.reason.name')),
8788
intMinZero(body('otherReimbursementProducts.*.cost')),
89+
90+
// WBS products - now materials
8891
body('wbsReimbursementProducts').isArray(),
89-
nonEmptyString(body('wbsReimbursementProducts.*.name')),
92+
nonEmptyString(body('wbsReimbursementProducts.*.materialId')),
93+
intMinZero(body('wbsReimbursementProducts.*.cost')),
94+
intMinZero(body('wbsReimbursementProducts.*.reason.carNumber')),
95+
intMinZero(body('wbsReimbursementProducts.*.reason.projectNumber')),
96+
intMinZero(body('wbsReimbursementProducts.*.reason.workPackageNumber'))
97+
];
98+
};
99+
100+
export const validateReimbursementProductsForEdit = (): ValidationChain[] => {
101+
return [
102+
// Other products (non-project) - keep as strings
103+
body('otherReimbursementProducts').isArray(),
104+
nonEmptyString(body('otherReimbursementProducts.*.name')),
105+
nonEmptyString(body('otherReimbursementProducts.*.reason.otherProductReasonId')),
106+
nonEmptyString(body('otherReimbursementProducts.*.reason.name')),
107+
intMinZero(body('otherReimbursementProducts.*.cost')),
108+
109+
// WBS products
110+
body('wbsReimbursementProducts').isArray(),
111+
112+
// Either materialId OR name must be present
113+
body('wbsReimbursementProducts.*.materialId').optional().isString(),
114+
body('wbsReimbursementProducts.*.name').optional().isString(),
115+
116+
// Ensure at least one is provided
117+
body('wbsReimbursementProducts.*').custom((product) => {
118+
if (!product.materialId && !product.name) {
119+
throw new Error('Either materialId or name must be provided');
120+
}
121+
return true;
122+
}),
123+
90124
intMinZero(body('wbsReimbursementProducts.*.cost')),
91125
intMinZero(body('wbsReimbursementProducts.*.reason.carNumber')),
92126
intMinZero(body('wbsReimbursementProducts.*.reason.projectNumber')),

0 commit comments

Comments
 (0)