6 Commits

Author SHA1 Message Date
jacy 2b5f42acdf merge(dev): HQ 推广码订单只计已完成并补快链
CI / verify (push) Waiting to run
2026-08-28 12:57:03 +08:00
jacy 71149044ca merge(dev_jacy): HQ 推广码订单只计已完成并补快链 2026-08-28 12:56:02 +08:00
jacy fccfd7abbe feat(promo): HQ 推广码订单只计已完成并补扫码订单快链
列表与详情的订单数/转化率按 COMPLETED 实时统计;扫码、订单、事件 ID 可跳到对应页。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 12:55:19 +08:00
jacy 1ecc6a11df merge(dev): 企微结算账单明细与成交播报用户明文
CI / verify (push) Waiting to run
2026-08-27 11:46:12 +08:00
jacy cfd42dfc32 merge(dev_jacy): 企微结算账单明细与成交播报用户明文 2026-08-27 11:45:59 +08:00
jacy 737efb89a3 feat(wecom): 结算账单明细与成交播报用户明文
支付/核销补 HQ 备注且手机号不脱敏;结算通知按城市、合伙人、笔数和累计金额分块推送。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 11:45:08 +08:00
25 changed files with 1109 additions and 286 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: '操作',
+1 -1
View File
@@ -30,7 +30,7 @@
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置 + 拖表头改列宽;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
| 3.5.10 | 08-25 | C 端门店详情去掉分享按钮;同城送提示改承运商 HTML;小程序客服接通企微微信客服 | [`v3.5.10`](./杜康好客-v3.5.10-开发文档.md) |
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户;结算任务失败通知 + 提现通过/四账单 | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
| 3.5.11 | 08-26 | 城市三种履约起购;企微通知补提交人/规格物流/核销用户(明文手机+备注);结算任务失败通知 + 提现通过/四账单(城市合伙人、笔数、累计金额) | [`v3.5.11`](./杜康好客-v3.5.11-开发文档.md) |
| 3.5.12 | 08-26 | 发布会订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线展示中文;修复删除门店分类被默认树回种;HQ 侧栏顺序(业务前 11 项,系统设置置底) | [`v3.5.12`](./杜康好客-v3.5.12-开发文档.md) |
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
+1 -1
View File
@@ -73,7 +73,7 @@
|------|------|
| 2026-08-26 | v3.5.14`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
| 2026-08-26 | v3.5.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单 |
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注)`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单(城市/合伙人、笔数、累计金额、收款人账号开户行) |
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
| 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
+7 -6
View File
@@ -13,7 +13,7 @@
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 |
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;支付与核销补用户昵称、明文手机、HQ 备注 |
| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
@@ -63,11 +63,12 @@
- 商品:`{名称} {规格} {瓶\|箱}×{数量}`
- 物流:现场提货 / 同城 / 跨城
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
- 用户:昵称、手机号(明文)、HQ 备注 `hq_remark`(空则 —)
- 测试单不推
### 4.3 核销成功 `redeem.success`
`userName``userPhone`脱敏,与支付播报一致)。
`userName``userPhone`明文)、`userRemark`HQ 用户备注,空则 —)。
---
@@ -84,7 +85,7 @@
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
账单一次摘要(多店多行,最多 15 行);缺卡写「未登记收款账户」;内部群账号不脱敏。`created=0` / `skipped` 不推。
账单一次摘要:抬头含账期 / 账单笔数 / 累计金额;每笔分块写城市、门店或承运商、合伙人(公司-姓名)、核销/订单笔数、账单金额、收款人 / 账户 / 开户行。缺卡写「未登记」。内部群账号不脱敏。`created=0` / `skipped` 不推。正文过长时截断并提示到 HQ 查看。
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
@@ -95,8 +96,8 @@
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
- [ ] HQ 新建门店:企微可见 HQ 名称
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确
- [ ] 核销成功:有用户昵称与脱敏手机
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确;用户昵称、明文手机、备注
- [ ] 核销成功:有用户昵称、明文手机、备注
- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
- [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏
- [ ] 提现通过、四类账单生成含城市/合伙人、笔数、累计金额、收款人/账号/开户行;无账单不刷屏
+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;
@@ -4,6 +4,7 @@ import {
WECOM_PUSH_CONDITION_LABELS,
WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS,
WECOM_SETTLEMENT_PUSH_NAME,
WECOM_TEMPLATE_PLACEHOLDERS,
parseWecomPushConditions,
} from './wecom-message-push';
@@ -33,4 +34,22 @@ describe('wecom-message-push', () => {
'finance.store_bill',
]);
});
it('支付与核销模板含昵称、明文手机、备注占位符', () => {
expect(WECOM_TEMPLATE_PLACEHOLDERS['order.paid']).toEqual(
expect.arrayContaining(['userName', 'userPhone', 'userRemark']),
);
expect(WECOM_TEMPLATE_PLACEHOLDERS['redeem.success']).toEqual(
expect.arrayContaining(['userName', 'userPhone', 'userRemark']),
);
});
it('结算账单模板含累计金额与明细摘要', () => {
expect(WECOM_TEMPLATE_PLACEHOLDERS['finance.store_bill']).toEqual(
expect.arrayContaining(['billCount', 'totalAmount', 'billSummary']),
);
expect(WECOM_TEMPLATE_PLACEHOLDERS['store.withdraw_approved']).toEqual(
expect.arrayContaining(['cityName', 'partnerLabel', 'payee', 'accountNo', 'bankBranch']),
);
});
});
@@ -197,6 +197,9 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'skuSummary',
'deliveryLabel',
'receiverInfo',
'userName',
'userPhone',
'userRemark',
'phoneMasked',
'time',
'handleUrl',
@@ -209,6 +212,7 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'channel',
'userName',
'userPhone',
'userRemark',
'time',
'handleUrl',
'handleLabel',
@@ -255,9 +259,14 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'storeId',
],
'store.withdraw_approved': [
'cityName',
'storeName',
'partnerLabel',
'withdrawNo',
'amount',
'payee',
'accountNo',
'bankBranch',
'bankAccount',
'time',
'handleUrl',
@@ -277,6 +286,7 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'finance.store_bill': [
'period',
'billCount',
'totalAmount',
'billSummary',
'time',
'handleUrl',
@@ -285,6 +295,7 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'finance.partner_bill': [
'period',
'billCount',
'totalAmount',
'billSummary',
'time',
'handleUrl',
@@ -292,8 +303,17 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
],
'finance.winery_bill': [
'period',
'amount',
'billCount',
'totalAmount',
'cityNames',
'partnerNames',
'orderCount',
'orderAmount',
'rate',
'amount',
'payee',
'accountNo',
'bankBranch',
'bankAccount',
'time',
'handleUrl',
@@ -302,6 +322,7 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'finance.logistics_bill': [
'period',
'billCount',
'totalAmount',
'billSummary',
'time',
'handleUrl',
@@ -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);
@@ -208,8 +208,8 @@ export class WecomMessagePushService implements OnModuleInit {
this.logger.log(`seeded wecom push template: ${def.eventKey}`);
continue;
}
const legacy = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey];
if (legacy && existing.body === legacy) {
const legacyBodies = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey] ?? [];
if (legacyBodies.includes(existing.body)) {
await this.prisma.wecomPushTemplate.update({
where: { eventKey: def.eventKey },
data: {
@@ -473,7 +473,10 @@ export class WecomMessagePushService implements OnModuleInit {
skuSummary: '杜康原浆 52度 瓶×2',
deliveryLabel: '同城',
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
phoneMasked: '138****8000',
userName: '好客用户',
userPhone: '13800008000',
userRemark: '大客户',
phoneMasked: '13800008000',
},
handlePath: '/orders?orderNo=DK202608200001',
},
@@ -484,7 +487,8 @@ export class WecomMessagePushService implements OnModuleInit {
storeName: '示例门店',
channel: '扫码',
userName: '好客用户',
userPhone: '138****8000',
userPhone: '13800008000',
userRemark: '大客户',
},
handlePath: '/redeem-records?redeemNo=RD202608200001',
},
@@ -531,9 +535,14 @@ export class WecomMessagePushService implements OnModuleInit {
},
'store.withdraw_approved': {
vars: {
cityName: '郑州',
storeName: '示例门店',
partnerLabel: '郑州某某商贸-张三',
withdrawNo: 'SW202608200001',
amount: '500.00',
payee: '张三',
accountNo: '6222000011112222',
bankBranch: '郑州支行',
bankAccount: '张三 / 郑州支行 / 6222000011112222',
storeId: '1',
},
@@ -553,7 +562,19 @@ export class WecomMessagePushService implements OnModuleInit {
vars: {
period: '2026-08-25',
billCount: '2',
billSummary: '示例门店 ¥300.00 张三 / 郑州支行 / 6222000011112222',
totalAmount: '500.00',
billSummary: [
'城市:郑州',
'门店:示例门店',
'合伙人:郑州某某商贸-张三',
'核销笔数:12',
'核销金额:¥500.00',
'结算比例:60%',
'账单:¥300.00',
'收款人:张三',
'收款账户:6222000011112222',
'收款开户行:郑州支行',
].join('\n'),
},
handlePath: '/finance/store-bills?kind=T1_BILL',
},
@@ -561,15 +582,36 @@ export class WecomMessagePushService implements OnModuleInit {
vars: {
period: '2026-07',
billCount: '1',
billSummary: '郑州某某商贸 ¥120.00 张三 / 郑州支行 / 6222000011112222',
totalAmount: '120.00',
billSummary: [
'城市:郑州',
'合伙人:郑州某某商贸-张三',
'订单笔数:8',
'核销笔数:12',
'订单佣金:¥100.00',
'核销佣金:¥20.00',
'账单:¥120.00',
'收款人:张三',
'收款账户:6222000011112222',
'收款开户行:郑州支行',
].join('\n'),
},
handlePath: '/finance/partner-bills',
},
'finance.winery_bill': {
vars: {
period: '2026-08-23',
billCount: '1',
totalAmount: '1990.00',
cityNames: '郑州、洛阳',
partnerNames: '郑州某某商贸-张三',
amount: '1990.00',
orderCount: '8',
orderAmount: '6633.33',
rate: '30%',
payee: '杜康酒厂',
accountNo: '6222000099998888',
bankBranch: '洛阳支行',
bankAccount: '杜康酒厂 / 洛阳支行 / 6222000099998888',
},
handlePath: '/finance/winery-bills',
@@ -578,7 +620,19 @@ export class WecomMessagePushService implements OnModuleInit {
vars: {
period: '2026-07',
billCount: '1',
billSummary: '小飞侠 ¥86.00 承运商 / 郑州支行 / 6222000033334444',
totalAmount: '86.00',
billSummary: [
'城市:郑州',
'合伙人:郑州某某商贸-张三',
'承运商:小飞侠',
'结算方式:充值扣款',
'发货笔数:10',
'瓶数:60',
'账单:¥86.00',
'收款人:承运商',
'收款账户:6222000033334444',
'收款开户行:郑州支行',
].join('\n'),
},
handlePath: '/finance/logistics-bills',
},
@@ -20,7 +20,8 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'商品:{{skuSummary}}',
'物流:{{deliveryLabel}}',
'收货:{{receiverInfo}}',
'用户:{{phoneMasked}}',
'用户:{{userName}} {{userPhone}}',
'备注:{{userRemark}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
@@ -36,6 +37,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'门店:{{storeName}}',
'渠道:{{channel}}',
'用户:{{userName}} {{userPhone}}',
'备注:{{userRemark}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
@@ -102,10 +104,14 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
title: '门店提现已通过',
body: [
'**门店提现已通过**',
'城市:{{cityName}}',
'门店:{{storeName}}',
'合伙人:{{partnerLabel}}',
'提现单号:{{withdrawNo}}',
'金额:¥{{amount}}',
'收款账户{{bankAccount}}',
'账单:¥{{amount}}',
'收款{{payee}}',
'收款账户:{{accountNo}}',
'收款开户行:{{bankBranch}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
@@ -132,9 +138,12 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**门店日账单已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
@@ -145,9 +154,12 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**合伙人月账单已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
@@ -158,10 +170,20 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**酒厂日账单已生成**',
'账期:{{period}}',
'订单数:{{orderCount}}',
'金额:¥{{amount}}',
'收款账户:{{bankAccount}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'城市:{{cityNames}}',
'合伙人:{{partnerNames}}',
'订单笔数:{{orderCount}}',
'订单金额:¥{{orderAmount}}',
'结算比例:{{rate}}',
'账单:¥{{amount}}',
'收款人:{{payee}}',
'收款账户:{{accountNo}}',
'收款开户行:{{bankBranch}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
@@ -172,62 +194,149 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [
'**物流月对账已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'账单笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}',
'',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
handleLabel: '去处理',
},
];
/** v3.5.3 默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */
export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEventKey, string>> = {
/** 上一版默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */
export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEventKey, string[]>> = {
'order.paid': [
'**订单支付成功**',
'订单号:{{orderNo}}',
'实付:¥{{payAmount}}',
'城市:{{cityName}}',
'商品:{{skuSummary}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**订单支付成功**',
'订单号:{{orderNo}}',
'实付:¥{{payAmount}}',
'城市:{{cityName}}',
'商品:{{skuSummary}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**订单支付成功**',
'订单号:{{orderNo}}',
'实付:¥{{payAmount}}',
'城市:{{cityName}}',
'商品:{{skuSummary}}',
'物流:{{deliveryLabel}}',
'收货:{{receiverInfo}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'redeem.success': [
'**门店核销成功**',
'核销单号:{{redeemNo}}',
'金额:¥{{amount}}',
'门店:{{storeName}}',
'渠道:{{channel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**门店核销成功**',
'核销单号:{{redeemNo}}',
'金额:¥{{amount}}',
'门店:{{storeName}}',
'渠道:{{channel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**门店核销成功**',
'核销单号:{{redeemNo}}',
'金额:¥{{amount}}',
'门店:{{storeName}}',
'渠道:{{channel}}',
'用户:{{userName}} {{userPhone}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'store.audit_pending': [
'**门店审核待处理 · {{action}}**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'合伙人:{{partnerLabel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**门店审核待处理 · {{action}}**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'合伙人:{{partnerLabel}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'store.package_audit_pending': [
'**套餐变更待审核**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'套餐条数:{{packageCount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**套餐变更待审核**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'套餐条数:{{packageCount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'store.info_change_pending': [
'**门店信息变更待审**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'变更字段:{{changedFields}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
[
'**门店信息变更待审**',
'门店:{{storeName}}',
'城市:{{cityName}}',
'提交端:{{submitter}}',
'变更字段:{{changedFields}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'store.withdraw_approved': [
[
'**门店提现已通过**',
'门店:{{storeName}}',
'提现单号:{{withdrawNo}}',
'金额:¥{{amount}}',
'收款账户:{{bankAccount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'finance.store_bill': [
[
'**门店日账单已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'finance.partner_bill': [
[
'**合伙人月账单已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'finance.winery_bill': [
[
'**酒厂日账单已生成**',
'账期:{{period}}',
'订单数:{{orderCount}}',
'金额:¥{{amount}}',
'收款账户:{{bankAccount}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'finance.logistics_bill': [
[
'**物流月对账已生成**',
'账期:{{period}}',
'笔数:{{billCount}}',
'{{billSummary}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
};
export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
@@ -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 }),
@@ -8,7 +8,6 @@ import {
calcRedeemSettleAmount,
generateRedeemNo,
generateRedeemPendingNo,
maskContactPhone,
REDEEM_WEAKNET_FAIL_THRESHOLD,
resolveSettlementRate,
validateRedeemAmount,
@@ -197,7 +196,7 @@ export class RedeemService {
const [userRow, storeRow] = await Promise.all([
this.prisma.user.findUnique({
where: { id: userId },
select: { isTest: true, nickname: true, phone: true },
select: { isTest: true, nickname: true, phone: true, hqRemark: true },
}),
this.prisma.store.findUnique({
where: { id: account.storeId },
@@ -295,7 +294,8 @@ export class RedeemService {
storeName: account.store.name || String(account.storeId),
channel: channelLabel,
userName: userRow?.nickname?.trim() || '—',
userPhone: maskContactPhone(userRow?.phone),
userPhone: (userRow?.phone || '').trim() || '—',
userRemark: (userRow?.hqRemark || '').trim() || '—',
},
{
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import {
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
DEFAULT_XFX_LOGISTICS_PRICING,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
WINERY_SETTLEMENT_LAG_DAYS,
WINERY_SETTLEMENT_RATE,
} from '@dukang/shared-types';
@@ -28,6 +29,17 @@ import {
loadWineryBankConfig,
formatWecomBankAccount,
} from '../../common/store/store-bank.util';
import {
capBillBlocks,
formatLogisticsBillWecomBlock,
formatPartnerBillWecomBlock,
formatPartnerDashLabel,
formatPercent,
formatStoreBillWecomBlock,
joinLimited,
wecomBankParts,
type WecomBankLike,
} from './wecom-bill-digest';
function generateBillNo(prefix: string) {
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -63,12 +75,37 @@ function round2(n: number) {
return Math.round(n * 100) / 100;
}
const BILL_DIGEST_MAX_LINES = 15;
function sumAmounts(rows: Array<{ amount: number }>): string {
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
}
function capBillSummary(lines: string[]): string {
if (!lines.length) return '无';
if (lines.length <= BILL_DIGEST_MAX_LINES) return lines.join('\n');
return `${lines.slice(0, BILL_DIGEST_MAX_LINES).join('\n')}\n…等共 ${lines.length}`;
function storeWecomMeta(
store:
| {
name?: string | null;
cityName?: string | null;
cityRef?: { name: string } | null;
partnerAccount?: {
name: string | null;
companyName: string | null;
parent?: { companyName: string | null } | null;
} | null;
}
| null
| undefined,
fallbackName: string,
) {
const cityName = store?.cityRef?.name?.trim() || store?.cityName?.trim() || '—';
const acc = store?.partnerAccount;
const partnerLabel = formatPartnerDashLabel(
acc?.companyName || acc?.parent?.companyName,
acc?.name,
);
return {
storeName: store?.name?.trim() || fallbackName,
cityName,
partnerLabel,
};
}
function getStoreWithdrawDailyLimit(): number {
@@ -107,24 +144,25 @@ export class SettlementService {
private notifyPartnerBillDigest(
period: string,
rows: Array<{
label: string;
cityName: string;
partnerLabel: string;
orderCount?: number;
redeemCount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
bankAccountName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
bank?: WecomBankLike | null;
}>,
) {
if (!rows.length) return;
const lines = rows.map(
(r) =>
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
);
const blocks = rows.map((r) => formatPartnerBillWecomBlock(r));
void this.wecomPush.dispatchEvent(
'finance.partner_bill',
{
period,
billCount: String(rows.length),
billSummary: capBillSummary(lines),
totalAmount: sumAmounts(rows),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/partner-bills' },
);
@@ -133,25 +171,25 @@ export class SettlementService {
private notifyLogisticsBillDigest(
period: string,
rows: Array<{
label: string;
cityNames: string;
partnerNames: string;
providerName: string;
settlementMethod: string;
orderCount: number;
bottleCount: number;
amount: number;
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
bank?: WecomBankLike | null;
}>,
) {
if (!rows.length) return;
const lines = rows.map(
(r) =>
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
);
const blocks = rows.map((r) => formatLogisticsBillWecomBlock(r));
void this.wecomPush.dispatchEvent(
'finance.logistics_bill',
{
period,
billCount: String(rows.length),
billSummary: capBillSummary(lines),
totalAmount: sumAmounts(rows),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/logistics-bills' },
);
@@ -649,16 +687,34 @@ export class SettlementService {
const [store, bank] = await Promise.all([
this.prisma.store.findUnique({
where: { id: row.storeId },
select: { name: true },
select: {
name: true,
cityName: true,
cityRef: { select: { name: true } },
partnerAccount: {
select: {
name: true,
companyName: true,
parent: { select: { companyName: true } },
},
},
},
}),
loadStorePrimaryBank(this.prisma, row.storeId),
]);
const meta = storeWecomMeta(store, String(row.storeId));
const bankParts = wecomBankParts(bank);
void this.wecomPush.dispatchEvent(
'store.withdraw_approved',
{
storeName: store?.name || String(row.storeId),
cityName: meta.cityName,
storeName: meta.storeName,
partnerLabel: meta.partnerLabel,
withdrawNo: row.withdrawNo,
amount: Number(row.amount).toFixed(2),
payee: bankParts.payee,
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(bank),
storeId: row.storeId.toString(),
},
@@ -930,7 +986,24 @@ export class SettlementService {
withdrawItem: { is: null },
status: 'PENDING',
},
include: { store: { select: { id: true, name: true, settlementRate: true } } },
include: {
store: {
select: {
id: true,
name: true,
cityName: true,
settlementRate: true,
cityRef: { select: { name: true } },
partnerAccount: {
select: {
name: true,
companyName: true,
parent: { select: { companyName: true } },
},
},
},
},
},
});
const byStore = new Map<string, typeof payouts>();
@@ -943,10 +1016,19 @@ export class SettlementService {
let created = 0;
let skipped = 0;
const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = [];
const createdBills: Array<{
storeId: bigint;
storeName: string;
cityName: string;
partnerLabel: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
amount: number;
}> = [];
for (const [storeIdStr, list] of byStore) {
const storeId = BigInt(storeIdStr);
const storeName = list[0]?.store?.name || storeIdStr;
const meta = storeWecomMeta(list[0]?.store, storeIdStr);
const existing = await this.prisma.storeBill.findUnique({
where: { storeId_billDate: { storeId, billDate } },
});
@@ -975,7 +1057,14 @@ export class SettlementService {
});
});
created += 1;
createdBills.push({ storeId, storeName, amount: updatedTotal });
createdBills.push({
storeId,
...meta,
redeemCount: existing.redeemCount + list.length,
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
settlementRate: rate,
amount: updatedTotal,
});
continue;
}
@@ -1001,7 +1090,14 @@ export class SettlementService {
});
});
created += 1;
createdBills.push({ storeId, storeName, amount: payoutAmount });
createdBills.push({
storeId,
...meta,
redeemCount: list.length,
redeemAmount,
settlementRate: rate,
amount: payoutAmount,
});
}
const period = billDate.toISOString().slice(0, 10);
@@ -1010,16 +1106,19 @@ export class SettlementService {
this.prisma,
createdBills.map((b) => b.storeId),
);
const lines = createdBills.map(
(b) =>
`${b.storeName} ¥${b.amount.toFixed(2)} ${formatWecomBankAccount(banks.get(b.storeId.toString()) ?? null)}`,
const blocks = createdBills.map((b) =>
formatStoreBillWecomBlock({
...b,
bank: banks.get(b.storeId.toString()) ?? null,
}),
);
void this.wecomPush.dispatchEvent(
'finance.store_bill',
{
period,
billCount: String(createdBills.length),
billSummary: capBillSummary(lines),
totalAmount: sumAmounts(createdBills),
billSummary: capBillBlocks(blocks),
},
{ handlePath: '/finance/store-bills?kind=T1_BILL' },
);
@@ -1476,15 +1575,19 @@ export class SettlementService {
bankAccountName: true,
bankBranch: true,
bankAccountNo: true,
city: { select: { name: true } },
},
});
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
const created: Array<{
label: string;
cityName: string;
partnerLabel: string;
orderCount?: number;
redeemCount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
bankAccountName: string | null;
bankBranch: string | null;
bankAccountNo: string | null;
bank?: WecomBankLike | null;
}> = [];
for (const p of partners) {
try {
@@ -1497,14 +1600,19 @@ export class SettlementService {
{ notify: false },
);
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true });
const label =
[p.companyName?.trim(), p.name?.trim()].filter(Boolean).join(' · ') || p.id.toString();
created.push({
label,
cityName: p.city?.name?.trim() || '—',
partnerLabel: formatPartnerDashLabel(p.companyName, p.name),
orderCount: Number(bill.orderCount ?? 0),
redeemCount: Number(bill.redeemCount ?? 0),
orderCommission: Number(bill.orderCommission),
redeemCommission: Number(bill.redeemCommission),
amount: Number(bill.totalAmount),
bankAccountName: p.bankAccountName,
bankBranch: p.bankBranch,
bankAccountNo: p.bankAccountNo,
bank: {
bankAccountName: p.bankAccountName,
bankBranch: p.bankBranch,
bankAccountNo: p.bankAccountNo,
},
});
} catch (e) {
results.push({
@@ -1628,24 +1736,37 @@ export class SettlementService {
});
if (opts?.notify !== false) {
const label =
[primary.companyName?.trim(), primary.name?.trim()].filter(Boolean).join(' · ') ||
primary.id.toString();
this.notifyPartnerBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
[
{
label,
amount: totalAmount,
let cityName = '—';
if (primary.cityId) {
const city = await this.prisma.commonCity.findUnique({
where: { id: primary.cityId },
select: { name: true },
});
cityName = city?.name?.trim() || '—';
}
this.notifyPartnerBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [
{
cityName,
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
orderCount: orders.length,
redeemCount: redeems.length,
orderCommission: round2(orderCommission),
redeemCommission: round2(redeemCommission),
amount: totalAmount,
bank: {
bankAccountName: primary.bankAccountName,
bankBranch: primary.bankBranch,
bankAccountNo: primary.bankAccountNo,
},
],
);
},
]);
}
return serializeBigInt(bill);
return serializeBigInt({
...bill,
orderCount: orders.length,
redeemCount: redeems.length,
});
}
/** HQ:发送给合伙人(待审核 → 待合伙人确认) */
@@ -1880,6 +2001,7 @@ export class SettlementService {
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end },
},
include: { city: { select: { name: true } } },
orderBy: { completedAt: 'asc' },
});
@@ -1937,12 +2059,27 @@ export class SettlementService {
const period = billDate.toISOString().slice(0, 10);
const wineryBank = await loadWineryBankConfig(this.prisma);
const bankParts = wecomBankParts(wineryBank);
const cityNames = joinLimited(
orders.map((o) => o.city?.name || ''),
'城',
);
const partnerNames = await this.partnerDashLabels(orders.map((o) => o.partnerAccountIdAtPay));
void this.wecomPush.dispatchEvent(
'finance.winery_bill',
{
period,
amount: wineryAmount.toFixed(2),
billCount: '1',
totalAmount: wineryAmount.toFixed(2),
cityNames,
partnerNames,
orderCount: String(orders.length),
orderAmount: orderAmount.toFixed(2),
rate: formatPercent(rate),
amount: wineryAmount.toFixed(2),
payee: bankParts.payee,
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(wineryBank),
},
{ handlePath: '/finance/winery-bills' },
@@ -2171,12 +2308,14 @@ export class SettlementService {
billId?: string;
}> = [];
const created: Array<{
label: string;
cityNames: string;
partnerNames: string;
providerName: string;
settlementMethod: string;
orderCount: number;
bottleCount: number;
amount: number;
bankAccountName: string | null;
bankName: string | null;
bankBranch: string | null;
bankAccountNo: string | null;
bank?: WecomBankLike | null;
}> = [];
for (const p of providers) {
@@ -2203,15 +2342,8 @@ export class SettlementService {
: String(bill.bill.id)
: undefined,
});
if (!bill.skipped && bill.bill) {
created.push({
label: p.name,
amount: Number(bill.bill.logisticsAmount ?? 0),
bankAccountName: p.bankAccountName,
bankName: p.bankName,
bankBranch: p.bankBranch,
bankAccountNo: p.bankAccountNo,
});
if (!bill.skipped && bill.wecom) {
created.push(bill.wecom);
}
} catch (e) {
results.push({
@@ -2237,6 +2369,20 @@ export class SettlementService {
};
}
private async partnerDashLabels(ids: Array<bigint | null | undefined>, max = 5): Promise<string> {
const uniq = [...new Set(ids.filter((id): id is bigint => id != null))];
if (!uniq.length) return '—';
const partners = await this.prisma.partnerAccount.findMany({
where: { id: { in: uniq } },
select: { name: true, companyName: true },
});
return joinLimited(
partners.map((p) => formatPartnerDashLabel(p.companyName, p.name)),
'家',
max,
);
}
async generateLogisticsBill(
body: { providerId: string; year: number; month: number },
opts?: { notify?: boolean },
@@ -2291,6 +2437,8 @@ export class SettlementService {
quantity: true,
deliveryType: true,
payStatus: true,
partnerAccountIdAtPay: true,
city: { select: { name: true } },
},
},
},
@@ -2392,25 +2540,38 @@ export class SettlementService {
return header;
});
const wecom = {
cityNames: joinLimited(
eligible.map((d) => d.order.city?.name || ''),
'城',
),
partnerNames: await this.partnerDashLabels(eligible.map((d) => d.order.partnerAccountIdAtPay)),
providerName: provider.name,
settlementMethod:
LOGISTICS_SETTLEMENT_METHOD_LABELS[
provider.settlementMethod as keyof typeof LOGISTICS_SETTLEMENT_METHOD_LABELS
] ?? String(provider.settlementMethod),
orderCount,
bottleCount,
amount: Number(bill.logisticsAmount),
bank: {
bankAccountName: provider.bankAccountName,
bankName: provider.bankName,
bankBranch: provider.bankBranch,
bankAccountNo: provider.bankAccountNo,
},
};
if (opts?.notify !== false) {
this.notifyLogisticsBillDigest(
`${body.year}-${String(body.month).padStart(2, '0')}`,
[
{
label: provider.name,
amount: Number(bill.logisticsAmount),
bankAccountName: provider.bankAccountName,
bankName: provider.bankName,
bankBranch: provider.bankBranch,
bankAccountNo: provider.bankAccountNo,
},
],
);
this.notifyLogisticsBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [
wecom,
]);
}
return {
skipped: false,
bill: serializeBigInt(bill),
wecom,
};
}
@@ -0,0 +1,157 @@
/** 企微结算账单正文:分字段块 + 字节上限(机器人 markdown 约 4096 字节) */
export type WecomBankLike = {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
export function formatYuan(n: number): string {
return `¥${Number(n).toFixed(2)}`;
}
export function formatPercent(rate: number): string {
const p = Number(rate) * 100;
if (!Number.isFinite(p)) return '—';
const rounded = Math.round(p * 100) / 100;
const text = Number.isInteger(rounded) ? String(rounded) : String(rounded);
return `${text}%`;
}
export function formatPartnerDashLabel(
company?: string | null,
name?: string | null,
): string {
const person = (name || '').trim() || '—';
const org = (company || '').trim();
return org ? `${org}-${person}` : person;
}
export function wecomBankParts(bank?: WecomBankLike | null): {
payee: string;
accountNo: string;
bankBranch: string;
} {
if (!bank) return { payee: '未登记', accountNo: '—', bankBranch: '—' };
const payee = bank.bankAccountName?.trim() || '';
const accountNo = bank.bankAccountNo?.trim() || '';
const bankBranch = bank.bankName?.trim() || bank.bankBranch?.trim() || '';
if (!payee && !accountNo) {
return { payee: '未登记', accountNo: '—', bankBranch: bankBranch || '—' };
}
return {
payee: payee || '—',
accountNo: accountNo || '—',
bankBranch: bankBranch || '—',
};
}
export function formatWecomFieldBlock(rows: Array<[string, string]>): string {
return rows.map(([k, v]) => `${k}${v}`).join('\n');
}
export function joinLimited(names: string[], unit: string, max = 8): string {
const uniq = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
if (!uniq.length) return '—';
if (uniq.length <= max) return uniq.join('、');
return `${uniq.slice(0, max).join('、')}${uniq.length}${unit}`;
}
const BILL_DIGEST_MAX_BYTES = 3200;
export function capBillBlocks(blocks: string[]): string {
if (!blocks.length) return '无';
const out: string[] = [];
let bytes = 0;
let shown = 0;
for (const block of blocks) {
const extra = (shown ? 2 : 0) + Buffer.byteLength(block, 'utf8');
if (shown > 0 && bytes + extra > BILL_DIGEST_MAX_BYTES) {
out.push(`另有 ${blocks.length - shown} 笔未列出,请到 HQ 查看`);
break;
}
out.push(block);
bytes += extra;
shown += 1;
}
return out.join('\n\n');
}
export function formatStoreBillWecomBlock(row: {
cityName: string;
storeName: string;
partnerLabel: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
amount: number;
bank?: WecomBankLike | null;
}): string {
const bank = wecomBankParts(row.bank);
return formatWecomFieldBlock([
['城市', row.cityName || '—'],
['门店', row.storeName || '—'],
['合伙人', row.partnerLabel || '—'],
['核销笔数', String(row.redeemCount)],
['核销金额', formatYuan(row.redeemAmount)],
['结算比例', formatPercent(row.settlementRate)],
['账单', formatYuan(row.amount)],
['收款人', bank.payee],
['收款账户', bank.accountNo],
['收款开户行', bank.bankBranch],
]);
}
export function formatPartnerBillWecomBlock(row: {
cityName: string;
partnerLabel: string;
orderCount?: number;
redeemCount?: number;
orderCommission: number;
redeemCommission: number;
amount: number;
bank?: WecomBankLike | null;
}): string {
const bank = wecomBankParts(row.bank);
const rows: Array<[string, string]> = [
['城市', row.cityName || '—'],
['合伙人', row.partnerLabel || '—'],
];
if (row.orderCount != null) rows.push(['订单笔数', String(row.orderCount)]);
if (row.redeemCount != null) rows.push(['核销笔数', String(row.redeemCount)]);
rows.push(
['订单佣金', formatYuan(row.orderCommission)],
['核销佣金', formatYuan(row.redeemCommission)],
['账单', formatYuan(row.amount)],
['收款人', bank.payee],
['收款账户', bank.accountNo],
['收款开户行', bank.bankBranch],
);
return formatWecomFieldBlock(rows);
}
export function formatLogisticsBillWecomBlock(row: {
cityNames: string;
partnerNames: string;
providerName: string;
settlementMethod: string;
orderCount: number;
bottleCount: number;
amount: number;
bank?: WecomBankLike | null;
}): string {
const bank = wecomBankParts(row.bank);
return formatWecomFieldBlock([
['城市', row.cityNames || '—'],
['合伙人', row.partnerNames || '—'],
['承运商', row.providerName || '—'],
['结算方式', row.settlementMethod || '—'],
['发货笔数', String(row.orderCount)],
['瓶数', String(row.bottleCount)],
['账单', formatYuan(row.amount)],
['收款人', bank.payee],
['收款账户', bank.accountNo],
['收款开户行', bank.bankBranch],
]);
}
@@ -10,7 +10,6 @@ import type { FreightPayType } from '@prisma/client';
import {
calcBenefitAmount,
generateOrderNo,
maskContactPhone,
minBottleQtyForDelivery,
orderTabToStatuses,
toMinSaleQuantity,
@@ -358,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',
@@ -522,11 +505,20 @@ export class TradeService {
where: { id: orderId },
include: {
city: { select: { name: true } },
user: { select: { phone: true } },
user: { select: { phone: true, nickname: true, hqRemark: true } },
},
});
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();
@@ -543,7 +535,9 @@ export class TradeService {
order.deliveryType === 'ON_SITE_PICKUP'
? `${order.receiverName} ${order.receiverPhone}`.trim()
: `${order.receiverName} ${order.receiverPhone} ${order.receiverAddress}`.trim();
const phoneMasked = maskContactPhone(order.user?.phone || order.receiverPhone);
const userName = order.user?.nickname?.trim() || '—';
const userPhone = (order.user?.phone || '').trim() || '—';
const userRemark = (order.user?.hqRemark || '').trim() || '—';
void this.wecomPush.dispatchEvent(
'order.paid',
{
@@ -553,7 +547,10 @@ export class TradeService {
skuSummary,
deliveryLabel,
receiverInfo,
phoneMasked,
userName,
userPhone,
userRemark,
phoneMasked: userPhone,
},
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
);
@@ -1589,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);
@@ -1930,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',
@@ -2343,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,