diff --git a/scripts/seed.ts b/scripts/seed.ts index 3f118a34..5a19a45b 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -236,6 +236,116 @@ async function buildSeedData() { console.log(` - Distribution Lead: ${distributionLead.email}`); console.log(` - Partner 1: ${partner1.email}`); console.log(` - Partner 2: ${partner2.email}`); + + // ============================================================================ +// Test: partner has an existing manual wishlist entry for an item, +// then a distribution only partially fulfills their request for that same item. +// Expected: existing wishlist entry quantity increases by the unfulfilled amount. +// +// Partner 1 manually added "Gauze Pads" to their wishlist (qty: 50) — this +// simulates a wishlist entry they created themselves before any donor offer. +// A donor offer comes in for Gauze Pads, Partner 1 requests 200. +// Distribution only allocates 1 line item (75 units) → unfulfilled = 125. +// Expected final wishlist qty: 50 + 125 = 175 +// ============================================================================ + +// Step 1: Partner 1 manually creates a wishlist entry beforehand +await db.wishlist.create({ + data: { + name: "Gauze Pads", + quantity: 50, + priority: "MEDIUM", + comments: "We are always in need of these", + partnerId: partner1.id, + }, +}); + +console.log("✓ Created manual wishlist entry for Partner 1: Gauze Pads qty 50"); + +// Step 2: Donor offer comes in with Gauze Pads +const gauzeOffer = await db.donorOffer.create({ + data: { + offerName: "Gauze Pads Offer", + donorName: "Test Donor", + state: "FINALIZED", + partnerResponseDeadline: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + donorResponseDeadline: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), + partnerVisibilities: { + connect: [{ id: partner1.id }], + }, + }, +}); + +const gauzeItem = await db.generalItem.create({ + data: { + donorOfferId: gauzeOffer.id, + title: "Gauze Pads", + unitType: "Box", + initialQuantity: 200, + weight: 0.3, + }, +}); + +// Two line items: 75 units and 125 units +const gauzeLineItem1 = await db.lineItem.create({ + data: { + generalItemId: gauzeItem.id, + donorName: "Test Donor", + quantity: 75, + lotNumber: "GLOT-1", + palletNumber: "GPAL-1", + boxNumber: "GBOX-1", + unitPrice: 3.0, + allowAllocations: true, + visible: true, + gik: true, + }, +}); + +await db.lineItem.create({ + data: { + generalItemId: gauzeItem.id, + donorName: "Test Donor", + quantity: 125, + lotNumber: "GLOT-2", + palletNumber: "GPAL-1", + boxNumber: "GBOX-2", + unitPrice: 3.0, + allowAllocations: true, + visible: true, + gik: true, + }, +}); + +// Step 3: Partner 1 requests 200 units +await db.generalItemRequest.create({ + data: { + generalItemId: gauzeItem.id, + partnerId: partner1.id, + quantity: 200, + priority: "MEDIUM", + finalQuantity: 200, + comments: "Requested from donor offer", + }, +}); + +// Step 4: Distribution created — only allocates the 75 unit line item +const gauzeDist = await db.distribution.create({ + data: { + partnerId: partner1.id, + pending: true, + allocations: { + create: [{ lineItemId: gauzeLineItem1.id, partnerId: partner1.id }], + }, + }, +}); + +console.log("✓ Created Gauze Pads distribution test"); +console.log(` - Distribution ID: ${gauzeDist.id} (pending)`); +console.log(` - Partner 1 wishlist BEFORE approval: Gauze Pads qty 50`); +console.log(` - After approving dist ${gauzeDist.id}:`); +console.log(` unfulfilled = 200 - 75 = 125`); +console.log(` merged wishlist qty = 50 + 125 = 175`); } buildSeedData() diff --git a/src/app/api/generalItems/[generalItemId]/requests/[requestId]/route.ts b/src/app/api/generalItems/[generalItemId]/requests/[requestId]/route.ts index bdf86305..e0ced252 100644 --- a/src/app/api/generalItems/[generalItemId]/requests/[requestId]/route.ts +++ b/src/app/api/generalItems/[generalItemId]/requests/[requestId]/route.ts @@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/auth"; -import DonorOfferService from "@/services/donorOfferService"; import { GeneralItemRequestService } from "@/services/generalItemRequestService"; import UserService from "@/services/userService"; import { @@ -95,10 +94,11 @@ export async function PATCH( throw new ArgumentError(updateParsed.error.message); } - await DonorOfferService.updateRequestFinalQuantity( + await GeneralItemRequestService.updateWishlist( parsed.data.requestId, updateParsed.data.finalQuantity ); + console.log(updateParsed.data.finalQuantity) return NextResponse.json(ok()); } diff --git a/src/services/distributionService.ts b/src/services/distributionService.ts index cc2136f2..c3534b13 100644 --- a/src/services/distributionService.ts +++ b/src/services/distributionService.ts @@ -367,9 +367,6 @@ export default class DistributionService { for (const distribution of distributions) { for (const allocation of distribution.allocations) { - // const item = await db.item.findUnique({ - // where: { id: allocation.itemId }, - // }); const item = await db.lineItem.findUnique({ where: { id: allocation.lineItemId }, }); @@ -672,10 +669,7 @@ export default class DistributionService { data, }); - if ( - data.pending === false && - currentDistribution.pending === true - ) { + if (data.pending === false && currentDistribution.pending === true) { await this.createShipmentsForDistribution(distributionId); } @@ -766,4 +760,4 @@ export default class DistributionService { where: { id: distributionId }, }); } -} +} \ No newline at end of file diff --git a/src/services/generalItemRequestService.ts b/src/services/generalItemRequestService.ts index 6a510572..0a388691 100644 --- a/src/services/generalItemRequestService.ts +++ b/src/services/generalItemRequestService.ts @@ -8,6 +8,7 @@ import { } from "@/types/api/donorOffer.types"; import { Filters } from "@/types/api/filter.types"; import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table"; +import { WishlistService } from "./wishlistService"; export class GeneralItemRequestService { static async getById(id: number) { @@ -198,6 +199,36 @@ export class GeneralItemRequestService { } } + static async updateWishlist(requestId: number, newFinalQuantity: number) { + const existingRequest = await db.generalItemRequest.findUnique({ + where: { id: requestId }, + select: { finalQuantity: true, id: true } + }); + + if (!existingRequest) throw new NotFoundError("Request not found"); + + const oldFinalQuantity = existingRequest.finalQuantity ?? 0; + const unfulfilledDelta = oldFinalQuantity - newFinalQuantity; + + return await db.$transaction(async (tx) => { + const updated = await tx.generalItemRequest.update({ + where: { id: requestId }, + data: { finalQuantity: newFinalQuantity } + }); + + if (unfulfilledDelta !== 0) { + await WishlistService.createUnfulfilledWishlistEntries([ + { + requestId: requestId, + unfulfilledQuantity: unfulfilledDelta, + }, + ]); + } + + return updated; + }); + } + static async bulkUpdateRequests( updates: Array<{ requestId: number; finalQuantity: number }> ) { @@ -241,14 +272,35 @@ export class GeneralItemRequestService { throw new ArgumentError("Cannot update requests after the partner response deadline has passed."); } - const results = await db.$transaction( - updates.map(({ requestId, finalQuantity }) => - db.generalItemRequest.update({ - where: { id: requestId }, - data: { finalQuantity }, - }) - ) - ); + const wishlistDeltas = updates.map(u => { + const existingRequest = requests.find(r => r.id === u.requestId); + if (!existingRequest) return null; + + const delta = (existingRequest.finalQuantity ?? 0) - u.finalQuantity; + + return delta !== 0 + ? { requestId: u.requestId, unfulfilledQuantity: delta } + : null; + }).filter((update): update is { requestId: number; unfulfilledQuantity: number } => update !== null); + + const results = await db.$transaction(async (tx) => { + // Execute the request updates + const updateResults = await Promise.all( + updates.map(({ requestId, finalQuantity }) => + tx.generalItemRequest.update({ + where: { id: requestId }, + data: { finalQuantity }, + }) + ) + ); + + // 3. Sync the wishlists using the deltas + if (wishlistDeltas.length > 0) { + await WishlistService.createUnfulfilledWishlistEntries(wishlistDeltas); + } + + return updateResults; + }); return results; } @@ -311,10 +363,10 @@ export class GeneralItemRequestService { ); } - const duplicateRequest = await tx.generalItemRequest.findFirst({ + const duplicateRequest = await tx.generalItemRequest.findUnique({ where: { - generalItemId: targetGeneralItemId, - partnerId: existingRequest.partnerId, + generalItemId_partnerId: {generalItemId: targetGeneralItemId, + partnerId: existingRequest.partnerId,} }, }); @@ -537,4 +589,4 @@ export class GeneralItemRequestService { total, }; } -} +} \ No newline at end of file diff --git a/src/services/wishlistService.ts b/src/services/wishlistService.ts index 8ecc2cd4..16ba2c68 100644 --- a/src/services/wishlistService.ts +++ b/src/services/wishlistService.ts @@ -11,6 +11,8 @@ import { Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { MatchingService } from "./matchingService"; +const AUTO_GENERATED_COMMENT = "Automatically generated due to unfulfilled request"; + export class WishlistService { static async createWishlist(data: CreateWishlistData) { const partner = await db.user.findUnique({ @@ -42,6 +44,70 @@ export class WishlistService { return wishlist; } + /** + * For each request where finalQuantity < quantity, automatically creates or + * updates a wishlist entry for the partner with the unfulfilled quantity. + * If an entry with the same name already exists for the partner, the + * unfulfilled quantity is added to it. + */ + static async createUnfulfilledWishlistEntries(updates: { requestId: number; unfulfilledQuantity: number }[]) { + const fullRequests = await db.generalItemRequest.findMany({ + where: { id: { in: updates.map(u => u.requestId) } }, + include: { partner: true, generalItem: true } + }); + + for (const update of updates) { + const request = fullRequests.find((r) => r.id === update.requestId); + if (!request || !request.partner.enabled || request.partner.pending) continue; + + // Use the delta (unfulfilledQuantity) passed from the service + const delta = update.unfulfilledQuantity; + + // IMPORTANT: Search by generalItemId to avoid duplicates + const existing = await db.wishlist.findFirst({ + where: { + partnerId: request.partnerId, + generalItemId: request.generalItemId, + } + }); + + if (existing) { + const newQty = (existing.quantity ?? 0) + delta; + if (newQty <= 0) { + await db.wishlist.delete({ where: { id: existing.id } }); + } else { + await db.wishlist.update({ + where: { id: existing.id }, + data: { quantity: newQty }, + }); + } + } else if (delta > 0) { + const wishlist = await db.wishlist.create({ + data: { + name: request.generalItem.title, + quantity: delta, + priority: request.priority ?? "LOW", + comments: AUTO_GENERATED_COMMENT, + partnerId: request.partnerId, + generalItemId: request.generalItemId, // KEY: Link it! + }, + }); + + try { + await MatchingService.add({ + wishlistId: wishlist.id, + title: wishlist.name, + }); + } catch (error) { + console.warn( + "Failed to generate embedding for auto-created wishlist entry:", + error + ); + } + } + } + } + /** * Automatically updates lastUpdated */ @@ -247,4 +313,4 @@ export class WishlistService { } } } -} +} \ No newline at end of file