import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js' import { asyncHandler } from '../lib/async-handler.js' import { isDeliveryCarrier } from '../lib/delivery-carrier.js' import { findUserOrder } from '../lib/find-user-order.js' import { prisma } from '../lib/prisma.js' export async function registerUserOrderRoutes(fastify) { // ---- Создание заказа (checkout) ---- fastify.post('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request, reply) => { const userId = request.user.sub const deliveryTypeRaw = request.body?.deliveryType const deliveryType = deliveryTypeRaw === undefined || deliveryTypeRaw === null || deliveryTypeRaw === '' ? 'delivery' : String(deliveryTypeRaw).trim() const addressId = String(request.body?.addressId || '').trim() const commentRaw = request.body?.comment const comment = commentRaw === null || commentRaw === undefined ? null : String(commentRaw).trim() const paymentMethodRaw = request.body?.paymentMethod const paymentMethod = paymentMethodRaw === undefined || paymentMethodRaw === null || paymentMethodRaw === '' ? 'online' : String(paymentMethodRaw).trim() if (paymentMethod !== 'online' && paymentMethod !== 'on_pickup') { return reply.code(400).send({ error: 'paymentMethod должен быть online | on_pickup' }) } if (deliveryType !== 'delivery' && deliveryType !== 'pickup') { return reply.code(400).send({ error: 'deliveryType должен быть delivery | pickup' }) } const carrierRaw = request.body?.deliveryCarrier let deliveryCarrier = null if (deliveryType === 'delivery') { const carrierStr = carrierRaw === undefined || carrierRaw === null || carrierRaw === '' ? '' : String(carrierRaw).trim() if (!isDeliveryCarrier(carrierStr)) { return reply.code(400).send({ error: 'deliveryCarrier обязателен для доставки: RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST | WB_PVZ', }) } deliveryCarrier = carrierStr } if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') { return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза', }) } let address = null if (deliveryType === 'delivery') { if (!addressId) return reply.code(400).send({ error: 'Выберите адрес доставки' }) address = await prisma.shippingAddress.findFirst({ where: { id: addressId, userId }, }) if (!address) return reply.code(404).send({ error: 'Адрес не найден' }) } const cartItems = await prisma.cartItem.findMany({ where: { userId }, include: { product: true }, }) if (cartItems.length === 0) return reply.code(400).send({ error: 'Корзина пуста' }) for (const ci of cartItems) { const available = ci.product.quantity if (ci.qty > available) { return reply.code(409).send({ error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`, }) } } const itemsPayload = cartItems.map((ci) => ({ productId: ci.productId, qty: ci.qty, titleSnapshot: ci.product.title, priceCentsSnapshot: ci.product.priceCents, })) const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0) const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0 const totalCents = itemsSubtotalCents + deliveryFeeCents const addressSnapshotJson = deliveryType === 'pickup' ? JSON.stringify({ deliveryType: 'pickup' }) : JSON.stringify({ deliveryType: 'delivery', id: address.id, label: address.label, recipientName: address.recipientName, recipientPhone: address.recipientPhone, addressLine: address.addressLine, comment: address.comment, lat: address.lat, lng: address.lng, }) let initialStatus = 'PENDING_PAYMENT' let deliveryFeeLocked = true if (paymentMethod === 'on_pickup') { initialStatus = 'IN_PROGRESS' } else if (deliveryType === 'delivery') { initialStatus = 'PENDING_PAYMENT' deliveryFeeLocked = false } let created try { created = await prisma.$transaction(async (tx) => { for (const ci of cartItems) { const res = await tx.product.updateMany({ where: { id: ci.productId, quantity: { gte: ci.qty } }, data: { quantity: { decrement: ci.qty } }, }) if (res.count !== 1) { throw new Error(`Недостаточно товара: "${ci.product.title}"`) } } const order = await tx.order.create({ data: { userId, status: initialStatus, deliveryFeeLocked, deliveryType, deliveryCarrier, paymentMethod, itemsSubtotalCents, deliveryFeeCents, totalCents, currency: 'RUB', addressSnapshotJson, comment: comment && comment.length ? comment : null, items: { create: itemsPayload.map((i) => ({ productId: i.productId, qty: i.qty, titleSnapshot: i.titleSnapshot, priceCentsSnapshot: i.priceCentsSnapshot, })), }, }, }) await tx.cartItem.deleteMany({ where: { userId } }) return order }) } catch (e) { return reply.code(409).send({ error: (e instanceof Error && e.message) || 'Недостаточно товара', }) } // Emit notification events request.server.eventBus.emit(NOTIFICATION_EVENTS.ORDER_CREATED, { orderId: created.id, userId, totalCents: created.totalCents, itemsCount: cartItems.length, deliveryType: created.deliveryType, }) // Also emit admin notification request.server.eventBus.emit('order:created:admin', { orderId: created.id, userId, userEmail: request.user.email || '', totalCents: created.totalCents, itemsCount: cartItems.length, deliveryType: created.deliveryType, }) return reply.code(201).send({ orderId: created.id }) }) fastify.get( '/api/me/orders', { preHandler: [fastify.authenticate] }, asyncHandler(async (request, reply) => { const userId = request.user.sub const orders = await prisma.order.findMany({ where: { userId }, include: { items: { select: { qty: true } } }, orderBy: { createdAt: 'desc' }, }) return { items: orders.map((o) => ({ id: o.id, status: o.status, totalCents: o.totalCents, currency: o.currency, createdAt: o.createdAt, updatedAt: o.updatedAt, itemsCount: o.items.reduce((s, i) => s + i.qty, 0), })), } }), ) fastify.get( '/api/me/orders/:id', { preHandler: [fastify.authenticate] }, asyncHandler(async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await findUserOrder(prisma, id, userId, { items: true, messages: { orderBy: { createdAt: 'asc' } }, }) return { item: order } }), ) fastify.get( '/api/me/orders/:id/review-eligibility', { preHandler: [fastify.authenticate] }, asyncHandler(async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await findUserOrder(prisma, id, userId, { items: true }) if (order.status !== 'DONE') { return { canReview: false, items: [] } } const uniq = new Map() for (const it of order.items) { if (!uniq.has(it.productId)) { uniq.set(it.productId, { productId: it.productId, title: it.titleSnapshot, }) } } const productIds = [...uniq.keys()] const existing = await prisma.review.findMany({ where: { userId, productId: { in: productIds } }, select: { productId: true }, }) const reviewed = new Set(existing.map((r) => r.productId)) return { canReview: true, items: [...uniq.values()].map((x) => ({ ...x, hasReview: reviewed.has(x.productId), })), } }), ) fastify.post( '/api/me/orders/:id/confirm-received', { preHandler: [fastify.authenticate] }, asyncHandler(async (request, reply) => { const userId = request.user.sub const { id } = request.params const order = await findUserOrder(prisma, id, userId) const okDelivery = order.deliveryType === 'delivery' && order.status === 'SHIPPED' const okPickup = order.deliveryType === 'pickup' && order.status === 'READY_FOR_PICKUP' if (!okDelivery && !okPickup) { return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' }) } await prisma.order.update({ where: { id }, data: { status: 'DONE' } }) return { ok: true, status: 'DONE' } }), ) }