26 Commits

Author SHA1 Message Date
jacy 3a9f27eb38 merge(dev): v3.5.12 大屏BGM、中文展示、分类删除与侧栏
CI / verify (push) Has been cancelled
2026-08-26 10:55:40 +08:00
jacy 6a5c0cb090 Merge pull request 'Dev' (#53) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/53
2026-08-26 09:56:50 +08:00
jacy 00bb6a2227 Merge pull request 'Dev' (#51) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/51
2026-08-26 07:16:04 +08:00
jacy 93390ccf75 merge(dev): 同城运费路由与配送单信息
CI / verify (push) Has been cancelled
2026-08-25 21:13:05 +08:00
jacy 8f2ebbfef4 merge(dev): 补承运商删除操作常量
CI / verify (push) Has been cancelled
2026-08-25 14:32:32 +08:00
jacy f2fbf95c53 merge(dev): v3.5.10 同城配送提示与企微客服
CI / verify (push) Has been cancelled
2026-08-25 14:30:43 +08:00
jacy 43de361e61 merge(dev): 列设置靠右与订单状态多选
CI / verify (push) Has been cancelled
2026-08-25 14:20:39 +08:00
jacy 0b548a2764 Merge pull request 'Dev' (#49) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/49
2026-08-25 12:39:52 +08:00
jacy 40e8a12596 Merge pull request 'Dev' (#47) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/47
2026-08-25 10:10:20 +08:00
jacy 3d819dc10b Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#45) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/45
2026-08-25 09:23:12 +08:00
jacy 8984465893 Merge pull request 'Dev' (#43) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/43
2026-08-23 19:27:28 +08:00
jacy 3f208d27a9 Merge pull request 'Dev' (#41) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/41
2026-08-23 17:34:40 +08:00
jacy fa69ced448 Merge pull request 'Dev v3.5.6' (#39) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/39
2026-08-23 16:43:51 +08:00
jacy 18c5abea79 Merge pull request 'v3.5.5版本合并' (#37) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/37
2026-08-23 12:30:04 +08:00
jacy 5d4ed566de merge(dev): HQ product list as SPU with expandable SKUs
CI / verify (push) Has been cancelled
2026-08-21 23:52:41 +08:00
jacy 5fdddae41c merge(dev): v3.5.4 规格主图与门店使用规则
CI / verify (push) Has been cancelled
2026-08-21 17:37:20 +08:00
jacy 22c0b03a47 Merge pull request 'Dev' (#35) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/35
2026-08-21 16:24:07 +08:00
jacy f2d03e2595 Merge pull request 'v3.5.4' (#33) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/33
2026-08-21 15:50:30 +08:00
jacy 0c28c204b2 Merge pull request 'Dev' (#31) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/31
2026-08-20 20:23:20 +08:00
jacy e7f49a9639 Merge pull request 'v3.5.3版本更新1' (#28) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/28
2026-08-20 18:55:18 +08:00
jacy f941dcf072 Merge pull request 'Dev' (#27) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/27
2026-08-20 15:06:24 +08:00
jacy 3382d36a6c Merge pull request 'Dev jacy 大屏优化' (#24) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/24
2026-08-20 13:43:06 +08:00
jacy 9b8e3f1347 Merge pull request 'v3.5.2 大屏调整' (#23) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/23
2026-08-20 12:01:21 +08:00
jacy 1440a59fc8 Merge pull request 'Dev' (#21) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/21
2026-08-20 00:54:54 +08:00
jacy 11659484c6 Merge pull request 'v3.5.1 版本更新' (#19) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/19
2026-08-19 16:01:31 +08:00
jacy 0fb7ab7abb Merge pull request 'v3.4.18' (#17) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/17
2026-08-17 23:45:19 +08:00
43 changed files with 496 additions and 1825 deletions
-50
View File
@@ -74,56 +74,6 @@ body.admin-col-resizing * {
user-select: none !important; user-select: none !important;
} }
/* 订单页筛选:第一行常驻,第二行起可收起 */
.admin-orders-filter-row {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
align-items: flex-end;
margin-bottom: 12px;
}
.admin-orders-filter-row--tools {
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.admin-orders-filter-item {
margin-bottom: 0 !important;
}
.admin-orders-filter-item--sm {
width: 140px;
}
.admin-orders-filter-item--md {
width: 168px;
}
.admin-orders-filter-item--search {
width: 180px;
}
.admin-orders-filter-item--status {
width: 220px;
min-width: 180px;
flex: 1 1 180px;
max-width: 280px;
}
.admin-orders-filter-item--date {
flex: 1 1 auto;
}
.admin-orders-filter-actions {
margin-left: auto;
}
.admin-orders-date-picker {
width: 240px;
}
/* 列表页顶栏:标题左、主操作右、列设置最右 */ /* 列表页顶栏:标题左、主操作右、列设置最右 */
.admin-list-header { .admin-list-header {
display: flex; display: flex;
@@ -15,13 +15,7 @@ type Props = {
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) { export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
const user = detail.user as const user = detail.user as
| { | { userNo?: string; nickname?: string | null; phone?: string | null }
id?: string;
userNo?: string;
nickname?: string | null;
phone?: string | null;
hqRemark?: string | null;
}
| undefined; | undefined;
const store = detail.store as const store = detail.store as
| { | {
@@ -47,13 +41,7 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item> <Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item> <Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户昵称"> <Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
{(() => {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
})()}
</Descriptions.Item>
<Descriptions.Item label="用户手机"> <Descriptions.Item label="用户手机">
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'} {maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
</Descriptions.Item> </Descriptions.Item>
+1 -1
View File
@@ -229,7 +229,7 @@ export type AdminOrderRow = {
proxyPartnerName?: string | null; proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null; proxyPartnerPhone?: string | null;
isTest?: boolean; isTest?: boolean;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { delivery?: {
provider: string; provider: string;
trackingNo: string | null; trackingNo: string | null;
+13 -41
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -111,16 +111,9 @@ type CouponDetail = Row & {
export default function BenefitCouponsPage() { export default function BenefitCouponsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>(); const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>(() => { const [filters, setFilters] = useState<Record<string, string>>({});
const init: Record<string, string> = {};
if (initialCouponNo) init.couponNo = initialCouponNo;
return init;
});
const [grantOpen, setGrantOpen] = useState(false); const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false); const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
@@ -139,35 +132,6 @@ export default function BenefitCouponsPage() {
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null); const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false); const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false); const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
const deepLinkOpenedRef = useRef(false);
async function openCouponDetail(id: string) {
setDetail(await request<CouponDetail>(`/admin/benefit/coupons/${id}`));
setDrawerOpen(true);
}
useEffect(() => {
if (initialCouponNo) form.setFieldsValue({ couponNo: initialCouponNo });
}, [form, initialCouponNo]);
useEffect(() => {
const openCouponId = (location.state as { openCouponId?: string } | null)?.openCouponId;
if (openCouponId && !deepLinkOpenedRef.current) {
deepLinkOpenedRef.current = true;
void openCouponDetail(openCouponId).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
return;
}
if (!initialCouponNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0];
if (first && String(first.couponNo) === initialCouponNo) {
deepLinkOpenedRef.current = true;
void openCouponDetail(first.id).catch((e) => {
message.error(e instanceof Error ? e.message : '加载权益券失败');
});
}
}, [data, initialCouponNo, loading, location.state]);
async function openRedeemDetail(redeemId: string) { async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true); setRedeemDetailLoading(true);
@@ -190,7 +154,12 @@ export default function BenefitCouponsPage() {
width: 200, width: 200,
ellipsis: false, ellipsis: false,
render: (v, row) => ( render: (v, row) => (
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}> <AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
{v} {v}
</AdminPrimaryLink> </AdminPrimaryLink>
), ),
@@ -242,7 +211,10 @@ export default function BenefitCouponsPage() {
<Button <Button
type="link" type="link"
size="small" size="small"
onClick={() => void openCouponDetail(row.id)} onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
> >
</Button> </Button>
+6 -38
View File
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>(); const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
useEffect(() => { useEffect(() => {
if (!modalOpen && !batchEditOpen) return; if (!batchEditOpen) return;
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100') request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
.then((res) => setVersions(res.items ?? [])) .then((res) => setVersions(res.items ?? []))
.catch(() => setVersions([])); .catch(() => setVersions([]));
}, [modalOpen, batchEditOpen]); }, [batchEditOpen]);
useEffect(() => { useEffect(() => {
if (!modalOpen || editing) return; if (!modalOpen || editing) return;
@@ -108,7 +108,6 @@ export default function DevPlanTasksPage() {
content: row.content, content: row.content,
type: row.type, type: row.type,
status: row.status, status: row.status,
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''], attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
}); });
setModalOpen(true); setModalOpen(true);
@@ -122,11 +121,7 @@ export default function DevPlanTasksPage() {
if (editing) { if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, { await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({ ...values, attachmentUrls }),
...values,
attachmentUrls,
versionIds: values.versionIds ?? [],
}),
}); });
message.success('已更新'); message.success('已更新');
} else { } else {
@@ -290,21 +285,6 @@ export default function DevPlanTasksPage() {
), ),
}, },
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' }, { title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
{
title: '关联版本',
dataIndex: 'versions',
width: 140,
render: (_, row) =>
row.versions?.length ? (
<Space size={4} wrap>
{row.versions.map((v) => (
<Tag key={v.id}>{v.versionNo}</Tag>
))}
</Space>
) : (
'—'
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 90 }, { title: '创建人', dataIndex: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -425,21 +405,9 @@ export default function DevPlanTasksPage() {
<Select options={TYPE_OPTIONS} /> <Select options={TYPE_OPTIONS} />
</Form.Item> </Form.Item>
{editing ? ( {editing ? (
<> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <Select options={STATUS_OPTIONS} />
<Select options={STATUS_OPTIONS} /> </Form.Item>
</Form.Item>
<Form.Item name="versionIds" label="关联版本">
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="可选:关联版本"
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
/>
</Form.Item>
</>
) : ( ) : (
<Form.Item name="supportTicketId" label="绑定技术支持工单"> <Form.Item name="supportTicketId" label="绑定技术支持工单">
<Select <Select
+109 -215
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Checkbox, Checkbox,
Col,
Collapse, Collapse,
DatePicker, DatePicker,
Descriptions, Descriptions,
@@ -13,6 +14,7 @@ import {
InputNumber, InputNumber,
Modal, Modal,
Radio, Radio,
Row,
Select, Select,
Space, Space,
Table, Table,
@@ -22,7 +24,6 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs'; import dayjs, { type Dayjs } from 'dayjs';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import { ORDER_TYPE_LABELS } from '@dukang/shared-types'; import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api'; import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
@@ -149,11 +150,9 @@ type OrderExportFilters = {
orderType?: string; orderType?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
userKeyword?: string;
productKeyword?: string;
fulfillmentHold?: boolean; fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string; deliveryType?: string;
promoCodeId?: string;
dateRange?: [Dayjs, Dayjs]; dateRange?: [Dayjs, Dayjs];
}; };
@@ -213,17 +212,6 @@ const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year';
{ key: 'year', label: '当年' }, { key: 'year', label: '当年' },
]; ];
const FILTERS_COLLAPSED_KEY = 'admin-orders-filters-collapsed';
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) { function formatBenefitBrief(row: AdminOrderRow) {
const coupon = row.benefitCoupon; const coupon = row.benefitCoupon;
if (coupon) { if (coupon) {
@@ -256,11 +244,9 @@ function buildExportPayload(
if (filters.orderType) payload.orderType = filters.orderType; if (filters.orderType) payload.orderType = filters.orderType;
if (filters.cityId) payload.cityId = filters.cityId; if (filters.cityId) payload.cityId = filters.cityId;
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone; if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
if (filters.userKeyword) payload.userKeyword = filters.userKeyword;
if (filters.productKeyword) payload.productKeyword = filters.productKeyword;
if (filters.deliveryType) payload.deliveryType = filters.deliveryType; if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
if (filters.fulfillmentHold) payload.fulfillmentHold = true; if (filters.fulfillmentHold) payload.fulfillmentHold = true;
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId; if (filters.excludeTest) payload.excludeTest = true;
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD'); 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'); if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
return payload; return payload;
@@ -299,15 +285,8 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
export default function OrdersPage() { export default function OrdersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || ''; 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 [form] = Form.useForm();
const [shipForm] = Form.useForm(); const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm(); const [logisticsForm] = Form.useForm();
@@ -338,13 +317,6 @@ export default function OrdersPage() {
const [trackOpen, setTrackOpen] = useState(false); const [trackOpen, setTrackOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx'); const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
const [filtersCollapsed, setFiltersCollapsed] = useState(() => {
try {
return localStorage.getItem(FILTERS_COLLAPSED_KEY) === '1';
} catch {
return false;
}
});
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete'); const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders'); const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders'); const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
@@ -364,20 +336,6 @@ export default function OrdersPage() {
} }
}, [form, initialOrderNo]); }, [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(() => { useEffect(() => {
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return; if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
const first = data?.items?.[0]; const first = data?.items?.[0];
@@ -408,17 +366,13 @@ export default function OrdersPage() {
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (initialOrderNo) qs.set('orderNo', initialOrderNo); if (initialOrderNo) qs.set('orderNo', initialOrderNo);
if (values.orderNo) qs.set('orderNo', values.orderNo); if (values.orderNo) qs.set('orderNo', values.orderNo);
const statuses = selectedStatuses(values.status); for (const status of selectedStatuses(values.status)) qs.append('status', 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.orderType) qs.set('orderType', values.orderType);
if (values.cityId) qs.set('cityId', values.cityId); if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone); if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
if (values.userKeyword) qs.set('userKeyword', values.userKeyword);
if (values.productKeyword) qs.set('productKeyword', values.productKeyword);
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true'); if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
if (values.excludeTest) qs.set('excludeTest', 'true');
if (values.deliveryType) qs.set('deliveryType', values.deliveryType); 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?.[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')); if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`); const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
@@ -426,7 +380,7 @@ export default function OrdersPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]); }, [form, page, pageSize, initialOrderNo]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -635,16 +589,7 @@ export default function OrdersPage() {
} }
setExporting(true); setExporting(true);
try { try {
const payload = buildExportPayload( const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
scope,
exportFormat,
{
...values,
promoCodeId: initialPromoCodeId || values.promoCodeId,
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
},
selectedRowKeys,
);
const result = await request<OrderExportResult>('/admin/orders/export', { const result = await request<OrderExportResult>('/admin/orders/export', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
@@ -673,14 +618,14 @@ export default function OrdersPage() {
{ {
title: '用户', title: '用户',
key: 'user', key: 'user',
width: 200, width: 120,
render: (_, row) => render: (_, row) =>
row.user?.id ? ( row.user?.id ? (
<AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}> <AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
{formatUserWithRemark(row.user)} {row.user.userNo}
</AdminPrimaryLink> </AdminPrimaryLink>
) : ( ) : (
formatUserWithRemark(row.user) (row.user?.userNo || '—')
), ),
}, },
{ {
@@ -833,93 +778,15 @@ export default function OrdersPage() {
/> />
) : null} ) : 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(); }}> <Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<div className="admin-orders-filter-row"> <Row gutter={[16, 8]}>
<Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm"> <Col xs={24} md={14} lg={12}>
<Select <Form.Item label="下单日期" style={{ marginBottom: 12 }}>
allowClear <Space direction="vertical" size={8} style={{ width: '100%' }}>
showSearch <Form.Item name="dateRange" noStyle>
optionFilterProp="label" <DatePicker.RangePicker allowClear style={{ width: '100%' }} />
placeholder="全部" </Form.Item>
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input allowClear placeholder="手机号" />
</Form.Item>
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="名称/规格" />
</Form.Item>
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
<Select
mode="multiple"
allowClear
maxTagCount="responsive"
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<>
<div className="admin-orders-filter-row">
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
<Space size={8} wrap> <Space size={8} wrap>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
</Form.Item>
{DATE_PRESETS.map((preset) => ( {DATE_PRESETS.map((preset) => (
<Button <Button
key={preset.key} key={preset.key}
@@ -930,60 +797,98 @@ export default function OrdersPage() {
</Button> </Button>
))} ))}
</Space> </Space>
</Form.Item> </Space>
</div> </Form.Item>
</Col>
<div className="admin-orders-filter-row admin-orders-filter-row--tools"> <Col xs={24} sm={12} md={8} lg={6}>
{canExportOrders ? ( <Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Space size={12} wrap> <Select
<Radio.Group mode="multiple"
optionType="button" allowClear
buttonStyle="solid" maxTagCount="responsive"
value={exportFormat} placeholder="全部"
onChange={(e) => setExportFormat(e.target.value)} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
> />
<Radio.Button value="xlsx">Excel</Radio.Button> </Form.Item>
<Radio.Button value="pdf">PDF</Radio.Button> </Col>
</Radio.Group> <Col xs={12} sm={6} md={5} lg={3}>
<Button <Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
disabled={!selectedRowKeys.length} <Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
loading={exporting} </Form.Item>
onClick={() => void submitExport('selected')} </Col>
> <Col xs={12} sm={6} md={5} lg={3}>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''} <Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
</Button> <Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
<Button loading={exporting} onClick={() => void submitExport('filter')}> </Form.Item>
</Col>
</Button> <Col xs={12} sm={6} md={5} lg={3}>
</Space> <Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
) : <span />} <Select
<Space size={12} wrap> allowClear
showSearch
optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={6}>
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
<Input allowClear placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={8} lg={8}>
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
<Space size={16} wrap>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle> <Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox> <Checkbox></Checkbox>
</Form.Item> </Form.Item>
<Button <Form.Item name="excludeTest" valuePropName="checked" noStyle>
onClick={() => { <Checkbox></Checkbox>
form.resetFields(); </Form.Item>
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space> </Space>
</div> </Form.Item>
</> </Col>
) : null} </Row>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
{canExportOrders ? (
<Space size={12} wrap>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<Radio.Button value="xlsx">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
<Button
disabled={!selectedRowKeys.length}
loading={exporting}
onClick={() => void submitExport('selected')}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
<Button loading={exporting} onClick={() => void submitExport('filter')}>
</Button>
</Space>
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</div>
</Form> </Form>
<Table <Table
@@ -1082,18 +987,7 @@ export default function OrdersPage() {
{[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'} {[detail.proxyPartnerName, detail.proxyPartnerPhone].filter(Boolean).join(' / ') || '—'}
</Descriptions.Item> </Descriptions.Item>
) : null} ) : null}
<Descriptions.Item label="用户"> <Descriptions.Item label="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
{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.productAmount}</Descriptions.Item>
<Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item> <Descriptions.Item label="实付">¥{detail.payAmount}</Descriptions.Item>
<Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item> <Descriptions.Item label="好客权益">¥{detail.benefitAmount}</Descriptions.Item>
+30 -22
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message, Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
@@ -23,6 +23,21 @@ type Row = PromoCodeItem;
type SceneOption = { value: PromoCodeScene; label: string }; 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() { export default function PromoCodesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
@@ -89,26 +104,8 @@ export default function PromoCodesPage() {
</Tag> </Tag>
), ),
}, },
{ { title: '扫码', dataIndex: 'scanCount', width: 70 },
title: '扫码', { title: '订单', dataIndex: 'orderCount', width: 70 },
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: '转化率', title: '转化率',
width: 90, width: 90,
@@ -122,13 +119,24 @@ export default function PromoCodesPage() {
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 140, width: 220,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size="small" wrap> <Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}> <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button> </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 type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
</Button> </Button>
+11 -59
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd'; import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types'; import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
@@ -20,28 +20,17 @@ type Row = {
channel?: RedeemChannel; channel?: RedeemChannel;
createdAt: string; createdAt: string;
isTest?: boolean; isTest?: boolean;
user?: { user?: { userNo: string; phone: string | null; nickname?: string | null };
id?: string;
userNo: string;
phone: string | null;
nickname?: string | null;
hqRemark?: string | null;
};
store?: { name: string; cityName: string }; store?: { name: string; cityName: string };
coupon?: { id?: string; couponNo: string }; coupon?: { couponNo: string };
}; };
function formatNicknameWithRemark(user?: { function maskPhone(phone: string | null | undefined) {
nickname?: string | null; if (!phone || phone.length < 7) return phone ?? '—';
hqRemark?: string | null; return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
} | null) {
const name = user?.nickname?.trim() || '—';
const remark = user?.hqRemark?.trim();
return remark ? `${name}${remark}` : name;
} }
export default function RedeemRecordsPage() { export default function RedeemRecordsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || ''; const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -119,60 +108,23 @@ export default function RedeemRecordsPage() {
); );
}, },
}, },
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) => { title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{v || '—'}
</AdminPrimaryLink>
) : (
v || '—'
),
},
{ {
title: '用户昵称', title: '用户昵称',
dataIndex: ['user', 'nickname'], dataIndex: ['user', 'nickname'],
width: 160, width: 100,
render: (_: string | null | undefined, row) => render: (v: string | null | undefined) => v || '—',
row.user?.id ? (
<AdminPrimaryLink
onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}
>
{formatNicknameWithRemark(row.user)}
</AdminPrimaryLink>
) : (
formatNicknameWithRemark(row.user)
),
}, },
{ {
title: '用户手机', title: '用户手机',
dataIndex: ['user', 'phone'], dataIndex: ['user', 'phone'],
width: 120, width: 120,
render: (v: string | null | undefined) => v || '—', render: (v: string | null | undefined) => maskPhone(v),
}, },
{ title: '门店', dataIndex: ['store', 'name'] }, { title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` }, { title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` }, { title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
{ { title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
title: '券号',
dataIndex: ['coupon', 'couponNo'],
width: 160,
render: (v: string | undefined, row) =>
v ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/benefit/coupons?couponNo=${encodeURIComponent(v)}`, {
state: row.coupon?.id ? { openCouponId: String(row.coupon.id) } : undefined,
})
}
>
{v}
</AdminPrimaryLink>
) : (
'—'
),
},
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
+7 -39
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { import {
Button, Button,
Card, Card,
@@ -14,7 +14,6 @@ import {
Statistic, Statistic,
Table, Table,
Tag, Tag,
Tooltip,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
@@ -76,7 +75,6 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
}; };
export default function StoreBillsPage() { export default function StoreBillsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : ''; const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const initialStoreId = searchParams.get('storeId') || ''; const initialStoreId = searchParams.get('storeId') || '';
@@ -296,11 +294,7 @@ export default function StoreBillsPage() {
), ),
}, },
{ {
title: ( title: '日期',
<Tooltip title="T+1 为出账日(昨日 00:00–今日 00:00 窗口中的今天,不是该账单里最后一笔核销时间);手动提现为申请时间">
</Tooltip>
),
dataIndex: 'date', dataIndex: 'date',
width: 160, width: 160,
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)), render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
@@ -395,7 +389,6 @@ export default function StoreBillsPage() {
<> <>
T+1 沿 T+1 沿
= T+1 = = T+1 =
T+1 00:00 00:00
{overdueSummary && overdueSummary.overdueCount > 0 ? ( {overdueSummary && overdueSummary.overdueCount > 0 ? (
<div> <div>
<Typography.Text type="danger"> <Typography.Text type="danger">
@@ -476,7 +469,7 @@ export default function StoreBillsPage() {
}))} }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="range" label="出账日"> <Form.Item name="range" label="日">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -550,7 +543,7 @@ export default function StoreBillsPage() {
<> <>
<Descriptions column={1} size="small" bordered> <Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item> <Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账日"> <Descriptions.Item label="账日">
{String(detail.billDate || '').slice(0, 10)} {String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="应付"> <Descriptions.Item label="应付">
@@ -599,22 +592,8 @@ export default function StoreBillsPage() {
columns={[ columns={[
{ {
title: '核销单号', title: '核销单号',
render: (_, r) => { render: (_, r) =>
const redeemNo = String( String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
);
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
},
}, },
{ {
title: '金额', title: '金额',
@@ -683,18 +662,7 @@ export default function StoreBillsPage() {
const payout = r.storePayout as const payout = r.storePayout as
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number } | { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
| undefined; | undefined;
const redeemNo = payout?.redeemRecord?.redeemNo || ''; return String(payout?.redeemRecord?.redeemNo || '');
return redeemNo ? (
<AdminPrimaryLink
onClick={() =>
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
}
>
{redeemNo}
</AdminPrimaryLink>
) : (
'—'
);
}, },
}, },
{ {
+44 -38
View File
@@ -47,7 +47,6 @@ import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload'; import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns'; import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink'; import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
@@ -561,43 +560,50 @@ export default function SupportTicketsPage() {
return ( return (
<div> <div>
{settingsModal} {settingsModal}
<AdminListHeader <div
title="技术支持" style={{
settings={settingsButton} display: 'flex',
actions={ justifyContent: 'space-between',
<> alignItems: 'center',
{isSuperAdmin ? ( marginBottom: 16,
<> }}
<Button >
disabled={!selectedRowKeys.length} <Typography.Title level={4} style={{ margin: 0 }}>
loading={batchCreateSaving}
onClick={() => void submitBatchCreateTasks()} </Typography.Title>
> {settingsButton}
<Space>
</Button> {isSuperAdmin ? (
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}> <>
<Button
</Button> disabled={!selectedRowKeys.length}
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}> loading={batchCreateSaving}
onClick={() => void submitBatchCreateTasks()}
</Button> >
<Button
disabled={!selectedRowKeys.length} </Button>
onClick={() => { <Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
setBatchStatusOpen(true); </Button>
}} <Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
>
</Button>
</Button> <Button
</> disabled={!selectedRowKeys.length}
) : null} onClick={() => {
<Button type="primary" onClick={() => setCreateOpen(true)}> batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
setBatchStatusOpen(true);
</Button> }}
</> >
}
/> </Button>
</>
) : null}
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
</div>
<Form <Form
layout="inline" layout="inline"
+11 -52
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
@@ -155,7 +155,6 @@ type BatchDeletePreview = {
export default function UsersPage() { export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null); const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null); const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
@@ -186,8 +185,6 @@ export default function UsersPage() {
try { try {
const values = form.getFieldsValue(); const values = form.getFieldsValue();
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) }); 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.phone) qs.set('phone', values.phone);
if (values.userNo) qs.set('userNo', values.userNo); if (values.userNo) qs.set('userNo', values.userNo);
if (values.deviceKey) qs.set('deviceKey', values.deviceKey); if (values.deviceKey) qs.set('deviceKey', values.deviceKey);
@@ -205,26 +202,17 @@ export default function UsersPage() {
} }
}, [form, page, pageSize]); }, [form, page, pageSize]);
useEffect(() => {
const userId = searchParams.get('userId');
if (userId) form.setFieldsValue({ userId });
}, [searchParams, form]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load, searchParams]); }, [load]);
useEffect(() => { useEffect(() => {
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId; const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
if (!openUserId) return; if (openUserId) {
form.setFieldsValue({ userId: openUserId }); void openDetail(openUserId);
setPage(1); navigate(location.pathname, { replace: true, state: null });
void openDetail(openUserId); }
navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, { }, [location.state, location.pathname, navigate]);
replace: true,
state: null,
});
}, [location.state, location.pathname, navigate, form]);
async function openDetail(id: string) { async function openDetail(id: string) {
const [res, logs] = await Promise.all([ const [res, logs] = await Promise.all([
@@ -510,30 +498,13 @@ export default function UsersPage() {
) : null ) : null
} }
/> />
<Form <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
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="手机号"> <Form.Item name="phone" label="手机号">
<Input placeholder="模糊搜索" allowClear /> <Input placeholder="模糊搜索" allowClear />
</Form.Item> </Form.Item>
<Form.Item name="userNo" label="用户编号"> <Form.Item name="userNo" label="用户编号">
<Input placeholder="DK..." allowClear /> <Input placeholder="DK..." allowClear />
</Form.Item> </Form.Item>
<Form.Item name="userId" label="用户ID">
<Input placeholder="精确匹配" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="deviceKey" label="deviceKey"> <Form.Item name="deviceKey" label="deviceKey">
<Input placeholder="UUID" allowClear style={{ width: 200 }} /> <Input placeholder="UUID" allowClear style={{ width: 200 }} />
</Form.Item> </Form.Item>
@@ -555,19 +526,7 @@ export default function UsersPage() {
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"></Button> <Button type="primary" htmlType="submit"></Button>
<Button <Button onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
onClick={() => {
form.resetFields();
setPage(1);
if (searchParams.get('userId')) {
setSearchParams({}, { replace: true });
} else {
void load();
}
}}
>
</Button>
</Space> </Space>
</Form.Item> </Form.Item>
</Form> </Form>
@@ -611,7 +570,7 @@ export default function UsersPage() {
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item> <Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item> <Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item> <Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
<Descriptions.Item label="验手机时间"> <Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item> </Descriptions.Item>
@@ -645,7 +604,7 @@ export default function UsersPage() {
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item> <Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至"> <Descriptions.Item label="合并至">
{detail.mergedInto {detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})` ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
: '—'} : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item> <Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
@@ -1,6 +1,5 @@
import { useState, type CSSProperties } from 'react'; import { useState, type CSSProperties } from 'react';
import { useNavigate, useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { import {
Button, Button,
Card, Card,
@@ -14,7 +13,6 @@ import {
Select, Select,
Space, Space,
Statistic, Statistic,
Tooltip,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
@@ -37,9 +35,6 @@ const descContentStyle: CSSProperties = {
wordBreak: 'break-all', wordBreak: 'break-all',
}; };
const ATTRIBUTION_HINT =
'首次触达本推广码的用户。每人只归因一次、只归一个码:已登录用户扫码或带参进入时,若还没有归因记录,则记到本码;之后再扫其他码不会改。与「扫码注册」不同:注册只统计来源仍是自然量并被标记为本码的用户;已有其他来源(如分享)的用户仍可计入归因,但不计入扫码注册。';
async function downloadQrcode(url: string, filename: string) { async function downloadQrcode(url: string, filename: string) {
try { try {
const res = await fetch(url); const res = await fetch(url);
@@ -56,7 +51,6 @@ async function downloadQrcode(url: string, filename: string) {
} }
export default function PromoCodeDetailPage() { export default function PromoCodeDetailPage() {
const navigate = useNavigate();
const { detail, reload } = useOutletContext<PromoCodeDetailContext>(); const { detail, reload } = useOutletContext<PromoCodeDetailContext>();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
@@ -215,26 +209,14 @@ export default function PromoCodeDetailPage() {
<Row gutter={16} style={{ marginTop: 16 }}> <Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card <Card size="small">
size="small"
hoverable
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/promo-codes/${detail.id}?eventType=SCAN`)}
>
<Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} /> <Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic <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} value={stats?.attributionCount ?? 0}
/> />
</Card> </Card>
@@ -248,23 +230,8 @@ export default function PromoCodeDetailPage() {
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card <Card size="small">
size="small" <Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
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> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Card, Card,
DatePicker, DatePicker,
@@ -22,7 +21,6 @@ import {
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../../lib/api'; import { request, type Paginated } from '../../lib/api';
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
type Props = { type Props = {
promoId: string; promoId: string;
@@ -59,45 +57,11 @@ function buildEventsQs(
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`; return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
} }
function parseEventType(raw: string | null): PromoMetricEventType | undefined { function formatRefId(row: PromoMetricEventItem): string {
if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw; if (row.orderId) return `订单 ${row.orderId}`;
return undefined; if (row.userId) return `用户 ${row.userId}`;
} if (row.sessionId) return `会话 ${row.sessionId}`;
return '—';
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 { function formatLocation(row: PromoMetricEventItem): string {
@@ -107,27 +71,15 @@ function formatLocation(row: PromoMetricEventItem): string {
} }
export default function PromoCodeMetricsPanel({ promoId }: Props) { 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 [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs().subtract(6, 'day'), dayjs()]);
const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); const [granularity, setGranularity] = useState<'day' | 'hour'>('day');
const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null); const [timeline, setTimeline] = useState<PromoMetricTimelineDto | null>(null);
const [timelineLoading, setTimelineLoading] = useState(false); const [timelineLoading, setTimelineLoading] = useState(false);
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType); const [eventType, setEventType] = useState<PromoMetricEventType | undefined>();
const [eventsPage, setEventsPage] = useState(1); const [eventsPage, setEventsPage] = useState(1);
const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null); const [events, setEvents] = useState<Paginated<PromoMetricEventItem> | null>(null);
const [eventsLoading, setEventsLoading] = useState(false); 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(() => { const loadTimeline = useCallback(() => {
setTimelineLoading(true); setTimelineLoading(true);
return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity)) return request<PromoMetricTimelineDto>(buildTimelineQs(promoId, range, granularity))
@@ -212,7 +164,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
{ {
title: 'ID', title: 'ID',
key: 'ref', key: 'ref',
render: (_, row) => <EventRefLinks row={row} />, render: (_, row) => formatRefId(row),
}, },
{ {
title: 'IP', title: 'IP',
@@ -268,7 +220,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
notMerge notMerge
/> />
<Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}> <Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
</Typography.Title> </Typography.Title>
<Space wrap style={{ marginBottom: 12 }}> <Space wrap style={{ marginBottom: 12 }}>
@@ -280,10 +232,6 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
onChange={(v) => { onChange={(v) => {
setEventType(v); setEventType(v);
setEventsPage(1); 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]) => ({ options={Object.entries(PROMO_METRIC_EVENT_LABELS).map(([value, label]) => ({
value, value,
@@ -1,5 +1,5 @@
import { useNavigate, useOutletContext, useParams } from 'react-router-dom'; import { useNavigate, useOutletContext, useParams } from 'react-router-dom';
import { Button, Space, Table, Tag, Tooltip } from 'antd'; import { Button, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
USER_SOURCE_TYPE_LABELS, USER_SOURCE_TYPE_LABELS,
@@ -66,7 +66,7 @@ export default function PromoCodeUsersPage() {
width: 160, width: 160,
render: (v) => (v ? fmtTime(v) : '—'), render: (v) => (v ? fmtTime(v) : '—'),
}, },
{ title: <Tooltip title="仅统计已完成订单"></Tooltip>, dataIndex: 'orderCount', width: 80 }, { title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
+1 -3
View File
@@ -30,9 +30,8 @@
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) | | 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.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.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.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) |
--- ---
@@ -112,7 +111,6 @@
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行) - 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示 - 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型) - 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
- 核销详情展示核销门店主账户收款信息 - 核销详情展示核销门店主账户收款信息
### 3.4~3.8 Wave 能力 ### 3.4~3.8 Wave 能力
-1
View File
@@ -9,7 +9,6 @@
2. 携带 `sessionId``dukang_session_id` 2. 携带 `sessionId``dukang_session_id`
3. `extraJson` 无密码/令牌;手机号脱敏 3. `extraJson` 无密码/令牌;手机号脱敏
4. eventName 先登记 taxonomy 再 emit 4. eventName 先登记 taxonomy 再 emit
5. C 端后端埋点 `clientApp` 取 JWT / `X-Client-App``USER_MINI``USER_H5`),禁止写死 H5。`order_submit` / `pay_success` 见 v3.5.14。
## API ## API
+1 -3
View File
@@ -67,13 +67,11 @@
| 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 | | 3.5.10 | [`门店详情去分享按钮 + 同城提示改承运商 HTML + 小程序客服接企微`](./杜康好客-v3.5.10-开发文档.md) | 🔶 开发完成 |
| 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 | | 3.5.11 | [`城市履约起购 + 企微通知字段与结算通知`](./杜康好客-v3.5.11-开发文档.md) | 🔶 开发完成 |
| 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 | | 3.5.12 | [`订单大屏 BGM + HQ 中文展示 + 修复删除门店分类 + HQ 侧栏顺序`](./杜康好客-v3.5.12-开发文档.md) | 🔶 开发完成 |
| 3.5.14 | [`提交订单/支付成功日志端回填 USER_MINI`](./杜康好客-v3.5.14-开发文档.md) | 🔶 开发完成 |
| 日期 | 说明 | | 日期 | 说明 |
|------|------| |------|------|
| 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.12:订单大屏循环 BGM;HQ 日志/订单状态流转/用户行为时间线英文码改中文;修复删除门店分类后被 `ensureDefaults` 回种;HQ 侧栏按业务前 11 项重排、系统设置置底 |
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注)`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单(城市/合伙人、笔数、累计金额、收款人账号开户行) | | 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单 |
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) | | 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 | | 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置/列宽存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏;订单列表状态可多选 |
| 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 | | 2026-08-24 | v3.5.8HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
+6 -7
View File
@@ -13,7 +13,7 @@
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 | | 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 | | 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 | | 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;支付与核销补用户昵称、明文手机、HQ 备注 | | 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 |
| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) | | 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。 **不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
@@ -63,12 +63,11 @@
- 商品:`{名称} {规格} {瓶\|箱}×{数量}` - 商品:`{名称} {规格} {瓶\|箱}×{数量}`
- 物流:现场提货 / 同城 / 跨城 - 物流:现场提货 / 同城 / 跨城
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址 - 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
- 用户:昵称、手机号(明文)、HQ 备注 `hq_remark`(空则 —)
- 测试单不推 - 测试单不推
### 4.3 核销成功 `redeem.success` ### 4.3 核销成功 `redeem.success`
`userName``userPhone`明文)、`userRemark`HQ 用户备注,空则 —)。 `userName``userPhone`脱敏,与支付播报一致)。
--- ---
@@ -85,7 +84,7 @@
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 | | `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 | | `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
账单一次摘要:抬头含账期 / 账单笔数 / 累计金额;每笔分块写城市、门店或承运商、合伙人(公司-姓名)、核销/订单笔数、账单金额、收款人 / 账户 / 开户行。缺卡写「未登记」。内部群账号不脱敏。`created=0` / `skipped` 不推。正文过长时截断并提示到 HQ 查看。 账单一次摘要(多店多行,最多 15 行);缺卡写「未登记收款账户」;内部群账号不脱敏。`created=0` / `skipped` 不推。
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。 启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
@@ -96,8 +95,8 @@
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单 - [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」 - [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
- [ ] HQ 新建门店:企微可见 HQ 名称 - [ ] HQ 新建门店:企微可见 HQ 名称
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确;用户昵称、明文手机、备注 - [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确
- [ ] 核销成功:有用户昵称、明文手机、备注 - [ ] 核销成功:有用户昵称与脱敏手机
- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组 - [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群 - [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
- [ ] 提现通过、四类账单生成含城市/合伙人、笔数、累计金额、收款人/账号/开户行;无账单不刷屏 - [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏
-95
View File
@@ -1,95 +0,0 @@
# 杜康好客 · v3.5.14 修复文档
> **2026-08-26** · API / 生产数据
> **主题**:C 端「提交订单 / 支付成功」用户日志 `client_app` 误标为 H5,改为真实端(小程序)
---
## 1. 版本目标
1. 修复后端埋点:`order_submit``pay_success` 不再写死 `USER_H5`
2. 回填线上 `log_user_analytics`:这两类事件里所有 `USER_H5` 改为 `USER_MINI`
**不做**:核销埋点(`benefit_redeem_*`);未登录 `POST /analytics/events` 的 H5 回退;H5 预览(`TARO_ENV=h5`)仍报 `USER_H5`
---
## 2. 根因
`TradeService.createOrder` / `payOrder`Mock 支付成功)/ `handlePaySuccess`(微信支付回调)调用 `analyticsService.trackOneSafe` 时把 `clientApp` 写死为 `'USER_H5'`
当前生产 C 端是 `apps/mini-user` 小程序,请求头为 `X-Client-App: USER_MINI`(写入 JWT)。同链路的 `pay_fail` 已使用 JWT 的 `clientApp`,故 HQ「用户日志」里提交订单显示「C 端 H5」,支付失败却可能显示「C 端小程序」。
---
## 3. 代码规则
| 写入点 | 事件 | `clientApp` |
|--------|------|-------------|
| `POST /trade/orders``createOrder` | `order_submit` | JWT `user.clientApp`(缺省 `USER_H5` |
| `POST /trade/orders/:id/pay` Mock 成功 | `pay_success` | 入参 `clientApp`(缺省 `USER_H5` |
| 微信支付回调 `handlePaySuccess` | `pay_success` | 该订单已有 `order_submit` / `pay_fail`;没有则默认 `USER_MINI` |
小程序原生:`USER_MINI``mini-user` H5 预览:仍为 `USER_H5`(公众号 JSAPI,见 `apps/mini-user/src/lib/api.ts`)。
---
## 4. 线上数据回填
表:`log_user_analytics`
范围:`event_name IN ('order_submit', 'pay_success') AND client_app = 'USER_H5'`
动作:`client_app``USER_MINI`
```sql
SELECT event_name, client_app, COUNT(*) AS cnt
FROM log_user_analytics
WHERE event_name IN ('order_submit', 'pay_success')
GROUP BY event_name, client_app;
UPDATE log_user_analytics
SET client_app = 'USER_MINI'
WHERE event_name IN ('order_submit', 'pay_success')
AND client_app = 'USER_H5';
```
回填结果(生产,2026-08-26):
| 事件 | 回填前 USER_H5 | 回填后 USER_MINI |
|------|----------------|------------------|
| `order_submit` | 55 | 55 |
| `pay_success` | 28 | 28 |
共更新 **83** 行。回填后这两类事件已无 `USER_H5`
不改其它事件名,不改 `common_event`
---
## 5. 关键路径
| 域 | 路径 |
|----|------|
| 建单 / 支付埋点 | `server/dukang-api/src/modules/trade/trade.service.ts` |
| 建单入参 | `server/dukang-api/src/modules/trade/trade.controller.ts` |
| HQ 展示 | `order_submit` → 提交订单;`USER_MINI` → C 端小程序 |
无 Prisma 迁移。
---
## 6. 发版步骤
| 步骤 | 动作 |
|------|------|
| 1 | 生产已执行上述 `UPDATE`(历史行) |
| 2 | 发 `dukang-api`(新单按 JWT 写端);`--skip-db` |
---
## 7. 验收清单
- [x] 历史 `order_submit` / `pay_success` 在 HQ 不再显示「C 端 H5」(已回填 83 行)
- [ ] 小程序提交订单后,HQ 用户日志「提交订单」端为「C 端小程序」(需发 `dukang-api`
- [ ] 小程序支付成功后,「支付成功」端为「C 端小程序」(需发 `dukang-api`
- [ ] `mini-user` H5 预览下单仍为「C 端 H5」
- [ ] 浏览类埋点(`home_view` 等)端字段不变
-2
View File
@@ -51,8 +51,6 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
**订单大屏(v3.5.12**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。 **订单大屏(v3.5.12**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。
**用户日志端(v3.5.14**`order_submit` / `pay_success``clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`
## 5. 验收用例(必过) ## 5. 验收用例(必过)
**主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30… **主链路 15 项**:登录、4 SKU、起购、支付+权益、双通道核销、payout、关店不可见、拓店审核、配送完成、退款、T+1/T+30…
-6
View File
@@ -94,10 +94,6 @@ export interface DevPlanTaskDto {
attachmentUrls?: string[] | null; attachmentUrls?: string[] | null;
versions?: Array<{ id: string; versionNo: string }>;
versionIds?: string[];
} }
@@ -170,8 +166,6 @@ export interface UpdateDevPlanTaskInput {
attachmentUrls?: string[]; attachmentUrls?: string[];
versionIds?: string[];
} }
-12
View File
@@ -9,8 +9,6 @@ export interface AdminOrdersListQuery extends AdminListQuery {
status?: string | string[]; status?: string | string[];
orderType?: string; orderType?: string;
userId?: string; userId?: string;
userKeyword?: string;
productKeyword?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
fulfillmentHold?: string | boolean; fulfillmentHold?: string | boolean;
@@ -20,16 +18,6 @@ export interface AdminOrdersListQuery extends AdminListQuery {
deliveryType?: string; deliveryType?: string;
} }
export interface AdminUsersListQuery extends AdminListQuery {
phone?: string;
userNo?: string;
userId?: string;
deviceKey?: string;
phoneVerified?: string;
status?: number;
excludeTest?: boolean;
}
export interface UpdateAdminUserRequest { export interface UpdateAdminUserRequest {
hqRemark: string; hqRemark: string;
} }
-5
View File
@@ -35,7 +35,6 @@ export type PromoCodeItem = {
qrcodeId: string; qrcodeId: string;
status: PromoCodeStatus; status: PromoCodeStatus;
scanCount: number; scanCount: number;
/** 已完成订单数(status=COMPLETED */
orderCount: number; orderCount: number;
landingUrl: string; landingUrl: string;
qrcodeUrl?: string | null; qrcodeUrl?: string | null;
@@ -48,7 +47,6 @@ export type PromoCodeItem = {
export type PromoCodeStats = { export type PromoCodeStats = {
/** 扫码进入次数 */ /** 扫码进入次数 */
scanCount: number; scanCount: number;
/** 已完成订单数(status=COMPLETED */
orderCount: number; orderCount: number;
conversionRate: number; conversionRate: number;
/** 归因用户数(user_promo_attribution */ /** 归因用户数(user_promo_attribution */
@@ -68,7 +66,6 @@ export type PromoCodeAttributedUser = {
sourceType: string; sourceType: string;
sourceRefId: string | null; sourceRefId: string | null;
firstTouchAt: string | null; firstTouchAt: string | null;
/** 该用户已完成订单数 */
orderCount: number; orderCount: number;
createdAt: string; createdAt: string;
}; };
@@ -101,9 +98,7 @@ export type PromoMetricEventItem = {
id: string; id: string;
eventType: PromoMetricEventType; eventType: PromoMetricEventType;
userId: string | null; userId: string | null;
userNo?: string | null;
orderId: string | null; orderId: string | null;
orderNo?: string | null;
sessionId: string | null; sessionId: string | null;
clientIp: string | null; clientIp: string | null;
ipProvince: string | null; ipProvince: string | null;
-1
View File
@@ -146,7 +146,6 @@ export interface StoreBillDto {
id: string; id: string;
billNo: string; billNo: string;
storeId: string; storeId: string;
/** 出账日 YYYY-MM-DDT+1 窗口「昨日 00:00–今日 00:00」中的今天) */
billDate: string; billDate: string;
redeemCount: number; redeemCount: number;
redeemAmount: number; redeemAmount: number;
@@ -4,7 +4,6 @@ import {
WECOM_PUSH_CONDITION_LABELS, WECOM_PUSH_CONDITION_LABELS,
WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS, WECOM_PUSH_DEFAULT_SETTLEMENT_CONDITIONS,
WECOM_SETTLEMENT_PUSH_NAME, WECOM_SETTLEMENT_PUSH_NAME,
WECOM_TEMPLATE_PLACEHOLDERS,
parseWecomPushConditions, parseWecomPushConditions,
} from './wecom-message-push'; } from './wecom-message-push';
@@ -34,22 +33,4 @@ describe('wecom-message-push', () => {
'finance.store_bill', '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,9 +197,6 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'skuSummary', 'skuSummary',
'deliveryLabel', 'deliveryLabel',
'receiverInfo', 'receiverInfo',
'userName',
'userPhone',
'userRemark',
'phoneMasked', 'phoneMasked',
'time', 'time',
'handleUrl', 'handleUrl',
@@ -212,7 +209,6 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'channel', 'channel',
'userName', 'userName',
'userPhone', 'userPhone',
'userRemark',
'time', 'time',
'handleUrl', 'handleUrl',
'handleLabel', 'handleLabel',
@@ -259,14 +255,9 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'storeId', 'storeId',
], ],
'store.withdraw_approved': [ 'store.withdraw_approved': [
'cityName',
'storeName', 'storeName',
'partnerLabel',
'withdrawNo', 'withdrawNo',
'amount', 'amount',
'payee',
'accountNo',
'bankBranch',
'bankAccount', 'bankAccount',
'time', 'time',
'handleUrl', 'handleUrl',
@@ -286,7 +277,6 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'finance.store_bill': [ 'finance.store_bill': [
'period', 'period',
'billCount', 'billCount',
'totalAmount',
'billSummary', 'billSummary',
'time', 'time',
'handleUrl', 'handleUrl',
@@ -295,7 +285,6 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'finance.partner_bill': [ 'finance.partner_bill': [
'period', 'period',
'billCount', 'billCount',
'totalAmount',
'billSummary', 'billSummary',
'time', 'time',
'handleUrl', 'handleUrl',
@@ -303,17 +292,8 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
], ],
'finance.winery_bill': [ 'finance.winery_bill': [
'period', 'period',
'billCount',
'totalAmount',
'cityNames',
'partnerNames',
'orderCount',
'orderAmount',
'rate',
'amount', 'amount',
'payee', 'orderCount',
'accountNo',
'bankBranch',
'bankAccount', 'bankAccount',
'time', 'time',
'handleUrl', 'handleUrl',
@@ -322,7 +302,6 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
'finance.logistics_bill': [ 'finance.logistics_bill': [
'period', 'period',
'billCount', 'billCount',
'totalAmount',
'billSummary', 'billSummary',
'time', 'time',
'handleUrl', 'handleUrl',
@@ -91,12 +91,12 @@ async function main() {
await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } }); await prisma.storeBill.deleteMany({ where: { billNo: { startsWith: 'SBMOCK' } } });
await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } }); await prisma.partnerBill.deleteMany({ where: { billNo: { startsWith: 'PBMOCK' } } });
// ─── 1. 门店账单(今日出账未打款 + 昨日出账已打款)─── // ─── 1. 门店账单(昨日未打款 + 前日已打款)───
const storeBillUnpaid = await prisma.storeBill.create({ const storeBillUnpaid = await prisma.storeBill.create({
data: { data: {
billNo: billNo('SB', `${yesterday.getTime()}U`), billNo: billNo('SB', `${yesterday.getTime()}U`),
storeId: store.id, storeId: store.id,
billDate: today, billDate: yesterday,
redeemCount: 5, redeemCount: 5,
redeemAmount: 1500, redeemAmount: 1500,
settlementRate: 0.6, settlementRate: 0.6,
@@ -108,7 +108,7 @@ async function main() {
data: { data: {
billNo: billNo('SB', `${dayBefore.getTime()}P`), billNo: billNo('SB', `${dayBefore.getTime()}P`),
storeId: store.id, storeId: store.id,
billDate: yesterday, billDate: dayBefore,
redeemCount: 3, redeemCount: 3,
redeemAmount: 800, redeemAmount: 800,
settlementRate: 0.6, settlementRate: 0.6,
@@ -100,27 +100,6 @@ export class WechatTradeManageService {
remark, 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); 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}`); this.logger.log(`seeded wecom push template: ${def.eventKey}`);
continue; continue;
} }
const legacyBodies = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey] ?? []; const legacy = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey];
if (legacyBodies.includes(existing.body)) { if (legacy && existing.body === legacy) {
await this.prisma.wecomPushTemplate.update({ await this.prisma.wecomPushTemplate.update({
where: { eventKey: def.eventKey }, where: { eventKey: def.eventKey },
data: { data: {
@@ -473,10 +473,7 @@ export class WecomMessagePushService implements OnModuleInit {
skuSummary: '杜康原浆 52度 瓶×2', skuSummary: '杜康原浆 52度 瓶×2',
deliveryLabel: '同城', deliveryLabel: '同城',
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号', receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
userName: '好客用户', phoneMasked: '138****8000',
userPhone: '13800008000',
userRemark: '大客户',
phoneMasked: '13800008000',
}, },
handlePath: '/orders?orderNo=DK202608200001', handlePath: '/orders?orderNo=DK202608200001',
}, },
@@ -487,8 +484,7 @@ export class WecomMessagePushService implements OnModuleInit {
storeName: '示例门店', storeName: '示例门店',
channel: '扫码', channel: '扫码',
userName: '好客用户', userName: '好客用户',
userPhone: '13800008000', userPhone: '138****8000',
userRemark: '大客户',
}, },
handlePath: '/redeem-records?redeemNo=RD202608200001', handlePath: '/redeem-records?redeemNo=RD202608200001',
}, },
@@ -535,14 +531,9 @@ export class WecomMessagePushService implements OnModuleInit {
}, },
'store.withdraw_approved': { 'store.withdraw_approved': {
vars: { vars: {
cityName: '郑州',
storeName: '示例门店', storeName: '示例门店',
partnerLabel: '郑州某某商贸-张三',
withdrawNo: 'SW202608200001', withdrawNo: 'SW202608200001',
amount: '500.00', amount: '500.00',
payee: '张三',
accountNo: '6222000011112222',
bankBranch: '郑州支行',
bankAccount: '张三 / 郑州支行 / 6222000011112222', bankAccount: '张三 / 郑州支行 / 6222000011112222',
storeId: '1', storeId: '1',
}, },
@@ -562,19 +553,7 @@ export class WecomMessagePushService implements OnModuleInit {
vars: { vars: {
period: '2026-08-25', period: '2026-08-25',
billCount: '2', billCount: '2',
totalAmount: '500.00', billSummary: '示例门店 ¥300.00 张三 / 郑州支行 / 6222000011112222',
billSummary: [
'城市:郑州',
'门店:示例门店',
'合伙人:郑州某某商贸-张三',
'核销笔数:12',
'核销金额:¥500.00',
'结算比例:60%',
'账单:¥300.00',
'收款人:张三',
'收款账户:6222000011112222',
'收款开户行:郑州支行',
].join('\n'),
}, },
handlePath: '/finance/store-bills?kind=T1_BILL', handlePath: '/finance/store-bills?kind=T1_BILL',
}, },
@@ -582,36 +561,15 @@ export class WecomMessagePushService implements OnModuleInit {
vars: { vars: {
period: '2026-07', period: '2026-07',
billCount: '1', billCount: '1',
totalAmount: '120.00', billSummary: '郑州某某商贸 ¥120.00 张三 / 郑州支行 / 6222000011112222',
billSummary: [
'城市:郑州',
'合伙人:郑州某某商贸-张三',
'订单笔数:8',
'核销笔数:12',
'订单佣金:¥100.00',
'核销佣金:¥20.00',
'账单:¥120.00',
'收款人:张三',
'收款账户:6222000011112222',
'收款开户行:郑州支行',
].join('\n'),
}, },
handlePath: '/finance/partner-bills', handlePath: '/finance/partner-bills',
}, },
'finance.winery_bill': { 'finance.winery_bill': {
vars: { vars: {
period: '2026-08-23', period: '2026-08-23',
billCount: '1',
totalAmount: '1990.00',
cityNames: '郑州、洛阳',
partnerNames: '郑州某某商贸-张三',
amount: '1990.00', amount: '1990.00',
orderCount: '8', orderCount: '8',
orderAmount: '6633.33',
rate: '30%',
payee: '杜康酒厂',
accountNo: '6222000099998888',
bankBranch: '洛阳支行',
bankAccount: '杜康酒厂 / 洛阳支行 / 6222000099998888', bankAccount: '杜康酒厂 / 洛阳支行 / 6222000099998888',
}, },
handlePath: '/finance/winery-bills', handlePath: '/finance/winery-bills',
@@ -620,19 +578,7 @@ export class WecomMessagePushService implements OnModuleInit {
vars: { vars: {
period: '2026-07', period: '2026-07',
billCount: '1', billCount: '1',
totalAmount: '86.00', billSummary: '小飞侠 ¥86.00 承运商 / 郑州支行 / 6222000033334444',
billSummary: [
'城市:郑州',
'合伙人:郑州某某商贸-张三',
'承运商:小飞侠',
'结算方式:充值扣款',
'发货笔数:10',
'瓶数:60',
'账单:¥86.00',
'收款人:承运商',
'收款账户:6222000033334444',
'收款开户行:郑州支行',
].join('\n'),
}, },
handlePath: '/finance/logistics-bills', handlePath: '/finance/logistics-bills',
}, },
@@ -20,8 +20,7 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'商品:{{skuSummary}}', '商品:{{skuSummary}}',
'物流:{{deliveryLabel}}', '物流:{{deliveryLabel}}',
'收货:{{receiverInfo}}', '收货:{{receiverInfo}}',
'用户:{{userName}} {{userPhone}}', '用户:{{phoneMasked}}',
'备注:{{userRemark}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
@@ -37,7 +36,6 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
'门店:{{storeName}}', '门店:{{storeName}}',
'渠道:{{channel}}', '渠道:{{channel}}',
'用户:{{userName}} {{userPhone}}', '用户:{{userName}} {{userPhone}}',
'备注:{{userRemark}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
@@ -104,14 +102,10 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
title: '门店提现已通过', title: '门店提现已通过',
body: [ body: [
'**门店提现已通过**', '**门店提现已通过**',
'城市:{{cityName}}',
'门店:{{storeName}}', '门店:{{storeName}}',
'合伙人:{{partnerLabel}}',
'提现单号:{{withdrawNo}}', '提现单号:{{withdrawNo}}',
'账单:¥{{amount}}', '金额:¥{{amount}}',
'收款{{payee}}', '收款账户{{bankAccount}}',
'收款账户:{{accountNo}}',
'收款开户行:{{bankBranch}}',
'时间:{{time}}', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
@@ -138,12 +132,9 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [ body: [
'**门店日账单已生成**', '**门店日账单已生成**',
'账期:{{period}}', '账期:{{period}}',
'账单笔数:{{billCount}}', '笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}', '{{billSummary}}',
'', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
handleLabel: '去处理', handleLabel: '去处理',
@@ -154,12 +145,9 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [ body: [
'**合伙人月账单已生成**', '**合伙人月账单已生成**',
'账期:{{period}}', '账期:{{period}}',
'账单笔数:{{billCount}}', '笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}', '{{billSummary}}',
'', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
handleLabel: '去处理', handleLabel: '去处理',
@@ -170,20 +158,10 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [ body: [
'**酒厂日账单已生成**', '**酒厂日账单已生成**',
'账期:{{period}}', '账期:{{period}}',
'账单笔数:{{billCount}}', '订单数:{{orderCount}}',
'累计金额:¥{{totalAmount}}', '金额:¥{{amount}}',
'收款账户:{{bankAccount}}',
'时间:{{time}}', '时间:{{time}}',
'',
'城市:{{cityNames}}',
'合伙人:{{partnerNames}}',
'订单笔数:{{orderCount}}',
'订单金额:¥{{orderAmount}}',
'结算比例:{{rate}}',
'账单:¥{{amount}}',
'收款人:{{payee}}',
'收款账户:{{accountNo}}',
'收款开户行:{{bankBranch}}',
'',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
handleLabel: '去处理', handleLabel: '去处理',
@@ -194,149 +172,62 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
body: [ body: [
'**物流月对账已生成**', '**物流月对账已生成**',
'账期:{{period}}', '账期:{{period}}',
'账单笔数:{{billCount}}', '笔数:{{billCount}}',
'累计金额:¥{{totalAmount}}',
'时间:{{time}}',
'',
'{{billSummary}}', '{{billSummary}}',
'', '时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})', '[{{handleLabel}}]({{handleUrl}})',
].join('\n'), ].join('\n'),
handleLabel: '去处理', handleLabel: '去处理',
}, },
]; ];
/** 上一版默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */ /** v3.5.3 默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */
export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEventKey, string[]>> = { export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEventKey, string>> = {
'order.paid': [ 'order.paid': [
[ '**订单支付成功**',
'**订单支付成功**', '订单号:{{orderNo}}',
'订单号:{{orderNo}}', '实付:¥{{payAmount}}',
'实付:¥{{payAmount}}', '城市:{{cityName}}',
'城市:{{cityName}}', '商品:{{skuSummary}}',
'商品:{{skuSummary}}', '用户:{{phoneMasked}}',
'用户:{{phoneMasked}}', '时间:{{time}}',
'时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})',
'[{{handleLabel}}]({{handleUrl}})', ].join('\n'),
].join('\n'),
[
'**订单支付成功**',
'订单号:{{orderNo}}',
'实付:¥{{payAmount}}',
'城市:{{cityName}}',
'商品:{{skuSummary}}',
'物流:{{deliveryLabel}}',
'收货:{{receiverInfo}}',
'用户:{{phoneMasked}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'redeem.success': [ 'redeem.success': [
[ '**门店核销成功**',
'**门店核销成功**', '核销单号:{{redeemNo}}',
'核销单号:{{redeemNo}}', '金额:¥{{amount}}',
'金额:¥{{amount}}', '门店:{{storeName}}',
'门店:{{storeName}}', '渠道:{{channel}}',
'渠道:{{channel}}', '时间:{{time}}',
'时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})',
'[{{handleLabel}}]({{handleUrl}})', ].join('\n'),
].join('\n'),
[
'**门店核销成功**',
'核销单号:{{redeemNo}}',
'金额:¥{{amount}}',
'门店:{{storeName}}',
'渠道:{{channel}}',
'用户:{{userName}} {{userPhone}}',
'时间:{{time}}',
'[{{handleLabel}}]({{handleUrl}})',
].join('\n'),
],
'store.audit_pending': [ 'store.audit_pending': [
[ '**门店审核待处理 · {{action}}**',
'**门店审核待处理 · {{action}}**', '门店:{{storeName}}',
'门店:{{storeName}}', '城市:{{cityName}}',
'城市:{{cityName}}', '合伙人:{{partnerLabel}}',
'合伙人:{{partnerLabel}}', '时间:{{time}}',
'时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})',
'[{{handleLabel}}]({{handleUrl}})', ].join('\n'),
].join('\n'),
],
'store.package_audit_pending': [ 'store.package_audit_pending': [
[ '**套餐变更待审核**',
'**套餐变更待审核**', '门店:{{storeName}}',
'门店:{{storeName}}', '城市:{{cityName}}',
'城市:{{cityName}}', '提交端:{{submitter}}',
'提交端:{{submitter}}', '套餐条数:{{packageCount}}',
'套餐条数:{{packageCount}}', '时间:{{time}}',
'时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})',
'[{{handleLabel}}]({{handleUrl}})', ].join('\n'),
].join('\n'),
],
'store.info_change_pending': [ 'store.info_change_pending': [
[ '**门店信息变更待审**',
'**门店信息变更待审**', '门店:{{storeName}}',
'门店:{{storeName}}', '城市:{{cityName}}',
'城市:{{cityName}}', '提交端:{{submitter}}',
'提交端:{{submitter}}', '变更字段:{{changedFields}}',
'变更字段:{{changedFields}}', '时间:{{time}}',
'时间:{{time}}', '[{{handleLabel}}]({{handleUrl}})',
'[{{handleLabel}}]({{handleUrl}})', ].join('\n'),
].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 { export function getDefaultTemplate(eventKey: string): WecomTemplateDefault | undefined {
@@ -5,7 +5,7 @@ import { AlertService } from '../common/alert/alert.service';
/** /**
* 财务对账单定时任务(Asia/Shanghai * 财务对账单定时任务(Asia/Shanghai
* - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销,出账日=今天 * - 每日 08:00:酒厂日账单(T+3:3 天前已完成订单)+ 门店日账单(昨日核销)
* - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账 * - 每月 1 日 08:00:合伙人上一自然月账单 + 物流承运商上一自然月对账
* - 工作日 18:05:门店提现 T+0 审完预警(FIN-003 * - 工作日 18:05:门店提现 T+0 审完预警(FIN-003
*/ */
@@ -156,16 +156,10 @@ export class DevPlanService {
}, },
extras?: { extras?: { creatorName?: string | null; supportTicketNo?: string | null },
creatorName?: string | null;
supportTicketNo?: string | null;
versions?: Array<{ id: string; versionNo: string }>;
},
): DevPlanTaskDto { ): DevPlanTaskDto {
const versions = extras?.versions ?? [];
return { return {
id: String(row.id), id: String(row.id),
@@ -194,10 +188,6 @@ export class DevPlanService {
attachmentUrls: parseAttachmentUrls(row.attachmentUrls), attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
versions,
versionIds: versions.map((v) => v.id),
}; };
} }
@@ -316,7 +306,7 @@ export class DevPlanService {
const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null); const ticketIds = rows.map((r) => r.supportTicketId).filter((id): id is bigint => id != null);
const [names, tickets, versionMap] = await Promise.all([ const [names, tickets] = await Promise.all([
this.loadHqNames(creatorIds), this.loadHqNames(creatorIds),
@@ -332,8 +322,6 @@ export class DevPlanService {
: Promise.resolve([]), : Promise.resolve([]),
this.loadTaskVersionMap(rows.map((r) => r.id)),
]); ]);
const ticketMap = new Map<string, string>( const ticketMap = new Map<string, string>(
@@ -352,8 +340,6 @@ export class DevPlanService {
supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null, supportTicketNo: r.supportTicketId != null ? ticketMap.get(String(r.supportTicketId)) ?? null : null,
versions: versionMap.get(String(r.id)) ?? [],
}), }),
); );
@@ -388,16 +374,12 @@ export class DevPlanService {
} }
const versionMap = await this.loadTaskVersionMap([row.id]);
return this.mapTask(row, { return this.mapTask(row, {
creatorName: names.get(String(row.creatorHqAccountId)) ?? null, creatorName: names.get(String(row.creatorHqAccountId)) ?? null,
supportTicketNo, supportTicketNo,
versions: versionMap.get(String(row.id)) ?? [],
}); });
} }
@@ -526,10 +508,6 @@ export class DevPlanService {
await this.prisma.devPlanTask.update({ where: { id }, data }); await this.prisma.devPlanTask.update({ where: { id }, data });
if (dto.versionIds !== undefined) {
await this.replaceTaskVersions(id, dto.versionIds);
}
return this.getTask(id); return this.getTask(id);
} }
@@ -628,41 +606,6 @@ export class DevPlanService {
private async loadTaskVersionMap(
taskIds: bigint[],
): Promise<Map<string, Array<{ id: string; versionNo: string }>>> {
const map = new Map<string, Array<{ id: string; versionNo: string }>>();
if (!taskIds.length) return map;
const links = await this.prisma.devPlanVersionTask.findMany({
where: { taskId: { in: taskIds } },
include: { version: { select: { id: true, versionNo: true } } },
orderBy: { versionId: 'asc' },
});
for (const link of links) {
const key = String(link.taskId);
const list = map.get(key) ?? [];
list.push({ id: String(link.version.id), versionNo: link.version.versionNo });
map.set(key, list);
}
return map;
}
private async replaceTaskVersions(taskId: bigint, versionIds: string[]) {
const unique = [...new Set(versionIds.map((id) => id.trim()).filter(Boolean))];
const ids = unique.map(BigInt);
if (ids.length) {
const versions = await this.prisma.devPlanVersion.findMany({
where: { id: { in: ids } },
select: { id: true },
});
if (versions.length !== ids.length) throw new BadRequestException('部分版本不存在');
}
await this.prisma.devPlanVersionTask.deleteMany({ where: { taskId } });
for (const versionId of ids) {
await this.appendVersionTasks(versionId, [String(taskId)]);
}
}
private async loadVersionTasks(versionId: bigint): Promise<{ tasks: DevPlanTaskDto[]; taskIds: string[] }> { private async loadVersionTasks(versionId: bigint): Promise<{ tasks: DevPlanTaskDto[]; taskIds: string[] }> {
const links = await this.prisma.devPlanVersionTask.findMany({ const links = await this.prisma.devPlanVersionTask.findMany({
@@ -62,11 +62,6 @@ export class UpdateDevPlanTaskDto {
@IsArray() @IsArray()
@IsString({ each: true }) @IsString({ each: true })
attachmentUrls?: string[]; attachmentUrls?: string[];
@IsOptional()
@IsArray()
@IsString({ each: true })
versionIds?: string[];
} }
export class CreateDevPlanVersionDto { export class CreateDevPlanVersionDto {
@@ -30,8 +30,6 @@ type OrderFilterInput = Pick<
| 'status' | 'status'
| 'orderType' | 'orderType'
| 'userId' | 'userId'
| 'userKeyword'
| 'productKeyword'
| 'cityId' | 'cityId'
| 'receiverPhone' | 'receiverPhone'
| 'fulfillmentHold' | 'fulfillmentHold'
@@ -39,7 +37,6 @@ type OrderFilterInput = Pick<
| 'createdTo' | 'createdTo'
| 'excludeTest' | 'excludeTest'
| 'deliveryType' | 'deliveryType'
| 'promoCodeId'
>; >;
@Injectable() @Injectable()
@@ -97,7 +94,7 @@ export class AdminOrdersService {
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
delivery: { delivery: {
select: { select: {
provider: true, provider: true,
@@ -179,28 +176,8 @@ export class AdminOrdersService {
} }
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals']; if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
if (query.userId) where.userId = BigInt(query.userId); if (query.userId) where.userId = BigInt(query.userId);
const userKeyword = query.userKeyword?.trim();
if (userKeyword) {
const userOr: Prisma.UserWhereInput[] = [
{ userNo: { contains: userKeyword } },
{ nickname: { contains: userKeyword } },
{ hqRemark: { contains: userKeyword } },
{ phone: { contains: userKeyword } },
];
if (/^\d+$/.test(userKeyword)) {
userOr.push({ id: BigInt(userKeyword) });
}
where.user = { OR: userOr };
}
if (query.cityId) where.cityId = BigInt(query.cityId); if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone }; if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
const productKeyword = query.productKeyword?.trim();
if (productKeyword) {
where.OR = [
{ productName: { contains: productKeyword } },
{ productSpec: { contains: productKeyword } },
];
}
if (query.deliveryType) { if (query.deliveryType) {
where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals']; where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals'];
} }
@@ -213,10 +190,6 @@ export class AdminOrdersService {
if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo); if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo);
} }
if (query.excludeTest) where.isTest = false; if (query.excludeTest) where.isTest = false;
const promoCodeId = query.promoCodeId?.trim();
if (promoCodeId && /^\d+$/.test(promoCodeId)) {
where.promoCodeId = BigInt(promoCodeId);
}
return where; return where;
} }
@@ -279,7 +252,6 @@ export class AdminOrdersService {
userNo: true, userNo: true,
phone: true, phone: true,
nickname: true, nickname: true,
hqRemark: true,
deviceKey: true, deviceKey: true,
phoneVerifiedAt: true, phoneVerifiedAt: true,
}, },
@@ -54,7 +54,7 @@ export class AdminRedeemService {
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
store: { select: { id: true, name: true, cityName: true } }, store: { select: { id: true, name: true, cityName: true } },
coupon: { select: { id: true, couponNo: true, balance: true } }, coupon: { select: { id: true, couponNo: true, balance: true } },
}, },
@@ -77,7 +77,7 @@ export class AdminRedeemService {
const record = await this.prisma.redeemRecord.findUnique({ const record = await this.prisma.redeemRecord.findUnique({
where: { id }, where: { id },
include: { include: {
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } }, user: { select: { id: true, userNo: true, phone: true, nickname: true } },
store: { store: {
select: { select: {
id: true, id: true,
@@ -81,13 +81,6 @@ export class AdminUsersService {
if (query.phone) where.phone = { contains: query.phone }; if (query.phone) where.phone = { contains: query.phone };
if (query.userNo) where.userNo = { contains: query.userNo }; 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.deviceKey) where.deviceKey = query.deviceKey;
if (query.status !== undefined) where.status = query.status; if (query.status !== undefined) where.status = query.status;
if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null }; if (query.phoneVerified === '1') where.phoneVerifiedAt = { not: null };
@@ -48,11 +48,6 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
@IsString() @IsString()
userNo?: string; userNo?: string;
/** 精确匹配用户主键,供订单等页的用户快链带入筛选 */
@IsOptional()
@IsString()
userId?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
deviceKey?: string; deviceKey?: string;
@@ -93,16 +88,6 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsString() @IsString()
userId?: string; userId?: string;
/** 用户编号 / 昵称 / 备注 / 手机 模糊匹配 */
@IsOptional()
@IsString()
userKeyword?: string;
/** 商品名称 / 规格 模糊匹配 */
@IsOptional()
@IsString()
productKeyword?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
cityId?: string; cityId?: string;
@@ -131,11 +116,6 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP']) @IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
deliveryType?: string; deliveryType?: string;
/** 按下单时绑定的推广码筛选 */
@IsOptional()
@IsString()
promoCodeId?: string;
} }
/** HQ 订单导出(筛选 + 勾选范围) */ /** HQ 订单导出(筛选 + 勾选范围) */
@@ -169,14 +149,6 @@ export class AdminOrdersExportDto {
@IsString() @IsString()
cityId?: string; cityId?: string;
@IsOptional()
@IsString()
userKeyword?: string;
@IsOptional()
@IsString()
productKeyword?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
receiverPhone?: string; receiverPhone?: string;
@@ -202,10 +174,6 @@ export class AdminOrdersExportDto {
@IsOptional() @IsOptional()
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP']) @IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
deliveryType?: string; deliveryType?: string;
@IsOptional()
@IsString()
promoCodeId?: string;
} }
/** 概览页用户/订单 ECharts 聚合筛选 */ /** 概览页用户/订单 ECharts 聚合筛选 */
@@ -151,7 +151,7 @@ export class PromoCodeService {
}); });
} }
private mapRow(row: PromoRow, orderCount?: number) { private mapRow(row: PromoRow) {
return serializeBigInt({ return serializeBigInt({
id: row.id, id: row.id,
code: row.code, code: row.code,
@@ -161,7 +161,7 @@ export class PromoCodeService {
status: row.status, status: row.status,
remark: row.remark, remark: row.remark,
scanCount: row.scanCount, scanCount: row.scanCount,
orderCount: orderCount ?? row.orderCount, orderCount: row.orderCount,
landingUrl: buildLandingUrl(row.code, row.qrcodeId), landingUrl: buildLandingUrl(row.code, row.qrcodeId),
qrcodeUrl: row.qrcodeResource?.url ?? null, qrcodeUrl: row.qrcodeResource?.url ?? null,
ownerUser: this.mapOwnerUser(row.ownerUser), ownerUser: this.mapOwnerUser(row.ownerUser),
@@ -207,10 +207,9 @@ export class PromoCodeService {
}), }),
this.prisma.commonPromoCode.count({ where }), this.prisma.commonPromoCode.count({ where }),
]); ]);
const completedByPromo = await this.completedOrderCounts(items.map((r) => r.id));
return serializeBigInt({ return serializeBigInt({
items: items.map((r) => this.mapRow(r, completedByPromo.get(r.id.toString()) ?? 0)), items: items.map((r) => this.mapRow(r)),
total, total,
page, page,
pageSize, pageSize,
@@ -224,7 +223,7 @@ export class PromoCodeService {
}); });
if (!row) throw new NotFoundException('推广码不存在'); if (!row) throw new NotFoundException('推广码不存在');
const stats = await this.statsFromRow(row); const stats = await this.statsFromRow(row);
return serializeBigInt({ ...this.mapRow(row, stats.orderCount), stats }); return serializeBigInt({ ...this.mapRow(row), stats });
} }
private async resolveOwnerUserId(ownerUserId?: string) { private async resolveOwnerUserId(ownerUserId?: string) {
@@ -520,40 +519,6 @@ export class PromoCodeService {
this.logPromoMetric(promoCodeId, 'ORDER', { clientIp }, { userId, orderId }); 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( private logPromoMetric(
promoCodeId: bigint, promoCodeId: bigint,
eventType: PromoMetricEventType, eventType: PromoMetricEventType,
@@ -645,37 +610,12 @@ export class PromoCodeService {
this.prisma.logPromoEvent.count({ where }), 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({ return serializeBigInt({
items: items.map((row) => ({ items: items.map((row) => ({
id: row.id, id: row.id,
eventType: row.eventType, eventType: row.eventType,
userId: row.userId, userId: row.userId,
userNo: row.userId ? userNoById.get(row.userId.toString()) ?? null : null,
orderId: row.orderId, orderId: row.orderId,
orderNo: row.orderId ? orderNoById.get(row.orderId.toString()) ?? null : null,
sessionId: row.sessionId, sessionId: row.sessionId,
clientIp: row.clientIp, clientIp: row.clientIp,
ipProvince: row.ipProvince, ipProvince: row.ipProvince,
@@ -696,9 +636,12 @@ export class PromoCodeService {
if (!promo) throw new NotFoundException('推广码不存在'); if (!promo) throw new NotFoundException('推广码不存在');
} }
private async statsFromRow(row: { id: bigint; scanCount: number }) { private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
const scanCount = row.scanCount; const scanCount = row.scanCount;
const [attributionCount, sourceMarkedCount, orderCount] = await Promise.all([ const orderCount = row.orderCount;
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
const [attributionCount, sourceMarkedCount] = await Promise.all([
this.prisma.userPromoAttribution.count({ this.prisma.userPromoAttribution.count({
where: { promoCodeId: row.id }, where: { promoCodeId: row.id },
}), }),
@@ -709,12 +652,7 @@ export class PromoCodeService {
mergedIntoUserId: null, mergedIntoUserId: null,
}, },
}), }),
this.prisma.order.count({
where: { promoCodeId: row.id, status: 'COMPLETED' },
}),
]); ]);
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
return { return {
scanCount, scanCount,
orderCount, orderCount,
@@ -757,7 +695,7 @@ export class PromoCodeService {
sourceRefId: true, sourceRefId: true,
createdAt: true, createdAt: true,
promoTouch: { select: { firstTouchAt: true, promoCodeId: true } }, promoTouch: { select: { firstTouchAt: true, promoCodeId: true } },
_count: { select: { orders: { where: { status: 'COMPLETED' } } } }, _count: { select: { orders: true } },
}, },
}), }),
this.prisma.user.count({ where }), this.prisma.user.count({ where }),
@@ -8,6 +8,7 @@ import {
calcRedeemSettleAmount, calcRedeemSettleAmount,
generateRedeemNo, generateRedeemNo,
generateRedeemPendingNo, generateRedeemPendingNo,
maskContactPhone,
REDEEM_WEAKNET_FAIL_THRESHOLD, REDEEM_WEAKNET_FAIL_THRESHOLD,
resolveSettlementRate, resolveSettlementRate,
validateRedeemAmount, validateRedeemAmount,
@@ -196,7 +197,7 @@ export class RedeemService {
const [userRow, storeRow] = await Promise.all([ const [userRow, storeRow] = await Promise.all([
this.prisma.user.findUnique({ this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { isTest: true, nickname: true, phone: true, hqRemark: true }, select: { isTest: true, nickname: true, phone: true },
}), }),
this.prisma.store.findUnique({ this.prisma.store.findUnique({
where: { id: account.storeId }, where: { id: account.storeId },
@@ -294,8 +295,7 @@ export class RedeemService {
storeName: account.store.name || String(account.storeId), storeName: account.store.name || String(account.storeId),
channel: channelLabel, channel: channelLabel,
userName: userRow?.nickname?.trim() || '—', userName: userRow?.nickname?.trim() || '—',
userPhone: (userRow?.phone || '').trim() || '—', userPhone: maskContactPhone(userRow?.phone),
userRemark: (userRow?.hqRemark || '').trim() || '—',
}, },
{ {
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`, handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
@@ -1,9 +1,8 @@
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { import {
DEFAULT_STORE_WITHDRAW_DAILY_LIMIT, DEFAULT_STORE_WITHDRAW_DAILY_LIMIT,
DEFAULT_XFX_LOGISTICS_PRICING, DEFAULT_XFX_LOGISTICS_PRICING,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
WINERY_SETTLEMENT_LAG_DAYS, WINERY_SETTLEMENT_LAG_DAYS,
WINERY_SETTLEMENT_RATE, WINERY_SETTLEMENT_RATE,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
@@ -29,17 +28,6 @@ import {
loadWineryBankConfig, loadWineryBankConfig,
formatWecomBankAccount, formatWecomBankAccount,
} from '../../common/store/store-bank.util'; } 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) { function generateBillNo(prefix: string) {
return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; return `${prefix}${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -55,16 +43,11 @@ function startOfDay(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0); return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
} }
/** 核销窗口:昨日 00:00 ≤ t < 今日 00:00;出账日 = 今日 00:00(窗口右端) */
function dayWindow(anchor = new Date()) { function dayWindow(anchor = new Date()) {
const end = startOfDay(anchor); const end = startOfDay(anchor);
const start = new Date(end); const start = new Date(end);
start.setDate(start.getDate() - 1); start.setDate(start.getDate() - 1);
return { start, end, billDate: end }; return { start, end, billDate: start };
}
function shanghaiYmd(d: Date): string {
return d.toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
} }
function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) { function wineryDayWindow(anchor = new Date(), lagDays = WINERY_SETTLEMENT_LAG_DAYS) {
@@ -80,37 +63,12 @@ function round2(n: number) {
return Math.round(n * 100) / 100; return Math.round(n * 100) / 100;
} }
function sumAmounts(rows: Array<{ amount: number }>): string { const BILL_DIGEST_MAX_LINES = 15;
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
}
function storeWecomMeta( function capBillSummary(lines: string[]): string {
store: if (!lines.length) return '无';
| { if (lines.length <= BILL_DIGEST_MAX_LINES) return lines.join('\n');
name?: string | null; return `${lines.slice(0, BILL_DIGEST_MAX_LINES).join('\n')}\n…等共 ${lines.length}`;
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 { function getStoreWithdrawDailyLimit(): number {
@@ -136,9 +94,7 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
} }
@Injectable() @Injectable()
export class SettlementService implements OnModuleInit { export class SettlementService {
private readonly logger = new Logger(SettlementService.name);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
@@ -148,59 +104,27 @@ export class SettlementService implements OnModuleInit {
private readonly wecomPush: WecomMessagePushService, private readonly wecomPush: WecomMessagePushService,
) {} ) {}
async onModuleInit() {
try {
const shifted = await this.realignStoreBillIssueDates();
if (shifted > 0) this.logger.log(`Store bill dates aligned to issue day: ${shifted}`);
} catch (e) {
this.logger.warn(`Store bill date align skipped: ${e instanceof Error ? e.message : e}`);
}
}
/**
* 旧数据 billDate=核销自然日(窗口左端);现改为出账日(窗口右端=今天)。
* 按 created_at 日历日对比,只把仍早一天的行 +1;从晚到早更新避免 (storeId,billDate) 冲突。
*/
private async realignStoreBillIssueDates(): Promise<number> {
const locked = await this.prisma.$queryRaw<Array<{ acquired: number | bigint | null }>>`
SELECT GET_LOCK('store_bill_issue_date_align', 5) AS acquired
`;
if (!Number(locked[0]?.acquired)) return 0;
try {
const shifted = await this.prisma.$executeRaw`
UPDATE store_bill
SET bill_date = DATE_ADD(bill_date, INTERVAL 1 DAY)
WHERE DATE(bill_date) < DATE(created_at)
ORDER BY bill_date DESC
`;
return Number(shifted);
} finally {
await this.prisma.$queryRaw`SELECT RELEASE_LOCK('store_bill_issue_date_align')`;
}
}
private notifyPartnerBillDigest( private notifyPartnerBillDigest(
period: string, period: string,
rows: Array<{ rows: Array<{
cityName: string; label: string;
partnerLabel: string;
orderCount?: number;
redeemCount?: number;
orderCommission: number;
redeemCommission: number;
amount: number; amount: number;
bank?: WecomBankLike | null; bankAccountName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
}>, }>,
) { ) {
if (!rows.length) return; if (!rows.length) return;
const blocks = rows.map((r) => formatPartnerBillWecomBlock(r)); const lines = rows.map(
(r) =>
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
);
void this.wecomPush.dispatchEvent( void this.wecomPush.dispatchEvent(
'finance.partner_bill', 'finance.partner_bill',
{ {
period, period,
billCount: String(rows.length), billCount: String(rows.length),
totalAmount: sumAmounts(rows), billSummary: capBillSummary(lines),
billSummary: capBillBlocks(blocks),
}, },
{ handlePath: '/finance/partner-bills' }, { handlePath: '/finance/partner-bills' },
); );
@@ -209,25 +133,25 @@ export class SettlementService implements OnModuleInit {
private notifyLogisticsBillDigest( private notifyLogisticsBillDigest(
period: string, period: string,
rows: Array<{ rows: Array<{
cityNames: string; label: string;
partnerNames: string;
providerName: string;
settlementMethod: string;
orderCount: number;
bottleCount: number;
amount: number; amount: number;
bank?: WecomBankLike | null; bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
}>, }>,
) { ) {
if (!rows.length) return; if (!rows.length) return;
const blocks = rows.map((r) => formatLogisticsBillWecomBlock(r)); const lines = rows.map(
(r) =>
`${r.label} ¥${Number(r.amount).toFixed(2)} ${formatWecomBankAccount(r)}`,
);
void this.wecomPush.dispatchEvent( void this.wecomPush.dispatchEvent(
'finance.logistics_bill', 'finance.logistics_bill',
{ {
period, period,
billCount: String(rows.length), billCount: String(rows.length),
totalAmount: sumAmounts(rows), billSummary: capBillSummary(lines),
billSummary: capBillBlocks(blocks),
}, },
{ handlePath: '/finance/logistics-bills' }, { handlePath: '/finance/logistics-bills' },
); );
@@ -725,34 +649,16 @@ export class SettlementService implements OnModuleInit {
const [store, bank] = await Promise.all([ const [store, bank] = await Promise.all([
this.prisma.store.findUnique({ this.prisma.store.findUnique({
where: { id: row.storeId }, where: { id: row.storeId },
select: { select: { name: true },
name: true,
cityName: true,
cityRef: { select: { name: true } },
partnerAccount: {
select: {
name: true,
companyName: true,
parent: { select: { companyName: true } },
},
},
},
}), }),
loadStorePrimaryBank(this.prisma, row.storeId), loadStorePrimaryBank(this.prisma, row.storeId),
]); ]);
const meta = storeWecomMeta(store, String(row.storeId));
const bankParts = wecomBankParts(bank);
void this.wecomPush.dispatchEvent( void this.wecomPush.dispatchEvent(
'store.withdraw_approved', 'store.withdraw_approved',
{ {
cityName: meta.cityName, storeName: store?.name || String(row.storeId),
storeName: meta.storeName,
partnerLabel: meta.partnerLabel,
withdrawNo: row.withdrawNo, withdrawNo: row.withdrawNo,
amount: Number(row.amount).toFixed(2), amount: Number(row.amount).toFixed(2),
payee: bankParts.payee,
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(bank), bankAccount: formatWecomBankAccount(bank),
storeId: row.storeId.toString(), storeId: row.storeId.toString(),
}, },
@@ -1013,7 +919,7 @@ export class SettlementService implements OnModuleInit {
// ─── Store bills (daily header) ────────────────────── // ─── Store bills (daily header) ──────────────────────
/** 生成昨日核销窗口的门店对账单billDate = 出账日(今日) */ /** 生成指定自然日窗口的门店对账单(默认昨日) */
async generateStoreBillsForDay(anchor = new Date()) { async generateStoreBillsForDay(anchor = new Date()) {
const { start, end, billDate } = dayWindow(anchor); const { start, end, billDate } = dayWindow(anchor);
const payouts = await this.prisma.storePayout.findMany({ const payouts = await this.prisma.storePayout.findMany({
@@ -1024,24 +930,7 @@ export class SettlementService implements OnModuleInit {
withdrawItem: { is: null }, withdrawItem: { is: null },
status: 'PENDING', status: 'PENDING',
}, },
include: { include: { store: { select: { id: true, name: true, settlementRate: true } } },
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>(); const byStore = new Map<string, typeof payouts>();
@@ -1054,19 +943,10 @@ export class SettlementService implements OnModuleInit {
let created = 0; let created = 0;
let skipped = 0; let skipped = 0;
const createdBills: Array<{ const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = [];
storeId: bigint;
storeName: string;
cityName: string;
partnerLabel: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
amount: number;
}> = [];
for (const [storeIdStr, list] of byStore) { for (const [storeIdStr, list] of byStore) {
const storeId = BigInt(storeIdStr); const storeId = BigInt(storeIdStr);
const meta = storeWecomMeta(list[0]?.store, storeIdStr); const storeName = list[0]?.store?.name || storeIdStr;
const existing = await this.prisma.storeBill.findUnique({ const existing = await this.prisma.storeBill.findUnique({
where: { storeId_billDate: { storeId, billDate } }, where: { storeId_billDate: { storeId, billDate } },
}); });
@@ -1095,14 +975,7 @@ export class SettlementService implements OnModuleInit {
}); });
}); });
created += 1; created += 1;
createdBills.push({ createdBills.push({ storeId, storeName, amount: updatedTotal });
storeId,
...meta,
redeemCount: existing.redeemCount + list.length,
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
settlementRate: rate,
amount: updatedTotal,
});
continue; continue;
} }
@@ -1128,35 +1001,25 @@ export class SettlementService implements OnModuleInit {
}); });
}); });
created += 1; created += 1;
createdBills.push({ createdBills.push({ storeId, storeName, amount: payoutAmount });
storeId,
...meta,
redeemCount: list.length,
redeemAmount,
settlementRate: rate,
amount: payoutAmount,
});
} }
const period = shanghaiYmd(billDate); const period = billDate.toISOString().slice(0, 10);
if (createdBills.length) { if (createdBills.length) {
const banks = await loadStorePrimaryBanksMap( const banks = await loadStorePrimaryBanksMap(
this.prisma, this.prisma,
createdBills.map((b) => b.storeId), createdBills.map((b) => b.storeId),
); );
const blocks = createdBills.map((b) => const lines = createdBills.map(
formatStoreBillWecomBlock({ (b) =>
...b, `${b.storeName} ¥${b.amount.toFixed(2)} ${formatWecomBankAccount(banks.get(b.storeId.toString()) ?? null)}`,
bank: banks.get(b.storeId.toString()) ?? null,
}),
); );
void this.wecomPush.dispatchEvent( void this.wecomPush.dispatchEvent(
'finance.store_bill', 'finance.store_bill',
{ {
period, period,
billCount: String(createdBills.length), billCount: String(createdBills.length),
totalAmount: sumAmounts(createdBills), billSummary: capBillSummary(lines),
billSummary: capBillBlocks(blocks),
}, },
{ handlePath: '/finance/store-bills?kind=T1_BILL' }, { handlePath: '/finance/store-bills?kind=T1_BILL' },
); );
@@ -1303,7 +1166,6 @@ export class SettlementService implements OnModuleInit {
return serializeBigInt({ return serializeBigInt({
items: slice.map((r) => ({ items: slice.map((r) => ({
...r, ...r,
date: r.kind === 'T1_BILL' ? shanghaiYmd(r.date) : r.date,
bankAccount: bankAccount:
r.kind === 'T1_BILL' r.kind === 'T1_BILL'
? bankMap.get(String(r.storeId)) ?? null ? bankMap.get(String(r.storeId)) ?? null
@@ -1358,13 +1220,7 @@ export class SettlementService implements OnModuleInit {
totalAmount: Number(aggregates._sum.payoutAmount ?? 0), totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
}; };
return serializeBigInt({ return serializeBigInt({ items, total, page, pageSize, summary });
items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })),
total,
page,
pageSize,
summary,
});
} }
async getAdminStoreBill(id: bigint) { async getAdminStoreBill(id: bigint) {
@@ -1380,7 +1236,7 @@ export class SettlementService implements OnModuleInit {
}); });
if (!bill) throw new NotFoundException('门店对账单不存在'); if (!bill) throw new NotFoundException('门店对账单不存在');
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId); const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), storeAccount }); return serializeBigInt({ ...bill, storeAccount });
} }
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) { async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
@@ -1438,7 +1294,7 @@ export class SettlementService implements OnModuleInit {
); );
const header = [ const header = [
'账单号', '账单号',
'账日', '账日',
'门店', '门店',
'城市', '城市',
'核销笔数', '核销笔数',
@@ -1456,7 +1312,7 @@ export class SettlementService implements OnModuleInit {
const bank = bankMap.get(String(b.storeId)); const bank = bankMap.get(String(b.storeId));
return [ return [
csvEscape(b.billNo), csvEscape(b.billNo),
shanghaiYmd(b.billDate), b.billDate.toISOString().slice(0, 10),
csvEscape(b.store.name), csvEscape(b.store.name),
csvEscape(b.store.cityName ?? ''), csvEscape(b.store.cityName ?? ''),
b.redeemCount, b.redeemCount,
@@ -1620,19 +1476,15 @@ export class SettlementService implements OnModuleInit {
bankAccountName: true, bankAccountName: true,
bankBranch: true, bankBranch: true,
bankAccountNo: true, bankAccountNo: true,
city: { select: { name: true } },
}, },
}); });
const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = []; const results: Array<{ partnerId: string; companyName: string; ok: boolean; message?: string }> = [];
const created: Array<{ const created: Array<{
cityName: string; label: string;
partnerLabel: string;
orderCount?: number;
redeemCount?: number;
orderCommission: number;
redeemCommission: number;
amount: number; amount: number;
bank?: WecomBankLike | null; bankAccountName: string | null;
bankBranch: string | null;
bankAccountNo: string | null;
}> = []; }> = [];
for (const p of partners) { for (const p of partners) {
try { try {
@@ -1645,19 +1497,14 @@ export class SettlementService implements OnModuleInit {
{ notify: false }, { notify: false },
); );
results.push({ partnerId: p.id.toString(), companyName: p.companyName ?? '', ok: true }); 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({ created.push({
cityName: p.city?.name?.trim() || '—', label,
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), amount: Number(bill.totalAmount),
bank: { bankAccountName: p.bankAccountName,
bankAccountName: p.bankAccountName, bankBranch: p.bankBranch,
bankBranch: p.bankBranch, bankAccountNo: p.bankAccountNo,
bankAccountNo: p.bankAccountNo,
},
}); });
} catch (e) { } catch (e) {
results.push({ results.push({
@@ -1781,37 +1628,24 @@ export class SettlementService implements OnModuleInit {
}); });
if (opts?.notify !== false) { if (opts?.notify !== false) {
let cityName = '—'; const label =
if (primary.cityId) { [primary.companyName?.trim(), primary.name?.trim()].filter(Boolean).join(' · ') ||
const city = await this.prisma.commonCity.findUnique({ primary.id.toString();
where: { id: primary.cityId }, this.notifyPartnerBillDigest(
select: { name: true }, `${body.year}-${String(body.month).padStart(2, '0')}`,
}); [
cityName = city?.name?.trim() || '—'; {
} label,
this.notifyPartnerBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [ amount: totalAmount,
{
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, bankAccountName: primary.bankAccountName,
bankBranch: primary.bankBranch, bankBranch: primary.bankBranch,
bankAccountNo: primary.bankAccountNo, bankAccountNo: primary.bankAccountNo,
}, },
}, ],
]); );
} }
return serializeBigInt({ return serializeBigInt(bill);
...bill,
orderCount: orders.length,
redeemCount: redeems.length,
});
} }
/** HQ:发送给合伙人(待审核 → 待合伙人确认) */ /** HQ:发送给合伙人(待审核 → 待合伙人确认) */
@@ -2046,7 +1880,6 @@ export class SettlementService implements OnModuleInit {
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] }, deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
completedAt: { gte: start, lt: end }, completedAt: { gte: start, lt: end },
}, },
include: { city: { select: { name: true } } },
orderBy: { completedAt: 'asc' }, orderBy: { completedAt: 'asc' },
}); });
@@ -2104,27 +1937,12 @@ export class SettlementService implements OnModuleInit {
const period = billDate.toISOString().slice(0, 10); const period = billDate.toISOString().slice(0, 10);
const wineryBank = await loadWineryBankConfig(this.prisma); 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( void this.wecomPush.dispatchEvent(
'finance.winery_bill', 'finance.winery_bill',
{ {
period, period,
billCount: '1',
totalAmount: wineryAmount.toFixed(2),
cityNames,
partnerNames,
orderCount: String(orders.length),
orderAmount: orderAmount.toFixed(2),
rate: formatPercent(rate),
amount: wineryAmount.toFixed(2), amount: wineryAmount.toFixed(2),
payee: bankParts.payee, orderCount: String(orders.length),
accountNo: bankParts.accountNo,
bankBranch: bankParts.bankBranch,
bankAccount: formatWecomBankAccount(wineryBank), bankAccount: formatWecomBankAccount(wineryBank),
}, },
{ handlePath: '/finance/winery-bills' }, { handlePath: '/finance/winery-bills' },
@@ -2353,14 +2171,12 @@ export class SettlementService implements OnModuleInit {
billId?: string; billId?: string;
}> = []; }> = [];
const created: Array<{ const created: Array<{
cityNames: string; label: string;
partnerNames: string;
providerName: string;
settlementMethod: string;
orderCount: number;
bottleCount: number;
amount: number; amount: number;
bank?: WecomBankLike | null; bankAccountName: string | null;
bankName: string | null;
bankBranch: string | null;
bankAccountNo: string | null;
}> = []; }> = [];
for (const p of providers) { for (const p of providers) {
@@ -2387,8 +2203,15 @@ export class SettlementService implements OnModuleInit {
: String(bill.bill.id) : String(bill.bill.id)
: undefined, : undefined,
}); });
if (!bill.skipped && bill.wecom) { if (!bill.skipped && bill.bill) {
created.push(bill.wecom); created.push({
label: p.name,
amount: Number(bill.bill.logisticsAmount ?? 0),
bankAccountName: p.bankAccountName,
bankName: p.bankName,
bankBranch: p.bankBranch,
bankAccountNo: p.bankAccountNo,
});
} }
} catch (e) { } catch (e) {
results.push({ results.push({
@@ -2414,20 +2237,6 @@ export class SettlementService implements OnModuleInit {
}; };
} }
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( async generateLogisticsBill(
body: { providerId: string; year: number; month: number }, body: { providerId: string; year: number; month: number },
opts?: { notify?: boolean }, opts?: { notify?: boolean },
@@ -2482,8 +2291,6 @@ export class SettlementService implements OnModuleInit {
quantity: true, quantity: true,
deliveryType: true, deliveryType: true,
payStatus: true, payStatus: true,
partnerAccountIdAtPay: true,
city: { select: { name: true } },
}, },
}, },
}, },
@@ -2585,38 +2392,25 @@ export class SettlementService implements OnModuleInit {
return header; 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) { if (opts?.notify !== false) {
this.notifyLogisticsBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [ this.notifyLogisticsBillDigest(
wecom, `${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,
},
],
);
} }
return { return {
skipped: false, skipped: false,
bill: serializeBigInt(bill), bill: serializeBigInt(bill),
wecom,
}; };
} }
@@ -1,157 +0,0 @@
/** 企微结算账单正文:分字段块 + 字节上限(机器人 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],
]);
}
@@ -26,7 +26,7 @@ export class TradeController {
@Post() @Post()
create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) { create(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>, @Req() req: Request) {
return this.tradeService.createOrder(user.actorId, body as never, req, user.clientApp); return this.tradeService.createOrder(user.actorId, body as never, req);
} }
@Get() @Get()
@@ -10,6 +10,7 @@ import type { FreightPayType } from '@prisma/client';
import { import {
calcBenefitAmount, calcBenefitAmount,
generateOrderNo, generateOrderNo,
maskContactPhone,
minBottleQtyForDelivery, minBottleQtyForDelivery,
orderTabToStatuses, orderTabToStatuses,
toMinSaleQuantity, toMinSaleQuantity,
@@ -209,31 +210,6 @@ export class TradeService {
}; };
} }
private resolveTrackedUserClientApp(
clientApp?: ClientApp | string | null,
fallback: ClientApp = ClientApp.USER_H5,
): ClientApp {
if (clientApp === ClientApp.USER_MINI || clientApp === ClientApp.USER_H5) {
return clientApp;
}
return fallback;
}
/** 支付回调无 JWT:沿用该订单已有埋点;没有则默认小程序(当前生产 C 端) */
private async clientAppForPaidOrder(userId: bigint, orderId: bigint): Promise<ClientApp> {
const row = await this.prisma.logUserAnalytics.findFirst({
where: {
userId,
refType: 'ORDER',
refId: orderId,
eventName: { in: ['order_submit', 'pay_fail'] },
},
orderBy: { createdAt: 'desc' },
select: { clientApp: true },
});
return this.resolveTrackedUserClientApp(row?.clientApp, ClientApp.USER_MINI);
}
async createOrder( async createOrder(
userId: bigint, userId: bigint,
body: { body: {
@@ -245,7 +221,6 @@ export class TradeService {
skuId?: string; skuId?: string;
}, },
req: Request, req: Request,
clientApp?: ClientApp,
) { ) {
const preview = await this.preview(userId, body); const preview = await this.preview(userId, body);
if (preview.quantityOk === false) { if (preview.quantityOk === false) {
@@ -357,10 +332,26 @@ export class TradeService {
include: { product: true, imageResource: true }, include: { product: true, imageResource: true },
}); });
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created; return created;
}); });
this.analyticsService.trackOneSafe(userId, this.resolveTrackedUserClientApp(clientApp), { if (promoCodeId) {
this.promoCodeService.logPromoOrderEvent(
promoCodeId,
order.id,
userId,
location.clientIp ?? undefined,
);
}
this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'order_submit', eventName: 'order_submit',
refType: 'ORDER', refType: 'ORDER',
refId: order.id, refId: order.id,
@@ -489,7 +480,7 @@ export class TradeService {
userId, userId,
}); });
this.analyticsService.trackOneSafe(userId, this.resolveTrackedUserClientApp(clientApp), { this.analyticsService.trackOneSafe(userId, 'USER_H5', {
eventName: 'pay_success', eventName: 'pay_success',
refType: 'ORDER', refType: 'ORDER',
refId: order.id, refId: order.id,
@@ -505,20 +496,11 @@ export class TradeService {
where: { id: orderId }, where: { id: orderId },
include: { include: {
city: { select: { name: true } }, city: { select: { name: true } },
user: { select: { phone: true, nickname: true, hqRemark: true } }, user: { select: { phone: true } },
}, },
}); });
if (!order) return; 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) { if (!order.isTest) {
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶'; const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
const spec = (order.productSpec || '').trim(); const spec = (order.productSpec || '').trim();
@@ -535,9 +517,7 @@ export class TradeService {
order.deliveryType === 'ON_SITE_PICKUP' order.deliveryType === 'ON_SITE_PICKUP'
? `${order.receiverName} ${order.receiverPhone}`.trim() ? `${order.receiverName} ${order.receiverPhone}`.trim()
: `${order.receiverName} ${order.receiverPhone} ${order.receiverAddress}`.trim(); : `${order.receiverName} ${order.receiverPhone} ${order.receiverAddress}`.trim();
const userName = order.user?.nickname?.trim() || '—'; const phoneMasked = maskContactPhone(order.user?.phone || order.receiverPhone);
const userPhone = (order.user?.phone || '').trim() || '—';
const userRemark = (order.user?.hqRemark || '').trim() || '—';
void this.wecomPush.dispatchEvent( void this.wecomPush.dispatchEvent(
'order.paid', 'order.paid',
{ {
@@ -547,10 +527,7 @@ export class TradeService {
skuSummary, skuSummary,
deliveryLabel, deliveryLabel,
receiverInfo, receiverInfo,
userName, phoneMasked,
userPhone,
userRemark,
phoneMasked: userPhone,
}, },
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` }, { handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
); );
@@ -676,16 +653,12 @@ export class TradeService {
orderNo: order.orderNo, orderNo: order.orderNo,
userId: order.userId, userId: order.userId,
}); });
this.analyticsService.trackOneSafe( this.analyticsService.trackOneSafe(order.userId, 'USER_H5', {
order.userId, eventName: 'pay_success',
await this.clientAppForPaidOrder(order.userId, order.id), refType: 'ORDER',
{ refId: order.id,
eventName: 'pay_success', extraJson: { orderId: order.id.toString(), mode: 'wechat_callback' },
refType: 'ORDER', });
refId: order.id,
extraJson: { orderId: order.id.toString(), mode: 'wechat_callback' },
},
);
} }
return { orderId: order.id.toString(), alreadyPaid: false }; return { orderId: order.id.toString(), alreadyPaid: false };
@@ -1586,15 +1559,6 @@ export class TradeService {
}); });
}); });
if (targetStatus === 'COMPLETED' && currentStatus !== 'COMPLETED' && order.promoCodeId) {
await this.promoCodeService.recordCompletedOrder(
order.promoCodeId,
order.id,
order.userId,
order.clientIp ?? undefined,
);
}
// 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置) // 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置)
if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') { if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') {
this.wechatOrderShipping.uploadForOrderSafe(orderId); this.wechatOrderShipping.uploadForOrderSafe(orderId);
@@ -1936,9 +1900,25 @@ export class TradeService {
}), }),
}); });
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created; return created;
}); });
if (promoCodeId) {
this.promoCodeService.logPromoOrderEvent(
promoCodeId,
order.id,
user.id,
location.clientIp ?? undefined,
);
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerAccountId: primary.id, partnerAccountId: primary.id,
eventName: 'partner_proxy_order_create', eventName: 'partner_proxy_order_create',
@@ -2333,9 +2313,25 @@ export class TradeService {
}), }),
}); });
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created; return created;
}); });
if (promoCodeId) {
this.promoCodeService.logPromoOrderEvent(
promoCodeId,
order.id,
user.id,
location.clientIp ?? undefined,
);
}
return { return {
id: order.id.toString(), id: order.id.toString(),
orderNo: order.orderNo, orderNo: order.orderNo,