deploy
This commit is contained in:
@@ -4,6 +4,7 @@ export type AdminOrderListItem = {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: string | null
|
||||
paymentMethod?: 'online' | 'on_pickup'
|
||||
totalCents: number
|
||||
currency: string
|
||||
@@ -25,6 +26,7 @@ export type AdminOrderDetailResponse = {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: string | null
|
||||
paymentMethod?: 'online' | 'on_pickup'
|
||||
itemsSubtotalCents: number
|
||||
deliveryFeeCents: number
|
||||
@@ -77,6 +79,10 @@ export async function setAdminOrderStatus(id: string, status: string): Promise<v
|
||||
await apiClient.patch(`admin/orders/${id}/status`, { status })
|
||||
}
|
||||
|
||||
export async function patchAdminOrderDeliveryFee(id: string, deliveryFeeCents: number): Promise<void> {
|
||||
await apiClient.patch(`admin/orders/${id}/delivery-fee`, { deliveryFeeCents })
|
||||
}
|
||||
|
||||
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
|
||||
await apiClient.post(`admin/orders/${id}/messages`, { text })
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiClient } from '@/shared/api/client'
|
||||
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
|
||||
|
||||
export type OrderListItem = {
|
||||
id: string
|
||||
@@ -19,6 +20,7 @@ export type OrderDetailResponse = {
|
||||
id: string
|
||||
status: string
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: DeliveryCarrierCode | null
|
||||
paymentMethod?: OrderPaymentMethod
|
||||
itemsSubtotalCents: number
|
||||
deliveryFeeCents: number
|
||||
@@ -47,6 +49,7 @@ export type OrderDetailResponse = {
|
||||
|
||||
export async function createOrder(body: {
|
||||
deliveryType: 'delivery' | 'pickup'
|
||||
deliveryCarrier?: DeliveryCarrierCode | null
|
||||
paymentMethod?: OrderPaymentMethod
|
||||
addressId?: string | null
|
||||
comment?: string | null
|
||||
|
||||
@@ -22,18 +22,63 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
fetchAdminOrder,
|
||||
fetchAdminOrders,
|
||||
patchAdminOrderDeliveryFee,
|
||||
postAdminOrderMessage,
|
||||
setAdminOrderStatus,
|
||||
} from '@/entities/order/api/admin-order-api'
|
||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
|
||||
function DeliveryFeeAdjustmentForm({ orderId, deliveryFeeCents }: { orderId: string; deliveryFeeCents: number }) {
|
||||
const qc = useQueryClient()
|
||||
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
|
||||
const feeMut = useMutation({
|
||||
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Доставка, ₽"
|
||||
type="number"
|
||||
value={rub}
|
||||
onChange={(e) => setRub(e.target.value)}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
feeMut.isPending ||
|
||||
!rub.trim() ||
|
||||
!Number.isFinite(Number.parseFloat(rub)) ||
|
||||
Number.parseFloat(rub) < 0 ||
|
||||
!Number.isInteger(Number.parseFloat(rub))
|
||||
}
|
||||
onClick={() => feeMut.mutate()}
|
||||
>
|
||||
Утвердить доставку и открыть оплату
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
export function AdminOrdersPage() {
|
||||
const qc = useQueryClient()
|
||||
const [q, setQ] = useState('')
|
||||
@@ -96,6 +141,11 @@ export function AdminOrdersPage() {
|
||||
const detail = orderDetailQuery.data?.item
|
||||
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||||
|
||||
const deliverySnapshot = useMemo(
|
||||
() => (detail?.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
|
||||
[detail],
|
||||
)
|
||||
|
||||
const nextStatuses = useMemo(() => {
|
||||
if (!detail) return []
|
||||
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
|
||||
@@ -211,8 +261,78 @@ export function AdminOrdersPage() {
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
||||
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
||||
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
||||
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
{detail.deliveryType === 'delivery' && (
|
||||
<Box
|
||||
sx={{
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
bgcolor: 'action.hover',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
Адрес и получатель (на момент заказа)
|
||||
</Typography>
|
||||
{deliverySnapshot ? (
|
||||
<Stack spacing={0.75}>
|
||||
{deliverySnapshot.label?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Метка: {deliverySnapshot.label}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Адрес:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.addressLine ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Получатель:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientName ?? '—'}
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||||
Телефон:
|
||||
</Box>{' '}
|
||||
{deliverySnapshot.recipientPhone ?? '—'}
|
||||
</Typography>
|
||||
{deliverySnapshot.comment?.trim() && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Комментарий к адресу: {deliverySnapshot.comment}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
Данные адреса в заказе отсутствуют или не распознаны.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||
import { fetchMyCart } from '@/entities/cart/api/cart-api'
|
||||
import { createOrder } from '@/entities/order/api/order-api'
|
||||
import { fetchMyAddresses } from '@/entities/user/api/address-api'
|
||||
import { DELIVERY_CARRIER_OPTIONS, type DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { $user } from '@/shared/model/auth'
|
||||
|
||||
@@ -28,6 +29,7 @@ export function CheckoutPage() {
|
||||
const navigate = useNavigate()
|
||||
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
|
||||
const [pickupPayment, setPickupPayment] = useState<'online' | 'on_pickup'>('online')
|
||||
const [deliveryCarrier, setDeliveryCarrier] = useState<DeliveryCarrierCode>('RUSSIAN_POST')
|
||||
const [addressId, setAddressId] = useState('')
|
||||
const [comment, setComment] = useState('')
|
||||
|
||||
@@ -50,6 +52,7 @@ export function CheckoutPage() {
|
||||
mutationFn: () =>
|
||||
createOrder({
|
||||
deliveryType,
|
||||
deliveryCarrier: deliveryType === 'delivery' ? deliveryCarrier : null,
|
||||
paymentMethod: deliveryType === 'delivery' ? 'online' : pickupPayment,
|
||||
addressId: deliveryType === 'delivery' ? selectedAddressId : null,
|
||||
comment: comment.trim() || null,
|
||||
@@ -74,9 +77,7 @@ export function CheckoutPage() {
|
||||
|
||||
const items = cartQuery.data?.items ?? []
|
||||
const itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
|
||||
const totalQty = items.reduce((s, x) => s + x.qty, 0)
|
||||
const deliveryFeeCents =
|
||||
deliveryType === 'delivery' && items.length > 0 ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
|
||||
const deliveryFeeCents = deliveryType === 'delivery' && items.length > 0 ? 50000 : 0
|
||||
const total = itemsSubtotalCents + deliveryFeeCents
|
||||
const addresses = addressesQuery.data?.items ?? []
|
||||
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
|
||||
@@ -153,6 +154,23 @@ export function CheckoutPage() {
|
||||
|
||||
{deliveryType === 'delivery' && (
|
||||
<>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 0.5 }}>
|
||||
Служба доставки
|
||||
</Typography>
|
||||
<RadioGroup
|
||||
value={deliveryCarrier}
|
||||
onChange={(e) => {
|
||||
const v = String(e.target.value) as DeliveryCarrierCode
|
||||
setDeliveryCarrier(v)
|
||||
}}
|
||||
>
|
||||
{DELIVERY_CARRIER_OPTIONS.map((o) => (
|
||||
<FormControlLabel key={o.code} value={o.code} control={<Radio size="small" />} label={o.label} />
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<FormControl size="small" fullWidth>
|
||||
<InputLabel id="addr-label">Адрес доставки</InputLabel>
|
||||
<Select
|
||||
@@ -180,13 +198,8 @@ export function CheckoutPage() {
|
||||
)}
|
||||
|
||||
<Alert severity="info">
|
||||
Стоимость доставки: 500 ₽ за каждые 2 единицы (минимум 500 ₽).
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
В этом заказе: {totalQty} шт. → доставка {formatPriceRub(deliveryFeeCents)}.
|
||||
</>
|
||||
)}
|
||||
Стоимость доставки ориентировочно 300 ₽. Точная цена будет скорректирована после расчёта. В сумме заказа
|
||||
сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
} from '@/entities/order/api/order-api'
|
||||
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
|
||||
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
|
||||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||
import { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
|
||||
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
|
||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||||
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
@@ -62,15 +64,6 @@ function reviewSubmitErrorMessage(err: unknown): string {
|
||||
return 'Не удалось отправить отзыв'
|
||||
}
|
||||
|
||||
type AddressSnapshot = {
|
||||
deliveryType?: 'delivery' | 'pickup'
|
||||
label?: string | null
|
||||
recipientName?: string
|
||||
recipientPhone?: string
|
||||
addressLine?: string
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
export function OrderDetailPage() {
|
||||
const { id } = useParams()
|
||||
const qc = useQueryClient()
|
||||
@@ -180,14 +173,7 @@ export function OrderDetailPage() {
|
||||
})()
|
||||
}, [id, order, orderQuery.status, qc])
|
||||
|
||||
const address = useMemo((): AddressSnapshot | null => {
|
||||
if (!order?.addressSnapshotJson) return null
|
||||
try {
|
||||
return JSON.parse(order.addressSnapshotJson) as AddressSnapshot
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [order])
|
||||
const address = useMemo(() => parseOrderAddressSnapshot(order?.addressSnapshotJson), [order?.addressSnapshotJson])
|
||||
|
||||
if (!id) return <Alert severity="error">Некорректный заказ.</Alert>
|
||||
if (orderQuery.isLoading) return <Typography>Загрузка…</Typography>
|
||||
@@ -245,6 +231,11 @@ export function OrderDetailPage() {
|
||||
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
|
||||
{order.deliveryType === 'pickup' && <> · оплата: {payOnPickup ? 'при получении' : 'онлайн'}</>}
|
||||
</Typography>
|
||||
{order.deliveryType === 'delivery' && deliveryCarrierLabelRu(order.deliveryCarrier) && (
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
Служба: {deliveryCarrierLabelRu(order.deliveryCarrier)}
|
||||
</Typography>
|
||||
)}
|
||||
{order.deliveryType === 'delivery' && (
|
||||
<>
|
||||
{address ? (
|
||||
@@ -298,6 +289,12 @@ export function OrderDetailPage() {
|
||||
</Typography>
|
||||
) : (
|
||||
<>
|
||||
{order.status === 'DELIVERY_FEE_ADJUSTMENT' && (
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в
|
||||
статус «{orderStatusLabelRu('PENDING_PAYMENT')}».
|
||||
</Typography>
|
||||
)}
|
||||
{order.status === 'PENDING_PAYMENT' && (
|
||||
<>
|
||||
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
|
||||
@@ -320,7 +317,7 @@ export function OrderDetailPage() {
|
||||
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
|
||||
</Typography>
|
||||
)}
|
||||
{!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
|
||||
{!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
На этом этапе действий по оплате в этом блоке не требуется.
|
||||
</Typography>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const DELIVERY_CARRIER_CODES = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] as const
|
||||
|
||||
export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
|
||||
|
||||
/** Варианты для формы чекаута (код → подпись). */
|
||||
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
|
||||
{ code: 'RUSSIAN_POST', label: 'Почта России' },
|
||||
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
|
||||
{ code: 'YANDEX_PVZ', label: 'Яндекс доставка (пункт выдачи)' },
|
||||
{ code: 'FIVE_POST', label: '5Post (пункт выдачи)' },
|
||||
]
|
||||
|
||||
const carrierLabelMap: Record<DeliveryCarrierCode, string> = Object.fromEntries(
|
||||
DELIVERY_CARRIER_OPTIONS.map((o) => [o.code, o.label]),
|
||||
) as Record<DeliveryCarrierCode, string>
|
||||
|
||||
export function deliveryCarrierLabelRu(code: string | null | undefined): string | null {
|
||||
if (!code) return null
|
||||
return carrierLabelMap[code as DeliveryCarrierCode] ?? code
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ORDER_STATUSES = [
|
||||
'DRAFT',
|
||||
'DELIVERY_FEE_ADJUSTMENT',
|
||||
'PENDING_PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'PAID',
|
||||
@@ -17,6 +18,8 @@ export function getAdminNextOrderStatuses(status: string, deliveryType: 'deliver
|
||||
switch (status) {
|
||||
case 'DRAFT':
|
||||
return ['PENDING_PAYMENT', 'CANCELLED']
|
||||
case 'DELIVERY_FEE_ADJUSTMENT':
|
||||
return ['CANCELLED']
|
||||
case 'PENDING_PAYMENT':
|
||||
return ['CANCELLED']
|
||||
case 'PAYMENT_VERIFICATION':
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Снимок адреса из addressSnapshotJson при оформлении заказа */
|
||||
export type OrderAddressSnapshot = {
|
||||
deliveryType?: 'delivery' | 'pickup'
|
||||
label?: string | null
|
||||
recipientName?: string
|
||||
recipientPhone?: string
|
||||
addressLine?: string
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
export function parseOrderAddressSnapshot(json: string | null | undefined): OrderAddressSnapshot | null {
|
||||
if (!json) return null
|
||||
try {
|
||||
return JSON.parse(json) as OrderAddressSnapshot
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
export function orderStatusLabelRu(code: string): string {
|
||||
const map: Record<string, string> = {
|
||||
DRAFT: 'Черновик',
|
||||
DELIVERY_FEE_ADJUSTMENT: 'Корректировка стоимости доставки',
|
||||
PENDING_PAYMENT: 'Ожидает оплаты',
|
||||
PAYMENT_VERIFICATION: 'Проверка оплаты',
|
||||
PAID: 'Оплачен',
|
||||
|
||||
@@ -169,7 +169,11 @@ deploy_backend() {
|
||||
echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
|
||||
remote_exec bash -lc "$DEPLOY_RESTART_CMD"
|
||||
elif [[ -z "$DRY_RUN" ]]; then
|
||||
echo "(подсказка) задайте DEPLOY_RESTART_CMD, если нужен перезапуск сервиса"
|
||||
echo "" >&2
|
||||
echo "ВНИМАНИЕ: код сервера обновлён, но процесс Node не перезапущен." >&2
|
||||
echo " Без рестарта новые маршруты (и правки API) не появятся." >&2
|
||||
echo " Задайте в deploy.env: DEPLOY_RESTART_CMD='systemctl restart <ваш-сервис-api>'" >&2
|
||||
echo "" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
DATABASE_URL="file:./dev.db"
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
# Default code for login
|
||||
DEFAULT_CODE=123456
|
||||
IS_DEFAULT_CODE_ENABLED=true
|
||||
PORT=3333
|
||||
# Токен для админ-запросов с фронта: Authorization: Bearer <значение>
|
||||
ADMIN_API_TOKEN=dev-secret-change-me
|
||||
# JWT для пользовательской авторизации (замени в проде)
|
||||
JWT_SECRET=dev-jwt-secret-change-me
|
||||
# Опционально: список origin для CORS через запятую (в dev можно не задавать)
|
||||
# CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# SMTP для отправки кода (если не задано — код логируется в консоль как [DEV])
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_SECURE=false
|
||||
# SMTP_USER=user@example.com
|
||||
# SMTP_PASS=password
|
||||
# MAIL_FROM="Craftshop <no-reply@example.com>"
|
||||
+1
-2
@@ -4,10 +4,9 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --env-file=.dev_env --watch src/index.js",
|
||||
"dev": "node --env-file=.env --watch src/index.js",
|
||||
"dev:classic": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"start:dev_env": "node --env-file=.dev_env src/index.js",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:migrate:test": "node --env-file=.dev_env ./node_modules/prisma/build/index.js migrate deploy",
|
||||
"db:seed": "prisma db seed",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Order" ADD COLUMN "deliveryCarrier" TEXT;
|
||||
@@ -116,6 +116,8 @@ model Order {
|
||||
status String @default("DRAFT")
|
||||
/// 'delivery' | 'pickup'
|
||||
deliveryType String @default("delivery")
|
||||
/// RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST при deliveryType=delivery
|
||||
deliveryCarrier String?
|
||||
/// 'online' | 'on_pickup' — способ расчёта для заказа
|
||||
paymentMethod String @default("online")
|
||||
itemsSubtotalCents Int @default(0)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export const DELIVERY_CARRIERS = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST']
|
||||
|
||||
/**
|
||||
* @param {unknown} value
|
||||
* @returns {value is typeof DELIVERY_CARRIERS[number]}
|
||||
*/
|
||||
export function isDeliveryCarrier(value) {
|
||||
return typeof value === 'string' && DELIVERY_CARRIERS.includes(value)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const ORDER_STATUSES = [
|
||||
'DRAFT',
|
||||
'DELIVERY_FEE_ADJUSTMENT',
|
||||
'PENDING_PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'PAID',
|
||||
@@ -22,6 +23,8 @@ 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':
|
||||
|
||||
@@ -7,7 +7,9 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
async () => {
|
||||
const attentionCount = await prisma.order.count({
|
||||
where: { status: { in: ['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] } },
|
||||
where: {
|
||||
status: { in: ['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'] },
|
||||
},
|
||||
})
|
||||
return { attentionCount }
|
||||
},
|
||||
@@ -57,6 +59,7 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
id: o.id,
|
||||
status: o.status,
|
||||
deliveryType: o.deliveryType,
|
||||
deliveryCarrier: o.deliveryCarrier,
|
||||
paymentMethod: o.paymentMethod,
|
||||
totalCents: o.totalCents,
|
||||
currency: o.currency,
|
||||
@@ -109,6 +112,37 @@ export async function registerAdminOrderRoutes(fastify) {
|
||||
},
|
||||
)
|
||||
|
||||
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 }
|
||||
},
|
||||
)
|
||||
|
||||
fastify.post(
|
||||
'/api/admin/orders/:id/messages',
|
||||
{ preHandler: [fastify.verifyAdmin] },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
|
||||
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
|
||||
import { escapeHtml } from '../lib/escape-html.js'
|
||||
import { prisma } from '../lib/prisma.js'
|
||||
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.js'
|
||||
@@ -436,6 +437,24 @@ export async function registerAuthRoutes(fastify) {
|
||||
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 (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
|
||||
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' })
|
||||
}
|
||||
@@ -468,9 +487,7 @@ export async function registerAuthRoutes(fastify) {
|
||||
}))
|
||||
|
||||
const itemsSubtotalCents = itemsPayload.reduce((sum, i) => sum + i.priceCentsSnapshot * i.qty, 0)
|
||||
const totalQty = itemsPayload.reduce((sum, i) => sum + i.qty, 0)
|
||||
const deliveryFeeCents =
|
||||
deliveryType === 'delivery' ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
|
||||
const deliveryFeeCents = deliveryType === 'delivery' ? 50000 : 0
|
||||
const totalCents = itemsSubtotalCents + deliveryFeeCents
|
||||
|
||||
const addressSnapshotJson =
|
||||
@@ -488,7 +505,9 @@ export async function registerAuthRoutes(fastify) {
|
||||
lng: address.lng,
|
||||
})
|
||||
|
||||
const initialStatus = paymentMethod === 'on_pickup' ? 'IN_PROGRESS' : 'PENDING_PAYMENT'
|
||||
let initialStatus = 'PENDING_PAYMENT'
|
||||
if (paymentMethod === 'on_pickup') initialStatus = 'IN_PROGRESS'
|
||||
else if (deliveryType === 'delivery') initialStatus = 'DELIVERY_FEE_ADJUSTMENT'
|
||||
|
||||
let created
|
||||
try {
|
||||
@@ -511,6 +530,7 @@ export async function registerAuthRoutes(fastify) {
|
||||
userId,
|
||||
status: initialStatus,
|
||||
deliveryType,
|
||||
deliveryCarrier,
|
||||
paymentMethod,
|
||||
itemsSubtotalCents,
|
||||
deliveryFeeCents,
|
||||
@@ -703,6 +723,15 @@ export async function registerAuthRoutes(fastify) {
|
||||
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
|
||||
}
|
||||
|
||||
if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
|
||||
return reply
|
||||
.code(409)
|
||||
.send({
|
||||
error:
|
||||
'Оплата станет доступна после корректировки стоимости доставки администратором.',
|
||||
})
|
||||
}
|
||||
|
||||
let nextStatus = order.status
|
||||
if (order.status === 'DRAFT') {
|
||||
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } })
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 393 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 321 KiB |
Reference in New Issue
Block a user