merge(dev_jacy): HQ 推广码订单只计已完成并补快链

This commit is contained in:
2026-08-28 12:56:02 +08:00
15 changed files with 421 additions and 124 deletions
+1 -1
View File
@@ -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;
+94 -10
View File
@@ -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<Paginated<AdminOrderRow>>(`/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<OrderExportResult>('/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 ? (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{row.user.userNo}
<AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}>
{formatUserWithRemark(row.user)}
</AdminPrimaryLink>
) : (
(row.user?.userNo || '—')
formatUserWithRemark(row.user)
),
},
{
@@ -778,6 +822,26 @@ export default function OrdersPage() {
/>
) : null}
{initialPromoCodeId ? (
<Alert
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
message={
initialStatuses.includes('COMPLETED') && initialStatuses.length === 1
? '已按推广码筛选已完成订单'
: '已按推广码筛选订单'
}
action={
<Button size="small" onClick={() => navigate('/orders')}>
</Button>
}
onClose={() => navigate('/orders')}
/>
) : null}
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<Row gutter={[16, 8]}>
<Col xs={24} md={14} lg={12}>
@@ -877,7 +941,16 @@ export default function OrdersPage() {
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
@@ -987,7 +1060,18 @@ export default function OrdersPage() {
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
</Descriptions.Item>
) : null}
<Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate(`/users?userId=${encodeURIComponent(String(detail.user!.id))}`)}
>
{formatUserWithRemark(detail.user)}
</AdminPrimaryLink>
) : (
formatUserWithRemark(detail.user)
)}
{detail.user?.nickname ? ` / ${detail.user.nickname}` : ''}
</Descriptions.Item>
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
+22 -30
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
@@ -23,21 +23,6 @@ type Row = PromoCodeItem;
type SceneOption = { value: PromoCodeScene; label: string };
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodesPage() {
const navigate = useNavigate();
const [filterForm] = Form.useForm();
@@ -104,8 +89,26 @@ export default function PromoCodesPage() {
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '扫码',
dataIndex: 'scanCount',
width: 70,
render: (v: number, row) => (
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
{v}
</AdminPrimaryLink>
),
},
{
title: <Tooltip title="仅统计已完成订单"></Tooltip>,
dataIndex: 'orderCount',
width: 70,
render: (v: number, row) => (
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
{v}
</AdminPrimaryLink>
),
},
{
title: '转化率',
width: 90,
@@ -119,24 +122,13 @@ export default function PromoCodesPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
width: 140,
fixed: 'right',
render: (_, row) => (
<Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button>
<Button
type="link"
size="small"
disabled={!row.qrcodeUrl}
onClick={() => {
if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
</Button>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
</Button>
+50 -9
View File
@@ -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<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | 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
}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={() => {
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();
}}
>
<Form.Item name="phone" label="手机号">
<Input placeholder="模糊搜索" allowClear />
</Form.Item>
<Form.Item name="userNo" label="用户编号">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="userId" label="用户ID">
<Input placeholder="精确匹配" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="deviceKey" label="deviceKey">
<Input placeholder="UUID" allowClear style={{ width: 200 }} />
</Form.Item>
@@ -526,7 +555,19 @@ export default function UsersPage() {
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (searchParams.get('userId')) {
setSearchParams({}, { replace: true });
} else {
void load();
}
}}
>
</Button>
</Space>
</Form.Item>
</Form>
@@ -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<PromoCodeDetailContext>();
const [editForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false);
@@ -209,14 +215,26 @@ export default function PromoCodeDetailPage() {
<Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}>
<Card size="small">
<Card
size="small"
hoverable
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
>
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="归因用户数"
title={
<span>
<Tooltip title={ATTRIBUTION_HINT} overlayInnerStyle={{ maxWidth: 360 }}>
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
</Tooltip>
</span>
}
value={stats?.attributionCount ?? 0}
/>
</Card>
@@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() {
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
<Card
size="small"
hoverable
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
>
<Statistic
title={
<span>
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
</Tooltip>
</span>
}
value={stats?.orderCount ?? detail.orderCount}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
@@ -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(
<AdminPrimaryLink
key="order"
onClick={() => {
if (row.orderNo) {
navigate(`/orders?orderNo=${encodeURIComponent(row.orderNo)}`);
} else {
navigate('/orders', { state: { openOrderId: String(row.orderId) } });
}
}}
>
{row.orderNo || row.orderId}
</AdminPrimaryLink>,
);
}
if (row.userId) {
links.push(
<AdminPrimaryLink
key="user"
onClick={() => navigate('/users', { state: { openUserId: String(row.userId) } })}
>
{row.userNo || row.userId}
</AdminPrimaryLink>,
);
}
if (links.length) {
return <Space size={12}>{links}</Space>;
}
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<PromoMetricTimelineDto | null>(null);
const [timelineLoading, setTimelineLoading] = useState(false);
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType);
const [eventsPage, setEventsPage] = useState(1);
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | 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<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
@@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
{
title: 'ID',
key: 'ref',
render: (_, row) => formatRefId(row),
render: (_, row) => <EventRefLinks row={row} />,
},
{
title: 'IP',
@@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
notMerge
/>
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
<Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}>
</Typography.Title>
<Space wrap style={{ marginBottom: 12 }}>
@@ -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,
@@ -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: <Tooltip title="仅统计已完成订单"></Tooltip>, dataIndex: 'orderCount', width: 80 },
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
+10
View File
@@ -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;
}
+5
View File
@@ -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;
@@ -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);
@@ -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,
},
@@ -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 };
@@ -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 聚合筛选 */
@@ -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<void> {
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<Map<string, number>> {
const map = new Map<string, number>();
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<string, string | null>(
users.map((u): [string, string | null] => [u.id.toString(), u.userNo]),
);
const orderNoById = new Map<string, string>(
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 }),
@@ -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,