deploy
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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: 'Оплачен',
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
"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;
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = [
|
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':
|
||||||
|
|||||||
@@ -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] },
|
||||||
|
|||||||
@@ -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 |
Reference in New Issue
Block a user