test commit

This commit is contained in:
Kirill
2026-05-19 11:25:23 +05:00
parent f8867f6457
commit 5adbe9baa7
81 changed files with 6549 additions and 3108 deletions
+214 -255
View File
@@ -1,312 +1,271 @@
import { isDeliveryCarrier } from "../lib/delivery-carrier.js";
import { prisma } from "../lib/prisma.js";
import { NOTIFICATION_EVENTS } from "../../../shared/constants/notification-events.js";
import { NOTIFICATION_EVENTS } from '../../../shared/constants/notification-events.js'
import { isDeliveryCarrier } from '../lib/delivery-carrier.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();
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 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" });
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',
})
}
deliveryCarrier = carrierStr
}
if (deliveryType !== "delivery" && deliveryType !== "pickup") {
return reply
.code(400)
.send({ error: "deliveryType должен быть delivery | pickup" });
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.inStock ? ci.product.quantity : 1
if (ci.qty > available) {
return reply.code(409).send({
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
})
}
}
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",
});
}
deliveryCarrier = carrierStr;
}
const itemsPayload = cartItems.map((ci) => ({
productId: ci.productId,
qty: ci.qty,
titleSnapshot: ci.product.title,
priceCentsSnapshot: ci.product.priceCents,
}))
if (paymentMethod === "on_pickup" && deliveryType !== "pickup") {
return reply
.code(400)
.send({
error: "Оплата при получении доступна только для самовывоза",
});
}
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
const totalCents = itemsSubtotalCents + deliveryFeeCents
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 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,
})
const cartItems = await prisma.cartItem.findMany({
where: { userId },
include: { product: true },
});
if (cartItems.length === 0)
return reply.code(400).send({ error: "Корзина пуста" });
let initialStatus = 'PENDING_PAYMENT'
let deliveryFeeLocked = true
if (paymentMethod === 'on_pickup') {
initialStatus = 'IN_PROGRESS'
} else if (deliveryType === 'delivery') {
initialStatus = 'PENDING_PAYMENT'
deliveryFeeLocked = false
}
for (const ci of cartItems) {
const available = ci.product.inStock ? ci.product.quantity : 1;
if (ci.qty > available) {
return reply
.code(409)
.send({
error: `Недостаточно товара: "${ci.product.title}". Доступно: ${available} шт.`,
});
}
}
let created
try {
created = await prisma.$transaction(async (tx) => {
for (const ci of cartItems) {
if (!ci.product.inStock) continue
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) {
if (!ci.product.inStock) continue;
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 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,
})),
},
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) || "Недостаточно товара",
});
}
},
})
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,
});
// 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,
});
// 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 });
},
);
return reply.code(201).send({ orderId: created.id })
})
fastify.get('/api/me/orders', { preHandler: [fastify.authenticate] }, async (request) => {
const userId = request.user.sub
const orders = await prisma.order.findMany({
where: { userId },
include: { items: 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] }, async (request, reply) => {
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true, messages: { orderBy: { createdAt: 'asc' } } },
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
return { item: order }
})
fastify.get(
"/api/me/orders",
{ preHandler: [fastify.authenticate] },
async (request) => {
const userId = request.user.sub;
const orders = await prisma.order.findMany({
where: { userId },
include: { items: 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",
'/api/me/orders/:id/review-eligibility',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub;
const { id } = request.params;
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true, messages: { orderBy: { createdAt: "asc" } } },
});
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
return { item: order };
},
);
fastify.get(
"/api/me/orders/:id/review-eligibility",
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub;
const { id } = request.params;
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({
where: { id, userId },
include: { items: true },
});
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
if (order.status !== "DONE") {
return { canReview: false, items: [] };
})
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
if (order.status !== 'DONE') {
return { canReview: false, items: [] }
}
const uniq = new Map();
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 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));
})
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",
'/api/me/orders/:id/confirm-received',
{ preHandler: [fastify.authenticate] },
async (request, reply) => {
const userId = request.user.sub;
const { id } = request.params;
const order = await prisma.order.findFirst({ where: { id, userId } });
if (!order) return reply.code(404).send({ error: "Заказ не найден" });
const userId = request.user.sub
const { id } = request.params
const order = await prisma.order.findFirst({ where: { id, userId } })
if (!order) return reply.code(404).send({ error: 'Заказ не найден' })
const okDelivery =
order.deliveryType === "delivery" && order.status === "SHIPPED";
const okPickup =
order.deliveryType === "pickup" && order.status === "READY_FOR_PICKUP";
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: "Сейчас нельзя подтвердить получение заказа" });
return reply.code(409).send({ error: 'Сейчас нельзя подтвердить получение заказа' })
}
await prisma.order.update({ where: { id }, data: { status: "DONE" } });
return { ok: true, status: "DONE" };
await prisma.order.update({ where: { id }, data: { status: 'DONE' } })
return { ok: true, status: 'DONE' }
},
);
)
}