refactor: simplify order status model — remove DELIVERY_FEE_ADJUSTMENT and PAYMENT_VERIFICATION
- Add deliveryFeeLocked field to Order model - Remove DELIVERY_FEE_ADJUSTMENT and PAYMENT_VERIFICATION statuses (11→8) - 3 order paths: delivery+online (locked→unlocked→paid), pickup+online (unlocked→paid), pickup+on_pickup (direct to in_progress) - Update checkout to use PENDING_PAYMENT + deliveryFeeLocked - Update payment flow to stay in PENDING_PAYMENT until admin confirms - Update admin UI to use deliveryFeeLocked instead of status check - Update client payment UI with new deliveryFeeLocked logic
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Order" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"status" TEXT NOT NULL DEFAULT 'DRAFT',
|
||||
"deliveryFeeLocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"deliveryType" TEXT NOT NULL DEFAULT 'delivery',
|
||||
"deliveryCarrier" TEXT,
|
||||
"paymentMethod" TEXT NOT NULL DEFAULT 'online',
|
||||
"itemsSubtotalCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"deliveryFeeCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"totalCents" INTEGER NOT NULL DEFAULT 0,
|
||||
"currency" TEXT NOT NULL DEFAULT 'RUB',
|
||||
"addressSnapshotJson" TEXT,
|
||||
"comment" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Order" ("addressSnapshotJson", "comment", "createdAt", "currency", "deliveryCarrier", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "paymentMethod", "status", "totalCents", "updatedAt", "userId") SELECT "addressSnapshotJson", "comment", "createdAt", "currency", "deliveryCarrier", "deliveryFeeCents", "deliveryType", "id", "itemsSubtotalCents", "paymentMethod", "status", "totalCents", "updatedAt", "userId" FROM "Order";
|
||||
DROP TABLE "Order";
|
||||
ALTER TABLE "new_Order" RENAME TO "Order";
|
||||
CREATE INDEX "Order_userId_createdAt_idx" ON "Order"("userId", "createdAt");
|
||||
CREATE INDEX "Order_status_updatedAt_idx" ON "Order"("status", "updatedAt");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -125,6 +125,7 @@ model Order {
|
||||
id String @id @default(cuid())
|
||||
/// Статус заказа (валидация переходов на уровне API)
|
||||
status String @default("DRAFT")
|
||||
deliveryFeeLocked Boolean @default(false)
|
||||
/// 'delivery' | 'pickup'
|
||||
deliveryType String @default("delivery")
|
||||
/// RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST при deliveryType=delivery
|
||||
|
||||
@@ -17,6 +17,14 @@ describe('canTransitionAdminOrderStatus', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PAID')).toBe(false)
|
||||
})
|
||||
|
||||
it('PENDING_PAYMENT → PAID', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'PAID')).toBe(true)
|
||||
})
|
||||
|
||||
it('PENDING_PAYMENT → CANCELLED', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'PENDING_PAYMENT', ...delivery }, 'CANCELLED')).toBe(true)
|
||||
})
|
||||
|
||||
it('PAID → IN_PROGRESS', () => {
|
||||
expect(canTransitionAdminOrderStatus({ status: 'PAID', ...delivery }, 'IN_PROGRESS')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -12,11 +12,7 @@ export function canTransitionAdminOrderStatus(order, next) {
|
||||
switch (from) {
|
||||
case 'DRAFT':
|
||||
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
|
||||
case 'DELIVERY_FEE_ADJUSTMENT':
|
||||
return next === 'CANCELLED'
|
||||
case 'PENDING_PAYMENT':
|
||||
return next === 'CANCELLED'
|
||||
case 'PAYMENT_VERIFICATION':
|
||||
return next === 'PAID' || next === 'CANCELLED'
|
||||
case 'PAID':
|
||||
return next === 'IN_PROGRESS' || next === 'CANCELLED'
|
||||
|
||||
@@ -8,7 +8,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
async () => {
|
||||
const attentionCount = await prisma.order.count({
|
||||
where: {
|
||||
status: { in: ['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] },
|
||||
status: 'PENDING_PAYMENT',
|
||||
},
|
||||
})
|
||||
return { attentionCount }
|
||||
@@ -126,8 +126,8 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
|
||||
const existing = await prisma.order.findUnique({ where: { id } })
|
||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||
if (existing.status !== 'DELIVERY_FEE_ADJUSTMENT') {
|
||||
return reply.code(409).send({ error: 'Корректировка доставки доступна только в статусе DELIVERY_FEE_ADJUSTMENT' })
|
||||
if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
|
||||
return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' })
|
||||
}
|
||||
|
||||
const totalCents = existing.itemsSubtotalCents + parsed
|
||||
@@ -136,7 +136,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
data: {
|
||||
deliveryFeeCents: parsed,
|
||||
totalCents,
|
||||
status: 'PENDING_PAYMENT',
|
||||
deliveryFeeLocked: true,
|
||||
},
|
||||
})
|
||||
return { item: updated }
|
||||
|
||||
@@ -101,8 +101,13 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
})
|
||||
|
||||
let initialStatus = 'PENDING_PAYMENT'
|
||||
if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
|
||||
else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
|
||||
let deliveryFeeLocked = true
|
||||
if (paymentMethod === 'on_pickup') {
|
||||
initialStatus = 'IN_PROGRESS'
|
||||
} else if (deliveryType === 'delivery') {
|
||||
initialStatus = 'PENDING_PAYMENT'
|
||||
deliveryFeeLocked = false
|
||||
}
|
||||
|
||||
let created
|
||||
try {
|
||||
@@ -124,6 +129,7 @@ export async function registerUserOrderRoutes(fastify) {
|
||||
data: {
|
||||
userId,
|
||||
status: initialStatus,
|
||||
deliveryFeeLocked,
|
||||
deliveryType,
|
||||
deliveryCarrier,
|
||||
paymentMethod,
|
||||
|
||||
@@ -18,115 +18,94 @@ export async function registerUserPaymentRoutes(fastify) {
|
||||
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
|
||||
}
|
||||
|
||||
if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
|
||||
if (order.status !== 'PENDING_PAYMENT') {
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||
}
|
||||
|
||||
if (!request.isMultipart()) {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error:
|
||||
'Оплата станет доступна после корректировки стоимости доставки администратором.',
|
||||
})
|
||||
.code(400)
|
||||
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
|
||||
}
|
||||
|
||||
let nextStatus = order.status
|
||||
if (order.status === 'DRAFT') {
|
||||
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
|
||||
nextStatus = 'PENDING_PAYMENT'
|
||||
return { ok: true, status: nextStatus }
|
||||
}
|
||||
|
||||
if (order.status === 'PAYMENT_VERIFICATION') {
|
||||
return { ok: true, status: nextStatus }
|
||||
}
|
||||
|
||||
if (order.status === 'PENDING_PAYMENT') {
|
||||
if (!request.isMultipart()) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
|
||||
let detail = ''
|
||||
let receiptBuffer = null
|
||||
let receiptFilename = ''
|
||||
try {
|
||||
const otherLimit = getOtherUploadMaxFileBytes()
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: otherLimit,
|
||||
files: 2,
|
||||
},
|
||||
})
|
||||
for await (const part of parts) {
|
||||
if (part.file) {
|
||||
if (part.fieldname === 'receipt') {
|
||||
if (receiptBuffer !== null) {
|
||||
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
||||
}
|
||||
receiptBuffer = await part.toBuffer()
|
||||
receiptFilename = part.filename ?? 'receipt'
|
||||
}
|
||||
} else if (part.fieldname === 'detail') {
|
||||
detail = String(part.value ?? '').trim()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
||||
return reply.code(400).send({ error: msg })
|
||||
}
|
||||
|
||||
let detail = ''
|
||||
let receiptBuffer = null
|
||||
let receiptFilename = ''
|
||||
const hasDetail = detail.length > 0
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||
|
||||
if (!hasDetail && !hasReceipt) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: 'Укажите текст о платеже и/или прикрепите изображение чека' })
|
||||
}
|
||||
|
||||
const maxDetail = 2000
|
||||
if (detail.length > maxDetail) {
|
||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
||||
}
|
||||
|
||||
let attachmentUrl = null
|
||||
if (hasReceipt) {
|
||||
try {
|
||||
const otherLimit = getOtherUploadMaxFileBytes()
|
||||
const parts = request.parts({
|
||||
limits: {
|
||||
fileSize: otherLimit,
|
||||
files: 2,
|
||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||
const statusCode =
|
||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
||||
? Number(err.statusCode)
|
||||
: 400
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const bodyHtml = hasDetail
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
||||
: ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.orderMessage.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
text: messageText,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
for await (const part of parts) {
|
||||
if (part.file) {
|
||||
if (part.fieldname === 'receipt') {
|
||||
if (receiptBuffer !== null) {
|
||||
return reply.code(400).send({ error: 'Допускается один файл receipt' })
|
||||
}
|
||||
receiptBuffer = await part.toBuffer()
|
||||
receiptFilename = part.filename ?? 'receipt'
|
||||
}
|
||||
} else if (part.fieldname === 'detail') {
|
||||
detail = String(part.value ?? '').trim()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Не удалось разобрать форму'
|
||||
return reply.code(400).send({ error: msg })
|
||||
}
|
||||
|
||||
const hasDetail = detail.length > 0
|
||||
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||
|
||||
if (!hasDetail && !hasReceipt) {
|
||||
return reply
|
||||
.code(400)
|
||||
.send({ error: 'Укажите текст о платеже и/или прикрепите изображение чека' })
|
||||
}
|
||||
|
||||
const maxDetail = 2000
|
||||
if (detail.length > maxDetail) {
|
||||
return reply.code(400).send({ error: `Текст не длиннее ${maxDetail} символов` })
|
||||
}
|
||||
|
||||
let attachmentUrl = null
|
||||
if (hasReceipt) {
|
||||
try {
|
||||
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||
const statusCode =
|
||||
err && typeof err === 'object' && 'statusCode' in err && Number.isInteger(err.statusCode)
|
||||
? Number(err.statusCode)
|
||||
: 400
|
||||
return reply.code(statusCode).send({ error: message })
|
||||
}
|
||||
}
|
||||
|
||||
const bodyHtml = hasDetail
|
||||
? `<p>${escapeHtml(detail).replace(/\r\n|\n|\r/g, '<br/>')}</p>`
|
||||
: ''
|
||||
const messageText = `<p><strong>Подтверждение оплаты (перевод ВТБ / Сбербанк)</strong></p>${bodyHtml}`
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({ where: { id }, data: { status: 'PAYMENT_VERIFICATION' } })
|
||||
await tx.orderMessage.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
authorType: 'user',
|
||||
text: messageText,
|
||||
attachmentUrl,
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||
}
|
||||
|
||||
return { ok: true, status: 'PAYMENT_VERIFICATION' }
|
||||
})
|
||||
} catch (err) {
|
||||
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||
}
|
||||
|
||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||
return { ok: true, status: 'PENDING_PAYMENT' }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user