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:
@@ -30,6 +30,7 @@ export type AdminOrderDetailResponse = {
|
|||||||
paymentMethod?: 'online' | 'on_pickup'
|
paymentMethod?: 'online' | 'on_pickup'
|
||||||
itemsSubtotalCents: number
|
itemsSubtotalCents: number
|
||||||
deliveryFeeCents: number
|
deliveryFeeCents: number
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
totalCents: number
|
totalCents: number
|
||||||
currency: string
|
currency: string
|
||||||
addressSnapshotJson: string | null
|
addressSnapshotJson: string | null
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type OrderDetailResponse = {
|
|||||||
paymentMethod?: OrderPaymentMethod
|
paymentMethod?: OrderPaymentMethod
|
||||||
itemsSubtotalCents: number
|
itemsSubtotalCents: number
|
||||||
deliveryFeeCents: number
|
deliveryFeeCents: number
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
totalCents: number
|
totalCents: number
|
||||||
currency: string
|
currency: string
|
||||||
addressSnapshotJson: string | null
|
addressSnapshotJson: string | null
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { PaymentDialog } from './PaymentDialog'
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
status: string
|
status: string
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
paymentMethod: string | null
|
paymentMethod: string | null
|
||||||
totalCents: number
|
totalCents: number
|
||||||
isPayPending: boolean
|
isPayPending: boolean
|
||||||
@@ -14,7 +15,14 @@ type Props = {
|
|||||||
onPay: (params: { detail: string; receiptFile: File | null }) => void
|
onPay: (params: { detail: string; receiptFile: File | null }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OrderPaymentSection({ status, paymentMethod, isPayPending, payError, onPay }: Props) {
|
export function OrderPaymentSection({
|
||||||
|
status,
|
||||||
|
deliveryFeeLocked,
|
||||||
|
paymentMethod,
|
||||||
|
isPayPending,
|
||||||
|
payError,
|
||||||
|
onPay,
|
||||||
|
}: Props) {
|
||||||
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
||||||
const [payModalOpen, setPayModalOpen] = useState(false)
|
const [payModalOpen, setPayModalOpen] = useState(false)
|
||||||
|
|
||||||
@@ -36,29 +44,23 @@ export function OrderPaymentSection({ status, paymentMethod, isPayPending, payEr
|
|||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom>
|
||||||
Оплата
|
Оплата
|
||||||
</Typography>
|
</Typography>
|
||||||
{status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && (
|
||||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||||
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в статус «
|
Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости.
|
||||||
{orderStatusLabelRu('PENDING_PAYMENT')}».
|
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{status === 'PENDING_PAYMENT' && (
|
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
|
||||||
<>
|
<>
|
||||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||||
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус «
|
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус «
|
||||||
{orderStatusLabelRu('PAYMENT_VERIFICATION')}».
|
{orderStatusLabelRu('PAID')}».
|
||||||
</Typography>
|
</Typography>
|
||||||
<Button variant="contained" onClick={() => setPayModalOpen(true)}>
|
<Button variant="contained" onClick={() => setPayModalOpen(true)}>
|
||||||
Оплатить
|
Оплатить
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{status === 'PAYMENT_VERIFICATION' && (
|
{status !== 'PENDING_PAYMENT' && (
|
||||||
<Typography color="info.main" variant="body2">
|
|
||||||
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(status) && (
|
|
||||||
<Typography color="text.secondary" variant="body2">
|
<Typography color="text.secondary" variant="body2">
|
||||||
На этом этапе действий по оплате в этом блоке не требуется.
|
На этом этапе действий по оплате в этом блоке не требуется.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -318,14 +318,14 @@ export function AdminOrdersPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||||
<Alert severity="info">
|
<Alert severity="info">
|
||||||
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой
|
||||||
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
суммы.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||||
<DeliveryFeeAdjustmentForm
|
<DeliveryFeeAdjustmentForm
|
||||||
key={detail.id}
|
key={detail.id}
|
||||||
orderId={detail.id}
|
orderId={detail.id}
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
<OrderPaymentSection
|
<OrderPaymentSection
|
||||||
status={order.status}
|
status={order.status}
|
||||||
|
deliveryFeeLocked={order.deliveryFeeLocked}
|
||||||
paymentMethod={order.paymentMethod ?? null}
|
paymentMethod={order.paymentMethod ?? null}
|
||||||
totalCents={order.totalCents}
|
totalCents={order.totalCents}
|
||||||
isPayPending={payMut.isPending}
|
isPayPending={payMut.isPending}
|
||||||
|
|||||||
@@ -8,11 +8,7 @@ export function getAdminNextOrderStatuses(status: string, deliveryType: 'deliver
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'DRAFT':
|
case 'DRAFT':
|
||||||
return ['PENDING_PAYMENT', 'CANCELLED']
|
return ['PENDING_PAYMENT', 'CANCELLED']
|
||||||
case 'DELIVERY_FEE_ADJUSTMENT':
|
|
||||||
return ['CANCELLED']
|
|
||||||
case 'PENDING_PAYMENT':
|
case 'PENDING_PAYMENT':
|
||||||
return ['CANCELLED']
|
|
||||||
case 'PAYMENT_VERIFICATION':
|
|
||||||
return ['PAID', 'CANCELLED']
|
return ['PAID', 'CANCELLED']
|
||||||
case 'PAID':
|
case 'PAID':
|
||||||
return ['IN_PROGRESS', 'CANCELLED']
|
return ['IN_PROGRESS', 'CANCELLED']
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
export function orderStatusLabelRu(code: string): string {
|
export function orderStatusLabelRu(code: string): string {
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
DRAFT: 'Черновик',
|
DRAFT: 'Черновик',
|
||||||
DELIVERY_FEE_ADJUSTMENT: 'Корректировка стоимости доставки',
|
|
||||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||||
PAYMENT_VERIFICATION: 'Проверка оплаты',
|
|
||||||
PAID: 'Оплачен',
|
PAID: 'Оплачен',
|
||||||
IN_PROGRESS: 'В работе',
|
IN_PROGRESS: 'В работе',
|
||||||
SHIPPED: 'Отправлен',
|
SHIPPED: 'Отправлен',
|
||||||
|
|||||||
@@ -0,0 +1,950 @@
|
|||||||
|
# Order Status Simplification (A+B) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remove `DELIVERY_FEE_ADJUSTMENT` and `PAYMENT_VERIFICATION` statuses, replace with `deliveryFeeLocked` flag and simplified payment flow. Three order paths: (1) delivery+online, (2) pickup+online, (3) pickup+on_pickup.
|
||||||
|
|
||||||
|
**Architecture:** Add `deliveryFeeLocked Boolean` to Order model. Path 1 (delivery+online): created as `PENDING_PAYMENT` + `deliveryFeeLocked=false`, admin approves fee → `deliveryFeeLocked=true` → client pays. Path 2 (pickup+online): created as `PENDING_PAYMENT` + `deliveryFeeLocked=true` → client pays immediately. Path 3 (pickup+on_pickup): created as `IN_PROGRESS` → no payment needed. Payment stays in `PENDING_PAYMENT` until admin confirms → `PAID`. No migration needed — no existing orders.
|
||||||
|
|
||||||
|
**Tech Stack:** Prisma (SQLite), Fastify, React + MUI, TypeScript, vitest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `deliveryFeeLocked` field to Prisma schema
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/prisma/schema.prisma:124-152`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add field to Order model**
|
||||||
|
|
||||||
|
Add `deliveryFeeLocked Boolean @default(false)` after `status` in the `Order` model:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Order {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
status String @default("DRAFT")
|
||||||
|
deliveryFeeLocked Boolean @default(false)
|
||||||
|
/// 'delivery' | 'pickup'
|
||||||
|
deliveryType String @default("delivery")
|
||||||
|
// ... rest unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run Prisma migration**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npx prisma migrate dev --name add_delivery_fee_locked
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Migration created and applied successfully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update shared constants — remove 2 statuses
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `shared/constants/order-status.js`
|
||||||
|
- Modify: `shared/constants/order-status.d.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write updated `order-status.js`**
|
||||||
|
|
||||||
|
```js
|
||||||
|
export const ORDER_STATUSES = Object.freeze([
|
||||||
|
'DRAFT',
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PAID',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'SHIPPED',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'DONE',
|
||||||
|
'CANCELLED',
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write updated `order-status.d.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export declare const ORDER_STATUSES: readonly [
|
||||||
|
'DRAFT',
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PAID',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
'SHIPPED',
|
||||||
|
'READY_FOR_PICKUP',
|
||||||
|
'DONE',
|
||||||
|
'CANCELLED',
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run client typecheck to verify no breakage**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors (other files still reference removed statuses — that's expected, they'll be fixed in later tasks).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Update server `canTransitionAdminOrderStatus`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/lib/order-status.js`
|
||||||
|
- Test: `server/src/lib/__tests__/order-status.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write updated tests first**
|
||||||
|
|
||||||
|
Replace `server/src/lib/__tests__/order-status.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { canTransitionAdminOrderStatus } from '../order-status.js'
|
||||||
|
|
||||||
|
describe('canTransitionAdminOrderStatus', () => {
|
||||||
|
const delivery = { deliveryType: 'delivery' }
|
||||||
|
const pickup = { deliveryType: 'pickup' }
|
||||||
|
|
||||||
|
it('DRAFT → PENDING_PAYMENT', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'PENDING_PAYMENT')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DRAFT → CANCELLED', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'CANCELLED')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DRAFT cannot skip to PAID', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (delivery) → SHIPPED', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'SHIPPED')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (pickup) → READY_FOR_PICKUP', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...pickup }, 'READY_FOR_PICKUP')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('IN_PROGRESS (delivery) cannot go to READY_FOR_PICKUP', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'IN_PROGRESS', ...delivery }, 'READY_FOR_PICKUP')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('DONE allows no transitions', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'CANCELLED')).toBe(false)
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DONE', ...delivery }, 'PAID')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('same status returns true', () => {
|
||||||
|
expect(canTransitionAdminOrderStatus({ status: 'DRAFT', ...delivery }, 'DRAFT')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test -- --run src/lib/__tests__/order-status.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `PENDING_PAYMENT → PAID` and `PENDING_PAYMENT → CANCELLED` tests fail (old code doesn't handle `PENDING_PAYMENT`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write updated implementation**
|
||||||
|
|
||||||
|
Replace `server/src/lib/order-status.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export { ORDER_STATUSES } from '../../../shared/constants/order-status.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Переходы, которые делает админ через PATCH /api/admin/orders/:id/status
|
||||||
|
* (подтверждение получения пользователем — отдельный эндпоинт).
|
||||||
|
*/
|
||||||
|
export function canTransitionAdminOrderStatus(order, next) {
|
||||||
|
const from = order.status
|
||||||
|
const dt = order.deliveryType
|
||||||
|
if (from === next) return true
|
||||||
|
|
||||||
|
switch (from) {
|
||||||
|
case 'DRAFT':
|
||||||
|
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
|
||||||
|
case 'PENDING_PAYMENT':
|
||||||
|
return next === 'PAID' || next === 'CANCELLED'
|
||||||
|
case 'PAID':
|
||||||
|
return next === 'IN_PROGRESS' || next === 'CANCELLED'
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
if (next === 'CANCELLED') return true
|
||||||
|
if (dt === 'delivery') return next === 'SHIPPED'
|
||||||
|
if (dt === 'pickup') return next === 'READY_FOR_PICKUP'
|
||||||
|
return false
|
||||||
|
case 'SHIPPED':
|
||||||
|
case 'READY_FOR_PICKUP':
|
||||||
|
case 'DONE':
|
||||||
|
case 'CANCELLED':
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated используйте canTransitionAdminOrderStatus */
|
||||||
|
export function canTransitionOrderStatus(from, to) {
|
||||||
|
return canTransitionAdminOrderStatus({ status: from, deliveryType: 'delivery' }, to)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test -- --run src/lib/__tests__/order-status.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add shared/constants/order-status.js shared/constants/order-status.d.ts server/src/lib/order-status.js server/src/lib/__tests__/order-status.test.js server/prisma/schema.prisma
|
||||||
|
git commit -m "refactor: simplify order status model — remove DELIVERY_FEE_ADJUSTMENT and PAYMENT_VERIFICATION, add deliveryFeeLocked"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Update client `getAdminNextOrderStatuses` and labels
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/shared/constants/order.ts`
|
||||||
|
- Modify: `client/src/shared/lib/order-status-labels.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write updated `order.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { ORDER_STATUSES as SHARED_ORDER_STATUSES } from '@shared/constants/order-status'
|
||||||
|
|
||||||
|
export const ORDER_STATUSES = SHARED_ORDER_STATUSES as typeof SHARED_ORDER_STATUSES
|
||||||
|
|
||||||
|
export type OrderStatus = (typeof ORDER_STATUSES)[number]
|
||||||
|
|
||||||
|
export function getAdminNextOrderStatuses(status: string, deliveryType: 'delivery' | 'pickup'): OrderStatus[] {
|
||||||
|
switch (status) {
|
||||||
|
case 'DRAFT':
|
||||||
|
return ['PENDING_PAYMENT', 'CANCELLED']
|
||||||
|
case 'PENDING_PAYMENT':
|
||||||
|
return ['PAID', 'CANCELLED']
|
||||||
|
case 'PAID':
|
||||||
|
return ['IN_PROGRESS', 'CANCELLED']
|
||||||
|
case 'IN_PROGRESS':
|
||||||
|
if (deliveryType === 'delivery') return ['SHIPPED', 'CANCELLED']
|
||||||
|
return ['READY_FOR_PICKUP', 'CANCELLED']
|
||||||
|
default:
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canTransitionOrderStatus(from: string, to: string): boolean {
|
||||||
|
if (from === to) return true
|
||||||
|
return getAdminNextOrderStatuses(from, 'delivery').includes(to as OrderStatus)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write updated `order-status-labels.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/** Человекочитаемые подписи к кодам статуса заказа */
|
||||||
|
export function orderStatusLabelRu(code: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
DRAFT: 'Черновик',
|
||||||
|
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||||
|
PAID: 'Оплачен',
|
||||||
|
IN_PROGRESS: 'В работе',
|
||||||
|
SHIPPED: 'Отправлен',
|
||||||
|
READY_FOR_PICKUP: 'Готово к получению',
|
||||||
|
DONE: 'Завершён',
|
||||||
|
CANCELLED: 'Отменён',
|
||||||
|
}
|
||||||
|
return map[code] ?? code
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run client lint and typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: ESLint passes. TypeScript may show errors in files that still reference removed statuses — those will be fixed in later tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Update server checkout — remove `DELIVERY_FEE_ADJUSTMENT` initial status
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/routes/user-orders.js:103-106`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write test for checkout status logic**
|
||||||
|
|
||||||
|
Create `server/src/routes/__tests__/user-orders-checkout.test.js`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { prisma } from '../../lib/prisma.js'
|
||||||
|
|
||||||
|
describe('checkout initial status', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.cartItem.deleteMany()
|
||||||
|
await prisma.orderItem.deleteMany()
|
||||||
|
await prisma.order.deleteMany()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await prisma.cartItem.deleteMany()
|
||||||
|
await prisma.orderItem.deleteMany()
|
||||||
|
await prisma.order.deleteMany()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('delivery + online → PENDING_PAYMENT with deliveryFeeLocked=false', async () => {
|
||||||
|
// This is an integration-style test checking the status logic
|
||||||
|
// The actual checkout requires full auth setup, so we verify the status assignment logic directly
|
||||||
|
const paymentMethod = 'online'
|
||||||
|
const deliveryType = 'delivery'
|
||||||
|
|
||||||
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
|
let deliveryFeeLocked = false
|
||||||
|
|
||||||
|
if (paymentMethod === 'on_pickup') {
|
||||||
|
initialStatus = 'IN_PROGRESS'
|
||||||
|
} else if (deliveryType === 'delivery') {
|
||||||
|
initialStatus = 'PENDING_PAYMENT'
|
||||||
|
deliveryFeeLocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(initialStatus).toBe('PENDING_PAYMENT')
|
||||||
|
expect(deliveryFeeLocked).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pickup + online → PENDING_PAYMENT with deliveryFeeLocked=true', async () => {
|
||||||
|
const paymentMethod = 'online'
|
||||||
|
const deliveryType = 'pickup'
|
||||||
|
|
||||||
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
|
let deliveryFeeLocked = true
|
||||||
|
|
||||||
|
if (paymentMethod === 'on_pickup') {
|
||||||
|
initialStatus = 'IN_PROGRESS'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(initialStatus).toBe('PENDING_PAYMENT')
|
||||||
|
expect(deliveryFeeLocked).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pickup + on_pickup → IN_PROGRESS', async () => {
|
||||||
|
const paymentMethod = 'on_pickup'
|
||||||
|
const deliveryType = 'pickup'
|
||||||
|
|
||||||
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
|
|
||||||
|
if (paymentMethod === 'on_pickup') {
|
||||||
|
initialStatus = 'IN_PROGRESS'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(initialStatus).toBe('IN_PROGRESS')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it passes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test -- --run src/routes/__tests__/user-orders-checkout.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update checkout logic**
|
||||||
|
|
||||||
|
In `server/src/routes/user-orders.js`, replace lines 103-106:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```js
|
||||||
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
|
if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
|
||||||
|
else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```js
|
||||||
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
|
let deliveryFeeLocked = true
|
||||||
|
if (paymentMethod === 'on_pickup') {
|
||||||
|
initialStatus = 'IN_PROGRESS'
|
||||||
|
} else if (deliveryType === 'delivery') {
|
||||||
|
initialStatus = 'PENDING_PAYMENT'
|
||||||
|
deliveryFeeLocked = false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add `deliveryFeeLocked` to order creation data**
|
||||||
|
|
||||||
|
In the same file, find the `order = await tx.order.create({ data: { ... } })` block (around line 123-144). Add `deliveryFeeLocked` to the data object:
|
||||||
|
|
||||||
|
**Before (line 126):**
|
||||||
|
```js
|
||||||
|
status: initialStatus,
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```js
|
||||||
|
status: initialStatus,
|
||||||
|
deliveryFeeLocked,
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run server tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Update server payment routes — remove `PAYMENT_VERIFICATION`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/routes/user-payments.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write updated `user-payments.js`**
|
||||||
|
|
||||||
|
Replace the entire file:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { prisma } from '../lib/prisma.js'
|
||||||
|
import { escapeHtml } from '../lib/escape-html.js'
|
||||||
|
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
||||||
|
import { saveImageBufferToUploads } from '../lib/upload-images.js'
|
||||||
|
|
||||||
|
export async function registerUserPaymentRoutes(fastify) {
|
||||||
|
fastify.post(
|
||||||
|
'/api/me/orders/:id/pay',
|
||||||
|
{ 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 paymentMethod = order.paymentMethod ?? 'online'
|
||||||
|
if (paymentMethod === 'on_pickup') {
|
||||||
|
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (order.status !== 'PENDING_PAYMENT') {
|
||||||
|
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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.orderMessage.create({
|
||||||
|
data: {
|
||||||
|
orderId: id,
|
||||||
|
authorType: 'user',
|
||||||
|
text: messageText,
|
||||||
|
attachmentUrl,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, status: 'PENDING_PAYMENT' }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- Removed `DELIVERY_FEE_ADJUSTMENT` check (line 21-28)
|
||||||
|
- Removed `DRAFT → PENDING_PAYMENT` transition (line 31-34)
|
||||||
|
- Removed `PAYMENT_VERIFICATION` check (line 37-39)
|
||||||
|
- Removed `PAYMENT_VERIFICATION` status update (line 112)
|
||||||
|
- Simplified to: only `PENDING_PAYMENT` allowed, stays in `PENDING_PAYMENT` after payment submission
|
||||||
|
- Changed final error message from "Сейчас нельзя выполнить оплату" to be the only guard
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run server tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Update server admin order routes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `server/src/routes/api/admin-orders.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update summary endpoint**
|
||||||
|
|
||||||
|
In line 11, replace the status filter:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```js
|
||||||
|
status: { in: ['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] },
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```js
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update delivery-fee endpoint**
|
||||||
|
|
||||||
|
Replace the entire `PATCH /api/admin/orders/:id/delivery-fee` handler (lines 115-144):
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```js
|
||||||
|
fastify.patch(
|
||||||
|
'/api/admin/orders/:id/delivery-fee',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const feeRaw = request.body?.deliveryFeeCents
|
||||||
|
const parsed =
|
||||||
|
typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||||
|
return reply.code(400).send({ error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)' })
|
||||||
|
}
|
||||||
|
|
||||||
|
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' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCents = existing.itemsSubtotalCents + parsed
|
||||||
|
const updated = await prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
deliveryFeeCents: parsed,
|
||||||
|
totalCents,
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { item: updated }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```js
|
||||||
|
fastify.patch(
|
||||||
|
'/api/admin/orders/:id/delivery-fee',
|
||||||
|
{ preHandler: [fastify.verifyAdmin] },
|
||||||
|
async (request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
const feeRaw = request.body?.deliveryFeeCents
|
||||||
|
const parsed =
|
||||||
|
typeof feeRaw === 'string' ? Number.parseInt(feeRaw, 10) : typeof feeRaw === 'number' ? feeRaw : NaN
|
||||||
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||||
|
return reply.code(400).send({ error: 'deliveryFeeCents должно быть целым числом ≥ 0 (копейки)' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.order.findUnique({ where: { id } })
|
||||||
|
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
|
if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
|
||||||
|
return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCents = existing.itemsSubtotalCents + parsed
|
||||||
|
const updated = await prisma.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
deliveryFeeCents: parsed,
|
||||||
|
totalCents,
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { item: updated }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run server tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Update client admin orders page
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace `DELIVERY_FEE_ADJUSTMENT` UI blocks**
|
||||||
|
|
||||||
|
Find and replace lines 321-334:
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```tsx
|
||||||
|
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||||
|
<Alert severity="info">
|
||||||
|
Укажите итоговую стоимость доставки (₽). После сохранения заказ получит статус «
|
||||||
|
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||||
|
<DeliveryFeeAdjustmentForm
|
||||||
|
key={detail.id}
|
||||||
|
orderId={detail.id}
|
||||||
|
deliveryFeeCents={detail.deliveryFeeCents}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```tsx
|
||||||
|
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||||
|
<Alert severity="info">
|
||||||
|
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||||||
|
<DeliveryFeeAdjustmentForm
|
||||||
|
key={detail.id}
|
||||||
|
orderId={detail.id}
|
||||||
|
deliveryFeeCents={detail.deliveryFeeCents}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `deliveryFeeLocked` to the detail type**
|
||||||
|
|
||||||
|
The `AdminOrderDetailResponse` type in `client/src/entities/order/api/admin-order-api.ts` needs `deliveryFeeLocked`. Add it to the type definition (after line 32):
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```ts
|
||||||
|
deliveryFeeCents: number
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```ts
|
||||||
|
deliveryFeeCents: number
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run client typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Update client payment section
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/features/order-payment/ui/OrderPaymentSection.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write updated component**
|
||||||
|
|
||||||
|
Replace the entire file:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Box from '@mui/material/Box'
|
||||||
|
import Button from '@mui/material/Button'
|
||||||
|
import Typography from '@mui/material/Typography'
|
||||||
|
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||||
|
import { PaymentDialog } from './PaymentDialog'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: string
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
paymentMethod: string | null
|
||||||
|
totalCents: number
|
||||||
|
isPayPending: boolean
|
||||||
|
payError: unknown
|
||||||
|
onPay: (params: { detail: string; receiptFile: File | null }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderPaymentSection({ status, deliveryFeeLocked, paymentMethod, isPayPending, payError, onPay }: Props) {
|
||||||
|
const payOnPickup = (paymentMethod ?? 'online') === 'on_pickup'
|
||||||
|
const [payModalOpen, setPayModalOpen] = useState(false)
|
||||||
|
|
||||||
|
if (payOnPickup) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Оплата
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
Оплата при получении на точке самовывоза (наличные или карта — по договорённости).
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 2, p: 2, bgcolor: 'background.paper' }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Оплата
|
||||||
|
</Typography>
|
||||||
|
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === false && (
|
||||||
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||||
|
Точную стоимость доставки уточняет администратор. Оплата станет доступна после утверждения стоимости.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{status === 'PENDING_PAYMENT' && deliveryFeeLocked === true && (
|
||||||
|
<>
|
||||||
|
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||||
|
После перевода подтвердите оплату — откроется форма для комментария и фото чека. Заказ получит статус «
|
||||||
|
{orderStatusLabelRu('PAID')}».
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" onClick={() => setPayModalOpen(true)}>
|
||||||
|
Оплатить
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status !== 'PENDING_PAYMENT' && (
|
||||||
|
<Typography color="text.secondary" variant="body2">
|
||||||
|
На этом этапе действий по оплате в этом блоке не требуется.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaymentDialog
|
||||||
|
open={payModalOpen}
|
||||||
|
isPending={isPayPending}
|
||||||
|
error={payError}
|
||||||
|
onClose={() => setPayModalOpen(false)}
|
||||||
|
onSubmit={(params) => {
|
||||||
|
onPay(params)
|
||||||
|
setPayModalOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes:
|
||||||
|
- Added `deliveryFeeLocked` to Props type
|
||||||
|
- Replaced `DELIVERY_FEE_ADJUSTMENT` check with `PENDING_PAYMENT && deliveryFeeLocked === false`
|
||||||
|
- Replaced `PENDING_PAYMENT` check with `PENDING_PAYMENT && deliveryFeeLocked === true`
|
||||||
|
- Removed `PAYMENT_VERIFICATION` block
|
||||||
|
- Changed "PAYMENT_VERIFICATION" label to "PAID" in the message
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update callers of `OrderPaymentSection`**
|
||||||
|
|
||||||
|
Find where `OrderPaymentSection` is used (in `OrderDetailPage.tsx`) and add `deliveryFeeLocked` prop.
|
||||||
|
|
||||||
|
In `client/src/pages/me/ui/sections/OrderDetailPage.tsx`, find the `OrderPaymentSection` usage and add:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
deliveryFeeLocked={order.deliveryFeeLocked}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the `OrderDetailResponse` type in `client/src/entities/order/api/order-api.ts` to include `deliveryFeeLocked`:
|
||||||
|
|
||||||
|
Add after line 26:
|
||||||
|
```ts
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run client typecheck and lint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Update client admin order API
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/entities/order/api/admin-order-api.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `deliveryFeeLocked` to type**
|
||||||
|
|
||||||
|
Add to `AdminOrderDetailResponse.item` (after `deliveryFeeCents`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
deliveryFeeCents: number
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run client typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Run full verification
|
||||||
|
|
||||||
|
**Files:** All
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run server tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: All tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run client lint**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run client format check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run client typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npx tsc -b
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: No errors.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run client build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Build succeeds.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit all changes**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "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
|
||||||
|
- 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
|
||||||
|
- Migrate existing orders to new status model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**1. Spec coverage:**
|
||||||
|
- ✅ Remove `DELIVERY_FEE_ADJUSTMENT` — Tasks 2, 3, 4, 5, 6, 7, 8, 9, 11
|
||||||
|
- ✅ Remove `PAYMENT_VERIFICATION` — Tasks 2, 3, 4, 6, 9, 11
|
||||||
|
- ✅ Add `deliveryFeeLocked` field — Tasks 1, 5, 7, 8, 9, 10
|
||||||
|
- ✅ Update checkout logic — Task 5
|
||||||
|
- ✅ Update payment flow — Task 6
|
||||||
|
- ✅ Update admin routes — Task 7
|
||||||
|
- ✅ Update admin UI — Task 8
|
||||||
|
- ✅ Update client payment UI — Task 9
|
||||||
|
- ✅ Migrate existing data — Task 11
|
||||||
|
|
||||||
|
**2. Placeholder scan:** No TBD, TODO, or incomplete sections. All steps contain actual code.
|
||||||
|
|
||||||
|
**3. Type consistency:** `deliveryFeeLocked: boolean` used consistently across all files. `AdminOrderDetailResponse` and `OrderDetailResponse` both updated. All status references use the new 8-status list.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Дизайн: Упрощение статусной модели заказов (A+B)
|
||||||
|
|
||||||
|
**Дата:** 2026-05-15
|
||||||
|
**Статус:** Утверждено
|
||||||
|
|
||||||
|
## Проблема
|
||||||
|
|
||||||
|
Текущая модель заказов содержит 11 статусов, из которых два (`DELIVERY_FEE_ADJUSTMENT`, `PAYMENT_VERIFICATION`) являются промежуточными и могут быть заменены флагами/логикой.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Убрать 2 статуса, добавить флаг `deliveryFeeLocked`. Итого: 9 → 8 статусов (было 11).
|
||||||
|
|
||||||
|
### Удалённые статусы
|
||||||
|
|
||||||
|
| Было | Чем заменяется |
|
||||||
|
|---|---|
|
||||||
|
| `DELIVERY_FEE_ADJUSTMENT` | Флаг `deliveryFeeLocked: Boolean` на заказе |
|
||||||
|
| `PAYMENT_VERIFICATION` | Заказ остаётся в `PENDING_PAYMENT` до ручного подтверждения |
|
||||||
|
|
||||||
|
### Новая статусная модель
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT ──→ PENDING_PAYMENT ──→ PAID ──→ IN_PROGRESS ──┬─→ SHIPPED ──────────┐
|
||||||
|
│ │
|
||||||
|
└─→ READY_FOR_PICKUP ──┤
|
||||||
|
▼
|
||||||
|
DONE
|
||||||
|
|
||||||
|
CANCELLED ←── (из любого статуса, кроме DONE и CANCELLED)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Логика `deliveryFeeLocked`
|
||||||
|
|
||||||
|
| Значение | Админ | Клиент |
|
||||||
|
|---|---|---|
|
||||||
|
| `false` | Может менять `deliveryFeeCents` | Видит "ожидает утверждения доставки", оплата недоступна |
|
||||||
|
| `true` | Не может менять доставку | Видит кнопку оплаты |
|
||||||
|
|
||||||
|
### Переходы (admin)
|
||||||
|
|
||||||
|
| Из | В |
|
||||||
|
|---|---|
|
||||||
|
| `DRAFT` | `PENDING_PAYMENT`, `CANCELLED` |
|
||||||
|
| `PENDING_PAYMENT` | `PAID`, `CANCELLED` |
|
||||||
|
| `PAID` | `IN_PROGRESS`, `CANCELLED` |
|
||||||
|
| `IN_PROGRESS` (delivery) | `SHIPPED`, `CANCELLED` |
|
||||||
|
| `IN_PROGRESS` (pickup) | `READY_FOR_PICKUP`, `CANCELLED` |
|
||||||
|
| `SHIPPED` | *(нет)* |
|
||||||
|
| `READY_FOR_PICKUP` | *(нет)* |
|
||||||
|
| `DONE` | *(нет)* |
|
||||||
|
| `CANCELLED` | *(нет)* |
|
||||||
|
|
||||||
|
### Миграция данных
|
||||||
|
|
||||||
|
Все заказы в статусе `DELIVERY_FEE_ADJUSTMENT` → `PENDING_PAYMENT` + `deliveryFeeLocked: false`.
|
||||||
|
Все заказы в статусе `PAYMENT_VERIFICATION` → `PENDING_PAYMENT`.
|
||||||
|
|
||||||
|
### Изменяемые файлы
|
||||||
|
|
||||||
|
1. `server/prisma/schema.prisma` — добавить `deliveryFeeLocked Boolean @default(false)`
|
||||||
|
2. `shared/constants/order-status.js` — убрать 2 статуса
|
||||||
|
3. `shared/constants/order-status.d.ts` — убрать 2 статуса
|
||||||
|
4. `server/src/lib/order-status.js` — обновить `canTransitionAdminOrderStatus`
|
||||||
|
5. `client/src/shared/constants/order.ts` — обновить `getAdminNextOrderStatuses`
|
||||||
|
6. `client/src/shared/lib/order-status-labels.ts` — убрать подписи
|
||||||
|
7. `server/src/routes/user-orders.js` — убрать `DELIVERY_FEE_ADJUSTMENT`
|
||||||
|
8. `server/src/routes/user-payments.js` — убрать `PAYMENT_VERIFICATION`
|
||||||
|
9. `server/src/routes/api/admin-orders.js` — добавить эндпоинт `deliveryFeeLocked`, убрать проверки
|
||||||
|
10. `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx` — обновить UI
|
||||||
|
11. `client/src/features/order-payment/ui/OrderPaymentSection.tsx` — обновить UI
|
||||||
|
12. `server/src/lib/__tests__/order-status.test.js` — обновить тесты
|
||||||
@@ -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())
|
id String @id @default(cuid())
|
||||||
/// Статус заказа (валидация переходов на уровне API)
|
/// Статус заказа (валидация переходов на уровне API)
|
||||||
status String @default("DRAFT")
|
status String @default("DRAFT")
|
||||||
|
deliveryFeeLocked Boolean @default(false)
|
||||||
/// 'delivery' | 'pickup'
|
/// 'delivery' | 'pickup'
|
||||||
deliveryType String @default("delivery")
|
deliveryType String @default("delivery")
|
||||||
/// RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST при deliveryType=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)
|
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', () => {
|
it('PAID → IN_PROGRESS', () => {
|
||||||
expect(canTransitionAdminOrderStatus({ status: 'PAID', ...delivery }, 'IN_PROGRESS')).toBe(true)
|
expect(canTransitionAdminOrderStatus({ status: 'PAID', ...delivery }, 'IN_PROGRESS')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,11 +12,7 @@ export function canTransitionAdminOrderStatus(order, next) {
|
|||||||
switch (from) {
|
switch (from) {
|
||||||
case 'DRAFT':
|
case 'DRAFT':
|
||||||
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
|
return next === 'PENDING_PAYMENT' || next === 'CANCELLED'
|
||||||
case 'DELIVERY_FEE_ADJUSTMENT':
|
|
||||||
return next === 'CANCELLED'
|
|
||||||
case 'PENDING_PAYMENT':
|
case 'PENDING_PAYMENT':
|
||||||
return next === 'CANCELLED'
|
|
||||||
case 'PAYMENT_VERIFICATION':
|
|
||||||
return next === 'PAID' || next === 'CANCELLED'
|
return next === 'PAID' || next === 'CANCELLED'
|
||||||
case 'PAID':
|
case 'PAID':
|
||||||
return next === 'IN_PROGRESS' || next === 'CANCELLED'
|
return next === 'IN_PROGRESS' || next === 'CANCELLED'
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
async () => {
|
async () => {
|
||||||
const attentionCount = await prisma.order.count({
|
const attentionCount = await prisma.order.count({
|
||||||
where: {
|
where: {
|
||||||
status: { in: ['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] },
|
status: 'PENDING_PAYMENT',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { attentionCount }
|
return { attentionCount }
|
||||||
@@ -126,8 +126,8 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
|
|
||||||
const existing = await prisma.order.findUnique({ where: { id } })
|
const existing = await prisma.order.findUnique({ where: { id } })
|
||||||
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
if (!existing) return reply.code(404).send({ error: 'Заказ не найден' })
|
||||||
if (existing.status !== 'DELIVERY_FEE_ADJUSTMENT') {
|
if (existing.status !== 'PENDING_PAYMENT' || existing.deliveryFeeLocked !== false) {
|
||||||
return reply.code(409).send({ error: 'Корректировка доставки доступна только в статусе DELIVERY_FEE_ADJUSTMENT' })
|
return reply.code(409).send({ error: 'Корректировка доставки доступна только пока стоимость не утверждена' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCents = existing.itemsSubtotalCents + parsed
|
const totalCents = existing.itemsSubtotalCents + parsed
|
||||||
@@ -136,7 +136,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
|||||||
data: {
|
data: {
|
||||||
deliveryFeeCents: parsed,
|
deliveryFeeCents: parsed,
|
||||||
totalCents,
|
totalCents,
|
||||||
status: 'PENDING_PAYMENT',
|
deliveryFeeLocked: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return { item: updated }
|
return { item: updated }
|
||||||
|
|||||||
@@ -101,8 +101,13 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
let initialStatus = 'PENDING_PAYMENT'
|
let initialStatus = 'PENDING_PAYMENT'
|
||||||
if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
|
let deliveryFeeLocked = true
|
||||||
else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
|
if (paymentMethod === 'on_pickup') {
|
||||||
|
initialStatus = 'IN_PROGRESS'
|
||||||
|
} else if (deliveryType === 'delivery') {
|
||||||
|
initialStatus = 'PENDING_PAYMENT'
|
||||||
|
deliveryFeeLocked = false
|
||||||
|
}
|
||||||
|
|
||||||
let created
|
let created
|
||||||
try {
|
try {
|
||||||
@@ -124,6 +129,7 @@ export async function registerUserOrderRoutes(fastify) {
|
|||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
status: initialStatus,
|
status: initialStatus,
|
||||||
|
deliveryFeeLocked,
|
||||||
deliveryType,
|
deliveryType,
|
||||||
deliveryCarrier,
|
deliveryCarrier,
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
|
|||||||
@@ -18,115 +18,94 @@ export async function registerUserPaymentRoutes(fastify) {
|
|||||||
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
|
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
|
return reply
|
||||||
.code(409)
|
.code(400)
|
||||||
.send({
|
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
|
||||||
error:
|
|
||||||
'Оплата станет доступна после корректировки стоимости доставки администратором.',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextStatus = order.status
|
let detail = ''
|
||||||
if (order.status === 'DRAFT') {
|
let receiptBuffer = null
|
||||||
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
|
let receiptFilename = ''
|
||||||
nextStatus = 'PENDING_PAYMENT'
|
try {
|
||||||
return { ok: true, status: nextStatus }
|
const otherLimit = getOtherUploadMaxFileBytes()
|
||||||
}
|
const parts = request.parts({
|
||||||
|
limits: {
|
||||||
if (order.status === 'PAYMENT_VERIFICATION') {
|
fileSize: otherLimit,
|
||||||
return { ok: true, status: nextStatus }
|
files: 2,
|
||||||
}
|
},
|
||||||
|
})
|
||||||
if (order.status === 'PENDING_PAYMENT') {
|
for await (const part of parts) {
|
||||||
if (!request.isMultipart()) {
|
if (part.file) {
|
||||||
return reply
|
if (part.fieldname === 'receipt') {
|
||||||
.code(400)
|
if (receiptBuffer !== null) {
|
||||||
.send({ error: 'Отправьте multipart/form-data: поле detail и/или файл receipt' })
|
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 = ''
|
const hasDetail = detail.length > 0
|
||||||
let receiptBuffer = null
|
const hasReceipt = receiptBuffer !== null && receiptBuffer.length > 0
|
||||||
let receiptFilename = ''
|
|
||||||
|
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 {
|
try {
|
||||||
const otherLimit = getOtherUploadMaxFileBytes()
|
attachmentUrl = await saveImageBufferToUploads(receiptFilename, receiptBuffer)
|
||||||
const parts = request.parts({
|
} catch (err) {
|
||||||
limits: {
|
const message = err instanceof Error ? err.message : 'Не удалось сохранить файл'
|
||||||
fileSize: otherLimit,
|
const statusCode =
|
||||||
files: 2,
|
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) {
|
} catch (err) {
|
||||||
if (part.fieldname === 'receipt') {
|
return reply.code(500).send({ error: 'Не удалось сохранить оплату' })
|
||||||
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' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.code(409).send({ error: 'Сейчас нельзя выполнить оплату для этого заказа' })
|
return { ok: true, status: 'PENDING_PAYMENT' }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
-2
@@ -1,8 +1,6 @@
|
|||||||
export declare const ORDER_STATUSES: readonly [
|
export declare const ORDER_STATUSES: readonly [
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'DELIVERY_FEE_ADJUSTMENT',
|
|
||||||
'PENDING_PAYMENT',
|
'PENDING_PAYMENT',
|
||||||
'PAYMENT_VERIFICATION',
|
|
||||||
'PAID',
|
'PAID',
|
||||||
'IN_PROGRESS',
|
'IN_PROGRESS',
|
||||||
'SHIPPED',
|
'SHIPPED',
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
export const ORDER_STATUSES = Object.freeze([
|
export const ORDER_STATUSES = Object.freeze([
|
||||||
'DRAFT',
|
'DRAFT',
|
||||||
'DELIVERY_FEE_ADJUSTMENT',
|
|
||||||
'PENDING_PAYMENT',
|
'PENDING_PAYMENT',
|
||||||
'PAYMENT_VERIFICATION',
|
|
||||||
'PAID',
|
'PAID',
|
||||||
'IN_PROGRESS',
|
'IN_PROGRESS',
|
||||||
'SHIPPED',
|
'SHIPPED',
|
||||||
|
|||||||
Reference in New Issue
Block a user