merge(dev_jacy): sync to dev
This commit is contained in:
@@ -102,7 +102,7 @@ Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
|||||||
| `/admin/users` | C 端用户 |
|
| `/admin/users` | C 端用户 |
|
||||||
| `/admin/orders` | 订单 |
|
| `/admin/orders` | 订单 |
|
||||||
| `/admin/stores` | 门店 |
|
| `/admin/stores` | 门店 |
|
||||||
| `/admin/store-accounts` | 门店账号 |
|
| `/admin/store-accounts` | 门店账号(含子账号 CRUD:`/:id/staff`) |
|
||||||
| `/admin/store-media` | 门店媒体 |
|
| `/admin/store-media` | 门店媒体 |
|
||||||
| `/admin/partners` | 合伙人 |
|
| `/admin/partners` | 合伙人 |
|
||||||
| `/admin/partner-accounts` | 合伙人账号 |
|
| `/admin/partner-accounts` | 合伙人账号 |
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
|
|||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import WineryBillsPage from './pages/WineryBillsPage';
|
import WineryBillsPage from './pages/WineryBillsPage';
|
||||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||||
|
import BankAccountsPage from './pages/BankAccountsPage';
|
||||||
import TicketsPage from './pages/TicketsPage';
|
import TicketsPage from './pages/TicketsPage';
|
||||||
import SupportTicketsPage from './pages/SupportTicketsPage';
|
import SupportTicketsPage from './pages/SupportTicketsPage';
|
||||||
import InvoicesPage from './pages/InvoicesPage';
|
import InvoicesPage from './pages/InvoicesPage';
|
||||||
@@ -133,6 +134,7 @@ export default function App() {
|
|||||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||||
|
<Route path="/finance/bank-accounts" element={<BankAccountsPage />} />
|
||||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||||
<Route
|
<Route
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
||||||
import { request, type HqProfile, type Paginated } from '../lib/api';
|
import { request, requestDownload, type HqProfile, type Paginated } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
|
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
||||||
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
||||||
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
|
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
|
||||||
|
const [downloadingQr, setDownloadingQr] = useState(false);
|
||||||
|
|
||||||
const loadSummary = useCallback(async () => {
|
const loadSummary = useCallback(async () => {
|
||||||
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
||||||
@@ -78,6 +79,26 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadBareQrcode() {
|
||||||
|
if (!summary?.qrcodeUrl) {
|
||||||
|
message.warning('尚未生成关联码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDownloadingQr(true);
|
||||||
|
try {
|
||||||
|
await requestDownload(
|
||||||
|
`/admin/partners/${partnerId}/assoc/qrcode`,
|
||||||
|
{},
|
||||||
|
`partner-assoc-${partnerId}.png`,
|
||||||
|
);
|
||||||
|
message.success('已开始下载二维码');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下载二维码失败');
|
||||||
|
} finally {
|
||||||
|
setDownloadingQr(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function unbind(userId: string) {
|
async function unbind(userId: string) {
|
||||||
try {
|
try {
|
||||||
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
||||||
@@ -137,12 +158,19 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps)
|
|||||||
关联用户 {summary?.userCount ?? 0} 人
|
关联用户 {summary?.userCount ?? 0} 人
|
||||||
</Link>
|
</Link>
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<Button loading={issuing} onClick={() => void reissue()}>
|
<Space wrap>
|
||||||
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
<Button loading={issuing} onClick={() => void reissue()}>
|
||||||
</Button>
|
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
||||||
{canDownloadPosters ? (
|
</Button>
|
||||||
<Button onClick={() => setPosterDownloadOpen(true)}>下载活动图</Button>
|
{summary?.qrcodeUrl ? (
|
||||||
) : null}
|
<Button loading={downloadingQr} onClick={() => void downloadBareQrcode()}>
|
||||||
|
下载二维码
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{canDownloadPosters ? (
|
||||||
|
<Button onClick={() => setPosterDownloadOpen(true)}>下载活动图</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</Space>
|
</Space>
|
||||||
<Table
|
<Table
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||||
|
{ key: '/finance/bank-accounts', label: '银行账户' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,6 +212,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/finance/partner-bills': 'finance',
|
'/finance/partner-bills': 'finance',
|
||||||
'/finance/winery-bills': 'finance',
|
'/finance/winery-bills': 'finance',
|
||||||
'/finance/logistics-bills': 'finance',
|
'/finance/logistics-bills': 'finance',
|
||||||
|
'/finance/bank-accounts': 'finance',
|
||||||
'benefit-group': 'benefit',
|
'benefit-group': 'benefit',
|
||||||
'/benefit/coupons': 'benefit',
|
'/benefit/coupons': 'benefit',
|
||||||
'/benefit/ledgers': 'benefit',
|
'/benefit/ledgers': 'benefit',
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||||
|
{ value: 'STORE_ACCOUNT_STAFF_CREATE', label: '新增门店子账号' },
|
||||||
|
{ value: 'STORE_ACCOUNT_STAFF_UPDATE', label: '编辑门店子账号' },
|
||||||
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
|
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
|
||||||
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||||
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
FINANCE_BANK_ACCOUNT_TYPE_LABELS,
|
||||||
|
FINANCE_BANK_ACCOUNT_TYPES,
|
||||||
|
type FinanceBankAccountDto,
|
||||||
|
type FinanceBankAccountType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
||||||
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
type CityOption = { id: string; name: string };
|
||||||
|
|
||||||
|
type ExportResult = {
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
contentBase64: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OtherForm = {
|
||||||
|
name?: string;
|
||||||
|
bankAccountName: string;
|
||||||
|
bankAccountNo: string;
|
||||||
|
bankBranch?: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<FinanceBankAccountType, string> = {
|
||||||
|
STORE: 'blue',
|
||||||
|
WINERY: 'gold',
|
||||||
|
PARTNER: 'purple',
|
||||||
|
LOGISTICS: 'cyan',
|
||||||
|
OTHER: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
function otherNumericId(id: string) {
|
||||||
|
return id.startsWith('OTHER:') ? id.slice('OTHER:'.length) : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BankAccountsPage() {
|
||||||
|
const [filterForm] = Form.useForm<{
|
||||||
|
type?: FinanceBankAccountType;
|
||||||
|
cityId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
}>();
|
||||||
|
const [otherForm] = Form.useForm<OtherForm>();
|
||||||
|
const [remarkForm] = Form.useForm<{ remark?: string }>();
|
||||||
|
const [filters, setFilters] = useState({ type: '', cityId: '', keyword: '' });
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [otherOpen, setOtherOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<FinanceBankAccountDto | null>(null);
|
||||||
|
const [remarkTarget, setRemarkTarget] = useState<FinanceBankAccountDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState<'xlsx' | 'pdf' | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items ?? []))
|
||||||
|
.catch(() => setCities([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<FinanceBankAccountDto>(
|
||||||
|
'/admin/finance/bank-accounts',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.type) qs.set('type', filters.type);
|
||||||
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
|
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters.type, filters.cityId, filters.keyword],
|
||||||
|
);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
otherForm.resetFields();
|
||||||
|
setOtherOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: FinanceBankAccountDto) {
|
||||||
|
setEditing(row);
|
||||||
|
otherForm.setFieldsValue({
|
||||||
|
name: row.ownerName === row.bankAccountName ? undefined : row.ownerName,
|
||||||
|
bankAccountName: row.bankAccountName,
|
||||||
|
bankAccountNo: row.bankAccountNo,
|
||||||
|
bankBranch: row.bankBranch ?? undefined,
|
||||||
|
remark: row.remark ?? undefined,
|
||||||
|
});
|
||||||
|
setOtherOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRemark(row: FinanceBankAccountDto) {
|
||||||
|
setRemarkTarget(row);
|
||||||
|
remarkForm.setFieldsValue({ remark: row.remark ?? undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveOther() {
|
||||||
|
const values = await otherForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/finance/bank-accounts/other/${otherNumericId(editing.id)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/finance/bank-accounts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('已新增');
|
||||||
|
}
|
||||||
|
setOtherOpen(false);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemark() {
|
||||||
|
if (!remarkTarget) return;
|
||||||
|
const values = await remarkForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/finance/bank-accounts/${encodeURIComponent(remarkTarget.id)}/remark`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ remark: values.remark ?? '' }),
|
||||||
|
});
|
||||||
|
message.success('备注已保存');
|
||||||
|
setRemarkTarget(null);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOther(row: FinanceBankAccountDto) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/finance/bank-accounts/other/${otherNumericId(row.id)}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportFile(format: 'xlsx' | 'pdf') {
|
||||||
|
setExporting(format);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('format', format);
|
||||||
|
if (filters.type) qs.set('type', filters.type);
|
||||||
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
|
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||||
|
const result = await request<ExportResult>(`/admin/finance/bank-accounts/export?${qs}`);
|
||||||
|
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||||
|
message.success(`已导出 ${result.count} 条`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeOptions = useMemo(
|
||||||
|
() => FINANCE_BANK_ACCOUNT_TYPES.map((value) => ({ value, label: FINANCE_BANK_ACCOUNT_TYPE_LABELS[value] })),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<FinanceBankAccountDto> = [
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
width: 88,
|
||||||
|
render: (type: FinanceBankAccountType) => (
|
||||||
|
<Tag color={TYPE_COLORS[type]}>{FINANCE_BANK_ACCOUNT_TYPE_LABELS[type]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '归属', dataIndex: 'ownerName', width: 180 },
|
||||||
|
{ title: '城市', dataIndex: 'cityName', width: 100, render: (v?: string | null) => v || '—' },
|
||||||
|
{ title: '户名', dataIndex: 'bankAccountName', width: 140 },
|
||||||
|
{ title: '银行账号', dataIndex: 'bankAccountNo', width: 180 },
|
||||||
|
{ title: '开户行', dataIndex: 'bankBranch', width: 180, render: (v?: string | null) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '默认',
|
||||||
|
dataIndex: 'isDefault',
|
||||||
|
width: 72,
|
||||||
|
render: (v: boolean | undefined, row) => (row.type === 'STORE' ? (v ? '是' : '否') : '—'),
|
||||||
|
},
|
||||||
|
{ title: '备注', dataIndex: 'remark', width: 200, render: (v?: string | null) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
width: 160,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => openRemark(row)}>
|
||||||
|
备注
|
||||||
|
</Button>
|
||||||
|
{row.editable ? (
|
||||||
|
<>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认删除账户「${row.ownerName}」?`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void removeOther(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-bank-accounts', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="银行账户"
|
||||||
|
description="汇总门店、酒厂、合伙人、物流的有效收款账户;可登记不挂门店的其他账户。打款仍以各业务源账户为准。"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新增账户
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters({
|
||||||
|
type: v.type || '',
|
||||||
|
cityId: v.cityId || '',
|
||||||
|
keyword: v.keyword?.trim() || '',
|
||||||
|
});
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="type" label="类型">
|
||||||
|
<Select allowClear style={{ width: 140 }} options={typeOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keyword" label="查找">
|
||||||
|
<Input allowClear style={{ width: 220 }} placeholder="户名 / 账号 / 开户行 / 归属 / 备注" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
filterForm.resetFields();
|
||||||
|
setFilters({ type: '', cityId: '', keyword: '' });
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button loading={exporting === 'xlsx'} onClick={() => void exportFile('xlsx')}>
|
||||||
|
导出 Excel
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button loading={exporting === 'pdf'} onClick={() => void exportFile('pdf')}>
|
||||||
|
导出 PDF
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? '编辑账户' : '新增账户'}
|
||||||
|
open={otherOpen}
|
||||||
|
onCancel={() => setOtherOpen(false)}
|
||||||
|
onOk={() => void saveOther()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={otherForm} layout="vertical">
|
||||||
|
<Form.Item name="name" label="归属名称">
|
||||||
|
<Input placeholder="选填,如供应商或内部账户名称" maxLength={128} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankAccountName" label="户名" rules={[{ required: true, message: '请填写户名' }]}>
|
||||||
|
<Input maxLength={64} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="bankAccountNo"
|
||||||
|
label="银行账号"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写银行账号' },
|
||||||
|
{ pattern: /^\d{8,32}$/, message: '银行账号须为 8~32 位数字' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={32} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankBranch" label="开户行">
|
||||||
|
<Input maxLength={128} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={3} maxLength={256} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="编辑备注"
|
||||||
|
open={!!remarkTarget}
|
||||||
|
onCancel={() => setRemarkTarget(null)}
|
||||||
|
onOk={() => void saveRemark()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={remarkForm} layout="vertical">
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={4} maxLength={256} placeholder="仅财务目录可见,不改写来源账户" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -246,7 +246,7 @@ export default function HqPermissionsPage() {
|
|||||||
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。
|
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市、修改用户关联合伙人需在「按用户分配」或「按角色分配」中单独勾选(默认均无)。
|
||||||
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单、物流对账与银行账户。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import {
|
|||||||
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
STORE_STAFF_DEFAULT_PERMISSIONS,
|
||||||
|
STORE_STAFF_PERMISSION_LABELS,
|
||||||
|
STORE_STAFF_ROLE_LABELS,
|
||||||
|
StoreStaffRole,
|
||||||
|
type AdminStoreStaffItem,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
@@ -10,7 +17,6 @@ import { useAdminListColumns } from '../lib/useAdminListColumns';
|
|||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -27,14 +33,22 @@ type Row = {
|
|||||||
bankBranch?: string | null;
|
bankBranch?: string | null;
|
||||||
store?: StoreBrief | null;
|
store?: StoreBrief | null;
|
||||||
stores?: StoreBrief[];
|
stores?: StoreBrief[];
|
||||||
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
|
staff?: AdminStoreStaffItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreOption = { id: string; name: string };
|
type StoreOption = { id: string; name: string };
|
||||||
|
|
||||||
|
const STAFF_ROLE_OPTIONS = Object.entries(STORE_STAFF_ROLE_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
|
const STAFF_PERMISSION_OPTIONS = STORE_STAFF_DEFAULT_PERMISSIONS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: STORE_STAFF_PERMISSION_LABELS[value],
|
||||||
|
}));
|
||||||
|
|
||||||
export default function StoreAccountsPage() {
|
export default function StoreAccountsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
|
const [staffForm] = Form.useForm();
|
||||||
|
const [staffEditForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/store-accounts',
|
'/admin/store-accounts',
|
||||||
@@ -50,9 +64,14 @@ export default function StoreAccountsPage() {
|
|||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [staffOpen, setStaffOpen] = useState(false);
|
||||||
|
const [staffEditOpen, setStaffEditOpen] = useState(false);
|
||||||
|
const [editingStaffId, setEditingStaffId] = useState<string | null>(null);
|
||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const parentStoreOptions = (detail?.stores ?? []).map((s) => ({ value: s.id, label: s.name }));
|
||||||
|
|
||||||
async function loadStores() {
|
async function loadStores() {
|
||||||
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||||
setStores(res.items);
|
setStores(res.items);
|
||||||
@@ -64,6 +83,34 @@ export default function StoreAccountsPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAddStaff() {
|
||||||
|
if (!detail) return;
|
||||||
|
if (!detail.stores?.length) {
|
||||||
|
message.warning('请先为该主账号绑定门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
staffForm.resetFields();
|
||||||
|
staffForm.setFieldsValue({
|
||||||
|
staffRole: StoreStaffRole.CASHIER,
|
||||||
|
permissions: [...STORE_STAFF_DEFAULT_PERMISSIONS],
|
||||||
|
storeIds: detail.stores.map((s) => s.id),
|
||||||
|
});
|
||||||
|
setStaffOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditStaff(staff: AdminStoreStaffItem) {
|
||||||
|
setEditingStaffId(staff.id);
|
||||||
|
staffEditForm.setFieldsValue({
|
||||||
|
name: staff.name,
|
||||||
|
phone: staff.phone,
|
||||||
|
staffRole: staff.staffRole ?? StoreStaffRole.CASHIER,
|
||||||
|
status: staff.status,
|
||||||
|
permissions: staff.permissions?.length ? staff.permissions : [...STORE_STAFF_DEFAULT_PERMISSIONS],
|
||||||
|
storeIds: staff.storeIds?.length ? staff.storeIds : (staff.stores ?? []).map((s) => s.id),
|
||||||
|
});
|
||||||
|
setStaffEditOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteStaff(staffId: string) {
|
async function deleteStaff(staffId: string) {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setDeletingStaffId(staffId);
|
setDeletingStaffId(staffId);
|
||||||
@@ -151,13 +198,58 @@ export default function StoreAccountsPage() {
|
|||||||
|
|
||||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
const staffColumns: ColumnsType<AdminStoreStaffItem> = [
|
||||||
|
{ title: '姓名', dataIndex: 'name', width: 90 },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'staffRole',
|
||||||
|
width: 80,
|
||||||
|
render: (role) => STORE_STAFF_ROLE_LABELS[role as StoreStaffRole] || role || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '门店',
|
||||||
|
render: (_, staff) =>
|
||||||
|
staff.stores?.length ? staff.stores.map((s) => s.name).join('、') : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 70,
|
||||||
|
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 120,
|
||||||
|
render: (_, staff) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => openEditStaff(staff)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该子账号?"
|
||||||
|
description={`${staff.name}(${staff.phone})删除后将无法登录门店端`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
|
||||||
|
cancelText="取消"
|
||||||
|
onConfirm={() => void deleteStaff(staff.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{settingsModal}
|
{settingsModal}
|
||||||
<AdminListHeader
|
<AdminListHeader
|
||||||
title="门店账户"
|
title="门店账户"
|
||||||
settings={settingsButton}
|
settings={settingsButton}
|
||||||
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
|
description="主账号可绑定多家门店;收款信息挂在主账号;详情内可管理店员子账号"
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -212,13 +304,13 @@ export default function StoreAccountsPage() {
|
|||||||
/>
|
/>
|
||||||
<Drawer
|
<Drawer
|
||||||
title="门店主账号"
|
title="门店主账号"
|
||||||
width={520}
|
width={640}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
detail && (
|
detail && (
|
||||||
<Select
|
<Select
|
||||||
defaultValue={detail.status}
|
value={detail.status}
|
||||||
style={{ width: 100 }}
|
style={{ width: 100 }}
|
||||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
onChange={async (status) => {
|
onChange={async (status) => {
|
||||||
@@ -227,7 +319,7 @@ export default function StoreAccountsPage() {
|
|||||||
body: JSON.stringify({ status }),
|
body: JSON.stringify({ status }),
|
||||||
});
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
void reload();
|
await refreshDetail(detail.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -251,48 +343,22 @@ export default function StoreAccountsPage() {
|
|||||||
{!detail.stores?.length ? '—' : null}
|
{!detail.stores?.length ? '—' : null}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{detail.staff?.length ? (
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, marginBottom: 8 }}>
|
||||||
<>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<Typography.Title level={5} style={{ marginTop: 24 }}>子账号</Typography.Title>
|
子账号({detail.staff?.length ?? 0})· 仅主账号可添加,不可多级
|
||||||
<Table
|
</Typography.Text>
|
||||||
rowKey="id"
|
<Button size="small" type="primary" onClick={openAddStaff}>
|
||||||
size="small"
|
添加子账号
|
||||||
pagination={false}
|
</Button>
|
||||||
dataSource={detail.staff}
|
</div>
|
||||||
columns={[
|
<Table
|
||||||
{ title: '姓名', dataIndex: 'name' },
|
rowKey="id"
|
||||||
{ title: '手机', dataIndex: 'phone' },
|
size="small"
|
||||||
{
|
pagination={false}
|
||||||
title: '状态',
|
dataSource={detail.staff ?? []}
|
||||||
dataIndex: 'status',
|
columns={staffColumns}
|
||||||
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
|
locale={{ emptyText: '暂无子账号' }}
|
||||||
},
|
/>
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
width: 80,
|
|
||||||
render: (_, staff) => (
|
|
||||||
<Popconfirm
|
|
||||||
title="确认删除该子账号?"
|
|
||||||
description={`${staff.name}(${staff.phone})删除后将无法登录门店端`}
|
|
||||||
okText="删除"
|
|
||||||
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
|
|
||||||
cancelText="取消"
|
|
||||||
onConfirm={() => void deleteStaff(staff.id)}
|
|
||||||
>
|
|
||||||
<Button type="link" size="small" danger>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
|
|
||||||
暂无子账号
|
|
||||||
</Typography.Paragraph>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
@@ -321,6 +387,87 @@ export default function StoreAccountsPage() {
|
|||||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title={detail ? `添加子账号 · ${detail.name}` : '添加子账号'}
|
||||||
|
open={staffOpen}
|
||||||
|
onCancel={() => setStaffOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!detail) return;
|
||||||
|
const v = await staffForm.validateFields();
|
||||||
|
await request(`/admin/store-accounts/${detail.id}/staff`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(v),
|
||||||
|
});
|
||||||
|
message.success('子账号已创建');
|
||||||
|
setStaffOpen(false);
|
||||||
|
await refreshDetail(detail.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={staffForm} layout="vertical">
|
||||||
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="登录手机"
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||||
|
<Select options={STAFF_ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeIds" label="可管门店" rules={[{ required: true, type: 'array', min: 1, message: '请至少绑定一家门店' }]}>
|
||||||
|
<Select mode="multiple" options={parentStoreOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限">
|
||||||
|
<Checkbox.Group options={STAFF_PERMISSION_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
title="编辑子账号"
|
||||||
|
open={staffEditOpen}
|
||||||
|
onCancel={() => setStaffEditOpen(false)}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!detail || !editingStaffId) return;
|
||||||
|
const v = await staffEditForm.validateFields();
|
||||||
|
await request(`/admin/store-accounts/${detail.id}/staff/${editingStaffId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(v),
|
||||||
|
});
|
||||||
|
message.success('子账号已更新');
|
||||||
|
setStaffEditOpen(false);
|
||||||
|
await refreshDetail(detail.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form form={staffEditForm} layout="vertical">
|
||||||
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="登录手机"
|
||||||
|
rules={[
|
||||||
|
{ required: true },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||||
|
<Select options={STAFF_ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
|
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeIds" label="可管门店" rules={[{ required: true, type: 'array', min: 1, message: '请至少绑定一家门店' }]}>
|
||||||
|
<Select mode="multiple" options={parentStoreOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限">
|
||||||
|
<Checkbox.Group options={STAFF_PERMISSION_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,19 +17,25 @@ export function storeStarCount(rating?: number | string | null): number {
|
|||||||
return Math.min(5, Math.max(1, Math.round(n)));
|
return Math.min(5, Math.max(1, Math.round(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 省市区县 + 详细地址 拼接;空则回退占位文案 */
|
export type StoreAddressParts = {
|
||||||
export function fullStoreAddress(
|
province?: string | null;
|
||||||
store: {
|
cityName?: string | null;
|
||||||
province?: string | null;
|
city?: string | null;
|
||||||
cityName?: string | null;
|
district?: string | null;
|
||||||
city?: string | null;
|
address?: string | null;
|
||||||
district?: string | null;
|
};
|
||||||
address?: string | null;
|
|
||||||
},
|
function addressPart(value: unknown): string {
|
||||||
fallback = '地址待完善',
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
): string {
|
}
|
||||||
const city = store.cityName || store.city || '';
|
|
||||||
const text = `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
/** 省 + 市 + 区 + 详细地址原样拼接;详细地址已含省市区时不去重 */
|
||||||
|
export function fullStoreAddress(store: StoreAddressParts, fallback = '地址待完善'): string {
|
||||||
|
const province = addressPart(store.province);
|
||||||
|
const city = addressPart(store.cityName) || addressPart(store.city);
|
||||||
|
const district = addressPart(store.district);
|
||||||
|
const detail = addressPart(store.address);
|
||||||
|
const text = `${province}${city}${district}${detail}`;
|
||||||
return text || fallback;
|
return text || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,3 +175,12 @@ export function storeLeafCategoryIds(store: {
|
|||||||
const id = String(store.categoryId || store.category?.id || '');
|
const id = String(store.categoryId || store.category?.id || '');
|
||||||
return id ? [id] : [];
|
return id ? [id] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C 端是否渲染「核销N次」;开关关闭或次数为 0 时不展示 */
|
||||||
|
export function shouldShowStoreRedeemCount(
|
||||||
|
enabled: boolean | undefined,
|
||||||
|
count?: number | null,
|
||||||
|
): boolean {
|
||||||
|
if (enabled === false) return false;
|
||||||
|
return Number(count) > 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../component
|
|||||||
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
import BenefitIntroCard from '../../components/BenefitIntroCard';
|
||||||
import WechatShareReady from '../../components/WechatShareReady';
|
import WechatShareReady from '../../components/WechatShareReady';
|
||||||
import { request, toast, isLoggedIn } from '../../lib/api';
|
import { request, toast, isLoggedIn } from '../../lib/api';
|
||||||
|
import { fetchClientConfig } from '../../lib/pay-wechat';
|
||||||
import { toMoneyNumber } from '../../lib/money';
|
import { toMoneyNumber } from '../../lib/money';
|
||||||
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
import { maskPhone, toDialablePhone } from '../../lib/phone';
|
||||||
import { track } from '../../lib/analytics';
|
import { track } from '../../lib/analytics';
|
||||||
import {
|
import {
|
||||||
|
fullStoreAddress,
|
||||||
|
shouldShowStoreRedeemCount,
|
||||||
storeCategoryTags,
|
storeCategoryTags,
|
||||||
storeStarCount,
|
storeStarCount,
|
||||||
type StoreCategoryTreeNode,
|
type StoreCategoryTreeNode,
|
||||||
@@ -115,8 +118,7 @@ function envPhotoUrls(store: Store) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fullAddress(store: Store) {
|
function fullAddress(store: Store) {
|
||||||
const city = store.cityName || store.city || '';
|
return fullStoreAddress(store, '');
|
||||||
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickStoreId(raw?: string | null) {
|
function pickStoreId(raw?: string | null) {
|
||||||
@@ -190,6 +192,7 @@ export default function StoreDetailPage() {
|
|||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [headerSolid, setHeaderSolid] = useState(false);
|
const [headerSolid, setHeaderSolid] = useState(false);
|
||||||
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
|
const [pendingRatingId, setPendingRatingId] = useState<string | null>(null);
|
||||||
|
const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
|
||||||
const storeRef = useRef<Store | null>(null);
|
const storeRef = useRef<Store | null>(null);
|
||||||
storeRef.current = store;
|
storeRef.current = store;
|
||||||
|
|
||||||
@@ -197,6 +200,12 @@ export default function StoreDetailPage() {
|
|||||||
setHeaderSolid(scrollTop > 100);
|
setHeaderSolid(scrollTop > 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchClientConfig()
|
||||||
|
.then((cfg) => setShowStoreRedeemCount(cfg.showStoreRedeemCount !== false))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadStore = useCallback(async (id: string) => {
|
const loadStore = useCallback(async (id: string) => {
|
||||||
if (!id) {
|
if (!id) {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -440,15 +449,14 @@ export default function StoreDetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
{Number(store.redeemCount) > 0 ? (
|
{shouldShowStoreRedeemCount(showStoreRedeemCount, store.redeemCount) ? (
|
||||||
<Text className="store-detail-redeem">核销{store.redeemCount}次</Text>
|
<Text className="store-detail-redeem">核销{store.redeemCount}次</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="store-detail-row">
|
<View className="store-detail-row">
|
||||||
<Text className="store-detail-meta store-detail-meta--flex">
|
<Text className="store-detail-meta store-detail-meta--flex">
|
||||||
{store.district ? `${store.district} · ` : ''}
|
{fullStoreAddress(store)}
|
||||||
{store.address || '地址待完善'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="store-detail-action" onClick={openMap}>
|
<Text className="store-detail-action" onClick={openMap}>
|
||||||
导航
|
导航
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
import { FALLBACK_CITY_CODE } from '../../lib/product-images';
|
||||||
import { formatDistanceMeters } from '../../lib/geo';
|
import { formatDistanceMeters } from '../../lib/geo';
|
||||||
import { getToken, request, toast } from '../../lib/api';
|
import { getToken, request, toast } from '../../lib/api';
|
||||||
|
import { fetchClientConfig } from '../../lib/pay-wechat';
|
||||||
import {
|
import {
|
||||||
getStoresListCache,
|
getStoresListCache,
|
||||||
isStoresSessionBootstrapped,
|
isStoresSessionBootstrapped,
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
toWeappShareTimeline,
|
toWeappShareTimeline,
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||||
import { fullStoreAddress, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
|
import { fullStoreAddress, shouldShowStoreRedeemCount, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
|
||||||
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
import openBadgeImg from '../../assets/icons/store-open-badge.png';
|
||||||
|
|
||||||
type Store = {
|
type Store = {
|
||||||
@@ -123,13 +124,21 @@ export default function StoresPage() {
|
|||||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||||
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
const [sort, setSort] = useState<StoreSortKey>(() => cached?.sort ?? 'nearby');
|
||||||
const [sortOpen, setSortOpen] = useState(false);
|
const [sortOpen, setSortOpen] = useState(false);
|
||||||
|
const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true);
|
||||||
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
const fetchCityKeyRef = useRef<string | null>(cached?.cityKey ?? null);
|
||||||
const fetchSeqRef = useRef(0);
|
const fetchSeqRef = useRef(0);
|
||||||
const regionRef = useRef(region);
|
const regionRef = useRef(region);
|
||||||
regionRef.current = region;
|
regionRef.current = region;
|
||||||
const regionLabel = formatRegionLabel(region);
|
const regionLabel = formatRegionLabel(region);
|
||||||
const categoryLabel = formatCategoryLabel(category);
|
const categoryLabel = formatCategoryLabel(category);
|
||||||
const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先';
|
const sortOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
showStoreRedeemCount
|
||||||
|
? STORE_SORT_OPTIONS
|
||||||
|
: STORE_SORT_OPTIONS.filter((o) => o.key !== 'redeem'),
|
||||||
|
[showStoreRedeemCount],
|
||||||
|
);
|
||||||
|
const sortLabel = sortOptions.find((o) => o.key === sort)?.label ?? '附近优先';
|
||||||
const showBootLoading = loading && stores.length === 0;
|
const showBootLoading = loading && stores.length === 0;
|
||||||
|
|
||||||
const childIdsByParent = useMemo(() => {
|
const childIdsByParent = useMemo(() => {
|
||||||
@@ -143,6 +152,22 @@ export default function StoresPage() {
|
|||||||
return map;
|
return map;
|
||||||
}, [categoryTree]);
|
}, [categoryTree]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchClientConfig()
|
||||||
|
.then((cfg) => {
|
||||||
|
const enabled = cfg.showStoreRedeemCount !== false;
|
||||||
|
setShowStoreRedeemCount(enabled);
|
||||||
|
if (!enabled) {
|
||||||
|
setSort((prev) => {
|
||||||
|
if (prev !== 'redeem') return prev;
|
||||||
|
patchStoresFilterCache({ sort: 'nearby' });
|
||||||
|
return 'nearby';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function fetchStores(
|
async function fetchStores(
|
||||||
nextCode: string,
|
nextCode: string,
|
||||||
coords: UserCoords | null,
|
coords: UserCoords | null,
|
||||||
@@ -329,7 +354,7 @@ export default function StoresPage() {
|
|||||||
if (sort === 'rating') {
|
if (sort === 'rating') {
|
||||||
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
const diff = storeStarCount(b.rating) - storeStarCount(a.rating);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
} else if (sort === 'redeem') {
|
} else if (sort === 'redeem' && showStoreRedeemCount) {
|
||||||
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0);
|
||||||
if (diff !== 0) return diff;
|
if (diff !== 0) return diff;
|
||||||
}
|
}
|
||||||
@@ -338,7 +363,7 @@ export default function StoresPage() {
|
|||||||
return da - db;
|
return da - db;
|
||||||
});
|
});
|
||||||
return next;
|
return next;
|
||||||
}, [stores, region, category, keyword, sort, childIdsByParent]);
|
}, [stores, region, category, keyword, sort, childIdsByParent, showStoreRedeemCount]);
|
||||||
|
|
||||||
function applySearch() {
|
function applySearch() {
|
||||||
const next = keywordInput.trim();
|
const next = keywordInput.trim();
|
||||||
@@ -497,7 +522,7 @@ export default function StoresPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
{Number(s.redeemCount) > 0 ? (
|
{shouldShowStoreRedeemCount(showStoreRedeemCount, s.redeemCount) ? (
|
||||||
<Text className="store-card-redeem">核销{s.redeemCount}次</Text>
|
<Text className="store-card-redeem">核销{s.redeemCount}次</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -551,7 +576,7 @@ export default function StoresPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="region-picker-list">
|
<View className="region-picker-list">
|
||||||
{STORE_SORT_OPTIONS.map((opt) => (
|
{sortOptions.map((opt) => (
|
||||||
<View
|
<View
|
||||||
key={opt.key}
|
key={opt.key}
|
||||||
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
className={`region-picker-option${sort === opt.key ? ' selected' : ''}`}
|
||||||
|
|||||||
@@ -177,6 +177,7 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-detail-row {
|
.store-detail-row {
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ HQ 页也会展示 Base URL 与 Header 名(不含密钥)。
|
|||||||
| partnersTotal / partnersIncrement | Integer | 合伙人 |
|
| partnersTotal / partnersIncrement | Integer | 合伙人 |
|
||||||
| storesTotal / storesIncrement | Integer | 门店存量/增量 |
|
| storesTotal / storesIncrement | Integer | 门店存量/增量 |
|
||||||
| **newStores** | Integer | **新增门店数**(与 storesIncrement 相同) |
|
| **newStores** | Integer | **新增门店数**(与 storesIncrement 相同) |
|
||||||
| ordersTotal / ordersIncrement | Integer | 订单笔数 |
|
| ordersTotal / ordersIncrement | Integer | 订单笔数(排除待支付 / 已取消 / 退款中) |
|
||||||
| orderAmountTotal / orderAmountIncrement | Number | 订单金额(已付 payAmount) |
|
| orderAmountTotal / orderAmountIncrement | Number | 订单金额(已付 payAmount,排除待支付 / 已取消 / 退款中) |
|
||||||
| redeemsTotal / redeemsIncrement | Integer | 核销笔数 |
|
| redeemsTotal / redeemsIncrement | Integer | 核销笔数 |
|
||||||
| redeemAmountTotal / redeemAmountIncrement | Number | 核销金额 |
|
| redeemAmountTotal / redeemAmountIncrement | Number | 核销金额 |
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@
|
|||||||
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
||||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
- HQ 主列表:最左序号;「列设置」控制显示列与顺序;可拖表头分割线改列宽;主展示列下划线点击进编辑/详情;偏好挂当前 HQ 账号。
|
||||||
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
||||||
|
- C 端小程序门店列表/详情的「核销N次」由 HQ「系统设置 → 功能开关 → C 端展示门店核销次数」(`SHOW_STORE_REDEEM_COUNT`)控制,默认开;关闭后接口不再返回次数。
|
||||||
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
||||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
||||||
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
||||||
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
||||||
| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持/企微报告 | 完整 SOP 审核 UI、发票、热力图 |
|
| **admin-web** | 商品/开城/门店/门店账号含子账号 CRUD/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持/企微报告 | 完整 SOP 审核 UI、发票、热力图 |
|
||||||
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
||||||
|
|
||||||
## 3. 场景 SC-01~09
|
## 3. 场景 SC-01~09
|
||||||
@@ -73,6 +73,7 @@
|
|||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| 2026-09-08 | HQ「门店账户」主账号详情可 CRUD 店员子账号(`/admin/store-accounts/:id/staff`) |
|
||||||
| 2026-09-06 | v3.5.17:企微 API 插件迁入 HQ「企微机器人 → API 插件」;多实例 Key + 工具权限;不再依赖 `WECOM_PLUGIN_*` env |
|
| 2026-09-06 | v3.5.17:企微 API 插件迁入 HQ「企微机器人 → API 插件」;多实例 Key + 工具权限;不再依赖 `WECOM_PLUGIN_*` env |
|
||||||
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 |
|
| 2026-09-03 | v3.5.16:企微 API 插件只读数据面 `GET /api/v1/wecom/plugin/*`(X-Api-Key);与长连接 Bot 独立 |
|
||||||
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
|
| 2026-08-30 | HQ 门店账单确认打款支持上传凭证照片(`payment_proof_urls`) |
|
||||||
|
|||||||
@@ -28,8 +28,8 @@
|
|||||||
| 用户数量 | `status=1` 且未合并,`createdAt` < 期末 | 区间内创建 |
|
| 用户数量 | `status=1` 且未合并,`createdAt` < 期末 | 区间内创建 |
|
||||||
| 合伙人数量 | 主账号 `is_primary=1` | 区间内创建 |
|
| 合伙人数量 | 主账号 `is_primary=1` | 区间内创建 |
|
||||||
| 门店数量 | 全部门店 | 区间内创建 |
|
| 门店数量 | 全部门店 | 区间内创建 |
|
||||||
| 订单数量 | 按下单 `createdAt` | 区间内下单 |
|
| 订单数量 | 按下单 `createdAt`,排除待支付 / 已取消 / 退款中 | 区间内下单 |
|
||||||
| 订单金额 | 已付 `payAmount`(`paidAt`) | 区间内支付 |
|
| 订单金额 | 已付 `payAmount`(`paidAt`),排除待支付 / 已取消 / 退款中 | 区间内支付 |
|
||||||
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
|
| 核销单数量 / 金额 | `RedeemRecord` | 区间内核销 |
|
||||||
|
|
||||||
- **日报**:发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
|
- **日报**:发送日前一自然日(截账至发送日 0 点 / 前一天 24 点);默认 20:00 发送;新增文案「当日新增」。
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ WECOM_PLUGIN_API_KEY=<随机长密钥>
|
|||||||
| GET | `/promo-codes/:code/stats` | 推广码统计 |
|
| GET | `/promo-codes/:code/stats` | 推广码统计 |
|
||||||
| GET | `/metrics?kind=` | `today` \| `daily` \| `weekly` \| `monthly` |
|
| GET | `/metrics?kind=` | `today` \| `daily` \| `weekly` \| `monthly` |
|
||||||
|
|
||||||
经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。`today` 期末为当前时刻。
|
经营指标口径与 v3.5.15 报告一致:用户=有效未合并;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。`today` 期末为当前时刻。
|
||||||
|
|
||||||
审计:`log_wecom_bot.botKey=plugin`。
|
审计:`log_wecom_bot.botKey=plugin`。
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -46,17 +46,19 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**HQ 概览(v4.0.14 / v4.0.15)**:`GET /admin/dashboard/analytics` 支持日/周/月/季/年分桶;默认窗口为上一档起点~今天。全局筛城市+时间;日期快捷上周/上月/上季度;总量/增量单选分开展示。改粒度不改日期。查询右侧可下载当前折线图 PDF(不含 KPI/待办)。v4.0.15 起为多张全宽折线图:总量=桶末日存量,增量=桶内新增;用户线=推广码/关联合伙人/活动,门店线=关联合伙人,订单线=用户/关联合伙人/商品,核销线=门店/关联合伙人,合伙人单线。订单与核销同时出笔数和金额。关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前活动图。「查看」只带全局城市与日期。无权限模块后端不算不返回。时间按北京日历。
|
**HQ 概览(v4.0.14 / v4.0.15)**:`GET /admin/dashboard/analytics` 支持日/周/月/季/年分桶;默认窗口为上一档起点~今天。全局筛城市+时间;日期快捷上周/上月/上季度;总量/增量单选分开展示。改粒度不改日期。查询右侧可下载当前折线图 PDF(不含 KPI/待办)。v4.0.15 起为多张全宽折线图:总量=桶末日存量,增量=桶内新增;用户线=推广码/关联合伙人/活动,门店线=关联合伙人,订单线=用户/关联合伙人/商品,核销线=门店/关联合伙人,合伙人单线。订单与核销同时出笔数和金额。关联合伙人=`assoc_partner_account_id`;活动=关联合伙人当前活动图。「查看」只带全局城市与日期。无权限模块后端不算不返回。时间按北京日历。
|
||||||
|
|
||||||
**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
|
**HQ 企微报告(v3.5.15)**:企微机器人下「报告」与「消息推送」分开。日报/周报/月报各配 Webhook 与发送时刻;走群机器人 markdown。账期截在发送日北京 0 点(前一天 24 点),不含发送当天:日报=昨日存量+当日新增;周报/月报=上一自然周/月期末存量+本期新增。用户=有效未合并;合伙人=主账号;订单笔数与金额均排除待支付 / 已取消 / 退款中;订单金额=已付 `payAmount`(`paidAt`);核销=`RedeemRecord`。
|
||||||
|
|
||||||
**企微 API 插件(v3.5.17)**:实例在 HQ「企微机器人 → API 插件」维护;共用 `GET /api/v1/wecom/plugin/*`,Header `X-Api-Key` 区分实例并按权限放行。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。
|
**企微 API 插件(v3.5.17)**:实例在 HQ「企微机器人 → API 插件」维护;共用 `GET /api/v1/wecom/plugin/*`,Header `X-Api-Key` 区分实例并按权限放行。经营指标含新增门店(`newStores`);门店搜索含经营数据;支持门店/信息/套餐审核对照只读查询;支持按合伙人查关联用户/门店/订单(`from`/`to` 时间筛选)。
|
||||||
|
|
||||||
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
**HQ 列表(v3.5.9)**:主表不省略号、可横滑;最左序号;列设置(显隐/顺序)与列宽(拖表头)存 `hq_account.list_column_prefs`。主展示列下划线,点击进编辑或详情。门店列表「累计核销好客权益」= 该店 `RedeemRecord.amount` 合计。用户列表昵称只读(点击进详情);双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏。
|
||||||
|
|
||||||
|
**HQ 门店账号子账号**:`GET /admin/store-accounts/:id` 详情含 `staff`;`POST /admin/store-accounts/:id/staff` · `PUT /admin/store-accounts/:id/staff/:staffId` · `DELETE /admin/store-accounts/:id/staff/:staffId`。仅主账号可挂子账号(不可多级);`storeIds` 必须是主账号已绑定门店;创建默认 `ACTIVE`、角色默认收银员、权限默认核销+记录;改登录手机解绑微信。门店端自管仍走 `GET/POST/PUT/DELETE /shop/staff`。
|
||||||
|
|
||||||
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
|
**C 端(v3.5.10)**:门店详情无顶栏分享按钮。同城送提示取开城仓库绑定承运商的 `delivery_hint_html`(`GET /catalog/local-deliveries`,按收货市是否开城);空则回退「同城配送,预计24小时内送到」。在线客服优先 `wx.openCustomerServiceChat`(`CUSTOMER_SERVICE_WECOM_URL` + `WECOM_CORP_ID`);未配 CorpID 回退小程序原生客服。
|
||||||
|
|
||||||
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
**起购(v3.5.11)**:现场提货 / 同城 / 跨城阈值在城市 `pickup_min_qty` / `local_min_qty` / `cross_min_qty`(瓶当量)。承运商「起送瓶数」只用于物流计价,不拦下单。
|
||||||
|
|
||||||
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。
|
**订单大屏(v3.5.12)**:循环 BGM;右上角「播放 BGM」开关(需点击才出声);成交撒花时压低背景音。HQ 日志 / 订单状态流转 / 用户行为时间线展示中文(存储码不变)。删除门店分类后列表不再自动补种默认树(「同步默认分类」才补)。HQ 侧栏:概览→用户→商品→订单→开城→门店→财务→好客权益→配送单→工单→发票,其余运营/工具项随后,**系统设置最后**。C 端门店「核销N次」由系统设置功能开关 `SHOW_STORE_REDEEM_COUNT` 控制(默认开)。
|
||||||
|
|
||||||
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
**用户日志端(v3.5.14)**:`order_submit` / `pay_success` 的 `clientApp` 取 JWT(小程序 `USER_MINI`);微信支付回调沿用该订单已有埋点,缺省小程序。禁止再写死 `USER_H5`。
|
||||||
|
|
||||||
@@ -64,7 +66,7 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**活动图(v4.0.2 / v4.0.7)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`(**子账号强制为空**)。码栏百分比相对图宽。子账号可进用户管理、下载纯关联码,无活动图入口。v4.0.7:HQ `GET /admin/activity-posters/:id/image?partnerId=` 单张合成 PNG;`POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }` 流式 zip(仅勾选,测试号/无码 skip)。城市合伙人快链 `/activity-posters?partnerId=`。不预生成缓存图、不批量调微信补码。
|
**活动图(v4.0.2 / v4.0.7)**:规则见 v4-PRD §6。表 `activity_poster`;HQ `GET/POST/PUT/DELETE /admin/activity-posters`(权限 `activity_posters`);合伙人 `GET /partner/activity-posters` · `GET/PUT /partner/activity-posters/selection`(写入 `partner_account.activity_poster_id`)· `GET /partner/activity-posters/:id/image`(合成本人关联码)。`GET /partner/assoc` 返回 `activityPosterId`(**子账号强制为空**)。码栏百分比相对图宽。子账号可进用户管理、下载纯关联码,无活动图入口。v4.0.7:HQ `GET /admin/activity-posters/:id/image?partnerId=` 单张合成 PNG;`POST /admin/activity-posters/:id/partner-pack` `{ partnerIds }` 流式 zip(仅勾选,测试号/无码 skip)。城市合伙人快链 `/activity-posters?partnerId=`。不预生成缓存图、不批量调微信补码。
|
||||||
|
|
||||||
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。
|
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。
|
||||||
|
|
||||||
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
||||||
|
|
||||||
|
|||||||
+23
-6
@@ -1,7 +1,7 @@
|
|||||||
# 杜康好客 · V4 PRD
|
# 杜康好客 · V4 PRD
|
||||||
|
|
||||||
> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 门店多收款账户与子账号继承码
|
> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 门店多收款账户、子账号继承码、财务银行账户总目录
|
||||||
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览)。
|
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
|
||||||
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
||||||
|
|
||||||
## 0. 版本
|
## 0. 版本
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
| 4.0.9 | 09-02 | 子账号默认启用;关联码已扫码计数;零元账单不同步;主账号自填银行账号;周账周一 08:00 出账;预付款预估;子账号用户管理(无活动图) | [`v4.0.9`](./杜康好客-v4.0.9-开发文档.md) |
|
| 4.0.9 | 09-02 | 子账号默认启用;关联码已扫码计数;零元账单不同步;主账号自填银行账号;周账周一 08:00 出账;预付款预估;子账号用户管理(无活动图) | [`v4.0.9`](./杜康好客-v4.0.9-开发文档.md) |
|
||||||
| 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) |
|
| 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) |
|
||||||
| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
|
| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
|
||||||
| 4.0.18 | 09-07 | C 端门店列表省市区县地址;门店多收款账户(默认账户打款,切换无需重启);子账号独立继承二维码 + 子账号维度统计 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
|
| 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);门店多收款账户(默认账户打款,切换无需重启);子账号独立继承二维码 + 子账号维度统计;HQ 财务银行账户总目录 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
|
||||||
|
|
||||||
## 1. 锚点(沿用 V3,佣金归属改写)
|
## 1. 锚点(沿用 V3,佣金归属改写)
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
- 每个**主合伙人**自动一张微信小程序码(`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。
|
- 每个**主合伙人**自动一张微信小程序码(`getwxacodeunlimit`,scene=`pa_{partnerId}`)。不复用推广码。
|
||||||
- C 端登录后**首次扫码锁定**;已绑定再扫任意码提示「已关联」,不更新。
|
- C 端登录后**首次扫码锁定**;已绑定再扫任意码提示「已关联」,不更新。
|
||||||
- 用户不可自换绑;HQ 持权限 `users_partner_assoc`(修改用户关联合伙人)可解绑或改绑,解绑后可再绑。已支付订单佣金快照不回刷。
|
- 用户不可自换绑;HQ 持权限 `users_partner_assoc`(修改用户关联合伙人)可解绑或改绑,解绑后可再绑。已支付订单佣金快照不回刷。
|
||||||
- 合伙人 H5 可展示并**下载** PNG;微信内下载失败则预览 + 长按保存。
|
- 合伙人 H5 可展示并**下载** PNG;微信内下载失败则预览 + 长按保存。HQ 合伙人账号/开城合伙人详情同样可下载**裸二维码**(「下载二维码」),与「下载活动图」合成海报分开。
|
||||||
- 主合伙人备注写独立表 `partner_user_note`(`partner_account_id` + `user_id` 唯一),与 HQ `user_user.hq_remark` 隔离;换绑后不跟随、不泄漏给下一个合伙人。合伙人 H5 **不返回** `hqRemark`。
|
- 主合伙人备注写独立表 `partner_user_note`(`partner_account_id` + `user_id` 唯一),与 HQ `user_user.hq_remark` 隔离;换绑后不跟随、不泄漏给下一个合伙人。合伙人 H5 **不返回** `hqRemark`。
|
||||||
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
||||||
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
||||||
@@ -90,7 +90,7 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
|||||||
- 微信内下载失败则预览 + 长按保存(与关联码下载一致)。
|
- 微信内下载失败则预览 + 长按保存(与关联码下载一致)。
|
||||||
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
- 合伙人只看 `ACTIVE`;下架后列表不再出现。无关联码则不可下载并明确报错。
|
||||||
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
- HQ 持权限 `activity_posters`(默认超管 + 运营)可增删改、上下架。
|
||||||
- HQ 城市合伙人页提供活动图快链(列表列 / 详情 / 顶栏,进入 `/activity-posters?partnerId=`)。可指定一张已上架活动图:**单个下载**合成 PNG,或 **勾选主合伙人导出 zip**(仅已勾选,不做隐式全量)。合入码为已有 OSS 关联码;无码则单张报错、zip 记入跳过清单。不在本路径批量调微信补码,不预生成每人缓存图。
|
- HQ 城市合伙人页提供活动图快链(列表列 / 详情 / 顶栏,进入 `/activity-posters?partnerId=`)。可指定一张已上架活动图:**单个下载**合成 PNG,或 **勾选主合伙人导出 zip**(仅已勾选,不做隐式全量)。合入码为已有 OSS 关联码;无码则单张报错、zip 记入跳过清单。不在本路径批量调微信补码,不预生成每人缓存图。合伙人详情关联码区另有「下载二维码」,直接下载裸关联码,不贴活动图。
|
||||||
|
|
||||||
## 7. 酒厂对账(v4.0.6)
|
## 7. 酒厂对账(v4.0.6)
|
||||||
|
|
||||||
@@ -106,6 +106,23 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
|||||||
- **切换/新增/删除账户无需重启服务器**:银行字段均实时查库,无进程内缓存。
|
- **切换/新增/删除账户无需重启服务器**:银行字段均实时查库,无进程内缓存。
|
||||||
- 权限:门店主账号维护本店账户(子账号只读);总部可维护任意门店账户。
|
- 权限:门店主账号维护本店账户(子账号只读);总部可维护任意门店账户。
|
||||||
|
|
||||||
## 9. 不做
|
## 9. 财务银行账户总目录(v4.0.18)
|
||||||
|
|
||||||
|
HQ「财务 → 银行账户」聚合**有效**收款账户,供财务查阅、筛选、导出;**不替代**各业务源的维护入口,也不作为打款主数据。
|
||||||
|
|
||||||
|
| 类型 | 来源 | 有效 | 本页可写 |
|
||||||
|
|------|------|------|----------|
|
||||||
|
| 门店 | `store_bank_account` | ACTIVE 且户名+账号非空;含非默认 | 仅备注 |
|
||||||
|
| 酒厂 | `WINERY_BANK_*` | 户名+账号已填 | 仅备注 |
|
||||||
|
| 合伙人 | 主账号 `bank_account_*` | ACTIVE 且户名+账号非空 | 仅备注 |
|
||||||
|
| 物流 | 承运商 `common_fulfillment_provider` | ACTIVE 且户名+账号非空 | 仅备注 |
|
||||||
|
| 其他 | `finance_bank_account` | ACTIVE | 增删改(不挂门店,不进入打款) |
|
||||||
|
|
||||||
|
- 列表字段:类型、归属、城市、户名、银行账号、开户行、备注;门店显示是否默认。
|
||||||
|
- 筛选:类型、城市、关键字(户名/账号/开户行/归属/备注)。可导出 Excel 与 PDF。
|
||||||
|
- 「其他」账户仅登记备查,不进入门店提现、账单打款、酒厂/物流对账。
|
||||||
|
- 权限:`finance`。
|
||||||
|
|
||||||
|
## 10. 不做
|
||||||
|
|
||||||
改推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
改推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
| 维度 | 结论 |
|
| 维度 | 结论 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 版本线 | **v4.0.18** 门店多收款账户 + 子账号继承二维码(含 v4.0.15 HQ 概览折线图) |
|
| 版本线 | **v4.0.18** 门店多收款账户 + 子账号继承二维码 + HQ 财务银行账户总目录(含 v4.0.15 HQ 概览折线图) |
|
||||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
|
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
|
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.18 | [`门店多收款账户 + 子账号继承二维码`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
| 4.0.18 | [`门店多收款账户 + 子账号继承二维码 + 财务银行账户总目录`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -39,3 +39,6 @@
|
|||||||
| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
|
| 2026-09-02 | v4.0.14:HQ 概览日/周/月/季/年、环比;全局城市/时间 + 五板块筛;订单/核销笔数与金额 |
|
||||||
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期;活动图上传超限自动压缩并提示尺寸 |
|
| 2026-09-02 | v4.0.15:HQ 概览改为全宽折线图;去掉板块筛;查看快链只带全局城市与日期;活动图上传超限自动压缩并提示尺寸 |
|
||||||
| 2026-09-07 | v4.0.18:C 端门店列表省市区县地址;门店多收款账户(默认账户打款、切换无需重启);子账号独立继承码 + 子账号维度统计 |
|
| 2026-09-07 | v4.0.18:C 端门店列表省市区县地址;门店多收款账户(默认账户打款、切换无需重启);子账号独立继承码 + 子账号维度统计 |
|
||||||
|
| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
|
||||||
|
| 2026-09-08 | v4.0.18 追加:HQ 财务银行账户总目录(聚合门店/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
||||||
|
| 2026-09-08 | HQ 合伙人详情关联码可下载裸二维码(与「下载活动图」分开);C 端门店核销次数由系统设置开关控制 |
|
||||||
|
|||||||
+67
-13
@@ -1,7 +1,9 @@
|
|||||||
# 杜康好客 · v4.0.18 开发文档
|
# 杜康好客 · v4.0.18 开发文档
|
||||||
|
|
||||||
> **2026-09-07** · mini-user / store / settlement / iam / h5-shop / admin-web / h5-partner / shared-types
|
> **2026-09-07** · mini-user / store / settlement / iam / h5-shop / admin-web / h5-partner / shared-types / domain
|
||||||
> **主题**:C 端门店列表省市区县地址;门店多收款账户;子账号独立继承二维码
|
> **主题**:C 端门店列表省市区县地址;门店多收款账户;子账号独立继承二维码;HQ 财务银行账户总目录
|
||||||
|
> **2026-09-08 修订**:门店列表地址按「省+市+区+详细地址」原样拼接,详细地址已含省市区时**不去重**
|
||||||
|
> **2026-09-08 追加**:HQ 财务「银行账户」总目录(聚合门店/酒厂/合伙人/物流 + 不挂门店的「其他」账户)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -9,11 +11,12 @@
|
|||||||
|
|
||||||
| # | 任务 | 类型 | 交付 |
|
| # | 任务 | 类型 | 交付 |
|
||||||
|---|------|------|------|
|
|---|------|------|------|
|
||||||
| 1 | C 端门店列表地址 | 需求 | 「省市区县 + 详细地址」拼接展示,搜索同步匹配完整地址 |
|
| 1 | C 端门店列表地址 | 需求 | 「省 + 市 + 区 + 详细地址」原样拼接展示(不去重);搜索匹配完整地址 |
|
||||||
| 2 | 门店多银行账号 | 需求 | 门店级收款账户列表;设「默认」账户用于打款;**切换无需重启** |
|
| 2 | 门店多银行账号 | 需求 | 门店级收款账户列表;设「默认」账户用于打款;**切换无需重启** |
|
||||||
| 3 | 子账号二维码 | 需求 | 子账号独立继承码 `sa_{subId}`;新增一级子账号维度统计 |
|
| 3 | 子账号二维码 | 需求 | 子账号独立继承码 `sa_{subId}`;新增一级子账号维度统计 |
|
||||||
|
| 4 | HQ 财务银行账户总目录 | 需求 | 财务菜单下列出有效账户;可新增不挂门店的「其他」账户;类型/备注/筛选/Excel+PDF 导出 |
|
||||||
|
|
||||||
**不做**:银行账号历史打款回刷;子账号佣金独立归属(佣金仍归主账号);按承运商维度的收款账户。
|
**不做**:银行账号历史打款回刷;子账号佣金独立归属(佣金仍归主账号);按承运商维度的收款账户;「其他」账户接入打款;本页改写门店/合伙人/酒厂/物流源字段。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -21,7 +24,23 @@
|
|||||||
|
|
||||||
### 2.1 门店列表地址
|
### 2.1 门店列表地址
|
||||||
|
|
||||||
接口已返回 `province` / `cityName` / `district` / `address`,纯前端拼接。复用助手 `fullStoreAddress(store)`(`province + (cityName ?? city) + district + address` trim,空回退「地址待完善」),与门店详情页逻辑一致。
|
C 端门店列表卡片「地址」一栏(门店详情页同规则)= **下拉省 + 下拉市 + 下拉区 + 详细地址 input 原文**,四段按字段原样拼接,中间不加分隔符。
|
||||||
|
|
||||||
|
| 字段 | 来源 | 存库 |
|
||||||
|
|------|------|------|
|
||||||
|
| 省 | 录店/改店省市区下拉 | `store.province` |
|
||||||
|
| 市 | 同上 | `store.cityName` |
|
||||||
|
| 区 | 同上 | `store.district` |
|
||||||
|
| 详细地址 | 用户自由输入(地图选点若回填也写入此字段) | `store.address` |
|
||||||
|
|
||||||
|
**禁止去重**:不得因详细地址已包含省/市/区而省略前缀。助手 `formatStoreDisplayAddress`(`packages/domain`)/ `fullStoreAddress`(mini-user)只做 trim 后拼接;空则回退「地址待完善」。
|
||||||
|
|
||||||
|
| 下拉省市区 | 详细地址 | 列表展示 |
|
||||||
|
|------------|----------|----------|
|
||||||
|
| 河南省 / 郑州市 / 金水区 | 金水东路333号 | `河南省郑州市金水区金水东路333号` |
|
||||||
|
| 河南省 / 郑州市 / 金水区 | 河南省郑州市金水区金水东路333号 | `河南省郑州市金水区河南省郑州市金水区金水东路333号` |
|
||||||
|
|
||||||
|
搜索关键字同时匹配 `address` / `district` / 完整拼接结果。规则实现:`formatStoreDisplayAddress`(domain,后端地理编码同用)与 mini-user `fullStoreAddress`(列表/详情展示)。
|
||||||
|
|
||||||
### 2.2 门店多银行账号
|
### 2.2 门店多银行账号
|
||||||
|
|
||||||
@@ -49,6 +68,26 @@ flowchart LR
|
|||||||
- `getSummary`/`getStats`/`listUsers`/`listAssocOrders`:子账号调用时返回**自己维度**数据(`assocSubAccountId=子账号`);`activityPosterId` 恒为空;主账号保持聚合行为不变。
|
- `getSummary`/`getStats`/`listUsers`/`listAssocOrders`:子账号调用时返回**自己维度**数据(`assocSubAccountId=子账号`);`activityPosterId` 恒为空;主账号保持聚合行为不变。
|
||||||
- 佣金仍归主账号,不因扫码来源为子账号而改变归属。
|
- 佣金仍归主账号,不因扫码来源为子账号而改变归属。
|
||||||
|
|
||||||
|
### 2.4 HQ 财务银行账户总目录
|
||||||
|
|
||||||
|
财务查阅与手工登记目录,**不改打款主数据**。打款仍读各业务源表(门店默认账户、合伙人主账号字段、承运商字段、酒厂 `WINERY_BANK_*`)。
|
||||||
|
|
||||||
|
**有效账户**(同时满足才入列):户名、银行账号均非空;来源状态为有效。
|
||||||
|
|
||||||
|
| 类型 | 数据源 | 有效条件 | 本页可写 |
|
||||||
|
|------|--------|----------|----------|
|
||||||
|
| 门店 | `store_bank_account` | `status=ACTIVE`;所有门店的全部有效账户(含非默认) | 仅备注 |
|
||||||
|
| 酒厂 | `system_config` `WINERY_BANK_*` | 户名+账号已填 | 仅备注 |
|
||||||
|
| 合伙人 | `partner_account` | `status=ACTIVE`、主账号、户名+账号已填 | 仅备注 |
|
||||||
|
| 物流 | `common_fulfillment_provider` | `status=ACTIVE`、户名+账号已填(仓无银行字段) | 仅备注 |
|
||||||
|
| 其他 | `finance_bank_account` | `status=ACTIVE` | 增删改 |
|
||||||
|
|
||||||
|
- 列表:类型、归属、城市、户名、银行账号、开户行、备注;门店另显示是否默认。
|
||||||
|
- **新增不关联门店** = 类型「其他」,不进入提现/账单打款/酒厂/物流对账。
|
||||||
|
- 来源账户户名/账号/开户行仍在原处改;本页只读这些字段。备注:其他写本表;来源写 overlay 表 `finance_bank_account_note`。
|
||||||
|
- 筛选:类型、城市、关键字(户名/账号/开户行/归属名/备注)。导出按当前筛选全量 Excel / PDF。
|
||||||
|
- 权限:HQ `finance`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. API
|
## 3. API
|
||||||
@@ -75,32 +114,47 @@ flowchart LR
|
|||||||
- `GET /partner/assoc/stats`、`GET /partner/assoc/users`、`GET /partner/assoc/orders`:子账号返回自己维度。
|
- `GET /partner/assoc/stats`、`GET /partner/assoc/users`、`GET /partner/assoc/orders`:子账号返回自己维度。
|
||||||
- `GET /partner/assoc/qrcode`:子账号下载自己的码。
|
- `GET /partner/assoc/qrcode`:子账号下载自己的码。
|
||||||
|
|
||||||
|
### 3.3 财务银行账户总目录(settlement 模块)
|
||||||
|
|
||||||
|
| 方法 | 路径 | Guard | 说明 |
|
||||||
|
|------|------|-------|------|
|
||||||
|
| GET | `/admin/finance/bank-accounts` | HQ `finance` | 聚合列表;`type` / `cityId` / `keyword` / `page` / `pageSize` |
|
||||||
|
| GET | `/admin/finance/bank-accounts/export` | HQ `finance` | `format=xlsx\|pdf`,同筛选全量 |
|
||||||
|
| POST | `/admin/finance/bank-accounts` | HQ `finance` | 新增「其他」 |
|
||||||
|
| PUT | `/admin/finance/bank-accounts/other/:id` | HQ `finance` | 编辑「其他」 |
|
||||||
|
| DELETE | `/admin/finance/bank-accounts/other/:id` | HQ `finance` | 删除「其他」 |
|
||||||
|
| PUT | `/admin/finance/bank-accounts/:id/remark` | HQ `finance` | 任意类型写备注;`id` 为复合键 |
|
||||||
|
|
||||||
|
行 `id`:`STORE:{storeBankAccountId}` / `PARTNER:{partnerId}` / `LOGISTICS:{providerId}` / `WINERY:winery` / `OTHER:{id}`。`bankAccountNo` 校验 `^\d{8,32}$`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 变更面
|
## 4. 变更面
|
||||||
|
|
||||||
| 层 | 路径 |
|
| 层 | 路径 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| Prisma | `schema.prisma`(新增 `StoreBankAccount`;`User.assocSubAccountId`) |
|
| Prisma | `schema.prisma`(新增 `StoreBankAccount`、`FinanceBankAccount`、`FinanceBankAccountNote`;`User.assocSubAccountId`) |
|
||||||
| 迁移 | `migrate-store-bank-account-v4018.sql`、`migrate-user-assoc-sub-account-v4018.sql` |
|
| 迁移 | `migrate-store-bank-account-v4018.sql`、`migrate-user-assoc-sub-account-v4018.sql`、`migrate-finance-bank-account-v4018.sql` |
|
||||||
| shared-types | `settlement.ts`(`StoreBankAccountDto`);`partner-assoc.ts`(summary/touch/bind 增 `subAccountId`/`isSubAccount`) |
|
| domain | `store-address.ts`(`formatStoreDisplayAddress`:省+市+区+详细地址原样拼接,不去重) |
|
||||||
| API store | `store-bank.service.ts`、`store-bank.controller.ts`(新增);`store.module.ts`;`store-bank.util.ts`(`loadStoreBankAccounts`/`loadStoreDefaultBank`);`partner-assoc.service.ts`(`sa_` 解析/生成/touch/bind/统计) |
|
| shared-types | `settlement.ts`(`StoreBankAccountDto`、`FinanceBankAccountDto`);`hq-list-columns.ts`(`finance-bank-accounts`);`partner-assoc.ts`(summary/touch/bind 增 `subAccountId`/`isSubAccount`) |
|
||||||
| API settlement | `settlement.service.ts`(summary/withdraw/admin-withdrawal/export 改读默认账户) |
|
| API store | `store-bank.service.ts`、`store-bank.controller.ts`(新增);`store.module.ts`;`store-bank.util.ts`(`loadStoreBankAccounts`/`loadStoreDefaultBank`);`partner-assoc.service.ts`(`sa_` 解析/生成/touch/bind/统计);`store.service.ts`(地理编码地址与 C 端同规则拼接) |
|
||||||
|
| API settlement | `settlement.service.ts`(summary/withdraw/admin-withdrawal/export 改读默认账户);`finance-bank-account.service.ts`、`finance-bank-export.util.ts`(财务银行账户总目录) |
|
||||||
| API ops | `admin-redeem.service.ts`(默认账户) |
|
| API ops | `admin-redeem.service.ts`(默认账户) |
|
||||||
| mini-user | `stores/index.tsx`、`lib/store-display.ts`(`fullStoreAddress`)、`lib/promo.ts`(放行 `sa_`) |
|
| mini-user | `stores/index.tsx`、`store-detail/index.tsx`、`lib/store-display.ts`(`fullStoreAddress`)、`lib/promo.ts`(放行 `sa_`) |
|
||||||
| h5-shop | `BankAccountsPage.tsx`(新增)、`App.tsx`、`MinePage.tsx`、`styles.css` |
|
| h5-shop | `BankAccountsPage.tsx`(新增)、`App.tsx`、`MinePage.tsx`、`styles.css` |
|
||||||
| admin-web | `StoreBankAccountsPanel.tsx`(新增)、`StoresPage.tsx`(收款账户页签) |
|
| admin-web | `StoreBankAccountsPanel.tsx`(新增)、`StoresPage.tsx`(收款账户页签);`BankAccountsPage.tsx`(财务银行账户总目录) |
|
||||||
| h5-partner | `AssocQrcodePage.tsx`、`UsersManagePage.tsx`(子账号提示) |
|
| h5-partner | `AssocQrcodePage.tsx`、`UsersManagePage.tsx`(子账号提示) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 验收
|
## 5. 验收
|
||||||
|
|
||||||
- [ ] C 端门店列表地址显示「省市区县 + 详细地址」;空值回退「地址待完善」;搜索「省/市/区县」关键词可命中
|
- [ ] C 端门店列表地址 = 省+市+区+详细地址原文;详细地址已含省市区时仍重复拼接(例:金水东路333号 → `河南省郑州市金水区金水东路333号`;详细地址写全称 → `河南省郑州市金水区河南省郑州市金水区金水东路333号`);空值回退「地址待完善」;搜索「省/市/区县」关键词可命中
|
||||||
- [ ] 门店可新增/编辑/删除/设默认收款账户;默认账户用于提现打款与账单导出
|
- [ ] 门店可新增/编辑/删除/设默认收款账户;默认账户用于提现打款与账单导出
|
||||||
- [ ] 切换默认账户后(无需重启)提现 summary 与打款信息即时生效
|
- [ ] 切换默认账户后(无需重启)提现 summary 与打款信息即时生效
|
||||||
- [ ] 子账号可生成并下载自己的二维码(scene `sa_{subId}`),与主账号 `pa_{id}` 不冲突
|
- [ ] 子账号可生成并下载自己的二维码(scene `sa_{subId}`),与主账号 `pa_{id}` 不冲突
|
||||||
- [ ] 扫子账号码:用户仍锁定主账号(佣金归主账号),并写入 `assoc_sub_account_id`
|
- [ ] 扫子账号码:用户仍锁定主账号(佣金归主账号),并写入 `assoc_sub_account_id`
|
||||||
- [ ] 子账号用户管理/关联码页展示自己维度的已扫码/已关联/订单统计;主账号聚合 own+children 不变
|
- [ ] 子账号用户管理/关联码页展示自己维度的已扫码/已关联/订单统计;主账号聚合 own+children 不变
|
||||||
- [ ] 主账号码扫码:`assoc_sub_account_id` 为空,行为与 v4.0.9 一致
|
- [ ] 主账号码扫码:`assoc_sub_account_id` 为空,行为与 v4.0.9 一致
|
||||||
|
- [ ] HQ 财务菜单「银行账户」:列出全部有效门店账户(含非默认)+ 有效酒厂/主合伙人/承运商账户;可新增不挂门店账户;类型/城市/关键字筛选;Excel 与 PDF 导出与筛选一致;备注可写;来源账户改后刷新即更新
|
||||||
- [ ] shared-types 构建通过;相关 lint/单测过
|
- [ ] shared-types 构建通过;相关 lint/单测过
|
||||||
|
|||||||
@@ -415,3 +415,4 @@ export * from './dashboard-series';
|
|||||||
export * from './wecom-report';
|
export * from './wecom-report';
|
||||||
export * from './wecom-plugin';
|
export * from './wecom-plugin';
|
||||||
export * from './shipping-address';
|
export * from './shipping-address';
|
||||||
|
export * from './store-address';
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatStoreDisplayAddress } from './store-address';
|
||||||
|
|
||||||
|
describe('formatStoreDisplayAddress', () => {
|
||||||
|
it('concatenates dropdown region with street-only detail', () => {
|
||||||
|
expect(
|
||||||
|
formatStoreDisplayAddress({
|
||||||
|
province: '河南省',
|
||||||
|
cityName: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
address: '金水东路333号',
|
||||||
|
}),
|
||||||
|
).toBe('河南省郑州市金水区金水东路333号');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip region prefix already present in detail', () => {
|
||||||
|
expect(
|
||||||
|
formatStoreDisplayAddress({
|
||||||
|
province: '河南省',
|
||||||
|
cityName: '郑州市',
|
||||||
|
district: '金水区',
|
||||||
|
address: '河南省郑州市金水区金水东路333号',
|
||||||
|
}),
|
||||||
|
).toBe('河南省郑州市金水区河南省郑州市金水区金水东路333号');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fallback when all parts are empty', () => {
|
||||||
|
expect(formatStoreDisplayAddress({})).toBe('地址待完善');
|
||||||
|
expect(formatStoreDisplayAddress({}, '')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export const STORE_ADDRESS_FALLBACK = '地址待完善';
|
||||||
|
|
||||||
|
export type StoreAddressParts = {
|
||||||
|
province?: string | null;
|
||||||
|
cityName?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function textPart(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 门店展示地址:省 + 市 + 区 + 详细地址,按字段原样拼接。
|
||||||
|
* 省/市/区来自下拉选择,详细地址来自自由输入;即使详细地址已含省市区也不得去重。
|
||||||
|
*/
|
||||||
|
export function formatStoreDisplayAddress(
|
||||||
|
store: StoreAddressParts,
|
||||||
|
fallback = STORE_ADDRESS_FALLBACK,
|
||||||
|
): string {
|
||||||
|
const province = textPart(store.province);
|
||||||
|
const city = textPart(store.cityName) || textPart(store.city);
|
||||||
|
const district = textPart(store.district);
|
||||||
|
const detail = textPart(store.address);
|
||||||
|
const text = `${province}${city}${district}${detail}`;
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
formatWecomReportMarkdown,
|
formatWecomReportMarkdown,
|
||||||
|
WECOM_REPORT_EXCLUDED_ORDER_STATUSES,
|
||||||
|
wecomReportCountedOrderWhere,
|
||||||
wecomReportCutoff,
|
wecomReportCutoff,
|
||||||
wecomReportDueAt,
|
wecomReportDueAt,
|
||||||
wecomReportPeriod,
|
wecomReportPeriod,
|
||||||
@@ -24,6 +26,19 @@ const emptyStats = {
|
|||||||
redeemAmountIncrement: 40,
|
redeemAmountIncrement: 40,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
describe('wecomReportCountedOrderWhere', () => {
|
||||||
|
it('excludes pending pay, cancelled and refunding', () => {
|
||||||
|
expect([...WECOM_REPORT_EXCLUDED_ORDER_STATUSES]).toEqual([
|
||||||
|
'PENDING_PAY',
|
||||||
|
'CANCELLED',
|
||||||
|
'REFUNDING',
|
||||||
|
]);
|
||||||
|
expect(wecomReportCountedOrderWhere()).toEqual({
|
||||||
|
status: { notIn: ['PENDING_PAY', 'CANCELLED', 'REFUNDING'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('wecomReportPeriod', () => {
|
describe('wecomReportPeriod', () => {
|
||||||
it('daily is the previous Shanghai calendar day (closed at send-day 00:00)', () => {
|
it('daily is the previous Shanghai calendar day (closed at send-day 00:00)', () => {
|
||||||
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
|
const p = wecomReportPeriod('daily', new Date('2026-09-02T20:00:00+08:00'));
|
||||||
|
|||||||
@@ -17,6 +17,20 @@ export function isWecomReportKind(v: string): v is WecomReportKind {
|
|||||||
return (WECOM_REPORT_KINDS as readonly string[]).includes(v);
|
return (WECOM_REPORT_KINDS as readonly string[]).includes(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 经营报告/插件指标:订单笔数与金额均不计入待支付、已取消、退款中 */
|
||||||
|
export const WECOM_REPORT_EXCLUDED_ORDER_STATUSES = [
|
||||||
|
'PENDING_PAY',
|
||||||
|
'CANCELLED',
|
||||||
|
'REFUNDING',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type WecomReportExcludedOrderStatus =
|
||||||
|
(typeof WECOM_REPORT_EXCLUDED_ORDER_STATUSES)[number];
|
||||||
|
|
||||||
|
export function wecomReportCountedOrderWhere() {
|
||||||
|
return { status: { notIn: [...WECOM_REPORT_EXCLUDED_ORDER_STATUSES] } };
|
||||||
|
}
|
||||||
|
|
||||||
export type WecomReportStats = {
|
export type WecomReportStats = {
|
||||||
usersTotal: number;
|
usersTotal: number;
|
||||||
usersIncrement: number;
|
usersIncrement: number;
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ export function resolveMockSmsFixedCode(env?: Record<string, string | undefined>
|
|||||||
return code || MOCK_SMS_FIXED_CODE;
|
return code || MOCK_SMS_FIXED_CODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C 端门店是否展示核销次数(系统设置 SHOW_STORE_REDEEM_COUNT;未配置默认开) */
|
||||||
|
export function isShowStoreRedeemCountEnabled(env?: Record<string, string | undefined>): boolean {
|
||||||
|
const raw = (readEnv(env).SHOW_STORE_REDEEM_COUNT ?? '').trim().toLowerCase();
|
||||||
|
if (!raw) return true;
|
||||||
|
return raw === 'true' || raw === '1';
|
||||||
|
}
|
||||||
|
|
||||||
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
|
/** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */
|
||||||
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
|
export const DEFAULT_SHARE_TITLE = '你吃饭,我买单';
|
||||||
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用';
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export const HQ_LIST_COLUMN_KEYS = [
|
|||||||
'finance-partner-bills',
|
'finance-partner-bills',
|
||||||
'finance-winery-bills',
|
'finance-winery-bills',
|
||||||
'finance-logistics-bills',
|
'finance-logistics-bills',
|
||||||
|
'finance-bank-accounts',
|
||||||
'finance-store-withdrawals',
|
'finance-store-withdrawals',
|
||||||
'benefit-coupons',
|
'benefit-coupons',
|
||||||
'benefit-ledgers',
|
'benefit-ledgers',
|
||||||
|
|||||||
@@ -74,6 +74,45 @@ export interface StoreBankAccountInputDto {
|
|||||||
bankBranch?: string;
|
bankBranch?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HQ 财务银行账户总目录类型(v4.0.18) */
|
||||||
|
export const FINANCE_BANK_ACCOUNT_TYPES = ['STORE', 'WINERY', 'PARTNER', 'LOGISTICS', 'OTHER'] as const;
|
||||||
|
export type FinanceBankAccountType = (typeof FINANCE_BANK_ACCOUNT_TYPES)[number];
|
||||||
|
|
||||||
|
export const FINANCE_BANK_ACCOUNT_TYPE_LABELS: Record<FinanceBankAccountType, string> = {
|
||||||
|
STORE: '门店',
|
||||||
|
WINERY: '酒厂',
|
||||||
|
PARTNER: '合伙人',
|
||||||
|
LOGISTICS: '物流',
|
||||||
|
OTHER: '其他',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FinanceBankAccountDto {
|
||||||
|
id: string;
|
||||||
|
type: FinanceBankAccountType;
|
||||||
|
ownerName: string;
|
||||||
|
ownerId?: string | null;
|
||||||
|
cityId?: string | null;
|
||||||
|
cityName?: string | null;
|
||||||
|
bankAccountName: string;
|
||||||
|
bankAccountNo: string;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
isDefault?: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceBankAccountInputDto {
|
||||||
|
name?: string;
|
||||||
|
bankAccountName: string;
|
||||||
|
bankAccountNo: string;
|
||||||
|
bankBranch?: string;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinanceBankAccountRemarkDto {
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreWithdrawSummaryDto {
|
export interface StoreWithdrawSummaryDto {
|
||||||
availableAmount: number;
|
availableAmount: number;
|
||||||
pendingReviewAmount: number;
|
pendingReviewAmount: number;
|
||||||
|
|||||||
@@ -75,6 +75,26 @@ export interface UpdateShopStaffRequest {
|
|||||||
storeIds?: string[];
|
storeIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HQ 管理门店子账号:创建体与门店端一致,更新可改登录手机。 */
|
||||||
|
export type CreateAdminStoreStaffRequest = CreateShopStaffRequest;
|
||||||
|
|
||||||
|
export interface UpdateAdminStoreStaffRequest extends UpdateShopStaffRequest {
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminStoreStaffItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
staffRole: StoreStaffRole;
|
||||||
|
permissions: string[];
|
||||||
|
status: AccountStatus;
|
||||||
|
storeIds: string[];
|
||||||
|
stores: Array<{ id: string; name: string; status: string }>;
|
||||||
|
lastLoginAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = {
|
export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = {
|
||||||
MANAGER: '店长',
|
MANAGER: '店长',
|
||||||
CASHIER: '收银员',
|
CASHIER: '收银员',
|
||||||
@@ -82,3 +102,8 @@ export const STORE_STAFF_ROLE_LABELS: Record<StoreStaffRole, string> = {
|
|||||||
|
|
||||||
/** Default permissions for store sub-accounts (首版). */
|
/** Default permissions for store sub-accounts (首版). */
|
||||||
export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const;
|
export const STORE_STAFF_DEFAULT_PERMISSIONS = ['redeem', 'records'] as const;
|
||||||
|
|
||||||
|
export const STORE_STAFF_PERMISSION_LABELS: Record<(typeof STORE_STAFF_DEFAULT_PERMISSIONS)[number], string> = {
|
||||||
|
redeem: '核销',
|
||||||
|
records: '核销记录',
|
||||||
|
};
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export type ClientRuntimeConfig = {
|
|||||||
partnerOnboardCsHint?: string | null;
|
partnerOnboardCsHint?: string | null;
|
||||||
/** 小程序各场景分享文案/图 */
|
/** 小程序各场景分享文案/图 */
|
||||||
share?: MiniShareRuntime;
|
share?: MiniShareRuntime;
|
||||||
|
/** C 端门店列表/详情是否展示核销次数;未下发时按开启处理 */
|
||||||
|
showStoreRedeemCount?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 是否展示微信授权入口 */
|
/** 是否展示微信授权入口 */
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const WECOM_PUSH_CONDITION_LABELS: Record<WecomPushCondition, string> = {
|
|||||||
'redeem.success': '门店核销成功',
|
'redeem.success': '门店核销成功',
|
||||||
'invoice.pending': '发票申请待开票',
|
'invoice.pending': '发票申请待开票',
|
||||||
'finance.store_bill': '门店日账单已生成',
|
'finance.store_bill': '门店日账单已生成',
|
||||||
'finance.partner_bill': '合伙人月账单已生成',
|
'finance.partner_bill': '合伙人账单',
|
||||||
'finance.winery_bill': '酒厂日账单已生成',
|
'finance.winery_bill': '酒厂日账单已生成',
|
||||||
'finance.logistics_bill': '物流月对账已生成',
|
'finance.logistics_bill': '物流月对账已生成',
|
||||||
};
|
};
|
||||||
@@ -183,7 +183,7 @@ export const WECOM_TEMPLATE_EVENT_LABELS: Record<WecomTemplateEventKey, string>
|
|||||||
'store.withdraw_approved': '门店手动提现已通过',
|
'store.withdraw_approved': '门店手动提现已通过',
|
||||||
'invoice.pending': '发票申请待开票',
|
'invoice.pending': '发票申请待开票',
|
||||||
'finance.store_bill': '门店日账单已生成',
|
'finance.store_bill': '门店日账单已生成',
|
||||||
'finance.partner_bill': '合伙人月账单已生成',
|
'finance.partner_bill': '合伙人账单',
|
||||||
'finance.winery_bill': '酒厂日账单已生成',
|
'finance.winery_bill': '酒厂日账单已生成',
|
||||||
'finance.logistics_bill': '物流月对账已生成',
|
'finance.logistics_bill': '物流月对账已生成',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- v4.0.18:HQ 财务银行账户总目录(其他账户 + 来源账户备注 overlay)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `finance_bank_account` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`name` VARCHAR(128) NULL,
|
||||||
|
`bank_account_name` VARCHAR(64) NOT NULL,
|
||||||
|
`bank_account_no` VARCHAR(32) NOT NULL,
|
||||||
|
`bank_branch` VARCHAR(128) NULL,
|
||||||
|
`remark` VARCHAR(256) NULL,
|
||||||
|
`status` ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_finance_bank_account_status` (`status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务手工银行账户(不挂门店)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `finance_bank_account_note` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`owner_type` ENUM('STORE','WINERY','PARTNER','LOGISTICS') NOT NULL,
|
||||||
|
`source_id` VARCHAR(64) NOT NULL,
|
||||||
|
`remark` VARCHAR(256) NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_finance_bank_account_note_owner_source` (`owner_type`, `source_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='财务银行账户备注 overlay';
|
||||||
@@ -262,6 +262,13 @@ enum AccountStatus {
|
|||||||
DISABLED
|
DISABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum FinanceBankAccountOwnerType {
|
||||||
|
STORE
|
||||||
|
WINERY
|
||||||
|
PARTNER
|
||||||
|
LOGISTICS
|
||||||
|
}
|
||||||
|
|
||||||
enum HqAdminRole {
|
enum HqAdminRole {
|
||||||
SUPER_ADMIN
|
SUPER_ADMIN
|
||||||
OPS
|
OPS
|
||||||
@@ -2176,6 +2183,35 @@ model WineryBillItem {
|
|||||||
@@map("winery_bill_item")
|
@@map("winery_bill_item")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// HQ 财务手工登记的银行账户(不挂门店,不进入打款)
|
||||||
|
model FinanceBankAccount {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
name String? @db.VarChar(128)
|
||||||
|
bankAccountName String @map("bank_account_name") @db.VarChar(64)
|
||||||
|
bankAccountNo String @map("bank_account_no") @db.VarChar(32)
|
||||||
|
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||||
|
remark String? @db.VarChar(256)
|
||||||
|
status AccountStatus @default(ACTIVE)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
@@index([status])
|
||||||
|
@@map("finance_bank_account")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HQ 财务对来源账户的备注 overlay(不改写门店/合伙人/酒厂/物流源字段)
|
||||||
|
model FinanceBankAccountNote {
|
||||||
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
ownerType FinanceBankAccountOwnerType @map("owner_type")
|
||||||
|
sourceId String @map("source_id") @db.VarChar(64)
|
||||||
|
remark String @db.VarChar(256)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
@@unique([ownerType, sourceId])
|
||||||
|
@@map("finance_bank_account_note")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── LOG ──────────────────────────────────────────────
|
// ─── LOG ──────────────────────────────────────────────
|
||||||
|
|
||||||
model LogThirdParty {
|
model LogThirdParty {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export const HqOperationAction = {
|
|||||||
STORE_AUDIT: 'STORE_AUDIT',
|
STORE_AUDIT: 'STORE_AUDIT',
|
||||||
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
STORE_ACCOUNT_CREATE: 'STORE_ACCOUNT_CREATE',
|
||||||
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
|
STORE_ACCOUNT_UPDATE: 'STORE_ACCOUNT_UPDATE',
|
||||||
|
STORE_ACCOUNT_STAFF_CREATE: 'STORE_ACCOUNT_STAFF_CREATE',
|
||||||
|
STORE_ACCOUNT_STAFF_UPDATE: 'STORE_ACCOUNT_STAFF_UPDATE',
|
||||||
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
|
STORE_ACCOUNT_STAFF_DELETE: 'STORE_ACCOUNT_STAFF_DELETE',
|
||||||
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
STORE_MEDIA_CREATE: 'STORE_MEDIA_CREATE',
|
||||||
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
STORE_MEDIA_UPDATE: 'STORE_MEDIA_UPDATE',
|
||||||
@@ -176,6 +178,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
[HqOperationAction.STORE_AUDIT]: '门店审核',
|
||||||
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
[HqOperationAction.STORE_ACCOUNT_CREATE]: '新增门店账户',
|
||||||
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
|
[HqOperationAction.STORE_ACCOUNT_UPDATE]: '编辑门店账户',
|
||||||
|
[HqOperationAction.STORE_ACCOUNT_STAFF_CREATE]: '新增门店子账号',
|
||||||
|
[HqOperationAction.STORE_ACCOUNT_STAFF_UPDATE]: '编辑门店子账号',
|
||||||
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
|
[HqOperationAction.STORE_ACCOUNT_STAFF_DELETE]: '删除门店子账号',
|
||||||
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
[HqOperationAction.STORE_MEDIA_CREATE]: '新增门店资源',
|
||||||
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
[HqOperationAction.STORE_MEDIA_UPDATE]: '编辑门店资源',
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
|
description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'SHOW_STORE_REDEEM_COUNT',
|
||||||
|
label: 'C 端展示门店核销次数',
|
||||||
|
group: G.feature,
|
||||||
|
type: 'boolean',
|
||||||
|
requiresRestart: false,
|
||||||
|
description: '关闭后小程序门店列表/详情不再展示「核销N次」,接口也不再返回核销次数',
|
||||||
|
},
|
||||||
|
|
||||||
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
|
{ key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false },
|
||||||
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
|
{ key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false },
|
||||||
@@ -612,6 +620,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k
|
|||||||
|
|
||||||
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
|
/** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */
|
||||||
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
export const SYSTEM_CONFIG_DEFAULTS: Record<string, string> = {
|
||||||
|
SHOW_STORE_REDEEM_COUNT: 'true',
|
||||||
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
|
USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''),
|
||||||
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
|
SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''),
|
||||||
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
|
BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE,
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import {
|
|||||||
renderWecomTemplate,
|
renderWecomTemplate,
|
||||||
} from './wecom-push-template.defaults';
|
} from './wecom-push-template.defaults';
|
||||||
|
|
||||||
|
/** 含 markdown 表格时改走 markdown_v2,群聊才能渲染表格 */
|
||||||
|
function wecomMarkdownUsesTable(content: string): boolean {
|
||||||
|
return /\|[^\n]*\|\s*\n\s*\|?\s*:?-{3,}/.test(content);
|
||||||
|
}
|
||||||
|
|
||||||
type PushRow = {
|
type PushRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -327,14 +332,16 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> {
|
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> {
|
||||||
const url = (webhookUrl || '').trim();
|
const url = (webhookUrl || '').trim();
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
|
const useTable = wecomMarkdownUsesTable(content);
|
||||||
|
const text = content.slice(0, useTable ? 4096 : 4000);
|
||||||
|
const payload = useTable
|
||||||
|
? { msgtype: 'markdown_v2', markdown_v2: { content: text } }
|
||||||
|
: { msgtype: 'markdown', markdown: { content: text } };
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload),
|
||||||
msgtype: 'markdown',
|
|
||||||
markdown: { content: content.slice(0, 4000) },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = (await res.json().catch(() => ({}))) as {
|
const data = (await res.json().catch(() => ({}))) as {
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
@@ -580,20 +587,23 @@ export class WecomMessagePushService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
'finance.partner_bill': {
|
'finance.partner_bill': {
|
||||||
vars: {
|
vars: {
|
||||||
period: '2026-07',
|
period: '2026-07-27 ~ 2026-08-02',
|
||||||
billCount: '1',
|
billCount: '1',
|
||||||
totalAmount: '120.00',
|
totalAmount: '120.00',
|
||||||
billSummary: [
|
billSummary: [
|
||||||
'城市:郑州',
|
'| 郑州某某商贸-张三 | |',
|
||||||
'合伙人:郑州某某商贸-张三',
|
'| :--- | ---: |',
|
||||||
'订单笔数:8',
|
'| 账期 | 2026-07-27 ~ 2026-08-02 |',
|
||||||
'核销笔数:12',
|
'| 订单数量 | 8 |',
|
||||||
'订单佣金:¥100.00',
|
'| 订单金额 | ¥1000.00 |',
|
||||||
'核销佣金:¥20.00',
|
'| 订单佣金 | ¥100.00 |',
|
||||||
'账单:¥120.00',
|
'| 核销单数量 | 12 |',
|
||||||
'收款人:张三',
|
'| 核销金额 | ¥200.00 |',
|
||||||
'收款账户:6222000011112222',
|
'| 核销佣金 | ¥20.00 |',
|
||||||
'收款开户行:郑州支行',
|
'| 累计金额 | ¥120.00 |',
|
||||||
|
'| 收款人 | 张三 |',
|
||||||
|
'| 收款银行账号 | 6222000011112222 |',
|
||||||
|
'| 开户行 | 郑州支行 |',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
},
|
},
|
||||||
handlePath: '/finance/partner-bills',
|
handlePath: '/finance/partner-bills',
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
toWecomPluginMetricsView,
|
toWecomPluginMetricsView,
|
||||||
toWecomPluginUserView,
|
toWecomPluginUserView,
|
||||||
wecomPluginMetricsPeriod,
|
wecomPluginMetricsPeriod,
|
||||||
type WecomReportStats,
|
wecomReportCountedOrderWhere,
|
||||||
|
type WecomReportStats,
|
||||||
} from '@dukang/domain';
|
} from '@dukang/domain';
|
||||||
import {
|
import {
|
||||||
WECOM_PLUGIN_TOOL_PATHS,
|
WECOM_PLUGIN_TOOL_PATHS,
|
||||||
@@ -428,10 +429,11 @@ export class WecomPluginQueryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
|
/** 日报口径:用户=有效未合并;订单排除待支付/已取消/退款中;金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */
|
||||||
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
|
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
|
||||||
const userBase = { status: 1, mergedIntoUserId: null } as const;
|
const userBase = { status: 1, mergedIntoUserId: null } as const;
|
||||||
const partnerBase = { isPrimary: 1 } as const;
|
const partnerBase = { isPrimary: 1 } as const;
|
||||||
|
const countedOrder = wecomReportCountedOrderWhere();
|
||||||
const paid = { payStatus: 'PAID' as const };
|
const paid = { payStatus: 'PAID' as const };
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -462,15 +464,15 @@ export class WecomPluginQueryService {
|
|||||||
}),
|
}),
|
||||||
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
|
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
|
||||||
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||||
this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
|
this.prisma.order.count({ where: { ...countedOrder, createdAt: { lt: cutoff } } }),
|
||||||
this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
this.prisma.order.count({ where: { ...countedOrder, createdAt: { gte: start, lt: cutoff } } }),
|
||||||
this.prisma.order.aggregate({
|
this.prisma.order.aggregate({
|
||||||
_sum: { payAmount: true },
|
_sum: { payAmount: true },
|
||||||
where: { ...paid, paidAt: { lt: cutoff } },
|
where: { ...countedOrder, ...paid, paidAt: { lt: cutoff } },
|
||||||
}),
|
}),
|
||||||
this.prisma.order.aggregate({
|
this.prisma.order.aggregate({
|
||||||
_sum: { payAmount: true },
|
_sum: { payAmount: true },
|
||||||
where: { ...paid, paidAt: { gte: start, lt: cutoff } },
|
where: { ...countedOrder, ...paid, paidAt: { gte: start, lt: cutoff } },
|
||||||
}),
|
}),
|
||||||
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
|
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
|
||||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ export const WECOM_PLUGIN_OPENAPI = {
|
|||||||
get: {
|
get: {
|
||||||
summary: '经营指标',
|
summary: '经营指标',
|
||||||
description:
|
description:
|
||||||
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
|
'today=今日截至当前;daily/weekly/monthly 与企微经营报告同一口径。订单排除待支付/已取消/退款中。stats 含 users/partners/stores/orders 存量与增量;storesIncrement 与 newStores 均为新增门店数。',
|
||||||
operationId: '查询经营指标',
|
operationId: '查询经营指标',
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -150,13 +150,9 @@ export const WECOM_PUSH_TEMPLATE_DEFAULTS: WecomTemplateDefault[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
eventKey: 'finance.partner_bill',
|
eventKey: 'finance.partner_bill',
|
||||||
title: '合伙人月账单已生成',
|
title: '合伙人账单',
|
||||||
body: [
|
body: [
|
||||||
'**合伙人月账单已生成**',
|
'**合伙人账单**',
|
||||||
'账期:{{period}}',
|
|
||||||
'账单笔数:{{billCount}}',
|
|
||||||
'累计金额:¥{{totalAmount}}',
|
|
||||||
'时间:{{time}}',
|
|
||||||
'',
|
'',
|
||||||
'{{billSummary}}',
|
'{{billSummary}}',
|
||||||
'',
|
'',
|
||||||
@@ -315,6 +311,17 @@ export const WECOM_PUSH_TEMPLATE_LEGACY_BODIES: Partial<Record<WecomTemplateEven
|
|||||||
'时间:{{time}}',
|
'时间:{{time}}',
|
||||||
'[{{handleLabel}}]({{handleUrl}})',
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
|
[
|
||||||
|
'**合伙人月账单已生成**',
|
||||||
|
'账期:{{period}}',
|
||||||
|
'账单笔数:{{billCount}}',
|
||||||
|
'累计金额:¥{{totalAmount}}',
|
||||||
|
'时间:{{time}}',
|
||||||
|
'',
|
||||||
|
'{{billSummary}}',
|
||||||
|
'',
|
||||||
|
'[{{handleLabel}}]({{handleUrl}})',
|
||||||
|
].join('\n'),
|
||||||
],
|
],
|
||||||
'finance.winery_bill': [
|
'finance.winery_bill': [
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
isShowStoreRedeemCountEnabled,
|
||||||
parseMiniHomeBanners,
|
parseMiniHomeBanners,
|
||||||
resolveClientBrandRuntime,
|
resolveClientBrandRuntime,
|
||||||
resolveMiniShareRuntime,
|
resolveMiniShareRuntime,
|
||||||
@@ -44,6 +45,7 @@ export class ClientConfigController {
|
|||||||
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
|
(env.PARTNER_ONBOARD_CS_HINT ?? '').trim() ||
|
||||||
'使用问题、提现问题等随时可联系【杜康好客】客服',
|
'使用问题、提现问题等随时可联系【杜康好客】客服',
|
||||||
share,
|
share,
|
||||||
|
showStoreRedeemCount: isShowStoreRedeemCountEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import {
|
import {
|
||||||
HqPermissionGuard,
|
HqPermissionGuard,
|
||||||
@@ -80,6 +81,17 @@ export class AdminPartnersController {
|
|||||||
return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20);
|
return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/assoc/qrcode')
|
||||||
|
async assocQrcode(@Param('id') id: string, @Res() res: Response) {
|
||||||
|
const { buffer, fileName } = await this.assoc.getQrcodeBuffer(BigInt(id));
|
||||||
|
res.setHeader('Content-Type', 'image/png');
|
||||||
|
res.setHeader(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
||||||
|
);
|
||||||
|
res.send(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':id/assoc/qrcode')
|
@Post(':id/assoc/qrcode')
|
||||||
regenQrcode(@Param('id') id: string) {
|
regenQrcode(@Param('id') id: string) {
|
||||||
return this.assoc.ensureQrcode(BigInt(id), true);
|
return this.assoc.ensureQrcode(BigInt(id), true);
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ import {
|
|||||||
} from './dto/admin-query.dto';
|
} from './dto/admin-query.dto';
|
||||||
import {
|
import {
|
||||||
CreateStoreAccountDto,
|
CreateStoreAccountDto,
|
||||||
|
CreateStoreAccountStaffDto,
|
||||||
CreateStoreDto,
|
CreateStoreDto,
|
||||||
CreateStoreMediaDto,
|
CreateStoreMediaDto,
|
||||||
UpdateStoreAccountDto,
|
UpdateStoreAccountDto,
|
||||||
|
UpdateStoreAccountStaffDto,
|
||||||
UpdateStoreDto,
|
UpdateStoreDto,
|
||||||
UpdateStoreMediaDto,
|
UpdateStoreMediaDto,
|
||||||
UpdateStoreStatusDto,
|
UpdateStoreStatusDto,
|
||||||
@@ -113,6 +115,37 @@ export class AdminStoreAccountsController {
|
|||||||
return this.service.updateStoreAccount(BigInt(id), dto, user.actorId);
|
return this.service.updateStoreAccount(BigInt(id), dto, user.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post(':id/staff')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_ACCOUNT_STAFF_CREATE,
|
||||||
|
refType: 'STORE_ACCOUNT',
|
||||||
|
refIdField: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
createStaff(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: CreateStoreAccountStaffDto,
|
||||||
|
) {
|
||||||
|
return this.service.createStoreStaff(BigInt(id), dto, user.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/staff/:staffId')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.STORE_ACCOUNT_STAFF_UPDATE,
|
||||||
|
refType: 'STORE_ACCOUNT',
|
||||||
|
refIdParam: 'staffId',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
updateStaff(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Param('staffId') staffId: string,
|
||||||
|
@Body() dto: UpdateStoreAccountStaffDto,
|
||||||
|
) {
|
||||||
|
return this.service.updateStoreStaff(BigInt(id), BigInt(staffId), dto, user.actorId);
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id/staff/:staffId')
|
@Delete(':id/staff/:staffId')
|
||||||
@HqOperation({
|
@HqOperation({
|
||||||
action: HqOperationAction.STORE_ACCOUNT_STAFF_DELETE,
|
action: HqOperationAction.STORE_ACCOUNT_STAFF_DELETE,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
STORE_STAFF_DEFAULT_PERMISSIONS,
|
||||||
|
StoreStaffRole,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
assertCanDeleteStore,
|
assertCanDeleteStore,
|
||||||
isMobilePhone,
|
isMobilePhone,
|
||||||
@@ -23,9 +27,11 @@ import {
|
|||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import type {
|
import type {
|
||||||
CreateStoreAccountDto,
|
CreateStoreAccountDto,
|
||||||
|
CreateStoreAccountStaffDto,
|
||||||
CreateStoreDto,
|
CreateStoreDto,
|
||||||
CreateStoreMediaDto,
|
CreateStoreMediaDto,
|
||||||
UpdateStoreAccountDto,
|
UpdateStoreAccountDto,
|
||||||
|
UpdateStoreAccountStaffDto,
|
||||||
UpdateStoreDto,
|
UpdateStoreDto,
|
||||||
UpdateStoreMediaDto,
|
UpdateStoreMediaDto,
|
||||||
UpdateStoreStatusDto,
|
UpdateStoreStatusDto,
|
||||||
@@ -1103,7 +1109,7 @@ export class AdminStoresService {
|
|||||||
...account,
|
...account,
|
||||||
stores: account.bindings.map((b) => b.store),
|
stores: account.bindings.map((b) => b.store),
|
||||||
store: account.bindings[0]?.store ?? null,
|
store: account.bindings[0]?.store ?? null,
|
||||||
staff: account.childAccounts,
|
staff: account.childAccounts.map((row) => this.mapStoreStaff(row)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1120,17 +1126,102 @@ export class AdminStoresService {
|
|||||||
return serializeBigInt(account);
|
return serializeBigInt(account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createStoreStaff(parentAccountId: bigint, dto: CreateStoreAccountStaffDto, actorId: bigint) {
|
||||||
|
const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
|
||||||
|
const phone = dto.phone.trim();
|
||||||
|
if (!isMobilePhone(phone)) {
|
||||||
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
|
}
|
||||||
|
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||||
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
|
|
||||||
|
const storeIds = await this.resolveStaffStoreIds(parent.id, dto.storeIds);
|
||||||
|
const staffRole = (dto.staffRole as StoreStaffRole | undefined) ?? StoreStaffRole.CASHIER;
|
||||||
|
const permissions = dto.permissions?.length
|
||||||
|
? dto.permissions
|
||||||
|
: [...STORE_STAFF_DEFAULT_PERMISSIONS];
|
||||||
|
|
||||||
|
const account = await this.prisma.storeAccount.create({
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
name,
|
||||||
|
isPrimary: 0,
|
||||||
|
parentAccountId: parent.id,
|
||||||
|
staffRole,
|
||||||
|
permissions,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
isTest: parent.isTest,
|
||||||
|
bindings: {
|
||||||
|
create: storeIds.map((storeId) => ({ storeId })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: this.staffBindingsInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt(this.mapStoreStaff(account));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStoreStaff(
|
||||||
|
parentAccountId: bigint,
|
||||||
|
staffId: bigint,
|
||||||
|
dto: UpdateStoreAccountStaffDto,
|
||||||
|
actorId: bigint,
|
||||||
|
) {
|
||||||
|
const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
|
||||||
|
const staff = await this.assertStaffOwned(parent.id, staffId);
|
||||||
|
|
||||||
|
const data: Prisma.StoreAccountUpdateInput = {};
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const name = dto.name.trim();
|
||||||
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
|
data.name = name;
|
||||||
|
}
|
||||||
|
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as StoreStaffRole;
|
||||||
|
if (dto.permissions !== undefined) data.permissions = dto.permissions;
|
||||||
|
if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED';
|
||||||
|
if (dto.phone !== undefined) {
|
||||||
|
const phone = dto.phone.trim();
|
||||||
|
if (!isMobilePhone(phone)) {
|
||||||
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
|
}
|
||||||
|
const phoneTaken = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||||
|
if (phoneTaken && phoneTaken.id !== staff.id) {
|
||||||
|
throw new BadRequestException('该手机号已被使用');
|
||||||
|
}
|
||||||
|
data.phone = phone;
|
||||||
|
if (phone !== staff.phone) {
|
||||||
|
data.wxOpenId = null;
|
||||||
|
data.wxUnionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.storeIds !== undefined) {
|
||||||
|
const storeIds = await this.resolveStaffStoreIds(parent.id, dto.storeIds);
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.storeAccountStore.deleteMany({ where: { storeAccountId: staff.id } }),
|
||||||
|
this.prisma.storeAccountStore.createMany({
|
||||||
|
data: storeIds.map((storeId) => ({ storeAccountId: staff.id, storeId })),
|
||||||
|
}),
|
||||||
|
this.prisma.storeAccount.update({ where: { id: staff.id }, data }),
|
||||||
|
]);
|
||||||
|
} else if (Object.keys(data).length) {
|
||||||
|
await this.prisma.storeAccount.update({ where: { id: staff.id }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
|
where: { id: staff.id },
|
||||||
|
include: this.staffBindingsInclude,
|
||||||
|
});
|
||||||
|
return serializeBigInt(this.mapStoreStaff(updated));
|
||||||
|
}
|
||||||
|
|
||||||
/** HQ 删除门店子账号(非主账号) */
|
/** HQ 删除门店子账号(非主账号) */
|
||||||
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) {
|
async deleteStoreStaff(parentAccountId: bigint, staffId: bigint, actorId: bigint) {
|
||||||
await this.assertStoreAccountInScope(actorId, parentAccountId);
|
const parent = await this.assertPrimaryStoreAccount(parentAccountId, actorId);
|
||||||
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
|
const staff = await this.assertStaffOwned(parent.id, staffId);
|
||||||
if (!parent || parent.isPrimary !== 1) {
|
|
||||||
throw new BadRequestException('主账号不存在');
|
|
||||||
}
|
|
||||||
const staff = await this.prisma.storeAccount.findFirst({
|
|
||||||
where: { id: staffId, parentAccountId, isPrimary: 0 },
|
|
||||||
});
|
|
||||||
if (!staff) throw new NotFoundException('子账号不存在');
|
|
||||||
|
|
||||||
const pending = await this.prisma.redeemPendingRecord.count({
|
const pending = await this.prisma.redeemPendingRecord.count({
|
||||||
where: { storeAccountId: staffId },
|
where: { storeAccountId: staffId },
|
||||||
@@ -1139,7 +1230,76 @@ export class AdminStoresService {
|
|||||||
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
|
throw new BadRequestException('该子账号仍有待处理核销单,无法删除');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.storeAccount.delete({ where: { id: staffId } });
|
await this.prisma.storeAccount.delete({ where: { id: staff.id } });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly staffBindingsInclude = {
|
||||||
|
bindings: {
|
||||||
|
include: {
|
||||||
|
store: { select: { id: true, name: true, status: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
private async assertPrimaryStoreAccount(parentAccountId: bigint, actorId: bigint) {
|
||||||
|
await this.assertStoreAccountInScope(actorId, parentAccountId);
|
||||||
|
const parent = await this.prisma.storeAccount.findUnique({ where: { id: parentAccountId } });
|
||||||
|
if (!parent || parent.isPrimary !== 1) {
|
||||||
|
throw new BadRequestException('主账号不存在');
|
||||||
|
}
|
||||||
|
return parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||||
|
const staff = await this.prisma.storeAccount.findFirst({
|
||||||
|
where: { id: staffId, parentAccountId, isPrimary: 0 },
|
||||||
|
});
|
||||||
|
if (!staff) throw new NotFoundException('子账号不存在');
|
||||||
|
return staff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子账号只能绑定主账号已管理的门店 */
|
||||||
|
private async resolveStaffStoreIds(primaryAccountId: bigint, storeIds: string[]) {
|
||||||
|
const unique = [...new Set(storeIds.map((id) => id.trim()).filter(Boolean))];
|
||||||
|
if (!unique.length) throw new BadRequestException('请至少绑定一家门店');
|
||||||
|
const ids = unique.map((id) => BigInt(id));
|
||||||
|
const owned = await this.prisma.storeAccountStore.findMany({
|
||||||
|
where: { storeAccountId: primaryAccountId, storeId: { in: ids } },
|
||||||
|
select: { storeId: true },
|
||||||
|
});
|
||||||
|
if (owned.length !== ids.length) {
|
||||||
|
throw new BadRequestException('只能绑定主账号已管理的门店');
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapStoreStaff(row: {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
staffRole: string | null;
|
||||||
|
permissions: Prisma.JsonValue;
|
||||||
|
status: string;
|
||||||
|
lastLoginAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
bindings: Array<{ store: { id: bigint; name: string; status: string } }>;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
name: row.name,
|
||||||
|
phone: row.phone,
|
||||||
|
staffRole: row.staffRole ?? StoreStaffRole.CASHIER,
|
||||||
|
permissions: Array.isArray(row.permissions) ? row.permissions : [],
|
||||||
|
status: row.status,
|
||||||
|
storeIds: row.bindings.map((b) => b.store.id.toString()),
|
||||||
|
stores: row.bindings.map((b) => ({
|
||||||
|
id: b.store.id.toString(),
|
||||||
|
name: b.store.name,
|
||||||
|
status: b.store.status,
|
||||||
|
})),
|
||||||
|
lastLoginAt: row.lastLoginAt?.toISOString() ?? null,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Prisma, type WecomReportPush } from '@prisma/client';
|
|||||||
import {
|
import {
|
||||||
formatWecomReportMarkdown,
|
formatWecomReportMarkdown,
|
||||||
isWecomReportKind,
|
isWecomReportKind,
|
||||||
|
wecomReportCountedOrderWhere,
|
||||||
wecomReportCutoff,
|
wecomReportCutoff,
|
||||||
wecomReportPeriod,
|
wecomReportPeriod,
|
||||||
wecomReportShouldFire,
|
wecomReportShouldFire,
|
||||||
@@ -274,6 +275,7 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
|
private async loadStats(start: Date, cutoff: Date): Promise<WecomReportStats> {
|
||||||
const userBase = { status: 1, mergedIntoUserId: null } as const;
|
const userBase = { status: 1, mergedIntoUserId: null } as const;
|
||||||
const partnerBase = { isPrimary: 1 } as const;
|
const partnerBase = { isPrimary: 1 } as const;
|
||||||
|
const countedOrder = wecomReportCountedOrderWhere();
|
||||||
const paid = { payStatus: 'PAID' as const };
|
const paid = { payStatus: 'PAID' as const };
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -304,15 +306,15 @@ export class AdminWecomReportsService implements OnModuleInit {
|
|||||||
}),
|
}),
|
||||||
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
|
this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }),
|
||||||
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||||
this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }),
|
this.prisma.order.count({ where: { ...countedOrder, createdAt: { lt: cutoff } } }),
|
||||||
this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
this.prisma.order.count({ where: { ...countedOrder, createdAt: { gte: start, lt: cutoff } } }),
|
||||||
this.prisma.order.aggregate({
|
this.prisma.order.aggregate({
|
||||||
_sum: { payAmount: true },
|
_sum: { payAmount: true },
|
||||||
where: { ...paid, paidAt: { lt: cutoff } },
|
where: { ...countedOrder, ...paid, paidAt: { lt: cutoff } },
|
||||||
}),
|
}),
|
||||||
this.prisma.order.aggregate({
|
this.prisma.order.aggregate({
|
||||||
_sum: { payAmount: true },
|
_sum: { payAmount: true },
|
||||||
where: { ...paid, paidAt: { gte: start, lt: cutoff } },
|
where: { ...countedOrder, ...paid, paidAt: { gte: start, lt: cutoff } },
|
||||||
}),
|
}),
|
||||||
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
|
this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }),
|
||||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
ArrayMaxSize,
|
||||||
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
ValidateIf,
|
ValidateIf,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
import { AccountStatus, StoreStaffRole } from '@dukang/shared-types';
|
||||||
|
|
||||||
export class UpdateStoreStatusDto {
|
export class UpdateStoreStatusDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -341,6 +343,61 @@ export class UpdateStoreAccountDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CreateStoreAccountStaffDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@IsString({ each: true })
|
||||||
|
storeIds: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(Object.values(StoreStaffRole))
|
||||||
|
staffRole?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateStoreAccountStaffDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(Object.values(StoreStaffRole))
|
||||||
|
staffRole?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
permissions?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(Object.values(AccountStatus))
|
||||||
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
storeIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class CreatePartnerDto {
|
export class CreatePartnerDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
FINANCE_BANK_ACCOUNT_TYPES,
|
||||||
|
type FinanceBankAccountDto,
|
||||||
|
type FinanceBankAccountInputDto,
|
||||||
|
type FinanceBankAccountType,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
|
import { loadWineryBankConfig } from '../../common/store/store-bank.util';
|
||||||
|
import {
|
||||||
|
buildFinanceBankAccountsPdf,
|
||||||
|
buildFinanceBankAccountsXlsx,
|
||||||
|
buildFinanceBankExportFilename,
|
||||||
|
} from './finance-bank-export.util';
|
||||||
|
|
||||||
|
const BANK_NO_RE = /^\d{8,32}$/;
|
||||||
|
const TYPE_ORDER: Record<FinanceBankAccountType, number> = {
|
||||||
|
STORE: 0,
|
||||||
|
WINERY: 1,
|
||||||
|
PARTNER: 2,
|
||||||
|
LOGISTICS: 3,
|
||||||
|
OTHER: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_NOTE_TYPES = ['STORE', 'WINERY', 'PARTNER', 'LOGISTICS'] as const;
|
||||||
|
|
||||||
|
export type FinanceBankAccountListQuery = {
|
||||||
|
type?: string;
|
||||||
|
cityId?: string;
|
||||||
|
keyword?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FinanceBankAccountService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async list(query: FinanceBankAccountListQuery) {
|
||||||
|
const page = Math.max(1, query.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, query.pageSize ?? 20));
|
||||||
|
const all = await this.collectFiltered(query);
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return {
|
||||||
|
items: all.slice(start, start + pageSize),
|
||||||
|
total: all.length,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(query: FinanceBankAccountListQuery & { format?: string }) {
|
||||||
|
const format = query.format === 'pdf' ? 'pdf' : 'xlsx';
|
||||||
|
const rows = await this.collectFiltered(query);
|
||||||
|
if (!rows.length) throw new BadRequestException('没有可导出的银行账户');
|
||||||
|
const buffer =
|
||||||
|
format === 'pdf' ? await buildFinanceBankAccountsPdf(rows) : await buildFinanceBankAccountsXlsx(rows);
|
||||||
|
return {
|
||||||
|
filename: buildFinanceBankExportFilename(format, rows.length),
|
||||||
|
mimeType:
|
||||||
|
format === 'pdf'
|
||||||
|
? 'application/pdf'
|
||||||
|
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
contentBase64: buffer.toString('base64'),
|
||||||
|
count: rows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOther(dto: FinanceBankAccountInputDto) {
|
||||||
|
const data = this.normalizeInput(dto);
|
||||||
|
const created = await this.prisma.financeBankAccount.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
bankAccountName: data.bankAccountName,
|
||||||
|
bankAccountNo: data.bankAccountNo,
|
||||||
|
bankBranch: data.bankBranch,
|
||||||
|
remark: data.remark,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeOther(created, created.remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOther(id: bigint, dto: FinanceBankAccountInputDto) {
|
||||||
|
const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('账户不存在');
|
||||||
|
const data = this.normalizeInput(dto);
|
||||||
|
const updated = await this.prisma.financeBankAccount.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
bankAccountName: data.bankAccountName,
|
||||||
|
bankAccountNo: data.bankAccountNo,
|
||||||
|
bankBranch: data.bankBranch,
|
||||||
|
remark: data.remark,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeOther(updated, updated.remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeOther(id: bigint) {
|
||||||
|
const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('账户不存在');
|
||||||
|
await this.prisma.financeBankAccount.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRemark(compositeId: string, remarkRaw?: string) {
|
||||||
|
const parsed = this.parseCompositeId(compositeId);
|
||||||
|
const remark = remarkRaw?.trim() || null;
|
||||||
|
if (remark && remark.length > 256) throw new BadRequestException('备注最多 256 字');
|
||||||
|
|
||||||
|
if (parsed.type === 'OTHER') {
|
||||||
|
const id = this.parseBigIntId(parsed.sourceId, '账户不存在');
|
||||||
|
const existing = await this.prisma.financeBankAccount.findUnique({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('账户不存在');
|
||||||
|
const updated = await this.prisma.financeBankAccount.update({
|
||||||
|
where: { id },
|
||||||
|
data: { remark },
|
||||||
|
});
|
||||||
|
return this.serializeOther(updated, updated.remark);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownerType = parsed.type;
|
||||||
|
if (remark) {
|
||||||
|
await this.prisma.financeBankAccountNote.upsert({
|
||||||
|
where: { ownerType_sourceId: { ownerType, sourceId: parsed.sourceId } },
|
||||||
|
create: { ownerType, sourceId: parsed.sourceId, remark },
|
||||||
|
update: { remark },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.prisma.financeBankAccountNote.deleteMany({
|
||||||
|
where: { ownerType, sourceId: parsed.sourceId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true, id: compositeId, remark };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectFiltered(query: FinanceBankAccountListQuery): Promise<FinanceBankAccountDto[]> {
|
||||||
|
const typeFilter = this.parseType(query.type);
|
||||||
|
const cityId = query.cityId?.trim() || '';
|
||||||
|
const keyword = query.keyword?.trim().toLowerCase() || '';
|
||||||
|
const rows = await this.collectAll();
|
||||||
|
return rows
|
||||||
|
.filter((row) => {
|
||||||
|
if (typeFilter && row.type !== typeFilter) return false;
|
||||||
|
if (cityId && row.cityId !== cityId) return false;
|
||||||
|
if (!keyword) return true;
|
||||||
|
const hay = [row.ownerName, row.bankAccountName, row.bankAccountNo, row.bankBranch ?? '', row.remark ?? '']
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
return hay.includes(keyword);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const t = TYPE_ORDER[a.type] - TYPE_ORDER[b.type];
|
||||||
|
if (t !== 0) return t;
|
||||||
|
const city = (a.cityName ?? '').localeCompare(b.cityName ?? '', 'zh');
|
||||||
|
if (city !== 0) return city;
|
||||||
|
return a.ownerName.localeCompare(b.ownerName, 'zh');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectAll(): Promise<FinanceBankAccountDto[]> {
|
||||||
|
const [storeRows, partnerRows, logisticsRows, otherRows, notes, winery] = await Promise.all([
|
||||||
|
this.prisma.storeBankAccount.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
include: { store: { select: { id: true, name: true, cityId: true, cityName: true } } },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
this.prisma.partnerAccount.findMany({
|
||||||
|
where: { status: 'ACTIVE', parentAccountId: null, isPrimary: 1 },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
companyName: true,
|
||||||
|
cityId: true,
|
||||||
|
bankAccountName: true,
|
||||||
|
bankAccountNo: true,
|
||||||
|
bankBranch: true,
|
||||||
|
city: { select: { name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.fulfillmentProvider.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
bankAccountName: true,
|
||||||
|
bankAccountNo: true,
|
||||||
|
bankBranch: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.financeBankAccount.findMany({
|
||||||
|
where: { status: 'ACTIVE' },
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
}),
|
||||||
|
this.prisma.financeBankAccountNote.findMany(),
|
||||||
|
loadWineryBankConfig(this.prisma),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const noteMap = new Map(notes.map((n) => [`${n.ownerType}:${n.sourceId}`, n.remark]));
|
||||||
|
|
||||||
|
const items: FinanceBankAccountDto[] = [];
|
||||||
|
|
||||||
|
for (const row of storeRows) {
|
||||||
|
const name = row.bankAccountName.trim();
|
||||||
|
const no = row.bankAccountNo.replace(/\s+/g, '');
|
||||||
|
if (!name || !no) continue;
|
||||||
|
const sourceId = row.id.toString();
|
||||||
|
items.push({
|
||||||
|
id: `STORE:${sourceId}`,
|
||||||
|
type: 'STORE',
|
||||||
|
ownerName: row.store.name,
|
||||||
|
ownerId: row.store.id.toString(),
|
||||||
|
cityId: row.store.cityId.toString(),
|
||||||
|
cityName: row.store.cityName,
|
||||||
|
bankAccountName: name,
|
||||||
|
bankAccountNo: no,
|
||||||
|
bankBranch: row.bankBranch,
|
||||||
|
remark: noteMap.get(`STORE:${sourceId}`) ?? null,
|
||||||
|
isDefault: row.isDefault === 1,
|
||||||
|
editable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const wineryName = winery.bankAccountName?.trim() ?? '';
|
||||||
|
const wineryNo = winery.bankAccountNo?.replace(/\s+/g, '') ?? '';
|
||||||
|
if (wineryName && wineryNo) {
|
||||||
|
items.push({
|
||||||
|
id: 'WINERY:winery',
|
||||||
|
type: 'WINERY',
|
||||||
|
ownerName: '酒厂',
|
||||||
|
ownerId: null,
|
||||||
|
cityId: null,
|
||||||
|
cityName: null,
|
||||||
|
bankAccountName: wineryName,
|
||||||
|
bankAccountNo: wineryNo,
|
||||||
|
bankBranch: winery.bankBranch?.trim() || winery.bankName?.trim() || null,
|
||||||
|
remark: noteMap.get('WINERY:winery') ?? null,
|
||||||
|
editable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of partnerRows) {
|
||||||
|
const name = row.bankAccountName?.trim() ?? '';
|
||||||
|
const no = row.bankAccountNo?.replace(/\s+/g, '') ?? '';
|
||||||
|
if (!name || !no) continue;
|
||||||
|
const sourceId = row.id.toString();
|
||||||
|
items.push({
|
||||||
|
id: `PARTNER:${sourceId}`,
|
||||||
|
type: 'PARTNER',
|
||||||
|
ownerName: row.companyName?.trim() || row.name,
|
||||||
|
ownerId: sourceId,
|
||||||
|
cityId: row.cityId?.toString() ?? null,
|
||||||
|
cityName: row.city?.name ?? null,
|
||||||
|
bankAccountName: name,
|
||||||
|
bankAccountNo: no,
|
||||||
|
bankBranch: row.bankBranch,
|
||||||
|
remark: noteMap.get(`PARTNER:${sourceId}`) ?? null,
|
||||||
|
editable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of logisticsRows) {
|
||||||
|
const name = row.bankAccountName?.trim() ?? '';
|
||||||
|
const no = row.bankAccountNo?.replace(/\s+/g, '') ?? '';
|
||||||
|
if (!name || !no) continue;
|
||||||
|
const sourceId = row.id.toString();
|
||||||
|
items.push({
|
||||||
|
id: `LOGISTICS:${sourceId}`,
|
||||||
|
type: 'LOGISTICS',
|
||||||
|
ownerName: row.name,
|
||||||
|
ownerId: sourceId,
|
||||||
|
cityId: null,
|
||||||
|
cityName: null,
|
||||||
|
bankAccountName: name,
|
||||||
|
bankAccountNo: no,
|
||||||
|
bankBranch: row.bankBranch,
|
||||||
|
remark: noteMap.get(`LOGISTICS:${sourceId}`) ?? null,
|
||||||
|
editable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of otherRows) {
|
||||||
|
items.push(this.serializeOther(row, row.remark));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeOther(
|
||||||
|
row: {
|
||||||
|
id: bigint;
|
||||||
|
name: string | null;
|
||||||
|
bankAccountName: string;
|
||||||
|
bankAccountNo: string;
|
||||||
|
bankBranch: string | null;
|
||||||
|
},
|
||||||
|
remark: string | null,
|
||||||
|
): FinanceBankAccountDto {
|
||||||
|
return {
|
||||||
|
id: `OTHER:${row.id.toString()}`,
|
||||||
|
type: 'OTHER',
|
||||||
|
ownerName: row.name?.trim() || row.bankAccountName,
|
||||||
|
ownerId: row.id.toString(),
|
||||||
|
cityId: null,
|
||||||
|
cityName: null,
|
||||||
|
bankAccountName: row.bankAccountName,
|
||||||
|
bankAccountNo: row.bankAccountNo,
|
||||||
|
bankBranch: row.bankBranch,
|
||||||
|
remark,
|
||||||
|
editable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeInput(dto: FinanceBankAccountInputDto) {
|
||||||
|
const bankAccountName = dto.bankAccountName?.trim() ?? '';
|
||||||
|
const bankAccountNo = dto.bankAccountNo?.replace(/\s+/g, '') ?? '';
|
||||||
|
const bankBranch = dto.bankBranch?.trim() || null;
|
||||||
|
const name = dto.name?.trim() || null;
|
||||||
|
const remark = dto.remark?.trim() || null;
|
||||||
|
if (!bankAccountName) throw new BadRequestException('请填写户名');
|
||||||
|
if (!BANK_NO_RE.test(bankAccountNo)) throw new BadRequestException('请填写正确的银行账号');
|
||||||
|
if (remark && remark.length > 256) throw new BadRequestException('备注最多 256 字');
|
||||||
|
return { name, bankAccountName, bankAccountNo, bankBranch, remark };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseType(raw?: string): FinanceBankAccountType | null {
|
||||||
|
if (!raw?.trim()) return null;
|
||||||
|
const value = raw.trim().toUpperCase();
|
||||||
|
if ((FINANCE_BANK_ACCOUNT_TYPES as readonly string[]).includes(value)) {
|
||||||
|
return value as FinanceBankAccountType;
|
||||||
|
}
|
||||||
|
throw new BadRequestException('无效的账户类型');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCompositeId(raw: string): { type: FinanceBankAccountType; sourceId: string } {
|
||||||
|
const idx = raw.indexOf(':');
|
||||||
|
if (idx <= 0) throw new BadRequestException('无效的账户编号');
|
||||||
|
const type = this.parseType(raw.slice(0, idx));
|
||||||
|
const sourceId = raw.slice(idx + 1).trim();
|
||||||
|
if (!type || !sourceId) throw new BadRequestException('无效的账户编号');
|
||||||
|
if (type === 'WINERY' && sourceId !== 'winery') throw new BadRequestException('无效的账户编号');
|
||||||
|
if (type !== 'OTHER' && !(SOURCE_NOTE_TYPES as readonly string[]).includes(type)) {
|
||||||
|
throw new BadRequestException('无效的账户编号');
|
||||||
|
}
|
||||||
|
return { type, sourceId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseBigIntId(raw: string, message: string): bigint {
|
||||||
|
if (!/^\d+$/.test(raw)) throw new NotFoundException(message);
|
||||||
|
return BigInt(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import ExcelJS from 'exceljs';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import {
|
||||||
|
FINANCE_BANK_ACCOUNT_TYPE_LABELS,
|
||||||
|
type FinanceBankAccountDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { pickExportColumns, type ExportColumnDef } from '../../common/export/column-export.util';
|
||||||
|
|
||||||
|
function resolvePdfFontPath(): string {
|
||||||
|
const candidates = [
|
||||||
|
process.env.EXPORT_PDF_FONT_PATH,
|
||||||
|
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
|
||||||
|
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
|
||||||
|
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
|
||||||
|
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
|
||||||
|
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'C:\\Windows\\Fonts\\simhei.ttf',
|
||||||
|
'C:\\Windows\\Fonts\\msyh.ttc',
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function columnDefs(): ExportColumnDef<FinanceBankAccountDto>[] {
|
||||||
|
return [
|
||||||
|
{ key: 'type', header: '类型', value: (r) => FINANCE_BANK_ACCOUNT_TYPE_LABELS[r.type] },
|
||||||
|
{ key: 'ownerName', header: '归属', value: (r) => r.ownerName },
|
||||||
|
{ key: 'cityName', header: '城市', value: (r) => r.cityName ?? '' },
|
||||||
|
{ key: 'bankAccountName', header: '户名', value: (r) => r.bankAccountName },
|
||||||
|
{ key: 'bankAccountNo', header: '银行账号', value: (r) => r.bankAccountNo },
|
||||||
|
{ key: 'bankBranch', header: '开户行', value: (r) => r.bankBranch ?? '' },
|
||||||
|
{ key: 'isDefault', header: '默认', value: (r) => (r.type === 'STORE' ? (r.isDefault ? '是' : '否') : '') },
|
||||||
|
{ key: 'remark', header: '备注', value: (r) => r.remark ?? '' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildFinanceBankAccountsXlsx(rows: FinanceBankAccountDto[]): Promise<Buffer> {
|
||||||
|
const cols = pickExportColumns(columnDefs());
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('银行账户');
|
||||||
|
sheet.addRow(cols.map((c) => c.header));
|
||||||
|
for (const row of rows) {
|
||||||
|
sheet.addRow(cols.map((c) => c.value(row)));
|
||||||
|
}
|
||||||
|
sheet.columns.forEach((col) => {
|
||||||
|
col.width = 18;
|
||||||
|
});
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
return Buffer.from(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildFinanceBankAccountsPdf(rows: FinanceBankAccountDto[]): Promise<Buffer> {
|
||||||
|
const cols = pickExportColumns(columnDefs());
|
||||||
|
const fontPath = resolvePdfFontPath();
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 24, bufferPages: true });
|
||||||
|
doc.on('data', (c) => chunks.push(c as Buffer));
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
doc.on('error', reject);
|
||||||
|
doc.font(fontPath);
|
||||||
|
doc.fontSize(12).text('银行账户');
|
||||||
|
doc.moveDown(0.4);
|
||||||
|
doc.fontSize(9).text(cols.map((c) => c.header).join(' | '));
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
for (const row of rows) {
|
||||||
|
doc.text(cols.map((c) => String(c.value(row))).join(' | '));
|
||||||
|
}
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFinanceBankExportFilename(format: 'xlsx' | 'pdf', count: number): string {
|
||||||
|
const stamp = new Date().toISOString().slice(0, 10);
|
||||||
|
return `银行账户_${stamp}_${count}.${format}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { BadRequestException, Body, Controller, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
import { BadRequestException, Body, Controller, Delete, ForbiddenException, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
|
import { FinanceBankAccountService } from './finance-bank-account.service';
|
||||||
|
import { HqPermissionGuard, RequireHqPermissions } from '../../common/guards/hq-permission.guard';
|
||||||
import { parseShanghaiYmd } from '@dukang/domain';
|
import { parseShanghaiYmd } from '@dukang/domain';
|
||||||
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
import { DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
|
||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.service';
|
||||||
@@ -37,6 +39,40 @@ class UpdatePartnerBankDto {
|
|||||||
bankBranch!: string;
|
bankBranch!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FinanceBankAccountBodyDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '请填写户名' })
|
||||||
|
@MaxLength(64)
|
||||||
|
bankAccountName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty({ message: '请填写银行账号' })
|
||||||
|
@MaxLength(32)
|
||||||
|
bankAccountNo!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(128)
|
||||||
|
bankBranch?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FinanceBankAccountRemarkBodyDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(256)
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('partner/settlement')
|
@Controller('partner/settlement')
|
||||||
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
export class SettlementController {
|
export class SettlementController {
|
||||||
@@ -627,6 +663,56 @@ export class AdminLogisticsBillController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Controller('admin/finance/bank-accounts')
|
||||||
|
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||||
|
@RequireHqPermissions('finance')
|
||||||
|
export class AdminFinanceBankAccountController {
|
||||||
|
constructor(private readonly financeBankAccounts: FinanceBankAccountService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@Query() query: Record<string, string>) {
|
||||||
|
return this.financeBankAccounts.list({
|
||||||
|
type: query.type,
|
||||||
|
cityId: query.cityId,
|
||||||
|
keyword: query.keyword,
|
||||||
|
page: query.page ? Number(query.page) : 1,
|
||||||
|
pageSize: query.pageSize ? Number(query.pageSize) : 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('export')
|
||||||
|
export(@Query() query: Record<string, string>) {
|
||||||
|
return this.financeBankAccounts.export({
|
||||||
|
type: query.type,
|
||||||
|
cityId: query.cityId,
|
||||||
|
keyword: query.keyword,
|
||||||
|
format: query.format,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: FinanceBankAccountBodyDto) {
|
||||||
|
return this.financeBankAccounts.createOther(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('other/:id')
|
||||||
|
updateOther(@Param('id') id: string, @Body() dto: FinanceBankAccountBodyDto) {
|
||||||
|
if (!/^\d+$/.test(id)) throw new BadRequestException('无效的账户编号');
|
||||||
|
return this.financeBankAccounts.updateOther(BigInt(id), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('other/:id')
|
||||||
|
removeOther(@Param('id') id: string) {
|
||||||
|
if (!/^\d+$/.test(id)) throw new BadRequestException('无效的账户编号');
|
||||||
|
return this.financeBankAccounts.removeOther(BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/remark')
|
||||||
|
updateRemark(@Param('id') id: string, @Body() dto: FinanceBankAccountRemarkBodyDto) {
|
||||||
|
return this.financeBankAccounts.updateRemark(id, dto.remark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('partner/me')
|
@Controller('partner/me')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class PartnerMeController {
|
export class PartnerMeController {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { AnalyticsModule } from '../analytics/analytics.module';
|
|||||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||||
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
import { FulfillmentModule } from '../fulfillment/fulfillment.module';
|
||||||
import { SettlementService } from './settlement.service';
|
import { SettlementService } from './settlement.service';
|
||||||
|
import { FinanceBankAccountService } from './finance-bank-account.service';
|
||||||
import {
|
import {
|
||||||
|
AdminFinanceBankAccountController,
|
||||||
AdminLogisticsBillController,
|
AdminLogisticsBillController,
|
||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
AdminStoreBillController,
|
AdminStoreBillController,
|
||||||
@@ -32,8 +34,9 @@ import {
|
|||||||
AdminPartnerBillController,
|
AdminPartnerBillController,
|
||||||
AdminWineryBillController,
|
AdminWineryBillController,
|
||||||
AdminLogisticsBillController,
|
AdminLogisticsBillController,
|
||||||
|
AdminFinanceBankAccountController,
|
||||||
],
|
],
|
||||||
providers: [SettlementService],
|
providers: [SettlementService, FinanceBankAccountService],
|
||||||
exports: [SettlementService],
|
exports: [SettlementService],
|
||||||
})
|
})
|
||||||
export class SettlementModule {}
|
export class SettlementModule {}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
} from '../../common/store/store-bank.util';
|
} from '../../common/store/store-bank.util';
|
||||||
import {
|
import {
|
||||||
capBillBlocks,
|
capBillBlocks,
|
||||||
|
filterNonZeroWecomBills,
|
||||||
formatLogisticsBillWecomBlock,
|
formatLogisticsBillWecomBlock,
|
||||||
formatPartnerBillWecomBlock,
|
formatPartnerBillWecomBlock,
|
||||||
formatPartnerDashLabel,
|
formatPartnerDashLabel,
|
||||||
@@ -199,6 +200,15 @@ function round2(n: number) {
|
|||||||
return Math.round(n * 100) / 100;
|
return Math.round(n * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPartnerBillPeriodLabel(weekStartYmd: string): string {
|
||||||
|
try {
|
||||||
|
const { periodStart, periodEnd } = resolvePartnerWeekPeriod(weekStartYmd);
|
||||||
|
return `${shanghaiYmd(periodStart)} ~ ${shanghaiYmd(periodEnd)}`;
|
||||||
|
} catch {
|
||||||
|
return weekStartYmd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function sumAmounts(rows: Array<{ amount: number }>): string {
|
function sumAmounts(rows: Array<{ amount: number }>): string {
|
||||||
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
|
return rows.reduce((s, r) => s + Number(r.amount || 0), 0).toFixed(2);
|
||||||
}
|
}
|
||||||
@@ -345,21 +355,25 @@ export class SettlementService implements OnModuleInit {
|
|||||||
cityName: string;
|
cityName: string;
|
||||||
partnerLabel: string;
|
partnerLabel: string;
|
||||||
orderCount?: number;
|
orderCount?: number;
|
||||||
|
orderAmount?: number;
|
||||||
redeemCount?: number;
|
redeemCount?: number;
|
||||||
|
redeemAmount?: number;
|
||||||
orderCommission: number;
|
orderCommission: number;
|
||||||
redeemCommission: number;
|
redeemCommission: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
bank?: WecomBankLike | null;
|
bank?: WecomBankLike | null;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
if (!rows.length) return;
|
const billed = filterNonZeroWecomBills(rows);
|
||||||
const blocks = rows.map((r) => formatPartnerBillWecomBlock(r));
|
if (!billed.length) return;
|
||||||
|
const periodLabel = formatPartnerBillPeriodLabel(period);
|
||||||
|
const blocks = billed.map((r) => formatPartnerBillWecomBlock({ ...r, period: periodLabel }));
|
||||||
void this.wecomPush.dispatchEvent(
|
void this.wecomPush.dispatchEvent(
|
||||||
'finance.partner_bill',
|
'finance.partner_bill',
|
||||||
{
|
{
|
||||||
period,
|
period: periodLabel,
|
||||||
billCount: String(rows.length),
|
billCount: String(billed.length),
|
||||||
totalAmount: sumAmounts(rows),
|
totalAmount: sumAmounts(billed),
|
||||||
billSummary: capBillBlocks(blocks),
|
billSummary: capBillBlocks(blocks),
|
||||||
},
|
},
|
||||||
{ handlePath: '/finance/partner-bills' },
|
{ handlePath: '/finance/partner-bills' },
|
||||||
@@ -1895,7 +1909,9 @@ export class SettlementService implements OnModuleInit {
|
|||||||
cityName: string;
|
cityName: string;
|
||||||
partnerLabel: string;
|
partnerLabel: string;
|
||||||
orderCount?: number;
|
orderCount?: number;
|
||||||
|
orderAmount?: number;
|
||||||
redeemCount?: number;
|
redeemCount?: number;
|
||||||
|
redeemAmount?: number;
|
||||||
orderCommission: number;
|
orderCommission: number;
|
||||||
redeemCommission: number;
|
redeemCommission: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -1915,7 +1931,9 @@ export class SettlementService implements OnModuleInit {
|
|||||||
cityName: p.city?.name?.trim() || '—',
|
cityName: p.city?.name?.trim() || '—',
|
||||||
partnerLabel: formatPartnerDashLabel(p.companyName, p.name),
|
partnerLabel: formatPartnerDashLabel(p.companyName, p.name),
|
||||||
orderCount: Number(bill.orderCount ?? 0),
|
orderCount: Number(bill.orderCount ?? 0),
|
||||||
|
orderAmount: Number(bill.orderAmount ?? 0),
|
||||||
redeemCount: Number(bill.redeemCount ?? 0),
|
redeemCount: Number(bill.redeemCount ?? 0),
|
||||||
|
redeemAmount: Number(bill.redeemAmount ?? 0),
|
||||||
orderCommission: Number(bill.orderCommission),
|
orderCommission: Number(bill.orderCommission),
|
||||||
redeemCommission: Number(bill.redeemCommission),
|
redeemCommission: Number(bill.redeemCommission),
|
||||||
amount: Number(bill.totalAmount),
|
amount: Number(bill.totalAmount),
|
||||||
@@ -2016,6 +2034,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const orderCommission = orderRows.reduce((sum, r) => sum + r.commission, 0);
|
const orderCommission = orderRows.reduce((sum, r) => sum + r.commission, 0);
|
||||||
|
const orderAmount = round2(orderRows.reduce((sum, r) => sum + r.baseAmount, 0));
|
||||||
|
|
||||||
const stores = await this.prisma.store.findMany({
|
const stores = await this.prisma.store.findMany({
|
||||||
where: { partnerAccountId: primary.id },
|
where: { partnerAccountId: primary.id },
|
||||||
@@ -2047,6 +2066,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
const redeemCommission = redeemRows.reduce((sum, r) => sum + r.commission, 0);
|
const redeemCommission = redeemRows.reduce((sum, r) => sum + r.commission, 0);
|
||||||
|
const redeemAmount = round2(redeemRows.reduce((sum, r) => sum + r.baseAmount, 0));
|
||||||
|
|
||||||
const totalAmount = round2(orderCommission + redeemCommission);
|
const totalAmount = round2(orderCommission + redeemCommission);
|
||||||
const itemRows = [...orderRows, ...redeemRows];
|
const itemRows = [...orderRows, ...redeemRows];
|
||||||
@@ -2111,7 +2131,9 @@ export class SettlementService implements OnModuleInit {
|
|||||||
cityName,
|
cityName,
|
||||||
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
|
partnerLabel: formatPartnerDashLabel(primary.companyName, primary.name),
|
||||||
orderCount: orders.length,
|
orderCount: orders.length,
|
||||||
|
orderAmount,
|
||||||
redeemCount: redeems.length,
|
redeemCount: redeems.length,
|
||||||
|
redeemAmount,
|
||||||
orderCommission: round2(orderCommission),
|
orderCommission: round2(orderCommission),
|
||||||
redeemCommission: round2(redeemCommission),
|
redeemCommission: round2(redeemCommission),
|
||||||
amount: totalAmount,
|
amount: totalAmount,
|
||||||
@@ -2128,6 +2150,8 @@ export class SettlementService implements OnModuleInit {
|
|||||||
...bill,
|
...bill,
|
||||||
orderCount: orders.length,
|
orderCount: orders.length,
|
||||||
redeemCount: redeems.length,
|
redeemCount: redeems.length,
|
||||||
|
orderAmount,
|
||||||
|
redeemAmount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
filterNonZeroWecomBills,
|
||||||
|
formatPartnerBillWecomBlock,
|
||||||
|
formatWecomKvTable,
|
||||||
|
} from './wecom-bill-digest';
|
||||||
|
|
||||||
|
describe('filterNonZeroWecomBills', () => {
|
||||||
|
it('drops zero-amount bills', () => {
|
||||||
|
expect(
|
||||||
|
filterNonZeroWecomBills([
|
||||||
|
{ amount: 0, name: 'zero' },
|
||||||
|
{ amount: 12.5, name: 'keep' },
|
||||||
|
{ amount: 0.0, name: 'also-zero' },
|
||||||
|
]).map((r) => r.name),
|
||||||
|
).toEqual(['keep']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatWecomKvTable', () => {
|
||||||
|
it('uses the title as table header and fields as rows', () => {
|
||||||
|
const md = formatWecomKvTable('郑州某某商贸-张三', [
|
||||||
|
['账期', '2026-07-27 ~ 2026-08-02'],
|
||||||
|
['累计金额', '¥120.00'],
|
||||||
|
]);
|
||||||
|
expect(md).toBe(
|
||||||
|
[
|
||||||
|
'| 郑州某某商贸-张三 | |',
|
||||||
|
'| :--- | ---: |',
|
||||||
|
'| 账期 | 2026-07-27 ~ 2026-08-02 |',
|
||||||
|
'| 累计金额 | ¥120.00 |',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPartnerBillWecomBlock', () => {
|
||||||
|
it('renders partner company-name header and settlement fields', () => {
|
||||||
|
const md = formatPartnerBillWecomBlock({
|
||||||
|
partnerLabel: '郑州某某商贸-张三',
|
||||||
|
period: '2026-07-27 ~ 2026-08-02',
|
||||||
|
orderCount: 8,
|
||||||
|
orderAmount: 1000,
|
||||||
|
orderCommission: 100,
|
||||||
|
redeemCount: 12,
|
||||||
|
redeemAmount: 200,
|
||||||
|
redeemCommission: 20,
|
||||||
|
amount: 120,
|
||||||
|
bank: {
|
||||||
|
bankAccountName: '张三',
|
||||||
|
bankAccountNo: '6222000011112222',
|
||||||
|
bankBranch: '郑州支行',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(md).toContain('| 郑州某某商贸-张三 | |');
|
||||||
|
expect(md).toContain('| 账期 | 2026-07-27 ~ 2026-08-02 |');
|
||||||
|
expect(md).toContain('| 订单数量 | 8 |');
|
||||||
|
expect(md).toContain('| 订单金额 | ¥1000.00 |');
|
||||||
|
expect(md).toContain('| 订单佣金 | ¥100.00 |');
|
||||||
|
expect(md).toContain('| 核销单数量 | 12 |');
|
||||||
|
expect(md).toContain('| 核销金额 | ¥200.00 |');
|
||||||
|
expect(md).toContain('| 核销佣金 | ¥20.00 |');
|
||||||
|
expect(md).toContain('| 累计金额 | ¥120.00 |');
|
||||||
|
expect(md).toContain('| 收款人 | 张三 |');
|
||||||
|
expect(md).toContain('| 收款银行账号 | 6222000011112222 |');
|
||||||
|
expect(md).toContain('| 开户行 | 郑州支行 |');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** 企微结算账单正文:分字段块 + 字节上限(机器人 markdown 约 4096 字节) */
|
/** 企微结算账单正文:字段块 / markdown_v2 表格 + 字节上限(机器人约 4096 字节) */
|
||||||
|
|
||||||
export type WecomBankLike = {
|
export type WecomBankLike = {
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
@@ -51,6 +51,27 @@ export function formatWecomFieldBlock(rows: Array<[string, string]>): string {
|
|||||||
return rows.map(([k, v]) => `${k}:${v}`).join('\n');
|
return rows.map(([k, v]) => `${k}:${v}`).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeWecomTableCell(v: string): string {
|
||||||
|
const text = String(v ?? '')
|
||||||
|
.replace(/\|/g, '|')
|
||||||
|
.replace(/\r?\n/g, ' ')
|
||||||
|
.trim();
|
||||||
|
return text || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 企微 markdown_v2 键值表:表头为标题,下列为字段/值 */
|
||||||
|
export function formatWecomKvTable(header: string, rows: Array<[string, string]>): string {
|
||||||
|
const lines = [`| ${escapeWecomTableCell(header)} | |`, '| :--- | ---: |'];
|
||||||
|
for (const [k, v] of rows) {
|
||||||
|
lines.push(`| ${escapeWecomTableCell(k)} | ${escapeWecomTableCell(v)} |`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterNonZeroWecomBills<T extends { amount: number }>(rows: T[]): T[] {
|
||||||
|
return rows.filter((r) => Number(r.amount) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export function joinLimited(names: string[], unit: string, max = 8): string {
|
export function joinLimited(names: string[], unit: string, max = 8): string {
|
||||||
const uniq = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
|
const uniq = [...new Set(names.map((n) => n.trim()).filter(Boolean))];
|
||||||
if (!uniq.length) return '—';
|
if (!uniq.length) return '—';
|
||||||
@@ -104,31 +125,31 @@ export function formatStoreBillWecomBlock(row: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatPartnerBillWecomBlock(row: {
|
export function formatPartnerBillWecomBlock(row: {
|
||||||
cityName: string;
|
|
||||||
partnerLabel: string;
|
partnerLabel: string;
|
||||||
|
period: string;
|
||||||
orderCount?: number;
|
orderCount?: number;
|
||||||
redeemCount?: number;
|
orderAmount?: number;
|
||||||
orderCommission: number;
|
orderCommission: number;
|
||||||
|
redeemCount?: number;
|
||||||
|
redeemAmount?: number;
|
||||||
redeemCommission: number;
|
redeemCommission: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
bank?: WecomBankLike | null;
|
bank?: WecomBankLike | null;
|
||||||
}): string {
|
}): string {
|
||||||
const bank = wecomBankParts(row.bank);
|
const bank = wecomBankParts(row.bank);
|
||||||
const rows: Array<[string, string]> = [
|
return formatWecomKvTable(row.partnerLabel || '—', [
|
||||||
['城市', row.cityName || '—'],
|
['账期', row.period || '—'],
|
||||||
['合伙人', row.partnerLabel || '—'],
|
['订单数量', String(row.orderCount ?? 0)],
|
||||||
];
|
['订单金额', formatYuan(row.orderAmount ?? 0)],
|
||||||
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.orderCommission)],
|
||||||
|
['核销单数量', String(row.redeemCount ?? 0)],
|
||||||
|
['核销金额', formatYuan(row.redeemAmount ?? 0)],
|
||||||
['核销佣金', formatYuan(row.redeemCommission)],
|
['核销佣金', formatYuan(row.redeemCommission)],
|
||||||
['账单', formatYuan(row.amount)],
|
['累计金额', formatYuan(row.amount)],
|
||||||
['收款人', bank.payee],
|
['收款人', bank.payee],
|
||||||
['收款账户', bank.accountNo],
|
['收款银行账号', bank.accountNo],
|
||||||
['收款开户行', bank.bankBranch],
|
['开户行', bank.bankBranch],
|
||||||
);
|
]);
|
||||||
return formatWecomFieldBlock(rows);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatLogisticsBillWecomBlock(row: {
|
export function formatLogisticsBillWecomBlock(row: {
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
import { loadAppConfig, ClientApp, SmsScene, isShowStoreRedeemCountEnabled } from '@dukang/shared-types';
|
||||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types';
|
||||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT, validateBusinessHours } from '@dukang/domain';
|
import {
|
||||||
|
formatStoreDisplayAddress,
|
||||||
|
isStoreContactPhone,
|
||||||
|
STORE_CONTACT_PHONE_HINT,
|
||||||
|
validateBusinessHours,
|
||||||
|
} from '@dukang/domain';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
@@ -31,6 +36,7 @@ import {
|
|||||||
} from '../../common/test-whitelist/test-whitelist.service';
|
} from '../../common/test-whitelist/test-whitelist.service';
|
||||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||||
import { formatPartnerWecomLabel } from './wecom-submitter-label';
|
import { formatPartnerWecomLabel } from './wecom-submitter-label';
|
||||||
|
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||||
|
|
||||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
@@ -81,6 +87,7 @@ export class StoreService {
|
|||||||
private readonly tencentLbs: TencentLbsProvider,
|
private readonly tencentLbs: TencentLbsProvider,
|
||||||
private readonly testWhitelist: TestWhitelistService,
|
private readonly testWhitelist: TestWhitelistService,
|
||||||
private readonly wecomPush: WecomMessagePushService,
|
private readonly wecomPush: WecomMessagePushService,
|
||||||
|
private readonly systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
|
/** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */
|
||||||
@@ -123,7 +130,7 @@ export class StoreService {
|
|||||||
district?: string | null;
|
district?: string | null;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return `${store.province ?? ''}${store.cityName ?? ''}${store.district ?? ''}${store.address ?? ''}`.trim();
|
return formatStoreDisplayAddress(store, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 缺坐标时用地址正向地理编码并回写 */
|
/** 缺坐标时用地址正向地理编码并回写 */
|
||||||
@@ -213,11 +220,12 @@ export class StoreService {
|
|||||||
latitude?: unknown;
|
latitude?: unknown;
|
||||||
longitude?: unknown;
|
longitude?: unknown;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
redeemCount: number;
|
redeemCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv());
|
||||||
const redeemGroups =
|
const redeemGroups =
|
||||||
visible.length === 0
|
!showRedeemCount || visible.length === 0
|
||||||
? []
|
? []
|
||||||
: await this.prisma.redeemRecord.groupBy({
|
: await this.prisma.redeemRecord.groupBy({
|
||||||
by: ['storeId'],
|
by: ['storeId'],
|
||||||
@@ -247,7 +255,9 @@ export class StoreService {
|
|||||||
items.push({
|
items.push({
|
||||||
...mapped,
|
...mapped,
|
||||||
distanceMeters,
|
distanceMeters,
|
||||||
redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0,
|
...(showRedeemCount
|
||||||
|
? { redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0 }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +291,7 @@ export class StoreService {
|
|||||||
throw new NotFoundException('门店不存在');
|
throw new NotFoundException('门店不存在');
|
||||||
}
|
}
|
||||||
const coords = await this.ensureStoreCoordinates(store);
|
const coords = await this.ensureStoreCoordinates(store);
|
||||||
|
const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv());
|
||||||
const [media, packageRows, redeemCount] = await Promise.all([
|
const [media, packageRows, redeemCount] = await Promise.all([
|
||||||
this.prisma.commonResource.findMany({
|
this.prisma.commonResource.findMany({
|
||||||
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' },
|
||||||
@@ -290,7 +301,7 @@ export class StoreService {
|
|||||||
where: { storeId: id },
|
where: { storeId: id },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
}),
|
}),
|
||||||
this.prisma.redeemRecord.count({ where: { storeId: id } }),
|
showRedeemCount ? this.prisma.redeemRecord.count({ where: { storeId: id } }) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
const { visibilityWhitelistEnabled: _wl, ...rest } = store;
|
||||||
return serializeBigInt(
|
return serializeBigInt(
|
||||||
@@ -299,7 +310,7 @@ export class StoreService {
|
|||||||
...rest,
|
...rest,
|
||||||
latitude: coords?.latitude ?? store.latitude,
|
latitude: coords?.latitude ?? store.latitude,
|
||||||
longitude: coords?.longitude ?? store.longitude,
|
longitude: coords?.longitude ?? store.longitude,
|
||||||
redeemCount,
|
...(showRedeemCount && redeemCount != null ? { redeemCount } : {}),
|
||||||
media,
|
media,
|
||||||
packages: packageRows.map((p) => {
|
packages: packageRows.map((p) => {
|
||||||
const imageUrls = normalizeStorePackageImageUrls({
|
const imageUrls = normalizeStorePackageImageUrls({
|
||||||
|
|||||||
Reference in New Issue
Block a user