3 Commits

Author SHA1 Message Date
jacy 3b669f7e38 fix(settlement): 门店 T+1 出账日改为窗口中的今天
HQ 门店账单列名改为出账日;billDate 记今日(昨日 00:00–今日 00:00 右端),启动时把旧数据从核销自然日对齐过来。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 14:12:35 +08:00
jacy da19c39965 feat(admin): v3.5.18 用户详情明文手机、任务关联版本与核销快链
HQ 后台:用户/核销记录展示完整手机号;任务列表与编辑可关联版本;账单核销单号与券号/用户可快链;门店账单日标明为出账自然日;技术支持操作按钮右对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:58:08 +08:00
jacy b226af2adc feat(admin): 订单页筛选改版并支持用户与商品模糊搜索
收起次要筛选项,去掉过滤测试;列表可按用户编号/昵称/备注/手机与商品名称/规格查询。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:10:36 +08:00
21 changed files with 586 additions and 211 deletions
+50
View File
@@ -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>
+41 -13
View File
@@ -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>
+38 -6
View File
@@ -71,11 +71,11 @@ export default function DevPlanTasksPage() {
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>(); const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
useEffect(() => { useEffect(() => {
if (!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 }]}> <>
<Select options={STATUS_OPTIONS} /> <Form.Item name="status" label="状态" rules={[{ required: true }]}>
</Form.Item> <Select options={STATUS_OPTIONS} />
</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
+131 -109
View File
@@ -4,7 +4,6 @@ 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,8 +149,9 @@ type OrderExportFilters = {
orderType?: string; orderType?: string;
cityId?: string; cityId?: string;
receiverPhone?: string; receiverPhone?: string;
userKeyword?: string;
productKeyword?: string;
fulfillmentHold?: boolean; fulfillmentHold?: boolean;
excludeTest?: boolean;
deliveryType?: string; deliveryType?: string;
promoCodeId?: string; promoCodeId?: string;
dateRange?: [Dayjs, Dayjs]; dateRange?: [Dayjs, Dayjs];
@@ -213,6 +213,8 @@ 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?: { function formatUserWithRemark(user?: {
userNo?: string; userNo?: string;
hqRemark?: string | null; hqRemark?: string | null;
@@ -254,9 +256,10 @@ 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.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');
@@ -335,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');
@@ -404,8 +414,9 @@ export default function OrdersPage() {
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 (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'));
@@ -843,14 +854,72 @@ export default function OrdersPage() {
) : null} ) : 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"
</Form.Item> placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="手机号" className="admin-orders-filter-item admin-orders-filter-item--md">
<Input allowClear placeholder="手机号" />
</Form.Item>
<Form.Item name="userKeyword" label="用户" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="编号/昵称/备注/手机" />
</Form.Item>
<Form.Item name="productKeyword" label="商品" className="admin-orders-filter-item admin-orders-filter-item--search">
<Input allowClear placeholder="名称/规格" />
</Form.Item>
<Form.Item name="status" label="状态" className="admin-orders-filter-item admin-orders-filter-item--status">
<Select
mode="multiple"
allowClear
maxTagCount="responsive"
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="orderType" label="类型" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="deliveryType" label="配送" className="admin-orders-filter-item admin-orders-filter-item--sm">
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
</Form.Item>
<Form.Item label=" " colon={false} className="admin-orders-filter-item admin-orders-filter-actions">
<Space size={8} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
type="link"
icon={filtersCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => {
setFiltersCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem(FILTERS_COLLAPSED_KEY, next ? '1' : '0');
} catch {
/* ignore */
}
return next;
});
}}
>
{filtersCollapsed ? '展开' : '收起'}
</Button>
</Space>
</Form.Item>
</div>
{!filtersCollapsed ? (
<>
<div className="admin-orders-filter-row">
<Form.Item label="下单日期" className="admin-orders-filter-item admin-orders-filter-item--date">
<Space size={8} wrap> <Space size={8} wrap>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear className="admin-orders-date-picker" />
</Form.Item>
{DATE_PRESETS.map((preset) => ( {DATE_PRESETS.map((preset) => (
<Button <Button
key={preset.key} key={preset.key}
@@ -861,107 +930,60 @@ export default function OrdersPage() {
</Button> </Button>
))} ))}
</Space> </Space>
</Space> </Form.Item>
</Form.Item> </div>
</Col>
<Col xs={24} sm={12} md={8} lg={6}> <div className="admin-orders-filter-row admin-orders-filter-row--tools">
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}> {canExportOrders ? (
<Select <Space size={12} wrap>
mode="multiple" <Radio.Group
allowClear optionType="button"
maxTagCount="responsive" buttonStyle="solid"
placeholder="全部" value={exportFormat}
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} onChange={(e) => setExportFormat(e.target.value)}
/> >
</Form.Item> <Radio.Button value="xlsx">Excel</Radio.Button>
</Col> <Radio.Button value="pdf">PDF</Radio.Button>
<Col xs={12} sm={6} md={5} lg={3}> </Radio.Group>
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}> <Button
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} /> disabled={!selectedRowKeys.length}
</Form.Item> loading={exporting}
</Col> onClick={() => void submitExport('selected')}
<Col xs={12} sm={6} md={5} lg={3}> >
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}> {selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} /> </Button>
</Form.Item> <Button loading={exporting} onClick={() => void submitExport('filter')}>
</Col>
<Col xs={12} sm={6} md={5} lg={3}> </Button>
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}> </Space>
<Select ) : <span />}
allowClear <Space size={12} wrap>
showSearch
optionFilterProp="label"
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={8} md={6} lg={6}>
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
<Input allowClear placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={12} sm={8} md={8} lg={8}>
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
<Space size={16} wrap>
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle> <Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
<Checkbox></Checkbox> <Checkbox></Checkbox>
</Form.Item> </Form.Item>
<Form.Item name="excludeTest" valuePropName="checked" noStyle> <Button
<Checkbox></Checkbox> onClick={() => {
</Form.Item> form.resetFields();
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space> </Space>
</Form.Item> </div>
</Col> </>
</Row> ) : null}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
{canExportOrders ? (
<Space size={12} wrap>
<Radio.Group
optionType="button"
buttonStyle="solid"
value={exportFormat}
onChange={(e) => setExportFormat(e.target.value)}
>
<Radio.Button value="xlsx">Excel</Radio.Button>
<Radio.Button value="pdf">PDF</Radio.Button>
</Radio.Group>
<Button
disabled={!selectedRowKeys.length}
loading={exporting}
onClick={() => void submitExport('selected')}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
<Button loading={exporting} onClick={() => void submitExport('filter')}>
</Button>
</Space>
) : <span />}
<Space size={12} wrap>
<Button type="primary" htmlType="submit"></Button>
<Button
onClick={() => {
form.resetFields();
setPage(1);
if (initialPromoCodeId || initialOrderNo || initialStatuses.length) navigate('/orders');
else void load();
}}
>
</Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</div>
</Form> </Form>
<Table <Table
+59 -11
View File
@@ -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: '操作',
+39 -7
View File
@@ -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>
) : (
'—'
);
}, },
}, },
{ {
+38 -44
View File
@@ -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,50 +561,43 @@ 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, {isSuperAdmin ? (
}} <>
> <Button
<Typography.Title level={4} style={{ margin: 0 }}> disabled={!selectedRowKeys.length}
loading={batchCreateSaving}
</Typography.Title> onClick={() => void submitBatchCreateTasks()}
{settingsButton} >
<Space>
{isSuperAdmin ? ( </Button>
<> <Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}>
<Button
disabled={!selectedRowKeys.length} </Button>
loading={batchCreateSaving} <Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}>
onClick={() => void submitBatchCreateTasks()}
> </Button>
<Button
</Button> disabled={!selectedRowKeys.length}
<Button disabled={!selectedRowKeys.length} onClick={openBatchPublish}> onClick={() => {
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' });
</Button> setBatchStatusOpen(true);
<Button disabled={!selectedRowKeys.length} loading={acting} onClick={() => void startBatchPreview()}> }}
>
</Button>
<Button </Button>
disabled={!selectedRowKeys.length} </>
onClick={() => { ) : null}
batchStatusForm.setFieldsValue({ status: 'TESTING', rejectReason: '', note: '' }); <Button type="primary" onClick={() => setCreateOpen(true)}>
setBatchStatusOpen(true);
}} </Button>
> </>
}
</Button> />
</>
) : null}
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
</div>
<Form <Form
layout="inline" layout="inline"
+2 -2
View File
@@ -611,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>
@@ -645,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
View File
@@ -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 能力
+6
View File
@@ -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[];
} }
+2
View File
@@ -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;
+1
View File
@@ -146,6 +146,7 @@ export interface StoreBillDto {
id: string; id: string;
billNo: string; billNo: string;
storeId: string; storeId: string;
/** 出账日 YYYY-MM-DDT+1 窗口「昨日 00:00–今日 00:00」中的今天) */
billDate: string; billDate: string;
redeemCount: number; redeemCount: number;
redeemAmount: number; redeemAmount: number;
@@ -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,
@@ -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'
@@ -177,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'];
} }
@@ -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,
@@ -93,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;
@@ -159,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;
@@ -1,4 +1,4 @@
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,
@@ -55,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) {
@@ -131,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,
@@ -141,6 +148,37 @@ 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<{
@@ -975,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({
@@ -1100,7 +1138,7 @@ export class SettlementService {
}); });
} }
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,
@@ -1265,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
@@ -1319,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) {
@@ -1335,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 } = {}) {
@@ -1393,7 +1438,7 @@ export class SettlementService {
); );
const header = [ const header = [
'账单号', '账单号',
'账日', '账日',
'门店', '门店',
'城市', '城市',
'核销笔数', '核销笔数',
@@ -1411,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,