Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 870494563d | |||
| 4c9b3f72bc | |||
| 3b669f7e38 | |||
| 14c5c8c3f3 | |||
| a7f3283ae5 | |||
| da19c39965 | |||
| c06251f0a4 | |||
| faa1830bde | |||
| b226af2adc | |||
| 2b5f42acdf | |||
| 71149044ca | |||
| fccfd7abbe | |||
| 1ecc6a11df | |||
| cfd42dfc32 | |||
| 737efb89a3 |
@@ -74,6 +74,56 @@ 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,7 +15,13 @@ 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
|
||||||
| {
|
| {
|
||||||
@@ -41,7 +47,13 @@ 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="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
<Descriptions.Item label="用户昵称">
|
||||||
|
{(() => {
|
||||||
|
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>
|
||||||
|
|||||||
@@ -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 };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null };
|
||||||
delivery?: {
|
delivery?: {
|
||||||
provider: string;
|
provider: string;
|
||||||
trackingNo: string | null;
|
trackingNo: string | null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -111,9 +111,16 @@ 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>(
|
||||||
@@ -132,6 +139,35 @@ 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);
|
||||||
@@ -154,12 +190,7 @@ export default function BenefitCouponsPage() {
|
|||||||
width: 200,
|
width: 200,
|
||||||
ellipsis: false,
|
ellipsis: false,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<AdminPrimaryLink
|
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}>
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{v}
|
{v}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
@@ -211,10 +242,7 @@ export default function BenefitCouponsPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={async () => {
|
onClick={() => void openCouponDetail(row.id)}
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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 (!batchEditOpen) return;
|
if (!modalOpen && !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([]));
|
||||||
}, [batchEditOpen]);
|
}, [modalOpen, batchEditOpen]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!modalOpen || editing) return;
|
if (!modalOpen || editing) return;
|
||||||
@@ -108,6 +108,7 @@ 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);
|
||||||
@@ -121,7 +122,11 @@ 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({ ...values, attachmentUrls }),
|
body: JSON.stringify({
|
||||||
|
...values,
|
||||||
|
attachmentUrls,
|
||||||
|
versionIds: values.versionIds ?? [],
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
} else {
|
} else {
|
||||||
@@ -285,6 +290,21 @@ 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 },
|
||||||
{
|
{
|
||||||
@@ -405,9 +425,21 @@ 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
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Radio,
|
Radio,
|
||||||
Row,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -24,6 +22,7 @@ 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';
|
||||||
@@ -150,9 +149,11 @@ 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];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -212,6 +213,17 @@ 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) {
|
||||||
@@ -244,9 +256,11 @@ 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.excludeTest) payload.excludeTest = true;
|
if (filters.promoCodeId) payload.promoCodeId = filters.promoCodeId;
|
||||||
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
|
if (filters.dateRange?.[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;
|
||||||
@@ -285,8 +299,15 @@ 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();
|
||||||
@@ -317,6 +338,13 @@ 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');
|
||||||
@@ -336,6 +364,20 @@ 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];
|
||||||
@@ -366,13 +408,17 @@ 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);
|
||||||
for (const status of selectedStatuses(values.status)) qs.append('status', status);
|
const statuses = selectedStatuses(values.status);
|
||||||
|
const statusList = statuses.length ? statuses : initialStatuses;
|
||||||
|
for (const status of statusList) qs.append('status', status);
|
||||||
if (values.orderType) qs.set('orderType', values.orderType);
|
if (values.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}`);
|
||||||
@@ -380,7 +426,7 @@ export default function OrdersPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [form, page, pageSize, initialOrderNo]);
|
}, [form, page, pageSize, initialOrderNo, initialPromoCodeId, initialStatuses]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -589,7 +635,16 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
|
const payload = buildExportPayload(
|
||||||
|
scope,
|
||||||
|
exportFormat,
|
||||||
|
{
|
||||||
|
...values,
|
||||||
|
promoCodeId: initialPromoCodeId || values.promoCodeId,
|
||||||
|
status: selectedStatuses(values.status).length ? values.status : initialStatuses,
|
||||||
|
},
|
||||||
|
selectedRowKeys,
|
||||||
|
);
|
||||||
const result = await request<OrderExportResult>('/admin/orders/export', {
|
const result = await request<OrderExportResult>('/admin/orders/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
@@ -618,14 +673,14 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '用户',
|
title: '用户',
|
||||||
key: 'user',
|
key: 'user',
|
||||||
width: 120,
|
width: 200,
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.user?.id ? (
|
row.user?.id ? (
|
||||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
<AdminPrimaryLink onClick={() => navigate(`/users?userId=${encodeURIComponent(String(row.user!.id))}`)}>
|
||||||
{row.user.userNo}
|
{formatUserWithRemark(row.user)}
|
||||||
</AdminPrimaryLink>
|
</AdminPrimaryLink>
|
||||||
) : (
|
) : (
|
||||||
(row.user?.userNo || '—')
|
formatUserWithRemark(row.user)
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -778,15 +833,93 @@ 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(); }}>
|
||||||
<Row gutter={[16, 8]}>
|
<div className="admin-orders-filter-row">
|
||||||
<Col xs={24} md={14} lg={12}>
|
<Form.Item name="cityId" label="城市" className="admin-orders-filter-item admin-orders-filter-item--sm">
|
||||||
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
|
<Select
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
allowClear
|
||||||
<Form.Item name="dateRange" noStyle>
|
showSearch
|
||||||
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
|
optionFilterProp="label"
|
||||||
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</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>
|
<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>
|
||||||
|
<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}
|
||||||
@@ -797,61 +930,10 @@ export default function OrdersPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
</Space>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</div>
|
||||||
<Col xs={24} sm={12} md={8} lg={6}>
|
|
||||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
|
||||||
<Select
|
|
||||||
mode="multiple"
|
|
||||||
allowClear
|
|
||||||
maxTagCount="responsive"
|
|
||||||
placeholder="全部"
|
|
||||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
|
||||||
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
|
|
||||||
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
|
||||||
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
|
|
||||||
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
<Col xs={12} sm={6} md={5} lg={3}>
|
|
||||||
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
|
|
||||||
<Select
|
|
||||||
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>
|
|
||||||
<Checkbox>大单拦截</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
|
|
||||||
<Checkbox>过滤测试</Checkbox>
|
|
||||||
</Form.Item>
|
|
||||||
</Space>
|
|
||||||
</Form.Item>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
|
<div className="admin-orders-filter-row admin-orders-filter-row--tools">
|
||||||
{canExportOrders ? (
|
{canExportOrders ? (
|
||||||
<Space size={12} wrap>
|
<Space size={12} wrap>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
@@ -876,8 +958,19 @@ export default function OrdersPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<Space size={12} wrap>
|
<Space size={12} wrap>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
<Checkbox>大单拦截</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
|
||||||
|
else void load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
{canDeleteOrders ? (
|
{canDeleteOrders ? (
|
||||||
<Button
|
<Button
|
||||||
danger
|
danger
|
||||||
@@ -889,6 +982,8 @@ export default function OrdersPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -987,7 +1082,18 @@ 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="用户">{detail.user?.userNo} / {detail.user?.nickname}</Descriptions.Item>
|
<Descriptions.Item label="用户">
|
||||||
|
{detail.user?.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate(`/users?userId=${encodeURIComponent(String(detail.user!.id))}`)}
|
||||||
|
>
|
||||||
|
{formatUserWithRemark(detail.user)}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
formatUserWithRemark(detail.user)
|
||||||
|
)}
|
||||||
|
{detail.user?.nickname ? ` / ${detail.user.nickname}` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="商品额">¥{detail.productAmount}</Descriptions.Item>
|
<Descriptions.Item label="商品额">¥{detail.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>
|
||||||
|
|||||||
@@ -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, Typography, message,
|
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
@@ -23,21 +23,6 @@ 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();
|
||||||
@@ -104,8 +89,26 @@ export default function PromoCodesPage() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
|
{
|
||||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
title: '扫码',
|
||||||
|
dataIndex: 'scanCount',
|
||||||
|
width: 70,
|
||||||
|
render: (v: number, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}?eventType=SCAN`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <Tooltip title="仅统计已完成订单">订单</Tooltip>,
|
||||||
|
dataIndex: 'orderCount',
|
||||||
|
width: 70,
|
||||||
|
render: (v: number, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/orders?promoCodeId=${row.id}&status=COMPLETED`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '转化率',
|
title: '转化率',
|
||||||
width: 90,
|
width: 90,
|
||||||
@@ -119,24 +122,13 @@ export default function PromoCodesPage() {
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 220,
|
width: 140,
|
||||||
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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useNavigate, 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,17 +20,28 @@ type Row = {
|
|||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isTest?: boolean;
|
isTest?: boolean;
|
||||||
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
user?: {
|
||||||
|
id?: string;
|
||||||
|
userNo: string;
|
||||||
|
phone: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
hqRemark?: string | null;
|
||||||
|
};
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { id?: string; couponNo: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
function maskPhone(phone: string | null | undefined) {
|
function formatNicknameWithRemark(user?: {
|
||||||
if (!phone || phone.length < 7) return phone ?? '—';
|
nickname?: string | null;
|
||||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
hqRemark?: string | null;
|
||||||
|
} | 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();
|
||||||
@@ -108,23 +119,60 @@ export default function RedeemRecordsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110, render: (v: string | undefined, row) =>
|
||||||
|
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: 100,
|
width: 160,
|
||||||
render: (v: string | null | undefined) => v || '—',
|
render: (_: string | null | undefined, row) =>
|
||||||
|
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) => maskPhone(v),
|
render: (v: string | null | undefined) => 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: '操作',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Statistic,
|
Statistic,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -75,6 +76,7 @@ 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') || '';
|
||||||
@@ -294,7 +296,11 @@ 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)),
|
||||||
@@ -389,6 +395,7 @@ 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">
|
||||||
@@ -469,7 +476,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>
|
||||||
@@ -543,7 +550,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="应付">
|
||||||
@@ -592,8 +599,22 @@ export default function StoreBillsPage() {
|
|||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '核销单号',
|
title: '核销单号',
|
||||||
render: (_, r) =>
|
render: (_, r) => {
|
||||||
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
const redeemNo = String(
|
||||||
|
(r.redeemRecord as { redeemNo?: string } | undefined)?.redeemNo || '',
|
||||||
|
);
|
||||||
|
return redeemNo ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{redeemNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '金额',
|
title: '金额',
|
||||||
@@ -662,7 +683,18 @@ 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;
|
||||||
return String(payout?.redeemRecord?.redeemNo || '—');
|
const redeemNo = payout?.redeemRecord?.redeemNo || '';
|
||||||
|
return redeemNo ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/redeem-records?redeemNo=${encodeURIComponent(redeemNo)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{redeemNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ 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';
|
||||||
|
|
||||||
|
|
||||||
@@ -560,19 +561,11 @@ export default function SupportTicketsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<div
|
<AdminListHeader
|
||||||
style={{
|
title="技术支持"
|
||||||
display: 'flex',
|
settings={settingsButton}
|
||||||
justifyContent: 'space-between',
|
actions={
|
||||||
alignItems: 'center',
|
<>
|
||||||
marginBottom: 16,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
|
||||||
技术支持
|
|
||||||
</Typography.Title>
|
|
||||||
{settingsButton}
|
|
||||||
<Space>
|
|
||||||
{isSuperAdmin ? (
|
{isSuperAdmin ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -602,8 +595,9 @@ export default function SupportTicketsPage() {
|
|||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
创建工单
|
创建工单
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
@@ -155,6 +155,7 @@ 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);
|
||||||
@@ -185,6 +186,8 @@ 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);
|
||||||
@@ -202,17 +205,26 @@ 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]);
|
}, [load, searchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
|
const openUserId = (location.state as { openUserId?: string } | null)?.openUserId;
|
||||||
if (openUserId) {
|
if (!openUserId) return;
|
||||||
|
form.setFieldsValue({ userId: openUserId });
|
||||||
|
setPage(1);
|
||||||
void openDetail(openUserId);
|
void openDetail(openUserId);
|
||||||
navigate(location.pathname, { replace: true, state: null });
|
navigate(`${location.pathname}?userId=${encodeURIComponent(openUserId)}`, {
|
||||||
}
|
replace: true,
|
||||||
}, [location.state, location.pathname, navigate]);
|
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([
|
||||||
@@ -498,13 +510,30 @@ export default function UsersPage() {
|
|||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={() => {
|
||||||
|
setPage(1);
|
||||||
|
const userId = String(form.getFieldValue('userId') || '').trim();
|
||||||
|
const current = searchParams.get('userId') ?? '';
|
||||||
|
if (userId !== current) {
|
||||||
|
if (userId) setSearchParams({ userId }, { replace: true });
|
||||||
|
else setSearchParams({}, { replace: true });
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Form.Item name="phone" label="手机号">
|
<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>
|
||||||
@@ -526,7 +555,19 @@ export default function UsersPage() {
|
|||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
if (searchParams.get('userId')) {
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
} else {
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -570,7 +611,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="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{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>
|
||||||
@@ -604,7 +645,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 ? maskPhone(detail.mergedInto.phone) : '无手机'})`
|
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})`
|
||||||
: '—'}
|
: '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, type CSSProperties } from 'react';
|
import { useState, type CSSProperties } from 'react';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -35,6 +37,9 @@ 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);
|
||||||
@@ -51,6 +56,7 @@ 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);
|
||||||
@@ -209,14 +215,26 @@ 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 size="small">
|
<Card
|
||||||
|
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>
|
||||||
@@ -230,8 +248,23 @@ export default function PromoCodeDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
<Card size="small">
|
<Card
|
||||||
<Statistic title="订单数" value={stats?.orderCount ?? detail.orderCount} />
|
size="small"
|
||||||
|
hoverable
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => navigate(`/orders?promoCodeId=${detail.id}&status=COMPLETED`)}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
订单数
|
||||||
|
<Tooltip title="仅统计已完成订单" overlayInnerStyle={{ maxWidth: 280 }}>
|
||||||
|
<QuestionCircleOutlined style={{ marginLeft: 6, color: 'rgba(0,0,0,0.45)' }} />
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={stats?.orderCount ?? detail.orderCount}
|
||||||
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={6}>
|
<Col xs={12} sm={6}>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -21,6 +22,7 @@ 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;
|
||||||
@@ -57,11 +59,45 @@ function buildEventsQs(
|
|||||||
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
return `/admin/promo-codes/${promoId}/metrics/events?${qs.toString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRefId(row: PromoMetricEventItem): string {
|
function parseEventType(raw: string | null): PromoMetricEventType | undefined {
|
||||||
if (row.orderId) return `订单 ${row.orderId}`;
|
if (raw === 'SCAN' || raw === 'ATTRIBUTION' || raw === 'REGISTER' || raw === 'ORDER') return raw;
|
||||||
if (row.userId) return `用户 ${row.userId}`;
|
return undefined;
|
||||||
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 {
|
||||||
@@ -71,15 +107,27 @@ 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>();
|
const [eventType, setEventType] = useState<PromoMetricEventType | undefined>(urlEventType);
|
||||||
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))
|
||||||
@@ -164,7 +212,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|||||||
{
|
{
|
||||||
title: 'ID',
|
title: 'ID',
|
||||||
key: 'ref',
|
key: 'ref',
|
||||||
render: (_, row) => formatRefId(row),
|
render: (_, row) => <EventRefLinks row={row} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'IP',
|
title: 'IP',
|
||||||
@@ -220,7 +268,7 @@ export default function PromoCodeMetricsPanel({ promoId }: Props) {
|
|||||||
notMerge
|
notMerge
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
<Typography.Title id="promo-metric-events" level={5} style={{ marginTop: 24, marginBottom: 12 }}>
|
||||||
事件日志
|
事件日志
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space wrap style={{ marginBottom: 12 }}>
|
<Space wrap style={{ marginBottom: 12 }}>
|
||||||
@@ -232,6 +280,10 @@ 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 } from 'antd';
|
import { Button, Space, Table, Tag, Tooltip } 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: '订单数', dataIndex: 'orderCount', width: 80 },
|
{ title: <Tooltip title="仅统计已完成订单">订单数</Tooltip>, dataIndex: 'orderCount', width: 80 },
|
||||||
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '注册时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
|||||||
+2
-1
@@ -30,7 +30,7 @@
|
|||||||
| 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) |
|
| 3.5.14 | 08-26 | 修复 C 端提交订单/支付成功日志误标 H5;线上 `order_submit`/`pay_success` 回填为小程序 | [`v3.5.14`](./杜康好客-v3.5.14-开发文档.md) |
|
||||||
|
|
||||||
@@ -112,6 +112,7 @@
|
|||||||
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
|
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
|
||||||
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
|
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
|
||||||
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
||||||
|
- 门店 T+1「出账日」= 出账当天(核销窗口「昨日 00:00–今日 00:00」中的今天)
|
||||||
- 核销详情展示核销门店主账户收款信息
|
- 核销详情展示核销门店主账户收款信息
|
||||||
|
|
||||||
### 3.4~3.8 Wave 能力
|
### 3.4~3.8 Wave 能力
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 2026-08-26 | v3.5.14:`order_submit`/`pay_success` 埋点改用真实 `clientApp`;线上这两类 `USER_H5` 回填为 `USER_MINI` |
|
| 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:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户;`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单 |
|
| 2026-08-26 | v3.5.11:城市三种履约起购;企微补提交人/订单规格物流收货/核销用户(昵称+明文手机+HQ备注);`alert.settlement` 改名「结算任务失败通知」归入系统监控;补提现通过与四账单(城市/合伙人、笔数、累计金额、收款人账号开户行) |
|
||||||
| 2026-08-25 | v3.5.10:门店详情去掉分享按钮;同城送提示改承运商 HTML(`GET /catalog/local-deliveries`);小程序客服接通企微(需填 CorpID) |
|
| 2026-08-25 | v3.5.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.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
|
| 2 | 现场提货 | 体验 | 默认数量对齐起购;文案用销售单位 |
|
||||||
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
|
| 3 | 承运商 | 文案 | 「起送瓶数」标明不影响用户起购 |
|
||||||
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
|
| 4 | 企微提交人 | 优化 | 门店新建/信息变更/套餐待审显示提交人名称 |
|
||||||
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;核销成功补用户 |
|
| 5 | 成交播报 | 优化 | 支付成功补规格/物流/收货;支付与核销补用户昵称、明文手机、HQ 备注 |
|
||||||
| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
|
| 6 | 结算通知 | 需求 | `alert.settlement` 改名为「结算任务失败通知」并归入系统监控;提现通过 + 财务四账单(含收款账户) |
|
||||||
|
|
||||||
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
|
**不做**:起购迁到承运商;多城按地址匹配起购(仍读当前 ACTIVE 城市);HQ 直改已营业门店不发待审通知。
|
||||||
@@ -63,11 +63,12 @@
|
|||||||
- 商品:`{名称} {规格} {瓶\|箱}×{数量}`
|
- 商品:`{名称} {规格} {瓶\|箱}×{数量}`
|
||||||
- 物流:现场提货 / 同城 / 跨城
|
- 物流:现场提货 / 同城 / 跨城
|
||||||
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
|
- 收货:提货=姓名+手机;同城/跨城=姓名+手机+详细地址
|
||||||
|
- 用户:昵称、手机号(明文)、HQ 备注 `hq_remark`(空则 —)
|
||||||
- 测试单不推
|
- 测试单不推
|
||||||
|
|
||||||
### 4.3 核销成功 `redeem.success`
|
### 4.3 核销成功 `redeem.success`
|
||||||
|
|
||||||
补 `userName`、`userPhone`(脱敏,与支付播报一致)。
|
补 `userName`、`userPhone`(明文)、`userRemark`(HQ 用户备注,空则 —)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
|
| `finance.winery_bill` | 酒厂日账单未 skip | 系统配置酒厂账户 |
|
||||||
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
|
| `finance.logistics_bill` | 物流月账成功且未 skip | 承运商卡 |
|
||||||
|
|
||||||
账单一次摘要(多店多行,最多 15 行);缺卡写「未登记收款账户」;内部群账号不脱敏。`created=0` / `skipped` 不推。
|
账单一次摘要:抬头含账期 / 账单笔数 / 累计金额;每笔分块写城市、门店或承运商、合伙人(公司-姓名)、核销/订单笔数、账单金额、收款人 / 账户 / 开户行。缺卡写「未登记」。内部群账号不脱敏。`created=0` / `skipped` 不推。正文过长时截断并提示到 HQ 查看。
|
||||||
|
|
||||||
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
|
启动 upsert「结算通知群」(占位 webhook + 禁用)。已有「运营告警」若勾了 `alert.settlement`,会收到任务失败/超时,**不会**自动勾上 5 个新业务事件。
|
||||||
|
|
||||||
@@ -95,8 +96,8 @@
|
|||||||
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
|
- [ ] 城市三字段起购;提货默认数量对齐;承运商起送不影响下单
|
||||||
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
|
- [ ] 合伙人新建/信息变更/套餐:企微可见「企业 · 姓名」
|
||||||
- [ ] HQ 新建门店:企微可见 HQ 名称
|
- [ ] HQ 新建门店:企微可见 HQ 名称
|
||||||
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确
|
- [ ] 支付成功:规格单位、物流方式、收货信息按提货/配送分支正确;用户昵称、明文手机、备注
|
||||||
- [ ] 核销成功:有用户昵称与脱敏手机
|
- [ ] 核销成功:有用户昵称、明文手机、备注
|
||||||
- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
|
- [ ] HQ 推送条件展示「结算任务失败通知」,在系统监控组
|
||||||
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
|
- [ ] 账单任务失败 / 提现超时出现在勾了「结算任务失败通知」的群
|
||||||
- [ ] 提现通过、四类账单生成含收款账户;无账单不刷屏
|
- [ ] 提现通过、四类账单生成含城市/合伙人、笔数、累计金额、收款人/账号/开户行;无账单不刷屏
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export interface DevPlanTaskDto {
|
|||||||
|
|
||||||
attachmentUrls?: string[] | null;
|
attachmentUrls?: string[] | null;
|
||||||
|
|
||||||
|
versions?: Array<{ id: string; versionNo: string }>;
|
||||||
|
|
||||||
|
versionIds?: string[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +170,8 @@ export interface UpdateDevPlanTaskInput {
|
|||||||
|
|
||||||
attachmentUrls?: string[];
|
attachmentUrls?: string[];
|
||||||
|
|
||||||
|
versionIds?: string[];
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ 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;
|
||||||
@@ -18,6 +20,16 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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;
|
||||||
@@ -47,6 +48,7 @@ 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) */
|
||||||
@@ -66,6 +68,7 @@ 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;
|
||||||
};
|
};
|
||||||
@@ -98,7 +101,9 @@ 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;
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export interface StoreBillDto {
|
|||||||
id: string;
|
id: string;
|
||||||
billNo: string;
|
billNo: string;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
|
/** 出账日 YYYY-MM-DD(T+1 窗口「昨日 00:00–今日 00:00」中的今天) */
|
||||||
billDate: string;
|
billDate: string;
|
||||||
redeemCount: number;
|
redeemCount: number;
|
||||||
redeemAmount: number;
|
redeemAmount: number;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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';
|
||||||
|
|
||||||
@@ -33,4 +34,22 @@ 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,6 +197,9 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
|
|||||||
'skuSummary',
|
'skuSummary',
|
||||||
'deliveryLabel',
|
'deliveryLabel',
|
||||||
'receiverInfo',
|
'receiverInfo',
|
||||||
|
'userName',
|
||||||
|
'userPhone',
|
||||||
|
'userRemark',
|
||||||
'phoneMasked',
|
'phoneMasked',
|
||||||
'time',
|
'time',
|
||||||
'handleUrl',
|
'handleUrl',
|
||||||
@@ -209,6 +212,7 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
|
|||||||
'channel',
|
'channel',
|
||||||
'userName',
|
'userName',
|
||||||
'userPhone',
|
'userPhone',
|
||||||
|
'userRemark',
|
||||||
'time',
|
'time',
|
||||||
'handleUrl',
|
'handleUrl',
|
||||||
'handleLabel',
|
'handleLabel',
|
||||||
@@ -255,9 +259,14 @@ 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',
|
||||||
@@ -277,6 +286,7 @@ 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',
|
||||||
@@ -285,6 +295,7 @@ 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',
|
||||||
@@ -292,8 +303,17 @@ export const WECOM_TEMPLATE_PLACEHOLDERS: Record<WecomTemplateEventKey, string[]
|
|||||||
],
|
],
|
||||||
'finance.winery_bill': [
|
'finance.winery_bill': [
|
||||||
'period',
|
'period',
|
||||||
'amount',
|
'billCount',
|
||||||
|
'totalAmount',
|
||||||
|
'cityNames',
|
||||||
|
'partnerNames',
|
||||||
'orderCount',
|
'orderCount',
|
||||||
|
'orderAmount',
|
||||||
|
'rate',
|
||||||
|
'amount',
|
||||||
|
'payee',
|
||||||
|
'accountNo',
|
||||||
|
'bankBranch',
|
||||||
'bankAccount',
|
'bankAccount',
|
||||||
'time',
|
'time',
|
||||||
'handleUrl',
|
'handleUrl',
|
||||||
@@ -302,6 +322,7 @@ 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: yesterday,
|
billDate: today,
|
||||||
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: dayBefore,
|
billDate: yesterday,
|
||||||
redeemCount: 3,
|
redeemCount: 3,
|
||||||
redeemAmount: 800,
|
redeemAmount: 800,
|
||||||
settlementRate: 0.6,
|
settlementRate: 0.6,
|
||||||
|
|||||||
@@ -100,6 +100,27 @@ 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 legacy = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey];
|
const legacyBodies = WECOM_PUSH_TEMPLATE_LEGACY_BODIES[def.eventKey] ?? [];
|
||||||
if (legacy && existing.body === legacy) {
|
if (legacyBodies.includes(existing.body)) {
|
||||||
await this.prisma.wecomPushTemplate.update({
|
await this.prisma.wecomPushTemplate.update({
|
||||||
where: { eventKey: def.eventKey },
|
where: { eventKey: def.eventKey },
|
||||||
data: {
|
data: {
|
||||||
@@ -473,7 +473,10 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
skuSummary: '杜康原浆 52度 瓶×2',
|
skuSummary: '杜康原浆 52度 瓶×2',
|
||||||
deliveryLabel: '同城',
|
deliveryLabel: '同城',
|
||||||
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
|
receiverInfo: '张三 13800008000 河南省郑州市金水区示例路1号',
|
||||||
phoneMasked: '138****8000',
|
userName: '好客用户',
|
||||||
|
userPhone: '13800008000',
|
||||||
|
userRemark: '大客户',
|
||||||
|
phoneMasked: '13800008000',
|
||||||
},
|
},
|
||||||
handlePath: '/orders?orderNo=DK202608200001',
|
handlePath: '/orders?orderNo=DK202608200001',
|
||||||
},
|
},
|
||||||
@@ -484,7 +487,8 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
storeName: '示例门店',
|
storeName: '示例门店',
|
||||||
channel: '扫码',
|
channel: '扫码',
|
||||||
userName: '好客用户',
|
userName: '好客用户',
|
||||||
userPhone: '138****8000',
|
userPhone: '13800008000',
|
||||||
|
userRemark: '大客户',
|
||||||
},
|
},
|
||||||
handlePath: '/redeem-records?redeemNo=RD202608200001',
|
handlePath: '/redeem-records?redeemNo=RD202608200001',
|
||||||
},
|
},
|
||||||
@@ -531,9 +535,14 @@ 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',
|
||||||
},
|
},
|
||||||
@@ -553,7 +562,19 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
period: '2026-08-25',
|
period: '2026-08-25',
|
||||||
billCount: '2',
|
billCount: '2',
|
||||||
billSummary: '示例门店 ¥300.00 张三 / 郑州支行 / 6222000011112222',
|
totalAmount: '500.00',
|
||||||
|
billSummary: [
|
||||||
|
'城市:郑州',
|
||||||
|
'门店:示例门店',
|
||||||
|
'合伙人:郑州某某商贸-张三',
|
||||||
|
'核销笔数:12',
|
||||||
|
'核销金额:¥500.00',
|
||||||
|
'结算比例:60%',
|
||||||
|
'账单:¥300.00',
|
||||||
|
'收款人:张三',
|
||||||
|
'收款账户:6222000011112222',
|
||||||
|
'收款开户行:郑州支行',
|
||||||
|
].join('\n'),
|
||||||
},
|
},
|
||||||
handlePath: '/finance/store-bills?kind=T1_BILL',
|
handlePath: '/finance/store-bills?kind=T1_BILL',
|
||||||
},
|
},
|
||||||
@@ -561,15 +582,36 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
period: '2026-07',
|
period: '2026-07',
|
||||||
billCount: '1',
|
billCount: '1',
|
||||||
billSummary: '郑州某某商贸 ¥120.00 张三 / 郑州支行 / 6222000011112222',
|
totalAmount: '120.00',
|
||||||
|
billSummary: [
|
||||||
|
'城市:郑州',
|
||||||
|
'合伙人:郑州某某商贸-张三',
|
||||||
|
'订单笔数:8',
|
||||||
|
'核销笔数:12',
|
||||||
|
'订单佣金:¥100.00',
|
||||||
|
'核销佣金:¥20.00',
|
||||||
|
'账单:¥120.00',
|
||||||
|
'收款人:张三',
|
||||||
|
'收款账户:6222000011112222',
|
||||||
|
'收款开户行:郑州支行',
|
||||||
|
].join('\n'),
|
||||||
},
|
},
|
||||||
handlePath: '/finance/partner-bills',
|
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',
|
||||||
@@ -578,7 +620,19 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
vars: {
|
vars: {
|
||||||
period: '2026-07',
|
period: '2026-07',
|
||||||
billCount: '1',
|
billCount: '1',
|
||||||
billSummary: '小飞侠 ¥86.00 承运商 / 郑州支行 / 6222000033334444',
|
totalAmount: '86.00',
|
||||||
|
billSummary: [
|
||||||
|
'城市:郑州',
|
||||||
|
'合伙人:郑州某某商贸-张三',
|
||||||
|
'承运商:小飞侠',
|
||||||
|
'结算方式:充值扣款',
|
||||||
|
'发货笔数:10',
|
||||||
|
'瓶数:60',
|
||||||
|
'账单:¥86.00',
|
||||||
|
'收款人:承运商',
|
||||||
|
'收款账户:6222000033334444',
|
||||||
|
'收款开户行:郑州支行',
|
||||||
|
].join('\n'),
|
||||||
},
|
},
|
||||||
handlePath: '/finance/logistics-bills',
|
handlePath: '/finance/logistics-bills',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
'商品:{{skuSummary}}',
|
'商品:{{skuSummary}}',
|
||||||
'物流:{{deliveryLabel}}',
|
'物流:{{deliveryLabel}}',
|
||||||
'收货:{{receiverInfo}}',
|
'收货:{{receiverInfo}}',
|
||||||
'用户:{{phoneMasked}}',
|
'用户:{{userName}} {{userPhone}}',
|
||||||
|
'备注:{{userRemark}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
@@ -36,6 +37,7 @@ 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'),
|
||||||
@@ -102,10 +104,14 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
title: '门店提现已通过',
|
title: '门店提现已通过',
|
||||||
body: [
|
body: [
|
||||||
'**门店提现已通过**',
|
'**门店提现已通过**',
|
||||||
|
'城市:{{cityName}}',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
|
'合伙人:{{partnerLabel}}',
|
||||||
'提现单号:{{withdrawNo}}',
|
'提现单号:{{withdrawNo}}',
|
||||||
'金额:¥{{amount}}',
|
'账单:¥{{amount}}',
|
||||||
'收款账户:{{bankAccount}}',
|
'收款人:{{payee}}',
|
||||||
|
'收款账户:{{accountNo}}',
|
||||||
|
'收款开户行:{{bankBranch}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
@@ -132,9 +138,12 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
body: [
|
body: [
|
||||||
'**门店日账单已生成**',
|
'**门店日账单已生成**',
|
||||||
'账期:{{period}}',
|
'账期:{{period}}',
|
||||||
'笔数:{{billCount}}',
|
'账单笔数:{{billCount}}',
|
||||||
'{{billSummary}}',
|
'累计金额:¥{{totalAmount}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
|
'',
|
||||||
|
'{{billSummary}}',
|
||||||
|
'',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
handleLabel: '去处理',
|
handleLabel: '去处理',
|
||||||
@@ -145,9 +154,12 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
body: [
|
body: [
|
||||||
'**合伙人月账单已生成**',
|
'**合伙人月账单已生成**',
|
||||||
'账期:{{period}}',
|
'账期:{{period}}',
|
||||||
'笔数:{{billCount}}',
|
'账单笔数:{{billCount}}',
|
||||||
'{{billSummary}}',
|
'累计金额:¥{{totalAmount}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
|
'',
|
||||||
|
'{{billSummary}}',
|
||||||
|
'',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
handleLabel: '去处理',
|
handleLabel: '去处理',
|
||||||
@@ -158,10 +170,20 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
body: [
|
body: [
|
||||||
'**酒厂日账单已生成**',
|
'**酒厂日账单已生成**',
|
||||||
'账期:{{period}}',
|
'账期:{{period}}',
|
||||||
'订单数:{{orderCount}}',
|
'账单笔数:{{billCount}}',
|
||||||
'金额:¥{{amount}}',
|
'累计金额:¥{{totalAmount}}',
|
||||||
'收款账户:{{bankAccount}}',
|
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
|
'',
|
||||||
|
'城市:{{cityNames}}',
|
||||||
|
'合伙人:{{partnerNames}}',
|
||||||
|
'订单笔数:{{orderCount}}',
|
||||||
|
'订单金额:¥{{orderAmount}}',
|
||||||
|
'结算比例:{{rate}}',
|
||||||
|
'账单:¥{{amount}}',
|
||||||
|
'收款人:{{payee}}',
|
||||||
|
'收款账户:{{accountNo}}',
|
||||||
|
'收款开户行:{{bankBranch}}',
|
||||||
|
'',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
handleLabel: '去处理',
|
handleLabel: '去处理',
|
||||||
@@ -172,18 +194,22 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
body: [
|
body: [
|
||||||
'**物流月对账已生成**',
|
'**物流月对账已生成**',
|
||||||
'账期:{{period}}',
|
'账期:{{period}}',
|
||||||
'笔数:{{billCount}}',
|
'账单笔数:{{billCount}}',
|
||||||
'{{billSummary}}',
|
'累计金额:¥{{totalAmount}}',
|
||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
|
'',
|
||||||
|
'{{billSummary}}',
|
||||||
|
'',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
handleLabel: '去处理',
|
handleLabel: '去处理',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** v3.5.3 默认正文;ensureTemplates 仅在库中仍为此文案时升级,不覆盖 HQ 手改 */
|
/** 上一版默认正文;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}}',
|
||||||
@@ -193,7 +219,21 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEven
|
|||||||
'时间:{{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}}',
|
||||||
@@ -202,7 +242,19 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEven
|
|||||||
'时间:{{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}}',
|
||||||
@@ -210,7 +262,9 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEven
|
|||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
|
],
|
||||||
'store.package_audit_pending': [
|
'store.package_audit_pending': [
|
||||||
|
[
|
||||||
'**套餐变更待审核**',
|
'**套餐变更待审核**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
@@ -219,7 +273,9 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEven
|
|||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
|
],
|
||||||
'store.info_change_pending': [
|
'store.info_change_pending': [
|
||||||
|
[
|
||||||
'**门店信息变更待审**',
|
'**门店信息变更待审**',
|
||||||
'门店:{{storeName}}',
|
'门店:{{storeName}}',
|
||||||
'城市:{{cityName}}',
|
'城市:{{cityName}}',
|
||||||
@@ -228,6 +284,59 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEven
|
|||||||
'时间:{{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,10 +156,16 @@ export class DevPlanService {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
extras?: { creatorName?: string | null; supportTicketNo?: string | null },
|
extras?: {
|
||||||
|
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),
|
||||||
@@ -188,6 +194,10 @@ export class DevPlanService {
|
|||||||
|
|
||||||
attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
|
attachmentUrls: parseAttachmentUrls(row.attachmentUrls),
|
||||||
|
|
||||||
|
versions,
|
||||||
|
|
||||||
|
versionIds: versions.map((v) => v.id),
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -306,7 +316,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] = await Promise.all([
|
const [names, tickets, versionMap] = await Promise.all([
|
||||||
|
|
||||||
this.loadHqNames(creatorIds),
|
this.loadHqNames(creatorIds),
|
||||||
|
|
||||||
@@ -322,6 +332,8 @@ 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>(
|
||||||
@@ -340,6 +352,8 @@ 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)) ?? [],
|
||||||
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
);
|
);
|
||||||
@@ -374,12 +388,16 @@ 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)) ?? [],
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -508,6 +526,10 @@ 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);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -606,6 +628,41 @@ 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,6 +62,11 @@ 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,6 +30,8 @@ type OrderFilterInput = Pick<
|
|||||||
| 'status'
|
| 'status'
|
||||||
| 'orderType'
|
| 'orderType'
|
||||||
| 'userId'
|
| 'userId'
|
||||||
|
| 'userKeyword'
|
||||||
|
| 'productKeyword'
|
||||||
| 'cityId'
|
| 'cityId'
|
||||||
| 'receiverPhone'
|
| 'receiverPhone'
|
||||||
| 'fulfillmentHold'
|
| 'fulfillmentHold'
|
||||||
@@ -37,6 +39,7 @@ type OrderFilterInput = Pick<
|
|||||||
| 'createdTo'
|
| 'createdTo'
|
||||||
| 'excludeTest'
|
| 'excludeTest'
|
||||||
| 'deliveryType'
|
| 'deliveryType'
|
||||||
|
| 'promoCodeId'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -94,7 +97,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 } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } },
|
||||||
delivery: {
|
delivery: {
|
||||||
select: {
|
select: {
|
||||||
provider: true,
|
provider: true,
|
||||||
@@ -176,8 +179,28 @@ 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'];
|
||||||
}
|
}
|
||||||
@@ -190,6 +213,10 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -252,6 +279,7 @@ 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 } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: 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 } },
|
user: { select: { id: true, userNo: true, phone: true, nickname: true, hqRemark: true } },
|
||||||
store: {
|
store: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ 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,6 +48,11 @@ export class AdminUsersQueryDto extends PaginationQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
userNo?: string;
|
userNo?: string;
|
||||||
|
|
||||||
|
/** 精确匹配用户主键,供订单等页的用户快链带入筛选 */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
deviceKey?: string;
|
deviceKey?: string;
|
||||||
@@ -88,6 +93,16 @@ 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;
|
||||||
@@ -116,6 +131,11 @@ 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 订单导出(筛选 + 勾选范围) */
|
||||||
@@ -149,6 +169,14 @@ 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;
|
||||||
@@ -174,6 +202,10 @@ 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) {
|
private mapRow(row: PromoRow, orderCount?: number) {
|
||||||
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: row.orderCount,
|
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,9 +207,10 @@ 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)),
|
items: items.map((r) => this.mapRow(r, completedByPromo.get(r.id.toString()) ?? 0)),
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -223,7 +224,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 });
|
return serializeBigInt({ ...this.mapRow(row, stats.orderCount), stats });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveOwnerUserId(ownerUserId?: string) {
|
private async resolveOwnerUserId(ownerUserId?: string) {
|
||||||
@@ -519,6 +520,40 @@ 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,
|
||||||
@@ -610,12 +645,37 @@ 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,
|
||||||
@@ -636,12 +696,9 @@ export class PromoCodeService {
|
|||||||
if (!promo) throw new NotFoundException('推广码不存在');
|
if (!promo) throw new NotFoundException('推广码不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async statsFromRow(row: { id: bigint; scanCount: number; orderCount: number }) {
|
private async statsFromRow(row: { id: bigint; scanCount: number }) {
|
||||||
const scanCount = row.scanCount;
|
const scanCount = row.scanCount;
|
||||||
const orderCount = row.orderCount;
|
const [attributionCount, sourceMarkedCount, orderCount] = await Promise.all([
|
||||||
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 },
|
||||||
}),
|
}),
|
||||||
@@ -652,7 +709,12 @@ 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,
|
||||||
@@ -695,7 +757,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: true } },
|
_count: { select: { orders: { where: { status: 'COMPLETED' } } } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.user.count({ where }),
|
this.prisma.user.count({ where }),
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
calcRedeemSettleAmount,
|
calcRedeemSettleAmount,
|
||||||
generateRedeemNo,
|
generateRedeemNo,
|
||||||
generateRedeemPendingNo,
|
generateRedeemPendingNo,
|
||||||
maskContactPhone,
|
|
||||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||||
resolveSettlementRate,
|
resolveSettlementRate,
|
||||||
validateRedeemAmount,
|
validateRedeemAmount,
|
||||||
@@ -197,7 +196,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 },
|
select: { isTest: true, nickname: true, phone: true, hqRemark: true },
|
||||||
}),
|
}),
|
||||||
this.prisma.store.findUnique({
|
this.prisma.store.findUnique({
|
||||||
where: { id: account.storeId },
|
where: { id: account.storeId },
|
||||||
@@ -295,7 +294,8 @@ 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: maskContactPhone(userRow?.phone),
|
userPhone: (userRow?.phone || '').trim() || '—',
|
||||||
|
userRemark: (userRow?.hqRemark || '').trim() || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
|
handlePath: `/redeem-records?redeemNo=${encodeURIComponent(record.redeemNo)}`,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleInit } 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';
|
||||||
@@ -28,6 +29,17 @@ 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)}`;
|
||||||
@@ -43,11 +55,16 @@ 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: start };
|
return { start, end, billDate: end };
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -63,12 +80,37 @@ function round2(n: number) {
|
|||||||
return Math.round(n * 100) / 100;
|
return Math.round(n * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BILL_DIGEST_MAX_LINES = 15;
|
function sumAmounts(rows: Array<{ amount: number }>): string {
|
||||||
|
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
function capBillSummary(lines: string[]): string {
|
function storeWecomMeta(
|
||||||
if (!lines.length) return '无';
|
store:
|
||||||
if (lines.length <= BILL_DIGEST_MAX_LINES) return lines.join('\n');
|
| {
|
||||||
return `${lines.slice(0, BILL_DIGEST_MAX_LINES).join('\n')}\n…等共 ${lines.length} 笔`;
|
name?: string | null;
|
||||||
|
cityName?: string | null;
|
||||||
|
cityRef?: { name: string } | null;
|
||||||
|
partnerAccount?: {
|
||||||
|
name: string | null;
|
||||||
|
companyName: string | null;
|
||||||
|
parent?: { companyName: string | null } | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
fallbackName: string,
|
||||||
|
) {
|
||||||
|
const cityName = store?.cityRef?.name?.trim() || store?.cityName?.trim() || '—';
|
||||||
|
const acc = store?.partnerAccount;
|
||||||
|
const partnerLabel = formatPartnerDashLabel(
|
||||||
|
acc?.companyName || acc?.parent?.companyName,
|
||||||
|
acc?.name,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
storeName: store?.name?.trim() || fallbackName,
|
||||||
|
cityName,
|
||||||
|
partnerLabel,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStoreWithdrawDailyLimit(): number {
|
function getStoreWithdrawDailyLimit(): number {
|
||||||
@@ -94,7 +136,9 @@ function isWithdrawOverdue(appliedAt: Date, now = new Date()): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SettlementService {
|
export class SettlementService implements OnModuleInit {
|
||||||
|
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,
|
||||||
@@ -104,27 +148,59 @@ export class SettlementService {
|
|||||||
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<{
|
||||||
label: string;
|
cityName: string;
|
||||||
|
partnerLabel: string;
|
||||||
|
orderCount?: number;
|
||||||
|
redeemCount?: number;
|
||||||
|
orderCommission: number;
|
||||||
|
redeemCommission: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
bankAccountName?: string | null;
|
bank?: WecomBankLike | null;
|
||||||
bankBranch?: string | null;
|
|
||||||
bankAccountNo?: string | null;
|
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
const lines = rows.map(
|
const blocks = rows.map((r) => formatPartnerBillWecomBlock(r));
|
||||||
(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),
|
||||||
billSummary: capBillSummary(lines),
|
totalAmount: sumAmounts(rows),
|
||||||
|
billSummary: capBillBlocks(blocks),
|
||||||
},
|
},
|
||||||
{ handlePath: '/finance/partner-bills' },
|
{ handlePath: '/finance/partner-bills' },
|
||||||
);
|
);
|
||||||
@@ -133,25 +209,25 @@ export class SettlementService {
|
|||||||
private notifyLogisticsBillDigest(
|
private notifyLogisticsBillDigest(
|
||||||
period: string,
|
period: string,
|
||||||
rows: Array<{
|
rows: Array<{
|
||||||
label: string;
|
cityNames: string;
|
||||||
|
partnerNames: string;
|
||||||
|
providerName: string;
|
||||||
|
settlementMethod: string;
|
||||||
|
orderCount: number;
|
||||||
|
bottleCount: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
bankAccountName?: string | null;
|
bank?: WecomBankLike | null;
|
||||||
bankName?: string | null;
|
|
||||||
bankBranch?: string | null;
|
|
||||||
bankAccountNo?: string | null;
|
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
const lines = rows.map(
|
const blocks = rows.map((r) => formatLogisticsBillWecomBlock(r));
|
||||||
(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),
|
||||||
billSummary: capBillSummary(lines),
|
totalAmount: sumAmounts(rows),
|
||||||
|
billSummary: capBillBlocks(blocks),
|
||||||
},
|
},
|
||||||
{ handlePath: '/finance/logistics-bills' },
|
{ handlePath: '/finance/logistics-bills' },
|
||||||
);
|
);
|
||||||
@@ -649,16 +725,34 @@ export class SettlementService {
|
|||||||
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: { name: true },
|
select: {
|
||||||
|
name: true,
|
||||||
|
cityName: true,
|
||||||
|
cityRef: { select: { name: true } },
|
||||||
|
partnerAccount: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
companyName: true,
|
||||||
|
parent: { select: { companyName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
loadStorePrimaryBank(this.prisma, row.storeId),
|
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',
|
||||||
{
|
{
|
||||||
storeName: store?.name || String(row.storeId),
|
cityName: meta.cityName,
|
||||||
|
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(),
|
||||||
},
|
},
|
||||||
@@ -919,7 +1013,7 @@ export class SettlementService {
|
|||||||
|
|
||||||
// ─── 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({
|
||||||
@@ -930,7 +1024,24 @@ export class SettlementService {
|
|||||||
withdrawItem: { is: null },
|
withdrawItem: { is: null },
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
},
|
},
|
||||||
include: { store: { select: { id: true, name: true, settlementRate: true } } },
|
include: {
|
||||||
|
store: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
cityName: true,
|
||||||
|
settlementRate: true,
|
||||||
|
cityRef: { select: { name: true } },
|
||||||
|
partnerAccount: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
companyName: true,
|
||||||
|
parent: { select: { companyName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const byStore = new Map<string, typeof payouts>();
|
const byStore = new Map<string, typeof payouts>();
|
||||||
@@ -943,10 +1054,19 @@ export class SettlementService {
|
|||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
const createdBills: Array<{ storeId: bigint; storeName: string; amount: number }> = [];
|
const createdBills: Array<{
|
||||||
|
storeId: bigint;
|
||||||
|
storeName: string;
|
||||||
|
cityName: string;
|
||||||
|
partnerLabel: string;
|
||||||
|
redeemCount: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
settlementRate: number;
|
||||||
|
amount: number;
|
||||||
|
}> = [];
|
||||||
for (const [storeIdStr, list] of byStore) {
|
for (const [storeIdStr, list] of byStore) {
|
||||||
const storeId = BigInt(storeIdStr);
|
const storeId = BigInt(storeIdStr);
|
||||||
const storeName = list[0]?.store?.name || storeIdStr;
|
const meta = storeWecomMeta(list[0]?.store, storeIdStr);
|
||||||
const existing = await this.prisma.storeBill.findUnique({
|
const existing = await this.prisma.storeBill.findUnique({
|
||||||
where: { storeId_billDate: { storeId, billDate } },
|
where: { storeId_billDate: { storeId, billDate } },
|
||||||
});
|
});
|
||||||
@@ -975,7 +1095,14 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
createdBills.push({ storeId, storeName, amount: updatedTotal });
|
createdBills.push({
|
||||||
|
storeId,
|
||||||
|
...meta,
|
||||||
|
redeemCount: existing.redeemCount + list.length,
|
||||||
|
redeemAmount: round2(Number(existing.redeemAmount) + redeemAmount),
|
||||||
|
settlementRate: rate,
|
||||||
|
amount: updatedTotal,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1001,25 +1128,35 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
createdBills.push({ storeId, storeName, amount: payoutAmount });
|
createdBills.push({
|
||||||
|
storeId,
|
||||||
|
...meta,
|
||||||
|
redeemCount: list.length,
|
||||||
|
redeemAmount,
|
||||||
|
settlementRate: rate,
|
||||||
|
amount: payoutAmount,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const period = billDate.toISOString().slice(0, 10);
|
const period = shanghaiYmd(billDate);
|
||||||
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 lines = createdBills.map(
|
const blocks = createdBills.map((b) =>
|
||||||
(b) =>
|
formatStoreBillWecomBlock({
|
||||||
`${b.storeName} ¥${b.amount.toFixed(2)} ${formatWecomBankAccount(banks.get(b.storeId.toString()) ?? null)}`,
|
...b,
|
||||||
|
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),
|
||||||
billSummary: capBillSummary(lines),
|
totalAmount: sumAmounts(createdBills),
|
||||||
|
billSummary: capBillBlocks(blocks),
|
||||||
},
|
},
|
||||||
{ handlePath: '/finance/store-bills?kind=T1_BILL' },
|
{ handlePath: '/finance/store-bills?kind=T1_BILL' },
|
||||||
);
|
);
|
||||||
@@ -1166,6 +1303,7 @@ export class SettlementService {
|
|||||||
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
|
||||||
@@ -1220,7 +1358,13 @@ export class SettlementService {
|
|||||||
totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
|
totalAmount: Number(aggregates._sum.payoutAmount ?? 0),
|
||||||
};
|
};
|
||||||
|
|
||||||
return serializeBigInt({ items, total, page, pageSize, summary });
|
return serializeBigInt({
|
||||||
|
items: items.map((b) => ({ ...b, billDate: shanghaiYmd(b.billDate) })),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
summary,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAdminStoreBill(id: bigint) {
|
async getAdminStoreBill(id: bigint) {
|
||||||
@@ -1236,7 +1380,7 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
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, storeAccount });
|
return serializeBigInt({ ...bill, billDate: shanghaiYmd(bill.billDate), storeAccount });
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
|
async confirmStoreBill(id: bigint, dto: { paymentRef?: string } = {}) {
|
||||||
@@ -1294,7 +1438,7 @@ export class SettlementService {
|
|||||||
);
|
);
|
||||||
const header = [
|
const header = [
|
||||||
'账单号',
|
'账单号',
|
||||||
'账单日',
|
'出账日',
|
||||||
'门店',
|
'门店',
|
||||||
'城市',
|
'城市',
|
||||||
'核销笔数',
|
'核销笔数',
|
||||||
@@ -1312,7 +1456,7 @@ export class SettlementService {
|
|||||||
const bank = bankMap.get(String(b.storeId));
|
const bank = bankMap.get(String(b.storeId));
|
||||||
return [
|
return [
|
||||||
csvEscape(b.billNo),
|
csvEscape(b.billNo),
|
||||||
b.billDate.toISOString().slice(0, 10),
|
shanghaiYmd(b.billDate),
|
||||||
csvEscape(b.store.name),
|
csvEscape(b.store.name),
|
||||||
csvEscape(b.store.cityName ?? ''),
|
csvEscape(b.store.cityName ?? ''),
|
||||||
b.redeemCount,
|
b.redeemCount,
|
||||||
@@ -1476,15 +1620,19 @@ export class SettlementService {
|
|||||||
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<{
|
||||||
label: string;
|
cityName: string;
|
||||||
|
partnerLabel: string;
|
||||||
|
orderCount?: number;
|
||||||
|
redeemCount?: number;
|
||||||
|
orderCommission: number;
|
||||||
|
redeemCommission: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
bankAccountName: string | null;
|
bank?: WecomBankLike | null;
|
||||||
bankBranch: string | null;
|
|
||||||
bankAccountNo: string | null;
|
|
||||||
}> = [];
|
}> = [];
|
||||||
for (const p of partners) {
|
for (const p of partners) {
|
||||||
try {
|
try {
|
||||||
@@ -1497,14 +1645,19 @@ export class SettlementService {
|
|||||||
{ 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({
|
||||||
label,
|
cityName: p.city?.name?.trim() || '—',
|
||||||
|
partnerLabel: formatPartnerDashLabel(p.companyName, p.name),
|
||||||
|
orderCount: Number(bill.orderCount ?? 0),
|
||||||
|
redeemCount: Number(bill.redeemCount ?? 0),
|
||||||
|
orderCommission: Number(bill.orderCommission),
|
||||||
|
redeemCommission: Number(bill.redeemCommission),
|
||||||
amount: Number(bill.totalAmount),
|
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({
|
||||||
@@ -1628,24 +1781,37 @@ export class SettlementService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (opts?.notify !== false) {
|
if (opts?.notify !== false) {
|
||||||
const label =
|
let cityName = '—';
|
||||||
[primary.companyName?.trim(), primary.name?.trim()].filter(Boolean).join(' · ') ||
|
if (primary.cityId) {
|
||||||
primary.id.toString();
|
const city = await this.prisma.commonCity.findUnique({
|
||||||
this.notifyPartnerBillDigest(
|
where: { id: primary.cityId },
|
||||||
`${body.year}-${String(body.month).padStart(2, '0')}`,
|
select: { name: true },
|
||||||
[
|
});
|
||||||
|
cityName = city?.name?.trim() || '—';
|
||||||
|
}
|
||||||
|
this.notifyPartnerBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [
|
||||||
{
|
{
|
||||||
label,
|
cityName,
|
||||||
|
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
|
||||||
|
orderCount: orders.length,
|
||||||
|
redeemCount: redeems.length,
|
||||||
|
orderCommission: round2(orderCommission),
|
||||||
|
redeemCommission: round2(redeemCommission),
|
||||||
amount: totalAmount,
|
amount: totalAmount,
|
||||||
|
bank: {
|
||||||
bankAccountName: primary.bankAccountName,
|
bankAccountName: primary.bankAccountName,
|
||||||
bankBranch: primary.bankBranch,
|
bankBranch: primary.bankBranch,
|
||||||
bankAccountNo: primary.bankAccountNo,
|
bankAccountNo: primary.bankAccountNo,
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeBigInt(bill);
|
return serializeBigInt({
|
||||||
|
...bill,
|
||||||
|
orderCount: orders.length,
|
||||||
|
redeemCount: redeems.length,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** HQ:发送给合伙人(待审核 → 待合伙人确认) */
|
/** HQ:发送给合伙人(待审核 → 待合伙人确认) */
|
||||||
@@ -1880,6 +2046,7 @@ export class SettlementService {
|
|||||||
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' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1937,12 +2104,27 @@ export class SettlementService {
|
|||||||
|
|
||||||
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,
|
||||||
amount: wineryAmount.toFixed(2),
|
billCount: '1',
|
||||||
|
totalAmount: wineryAmount.toFixed(2),
|
||||||
|
cityNames,
|
||||||
|
partnerNames,
|
||||||
orderCount: String(orders.length),
|
orderCount: String(orders.length),
|
||||||
|
orderAmount: orderAmount.toFixed(2),
|
||||||
|
rate: formatPercent(rate),
|
||||||
|
amount: wineryAmount.toFixed(2),
|
||||||
|
payee: bankParts.payee,
|
||||||
|
accountNo: bankParts.accountNo,
|
||||||
|
bankBranch: bankParts.bankBranch,
|
||||||
bankAccount: formatWecomBankAccount(wineryBank),
|
bankAccount: formatWecomBankAccount(wineryBank),
|
||||||
},
|
},
|
||||||
{ handlePath: '/finance/winery-bills' },
|
{ handlePath: '/finance/winery-bills' },
|
||||||
@@ -2171,12 +2353,14 @@ export class SettlementService {
|
|||||||
billId?: string;
|
billId?: string;
|
||||||
}> = [];
|
}> = [];
|
||||||
const created: Array<{
|
const created: Array<{
|
||||||
label: string;
|
cityNames: string;
|
||||||
|
partnerNames: string;
|
||||||
|
providerName: string;
|
||||||
|
settlementMethod: string;
|
||||||
|
orderCount: number;
|
||||||
|
bottleCount: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
bankAccountName: string | null;
|
bank?: WecomBankLike | null;
|
||||||
bankName: string | null;
|
|
||||||
bankBranch: string | null;
|
|
||||||
bankAccountNo: string | null;
|
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
for (const p of providers) {
|
for (const p of providers) {
|
||||||
@@ -2203,15 +2387,8 @@ export class SettlementService {
|
|||||||
: String(bill.bill.id)
|
: String(bill.bill.id)
|
||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
if (!bill.skipped && bill.bill) {
|
if (!bill.skipped && bill.wecom) {
|
||||||
created.push({
|
created.push(bill.wecom);
|
||||||
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({
|
||||||
@@ -2237,6 +2414,20 @@ export class SettlementService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async partnerDashLabels(ids: Array<bigint | null | undefined>, max = 5): Promise<string> {
|
||||||
|
const uniq = [...new Set(ids.filter((id): id is bigint => id != null))];
|
||||||
|
if (!uniq.length) return '—';
|
||||||
|
const partners = await this.prisma.partnerAccount.findMany({
|
||||||
|
where: { id: { in: uniq } },
|
||||||
|
select: { name: true, companyName: true },
|
||||||
|
});
|
||||||
|
return joinLimited(
|
||||||
|
partners.map((p) => formatPartnerDashLabel(p.companyName, p.name)),
|
||||||
|
'家',
|
||||||
|
max,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async generateLogisticsBill(
|
async generateLogisticsBill(
|
||||||
body: { providerId: string; year: number; month: number },
|
body: { providerId: string; year: number; month: number },
|
||||||
opts?: { notify?: boolean },
|
opts?: { notify?: boolean },
|
||||||
@@ -2291,6 +2482,8 @@ export class SettlementService {
|
|||||||
quantity: true,
|
quantity: true,
|
||||||
deliveryType: true,
|
deliveryType: true,
|
||||||
payStatus: true,
|
payStatus: true,
|
||||||
|
partnerAccountIdAtPay: true,
|
||||||
|
city: { select: { name: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2392,25 +2585,38 @@ export class SettlementService {
|
|||||||
return header;
|
return header;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (opts?.notify !== false) {
|
const wecom = {
|
||||||
this.notifyLogisticsBillDigest(
|
cityNames: joinLimited(
|
||||||
`${body.year}-${String(body.month).padStart(2, '0')}`,
|
eligible.map((d) => d.order.city?.name || ''),
|
||||||
[
|
'城',
|
||||||
{
|
),
|
||||||
label: provider.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),
|
amount: Number(bill.logisticsAmount),
|
||||||
|
bank: {
|
||||||
bankAccountName: provider.bankAccountName,
|
bankAccountName: provider.bankAccountName,
|
||||||
bankName: provider.bankName,
|
bankName: provider.bankName,
|
||||||
bankBranch: provider.bankBranch,
|
bankBranch: provider.bankBranch,
|
||||||
bankAccountNo: provider.bankAccountNo,
|
bankAccountNo: provider.bankAccountNo,
|
||||||
},
|
},
|
||||||
],
|
};
|
||||||
);
|
|
||||||
|
if (opts?.notify !== false) {
|
||||||
|
this.notifyLogisticsBillDigest(`${body.year}-${String(body.month).padStart(2, '0')}`, [
|
||||||
|
wecom,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
skipped: false,
|
skipped: false,
|
||||||
bill: serializeBigInt(bill),
|
bill: serializeBigInt(bill),
|
||||||
|
wecom,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/** 企微结算账单正文:分字段块 + 字节上限(机器人 markdown 约 4096 字节) */
|
||||||
|
|
||||||
|
export type WecomBankLike = {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatYuan(n: number): string {
|
||||||
|
return `¥${Number(n).toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercent(rate: number): string {
|
||||||
|
const p = Number(rate) * 100;
|
||||||
|
if (!Number.isFinite(p)) return '—';
|
||||||
|
const rounded = Math.round(p * 100) / 100;
|
||||||
|
const text = Number.isInteger(rounded) ? String(rounded) : String(rounded);
|
||||||
|
return `${text}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPartnerDashLabel(
|
||||||
|
company?: string | null,
|
||||||
|
name?: string | null,
|
||||||
|
): string {
|
||||||
|
const person = (name || '').trim() || '—';
|
||||||
|
const org = (company || '').trim();
|
||||||
|
return org ? `${org}-${person}` : person;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomBankParts(bank?: WecomBankLike | null): {
|
||||||
|
payee: string;
|
||||||
|
accountNo: string;
|
||||||
|
bankBranch: string;
|
||||||
|
} {
|
||||||
|
if (!bank) return { payee: '未登记', accountNo: '—', bankBranch: '—' };
|
||||||
|
const payee = bank.bankAccountName?.trim() || '';
|
||||||
|
const accountNo = bank.bankAccountNo?.trim() || '';
|
||||||
|
const bankBranch = bank.bankName?.trim() || bank.bankBranch?.trim() || '';
|
||||||
|
if (!payee && !accountNo) {
|
||||||
|
return { payee: '未登记', accountNo: '—', bankBranch: bankBranch || '—' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
payee: payee || '—',
|
||||||
|
accountNo: accountNo || '—',
|
||||||
|
bankBranch: bankBranch || '—',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWecomFieldBlock(rows: Array<[string, string]>): string {
|
||||||
|
return rows.map(([k, v]) => `${k}:${v}`).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinLimited(names: string[], unit: string, max = 8): string {
|
||||||
|
const uniq = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
|
||||||
|
if (!uniq.length) return '—';
|
||||||
|
if (uniq.length <= max) return uniq.join('、');
|
||||||
|
return `${uniq.slice(0, max).join('、')} 等${uniq.length}${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BILL_DIGEST_MAX_BYTES = 3200;
|
||||||
|
|
||||||
|
export function capBillBlocks(blocks: string[]): string {
|
||||||
|
if (!blocks.length) return '无';
|
||||||
|
const out: string[] = [];
|
||||||
|
let bytes = 0;
|
||||||
|
let shown = 0;
|
||||||
|
for (const block of blocks) {
|
||||||
|
const extra = (shown ? 2 : 0) + Buffer.byteLength(block, 'utf8');
|
||||||
|
if (shown > 0 && bytes + extra > BILL_DIGEST_MAX_BYTES) {
|
||||||
|
out.push(`另有 ${blocks.length - shown} 笔未列出,请到 HQ 查看`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
out.push(block);
|
||||||
|
bytes += extra;
|
||||||
|
shown += 1;
|
||||||
|
}
|
||||||
|
return out.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatStoreBillWecomBlock(row: {
|
||||||
|
cityName: string;
|
||||||
|
storeName: string;
|
||||||
|
partnerLabel: string;
|
||||||
|
redeemCount: number;
|
||||||
|
redeemAmount: number;
|
||||||
|
settlementRate: number;
|
||||||
|
amount: number;
|
||||||
|
bank?: WecomBankLike | null;
|
||||||
|
}): string {
|
||||||
|
const bank = wecomBankParts(row.bank);
|
||||||
|
return formatWecomFieldBlock([
|
||||||
|
['城市', row.cityName || '—'],
|
||||||
|
['门店', row.storeName || '—'],
|
||||||
|
['合伙人', row.partnerLabel || '—'],
|
||||||
|
['核销笔数', String(row.redeemCount)],
|
||||||
|
['核销金额', formatYuan(row.redeemAmount)],
|
||||||
|
['结算比例', formatPercent(row.settlementRate)],
|
||||||
|
['账单', formatYuan(row.amount)],
|
||||||
|
['收款人', bank.payee],
|
||||||
|
['收款账户', bank.accountNo],
|
||||||
|
['收款开户行', bank.bankBranch],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPartnerBillWecomBlock(row: {
|
||||||
|
cityName: string;
|
||||||
|
partnerLabel: string;
|
||||||
|
orderCount?: number;
|
||||||
|
redeemCount?: number;
|
||||||
|
orderCommission: number;
|
||||||
|
redeemCommission: number;
|
||||||
|
amount: number;
|
||||||
|
bank?: WecomBankLike | null;
|
||||||
|
}): string {
|
||||||
|
const bank = wecomBankParts(row.bank);
|
||||||
|
const rows: Array<[string, string]> = [
|
||||||
|
['城市', row.cityName || '—'],
|
||||||
|
['合伙人', row.partnerLabel || '—'],
|
||||||
|
];
|
||||||
|
if (row.orderCount != null) rows.push(['订单笔数', String(row.orderCount)]);
|
||||||
|
if (row.redeemCount != null) rows.push(['核销笔数', String(row.redeemCount)]);
|
||||||
|
rows.push(
|
||||||
|
['订单佣金', formatYuan(row.orderCommission)],
|
||||||
|
['核销佣金', formatYuan(row.redeemCommission)],
|
||||||
|
['账单', formatYuan(row.amount)],
|
||||||
|
['收款人', bank.payee],
|
||||||
|
['收款账户', bank.accountNo],
|
||||||
|
['收款开户行', bank.bankBranch],
|
||||||
|
);
|
||||||
|
return formatWecomFieldBlock(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatLogisticsBillWecomBlock(row: {
|
||||||
|
cityNames: string;
|
||||||
|
partnerNames: string;
|
||||||
|
providerName: string;
|
||||||
|
settlementMethod: string;
|
||||||
|
orderCount: number;
|
||||||
|
bottleCount: number;
|
||||||
|
amount: number;
|
||||||
|
bank?: WecomBankLike | null;
|
||||||
|
}): string {
|
||||||
|
const bank = wecomBankParts(row.bank);
|
||||||
|
return formatWecomFieldBlock([
|
||||||
|
['城市', row.cityNames || '—'],
|
||||||
|
['合伙人', row.partnerNames || '—'],
|
||||||
|
['承运商', row.providerName || '—'],
|
||||||
|
['结算方式', row.settlementMethod || '—'],
|
||||||
|
['发货笔数', String(row.orderCount)],
|
||||||
|
['瓶数', String(row.bottleCount)],
|
||||||
|
['账单', formatYuan(row.amount)],
|
||||||
|
['收款人', bank.payee],
|
||||||
|
['收款账户', bank.accountNo],
|
||||||
|
['收款开户行', bank.bankBranch],
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import type { FreightPayType } from '@prisma/client';
|
|||||||
import {
|
import {
|
||||||
calcBenefitAmount,
|
calcBenefitAmount,
|
||||||
generateOrderNo,
|
generateOrderNo,
|
||||||
maskContactPhone,
|
|
||||||
minBottleQtyForDelivery,
|
minBottleQtyForDelivery,
|
||||||
orderTabToStatuses,
|
orderTabToStatuses,
|
||||||
toMinSaleQuantity,
|
toMinSaleQuantity,
|
||||||
@@ -358,25 +357,9 @@ 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;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (promoCodeId) {
|
|
||||||
this.promoCodeService.logPromoOrderEvent(
|
|
||||||
promoCodeId,
|
|
||||||
order.id,
|
|
||||||
userId,
|
|
||||||
location.clientIp ?? undefined,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.analyticsService.trackOneSafe(userId, this.resolveTrackedUserClientApp(clientApp), {
|
this.analyticsService.trackOneSafe(userId, this.resolveTrackedUserClientApp(clientApp), {
|
||||||
eventName: 'order_submit',
|
eventName: 'order_submit',
|
||||||
refType: 'ORDER',
|
refType: 'ORDER',
|
||||||
@@ -522,11 +505,20 @@ export class TradeService {
|
|||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
include: {
|
include: {
|
||||||
city: { select: { name: true } },
|
city: { select: { name: true } },
|
||||||
user: { select: { phone: true } },
|
user: { select: { phone: true, nickname: true, hqRemark: 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();
|
||||||
@@ -543,7 +535,9 @@ 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 phoneMasked = maskContactPhone(order.user?.phone || order.receiverPhone);
|
const userName = order.user?.nickname?.trim() || '—';
|
||||||
|
const userPhone = (order.user?.phone || '').trim() || '—';
|
||||||
|
const userRemark = (order.user?.hqRemark || '').trim() || '—';
|
||||||
void this.wecomPush.dispatchEvent(
|
void this.wecomPush.dispatchEvent(
|
||||||
'order.paid',
|
'order.paid',
|
||||||
{
|
{
|
||||||
@@ -553,7 +547,10 @@ export class TradeService {
|
|||||||
skuSummary,
|
skuSummary,
|
||||||
deliveryLabel,
|
deliveryLabel,
|
||||||
receiverInfo,
|
receiverInfo,
|
||||||
phoneMasked,
|
userName,
|
||||||
|
userPhone,
|
||||||
|
userRemark,
|
||||||
|
phoneMasked: userPhone,
|
||||||
},
|
},
|
||||||
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
|
{ handlePath: `/orders?orderNo=${encodeURIComponent(order.orderNo)}` },
|
||||||
);
|
);
|
||||||
@@ -1589,6 +1586,15 @@ export class TradeService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (targetStatus === 'COMPLETED' && currentStatus !== 'COMPLETED' && order.promoCodeId) {
|
||||||
|
await this.promoCodeService.recordCompletedOrder(
|
||||||
|
order.promoCodeId,
|
||||||
|
order.id,
|
||||||
|
order.userId,
|
||||||
|
order.clientIp ?? undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置)
|
// 发货信息管理:进入 SHIPPING 时向微信录入(解冻结算前置)
|
||||||
if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') {
|
if (targetStatus === 'SHIPPING' && currentStatus !== 'SHIPPING') {
|
||||||
this.wechatOrderShipping.uploadForOrderSafe(orderId);
|
this.wechatOrderShipping.uploadForOrderSafe(orderId);
|
||||||
@@ -1930,25 +1936,9 @@ export class TradeService {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (promoCodeId) {
|
|
||||||
await tx.commonPromoCode.update({
|
|
||||||
where: { id: promoCodeId },
|
|
||||||
data: { orderCount: { increment: 1 } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return created;
|
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',
|
||||||
@@ -2343,25 +2333,9 @@ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user