diff --git a/prisma/migrations/0008_order_item_pricing/migration.sql b/prisma/migrations/0008_order_item_pricing/migration.sql new file mode 100644 index 0000000..e422a8c --- /dev/null +++ b/prisma/migrations/0008_order_item_pricing/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "OrderItem" +ADD COLUMN "unitPrice" DECIMAL(14, 2); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 24f0127..f2a8a4a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -273,6 +273,7 @@ model OrderItem { product Product? @relation(fields: [productId], references: [id]) productName String quantity Decimal @db.Decimal(14, 3) + unitPrice Decimal? @db.Decimal(14, 2) createdAt DateTime @default(now()) } diff --git a/src/resolvers.js b/src/resolvers.js index 0e26fdf..b86680b 100644 --- a/src/resolvers.js +++ b/src/resolvers.js @@ -25,6 +25,10 @@ function toFloat(value) { return value == null ? null : Number(value); } +function roundMoney(value) { + return Math.round((Number(value) + Number.EPSILON) * 100) / 100; +} + function requireUser(context) { if (!context.user) { throw new Error('Authentication required.'); @@ -1365,18 +1369,74 @@ export const resolvers = { const manager = requireManagerAccess(context); const existingOrder = await context.prisma.order.findUnique({ where: { id: input.orderId }, + include: { + items: true, + }, }); assertManagerCanAccessOrder(existingOrder); - const order = await context.prisma.order.update({ - where: { id: input.orderId }, - data: { - managerId: manager.id, - status: 'WAITING_DOUBLE_CONFIRM', - deliveryTerms: input.deliveryTerms, - deliveryFee: input.deliveryFee, - totalPrice: input.totalPrice, - }, + if (!existingOrder.items.length) { + throw new Error('Order has no items to price.'); + } + + const deliveryFee = Number(input.deliveryFee); + if (!Number.isFinite(deliveryFee) || deliveryFee < 0) { + throw new Error('Delivery fee must be zero or greater.'); + } + + const orderItemIds = new Set(existingOrder.items.map((item) => item.id)); + const itemPriceMap = new Map(); + + for (const itemPrice of input.itemPrices) { + if (itemPriceMap.has(itemPrice.itemId)) { + throw new Error('Duplicate item pricing entries are not allowed.'); + } + + if (!orderItemIds.has(itemPrice.itemId)) { + throw new Error('Pricing can only be set for items from this order.'); + } + + const unitPrice = Number(itemPrice.unitPrice); + if (!Number.isFinite(unitPrice) || unitPrice < 0) { + throw new Error('Unit price must be zero or greater.'); + } + + itemPriceMap.set(itemPrice.itemId, roundMoney(unitPrice)); + } + + if (itemPriceMap.size !== existingOrder.items.length) { + throw new Error('Pricing must be provided for every order item.'); + } + + const totalProductsPrice = existingOrder.items.reduce( + (sum, item) => sum + (Number(item.quantity) * itemPriceMap.get(item.id)), + 0, + ); + const totalPrice = roundMoney(totalProductsPrice + deliveryFee); + + const order = await context.prisma.$transaction(async (tx) => { + for (const item of existingOrder.items) { + await tx.orderItem.update({ + where: { id: item.id }, + data: { + unitPrice: itemPriceMap.get(item.id), + }, + }); + } + + return tx.order.update({ + where: { id: input.orderId }, + data: { + managerId: manager.id, + status: 'WAITING_DOUBLE_CONFIRM', + clientApproved: null, + managerApproved: null, + blockReason: null, + deliveryTerms: normalizeOptionalText(input.deliveryTerms), + deliveryFee: roundMoney(deliveryFee), + totalPrice, + }, + }); }); await appendOrderEvent(context.prisma, order.id, 'WAITING_DOUBLE_CONFIRM', manager.id, 'Offer is published by manager'); @@ -1636,6 +1696,12 @@ export const resolvers = { OrderItem: { quantity: (item) => toFloat(item.quantity), + unitPrice: (item) => toFloat(item.unitPrice), + lineTotal: (item) => ( + item.unitPrice == null + ? null + : roundMoney(Number(item.quantity) * Number(item.unitPrice)) + ), }, BonusTransaction: { diff --git a/src/schema.graphql b/src/schema.graphql index 3796511..34a1c30 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -232,6 +232,8 @@ type OrderItem { productId: ID productName: String! quantity: Float! + unitPrice: Float + lineTotal: Float } type OrderStatusEvent { @@ -428,9 +430,14 @@ input SubmitCalculationOrderInput { input SetOrderOfferInput { orderId: ID! + itemPrices: [OrderItemPriceInput!]! deliveryTerms: String! deliveryFee: Float! - totalPrice: Float! +} + +input OrderItemPriceInput { + itemId: ID! + unitPrice: Float! } input BlockOrderInput {