This commit is contained in:
@kirill.komarov
2026-05-11 15:14:35 +05:00
parent 20096c1eec
commit 4eda6d0f81
20 changed files with 299 additions and 56 deletions
@@ -4,6 +4,7 @@ export type AdminOrderListItem = {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup' paymentMethod?: 'online' | 'on_pickup'
totalCents: number totalCents: number
currency: string currency: string
@@ -25,6 +26,7 @@ export type AdminOrderDetailResponse = {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup' paymentMethod?: 'online' | 'on_pickup'
itemsSubtotalCents: number itemsSubtotalCents: number
deliveryFeeCents: 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 }) 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> { export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`admin/orders/${id}/messages`, { text }) await apiClient.post(`admin/orders/${id}/messages`, { text })
} }
@@ -1,4 +1,5 @@
import { apiClient } from '@/shared/api/client' import { apiClient } from '@/shared/api/client'
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
export type OrderListItem = { export type OrderListItem = {
id: string id: string
@@ -19,6 +20,7 @@ export type OrderDetailResponse = {
id: string id: string
status: string status: string
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod paymentMethod?: OrderPaymentMethod
itemsSubtotalCents: number itemsSubtotalCents: number
deliveryFeeCents: number deliveryFeeCents: number
@@ -47,6 +49,7 @@ export type OrderDetailResponse = {
export async function createOrder(body: { export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup' deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod paymentMethod?: OrderPaymentMethod
addressId?: string | null addressId?: string | null
comment?: string | null comment?: string | null
@@ -22,18 +22,63 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { import {
fetchAdminOrder, fetchAdminOrder,
fetchAdminOrders, fetchAdminOrders,
patchAdminOrderDeliveryFee,
postAdminOrderMessage, postAdminOrderMessage,
setAdminOrderStatus, setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api' } from '@/entities/order/api/admin-order-api'
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order' import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status' import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys' 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 { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor' 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() { export function AdminOrdersPage() {
const qc = useQueryClient() const qc = useQueryClient()
const [q, setQ] = useState('') const [q, setQ] = useState('')
@@ -96,6 +141,11 @@ export function AdminOrdersPage() {
const detail = orderDetailQuery.data?.item const detail = orderDetailQuery.data?.item
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0 const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
const deliverySnapshot = useMemo(
() => (detail?.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
[detail],
)
const nextStatuses = useMemo(() => { const nextStatuses = useMemo(() => {
if (!detail) return [] if (!detail) return []
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery') return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
@@ -211,8 +261,78 @@ export function AdminOrdersPage() {
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'} Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'} {(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
)}
</Typography> </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' } }}> <Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
<FormControl size="small" sx={{ minWidth: 240 }}> <FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel> <InputLabel id="next-status-label">Сменить статус</InputLabel>
+23 -10
View File
@@ -19,6 +19,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { fetchMyCart } from '@/entities/cart/api/cart-api' import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { createOrder } from '@/entities/order/api/order-api' import { createOrder } from '@/entities/order/api/order-api'
import { fetchMyAddresses } from '@/entities/user/api/address-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 { formatPriceRub } from '@/shared/lib/format-price'
import { $user } from '@/shared/model/auth' import { $user } from '@/shared/model/auth'
@@ -28,6 +29,7 @@ export function CheckoutPage() {
const navigate = useNavigate() const navigate = useNavigate()
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery') const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [pickupPayment, setPickupPayment] = useState<'online' | 'on_pickup'>('online') const [pickupPayment, setPickupPayment] = useState<'online' | 'on_pickup'>('online')
const [deliveryCarrier, setDeliveryCarrier] = useState<DeliveryCarrierCode>('RUSSIAN_POST')
const [addressId, setAddressId] = useState('') const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
@@ -50,6 +52,7 @@ export function CheckoutPage() {
mutationFn: () => mutationFn: () =>
createOrder({ createOrder({
deliveryType, deliveryType,
deliveryCarrier: deliveryType === 'delivery' ? deliveryCarrier : null,
paymentMethod: deliveryType === 'delivery' ? 'online' : pickupPayment, paymentMethod: deliveryType === 'delivery' ? 'online' : pickupPayment,
addressId: deliveryType === 'delivery' ? selectedAddressId : null, addressId: deliveryType === 'delivery' ? selectedAddressId : null,
comment: comment.trim() || null, comment: comment.trim() || null,
@@ -74,9 +77,7 @@ export function CheckoutPage() {
const items = cartQuery.data?.items ?? [] const items = cartQuery.data?.items ?? []
const itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0) 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 : 0
const deliveryFeeCents =
deliveryType === 'delivery' && items.length > 0 ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
const total = itemsSubtotalCents + deliveryFeeCents const total = itemsSubtotalCents + deliveryFeeCents
const addresses = addressesQuery.data?.items ?? [] const addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1)) const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
@@ -153,6 +154,23 @@ export function CheckoutPage() {
{deliveryType === 'delivery' && ( {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> <FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel> <InputLabel id="addr-label">Адрес доставки</InputLabel>
<Select <Select
@@ -180,13 +198,8 @@ export function CheckoutPage() {
)} )}
<Alert severity="info"> <Alert severity="info">
Стоимость доставки: 500 за каждые 2 единицы (минимум 500 ). Стоимость доставки ориентировочно 300 . Точная цена будет скорректирована после расчёта. В сумме заказа
{items.length > 0 && ( сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
<>
{' '}
В этом заказе: {totalQty} шт. доставка {formatPriceRub(deliveryFeeCents)}.
</>
)}
</Alert> </Alert>
</> </>
)} )}
@@ -24,9 +24,11 @@ import {
} from '@/entities/order/api/order-api' } from '@/entities/order/api/order-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api' import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-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 { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point' import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
import { formatPriceRub } from '@/shared/lib/format-price' import { formatPriceRub } from '@/shared/lib/format-price'
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble' import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody' import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
@@ -62,15 +64,6 @@ function reviewSubmitErrorMessage(err: unknown): string {
return 'Не удалось отправить отзыв' return 'Не удалось отправить отзыв'
} }
type AddressSnapshot = {
deliveryType?: 'delivery' | 'pickup'
label?: string | null
recipientName?: string
recipientPhone?: string
addressLine?: string
comment?: string | null
}
export function OrderDetailPage() { export function OrderDetailPage() {
const { id } = useParams() const { id } = useParams()
const qc = useQueryClient() const qc = useQueryClient()
@@ -180,14 +173,7 @@ export function OrderDetailPage() {
})() })()
}, [id, order, orderQuery.status, qc]) }, [id, order, orderQuery.status, qc])
const address = useMemo((): AddressSnapshot | null => { const address = useMemo(() => parseOrderAddressSnapshot(order?.addressSnapshotJson), [order?.addressSnapshotJson])
if (!order?.addressSnapshotJson) return null
try {
return JSON.parse(order.addressSnapshotJson) as AddressSnapshot
} catch {
return null
}
}, [order])
if (!id) return <Alert severity="error">Некорректный заказ.</Alert> if (!id) return <Alert severity="error">Некорректный заказ.</Alert>
if (orderQuery.isLoading) return <Typography>Загрузка</Typography> if (orderQuery.isLoading) return <Typography>Загрузка</Typography>
@@ -245,6 +231,11 @@ export function OrderDetailPage() {
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'} Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
{order.deliveryType === 'pickup' && <> · оплата: {payOnPickup ? 'при получении' : 'онлайн'}</>} {order.deliveryType === 'pickup' && <> · оплата: {payOnPickup ? 'при получении' : 'онлайн'}</>}
</Typography> </Typography>
{order.deliveryType === 'delivery' && deliveryCarrierLabelRu(order.deliveryCarrier) && (
<Typography variant="body2" sx={{ mb: 1 }}>
Служба: {deliveryCarrierLabelRu(order.deliveryCarrier)}
</Typography>
)}
{order.deliveryType === 'delivery' && ( {order.deliveryType === 'delivery' && (
<> <>
{address ? ( {address ? (
@@ -298,6 +289,12 @@ export function OrderDetailPage() {
</Typography> </Typography>
) : ( ) : (
<> <>
{order.status === 'DELIVERY_FEE_ADJUSTMENT' && (
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в
статус «{orderStatusLabelRu('PENDING_PAYMENT')}».
</Typography>
)}
{order.status === 'PENDING_PAYMENT' && ( {order.status === 'PENDING_PAYMENT' && (
<> <>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}> <Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
@@ -320,7 +317,7 @@ export function OrderDetailPage() {
Оплата отправлена на проверку. Мы проверим поступление и обновим статус. Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
</Typography> </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 color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется. На этом этапе действий по оплате в этом блоке не требуется.
</Typography> </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
}
+3
View File
@@ -1,5 +1,6 @@
export const ORDER_STATUSES = [ export const ORDER_STATUSES = [
'DRAFT', 'DRAFT',
'DELIVERY_FEE_ADJUSTMENT',
'PENDING_PAYMENT', 'PENDING_PAYMENT',
'PAYMENT_VERIFICATION', 'PAYMENT_VERIFICATION',
'PAID', 'PAID',
@@ -17,6 +18,8 @@ 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'] return ['CANCELLED']
case 'PAYMENT_VERIFICATION': 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 { 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: 'Проверка оплаты', PAYMENT_VERIFICATION: 'Проверка оплаты',
PAID: 'Оплачен', PAID: 'Оплачен',
+5 -1
View File
@@ -169,7 +169,11 @@ deploy_backend() {
echo ">>> Рестарт: $DEPLOY_RESTART_CMD" echo ">>> Рестарт: $DEPLOY_RESTART_CMD"
remote_exec bash -lc "$DEPLOY_RESTART_CMD" remote_exec bash -lc "$DEPLOY_RESTART_CMD"
elif [[ -z "$DRY_RUN" ]]; then 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 fi
} }
-20
View File
@@ -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
View File
@@ -4,10 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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", "dev:classic": "node --watch src/index.js",
"start": "node 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": "prisma migrate dev",
"db:migrate:test": "node --env-file=.dev_env ./node_modules/prisma/build/index.js migrate deploy", "db:migrate:test": "node --env-file=.dev_env ./node_modules/prisma/build/index.js migrate deploy",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "deliveryCarrier" TEXT;
+2
View File
@@ -116,6 +116,8 @@ model Order {
status String @default("DRAFT") status String @default("DRAFT")
/// 'delivery' | 'pickup' /// 'delivery' | 'pickup'
deliveryType String @default("delivery") deliveryType String @default("delivery")
/// RUSSIAN_POST | OZON_PVZ | YANDEX_PVZ | FIVE_POST при deliveryType=delivery
deliveryCarrier String?
/// 'online' | 'on_pickup' — способ расчёта для заказа /// 'online' | 'on_pickup' — способ расчёта для заказа
paymentMethod String @default("online") paymentMethod String @default("online")
itemsSubtotalCents Int @default(0) itemsSubtotalCents Int @default(0)
+9
View File
@@ -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)
}
+3
View File
@@ -1,5 +1,6 @@
export const ORDER_STATUSES = [ export const ORDER_STATUSES = [
'DRAFT', 'DRAFT',
'DELIVERY_FEE_ADJUSTMENT',
'PENDING_PAYMENT', 'PENDING_PAYMENT',
'PAYMENT_VERIFICATION', 'PAYMENT_VERIFICATION',
'PAID', 'PAID',
@@ -22,6 +23,8 @@ 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' return next === 'CANCELLED'
case 'PAYMENT_VERIFICATION': case 'PAYMENT_VERIFICATION':
+35 -1
View File
@@ -7,7 +7,9 @@ export async function registerAdminOrderRoutes(fastify) {
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
async () => { async () => {
const attentionCount = await prisma.order.count({ 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 } return { attentionCount }
}, },
@@ -57,6 +59,7 @@ export async function registerAdminOrderRoutes(fastify) {
id: o.id, id: o.id,
status: o.status, status: o.status,
deliveryType: o.deliveryType, deliveryType: o.deliveryType,
deliveryCarrier: o.deliveryCarrier,
paymentMethod: o.paymentMethod, paymentMethod: o.paymentMethod,
totalCents: o.totalCents, totalCents: o.totalCents,
currency: o.currency, 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( fastify.post(
'/api/admin/orders/:id/messages', '/api/admin/orders/:id/messages',
{ preHandler: [fastify.verifyAdmin] }, { preHandler: [fastify.verifyAdmin] },
+33 -4
View File
@@ -1,4 +1,5 @@
import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js' import { issueEmailCode, normalizeEmail, verifyEmailCode } from '../lib/auth.js'
import { isDeliveryCarrier } from '../lib/delivery-carrier.js'
import { escapeHtml } from '../lib/escape-html.js' import { escapeHtml } from '../lib/escape-html.js'
import { prisma } from '../lib/prisma.js' import { prisma } from '../lib/prisma.js'
import { getOtherUploadMaxFileBytes } from '../lib/upload-limits.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' }) 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') { if (paymentMethod === 'on_pickup' && deliveryType !== 'pickup') {
return reply.code(400).send({ error: 'Оплата при получении доступна только для самовывоза' }) 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 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 : 0
const deliveryFeeCents =
deliveryType === 'delivery' ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
const totalCents = itemsSubtotalCents + deliveryFeeCents const totalCents = itemsSubtotalCents + deliveryFeeCents
const addressSnapshotJson = const addressSnapshotJson =
@@ -488,7 +505,9 @@ export async function registerAuthRoutes(fastify) {
lng: address.lng, 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 let created
try { try {
@@ -511,6 +530,7 @@ export async function registerAuthRoutes(fastify) {
userId, userId,
status: initialStatus, status: initialStatus,
deliveryType, deliveryType,
deliveryCarrier,
paymentMethod, paymentMethod,
itemsSubtotalCents, itemsSubtotalCents,
deliveryFeeCents, deliveryFeeCents,
@@ -703,6 +723,15 @@ export async function registerAuthRoutes(fastify) {
return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' }) return reply.code(409).send({ error: 'Для этого заказа оплата при получении — кнопка оплаты не нужна.' })
} }
if (order.status === 'DELIVERY_FEE_ADJUSTMENT') {
return reply
.code(409)
.send({
error:
'Оплата станет доступна после корректировки стоимости доставки администратором.',
})
}
let nextStatus = order.status let nextStatus = order.status
if (order.status === 'DRAFT') { if (order.status === 'DRAFT') {
await prisma.order.update({ where: { id }, data: { status: 'PENDING_PAYMENT' } }) 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