From fccfd7abbecf95b2f9cb5cd6ce632648e3553c04 Mon Sep 17 00:00:00 2001 From: jacy <18049821889@163.com> Date: Fri, 28 Aug 2026 12:55:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(promo):=20HQ=20=E6=8E=A8=E5=B9=BF=E7=A0=81?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E5=8F=AA=E8=AE=A1=E5=B7=B2=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E6=89=AB=E7=A0=81=E8=AE=A2=E5=8D=95=E5=BF=AB?= =?UTF-8?q?=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。 Co-authored-by: Cursor --- apps/admin-web/src/lib/api.ts | 2 +- apps/admin-web/src/pages/OrdersPage.tsx | 104 ++++++++++++++++-- apps/admin-web/src/pages/PromoCodesPage.tsx | 52 ++++----- apps/admin-web/src/pages/UsersPage.tsx | 59 ++++++++-- .../src/pages/promo/PromoCodeDetailPage.tsx | 43 +++++++- .../src/pages/promo/PromoCodeMetricsPanel.tsx | 68 ++++++++++-- .../src/pages/promo/PromoCodeUsersPage.tsx | 4 +- packages/shared-types/src/ops.ts | 10 ++ packages/shared-types/src/promo.ts | 5 + .../wechat/wechat-trade-manage.service.ts | 21 ++++ .../src/modules/ops/admin-orders.service.ts | 8 +- .../src/modules/ops/admin-users.service.ts | 7 ++ .../src/modules/ops/dto/admin-query.dto.ts | 14 +++ .../src/modules/promo/promo-code.service.ts | 82 ++++++++++++-- .../src/modules/trade/trade.service.ts | 66 +++-------- 15 files changed, 421 insertions(+), 124 deletions(-) diff --git a/apps/admin-web/src/lib/api.ts b/apps/admin-web/src/lib/api.ts index d96b449..9cafb9e 100644 --- a/apps/admin-web/src/lib/api.ts +++ b/apps/admin-web/src/lib/api.ts @@ -229,7 +229,7 @@ export type AdminOrderRow = { proxyPartnerName?: string | null; proxyPartnerPhone?: string | null; isTest?: boolean; - user?: { id: string; userNo: string; phone: string | null; nickname: string | null }; + user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null }; delivery?: { provider: string; trackingNo: string | null; diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 5771ea4..e182e08 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, Button, @@ -153,6 +153,7 @@ type OrderExportFilters = { fulfillmentHold?: boolean; excludeTest?: boolean; deliveryType?: string; + promoCodeId?: string; dateRange?: [Dayjs, Dayjs]; }; @@ -212,6 +213,15 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year'; { key: 'year', label: '当年' }, ]; +function formatUserWithRemark(user?: { + userNo?: string; + hqRemark?: string | null; +} | null) { + const userNo = user?.userNo || '—'; + const remark = user?.hqRemark?.trim(); + return remark ? `${userNo}(${remark})` : userNo; +} + function formatBenefitBrief(row: AdminOrderRow) { const coupon = row.benefitCoupon; if (coupon) { @@ -247,6 +257,7 @@ function buildExportPayload( if (filters.deliveryType) payload.deliveryType = filters.deliveryType; if (filters.fulfillmentHold) payload.fulfillmentHold = true; if (filters.excludeTest) payload.excludeTest = true; + if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId; if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD'); if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD'); return payload; @@ -285,8 +296,15 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S export default function OrdersPage() { const navigate = useNavigate(); + const location = useLocation(); const [searchParams] = useSearchParams(); const initialOrderNo = searchParams.get('orderNo')?.trim() || ''; + const initialPromoCodeId = searchParams.get('promoCodeId')?.trim() || ''; + const initialStatusParam = searchParams.getAll('status').join(','); + const initialStatuses = useMemo( + () => initialStatusParam.split(',').map((s) => s.trim()).filter(Boolean), + [initialStatusParam], + ); const [form] = Form.useForm(); const [shipForm] = Form.useForm(); const [logisticsForm] = Form.useForm(); @@ -336,6 +354,20 @@ export default function OrdersPage() { } }, [form, initialOrderNo]); + useEffect(() => { + if (initialStatuses.length) { + form.setFieldsValue({ status: initialStatuses }); + } + }, [form, initialStatuses]); + + useEffect(() => { + const openOrderId = (location.state as { openOrderId?: string } | null)?.openOrderId; + if (openOrderId) { + void openDetail(openOrderId); + navigate(`${location.pathname}${location.search}`, { replace: true, state: null }); + } + }, [location.state, location.pathname, location.search, navigate]); + useEffect(() => { if (!initialOrderNo || deepLinkOpenedRef.current || loading) return; const first = data?.items?.[0]; @@ -366,13 +398,16 @@ export default function OrdersPage() { const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); if (initialOrderNo) qs.set('orderNo', initialOrderNo); if (values.orderNo) qs.set('orderNo', values.orderNo); - for (const status of selectedStatuses(values.status)) qs.append('status', status); + const statuses = selectedStatuses(values.status); + const statusList = statuses.length ? statuses : initialStatuses; + for (const status of statusList) qs.append('status', status); if (values.orderType) qs.set('orderType', values.orderType); if (values.cityId) qs.set('cityId', values.cityId); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.excludeTest) qs.set('excludeTest', 'true'); if (values.deliveryType) qs.set('deliveryType', values.deliveryType); + if (initialPromoCodeId) qs.set('promoCodeId', initialPromoCodeId); if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD')); if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD')); const res = await request>(`/admin/orders?${qs}`); @@ -380,7 +415,7 @@ export default function OrdersPage() { } finally { setLoading(false); } - }, [form, page, pageSize, initialOrderNo]); + }, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]); useEffect(() => { void load(); @@ -589,7 +624,16 @@ export default function OrdersPage() { } setExporting(true); try { - const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys); + const payload = buildExportPayload( + scope, + exportFormat, + { + ...values, + promoCodeId: initialPromoCodeId || values.promoCodeId, + status: selectedStatuses(values.status).length ? values.status : initialStatuses, + }, + selectedRowKeys, + ); const result = await request('/admin/orders/export', { method: 'POST', body: JSON.stringify(payload), @@ -618,14 +662,14 @@ export default function OrdersPage() { { title: '用户', key: 'user', - width: 120, + width: 200, render: (_, row) => row.user?.id ? ( - navigate('/users', { state: { openUserId: String(row.user!.id) } })}> - {row.user.userNo} + navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}> + {formatUserWithRemark(row.user)} ) : ( - (row.user?.userNo || '—') + formatUserWithRemark(row.user) ), }, { @@ -778,6 +822,26 @@ export default function OrdersPage() { /> ) : null} + {initialPromoCodeId ? ( + navigate('/orders')}> + 清除筛选 + + } + onClose={() => navigate('/orders')} + /> + ) : null} +
{ setPage(1); void load(); }}> @@ -877,7 +941,16 @@ export default function OrdersPage() { ) : } - + {canDeleteOrders ? ( - diff --git a/apps/admin-web/src/pages/UsersPage.tsx b/apps/admin-web/src/pages/UsersPage.tsx index 214ca09..15447b4 100644 --- a/apps/admin-web/src/pages/UsersPage.tsx +++ b/apps/admin-web/src/pages/UsersPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Alert, Button, @@ -155,6 +155,7 @@ type BatchDeletePreview = { export default function UsersPage() { const navigate = useNavigate(); const location = useLocation(); + const [searchParams, setSearchParams] = useSearchParams(); const [form] = Form.useForm(); const [profile, setProfile] = useState(null); const [data, setData] = useState | null>(null); @@ -185,6 +186,8 @@ export default function UsersPage() { try { const values = form.getFieldsValue(); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); + const userId = String(values.userId || '').trim(); + if (userId) qs.set('userId', userId); if (values.phone) qs.set('phone', values.phone); if (values.userNo) qs.set('userNo', values.userNo); if (values.deviceKey) qs.set('deviceKey', values.deviceKey); @@ -202,17 +205,26 @@ export default function UsersPage() { } }, [form, page, pageSize]); + useEffect(() => { + const userId = searchParams.get('userId'); + if (userId) form.setFieldsValue({ userId }); + }, [searchParams, form]); + useEffect(() => { void load(); - }, [load]); + }, [load, searchParams]); useEffect(() => { const openUserId = (location.state as { openUserId?: string } | null)?.openUserId; - if (openUserId) { - void openDetail(openUserId); - navigate(location.pathname, { replace: true, state: null }); - } - }, [location.state, location.pathname, navigate]); + if (!openUserId) return; + form.setFieldsValue({ userId: openUserId }); + setPage(1); + void openDetail(openUserId); + navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, { + replace: true, + state: null, + }); + }, [location.state, location.pathname, navigate, form]); async function openDetail(id: string) { const [res, logs] = await Promise.all([ @@ -498,13 +510,30 @@ export default function UsersPage() { ) : null } /> - { setPage(1); void load(); }}> + { + setPage(1); + const userId = String(form.getFieldValue('userId') || '').trim(); + const current = searchParams.get('userId') ?? ''; + if (userId !== current) { + if (userId) setSearchParams({ userId }, { replace: true }); + else setSearchParams({}, { replace: true }); + } + void load(); + }} + > + + + @@ -526,7 +555,19 @@ export default function UsersPage() { - + diff --git a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx index 0137f3c..a7971b2 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeDetailPage.tsx @@ -1,5 +1,6 @@ import { useState, type CSSProperties } from 'react'; -import { useOutletContext } from 'react-router-dom'; +import { useNavigate, useOutletContext } from 'react-router-dom'; +import { QuestionCircleOutlined } from '@ant-design/icons'; import { Button, Card, @@ -13,6 +14,7 @@ import { Select, Space, Statistic, + Tooltip, Typography, message, } from 'antd'; @@ -35,6 +37,9 @@ const descContentStyle: CSSProperties = { wordBreak: 'break-all', }; +const ATTRIBUTION_HINT = + '首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。'; + async function downloadQrcode(url: string, filename: string) { try { const res = await fetch(url); @@ -51,6 +56,7 @@ async function downloadQrcode(url: string, filename: string) { } export default function PromoCodeDetailPage() { + const navigate = useNavigate(); const { detail, reload } = useOutletContext(); const [editForm] = Form.useForm(); const [editOpen, setEditOpen] = useState(false); @@ -209,14 +215,26 @@ export default function PromoCodeDetailPage() { - + navigate(`/promo-codes/${detail.id}?eventType=SCAN`)} + > + 归因用户数 + + + + + } value={stats?.attributionCount ?? 0} /> @@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() { - - + navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)} + > + + 订单数 + + + + + } + value={stats?.orderCount ?? detail.orderCount} + /> diff --git a/apps/admin-web/src/pages/promo/PromoCodeMetricsPanel.tsx b/apps/admin-web/src/pages/promo/PromoCodeMetricsPanel.tsx index b9ff670..213de9a 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeMetricsPanel.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeMetricsPanel.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Card, DatePicker, @@ -21,6 +22,7 @@ import { } from '@dukang/shared-types'; import { request, type Paginated } from '../../lib/api'; import { fmtTime } from '../../lib/constants'; +import { AdminPrimaryLink } from '../../components/AdminPrimaryLink'; type Props = { promoId: string; @@ -57,11 +59,45 @@ function buildEventsQs( return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`; } -function formatRefId(row: PromoMetricEventItem): string { - if (row.orderId) return `订单 ${row.orderId}`; - if (row.userId) return `用户 ${row.userId}`; - if (row.sessionId) return `会话 ${row.sessionId}`; - return '—'; +function parseEventType(raw: string | null): PromoMetricEventType | undefined { + if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw; + return undefined; +} + +function EventRefLinks({ row }: { row: PromoMetricEventItem }) { + const navigate = useNavigate(); + const links = []; + if (row.orderId) { + links.push( + { + if (row.orderNo) { + navigate(`/orders?orderNo=${encodeURIComponent(row.orderNo)}`); + } else { + navigate('/orders', { state: { openOrderId: String(row.orderId) } }); + } + }} + > + 订单 {row.orderNo || row.orderId} + , + ); + } + if (row.userId) { + links.push( + navigate('/users', { state: { openUserId: String(row.userId) } })} + > + 用户 {row.userNo || row.userId} + , + ); + } + if (links.length) { + return {links}; + } + if (row.sessionId) return <>会话 {row.sessionId}; + return <>—; } function formatLocation(row: PromoMetricEventItem): string { @@ -71,15 +107,27 @@ function formatLocation(row: PromoMetricEventItem): string { } export default function PromoCodeMetricsPanel({ promoId }: Props) { + const [searchParams, setSearchParams] = useSearchParams(); + const urlEventType = parseEventType(searchParams.get('eventType')); const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]); const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [timeline, setTimeline] = useState(null); const [timelineLoading, setTimelineLoading] = useState(false); - const [eventType, setEventType] = useState(); + const [eventType, setEventType] = useState(urlEventType); const [eventsPage, setEventsPage] = useState(1); const [events, setEvents] = useState | null>(null); const [eventsLoading, setEventsLoading] = useState(false); + useEffect(() => { + setEventType(urlEventType); + setEventsPage(1); + if (urlEventType) { + window.requestAnimationFrame(() => { + document.getElementById('promo-metric-events')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + }, [urlEventType]); + const loadTimeline = useCallback(() => { setTimelineLoading(true); return request(buildTimelineQs(promoId, range, granularity)) @@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) { { title: 'ID', key: 'ref', - render: (_, row) => formatRefId(row), + render: (_, row) => , }, { title: 'IP', @@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) { notMerge /> - + 事件日志 @@ -232,6 +280,10 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) { onChange={(v) => { setEventType(v); setEventsPage(1); + const next = new URLSearchParams(searchParams); + if (v) next.set('eventType', v); + else next.delete('eventType'); + setSearchParams(next, { replace: true }); }} options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({ value, diff --git a/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx b/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx index a4b761b..481964f 100644 --- a/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx +++ b/apps/admin-web/src/pages/promo/PromoCodeUsersPage.tsx @@ -1,5 +1,5 @@ import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; -import { Button, Space, Table, Tag } from 'antd'; +import { Button, Space, Table, Tag, Tooltip } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { USER_SOURCE_TYPE_LABELS, @@ -66,7 +66,7 @@ export default function PromoCodeUsersPage() { width: 160, render: (v) => (v ? fmtTime(v) : '—'), }, - { title: '订单数', dataIndex: 'orderCount', width: 80 }, + { title: 订单数, dataIndex: 'orderCount', width: 80 }, { title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '操作', diff --git a/packages/shared-types/src/ops.ts b/packages/shared-types/src/ops.ts index 2303ab1..0e75067 100644 --- a/packages/shared-types/src/ops.ts +++ b/packages/shared-types/src/ops.ts @@ -18,6 +18,16 @@ export interface AdminOrdersListQuery extends AdminListQuery { deliveryType?: string; } +export interface AdminUsersListQuery extends AdminListQuery { + phone?: string; + userNo?: string; + userId?: string; + deviceKey?: string; + phoneVerified?: string; + status?: number; + excludeTest?: boolean; +} + export interface UpdateAdminUserRequest { hqRemark: string; } diff --git a/packages/shared-types/src/promo.ts b/packages/shared-types/src/promo.ts index 71cd2d3..86164ca 100644 --- a/packages/shared-types/src/promo.ts +++ b/packages/shared-types/src/promo.ts @@ -35,6 +35,7 @@ export type PromoCodeItem = { qrcodeId: string; status: PromoCodeStatus; scanCount: number; + /** 已完成订单数(status=COMPLETED) */ orderCount: number; landingUrl: string; qrcodeUrl?: string | null; @@ -47,6 +48,7 @@ export type PromoCodeItem = { export type PromoCodeStats = { /** 扫码进入次数 */ scanCount: number; + /** 已完成订单数(status=COMPLETED) */ orderCount: number; conversionRate: number; /** 归因用户数(user_promo_attribution) */ @@ -66,6 +68,7 @@ export type PromoCodeAttributedUser = { sourceType: string; sourceRefId: string | null; firstTouchAt: string | null; + /** 该用户已完成订单数 */ orderCount: number; createdAt: string; }; @@ -98,7 +101,9 @@ export type PromoMetricEventItem = { id: string; eventType: PromoMetricEventType; userId: string | null; + userNo?: string | null; orderId: string | null; + orderNo?: string | null; sessionId: string | null; clientIp: string | null; ipProvince: string | null; diff --git a/server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts b/server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts index 2986be5..cb4fe0f 100644 --- a/server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts +++ b/server/dukang-api/src/integrations/wechat/wechat-trade-manage.service.ts @@ -100,6 +100,27 @@ export class WechatTradeManageService { remark, }), }); + if (order.promoCodeId) { + const already = await tx.logPromoEvent.findFirst({ + where: { orderId: order.id, eventType: 'ORDER' }, + select: { id: true }, + }); + if (!already) { + await tx.commonPromoCode.update({ + where: { id: order.promoCodeId }, + data: { orderCount: { increment: 1 } }, + }); + await tx.logPromoEvent.create({ + data: { + promoCodeId: order.promoCodeId, + eventType: 'ORDER', + userId: order.userId, + orderId: order.id, + clientIp: order.clientIp, + }, + }); + } + } }); await this.logEvent(evt, order.id, 'SUCCESS', methodLabel); diff --git a/server/dukang-api/src/modules/ops/admin-orders.service.ts b/server/dukang-api/src/modules/ops/admin-orders.service.ts index 840e4e0..1c4a1b1 100644 --- a/server/dukang-api/src/modules/ops/admin-orders.service.ts +++ b/server/dukang-api/src/modules/ops/admin-orders.service.ts @@ -37,6 +37,7 @@ type OrderFilterInput = Pick< | 'createdTo' | 'excludeTest' | 'deliveryType' + | 'promoCodeId' >; @Injectable() @@ -94,7 +95,7 @@ export class AdminOrdersService { skip: (page - 1) * pageSize, take: pageSize, include: { - user: { select: { id: true, userNo: true, phone: true, nickname: true } }, + user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } }, delivery: { select: { provider: true, @@ -190,6 +191,10 @@ export class AdminOrdersService { if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo); } if (query.excludeTest) where.isTest = false; + const promoCodeId = query.promoCodeId?.trim(); + if (promoCodeId && /^\d+$/.test(promoCodeId)) { + where.promoCodeId = BigInt(promoCodeId); + } return where; } @@ -252,6 +257,7 @@ export class AdminOrdersService { userNo: true, phone: true, nickname: true, + hqRemark: true, deviceKey: true, phoneVerifiedAt: true, }, diff --git a/server/dukang-api/src/modules/ops/admin-users.service.ts b/server/dukang-api/src/modules/ops/admin-users.service.ts index ce135fa..c987b94 100644 --- a/server/dukang-api/src/modules/ops/admin-users.service.ts +++ b/server/dukang-api/src/modules/ops/admin-users.service.ts @@ -81,6 +81,13 @@ export class AdminUsersService { if (query.phone) where.phone = { contains: query.phone }; if (query.userNo) where.userNo = { contains: query.userNo }; + const userId = query.userId?.trim(); + if (userId) { + if (!/^\d+$/.test(userId)) { + return serializeBigInt({ items: [], total: 0, page, pageSize }); + } + where.id = BigInt(userId); + } if (query.deviceKey) where.deviceKey = query.deviceKey; if (query.status !== undefined) where.status = query.status; if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null }; diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index f1d4ec8..c862559 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -48,6 +48,11 @@ export class AdminUsersQueryDto extends PaginationQueryDto { @IsString() userNo?: string; + /** 精确匹配用户主键,供订单等页的用户快链带入筛选 */ + @IsOptional() + @IsString() + userId?: string; + @IsOptional() @IsString() deviceKey?: string; @@ -116,6 +121,11 @@ export class AdminOrdersQueryDto extends PaginationQueryDto { @IsOptional() @IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP']) deliveryType?: string; + + /** 按下单时绑定的推广码筛选 */ + @IsOptional() + @IsString() + promoCodeId?: string; } /** HQ 订单导出(筛选 + 勾选范围) */ @@ -174,6 +184,10 @@ export class AdminOrdersExportDto { @IsOptional() @IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP']) deliveryType?: string; + + @IsOptional() + @IsString() + promoCodeId?: string; } /** 概览页用户/订单 ECharts 聚合筛选 */ diff --git a/server/dukang-api/src/modules/promo/promo-code.service.ts b/server/dukang-api/src/modules/promo/promo-code.service.ts index 6efdf8a..2f164ef 100644 --- a/server/dukang-api/src/modules/promo/promo-code.service.ts +++ b/server/dukang-api/src/modules/promo/promo-code.service.ts @@ -151,7 +151,7 @@ export class PromoCodeService { }); } - private mapRow(row: PromoRow) { + private mapRow(row: PromoRow, orderCount?: number) { return serializeBigInt({ id: row.id, code: row.code, @@ -161,7 +161,7 @@ export class PromoCodeService { status: row.status, remark: row.remark, scanCount: row.scanCount, - orderCount: row.orderCount, + orderCount: orderCount ?? row.orderCount, landingUrl: buildLandingUrl(row.code, row.qrcodeId), qrcodeUrl: row.qrcodeResource?.url ?? null, ownerUser: this.mapOwnerUser(row.ownerUser), @@ -207,9 +207,10 @@ export class PromoCodeService { }), this.prisma.commonPromoCode.count({ where }), ]); + const completedByPromo = await this.completedOrderCounts(items.map((r) => r.id)); return serializeBigInt({ - items: items.map((r) => this.mapRow(r)), + items: items.map((r) => this.mapRow(r, completedByPromo.get(r.id.toString()) ?? 0)), total, page, pageSize, @@ -223,7 +224,7 @@ export class PromoCodeService { }); if (!row) throw new NotFoundException('推广码不存在'); const stats = await this.statsFromRow(row); - return serializeBigInt({ ...this.mapRow(row), stats }); + return serializeBigInt({ ...this.mapRow(row, stats.orderCount), stats }); } private async resolveOwnerUserId(ownerUserId?: string) { @@ -519,6 +520,40 @@ export class PromoCodeService { this.logPromoMetric(promoCodeId, 'ORDER', { clientIp }, { userId, orderId }); } + /** 订单完成时计入推广码订单数(下单/待付款不计入) */ + async recordCompletedOrder( + promoCodeId: bigint, + orderId: bigint, + userId: bigint, + clientIp?: string, + ): Promise { + const already = await this.prisma.logPromoEvent.findFirst({ + where: { orderId, eventType: 'ORDER' }, + select: { id: true }, + }); + if (already) return; + await this.prisma.commonPromoCode.update({ + where: { id: promoCodeId }, + data: { orderCount: { increment: 1 } }, + }); + this.logPromoOrderEvent(promoCodeId, orderId, userId, clientIp); + } + + private async completedOrderCounts(promoIds: bigint[]): Promise> { + const map = new Map(); + if (!promoIds.length) return map; + const rows = await this.prisma.order.groupBy({ + by: ['promoCodeId'], + where: { promoCodeId: { in: promoIds }, status: 'COMPLETED' }, + _count: { _all: true }, + }); + for (const row of rows) { + if (row.promoCodeId == null) continue; + map.set(row.promoCodeId.toString(), row._count._all); + } + return map; + } + private logPromoMetric( promoCodeId: bigint, eventType: PromoMetricEventType, @@ -610,12 +645,37 @@ export class PromoCodeService { this.prisma.logPromoEvent.count({ where }), ]); + const userIds = [...new Set(items.map((row) => row.userId).filter((id): id is bigint => id != null))]; + const orderIds = [...new Set(items.map((row) => row.orderId).filter((id): id is bigint => id != null))]; + const [users, orders] = await Promise.all([ + userIds.length + ? this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, userNo: true }, + }) + : [], + orderIds.length + ? this.prisma.order.findMany({ + where: { id: { in: orderIds } }, + select: { id: true, orderNo: true }, + }) + : [], + ]); + const userNoById = new Map( + users.map((u): [string, string | null] => [u.id.toString(), u.userNo]), + ); + const orderNoById = new Map( + orders.map((o): [string, string] => [o.id.toString(), o.orderNo]), + ); + return serializeBigInt({ items: items.map((row) => ({ id: row.id, eventType: row.eventType, userId: row.userId, + userNo: row.userId ? userNoById.get(row.userId.toString()) ?? null : null, orderId: row.orderId, + orderNo: row.orderId ? orderNoById.get(row.orderId.toString()) ?? null : null, sessionId: row.sessionId, clientIp: row.clientIp, ipProvince: row.ipProvince, @@ -636,12 +696,9 @@ export class PromoCodeService { if (!promo) throw new NotFoundException('推广码不存在'); } - private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) { + private async statsFromRow(row: { id: bigint; scanCount: number }) { const scanCount = row.scanCount; - const orderCount = row.orderCount; - const conversionRate = - scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0; - const [attributionCount, sourceMarkedCount] = await Promise.all([ + const [attributionCount, sourceMarkedCount, orderCount] = await Promise.all([ this.prisma.userPromoAttribution.count({ where: { promoCodeId: row.id }, }), @@ -652,7 +709,12 @@ export class PromoCodeService { mergedIntoUserId: null, }, }), + this.prisma.order.count({ + where: { promoCodeId: row.id, status: 'COMPLETED' }, + }), ]); + const conversionRate = + scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0; return { scanCount, orderCount, @@ -695,7 +757,7 @@ export class PromoCodeService { sourceRefId: true, createdAt: true, promoTouch: { select: { firstTouchAt: true, promoCodeId: true } }, - _count: { select: { orders: true } }, + _count: { select: { orders: { where: { status: 'COMPLETED' } } } }, }, }), this.prisma.user.count({ where }), diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 5e5c580..7a2013b 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -357,25 +357,9 @@ export class TradeService { include: { product: true, imageResource: true }, }); - if (promoCodeId) { - await tx.commonPromoCode.update({ - where: { id: promoCodeId }, - data: { orderCount: { increment: 1 } }, - }); - } - return created; }); - if (promoCodeId) { - this.promoCodeService.logPromoOrderEvent( - promoCodeId, - order.id, - userId, - location.clientIp ?? undefined, - ); - } - this.analyticsService.trackOneSafe(userId, this.resolveTrackedUserClientApp(clientApp), { eventName: 'order_submit', refType: 'ORDER', @@ -526,6 +510,15 @@ export class TradeService { }); if (!order) return; + if (order.status === 'COMPLETED' && order.promoCodeId) { + await this.promoCodeService.recordCompletedOrder( + order.promoCodeId, + order.id, + order.userId, + order.clientIp ?? undefined, + ); + } + if (!order.isTest) { const unit = order.saleUnit === 'BOX' ? '箱' : '瓶'; const spec = (order.productSpec || '').trim(); @@ -1593,6 +1586,15 @@ export class TradeService { }); }); + if (targetStatus === 'COMPLETED' && currentStatus !== 'COMPLETED' && order.promoCodeId) { + await this.promoCodeService.recordCompletedOrder( + order.promoCodeId, + order.id, + order.userId, + order.clientIp ?? undefined, + ); + } + // 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置) if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') { this.wechatOrderShipping.uploadForOrderSafe(orderId); @@ -1934,25 +1936,9 @@ export class TradeService { }), }); - if (promoCodeId) { - await tx.commonPromoCode.update({ - where: { id: promoCodeId }, - data: { orderCount: { increment: 1 } }, - }); - } - return created; }); - if (promoCodeId) { - this.promoCodeService.logPromoOrderEvent( - promoCodeId, - order.id, - user.id, - location.clientIp ?? undefined, - ); - } - this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { partnerAccountId: primary.id, eventName: 'partner_proxy_order_create', @@ -2347,25 +2333,9 @@ export class TradeService { }), }); - if (promoCodeId) { - await tx.commonPromoCode.update({ - where: { id: promoCodeId }, - data: { orderCount: { increment: 1 } }, - }); - } - return created; }); - if (promoCodeId) { - this.promoCodeService.logPromoOrderEvent( - promoCodeId, - order.id, - user.id, - location.clientIp ?? undefined, - ); - } - return { id: order.id.toString(), orderNo: order.orderNo,